@mastra/factory 0.25.0-alpha.2 → 0.25.0-alpha.4

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/routes/fs.js CHANGED
@@ -11,16 +11,19 @@ import { detectProject, getResourceIdOverride } from "@mastra/code-sdk/utils/pro
11
11
  //#region src/routes/fs.ts
12
12
  const MAX_TEXT_FILE_BYTES = 524288;
13
13
  const MAX_DIFF_BYTES = 524288;
14
- const WORKSPACE_NUMSTAT_SCRIPT = `
14
+ const MAX_PATCH_SET_BYTES = 8388608;
15
+ /** Diffs the worktree against `$2`, untracked files as added through a throwaway index so the real one stays untouched. */
16
+ const WORKSPACE_DIFF_SCRIPT = `
15
17
  set -e
16
18
  workdir=$1
17
19
  base=$2
20
+ shift 2
18
21
  index_file=$(mktemp)
19
22
  untracked_file=$(mktemp)
20
23
  object_dir=$(mktemp -d)
21
24
  trap 'rm -f "$index_file" "$untracked_file"; rm -rf "$object_dir"' EXIT
22
25
  rm -f "$index_file"
23
- git -C "$workdir" diff --numstat -z --find-renames --no-ext-diff --no-textconv "$base"
26
+ git -C "$workdir" -c core.quotePath=false diff --no-ext-diff --no-textconv --find-renames "$@" "$base"
24
27
  git -C "$workdir" ls-files --others --exclude-standard -z >"$untracked_file"
25
28
  git_dir=$(git -C "$workdir" rev-parse --absolute-git-dir)
26
29
  export GIT_INDEX_FILE="$index_file"
@@ -29,24 +32,30 @@ export GIT_ALTERNATE_OBJECT_DIRECTORIES="$git_dir/objects"
29
32
  git -C "$workdir" read-tree --empty
30
33
  git -C "$workdir" update-index --add -z --stdin <"$untracked_file"
31
34
  empty_tree=$(git -C "$workdir" hash-object -t tree /dev/null)
32
- git -C "$workdir" diff --cached --numstat -z --no-ext-diff --no-textconv "$empty_tree"
35
+ git -C "$workdir" -c core.quotePath=false diff --cached --no-ext-diff --no-textconv "$@" "$empty_tree"
33
36
  `;
34
- const BOUNDED_GIT_DIFF_SCRIPT = `
35
- allow_exit_one=$1
37
+ const NUMSTAT_FORMAT = ["--numstat", "-z"];
38
+ const PATCH_FORMAT = [
39
+ "--patch",
40
+ "--no-color",
41
+ "--unified=3",
42
+ "--src-prefix=a/",
43
+ "--dst-prefix=b/"
44
+ ];
45
+ /** Keeps the first `$1` bytes of the command in the remaining arguments; being cut short is not a failure. */
46
+ const BOUNDED_OUTPUT_SCRIPT = `
47
+ limit=$1
36
48
  shift
37
49
  status_file=$(mktemp)
38
- stderr_file=$(mktemp)
39
- trap 'rm -f "$status_file" "$stderr_file"' EXIT
50
+ trap 'rm -f "$status_file"' EXIT
40
51
  (
41
- git "$@" 2>"$stderr_file"
52
+ "$@"
42
53
  printf '%s' "$?" >"$status_file"
43
- ) | head -c 524289
54
+ ) | head -c "$limit"
44
55
  status=$(cat "$status_file")
45
56
  case "$status" in
46
57
  0|141) exit 0 ;;
47
- 1) [ "$allow_exit_one" = "1" ] && exit 0 ;;
48
58
  esac
49
- cat "$stderr_file" >&2
50
59
  exit "\${status:-1}"
51
60
  `;
52
61
  const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
@@ -318,26 +327,6 @@ function repositoryBaseBranches(session, layout, ownRepo) {
318
327
  }
319
328
  return bases;
320
329
  }
321
- /**
322
- * Split a client path into the layout repository it belongs to and the path
323
- * inside that checkout. Longest prefix wins; the inside path is validated
324
- * again so a prefix cannot smuggle a traversal. A path matching no repository
325
- * (including a bare prefix) is not available: the file routes only serve the
326
- * checkouts, never the workspace root itself.
327
- */
328
- function resolveLayoutPath(layout, path, label) {
329
- const safePath = assertRelativePath(path, label);
330
- if (layout.repos.length === 1) return {
331
- repo: layout.repos[0],
332
- relative: safePath
333
- };
334
- const repo = layout.repos.filter((candidate) => safePath.startsWith(candidate.prefix) && safePath.length > candidate.prefix.length).toSorted((a, b) => b.prefix.length - a.prefix.length)[0];
335
- if (!repo) throw new Error(`${label} is not available in this session's repositories`);
336
- return {
337
- repo,
338
- relative: assertRelativePath(safePath.slice(repo.prefix.length), label)
339
- };
340
- }
341
330
  /** List an approved rendered root inside a Factory session's sandbox workdir. */
342
331
  async function listSessionRenderedPath(session, renderedRoot) {
343
332
  const safeRoot = assertApprovedRenderedRoot(renderedRoot);
@@ -696,7 +685,11 @@ function unavailableWorkspaceChanges(workspacePath) {
696
685
  changes: []
697
686
  };
698
687
  }
699
- async function resolveSessionComparisonBase(sandbox, workdir, baseBranch) {
688
+ function isWorkspaceChangesScope(value) {
689
+ return value === "branch" || value === "uncommitted";
690
+ }
691
+ async function resolveSessionComparisonBase(sandbox, workdir, baseBranch, scope) {
692
+ if (scope === "uncommitted") return "HEAD";
700
693
  const result = await sandbox.executeCommand("git", [
701
694
  "-C",
702
695
  workdir,
@@ -708,8 +701,7 @@ async function resolveSessionComparisonBase(sandbox, workdir, baseBranch) {
708
701
  return result.exitCode === 0 && /^[0-9a-f]{40,64}$/i.test(sha) ? sha : "HEAD";
709
702
  }
710
703
  /** The changes of one checkout against its comparison base; `available: false` when git cannot inspect it. */
711
- async function listRepositoryChanges(sandbox, workdir, baseBranch) {
712
- const comparisonBase = await resolveSessionComparisonBase(sandbox, workdir, baseBranch);
704
+ async function listRepositoryChanges(sandbox, workdir, comparisonBase) {
713
705
  const [statusResult, trackedResult, statsResult] = await Promise.all([
714
706
  sandbox.executeCommand("git", [
715
707
  "-C",
@@ -736,10 +728,11 @@ async function listRepositoryChanges(sandbox, workdir, baseBranch) {
736
728
  }),
737
729
  sandbox.executeCommand("sh", [
738
730
  "-c",
739
- WORKSPACE_NUMSTAT_SCRIPT,
731
+ WORKSPACE_DIFF_SCRIPT,
740
732
  "mastracode-numstat",
741
733
  workdir,
742
- comparisonBase
734
+ comparisonBase,
735
+ ...NUMSTAT_FORMAT
743
736
  ], {
744
737
  timeout: 3e4,
745
738
  maxRetainedBytes: Infinity
@@ -784,27 +777,20 @@ function prefixChange(change, prefix) {
784
777
  ...change.previousPath ? { previousPath: `${prefix}${change.previousPath}` } : {}
785
778
  };
786
779
  }
787
- async function listSessionWorkspaceChanges(session) {
788
- const handle = await sessionSandbox(session);
789
- if (!handle) return unavailableWorkspaceChanges(session.sessionId);
790
- const { layout, baseBranches: bases } = handle;
791
- if (layout.repos.length === 1) {
792
- const repo = layout.repos[0];
793
- const result = await listRepositoryChanges(handle.sandbox, repo.dir, bases.get(repo.slug) ?? session.baseBranch);
794
- if (!result.available) return unavailableWorkspaceChanges(session.sessionId);
780
+ function assembleWorkspaceChanges(workspacePath, inspected) {
781
+ if (inspected.length === 1) {
782
+ const { changes } = inspected[0];
783
+ if (!changes.available) return unavailableWorkspaceChanges(workspacePath);
795
784
  return {
796
- workspacePath: session.sessionId,
797
- ...result
785
+ workspacePath,
786
+ ...changes
798
787
  };
799
788
  }
800
- const groups = await Promise.all(layout.repos.map(async (repo) => {
801
- const result = await listRepositoryChanges(handle.sandbox, repo.dir, bases.get(repo.slug));
802
- return {
803
- slug: repo.slug,
804
- prefix: repo.prefix,
805
- ...result,
806
- changes: result.changes.map((change) => prefixChange(change, repo.prefix))
807
- };
789
+ const groups = inspected.map(({ repo, changes }) => ({
790
+ slug: repo.slug,
791
+ prefix: repo.prefix,
792
+ ...changes,
793
+ changes: changes.changes.map((change) => prefixChange(change, repo.prefix))
808
794
  }));
809
795
  const available = groups.filter((group) => group.available);
810
796
  const changes = available.flatMap((group) => group.changes).toSorted((a, b) => a.path.localeCompare(b.path));
@@ -814,90 +800,134 @@ async function listSessionWorkspaceChanges(session) {
814
800
  deletions: counted.reduce((sum, group) => sum + (group.deletions ?? 0), 0)
815
801
  } : {};
816
802
  return {
817
- workspacePath: session.sessionId,
803
+ workspacePath,
818
804
  available: available.length > 0,
819
805
  changes,
820
806
  ...totals,
821
807
  repositories: groups
822
808
  };
823
809
  }
824
- async function executeBoundedGitDiff(sandbox, args, allowExitOne = false) {
825
- return sandbox.executeCommand("sh", [
826
- "-c",
827
- BOUNDED_GIT_DIFF_SCRIPT,
828
- "mastracode-diff",
829
- allowExitOne ? "1" : "0",
830
- ...args
831
- ], { timeout: 3e4 });
810
+ async function listSessionWorkspaceChanges(session) {
811
+ const handle = await sessionSandbox(session);
812
+ if (!handle) return unavailableWorkspaceChanges(session.sessionId);
813
+ const { sandbox, layout, baseBranches } = handle;
814
+ const inspected = await Promise.all(layout.repos.map(async (repo) => {
815
+ const comparisonBase = await resolveSessionComparisonBase(sandbox, repo.dir, baseBranches.get(repo.slug) ?? session.baseBranch, "branch");
816
+ return {
817
+ repo,
818
+ changes: await listRepositoryChanges(sandbox, repo.dir, comparisonBase)
819
+ };
820
+ }));
821
+ return assembleWorkspaceChanges(session.sessionId, inspected);
832
822
  }
833
- function truncatePatch(patchBuffer) {
834
- const patch = patchBuffer.subarray(0, MAX_DIFF_BYTES).toString("utf8");
835
- const lastNewline = patch.lastIndexOf("\n");
836
- if (lastNewline >= 0) return patch.slice(0, lastNewline + 1);
837
- return patch.replace(/\uFFFD+$/, "");
823
+ const GIT_C_ESCAPES = {
824
+ "\"": "\\\"",
825
+ "\\": "\\\\",
826
+ "\x07": "\\a",
827
+ "\b": "\\b",
828
+ " ": "\\t",
829
+ "\n": "\\n",
830
+ "\v": "\\v",
831
+ "\f": "\\f",
832
+ "\r": "\\r"
833
+ };
834
+ /** A path as git writes it in a diff header: `core.quotePath=false` quotes only for quotes, backslashes and control characters. */
835
+ function gitHeaderPath(path) {
836
+ let escaped = "";
837
+ let quoted = false;
838
+ for (const char of path) {
839
+ const code = char.charCodeAt(0);
840
+ const escape = GIT_C_ESCAPES[char] ?? (code < 32 || code === 127 ? `\\${code.toString(8).padStart(3, "0")}` : "");
841
+ quoted ||= escape !== "";
842
+ escaped += escape || char;
843
+ }
844
+ return quoted ? `"${escaped}"` : path;
838
845
  }
839
- async function readSessionWorkspaceDiff(session, path, previousPath) {
840
- const safePath = assertRelativePath(path, "path");
841
- if (previousPath) assertRelativePath(previousPath, "previousPath");
846
+ function keyWorkspacePatches(patch, changes) {
847
+ const sections = patch.split(/^(?=diff --git )/m);
848
+ const budgetSpent = Buffer.byteLength(patch) > MAX_PATCH_SET_BYTES;
849
+ if (budgetSpent) sections.pop();
850
+ const sectionsByHeader = /* @__PURE__ */ new Map();
851
+ for (const section of sections) {
852
+ const headerEnd = section.indexOf("\n");
853
+ if (headerEnd >= 0) sectionsByHeader.set(section.slice(0, headerEnd), section);
854
+ }
855
+ const keyed = {
856
+ patches: /* @__PURE__ */ new Map(),
857
+ omitted: /* @__PURE__ */ new Map()
858
+ };
859
+ for (const change of changes) {
860
+ const header = `diff --git ${gitHeaderPath(`a/${change.previousPath ?? change.path}`)} ${gitHeaderPath(`b/${change.path}`)}`;
861
+ const section = sectionsByHeader.get(header);
862
+ if (section === void 0) {
863
+ if (budgetSpent) keyed.omitted.set(change.path, "total-too-large");
864
+ } else if (Buffer.byteLength(section) > MAX_DIFF_BYTES) keyed.omitted.set(change.path, "file-too-large");
865
+ else keyed.patches.set(change.path, section);
866
+ }
867
+ return keyed;
868
+ }
869
+ function prefixedEntries(prefix, entries) {
870
+ return Array.from(entries, ([path, value]) => [`${prefix}${path}`, value]);
871
+ }
872
+ function unavailableWorkspacePatches(workspacePath, scope) {
873
+ return {
874
+ ...unavailableWorkspaceChanges(workspacePath),
875
+ scope,
876
+ patches: {},
877
+ omittedPatches: {}
878
+ };
879
+ }
880
+ async function listSessionWorkspacePatches(session, scope) {
842
881
  const handle = await sessionSandbox(session);
843
- if (!handle) throw new Error("Session workspace is not available");
844
- const target = resolveLayoutPath(handle.layout, safePath, "path");
845
- const previous = previousPath ? resolveLayoutPath(handle.layout, previousPath, "previousPath") : void 0;
846
- if (previous && previous.repo !== target.repo) throw new Error("previousPath is not available in the repository of path");
847
- const workdir = target.repo.dir;
848
- const comparisonBase = await resolveSessionComparisonBase(handle.sandbox, workdir, handle.baseBranches.get(target.repo.slug));
849
- const pathspecs = previous ? [previous.relative, target.relative] : [target.relative];
850
- let result = await executeBoundedGitDiff(handle.sandbox, [
851
- "--literal-pathspecs",
852
- "-C",
853
- workdir,
854
- "diff",
855
- "--find-renames",
856
- "--no-ext-diff",
857
- "--no-color",
858
- "--unified=3",
859
- comparisonBase,
860
- "--",
861
- ...pathspecs
862
- ]);
863
- if (result.exitCode !== 0) throw new Error(result.stderr || "Unable to read workspace diff");
864
- if (!result.stdout) {
865
- const untracked = await handle.sandbox.executeCommand("git", [
866
- "--literal-pathspecs",
867
- "-C",
868
- workdir,
869
- "ls-files",
870
- "--others",
871
- "--exclude-standard",
872
- "--",
873
- target.relative
882
+ if (!handle) return unavailableWorkspacePatches(session.sessionId, scope);
883
+ const { sandbox, layout, baseBranches } = handle;
884
+ const inspected = await Promise.all(layout.repos.map(async (repo) => {
885
+ const comparisonBase = await resolveSessionComparisonBase(sandbox, repo.dir, baseBranches.get(repo.slug) ?? session.baseBranch, scope);
886
+ const [changes, patch] = await Promise.all([listRepositoryChanges(sandbox, repo.dir, comparisonBase), sandbox.executeCommand("sh", [
887
+ "-c",
888
+ BOUNDED_OUTPUT_SCRIPT,
889
+ "mastracode-bounded",
890
+ String(8388609),
891
+ "sh",
892
+ "-c",
893
+ WORKSPACE_DIFF_SCRIPT,
894
+ "mastracode-patch",
895
+ repo.dir,
896
+ comparisonBase,
897
+ ...PATCH_FORMAT
874
898
  ], {
875
899
  timeout: 3e4,
876
900
  maxRetainedBytes: Infinity
877
- });
878
- if (untracked.exitCode === 0 && untracked.stdout.trim()) {
879
- result = await executeBoundedGitDiff(handle.sandbox, [
880
- "-C",
881
- workdir,
882
- "diff",
883
- "--no-index",
884
- "--no-ext-diff",
885
- "--no-color",
886
- "--unified=3",
887
- "--",
888
- "/dev/null",
889
- target.relative
890
- ], true);
891
- if (result.exitCode !== 0 && result.exitCode !== 1) throw new Error(result.stderr || "Unable to read workspace diff");
901
+ })]);
902
+ if (patch.exitCode !== 0 && changes.changes.length > 0) {
903
+ const diffError = patch.stderr.trim().split("\n")[0] || `git diff exited with code ${patch.exitCode}`;
904
+ return {
905
+ repo,
906
+ changes,
907
+ keyed: {
908
+ patches: /* @__PURE__ */ new Map(),
909
+ omitted: /* @__PURE__ */ new Map()
910
+ },
911
+ diffError
912
+ };
892
913
  }
893
- }
894
- const patchBuffer = Buffer.from(result.stdout);
895
- const truncated = patchBuffer.length > MAX_DIFF_BYTES;
914
+ return {
915
+ repo,
916
+ changes,
917
+ keyed: keyWorkspacePatches(patch.stdout, changes.changes),
918
+ diffError: void 0
919
+ };
920
+ }));
921
+ const diffErrors = inspected.flatMap(({ repo, diffError }) => {
922
+ if (!diffError) return [];
923
+ return [layout.repos.length > 1 ? `${repo.slug}: ${diffError}` : diffError];
924
+ });
896
925
  return {
897
- workspacePath: session.sessionId,
898
- path: safePath,
899
- patch: truncated ? truncatePatch(patchBuffer) : result.stdout,
900
- truncated
926
+ ...assembleWorkspaceChanges(session.sessionId, inspected),
927
+ scope,
928
+ patches: Object.fromEntries(inspected.flatMap(({ repo, keyed }) => prefixedEntries(repo.prefix, keyed.patches))),
929
+ omittedPatches: Object.fromEntries(inspected.flatMap(({ repo, keyed }) => prefixedEntries(repo.prefix, keyed.omitted))),
930
+ ...diffErrors.length > 0 ? { diffError: diffErrors.join("\n") } : {}
901
931
  };
902
932
  }
903
933
  /**
@@ -990,23 +1020,21 @@ function buildFsRoutes(options = {}) {
990
1020
  }
991
1021
  }
992
1022
  }),
993
- registerApiRoute("/web/workspace/changes/diff", {
1023
+ registerApiRoute("/web/workspace/changes/patches", {
994
1024
  method: "GET",
995
1025
  requiresAuth: false,
996
1026
  handler: async (c) => {
997
1027
  const workspacePath = c.req.query("workspacePath");
998
- const path = c.req.query("path");
999
- const previousPath = c.req.query("previousPath");
1028
+ const scope = c.req.query("scope") ?? "branch";
1000
1029
  if (!workspacePath) return c.json({ error: "Missing required query param: workspacePath" }, 400);
1001
- if (!path) return c.json({ error: "Missing required query param: path" }, 400);
1030
+ if (!isWorkspaceChangesScope(scope)) return c.json({ error: "scope must be branch or uncommitted" }, 400);
1002
1031
  try {
1003
1032
  const session = await resolveAuthorizedSession(loose(c), sessionFs, workspacePath);
1004
- if (!session || !sessionFs) return c.json({ error: "Session workspace is not available" }, 403);
1005
- return c.json(await readSessionWorkspaceDiff(session, path, previousPath));
1033
+ if (!session || !sessionFs) return c.json(unavailableWorkspacePatches(workspacePath, scope));
1034
+ return c.json(await listSessionWorkspacePatches(session, scope));
1006
1035
  } catch (error) {
1007
1036
  const message = error instanceof Error ? error.message : String(error);
1008
- const status = message.includes("relative") || message.includes("escapes") || message.includes("not available") ? 403 : 500;
1009
- return c.json({ error: message }, status);
1037
+ return c.json({ error: message }, message.includes("not available") ? 403 : 500);
1010
1038
  }
1011
1039
  }
1012
1040
  }),
@@ -1065,6 +1093,6 @@ function buildFsRoutes(options = {}) {
1065
1093
  ];
1066
1094
  }
1067
1095
  //#endregion
1068
- export { buildFsRoutes, listArtifacts, listDirectory, listSessionRenderedPath, listSessionWorkspaceChanges, listSessionWorkspaceTree, listWorkspaceRenderedPath, parseWorkspaceChangeStats, parseWorkspaceChanges, parseWorkspaceTrackedChanges, readSessionWorkspaceDiff, readSessionWorkspaceFile, readWorkspaceFile, resolveCodebase, resolveFsRoot };
1096
+ export { buildFsRoutes, listArtifacts, listDirectory, listSessionRenderedPath, listSessionWorkspaceChanges, listSessionWorkspacePatches, listSessionWorkspaceTree, listWorkspaceRenderedPath, parseWorkspaceChangeStats, parseWorkspaceChanges, parseWorkspaceTrackedChanges, readSessionWorkspaceFile, readWorkspaceFile, resolveCodebase, resolveFsRoot };
1069
1097
 
1070
1098
  //# sourceMappingURL=fs.js.map