@mastra/platform-workspace 1.6.2 → 1.7.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.cjs CHANGED
@@ -27,8 +27,8 @@ path = __toESM(path, 1);
27
27
  let _mastra_core_workspace = require("@mastra/core/workspace");
28
28
  let e2b = require("e2b");
29
29
  let child_process = require("child_process");
30
- let util = require("util");
31
30
  let crypto = require("crypto");
31
+ let util = require("util");
32
32
  //#region src/client.ts
33
33
  const DEFAULT_PROXY_URL = "https://workspaces.mastra.ai";
34
34
  const REGIONAL_PROXY_URLS = {
@@ -1076,9 +1076,9 @@ var SandboxDestroyedError = class extends Error {
1076
1076
  * sandbox.
1077
1077
  */
1078
1078
  function buildCommand(command, args) {
1079
- return args?.length ? `${command} ${args.map(shellQuote$1).join(" ")}` : command;
1079
+ return args?.length ? `${command} ${args.map(shellQuote$2).join(" ")}` : command;
1080
1080
  }
1081
- function shellQuote$1(arg) {
1081
+ function shellQuote$2(arg) {
1082
1082
  if (/^[a-zA-Z0-9._\-/=:@]+$/.test(arg)) return arg;
1083
1083
  return `'${arg.replace(/'/g, `'\\''`)}'`;
1084
1084
  }
@@ -1151,6 +1151,16 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
1151
1151
  * on a later start.
1152
1152
  */
1153
1153
  templatePending;
1154
+ /**
1155
+ * The platform's id for the provisioned sandbox, as returned by the create
1156
+ * or reattach response. Persist this to reattach later via the `sandboxId`
1157
+ * option. Undefined until the sandbox has been started in this process; a
1158
+ * `sandboxId` passed to the constructor is only a hint for `start()` and is
1159
+ * not reported here until the proxy confirms it.
1160
+ */
1161
+ get sandboxId() {
1162
+ return this.status === "running" ? this._sandboxId : void 0;
1163
+ }
1154
1164
  _client;
1155
1165
  _usesProviderRoutes;
1156
1166
  _environmentId;
@@ -1335,7 +1345,7 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
1335
1345
  }
1336
1346
  this._sandboxId = void 0;
1337
1347
  } catch (error) {
1338
- if (!(error instanceof PlatformApiError) || error.status !== 404) throw error;
1348
+ if (!(error instanceof PlatformApiError) || error.status !== 404 && error.status !== 410) throw error;
1339
1349
  this._sandboxId = void 0;
1340
1350
  }
1341
1351
  if (!this._environmentId) throw new Error("environmentId is required");
@@ -1518,23 +1528,28 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
1518
1528
  await Promise.race([this._transportReadyPromise, new Promise((r) => setTimeout(r, TRANSPORT_READY_WAIT_MS))]);
1519
1529
  }
1520
1530
  /**
1521
- * Stop the sandbox while **preserving its recovery checkpoint**.
1531
+ * Stop this process's use of the sandbox without touching the remote one.
1522
1532
  *
1523
- * Semantic parity with `@mastra/railway` `RailwaySandbox.stop()`: the VM
1524
- * is released but the on-provider checkpoint survives, so a subsequent
1525
- * `start()` on a sandbox constructed with the same `id` can restore from
1526
- * it. Any in-flight capture is awaited first so the preserved checkpoint
1527
- * reflects the latest disk state we asked for.
1533
+ * Nothing is sent to workspace-proxy. The VM keeps running and idles out
1534
+ * under the environment's own idle policy, the recovery checkpoint stays,
1535
+ * and `_sandboxId` is kept so a later `start()` on this instance, or on a
1536
+ * new instance constructed with the same `id` or `sandboxId`, reattaches
1537
+ * instead of provisioning. `Mastra.shutdown()` stops every registered
1538
+ * workspace on SIGINT; releasing the VM here would destroy every live
1539
+ * session on a host restart. Use {@link destroy} to release the VM and
1540
+ * its checkpoint.
1528
1541
  *
1529
- * Corresponds to `DELETE /v1/:provider/projects/:pid/sandbox/:sandboxId` on
1530
- * workspace-proxy, which by contract does not touch the checkpoint. Use
1531
- * {@link destroy} when you want the checkpoint released too.
1542
+ * Any in-flight capture is awaited first so the checkpoint reflects the
1543
+ * latest disk state the caller asked for.
1532
1544
  */
1533
1545
  async stop() {
1534
1546
  if (this._captureInFlight) await this._captureInFlight.catch((error) => {
1535
- this.logger.warn(`stop(): failed to flush in-flight capture before teardown:`, error);
1547
+ this.logger.warn(`stop(): failed to flush in-flight capture before stop:`, error);
1536
1548
  });
1537
- await this._teardownSandbox();
1549
+ this._probeGeneration++;
1550
+ this._probeTarget = null;
1551
+ this._transportReadyPromise = null;
1552
+ this._lease = null;
1538
1553
  }
1539
1554
  /**
1540
1555
  * Destroy the sandbox **and release its recovery checkpoint**.
@@ -1571,12 +1586,9 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
1571
1586
  /**
1572
1587
  * Release the remote sandbox VM and clear the local state pointing at it.
1573
1588
  *
1574
- * Shared body of {@link stop} and {@link destroy} — both funnel through
1575
- * here after they've dealt with the checkpoint (preserve vs release).
1576
- * The VM DELETE is safe to issue in either mode: the proxy's DELETE
1577
- * route does not touch the checkpoint on its own, so `stop()` correctly
1578
- * leaves the checkpoint intact and `destroy()` has already removed it
1579
- * before this call.
1589
+ * Body of {@link destroy}, called after the checkpoint has been released.
1590
+ * The proxy's DELETE route releases the VM (on E2B that kills it), so
1591
+ * only destroy() may reach here; stop() never does.
1580
1592
  */
1581
1593
  async _teardownSandbox() {
1582
1594
  if (!this._sandboxId) return;
@@ -1955,7 +1967,7 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
1955
1967
  }
1956
1968
  }
1957
1969
  async getInfo() {
1958
- if (!this._sandboxId) return {
1970
+ if (!this._sandboxId || this.status !== "running") return {
1959
1971
  id: this.id,
1960
1972
  name: this.name,
1961
1973
  provider: this.provider,
@@ -1996,6 +2008,8 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
1996
2008
  };
1997
2009
  //#endregion
1998
2010
  //#region ../../packages/_internals/workspace/dist/index.js
2011
+ /** Directory, relative to the template's build cwd, that holds every marker below. */
2012
+ const SETUP_MARKER_DIR = ".mastra-sandbox";
1999
2013
  /**
2000
2014
  * Setup completion marker shared by repo templates and their consumers.
2001
2015
  *
@@ -2005,8 +2019,29 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
2005
2019
  * booted from the image tell whether the setup it is about to run already
2006
2020
  * happened. Relative to the template's build cwd, which is also the runtime
2007
2021
  * working directory the repo was cloned into.
2022
+ *
2023
+ * This is the single-repository marker. Templates built from a repository
2024
+ * list write one marker per repository instead ({@link repoSetupMarkerPath}).
2008
2025
  */
2009
2026
  const SETUP_MARKER_PATH = ".mastra-sandbox/setup";
2027
+ /**
2028
+ * Marker for the workspace-level setup of a multi-repository template: the
2029
+ * commands that ran at the working directory after every repository was set
2030
+ * up. Written even when there were no such commands, so a consumer can tell
2031
+ * "ran nothing" from "never built".
2032
+ */
2033
+ const WORKSPACE_SETUP_MARKER_PATH = ".mastra-sandbox/workspace-setup";
2034
+ /**
2035
+ * One line per repository directory whose setup failed while the build was
2036
+ * told to continue. Absent when nothing failed. Consumers check this before
2037
+ * trusting a per-repository marker: that marker means every step ran, not
2038
+ * that every step succeeded.
2039
+ */
2040
+ const SETUP_FAILED_MARKER_PATH = ".mastra-sandbox/setup-failed";
2041
+ /** Per-repository marker of a multi-repository template, keyed by the clone directory name. */
2042
+ function repoSetupMarkerPath(repoDir) {
2043
+ return `${SETUP_MARKER_DIR}/repos/${repoDir}`;
2044
+ }
2010
2045
  /** Blank entries never become build steps, so they never count toward the digest either. */
2011
2046
  function normalizeSetupCommands(setupCommand) {
2012
2047
  return (setupCommand === void 0 ? [] : Array.isArray(setupCommand) ? setupCommand : [setupCommand]).filter((command) => command.trim() !== "");
@@ -2016,8 +2051,26 @@ function setupMarkerContent(setupCommand) {
2016
2051
  return `sha256:${(0, crypto.createHash)("sha256").update(normalizeSetupCommands(setupCommand).join("\n")).digest("hex")}`;
2017
2052
  }
2018
2053
  /** Shell step that writes the marker relative to the cwd. `content` is a digest, so it is shell-safe. */
2019
- function setupMarkerCommand(content) {
2020
- return `mkdir -p "$(dirname "${SETUP_MARKER_PATH}")" && printf '%s' '${content}' > "${SETUP_MARKER_PATH}"`;
2054
+ function setupMarkerCommand(content, markerPath = SETUP_MARKER_PATH) {
2055
+ return `mkdir -p "$(dirname "${markerPath}")" && printf '%s' '${content}' > "${markerPath}"`;
2056
+ }
2057
+ /** A clone directory name is one path segment of word characters, dots and dashes, so it never needs quoting. */
2058
+ const REPO_DIR_PATTERN = /^[\w.-]+$/;
2059
+ /**
2060
+ * Shell step that runs one setup command inside a repository clone.
2061
+ *
2062
+ * Without the guard this is the plain `cd "<dir>" && <cmd>` step. With it,
2063
+ * the `cd` sits inside the parenthesized group so that the failure branch
2064
+ * runs back at the build cwd and the failure list lands at the workspace
2065
+ * level, never inside the repository.
2066
+ */
2067
+ function guardedSetupCommand({ repoDir, command, continueOnFailure }) {
2068
+ if (!REPO_DIR_PATTERN.test(repoDir)) throw new TypeError(`Repository directory "${repoDir}" must match ${REPO_DIR_PATTERN}.`);
2069
+ if (!continueOnFailure) return `cd "${repoDir}" && ${command}`;
2070
+ return `( cd "${repoDir}" && sh -c ${shellQuote$1(command)} ) || { mkdir -p "${SETUP_MARKER_DIR}" && grep -qxF -- '${repoDir}' "${SETUP_FAILED_MARKER_PATH}" 2>/dev/null || printf '%s\\n' '${repoDir}' >> "${SETUP_FAILED_MARKER_PATH}"; }`;
2071
+ }
2072
+ function shellQuote$1(value) {
2073
+ return `'${value.replace(/'/g, `'\\''`)}'`;
2021
2074
  }
2022
2075
  function repoCloneCommand({ cloneUrl, destination, branch, tokenEnv }) {
2023
2076
  return `git ${tokenEnv ? `${gitAuthFlag$1(tokenEnv)} ` : ""}clone --depth=1 --single-branch ${branch ? `--branch ${shellQuote(branch)} ` : ""}${shellQuote(cloneUrl)} ${shellQuote(destination)}`;
@@ -2070,77 +2123,126 @@ function rememberHead(cloneUrl, sha) {
2070
2123
  * requested size, and the caller's runtime setup materializes the checkout.
2071
2124
  */
2072
2125
  function createRepoTemplate(options) {
2073
- const getRepositoryAccess = options.getRepositoryAccess;
2126
+ if (options.getRepositoryAccess && options.repos) throw new TypeError("createRepoTemplate: pass either getRepositoryAccess or repos, not both");
2127
+ if (!options.repos && (options.workspaceSetupCommand !== void 0 || options.continueOnSetupFailure !== void 0)) throw new TypeError("createRepoTemplate: workspaceSetupCommand and continueOnSetupFailure require repos");
2128
+ const isList = options.repos !== void 0;
2129
+ const entries = options.repos ? options.repos : options.getRepositoryAccess ? [{
2130
+ getRepositoryAccess: options.getRepositoryAccess,
2131
+ setupCommand: options.setupCommand
2132
+ }] : [];
2074
2133
  const resourcesOnly = () => {
2075
2134
  if (options.cpuCount === void 0 && options.memoryMB === void 0) return void 0;
2076
2135
  return withResources(Template(), options);
2077
2136
  };
2078
- if (!getRepositoryAccess) {
2137
+ if (entries.length === 0) {
2079
2138
  const template = resourcesOnly();
2080
2139
  return template ? async () => template : void 0;
2081
2140
  }
2082
2141
  const resolveHead = options.resolveHead ?? resolveDefaultBranchHead;
2142
+ const continueOnFailure = options.continueOnSetupFailure ?? false;
2083
2143
  return async () => {
2084
- let accessError;
2085
- const access = await getRepositoryAccess().catch((error) => {
2086
- accessError = error;
2087
- });
2088
- if (!access?.cloneUrl) {
2089
- console.warn("[platform-workspace] repo template skipped: repository access unavailable", { error: redactSecrets(accessError) });
2090
- return resourcesOnly();
2091
- }
2092
- const cloneUrl = normalizeCloneUrl(access.cloneUrl);
2093
- if (!isValidCloneUrl(cloneUrl)) {
2094
- console.warn("[platform-workspace] repo template skipped: clone URL failed validation", { cloneUrl: redactSecrets(cloneUrl) });
2095
- return resourcesOnly();
2096
- }
2097
- const token = access.authorization?.token;
2098
- let headError;
2099
- const resolved = await (token ? resolveHead(cloneUrl, token) : resolveHead(cloneUrl)).catch((error) => {
2100
- headError = error;
2101
- });
2102
- let sha;
2103
- if (resolved && SHA_PATTERN.test(resolved)) {
2104
- sha = resolved;
2105
- rememberHead(cloneUrl, sha);
2106
- } else {
2107
- const lastKnown = lastKnownHeads.get(cloneUrl);
2108
- if (!lastKnown) {
2109
- console.warn("[platform-workspace] repo template skipped: could not resolve default-branch head", {
2144
+ const repos = [];
2145
+ for (const [index, entry] of entries.entries()) {
2146
+ let accessError;
2147
+ const access = await entry.getRepositoryAccess().catch((error) => {
2148
+ accessError = error;
2149
+ });
2150
+ if (!access?.cloneUrl) {
2151
+ console.warn("[platform-workspace] repo template skipped: repository access unavailable", {
2152
+ ...isList ? { index } : {},
2153
+ error: redactSecrets(accessError)
2154
+ });
2155
+ return resourcesOnly();
2156
+ }
2157
+ const cloneUrl = normalizeCloneUrl(access.cloneUrl);
2158
+ if (!isValidCloneUrl(cloneUrl)) {
2159
+ console.warn("[platform-workspace] repo template skipped: clone URL failed validation", {
2160
+ ...isList ? { index } : {},
2161
+ cloneUrl: redactSecrets(cloneUrl)
2162
+ });
2163
+ return resourcesOnly();
2164
+ }
2165
+ const token = access.authorization?.token;
2166
+ let headError;
2167
+ const resolved = await (token ? resolveHead(cloneUrl, token) : resolveHead(cloneUrl)).catch((error) => {
2168
+ headError = error;
2169
+ });
2170
+ let sha;
2171
+ if (resolved && SHA_PATTERN.test(resolved)) {
2172
+ sha = resolved;
2173
+ rememberHead(cloneUrl, sha);
2174
+ } else {
2175
+ const lastKnown = lastKnownHeads.get(cloneUrl);
2176
+ if (lastKnown) {
2177
+ console.warn("[platform-workspace] repo template pinned to the last known default-branch head", {
2178
+ cloneUrl,
2179
+ sha: lastKnown,
2180
+ resolved,
2181
+ error: redactSecrets(headError)
2182
+ });
2183
+ sha = lastKnown;
2184
+ } else if (isList) console.warn("[platform-workspace] repo template clones unpinned: could not resolve default-branch head", {
2110
2185
  cloneUrl,
2111
2186
  sha: resolved,
2112
2187
  error: redactSecrets(headError)
2113
2188
  });
2114
- return resourcesOnly();
2189
+ else {
2190
+ console.warn("[platform-workspace] repo template skipped: could not resolve default-branch head", {
2191
+ cloneUrl,
2192
+ sha: resolved,
2193
+ error: redactSecrets(headError)
2194
+ });
2195
+ return resourcesOnly();
2196
+ }
2115
2197
  }
2116
- console.warn("[platform-workspace] repo template pinned to the last known default-branch head", {
2198
+ const repoDir = repoDirName(cloneUrl);
2199
+ repos.push({
2200
+ index,
2117
2201
  cloneUrl,
2118
- sha: lastKnown,
2119
- resolved,
2120
- error: redactSecrets(headError)
2202
+ repoDir,
2203
+ token,
2204
+ tokenEnv: token ? isList ? `${BUILD_TOKEN_ENV}_${index}` : BUILD_TOKEN_ENV : void 0,
2205
+ sha,
2206
+ setupCommands: normalizeSetupCommands(entry.setupCommand)
2121
2207
  });
2122
- sha = lastKnown;
2208
+ }
2209
+ const seenDirs = /* @__PURE__ */ new Map();
2210
+ for (const repo of repos) {
2211
+ const other = seenDirs.get(repo.repoDir);
2212
+ if (other) throw new Error(`createRepoTemplate: repositories ${other} and ${repo.cloneUrl} would both clone into "${repo.repoDir}"`);
2213
+ seenDirs.set(repo.repoDir, repo.cloneUrl);
2123
2214
  }
2124
2215
  const workingDirectory = options.workingDirectory === void 0 ? void 0 : trimTrailingSlashes(assertWorkingDirectory(options.workingDirectory));
2125
- const repoDir = repoDirName(cloneUrl);
2126
- const auth = token ? `${gitAuthFlag()} ` : "";
2127
- const setupCommands = (options.setupCommand === void 0 ? [] : Array.isArray(options.setupCommand) ? options.setupCommand : [options.setupCommand]).filter((command) => command.trim() !== "");
2128
- const family = `repo:${cloneUrl}:${workingDirectory ?? ""}/${repoDir}`;
2216
+ const family = isList ? `repos:${(0, crypto.createHash)("sha256").update(JSON.stringify([workingDirectory ?? "", repos.map((repo) => [repo.cloneUrl, repo.repoDir])])).digest("hex").slice(0, 32)}` : `repo:${repos[0].cloneUrl}:${workingDirectory ?? ""}/${repos[0].repoDir}`;
2129
2217
  let template = Template();
2130
- const buildEnv = {
2131
- ...options.buildEnv,
2132
- ...token ? { [BUILD_TOKEN_ENV]: token } : {}
2133
- };
2218
+ const buildEnv = { ...options.buildEnv };
2219
+ for (const repo of repos) if (repo.tokenEnv && repo.token) buildEnv[repo.tokenEnv] = repo.token;
2134
2220
  if (Object.keys(buildEnv).length > 0) template = template.setEnvs(buildEnv, { ephemeral: true });
2135
2221
  template = withResources(template, options);
2136
2222
  if (workingDirectory) template = template.runCmd(`mkdir -p "${workingDirectory}"`).setWorkdir(workingDirectory);
2137
- template = template.runCmd(repoCloneCommand({
2138
- cloneUrl,
2139
- destination: repoDir,
2140
- ...token ? { tokenEnv: BUILD_TOKEN_ENV } : {}
2141
- })).runCmd(`git -C "${repoDir}" ${auth}fetch origin ${sha}`).runCmd(`git -C "${repoDir}" checkout ${sha}`);
2142
- for (const command of setupCommands) template = template.runCmd(`cd "${repoDir}" && ${command}`);
2143
- template = template.runCmd(setupMarkerCommand(setupMarkerContent(setupCommands)));
2223
+ const ordered = [...repos.filter((repo) => !repo.token), ...repos.filter((repo) => repo.token)];
2224
+ for (const repo of ordered) {
2225
+ const { repoDir, sha, tokenEnv, setupCommands } = repo;
2226
+ const auth = tokenEnv ? `${gitAuthFlag(tokenEnv)} ` : "";
2227
+ template = template.runCmd(repoCloneCommand({
2228
+ cloneUrl: repo.cloneUrl,
2229
+ destination: repoDir,
2230
+ ...tokenEnv ? { tokenEnv } : {}
2231
+ }));
2232
+ if (sha) template = template.runCmd(`git -C "${repoDir}" ${auth}fetch origin ${sha}`).runCmd(`git -C "${repoDir}" checkout ${sha}`);
2233
+ for (const command of setupCommands) template = template.runCmd(guardedSetupCommand({
2234
+ repoDir,
2235
+ command,
2236
+ continueOnFailure
2237
+ }));
2238
+ const content = setupMarkerContent(setupCommands);
2239
+ template = template.runCmd(isList ? setupMarkerCommand(content, repoSetupMarkerPath(repoDir)) : setupMarkerCommand(content));
2240
+ }
2241
+ if (isList) {
2242
+ const workspaceCommands = normalizeSetupCommands(options.workspaceSetupCommand);
2243
+ for (const command of workspaceCommands) template = template.runCmd(command);
2244
+ template = template.runCmd(setupMarkerCommand(setupMarkerContent(workspaceCommands), WORKSPACE_SETUP_MARKER_PATH));
2245
+ }
2144
2246
  return template.withFamily(family);
2145
2247
  };
2146
2248
  }
@@ -2195,8 +2297,8 @@ function redactSecrets(value) {
2195
2297
  if (value === void 0 || value === null) return void 0;
2196
2298
  return (value instanceof Error ? value.message : typeof value === "string" ? value : String(value)).replace(/\/\/[^/@\s]+@/g, "//***@").replace(/\b(bearer|basic)\s+[^\s"']+/gi, "$1 ***").replace(/\b(gh[pousr]_|github_pat_)[A-Za-z0-9_]+/g, "$1***");
2197
2299
  }
2198
- function gitAuthFlag() {
2199
- return `-c http.extraheader="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$${BUILD_TOKEN_ENV}" | base64 -w0)"`;
2300
+ function gitAuthFlag(tokenEnv) {
2301
+ return `-c http.extraheader="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$${tokenEnv}" | base64 -w0)"`;
2200
2302
  }
2201
2303
  /**
2202
2304
  * `owner/repo` for a github.com clone URL, else undefined. Only the public