@mastra/platform-workspace 1.1.0 → 1.2.0-alpha.1

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
@@ -834,12 +834,42 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
834
834
  * Cleared (regardless of success or failure) when the request settles.
835
835
  */
836
836
  _leaseInFlight = null;
837
+ /**
838
+ * True when this sandbox was constructed with a caller-supplied `id` (the
839
+ * recovery key the proxy hashes into an on-provider checkpoint name).
840
+ * `captureCheckpoint()` needs this to distinguish "no checkpoint intent"
841
+ * (auto-generated random id — capture would land under a name no future
842
+ * boot would look for) from "capture on demand". Cloned sandboxes route
843
+ * `checkpointName` through `id`, so both entry points set this the same
844
+ * way.
845
+ */
846
+ _hasRecoveryKey;
847
+ /**
848
+ * In-flight `captureCheckpoint()` request. Concurrent callers on the same
849
+ * instance coalesce onto this single promise so we don't burn N `POST
850
+ * /checkpoint` round-trips when the fleet fires several turn-end captures
851
+ * before the first one resolves. Cleared when the request settles.
852
+ */
853
+ _captureInFlight = null;
854
+ /**
855
+ * In-flight `start()` attempt. Concurrent callers on a fresh instance
856
+ * coalesce onto this single promise so a `POST /sandbox` is not fired
857
+ * N times when N fleet callers race to bring the same logical sandbox
858
+ * up. Published **synchronously** with `??=` before the first `await`
859
+ * so a later caller cannot slip through the null check while the
860
+ * originator is mid-round-trip. Cleared when the shared attempt
861
+ * settles (success or failure) so the next call sees a clean slot.
862
+ *
863
+ * Mirrors OSS `@mastra/railway` `RailwaySandbox._startInFlight`.
864
+ */
865
+ _startInFlight = null;
837
866
  constructor(options = {}) {
838
867
  super({
839
868
  ...options,
840
869
  name: "PlatformSandbox",
841
870
  processes: new PlatformProcessManager()
842
871
  });
872
+ this._hasRecoveryKey = options.id !== void 0;
843
873
  this.id = options.id ?? this.generateId();
844
874
  this._client = new PlatformClient(options);
845
875
  this._environmentId = options.environmentId ?? process.env.MASTRA_ENVIRONMENT_ID ?? "";
@@ -889,6 +919,21 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
889
919
  });
890
920
  }
891
921
  async start() {
922
+ this._startInFlight ??= this._doStart().finally(() => {
923
+ this._startInFlight = null;
924
+ });
925
+ return this._startInFlight;
926
+ }
927
+ /**
928
+ * The single `start` attempt behind {@link start}'s coalescing wrapper.
929
+ *
930
+ * Split out so the wrapper can install a shared in-flight promise
931
+ * synchronously (before the first `await`) without inlining the reattach
932
+ * / retry logic. Joined callers observe whatever outcome this method
933
+ * produces — success returns normally, failures propagate to every
934
+ * awaiter.
935
+ */
936
+ async _doStart() {
892
937
  if (this._sandboxId) try {
893
938
  const json = await (await this._client.request(`/sandbox/${encodeURIComponent(this._sandboxId)}`)).json();
894
939
  if (!json.destroyedAt) {
@@ -947,10 +992,67 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
947
992
  if (!json.instanceUrl) return;
948
993
  this._addressRegistry.set(json.id, json.instanceUrl);
949
994
  }
995
+ /**
996
+ * Stop the sandbox while **preserving its recovery checkpoint**.
997
+ *
998
+ * Semantic parity with `@mastra/railway` `RailwaySandbox.stop()`: the VM
999
+ * is released but the on-provider checkpoint survives, so a subsequent
1000
+ * `start()` on a sandbox constructed with the same `id` can restore from
1001
+ * it. Any in-flight capture is awaited first so the preserved checkpoint
1002
+ * reflects the latest disk state we asked for.
1003
+ *
1004
+ * Corresponds to `DELETE /v1/projects/:pid/sandbox/:sandboxId` on
1005
+ * workspace-proxy, which by contract does not touch the checkpoint. Use
1006
+ * {@link destroy} when you want the checkpoint released too.
1007
+ */
950
1008
  async stop() {
951
- await this.destroy();
1009
+ if (this._captureInFlight) await this._captureInFlight.catch((error) => {
1010
+ this.logger.warn(`stop(): failed to flush in-flight capture before teardown:`, error);
1011
+ });
1012
+ await this._teardownSandbox();
952
1013
  }
1014
+ /**
1015
+ * Destroy the sandbox **and release its recovery checkpoint**.
1016
+ *
1017
+ * Semantic parity with `@mastra/railway` `RailwaySandbox.destroy()`:
1018
+ * cancels any in-flight capture (the checkpoint is about to be deleted
1019
+ * — no reason to burn a capture on state we're releasing), asks the
1020
+ * proxy to delete the checkpoint, then releases the VM. Both remote
1021
+ * operations are best-effort logged failures — a stray checkpoint or a
1022
+ * transient proxy error must not leave the caller with a half-torn-down
1023
+ * sandbox they can't safely retry.
1024
+ *
1025
+ * Requires the caller to have constructed with a recovery `id` (there is
1026
+ * no checkpoint to delete otherwise); callers without one skip the
1027
+ * checkpoint DELETE and behave identically to {@link stop}.
1028
+ */
953
1029
  async destroy() {
1030
+ if (!this._sandboxId) return;
1031
+ const destroyedSandboxId = this._sandboxId;
1032
+ this._captureInFlight = null;
1033
+ if (this._hasRecoveryKey) try {
1034
+ await this._client.request(`/sandbox/${encodeURIComponent(destroyedSandboxId)}/checkpoint`, {
1035
+ method: "DELETE",
1036
+ headers: { "content-type": "application/json" },
1037
+ body: JSON.stringify({ id: this.id })
1038
+ });
1039
+ } catch (error) {
1040
+ if (error instanceof PlatformApiError && (error.status === 404 || error.status === 410)) this.logger.debug(`destroy(): checkpoint already absent upstream (status=${error.status})`);
1041
+ else this.logger.warn(`destroy(): failed to delete checkpoint upstream:`, error);
1042
+ }
1043
+ await this._teardownSandbox();
1044
+ }
1045
+ /**
1046
+ * Release the remote sandbox VM and clear the local state pointing at it.
1047
+ *
1048
+ * Shared body of {@link stop} and {@link destroy} — both funnel through
1049
+ * here after they've dealt with the checkpoint (preserve vs release).
1050
+ * The VM DELETE is safe to issue in either mode: the proxy's DELETE
1051
+ * route does not touch the checkpoint on its own, so `stop()` correctly
1052
+ * leaves the checkpoint intact and `destroy()` has already removed it
1053
+ * before this call.
1054
+ */
1055
+ async _teardownSandbox() {
954
1056
  if (!this._sandboxId) return;
955
1057
  const destroyedSandboxId = this._sandboxId;
956
1058
  await this._client.request(`/sandbox/${encodeURIComponent(destroyedSandboxId)}`, { method: "DELETE" });
@@ -960,6 +1062,131 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
960
1062
  this._addressRegistry?.delete(destroyedSandboxId);
961
1063
  }
962
1064
  /**
1065
+ * Capture the sandbox's checkpoint on demand, outside any refresh timer the
1066
+ * workspace-proxy owns internally.
1067
+ *
1068
+ * Intended for callers (e.g. a factory-side scheduler) that want to refresh
1069
+ * the recovery checkpoint at semantic moments — turn end, session-idle,
1070
+ * pre-teardown — rather than only just before the upstream's idle destroy.
1071
+ *
1072
+ * Mirrors the OSS `@mastra/railway` `RailwaySandbox.captureCheckpoint()`
1073
+ * shape so factory can call `sandbox.captureCheckpoint()` uniformly and
1074
+ * branch on `status`/`reason` without knowing which provider is underneath.
1075
+ * Both `captured` and `coalesced` carry the checkpoint name inline so the
1076
+ * caller can persist a session→checkpoint binding atomically with the
1077
+ * awaited capture.
1078
+ *
1079
+ * Skip semantics:
1080
+ * - No caller-supplied `id`: returns `{ status: 'skipped', reason:
1081
+ * 'no-checkpoint-name-configured' }`. An auto-generated random id is
1082
+ * never a meaningful recovery key (no future boot would look for a
1083
+ * checkpoint under it), so capturing would silently produce dead data.
1084
+ * - Not started (no `_sandboxId`): returns `{ status: 'skipped', reason:
1085
+ * 'sandbox-not-running' }` without a round-trip.
1086
+ * - Upstream 410 (workspace-proxy or Railway reports the sandbox is
1087
+ * already destroyed): returns the same `sandbox-not-running` skip so
1088
+ * the discriminant matches the pre-flight case. Local state
1089
+ * (`_sandboxId`, `_lease`, sidecar address) is cleared as a side
1090
+ * effect so the next `start()` provisions fresh instead of reattaching
1091
+ * to a dead id. The diagnostic distinction (pre-flight vs post-hoc)
1092
+ * is preserved in log level: debug for the expected pre-flight skip,
1093
+ * warn for the surprise upstream destroy.
1094
+ *
1095
+ * Concurrent callers on the same instance coalesce onto a single in-flight
1096
+ * `POST /checkpoint` so N simultaneous turn-end fires (e.g. several tabs)
1097
+ * do not each round-trip the proxy. Both the originator and joiners
1098
+ * receive `{ status: 'coalesced', ... }` for the joined result — the
1099
+ * outer contract does not distinguish who started the request, only that
1100
+ * one upstream capture was made.
1101
+ *
1102
+ * Never throws for expected outcomes. Transport failures (5xx, 4xx other
1103
+ * than 410) propagate as {@link PlatformApiError}; a 410 is normalized
1104
+ * to a skip as described above.
1105
+ */
1106
+ async captureCheckpoint() {
1107
+ if (!this._hasRecoveryKey) {
1108
+ this.logger.debug(`captureCheckpoint skipped: no recovery key configured for sandbox ${this._sandboxId ?? "(unstarted)"}`);
1109
+ return {
1110
+ status: "skipped",
1111
+ reason: "no-checkpoint-name-configured"
1112
+ };
1113
+ }
1114
+ if (!this._sandboxId) {
1115
+ this.logger.debug(`captureCheckpoint skipped: sandbox not running (local pre-flight, id=${this.id})`);
1116
+ return {
1117
+ status: "skipped",
1118
+ reason: "sandbox-not-running"
1119
+ };
1120
+ }
1121
+ if (this._captureInFlight) return this._captureInFlight;
1122
+ const sandboxId = this._sandboxId;
1123
+ const capture = this._doCaptureCheckpoint(sandboxId).finally(() => {
1124
+ if (this._captureInFlight === capture) this._captureInFlight = null;
1125
+ });
1126
+ this._captureInFlight = capture;
1127
+ return capture;
1128
+ }
1129
+ /**
1130
+ * The single `POST /checkpoint` attempt behind {@link captureCheckpoint}.
1131
+ *
1132
+ * Split out so the coalescing wrapper can install a shared in-flight
1133
+ * promise without inlining the transport + response-mapping logic.
1134
+ * Joined callers observe `{ status: 'coalesced', ... }` — the initiator
1135
+ * sees the underlying `captured` / `coalesced` / `skipped` result the
1136
+ * proxy returned. Both are legitimate: the OSS mirror uses the same
1137
+ * "initiator sees the truth, joiners see coalesced" split.
1138
+ */
1139
+ async _doCaptureCheckpoint(sandboxId) {
1140
+ let response;
1141
+ try {
1142
+ response = await this._client.request(`/sandbox/${encodeURIComponent(sandboxId)}/checkpoint`, {
1143
+ method: "POST",
1144
+ headers: { "content-type": "application/json" },
1145
+ body: JSON.stringify({ id: this.id })
1146
+ });
1147
+ } catch (error) {
1148
+ if (error instanceof PlatformApiError && error.status === 410) {
1149
+ this.logger.warn(`captureCheckpoint skipped: sandbox destroyed upstream (proxy 410, sandboxId=${sandboxId})`);
1150
+ this._clearDestroyedState(sandboxId);
1151
+ return {
1152
+ status: "skipped",
1153
+ reason: "sandbox-not-running"
1154
+ };
1155
+ }
1156
+ throw error;
1157
+ }
1158
+ const json = await response.json();
1159
+ if (json.status === "skipped") {
1160
+ this.logger.warn(`captureCheckpoint skipped: sandbox destroyed upstream (proxy reported skipped, sandboxId=${sandboxId})`);
1161
+ this._clearDestroyedState(sandboxId);
1162
+ return {
1163
+ status: "skipped",
1164
+ reason: "sandbox-not-running"
1165
+ };
1166
+ }
1167
+ return {
1168
+ status: json.status,
1169
+ checkpointName: json.checkpointName
1170
+ };
1171
+ }
1172
+ /**
1173
+ * Clear local state that would otherwise let the caller keep exec'ing
1174
+ * against a sandbox the upstream has already destroyed. Mirrors what
1175
+ * `destroy()` does minus the outbound DELETE — the sandbox is already
1176
+ * gone, so all that remains is to stop pointing at it.
1177
+ *
1178
+ * Also resets `status` to `'pending'` so a subsequent `_start()` on this
1179
+ * reused instance re-runs provisioning instead of short-circuiting on
1180
+ * the cached `'running'` state (see `MastraSandbox._start`).
1181
+ */
1182
+ _clearDestroyedState(destroyedSandboxId) {
1183
+ this._sandboxId = void 0;
1184
+ this._createdAt = null;
1185
+ this._lease = null;
1186
+ this._addressRegistry?.delete(destroyedSandboxId);
1187
+ this.status = "pending";
1188
+ }
1189
+ /**
963
1190
  * Execute a command on the remote sandbox.
964
1191
  *
965
1192
  * `command` is a **shell string**: it is concatenated verbatim into the
@@ -1169,6 +1396,14 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
1169
1396
  status: this.status,
1170
1397
  createdAt: this._createdAt ?? /* @__PURE__ */ new Date()
1171
1398
  };
1399
+ if (this._addressRegistry?.get(this._sandboxId)) return {
1400
+ id: this._sandboxId,
1401
+ name: this.name,
1402
+ provider: this.provider,
1403
+ status: this.status,
1404
+ createdAt: this._createdAt ?? /* @__PURE__ */ new Date(),
1405
+ metadata: { sandboxId: this._sandboxId }
1406
+ };
1172
1407
  const json = await (await this._client.request(`/sandbox/${encodeURIComponent(this._sandboxId)}`)).json();
1173
1408
  return {
1174
1409
  id: json.id,