@mastra/platform-workspace 0.2.3 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # @mastra/platform
2
2
 
3
+ ## 0.3.0-alpha.1
4
+
5
+ ### Minor Changes
6
+
7
+ - `PlatformSandbox.executeCommand` now retries a dropped connection once and continues using direct execution for later commands. Previously a single connection hiccup permanently downgraded the sandbox to a slower fallback route for the rest of its lifetime. ([#20482](https://github.com/mastra-ai/mastra/pull/20482))
8
+
9
+ Execution failures now surface directly:
10
+
11
+ - A destroyed sandbox throws the new `SandboxDestroyedError`. The cached sandbox is cleared, so the next call provisions a fresh one.
12
+ - Two connection failures in a row against a live sandbox throw the new `SandboxExecTransportError`, which carries `sandboxId`, `command`, `attempts`, `opened`, `closeCode`, `closeReason`, and `wsEndpoint` for diagnostics.
13
+ - Other platform errors previously masked by the fallback now bubble out as `PlatformApiError`.
14
+
15
+ ```ts
16
+ import { SandboxDestroyedError, SandboxExecTransportError } from '@mastra/platform-workspace';
17
+
18
+ try {
19
+ await sandbox.executeCommand('pytest');
20
+ } catch (err) {
21
+ if (err instanceof SandboxDestroyedError) {
22
+ // Reprovision and retry.
23
+ } else if (err instanceof SandboxExecTransportError) {
24
+ // Connection failed twice; sandbox is still alive.
25
+ }
26
+ }
27
+ ```
28
+
29
+ ### Patch Changes
30
+
31
+ - Updated dependencies [[`594f7b2`](https://github.com/mastra-ai/mastra/commit/594f7b28f5263fb9982fd50d95c471fb971ea984), [`311f943`](https://github.com/mastra-ai/mastra/commit/311f943bee60e8fdf5c84499ea50e884276c936c), [`0c89896`](https://github.com/mastra-ai/mastra/commit/0c8989673fb7d106837098398131e570c6023b68), [`23b4238`](https://github.com/mastra-ai/mastra/commit/23b423844ad0bcf2a502a68dd62866d6160f9f6d), [`e320a76`](https://github.com/mastra-ai/mastra/commit/e320a763feaf65c6be3cebecf746defcbde161b3), [`03b4918`](https://github.com/mastra-ai/mastra/commit/03b4918c80d188ce375334c393e131c6e94bd7eb), [`14ef73a`](https://github.com/mastra-ai/mastra/commit/14ef73a4bbd73e7808414816eb0628ce1d80b5d7), [`1d677d5`](https://github.com/mastra-ai/mastra/commit/1d677d5f99d7db403f7828585e8c25f299f72628), [`93e28ec`](https://github.com/mastra-ai/mastra/commit/93e28ecce9031c02397e0ae8406593e5c7a95883), [`729dab4`](https://github.com/mastra-ai/mastra/commit/729dab408faccfaef0cbb048e5a4338f9172847e), [`484003d`](https://github.com/mastra-ai/mastra/commit/484003d33ff59330c86b19863e4a38732d7e4155), [`933d291`](https://github.com/mastra-ai/mastra/commit/933d291146b789c19442ad206f94da3e4be90c64)]:
32
+ - @mastra/core@1.56.0-alpha.3
33
+
34
+ ## 0.2.4-alpha.0
35
+
36
+ ### Patch Changes
37
+
38
+ - Fixed `PlatformSandbox.clone()` silently ignoring `checkpointName`. Clones created with `clone({ checkpointName })` now reuse a matching captured checkpoint on `start()` instead of always provisioning a fresh sandbox, so repeated boots of the same session start much faster. ([#20477](https://github.com/mastra-ai/mastra/pull/20477))
39
+
40
+ ```ts
41
+ const child = template.clone({ checkpointName: 'mastra-recovery-session-42' });
42
+ await child.start(); // Reuses the captured checkpoint when one is available.
43
+ ```
44
+
45
+ An explicit `id` still takes precedence over `checkpointName` when both are passed.
46
+
47
+ - Updated dependencies [[`322daa6`](https://github.com/mastra-ai/mastra/commit/322daa6d90552909204044790d850958f6745fed), [`cadaa13`](https://github.com/mastra-ai/mastra/commit/cadaa1372e1077c8e85eb64c5499ba8803caa323), [`06000d7`](https://github.com/mastra-ai/mastra/commit/06000d73712911572e913b8a83339270296d0a22), [`3de0188`](https://github.com/mastra-ai/mastra/commit/3de0188bfaf9a9c09c95fe322b53838cf52c70b6)]:
48
+ - @mastra/core@1.56.0-alpha.2
49
+
3
50
  ## 0.2.3
4
51
 
5
52
  ### Patch Changes
package/README.md CHANGED
@@ -106,3 +106,12 @@ try {
106
106
  `code` / `proxyMessage` are `undefined` when the proxy returns a non-JSON body (e.g. an HTML 502 from a load balancer).
107
107
 
108
108
  Filesystem-specific errors (`FileNotFoundError`, `FileExistsError`, `WorkspaceReadOnlyError`) are re-exported from `@mastra/core`.
109
+
110
+ ### Sandbox exec errors
111
+
112
+ `PlatformSandbox.executeCommand` runs over the direct-exec data plane (a WebSocket straight to the Railway tcp-proxy) and can throw two typed errors on unrecoverable failure:
113
+
114
+ - `SandboxDestroyedError` — the platform returned 410 for `/exec-lease`, meaning the sandbox has been destroyed. The cached sandbox id and lease are cleared, so a reused `PlatformSandbox` instance will re-provision on the next call. Fleet-level code that owns a binding store should catch this, clear the stale sandbox id, and reprovision + replay.
115
+ - `SandboxExecTransportError` — both the initial WebSocket attempt and the built-in retry closed without an `exit` frame against a live sandbox. Carries `{ opened, closeCode, closeReason, wsEndpoint }` diagnostics plus `sandboxId`, `command`, and `attempts` so upstream logs / alerts can distinguish "the Railway data plane is broken" from "your command failed".
116
+
117
+ `PlatformApiError` (with status 404 / 500 / 501 on `/exec-lease`) can also bubble up from `executeCommand` — those are configuration or platform errors, not "reprovision me" signals, and are propagated as-is.
package/dist/index.cjs CHANGED
@@ -524,6 +524,59 @@ const CREATE_MAX_ATTEMPTS = 3;
524
524
  /** Base delay between create retries; multiplied by the attempt number. */
525
525
  const CREATE_RETRY_BASE_DELAY_MS = 2e3;
526
526
  /**
527
+ * Diagnostic error thrown when the direct-exec WebSocket transport fails
528
+ * twice in a row (opening handshake refused or socket closed mid-stream
529
+ * without an `exit` frame). Distinguishes "the sandbox transport is broken"
530
+ * from "your command failed" so callers can decide whether to retry at a
531
+ * higher level (e.g. reprovision the sandbox) or surface the error.
532
+ *
533
+ * `opened` is `true` when the WebSocket completed its handshake at least
534
+ * once before closing; `false` when Railway refused the upgrade outright.
535
+ */
536
+ var SandboxExecTransportError = class extends Error {
537
+ sandboxId;
538
+ command;
539
+ attempts;
540
+ opened;
541
+ closeCode;
542
+ closeReason;
543
+ wsEndpoint;
544
+ constructor(message, diagnostics) {
545
+ super(message);
546
+ this.name = "SandboxExecTransportError";
547
+ this.sandboxId = diagnostics.sandboxId;
548
+ this.command = diagnostics.command;
549
+ this.attempts = diagnostics.attempts;
550
+ this.opened = diagnostics.opened;
551
+ this.closeCode = diagnostics.closeCode;
552
+ this.closeReason = diagnostics.closeReason;
553
+ this.wsEndpoint = diagnostics.wsEndpoint;
554
+ }
555
+ };
556
+ /**
557
+ * Thrown when `/exec-lease` returns 410 Gone — the sandbox has been destroyed
558
+ * (Railway destroy, quota reclamation, etc.). The client cannot recover from
559
+ * this on its own because it does not own the binding store; only the fleet
560
+ * layer can clear the stale sandbox id and provision a fresh one. Callers
561
+ * (typically `SandboxFleet`) must catch this and reprovision-and-replay.
562
+ *
563
+ * When this is thrown the cached `_lease` and `_sandboxId` on the sandbox
564
+ * instance are cleared, so the next `ensureRunning()` on a reused instance
565
+ * will re-provision cleanly.
566
+ */
567
+ var SandboxDestroyedError = class extends Error {
568
+ sandboxId;
569
+ command;
570
+ attempts;
571
+ constructor(message, diagnostics) {
572
+ super(message);
573
+ this.name = "SandboxDestroyedError";
574
+ this.sandboxId = diagnostics.sandboxId;
575
+ this.command = diagnostics.command;
576
+ this.attempts = diagnostics.attempts;
577
+ }
578
+ };
579
+ /**
527
580
  * Compose a shell command line from a `command` string and optional `args`.
528
581
  *
529
582
  * IMPORTANT: `command` is treated as a **shell string** and passed to the
@@ -623,16 +676,6 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
623
676
  * Cleared (regardless of success or failure) when the request settles.
624
677
  */
625
678
  _leaseInFlight = null;
626
- /**
627
- * Tri-state feature detection for the platform's exec-lease endpoint:
628
- * undefined — not yet tried (default; try direct on first exec)
629
- * true — endpoint present, use direct exec
630
- * false — endpoint absent (404/501) OR the WebSocket transport failed
631
- * once; fall back permanently to /exec for this sandbox
632
- * Sticky per instance so we make the fallback decision once per sandbox
633
- * lifetime instead of paying an extra round-trip on every exec.
634
- */
635
- _directExecAvailable = void 0;
636
679
  constructor(options = {}) {
637
680
  super({
638
681
  ...options,
@@ -667,8 +710,9 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
667
710
  * (e.g. one per project).
668
711
  */
669
712
  clone(options = {}) {
713
+ const id = options.id ?? options.checkpointName;
670
714
  return new PlatformSandbox({
671
- ...options.id !== void 0 && { id: options.id },
715
+ ...id !== void 0 && { id },
672
716
  accessToken: this._client.accessToken,
673
717
  projectId: this._client.projectId,
674
718
  fetch: this._client.fetch,
@@ -751,47 +795,7 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
751
795
  const started = Date.now();
752
796
  const fullCommand = buildCommand(command, args);
753
797
  const effectiveTimeout = options?.timeout ?? this._timeout;
754
- if (this._directExecAvailable !== false) {
755
- const leaseResult = await this._tryDirectExec(fullCommand, effectiveTimeout, options);
756
- if (leaseResult) return {
757
- ...leaseResult,
758
- executionTimeMs: Date.now() - started
759
- };
760
- }
761
- return this._execViaProxy(fullCommand, effectiveTimeout, options, started);
762
- }
763
- async _tryDirectExec(fullCommand, effectiveTimeout, options) {
764
- let lease;
765
- try {
766
- lease = await this._ensureLease();
767
- } catch (error) {
768
- if (error instanceof PlatformApiError && (error.status === 404 || error.status === 501)) {
769
- this._directExecAvailable = false;
770
- return null;
771
- }
772
- throw error;
773
- }
774
- this._directExecAvailable = true;
775
- const filteredEnv = options?.env ? Object.fromEntries(Object.entries(options.env).filter((entry) => entry[1] !== void 0)) : void 0;
776
- const result = await execViaLease(lease, {
777
- command: fullCommand,
778
- ...options?.cwd !== void 0 && { cwd: options.cwd },
779
- ...filteredEnv !== void 0 && { env: filteredEnv },
780
- ...effectiveTimeout != null && effectiveTimeout > 0 && { timeoutMs: effectiveTimeout },
781
- ...this._webSocketFactory && { webSocketFactory: this._webSocketFactory }
782
- });
783
- if (result.exitCode === null && !result.timedOut) {
784
- console.warn("[platform-workspace] direct-exec transport failed; falling back to /exec permanently for this sandbox", {
785
- sandboxId: this._sandboxId,
786
- opened: result.opened,
787
- closeCode: result.closeCode,
788
- closeReason: result.closeReason,
789
- wsEndpoint: lease.wsEndpoint
790
- });
791
- this._lease = null;
792
- this._directExecAvailable = false;
793
- return null;
794
- }
798
+ const result = await this._runDirectExec(fullCommand, effectiveTimeout, options);
795
799
  const exitCode = result.exitCode ?? 124;
796
800
  return {
797
801
  success: exitCode === 0,
@@ -799,34 +803,78 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
799
803
  stdout: result.stdout,
800
804
  stderr: result.stderr,
801
805
  timedOut: result.timedOut,
802
- command: fullCommand
806
+ command: fullCommand,
807
+ executionTimeMs: Date.now() - started
803
808
  };
804
809
  }
805
- async _execViaProxy(fullCommand, effectiveTimeout, options, started) {
806
- if (!this._sandboxId) throw new _mastra_core_workspace.SandboxNotReadyError(this.id);
807
- const timeoutSec = effectiveTimeout != null ? Math.ceil(effectiveTimeout / 1e3) : void 0;
808
- const clientSignal = effectiveTimeout != null && effectiveTimeout > 0 ? AbortSignal.timeout(effectiveTimeout + 3e4) : void 0;
809
- const json = await (await this._client.request(`/sandbox/${encodeURIComponent(this._sandboxId)}/exec`, {
810
- method: "POST",
811
- headers: { "content-type": "application/json" },
812
- body: JSON.stringify({
810
+ /**
811
+ * Run a single exec against the direct-exec transport, with one in-flight
812
+ * retry on WebSocket transport failure (socket closed without an `exit`
813
+ * frame and the exec did not time out). The retry mints a fresh lease
814
+ * the failure could be a stale JWT — and reopens a new WebSocket.
815
+ *
816
+ * Error taxonomy:
817
+ * - **410 on `/exec-lease`** (either attempt) → the sandbox is gone.
818
+ * Nulls the cached `_lease` and `_sandboxId` and throws
819
+ * {@link SandboxDestroyedError}. Callers (typically `SandboxFleet`) must
820
+ * catch this, clear the stale binding, and reprovision + replay.
821
+ * - **Persistent transport failure** (both WS attempts close without an
822
+ * `exit` frame against a live sandbox) → {@link SandboxExecTransportError}
823
+ * with WebSocket close diagnostics.
824
+ * - **Other `PlatformApiError`s** (404/500/501) propagate directly.
825
+ * - **Real command result** (exit code from Railway's exit frame, or
826
+ * `timedOut: true`) returns normally.
827
+ *
828
+ * Returns a result with a real `exitCode` OR `timedOut: true`. Never
829
+ * returns `{ exitCode: null, timedOut: false }` — that case throws.
830
+ */
831
+ async _runDirectExec(fullCommand, effectiveTimeout, options) {
832
+ const filteredEnv = options?.env ? Object.fromEntries(Object.entries(options.env).filter((entry) => entry[1] !== void 0)) : void 0;
833
+ let lastResult;
834
+ let lastLease;
835
+ let attemptsMade = 0;
836
+ for (let attempt = 0; attempt < 2; attempt++) {
837
+ if (attempt > 0 && lastLease && this._lease === lastLease) this._lease = null;
838
+ let lease;
839
+ try {
840
+ lease = await this._ensureLease();
841
+ } catch (error) {
842
+ if (error instanceof PlatformApiError && error.status === 410) {
843
+ this._lease = null;
844
+ const priorSandboxId = this._sandboxId;
845
+ this._sandboxId = void 0;
846
+ throw new SandboxDestroyedError(`Sandbox ${priorSandboxId ?? "(unknown)"} was destroyed; /exec-lease returned 410`, {
847
+ ...priorSandboxId && { sandboxId: priorSandboxId },
848
+ command: fullCommand,
849
+ attempts: attempt + 1
850
+ });
851
+ }
852
+ throw error;
853
+ }
854
+ lastLease = lease;
855
+ attemptsMade = attempt + 1;
856
+ const result = await execViaLease(lease, {
813
857
  command: fullCommand,
814
- timeoutSec,
815
- cwd: options?.cwd,
816
- env: options?.env
817
- }),
818
- signal: clientSignal
819
- })).json();
820
- const exitCode = json.exitCode ?? (json.timedOut ? 124 : 1);
821
- return {
822
- success: exitCode === 0,
823
- exitCode,
824
- stdout: json.stdout,
825
- stderr: json.stderr,
826
- executionTimeMs: Date.now() - started,
827
- timedOut: json.timedOut,
828
- command: fullCommand
829
- };
858
+ ...options?.cwd !== void 0 && { cwd: options.cwd },
859
+ ...filteredEnv !== void 0 && { env: filteredEnv },
860
+ ...effectiveTimeout != null && effectiveTimeout > 0 && { timeoutMs: effectiveTimeout },
861
+ ...this._webSocketFactory && { webSocketFactory: this._webSocketFactory }
862
+ });
863
+ lastResult = result;
864
+ if (result.exitCode !== null || result.timedOut) return result;
865
+ }
866
+ const result = lastResult;
867
+ const lease = lastLease;
868
+ if (this._lease === lease) this._lease = null;
869
+ throw new SandboxExecTransportError(`Direct-exec transport failed for sandbox ${this._sandboxId ?? "(unknown)"} after ${attemptsMade} attempt(s)` + (result.closeCode !== void 0 ? ` (close ${result.closeCode}${result.closeReason ? ` ${result.closeReason}` : ""})` : ""), {
870
+ ...this._sandboxId && { sandboxId: this._sandboxId },
871
+ command: fullCommand,
872
+ attempts: attemptsMade,
873
+ opened: result.opened ?? false,
874
+ ...result.closeCode !== void 0 && { closeCode: result.closeCode },
875
+ ...result.closeReason !== void 0 && { closeReason: result.closeReason },
876
+ wsEndpoint: lease.wsEndpoint
877
+ });
830
878
  }
831
879
  /**
832
880
  * Return a cached exec lease, minting a fresh one when the cache is empty
@@ -974,6 +1022,8 @@ exports.PlatformApiError = PlatformApiError;
974
1022
  exports.PlatformClient = PlatformClient;
975
1023
  exports.PlatformFilesystem = PlatformFilesystem;
976
1024
  exports.PlatformSandbox = PlatformSandbox;
1025
+ exports.SandboxDestroyedError = SandboxDestroyedError;
1026
+ exports.SandboxExecTransportError = SandboxExecTransportError;
977
1027
  exports.platformFilesystemProvider = platformFilesystemProvider;
978
1028
  exports.platformSandboxProvider = platformSandboxProvider;
979
1029