@miosa/cli 1.3.2 → 1.3.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.
@@ -3,13 +3,14 @@ import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { spawn, spawnSync } from "node:child_process";
5
5
  import { createServer } from "node:net";
6
+ import { randomUUID } from "node:crypto";
6
7
  import * as http from "node:http";
7
8
  import * as https from "node:https";
8
9
  import chalk from "chalk";
9
10
  import WebSocket from "ws";
10
11
  import { loadAppManifest, manifestPort, manifestProbePath, manifestStartCommand, parseAppManifest, } from "../app-manifest.js";
11
12
  import { detectFramework } from "../framework-detector.js";
12
- import { addDataOption, client, apiPath, deleteAndPrint, enc, getAndPrint, postAndPrint, printValue, runAction, unwrap, } from "./enterprise-util.js";
13
+ import { addDataOption, client, apiPath, deleteAndPrint, enc, getAndPrint, parseData, postAndPrint, printValue, runAction, unwrap, } from "./enterprise-util.js";
13
14
  import { loadConfig } from "../config.js";
14
15
  import { assertDeletableRemoteDir } from "./sandbox-delete-guard.js";
15
16
  import { ENV_FILE_OPTION_HELP, ENV_INLINE_SHELL_WARNING, ENV_STDIN_OPTION_HELP, resolveEnvInputs, } from "./env-input.js";
@@ -23,6 +24,57 @@ import { registerSandboxDevCommands, runFullSandboxDoctor, } from "./sandbox-dev
23
24
  const DEFAULT_INTERACTIVE_SANDBOX_TIMEOUT_SEC = 3_600;
24
25
  const DEFAULT_CREATE_WAIT_TIMEOUT_SEC = 120;
25
26
  const EXPIRING_SANDBOX_THRESHOLD_SEC = 5 * 60;
27
+ // How long, after a sandbox reaches `running`, to keep polling for
28
+ // command-readiness before `--wait` gives up.
29
+ const COMMAND_READY_WAIT_TIMEOUT_SEC = 60;
30
+ const COMMAND_READY_POLL_INTERVAL_MS = 500;
31
+ // Platform readiness components that gate command execution. Mirrors the
32
+ // server's `Engine.Sandbox.Readiness` platform set: `exec` returns
33
+ // `409 SANDBOX_NOT_COMMAND_READY` until all of these report `ready`. Excludes
34
+ // the template `application_probe` (the workload), which is started BY exec.
35
+ const PLATFORM_READINESS_COMPONENTS = [
36
+ "command_agent",
37
+ "process_control",
38
+ "resource_contract",
39
+ "clock",
40
+ "identity",
41
+ "durability",
42
+ "filesystem",
43
+ "internal_probe",
44
+ ];
45
+ /**
46
+ * True when a sandbox is command-ready — `exec` will succeed rather than return
47
+ * `409 SANDBOX_NOT_COMMAND_READY`. Accepts a readiness payload
48
+ * (`GET /sandboxes/:id/readiness`) or a sandbox record. Mirrors the server's
49
+ * `command_path_ready?/1`: the aggregate `ready` flag short-circuits, otherwise
50
+ * every platform component must report `ready`.
51
+ */
52
+ function isCommandReady(payload) {
53
+ if (!payload || typeof payload !== "object")
54
+ return false;
55
+ const p = payload;
56
+ if (p["ready"] === true)
57
+ return true;
58
+ const readiness = p["readiness"];
59
+ const components = (readiness?.["components"] ??
60
+ p["components"] ??
61
+ p["readiness_components"]);
62
+ if (!components || typeof components !== "object")
63
+ return false;
64
+ return PLATFORM_READINESS_COMPONENTS.every((name) => {
65
+ const comp = components[name];
66
+ const status = typeof comp === "string" ? comp : comp?.status;
67
+ return status === "ready";
68
+ });
69
+ }
70
+ /**
71
+ * Generate a create Idempotency-Key. Generated once per logical `create` and
72
+ * re-sent unchanged on every retry (see `MiosaClient.apiPostWithRetry`), so a
73
+ * retried create dedups to the same sandbox instead of billing a duplicate VM.
74
+ */
75
+ function generateIdempotencyKey() {
76
+ return randomUUID();
77
+ }
26
78
  const SANDBOX_SIZE_CONTRACTS = {
27
79
  xs: { cpu: 1, memory: 2_048, disk: 10_240 },
28
80
  small: { cpu: 2, memory: 4_096, disk: 10_240 },
@@ -285,12 +337,15 @@ Note:
285
337
  const t0 = Date.now();
286
338
  const json = !!isJsonMode(opts) || process.env["MIOSA_JSON"] === "1";
287
339
  if (opts.data) {
340
+ // Raw --data/--input/--file body — still idempotent and retry-safe.
341
+ const idempotencyKey = opts.idempotencyKey ?? generateIdempotencyKey();
342
+ const dataBody = parseData(opts.data, opts.input, opts.file) ?? {};
343
+ const value = unwrap(await client().apiPostWithRetry(apiPath("/sandboxes"), dataBody, { "Idempotency-Key": idempotencyKey }));
288
344
  if (json) {
289
- await postAndPrint("/sandboxes", opts, {});
345
+ printValue(value, opts);
290
346
  return;
291
347
  }
292
- const raw = unwrap(await client().apiPost(apiPath("/sandboxes"), {}));
293
- renderCreateSuccess(raw, Date.now() - t0);
348
+ renderCreateSuccess(value, Date.now() - t0);
294
349
  return;
295
350
  }
296
351
  const body = {
@@ -350,9 +405,13 @@ Note:
350
405
  body["persistent"] = opts.nonPersistent ? false : true;
351
406
  if (opts.autoStart)
352
407
  body["auto_start"] = true;
353
- const raw = unwrap(await client().apiPost(apiPath("/sandboxes"), body, opts.idempotencyKey
354
- ? { "Idempotency-Key": opts.idempotencyKey }
355
- : undefined));
408
+ // Auto-generate an Idempotency-Key when none is supplied, and retry
409
+ // on 429/5xx with the SAME key so a retried create dedups to one
410
+ // sandbox instead of billing a duplicate.
411
+ const idempotencyKey = opts.idempotencyKey ?? generateIdempotencyKey();
412
+ const raw = unwrap(await client().apiPostWithRetry(apiPath("/sandboxes"), body, {
413
+ "Idempotency-Key": idempotencyKey,
414
+ }));
356
415
  const sb = (raw ?? {});
357
416
  const id = String(sb["id"] ?? "");
358
417
  if (opts.publishPort != null && id) {
@@ -376,7 +435,9 @@ Note:
376
435
  }
377
436
  }
378
437
  else if (opts.wait && id) {
379
- const latest = await waitForSandboxRunning(client(), id, Math.max(Math.min(timeoutSec, DEFAULT_CREATE_WAIT_TIMEOUT_SEC), 30));
438
+ const latest = await waitForSandboxRunning(client(), id, Math.max(Math.min(timeoutSec, DEFAULT_CREATE_WAIT_TIMEOUT_SEC), 30),
439
+ // create --wait must guarantee exec works before returning.
440
+ true);
380
441
  Object.assign(sb, latest, { ready: true });
381
442
  }
382
443
  if (json) {
@@ -2046,7 +2107,9 @@ async function waitSandboxReady(sandboxId, port, probePath, timeoutSec) {
2046
2107
  };
2047
2108
  }
2048
2109
  async function waitSandboxVmReady(sandboxId, timeoutSec) {
2049
- const sandbox = await waitForSandboxRunning(client(), sandboxId, timeoutSec);
2110
+ // `sandbox wait` reports readiness for use — confirm command-readiness so a
2111
+ // successful wait guarantees exec works.
2112
+ const sandbox = await waitForSandboxRunning(client(), sandboxId, timeoutSec, true);
2050
2113
  return {
2051
2114
  ...sandbox,
2052
2115
  sandbox_id: sandboxId,
@@ -2680,13 +2743,24 @@ async function readRemoteAppManifest(c, sandboxId, timeoutSec) {
2680
2743
  }
2681
2744
  return null;
2682
2745
  }
2683
- async function waitForSandboxRunning(c, sandboxId, timeoutSec) {
2746
+ async function waitForSandboxRunning(c, sandboxId, timeoutSec,
2747
+ // Off by default so existing callers (e.g. `sandbox deploy`, which has its
2748
+ // own downstream recovery) keep their behavior. The explicit user-facing
2749
+ // waits (`create --wait`, `sandbox wait`) opt in.
2750
+ requireCommandReady = false) {
2684
2751
  const deadline = Date.now() + timeoutSec * 1000;
2685
2752
  while (Date.now() < deadline) {
2686
2753
  const sandbox = unwrap(await c.apiGet(apiPath(`/sandboxes/${enc(sandboxId)}`)));
2687
2754
  const state = String(sandbox["state"] ?? sandbox["status"] ?? "").toLowerCase();
2688
- if (state === "running" || state === "active")
2755
+ if (state === "running" || state === "active") {
2756
+ // A running VM is not proof that exec will work: the command agent may
2757
+ // not be attached yet (409 SANDBOX_NOT_COMMAND_READY). Confirm
2758
+ // command-readiness so `--wait` only reports success once exec works.
2759
+ if (requireCommandReady) {
2760
+ await confirmCommandReady(c, sandboxId, deadline);
2761
+ }
2689
2762
  return sandbox;
2763
+ }
2690
2764
  if (state === "error" || state === "failed") {
2691
2765
  throw sandboxStateError(sandboxId, sandbox, state);
2692
2766
  }
@@ -2697,6 +2771,34 @@ async function waitForSandboxRunning(c, sandboxId, timeoutSec) {
2697
2771
  }
2698
2772
  throw new UserError(`Sandbox ${sandboxId} did not become running within ${timeoutSec}s.`);
2699
2773
  }
2774
+ /**
2775
+ * After a sandbox reaches `running`, poll `GET /sandboxes/:id/readiness` until
2776
+ * it is command-ready (exec will succeed). Bounded by the running-wait
2777
+ * `deadline`, extended by a command-ready grace window. A missing readiness
2778
+ * endpoint (`NOT_FOUND`, older servers) is tolerated — return without blocking.
2779
+ * Throws when command-readiness is not reached before the deadline.
2780
+ */
2781
+ async function confirmCommandReady(c, sandboxId, runningDeadline) {
2782
+ const deadline = Math.max(runningDeadline, Date.now() + COMMAND_READY_WAIT_TIMEOUT_SEC * 1000);
2783
+ for (;;) {
2784
+ try {
2785
+ const readiness = unwrap(await c.apiGet(apiPath(`/sandboxes/${enc(sandboxId)}/readiness`)));
2786
+ if (isCommandReady(readiness))
2787
+ return;
2788
+ }
2789
+ catch (err) {
2790
+ // Older servers may not expose the readiness endpoint. Don't block the
2791
+ // create — treat a missing endpoint as best-effort success.
2792
+ if (err instanceof ApiResponseError && err.code === "NOT_FOUND")
2793
+ return;
2794
+ // Other transient errors: keep polling until the deadline.
2795
+ }
2796
+ if (Date.now() >= deadline) {
2797
+ throw new UserError(`Sandbox ${sandboxId} reached running but its command agent did not attach in time; exec may return SANDBOX_NOT_COMMAND_READY.`, "Retry the command, or poll `miosa sandbox get <id>` before exec.");
2798
+ }
2799
+ await sleep(COMMAND_READY_POLL_INTERVAL_MS);
2800
+ }
2801
+ }
2700
2802
  async function resumeSandboxAndPrint(sandboxId, opts) {
2701
2803
  try {
2702
2804
  const result = unwrap(await client().apiPost(apiPath(`/sandboxes/${enc(sandboxId)}/resume`), {}, opts.idempotencyKey