@mastra/platform-workspace 1.1.1-alpha.0 → 1.2.0-alpha.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.js CHANGED
@@ -18,6 +18,8 @@ function resolvePlatformOptions(options) {
18
18
  accessToken: requireOption(options.accessToken ?? process.env.MASTRA_PLATFORM_ACCESS_TOKEN, "accessToken"),
19
19
  projectId: requireOption(options.projectId ?? process.env.MASTRA_PROJECT_ID, "projectId"),
20
20
  proxyUrl: (process.env.MASTRA_WORKSPACE_PROXY_URL ?? DEFAULT_PROXY_URL).replace(/\/$/, ""),
21
+ sessionId: options.sessionId,
22
+ threadId: options.threadId,
21
23
  fetch: options.fetch ?? fetch
22
24
  };
23
25
  }
@@ -61,12 +63,18 @@ var PlatformClient = class {
61
63
  accessToken;
62
64
  projectId;
63
65
  proxyUrl;
66
+ /** Advisory session correlation id — see {@link PlatformClientOptions.sessionId}. */
67
+ sessionId;
68
+ /** Advisory thread correlation id — see {@link PlatformClientOptions.threadId}. */
69
+ threadId;
64
70
  fetch;
65
71
  constructor(options) {
66
72
  const resolved = resolvePlatformOptions(options);
67
73
  this.accessToken = resolved.accessToken;
68
74
  this.projectId = resolved.projectId;
69
75
  this.proxyUrl = resolved.proxyUrl;
76
+ this.sessionId = resolved.sessionId;
77
+ this.threadId = resolved.threadId;
70
78
  this.fetch = resolved.fetch;
71
79
  }
72
80
  async request(path, options = {}) {
@@ -74,6 +82,8 @@ var PlatformClient = class {
74
82
  for (const [key, value] of Object.entries(options.query ?? {})) if (value !== void 0) url.searchParams.set(key, String(value));
75
83
  const headers = new Headers(options.headers);
76
84
  headers.set("authorization", `Bearer ${this.accessToken}`);
85
+ if (this.sessionId) headers.set("x-mastra-session-id", this.sessionId);
86
+ if (this.threadId) headers.set("x-mastra-thread-id", this.threadId);
77
87
  const { query: _query, ...fetchOptions } = options;
78
88
  const signal = fetchOptions.signal ?? AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MS);
79
89
  const response = await this.fetch(url, {
@@ -669,6 +679,24 @@ const CREATE_MAX_ATTEMPTS = 3;
669
679
  /** Base delay between create retries; multiplied by the attempt number. */
670
680
  const CREATE_RETRY_BASE_DELAY_MS = 2e3;
671
681
  /**
682
+ * How long to wait for the in-sandbox sidecar's `/health` endpoint to respond
683
+ * before giving up and leaving the address registry unpopulated (execs fall
684
+ * back to the lease path). This bounds the fire-and-forget probe that runs
685
+ * after `start()` resolves; the sandbox is usable immediately — the probe
686
+ * only controls whether early execs go via private-net or lease.
687
+ */
688
+ const SIDECAR_PROBE_TIMEOUT_MS = 3e4;
689
+ /** Delay between sidecar probe attempts. */
690
+ const SIDECAR_PROBE_INTERVAL_MS = 250;
691
+ /**
692
+ * How long `executeCommand` waits for the transport to become ready before
693
+ * falling back to the lease path. This is much shorter than
694
+ * `SIDECAR_PROBE_TIMEOUT_MS` because we want execs to proceed quickly if
695
+ * the sidecar is slow to boot — the probe continues in the background and
696
+ * later execs will use private-net once it succeeds.
697
+ */
698
+ const TRANSPORT_READY_WAIT_MS = 5e3;
699
+ /**
672
700
  * Diagnostic error thrown when the direct-exec WebSocket transport fails
673
701
  * twice in a row (opening handshake refused or socket closed mid-stream
674
702
  * without an `exit` frame). Distinguishes "the sandbox transport is broken"
@@ -834,12 +862,58 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
834
862
  * Cleared (regardless of success or failure) when the request settles.
835
863
  */
836
864
  _leaseInFlight = null;
865
+ /**
866
+ * True when this sandbox was constructed with a caller-supplied `id` (the
867
+ * recovery key the proxy hashes into an on-provider checkpoint name).
868
+ * `captureCheckpoint()` needs this to distinguish "no checkpoint intent"
869
+ * (auto-generated random id — capture would land under a name no future
870
+ * boot would look for) from "capture on demand". Cloned sandboxes route
871
+ * `checkpointName` through `id`, so both entry points set this the same
872
+ * way.
873
+ */
874
+ _hasRecoveryKey;
875
+ /**
876
+ * In-flight `captureCheckpoint()` request. Concurrent callers on the same
877
+ * instance coalesce onto this single promise so we don't burn N `POST
878
+ * /checkpoint` round-trips when the fleet fires several turn-end captures
879
+ * before the first one resolves. Cleared when the request settles.
880
+ */
881
+ _captureInFlight = null;
882
+ /**
883
+ * In-flight `start()` attempt. Concurrent callers on a fresh instance
884
+ * coalesce onto this single promise so a `POST /sandbox` is not fired
885
+ * N times when N fleet callers race to bring the same logical sandbox
886
+ * up. Published **synchronously** with `??=` before the first `await`
887
+ * so a later caller cannot slip through the null check while the
888
+ * originator is mid-round-trip. Cleared when the shared attempt
889
+ * settles (success or failure) so the next call sees a clean slot.
890
+ *
891
+ * Mirrors OSS `@mastra/railway` `RailwaySandbox._startInFlight`.
892
+ */
893
+ _startInFlight = null;
894
+ /**
895
+ * Generation token for the sidecar probe. Incremented on every `start()`
896
+ * and on teardown. The probe captures this value when it begins; if the
897
+ * generation has changed by the time the probe succeeds, the probe skips
898
+ * the `set()` to avoid re-populating a deleted or superseded sandbox entry.
899
+ */
900
+ _probeGeneration = 0;
901
+ /**
902
+ * In-flight sidecar probe promise. Concurrent `executeCommand` callers that
903
+ * arrive before the registry is populated all await this single promise so
904
+ * we don't fire N independent lease requests during the sidecar boot window.
905
+ * Once the probe resolves (success or timeout), callers check the registry
906
+ * and proceed — either via private-net (probe succeeded) or via lease (probe
907
+ * failed/timed out, but now coalesced via `_leaseInFlight`).
908
+ */
909
+ _transportReadyPromise = null;
837
910
  constructor(options = {}) {
838
911
  super({
839
912
  ...options,
840
913
  name: "PlatformSandbox",
841
914
  processes: new PlatformProcessManager()
842
915
  });
916
+ this._hasRecoveryKey = options.id !== void 0;
843
917
  this.id = options.id ?? this.generateId();
844
918
  this._client = new PlatformClient(options);
845
919
  this._environmentId = options.environmentId ?? process.env.MASTRA_ENVIRONMENT_ID ?? "";
@@ -875,6 +949,8 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
875
949
  ...id !== void 0 && { id },
876
950
  accessToken: this._client.accessToken,
877
951
  projectId: this._client.projectId,
952
+ ...this._client.sessionId !== void 0 && { sessionId: this._client.sessionId },
953
+ ...this._client.threadId !== void 0 && { threadId: this._client.threadId },
878
954
  fetch: this._client.fetch,
879
955
  environmentId: this._environmentId,
880
956
  ...options.sandboxId !== void 0 && { sandboxId: options.sandboxId },
@@ -889,11 +965,31 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
889
965
  });
890
966
  }
891
967
  async start() {
968
+ this._startInFlight ??= this._doStart().finally(() => {
969
+ this._startInFlight = null;
970
+ });
971
+ return this._startInFlight;
972
+ }
973
+ /**
974
+ * The single `start` attempt behind {@link start}'s coalescing wrapper.
975
+ *
976
+ * Split out so the wrapper can install a shared in-flight promise
977
+ * synchronously (before the first `await`) without inlining the reattach
978
+ * / retry logic. Joined callers observe whatever outcome this method
979
+ * produces — success returns normally, failures propagate to every
980
+ * awaiter.
981
+ */
982
+ async _doStart() {
983
+ const startedAt = Date.now();
892
984
  if (this._sandboxId) try {
893
- const json = await (await this._client.request(`/sandbox/${encodeURIComponent(this._sandboxId)}`)).json();
985
+ const requestStartedAt = Date.now();
986
+ const response = await this._client.request(`/sandbox/${encodeURIComponent(this._sandboxId)}`);
987
+ const requestMs = Date.now() - requestStartedAt;
988
+ const json = await response.json();
894
989
  if (!json.destroyedAt) {
895
990
  this._createdAt = json.createdAt ? new Date(json.createdAt) : /* @__PURE__ */ new Date();
896
991
  this._populateAddressFromResponse(json);
992
+ this._logStartComplete(json.id, startedAt, requestMs, "reattach");
897
993
  return;
898
994
  }
899
995
  this._sandboxId = void 0;
@@ -910,6 +1006,7 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
910
1006
  env: this._env
911
1007
  });
912
1008
  let response;
1009
+ const requestStartedAt = Date.now();
913
1010
  for (let attempt = 1;; attempt++) try {
914
1011
  response = await this._client.request("/sandbox", {
915
1012
  method: "POST",
@@ -921,10 +1018,32 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
921
1018
  if (!(error instanceof PlatformApiError && error.status >= 500) || attempt >= CREATE_MAX_ATTEMPTS) throw error;
922
1019
  await new Promise((resolve) => setTimeout(resolve, CREATE_RETRY_BASE_DELAY_MS * attempt));
923
1020
  }
1021
+ const requestMs = Date.now() - requestStartedAt;
924
1022
  const json = await response.json();
925
1023
  this._sandboxId = json.id;
926
1024
  this._createdAt = json.createdAt ? new Date(json.createdAt) : /* @__PURE__ */ new Date();
927
1025
  this._populateAddressFromResponse(json);
1026
+ this._logStartComplete(json.id, startedAt, requestMs, "provision");
1027
+ }
1028
+ /**
1029
+ * One timing summary per completed `start()` — the whole
1030
+ * `PlatformSandbox`-visible boot in a single greppable line.
1031
+ *
1032
+ * `requestMs` is the proxy round-trip (`GET /sandbox/:id` on reattach,
1033
+ * `POST /sandbox` including transient-5xx retries on provision) — a black
1034
+ * box from this side that rolls up Railway RPC, sidecar launch, and the
1035
+ * proxy's discovery exec. Sidecar probe cost is intentionally NOT here: the
1036
+ * probe is fire-and-forget and outlives `start()` by design, so its
1037
+ * duration lands on the `platform-workspace probe ok` line instead.
1038
+ */
1039
+ _logStartComplete(sandboxId, startedAt, requestMs, mode) {
1040
+ this.logger.info("platform-workspace start complete", {
1041
+ sandboxId,
1042
+ sessionId: this._client.sessionId,
1043
+ mode,
1044
+ totalMs: Date.now() - startedAt,
1045
+ requestMs
1046
+ });
928
1047
  }
929
1048
  /**
930
1049
  * Copy `response.instanceUrl` into the injected {@link SandboxAddressRegistry}
@@ -945,20 +1064,274 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
945
1064
  _populateAddressFromResponse(json) {
946
1065
  if (!this._addressRegistry) return;
947
1066
  if (!json.instanceUrl) return;
948
- this._addressRegistry.set(json.id, json.instanceUrl);
1067
+ this._addressRegistry.delete(json.id);
1068
+ const generation = ++this._probeGeneration;
1069
+ this._transportReadyPromise = this._probeSidecarThenRegister(json.id, json.instanceUrl, generation);
1070
+ }
1071
+ /**
1072
+ * Fire-and-forget probe that polls the sidecar's `/health` endpoint until
1073
+ * it responds, then populates the address registry. Runs detached from
1074
+ * `start()` so sandbox provision latency is unchanged; early execs simply
1075
+ * fall back to the lease path until the probe succeeds.
1076
+ *
1077
+ * If the sidecar never comes up within {@link SIDECAR_PROBE_TIMEOUT_MS},
1078
+ * the registry stays unpopulated and all execs go via lease for this
1079
+ * sandbox's lifetime (or until a future `start()` re-runs the probe).
1080
+ *
1081
+ * @param generation - The probe generation captured at call time. If this
1082
+ * no longer matches `_probeGeneration` when the probe succeeds, the probe
1083
+ * was superseded by a teardown or a new `start()`, so we skip the `set()`.
1084
+ */
1085
+ async _probeSidecarThenRegister(sandboxId, instanceUrl, generation) {
1086
+ const probeStartedAt = Date.now();
1087
+ const deadline = probeStartedAt + SIDECAR_PROBE_TIMEOUT_MS;
1088
+ const fetchFn = this._privateNetFetch ?? fetch;
1089
+ let attempts = 0;
1090
+ while (Date.now() < deadline) {
1091
+ if (generation !== this._probeGeneration) return;
1092
+ attempts++;
1093
+ try {
1094
+ const res = await fetchFn(`${instanceUrl}/health`, {
1095
+ method: "GET",
1096
+ signal: AbortSignal.timeout(1e3)
1097
+ });
1098
+ const ok = res.ok;
1099
+ await res.body?.cancel().catch(() => {});
1100
+ if (ok) {
1101
+ this.logger.info("platform-workspace probe ok", {
1102
+ sandboxId,
1103
+ sessionId: this._client.sessionId,
1104
+ probeDurationMs: Date.now() - probeStartedAt,
1105
+ attempts
1106
+ });
1107
+ if (generation === this._probeGeneration && this._sandboxId === sandboxId) this._addressRegistry?.set(sandboxId, instanceUrl);
1108
+ return;
1109
+ }
1110
+ } catch {}
1111
+ await new Promise((r) => setTimeout(r, SIDECAR_PROBE_INTERVAL_MS));
1112
+ }
1113
+ this.logger.warn("platform-workspace probe timed out", {
1114
+ sandboxId,
1115
+ sessionId: this._client.sessionId,
1116
+ timeoutMs: SIDECAR_PROBE_TIMEOUT_MS,
1117
+ attempts
1118
+ });
1119
+ }
1120
+ /**
1121
+ * Wait for the transport to become ready (sidecar probe succeeds) or time
1122
+ * out. Concurrent callers all await the same probe promise, coalescing the
1123
+ * cold-start storm into a single warmup attempt.
1124
+ *
1125
+ * If no probe is in flight (no registry, or registry already populated),
1126
+ * this returns immediately. After the wait (success or timeout), callers
1127
+ * check the registry and proceed — either via private-net or lease. The
1128
+ * lease path is still coalesced via `_leaseInFlight`, so even if the probe
1129
+ * times out, we only mint one lease for all concurrent execs.
1130
+ */
1131
+ async _awaitTransportReady() {
1132
+ if (this._sandboxId && this._addressRegistry?.get(this._sandboxId)) return;
1133
+ if (!this._transportReadyPromise) return;
1134
+ await Promise.race([this._transportReadyPromise, new Promise((r) => setTimeout(r, TRANSPORT_READY_WAIT_MS))]);
949
1135
  }
1136
+ /**
1137
+ * Stop the sandbox while **preserving its recovery checkpoint**.
1138
+ *
1139
+ * Semantic parity with `@mastra/railway` `RailwaySandbox.stop()`: the VM
1140
+ * is released but the on-provider checkpoint survives, so a subsequent
1141
+ * `start()` on a sandbox constructed with the same `id` can restore from
1142
+ * it. Any in-flight capture is awaited first so the preserved checkpoint
1143
+ * reflects the latest disk state we asked for.
1144
+ *
1145
+ * Corresponds to `DELETE /v1/projects/:pid/sandbox/:sandboxId` on
1146
+ * workspace-proxy, which by contract does not touch the checkpoint. Use
1147
+ * {@link destroy} when you want the checkpoint released too.
1148
+ */
950
1149
  async stop() {
951
- await this.destroy();
1150
+ if (this._captureInFlight) await this._captureInFlight.catch((error) => {
1151
+ this.logger.warn(`stop(): failed to flush in-flight capture before teardown:`, error);
1152
+ });
1153
+ await this._teardownSandbox();
952
1154
  }
1155
+ /**
1156
+ * Destroy the sandbox **and release its recovery checkpoint**.
1157
+ *
1158
+ * Semantic parity with `@mastra/railway` `RailwaySandbox.destroy()`:
1159
+ * cancels any in-flight capture (the checkpoint is about to be deleted
1160
+ * — no reason to burn a capture on state we're releasing), asks the
1161
+ * proxy to delete the checkpoint, then releases the VM. Both remote
1162
+ * operations are best-effort logged failures — a stray checkpoint or a
1163
+ * transient proxy error must not leave the caller with a half-torn-down
1164
+ * sandbox they can't safely retry.
1165
+ *
1166
+ * Requires the caller to have constructed with a recovery `id` (there is
1167
+ * no checkpoint to delete otherwise); callers without one skip the
1168
+ * checkpoint DELETE and behave identically to {@link stop}.
1169
+ */
953
1170
  async destroy() {
954
1171
  if (!this._sandboxId) return;
955
1172
  const destroyedSandboxId = this._sandboxId;
1173
+ this._captureInFlight = null;
1174
+ if (this._hasRecoveryKey) try {
1175
+ await this._client.request(`/sandbox/${encodeURIComponent(destroyedSandboxId)}/checkpoint`, {
1176
+ method: "DELETE",
1177
+ headers: { "content-type": "application/json" },
1178
+ body: JSON.stringify({ id: this.id })
1179
+ });
1180
+ } catch (error) {
1181
+ if (error instanceof PlatformApiError && (error.status === 404 || error.status === 410)) this.logger.debug(`destroy(): checkpoint already absent upstream (status=${error.status})`);
1182
+ else this.logger.warn(`destroy(): failed to delete checkpoint upstream:`, error);
1183
+ }
1184
+ await this._teardownSandbox();
1185
+ }
1186
+ /**
1187
+ * Release the remote sandbox VM and clear the local state pointing at it.
1188
+ *
1189
+ * Shared body of {@link stop} and {@link destroy} — both funnel through
1190
+ * here after they've dealt with the checkpoint (preserve vs release).
1191
+ * The VM DELETE is safe to issue in either mode: the proxy's DELETE
1192
+ * route does not touch the checkpoint on its own, so `stop()` correctly
1193
+ * leaves the checkpoint intact and `destroy()` has already removed it
1194
+ * before this call.
1195
+ */
1196
+ async _teardownSandbox() {
1197
+ if (!this._sandboxId) return;
1198
+ const destroyedSandboxId = this._sandboxId;
1199
+ this._probeGeneration++;
956
1200
  await this._client.request(`/sandbox/${encodeURIComponent(destroyedSandboxId)}`, { method: "DELETE" });
957
1201
  this._sandboxId = void 0;
958
1202
  this._createdAt = null;
959
1203
  this._lease = null;
960
1204
  this._addressRegistry?.delete(destroyedSandboxId);
961
1205
  }
1206
+ /** Persist the configured recovery checkpoint when available. */
1207
+ async snapshot() {
1208
+ await this.captureCheckpoint();
1209
+ }
1210
+ /**
1211
+ * Capture the sandbox's checkpoint on demand, outside any refresh timer the
1212
+ * workspace-proxy owns internally.
1213
+ *
1214
+ * Intended for callers (e.g. a factory-side scheduler) that want to refresh
1215
+ * the recovery checkpoint at semantic moments — turn end, session-idle,
1216
+ * pre-teardown — rather than only just before the upstream's idle destroy.
1217
+ *
1218
+ * Mirrors the OSS `@mastra/railway` `RailwaySandbox.captureCheckpoint()`
1219
+ * shape so factory can call `sandbox.captureCheckpoint()` uniformly and
1220
+ * branch on `status`/`reason` without knowing which provider is underneath.
1221
+ * Both `captured` and `coalesced` carry the checkpoint name inline so the
1222
+ * caller can persist a session→checkpoint binding atomically with the
1223
+ * awaited capture.
1224
+ *
1225
+ * Skip semantics:
1226
+ * - No caller-supplied `id`: returns `{ status: 'skipped', reason:
1227
+ * 'no-checkpoint-name-configured' }`. An auto-generated random id is
1228
+ * never a meaningful recovery key (no future boot would look for a
1229
+ * checkpoint under it), so capturing would silently produce dead data.
1230
+ * - Not started (no `_sandboxId`): returns `{ status: 'skipped', reason:
1231
+ * 'sandbox-not-running' }` without a round-trip.
1232
+ * - Upstream 410 (workspace-proxy or Railway reports the sandbox is
1233
+ * already destroyed): returns the same `sandbox-not-running` skip so
1234
+ * the discriminant matches the pre-flight case. Local state
1235
+ * (`_sandboxId`, `_lease`, sidecar address) is cleared as a side
1236
+ * effect so the next `start()` provisions fresh instead of reattaching
1237
+ * to a dead id. The diagnostic distinction (pre-flight vs post-hoc)
1238
+ * is preserved in log level: debug for the expected pre-flight skip,
1239
+ * warn for the surprise upstream destroy.
1240
+ *
1241
+ * Concurrent callers on the same instance coalesce onto a single in-flight
1242
+ * `POST /checkpoint` so N simultaneous turn-end fires (e.g. several tabs)
1243
+ * do not each round-trip the proxy. Both the originator and joiners
1244
+ * receive `{ status: 'coalesced', ... }` for the joined result — the
1245
+ * outer contract does not distinguish who started the request, only that
1246
+ * one upstream capture was made.
1247
+ *
1248
+ * Never throws for expected outcomes. Transport failures (5xx, 4xx other
1249
+ * than 410) propagate as {@link PlatformApiError}; a 410 is normalized
1250
+ * to a skip as described above.
1251
+ */
1252
+ async captureCheckpoint() {
1253
+ if (!this._hasRecoveryKey) {
1254
+ this.logger.debug(`captureCheckpoint skipped: no recovery key configured for sandbox ${this._sandboxId ?? "(unstarted)"}`);
1255
+ return {
1256
+ status: "skipped",
1257
+ reason: "no-checkpoint-name-configured"
1258
+ };
1259
+ }
1260
+ if (!this._sandboxId) {
1261
+ this.logger.debug(`captureCheckpoint skipped: sandbox not running (local pre-flight, id=${this.id})`);
1262
+ return {
1263
+ status: "skipped",
1264
+ reason: "sandbox-not-running"
1265
+ };
1266
+ }
1267
+ if (this._captureInFlight) return this._captureInFlight;
1268
+ const sandboxId = this._sandboxId;
1269
+ const capture = this._doCaptureCheckpoint(sandboxId).finally(() => {
1270
+ if (this._captureInFlight === capture) this._captureInFlight = null;
1271
+ });
1272
+ this._captureInFlight = capture;
1273
+ return capture;
1274
+ }
1275
+ /**
1276
+ * The single `POST /checkpoint` attempt behind {@link captureCheckpoint}.
1277
+ *
1278
+ * Split out so the coalescing wrapper can install a shared in-flight
1279
+ * promise without inlining the transport + response-mapping logic.
1280
+ * Joined callers observe `{ status: 'coalesced', ... }` — the initiator
1281
+ * sees the underlying `captured` / `coalesced` / `skipped` result the
1282
+ * proxy returned. Both are legitimate: the OSS mirror uses the same
1283
+ * "initiator sees the truth, joiners see coalesced" split.
1284
+ */
1285
+ async _doCaptureCheckpoint(sandboxId) {
1286
+ let response;
1287
+ try {
1288
+ response = await this._client.request(`/sandbox/${encodeURIComponent(sandboxId)}/checkpoint`, {
1289
+ method: "POST",
1290
+ headers: { "content-type": "application/json" },
1291
+ body: JSON.stringify({ id: this.id })
1292
+ });
1293
+ } catch (error) {
1294
+ if (error instanceof PlatformApiError && error.status === 410) {
1295
+ this.logger.warn(`captureCheckpoint skipped: sandbox destroyed upstream (proxy 410, sandboxId=${sandboxId})`);
1296
+ this._clearDestroyedState(sandboxId);
1297
+ return {
1298
+ status: "skipped",
1299
+ reason: "sandbox-not-running"
1300
+ };
1301
+ }
1302
+ throw error;
1303
+ }
1304
+ const json = await response.json();
1305
+ if (json.status === "skipped") {
1306
+ this.logger.warn(`captureCheckpoint skipped: sandbox destroyed upstream (proxy reported skipped, sandboxId=${sandboxId})`);
1307
+ this._clearDestroyedState(sandboxId);
1308
+ return {
1309
+ status: "skipped",
1310
+ reason: "sandbox-not-running"
1311
+ };
1312
+ }
1313
+ return {
1314
+ status: json.status,
1315
+ checkpointName: json.checkpointName
1316
+ };
1317
+ }
1318
+ /**
1319
+ * Clear local state that would otherwise let the caller keep exec'ing
1320
+ * against a sandbox the upstream has already destroyed. Mirrors what
1321
+ * `destroy()` does minus the outbound DELETE — the sandbox is already
1322
+ * gone, so all that remains is to stop pointing at it.
1323
+ *
1324
+ * Also resets `status` to `'pending'` so a subsequent `_start()` on this
1325
+ * reused instance re-runs provisioning instead of short-circuiting on
1326
+ * the cached `'running'` state (see `MastraSandbox._start`).
1327
+ */
1328
+ _clearDestroyedState(destroyedSandboxId) {
1329
+ this._sandboxId = void 0;
1330
+ this._createdAt = null;
1331
+ this._lease = null;
1332
+ this._addressRegistry?.delete(destroyedSandboxId);
1333
+ this.status = "pending";
1334
+ }
962
1335
  /**
963
1336
  * Execute a command on the remote sandbox.
964
1337
  *
@@ -982,6 +1355,7 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
982
1355
  const started = Date.now();
983
1356
  const fullCommand = buildCommand(command, args);
984
1357
  const effectiveTimeout = options?.timeout ?? this._timeout;
1358
+ await this._awaitTransportReady();
985
1359
  const instanceUrl = this._addressRegistry?.get(this._sandboxId);
986
1360
  if (instanceUrl) {
987
1361
  const privateNet = await this._tryExecViaPrivateNetwork(instanceUrl, fullCommand, effectiveTimeout, options);