@mastra/factory 0.24.0-alpha.2 → 0.24.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.
Files changed (113) hide show
  1. package/README.md +23 -0
  2. package/dist/auth.d.ts +6 -0
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js +31 -9
  5. package/dist/auth.js.map +1 -1
  6. package/dist/factory.d.ts +8 -0
  7. package/dist/factory.d.ts.map +1 -1
  8. package/dist/factory.js +50 -31
  9. package/dist/factory.js.map +1 -1
  10. package/dist/integrations/github/default-rules.d.ts.map +1 -1
  11. package/dist/integrations/github/default-rules.js +1 -16
  12. package/dist/integrations/github/default-rules.js.map +1 -1
  13. package/dist/integrations/github/sandbox-release.d.ts +15 -3
  14. package/dist/integrations/github/sandbox-release.d.ts.map +1 -1
  15. package/dist/integrations/github/sandbox-release.js +20 -3
  16. package/dist/integrations/github/sandbox-release.js.map +1 -1
  17. package/dist/integrations/github/webhook.d.ts +4 -2
  18. package/dist/integrations/github/webhook.d.ts.map +1 -1
  19. package/dist/integrations/github/webhook.js +59 -36
  20. package/dist/integrations/github/webhook.js.map +1 -1
  21. package/dist/integrations/gitlab/api.d.ts +1 -1
  22. package/dist/integrations/gitlab/api.d.ts.map +1 -1
  23. package/dist/integrations/gitlab/api.js.map +1 -1
  24. package/dist/integrations/incidentio/intake.d.ts +1 -1
  25. package/dist/integrations/incidentio/intake.d.ts.map +1 -1
  26. package/dist/integrations/incidentio/intake.js.map +1 -1
  27. package/dist/integrations/platform/connect/routes.d.ts +1 -1
  28. package/dist/integrations/platform/connect/routes.d.ts.map +1 -1
  29. package/dist/integrations/platform/connect/routes.js.map +1 -1
  30. package/dist/integrations/platform/incidentio/integration.d.ts +1 -1
  31. package/dist/integrations/platform/incidentio/integration.d.ts.map +1 -1
  32. package/dist/integrations/platform/incidentio/integration.js.map +1 -1
  33. package/dist/integrations/platform/jira/integration.d.ts +1 -1
  34. package/dist/integrations/platform/jira/integration.d.ts.map +1 -1
  35. package/dist/integrations/platform/jira/integration.js.map +1 -1
  36. package/dist/integrations/platform/linear/event-worker.d.ts +1 -1
  37. package/dist/integrations/platform/linear/event-worker.d.ts.map +1 -1
  38. package/dist/integrations/platform/linear/event-worker.js.map +1 -1
  39. package/dist/mcp/disconnect.d.ts +10 -0
  40. package/dist/mcp/disconnect.d.ts.map +1 -0
  41. package/dist/mcp/disconnect.js +44 -0
  42. package/dist/mcp/disconnect.js.map +1 -0
  43. package/dist/mcp/routes.d.ts +46 -0
  44. package/dist/mcp/routes.d.ts.map +1 -0
  45. package/dist/mcp/routes.js +243 -0
  46. package/dist/mcp/routes.js.map +1 -0
  47. package/dist/review-verdict.d.ts +11 -0
  48. package/dist/review-verdict.d.ts.map +1 -1
  49. package/dist/review-verdict.js +16 -1
  50. package/dist/review-verdict.js.map +1 -1
  51. package/dist/routes/source-control-sessions.d.ts +7 -0
  52. package/dist/routes/source-control-sessions.d.ts.map +1 -1
  53. package/dist/routes/source-control-sessions.js +121 -3
  54. package/dist/routes/source-control-sessions.js.map +1 -1
  55. package/dist/routes/surface.d.ts +2 -2
  56. package/dist/routes/surface.d.ts.map +1 -1
  57. package/dist/routes/surface.js +5 -3
  58. package/dist/routes/surface.js.map +1 -1
  59. package/dist/routes/work-items.js +6 -6
  60. package/dist/rules/dispatcher.d.ts.map +1 -1
  61. package/dist/rules/dispatcher.js +12 -3
  62. package/dist/rules/dispatcher.js.map +1 -1
  63. package/dist/rules/types.d.ts +0 -4
  64. package/dist/rules/types.d.ts.map +1 -1
  65. package/dist/rules/types.js +1 -4
  66. package/dist/rules/types.js.map +1 -1
  67. package/dist/sandbox/session-retirement.d.ts +27 -3
  68. package/dist/sandbox/session-retirement.d.ts.map +1 -1
  69. package/dist/sandbox/session-retirement.js +158 -20
  70. package/dist/sandbox/session-retirement.js.map +1 -1
  71. package/dist/session/credential-policy.d.ts +1 -1
  72. package/dist/session/credential-policy.d.ts.map +1 -1
  73. package/dist/session/credential-policy.js.map +1 -1
  74. package/dist/session/factory-session.d.ts +1 -1
  75. package/dist/session/factory-session.d.ts.map +1 -1
  76. package/dist/session/factory-session.js.map +1 -1
  77. package/dist/session/work-item-repository.d.ts +1 -1
  78. package/dist/session/work-item-repository.d.ts.map +1 -1
  79. package/dist/session/work-item-repository.js.map +1 -1
  80. package/dist/storage/domains/audit/domain.js +1 -1
  81. package/dist/storage/domains/filesystem/base.d.ts +4 -0
  82. package/dist/storage/domains/filesystem/base.d.ts.map +1 -1
  83. package/dist/storage/domains/filesystem/base.js +5 -0
  84. package/dist/storage/domains/filesystem/base.js.map +1 -1
  85. package/dist/storage/domains/work-items/base.d.ts +7 -5
  86. package/dist/storage/domains/work-items/base.d.ts.map +1 -1
  87. package/dist/storage/domains/work-items/base.js +5 -5
  88. package/dist/storage/domains/work-items/base.js.map +1 -1
  89. package/dist/timing.d.ts +23 -0
  90. package/dist/timing.d.ts.map +1 -1
  91. package/dist/timing.js +85 -6
  92. package/dist/timing.js.map +1 -1
  93. package/dist/tools/annotations.d.ts +7 -0
  94. package/dist/tools/annotations.d.ts.map +1 -0
  95. package/dist/tools/annotations.js +102 -0
  96. package/dist/tools/annotations.js.map +1 -0
  97. package/dist/tools/reports.d.ts +11 -0
  98. package/dist/tools/reports.d.ts.map +1 -1
  99. package/dist/tools/reports.js +20 -1
  100. package/dist/tools/reports.js.map +1 -1
  101. package/dist/tools/run-watch.d.ts +1 -1
  102. package/dist/tools/run-watch.d.ts.map +1 -1
  103. package/dist/tools/run-watch.js.map +1 -1
  104. package/dist/tools/session-tools.d.ts +1 -1
  105. package/dist/tools/session-tools.d.ts.map +1 -1
  106. package/dist/tools/session-tools.js +3 -3
  107. package/dist/tools/session-tools.js.map +1 -1
  108. package/dist/tools/work-item-tools.js +2 -2
  109. package/dist/workspace.d.ts +1 -1
  110. package/dist/workspace.d.ts.map +1 -1
  111. package/dist/workspace.js +10 -4
  112. package/dist/workspace.js.map +1 -1
  113. package/package.json +14 -14
@@ -0,0 +1,44 @@
1
+ //#region src/mcp/disconnect.ts
2
+ /**
3
+ * Emits `close` on the `toReqRes` response when the HTTP client cancels the
4
+ * body, so the MCP transport aborts instead of writing into a closed stream.
5
+ * Mirrors `@mastra/hono`'s helper, which isn't exported.
6
+ */
7
+ function propagateClientDisconnect(fetchResponse, res) {
8
+ const upstream = fetchResponse.body;
9
+ if (!upstream) return fetchResponse;
10
+ let disconnected = false;
11
+ const disconnect = () => {
12
+ if (disconnected) return;
13
+ disconnected = true;
14
+ try {
15
+ res.emit("close");
16
+ } catch {}
17
+ reader.read().then(function drain({ done }) {
18
+ return done ? void 0 : reader.read().then(drain);
19
+ }, () => {});
20
+ };
21
+ const reader = upstream.getReader();
22
+ const body = new ReadableStream({
23
+ async pull(controller) {
24
+ const { done, value } = await reader.read();
25
+ if (done) {
26
+ controller.close();
27
+ return;
28
+ }
29
+ controller.enqueue(value);
30
+ },
31
+ cancel() {
32
+ disconnect();
33
+ }
34
+ });
35
+ return new Response(body, {
36
+ status: fetchResponse.status,
37
+ statusText: fetchResponse.statusText,
38
+ headers: fetchResponse.headers
39
+ });
40
+ }
41
+ //#endregion
42
+ export { propagateClientDisconnect };
43
+
44
+ //# sourceMappingURL=disconnect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"disconnect.js","names":[],"sources":["../../src/mcp/disconnect.ts"],"sourcesContent":["/**\n * Emits `close` on the `toReqRes` response when the HTTP client cancels the\n * body, so the MCP transport aborts instead of writing into a closed stream.\n * Mirrors `@mastra/hono`'s helper, which isn't exported.\n */\nexport function propagateClientDisconnect(\n fetchResponse: Response,\n res: { emit: (event: string) => void; destroy?: () => void },\n): Response {\n const upstream = fetchResponse.body;\n if (!upstream) return fetchResponse;\n\n let disconnected = false;\n const disconnect = () => {\n if (disconnected) return;\n disconnected = true;\n try {\n res.emit('close');\n } catch {\n // Already torn down - the transport has nothing left to clean up.\n }\n // Deliberately *not* cancelling or destroying the bridge stream here. `fetch-to-node`\n // buffers writes and flushes them from a cork timer; tearing its controller down leaves\n // that pending flush to enqueue into a closed controller, which throws an unhandled\n // ERR_INVALID_STATE from a timer callback - the very crash this guards against.\n // Emitting `close` aborts the transport, which ends the response, so the bridge closes\n // its own controller in the right order once buffered data has drained.\n void reader.read().then(\n function drain({ done }): unknown {\n return done ? undefined : reader.read().then(drain);\n },\n () => {},\n );\n };\n\n const reader = upstream.getReader();\n const body = new ReadableStream<Uint8Array>({\n async pull(controller) {\n const { done, value } = await reader.read();\n if (done) {\n controller.close();\n return;\n }\n controller.enqueue(value);\n },\n cancel() {\n disconnect();\n },\n });\n\n return new Response(body, {\n status: fetchResponse.status,\n statusText: fetchResponse.statusText,\n headers: fetchResponse.headers,\n });\n}\n"],"mappings":";;;;;;AAKA,SAAgB,0BACd,eACA,KACU;CACV,MAAM,WAAW,cAAc;CAC/B,IAAI,CAAC,UAAU,OAAO;CAEtB,IAAI,eAAe;CACnB,MAAM,mBAAmB;EACvB,IAAI,cAAc;EAClB,eAAe;EACf,IAAI;GACF,IAAI,KAAK,OAAO;EAClB,QAAQ,CAER;EAOA,OAAY,KAAK,CAAC,CAAC,KACjB,SAAS,MAAM,EAAE,QAAiB;GAChC,OAAO,OAAO,KAAA,IAAY,OAAO,KAAK,CAAC,CAAC,KAAK,KAAK;EACpD,SACM,CAAC,CACT;CACF;CAEA,MAAM,SAAS,SAAS,UAAU;CAClC,MAAM,OAAO,IAAI,eAA2B;EAC1C,MAAM,KAAK,YAAY;GACrB,MAAM,EAAE,MAAM,UAAU,MAAM,OAAO,KAAK;GAC1C,IAAI,MAAM;IACR,WAAW,MAAM;IACjB;GACF;GACA,WAAW,QAAQ,KAAK;EAC1B;EACA,SAAS;GACP,WAAW;EACb;CACF,CAAC;CAED,OAAO,IAAI,SAAS,MAAM;EACxB,QAAQ,cAAc;EACtB,YAAY,cAAc;EAC1B,SAAS,cAAc;CACzB,CAAC;AACH"}
@@ -0,0 +1,46 @@
1
+ import type { ApiRoute } from '@mastra/core/server';
2
+ import type { Tool } from '@mastra/core/tools';
3
+ import type { RouteAuth } from '../routes/route.js';
4
+ import type { FactoryProjectsStorage } from '../storage/domains/projects/base.js';
5
+ import type { SourceControlStorageHandle } from '../storage/domains/source-control/base.js';
6
+ import type { FactoryToolDependencies, FactoryToolScope } from '../tools/index.js';
7
+ export declare const FACTORY_MCP_PATH = "/web/factory/projects/:projectId/mcp";
8
+ export interface FactoryMcpConfig {
9
+ /** Serve the MCP endpoint. Default `true`; it is never served without an auth provider. */
10
+ enabled?: boolean;
11
+ /**
12
+ * OAuth authorization servers advertised in the protected-resource metadata
13
+ * (RFC 9728) so clients like ChatGPT know where to sign in. Defaults to the
14
+ * `MASTRA_FACTORY_MCP_AUTHORIZATION_SERVER` env var, then to Mastra Platform's
15
+ * AuthKit when the Factory uses Platform auth. The endpoint isn't served
16
+ * without one, since clients would have nowhere to sign in.
17
+ */
18
+ authorizationServers?: string[];
19
+ }
20
+ /** Mastra Platform's WorkOS AuthKit, which issues tokens Platform's `/auth/verify` accepts. */
21
+ export declare const PLATFORM_MCP_AUTHORIZATION_SERVER = "https://auth-kit.mastra.ai";
22
+ export declare function resolveMcpAuthorizationServers(providerName: string | undefined, config: FactoryMcpConfig | undefined, env?: Record<string, string | undefined>): string[];
23
+ export interface FactoryMcpRoutesDeps {
24
+ auth: RouteAuth;
25
+ publicOrigin: string;
26
+ authorizationServers: string[];
27
+ projects: Pick<FactoryProjectsStorage, 'get'>;
28
+ /** Source-control handles a connector session can be filed under, tried in order. */
29
+ sourceControls: readonly SourceControlStorageHandle[];
30
+ tools: FactoryToolDependencies;
31
+ version?: string;
32
+ }
33
+ /** The connector session a user's MCP client acts from: one per user and Factory, stable across requests. */
34
+ export declare function connectorSessionId(orgId: string, userId: string, factoryProjectId: string): string;
35
+ /** The tools a signed-in person's MCP client sees for one Factory. */
36
+ export declare function createFactoryMcpTools(scope: FactoryToolScope, deps: FactoryMcpRoutesDeps, options?: {
37
+ persistedSession?: boolean;
38
+ }): Record<string, Tool<any, any, any, any>>;
39
+ /**
40
+ * The Factory's MCP endpoint: Streamable HTTP at
41
+ * `/web/factory/projects/:projectId/mcp`, plus its RFC 9728 protected-resource
42
+ * metadata. Callers sign in with OAuth through the Factory's auth provider and
43
+ * get the tools a web UI session would, under the same permissions.
44
+ */
45
+ export declare function buildFactoryMcpRoutes(deps: FactoryMcpRoutesDeps): ApiRoute[];
46
+ //# sourceMappingURL=routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/mcp/routes.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAGpD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAC;AAQ/C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAGpD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qCAAqC,CAAC;AAClF,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,2CAA2C,CAAC;AAG5F,OAAO,KAAK,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAGnF,eAAO,MAAM,gBAAgB,yCAAyC,CAAC;AASvE,MAAM,WAAW,gBAAgB;IAC/B,2FAA2F;IAC3F,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;OAMG;IACH,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;CACjC;AAED,+FAA+F;AAC/F,eAAO,MAAM,iCAAiC,+BAA+B,CAAC;AAE9E,wBAAgB,8BAA8B,CAC5C,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,MAAM,EAAE,gBAAgB,GAAG,SAAS,EACpC,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GACpD,MAAM,EAAE,CAMV;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,SAAS,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B,QAAQ,EAAE,IAAI,CAAC,sBAAsB,EAAE,KAAK,CAAC,CAAC;IAC9C,qFAAqF;IACrF,cAAc,EAAE,SAAS,0BAA0B,EAAE,CAAC;IACtD,KAAK,EAAE,uBAAuB,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,6GAA6G;AAC7G,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAGlG;AAyFD,sEAAsE;AACtE,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,gBAAgB,EACvB,IAAI,EAAE,oBAAoB,EAC1B,OAAO,GAAE;IAAE,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAAO,GAC3C,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAe1C;AAwED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,oBAAoB,GAAG,QAAQ,EAAE,CAoB5E"}
@@ -0,0 +1,243 @@
1
+ import "../platform-auth-host.js";
2
+ import { getFactoryAuthUser } from "../auth.js";
3
+ import { FactorySourceSessionResolutionError, ensureFactorySourceSession } from "../session/factory-session.js";
4
+ import { auditActorProfile } from "../storage/domains/audit/base.js";
5
+ import { FACTORY_TOOL_ANNOTATIONS } from "../tools/annotations.js";
6
+ import { createFactoryTools } from "../tools/index.js";
7
+ import { propagateClientDisconnect } from "./disconnect.js";
8
+ import { registerApiRoute } from "@mastra/core/server";
9
+ import { createHash } from "crypto";
10
+ import { z } from "zod";
11
+ import { createTool } from "@mastra/core/tools";
12
+ import { MCPServer } from "@mastra/mcp";
13
+ import { toFetchResponse, toReqRes } from "fetch-to-node";
14
+ //#region src/mcp/routes.ts
15
+ const FACTORY_MCP_PATH = "/web/factory/projects/:projectId/mcp";
16
+ const PROTECTED_RESOURCE_PREFIX = "/.well-known/oauth-protected-resource";
17
+ /**
18
+ * Tools an MCP client never gets. `factory_message_origin` answers the session
19
+ * that started this one; a connector session is started by a person, not a session.
20
+ */
21
+ const EXCLUDED_TOOLS = /* @__PURE__ */ new Set(["factory_message_origin"]);
22
+ /** Mastra Platform's WorkOS AuthKit, which issues tokens Platform's `/auth/verify` accepts. */
23
+ const PLATFORM_MCP_AUTHORIZATION_SERVER = "https://auth-kit.mastra.ai";
24
+ function resolveMcpAuthorizationServers(providerName, config, env = process.env) {
25
+ if (config?.authorizationServers?.length) return config.authorizationServers;
26
+ const fromEnv = env.MASTRA_FACTORY_MCP_AUTHORIZATION_SERVER?.trim();
27
+ if (fromEnv) return [fromEnv];
28
+ if (providerName === "mastra-studio") return [PLATFORM_MCP_AUTHORIZATION_SERVER];
29
+ return [];
30
+ }
31
+ /** The connector session a user's MCP client acts from: one per user and Factory, stable across requests. */
32
+ function connectorSessionId(orgId, userId, factoryProjectId) {
33
+ const hex = createHash("sha256").update(`mcp\0${orgId}\0${userId}\0${factoryProjectId}`).digest("hex");
34
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
35
+ }
36
+ /**
37
+ * Get or create the private user session behind a person's MCP client, so
38
+ * session-scoped tools (start/signal sessions, follow work items) have a real
39
+ * session to act from. A Factory without a connected repository has nowhere to
40
+ * file one; its tools still run against the stable id.
41
+ */
42
+ async function ensureConnectorSession(deps, args) {
43
+ const sessionId = connectorSessionId(args.orgId, args.userId, args.factoryProjectId);
44
+ if (await deps.tools.sessions.getBySessionId(sessionId)) return {
45
+ sessionId,
46
+ persisted: true
47
+ };
48
+ for (const sourceControl of deps.sourceControls) try {
49
+ await ensureFactorySourceSession({
50
+ sourceControl,
51
+ orgId: args.orgId,
52
+ factoryProjectId: args.factoryProjectId,
53
+ branch: `factory/mcp-${sessionId.slice(0, 8)}`,
54
+ attributeToUserId: args.userId,
55
+ sessionId,
56
+ visibility: "private"
57
+ });
58
+ return {
59
+ sessionId,
60
+ persisted: true
61
+ };
62
+ } catch (error) {
63
+ if (!(error instanceof FactorySourceSessionResolutionError)) throw error;
64
+ }
65
+ return {
66
+ sessionId,
67
+ persisted: false
68
+ };
69
+ }
70
+ function workItemUrl(publicOrigin, factoryProjectId, workItemId) {
71
+ return `${publicOrigin}/factory/${encodeURIComponent(factoryProjectId)}/work-items/${encodeURIComponent(workItemId)}`;
72
+ }
73
+ /**
74
+ * `search` and `fetch`, the two tools ChatGPT's connectors and deep research
75
+ * look for, answered from the Factory's work items.
76
+ */
77
+ function createSearchFetchTools(scope, deps, inspect) {
78
+ const project = {
79
+ orgId: scope.orgId,
80
+ factoryProjectId: scope.factoryProjectId
81
+ };
82
+ return {
83
+ search: createTool({
84
+ id: "search",
85
+ description: "Search this Factory’s work items by title or id. Returns matching items with ids to pass to `fetch`.",
86
+ inputSchema: z.object({ query: z.string().describe("Words to look for in work item titles.") }),
87
+ execute: async ({ query }) => {
88
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
89
+ return { results: (await deps.tools.workItems.list(project)).filter((item) => {
90
+ const haystack = `${item.id} ${item.title}`.toLowerCase();
91
+ return terms.every((term) => haystack.includes(term));
92
+ }).slice(0, 20).map((item) => ({
93
+ id: item.id,
94
+ title: item.title,
95
+ url: workItemUrl(deps.publicOrigin, scope.factoryProjectId, item.id)
96
+ })) };
97
+ }
98
+ }),
99
+ fetch: createTool({
100
+ id: "fetch",
101
+ description: "Fetch everything the Factory knows about one work item, by the id `search` returned.",
102
+ inputSchema: z.object({ id: z.string().describe("Work item id.") }),
103
+ execute: async ({ id }, context) => {
104
+ if (!inspect?.execute) throw new Error("Work item details are unavailable.");
105
+ const details = await inspect.execute({ workItem: id }, context);
106
+ return {
107
+ id,
108
+ title: details.item?.title ?? id,
109
+ text: JSON.stringify(details, null, 2),
110
+ url: workItemUrl(deps.publicOrigin, scope.factoryProjectId, id),
111
+ metadata: { factoryProjectId: scope.factoryProjectId }
112
+ };
113
+ }
114
+ })
115
+ };
116
+ }
117
+ /** The tools a signed-in person's MCP client sees for one Factory. */
118
+ function createFactoryMcpTools(scope, deps, options = {}) {
119
+ const factoryTools = createFactoryTools({
120
+ scope,
121
+ deps: deps.tools
122
+ });
123
+ const tools = {};
124
+ for (const [name, tool] of Object.entries(factoryTools)) {
125
+ if (EXCLUDED_TOOLS.has(name)) continue;
126
+ if (name === "factory_start_session" && options.persistedSession === false) continue;
127
+ tools[name] = tool;
128
+ }
129
+ Object.assign(tools, createSearchFetchTools(scope, deps, factoryTools.factory_inspect_work_item));
130
+ for (const [name, tool] of Object.entries(tools)) {
131
+ const annotations = FACTORY_TOOL_ANNOTATIONS[name];
132
+ if (annotations) tool.mcp = {
133
+ ...tool.mcp,
134
+ annotations
135
+ };
136
+ }
137
+ return tools;
138
+ }
139
+ function resourceMetadataUrl(deps, mcpPath) {
140
+ return `${deps.publicOrigin}${PROTECTED_RESOURCE_PREFIX}${mcpPath}`;
141
+ }
142
+ function unauthorized(deps, mcpPath, error = "unauthorized") {
143
+ return new Response(JSON.stringify({ error }), {
144
+ status: 401,
145
+ headers: {
146
+ "Content-Type": "application/json",
147
+ "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl(deps, mcpPath)}"`
148
+ }
149
+ });
150
+ }
151
+ async function handleMcp(deps, c) {
152
+ const factoryProjectId = c.req.param("projectId") ?? "";
153
+ const mcpPath = new URL(c.req.url).pathname;
154
+ await deps.auth.ensureUser(c);
155
+ const tenant = deps.auth.tenant(c);
156
+ if (!tenant) return unauthorized(deps, mcpPath);
157
+ if (!tenant.orgId) return c.json({ error: "organization_required" }, 403);
158
+ const project = await deps.projects.get({
159
+ orgId: tenant.orgId,
160
+ id: factoryProjectId
161
+ });
162
+ if (!project) return c.json({ error: "not_found" }, 404);
163
+ const { sessionId, persisted } = await ensureConnectorSession(deps, {
164
+ orgId: tenant.orgId,
165
+ userId: tenant.userId,
166
+ factoryProjectId
167
+ });
168
+ const actorProfile = auditActorProfile(getFactoryAuthUser(c));
169
+ const scope = {
170
+ orgId: tenant.orgId,
171
+ factoryProjectId,
172
+ userId: tenant.userId,
173
+ sessionId,
174
+ resourceId: sessionId,
175
+ threadId: sessionId,
176
+ kind: "user",
177
+ interactive: true,
178
+ ...actorProfile ? { actorProfile } : {}
179
+ };
180
+ const server = new MCPServer({
181
+ id: `factory-${factoryProjectId}`,
182
+ name: `${project.name ?? "Mastra"} Factory`,
183
+ version: deps.version ?? "1.0.0",
184
+ description: "Manage work items, sessions and decisions in a Mastra Factory.",
185
+ tools: createFactoryMcpTools(scope, deps, { persistedSession: persisted })
186
+ });
187
+ const { req, res } = toReqRes(c.req.raw);
188
+ const done = server.startHTTP({
189
+ url: new URL(c.req.url),
190
+ httpPath: mcpPath,
191
+ req,
192
+ res
193
+ }).catch(() => {
194
+ if (!res.headersSent) {
195
+ res.writeHead(500, { "Content-Type": "application/json" });
196
+ res.end(JSON.stringify({
197
+ jsonrpc: "2.0",
198
+ error: {
199
+ code: -32603,
200
+ message: "Internal server error"
201
+ },
202
+ id: null
203
+ }));
204
+ }
205
+ }).finally(() => void server.close());
206
+ const response = propagateClientDisconnect(await toFetchResponse(res), res);
207
+ if (res.writableEnded) await done;
208
+ return response;
209
+ }
210
+ /**
211
+ * The Factory's MCP endpoint: Streamable HTTP at
212
+ * `/web/factory/projects/:projectId/mcp`, plus its RFC 9728 protected-resource
213
+ * metadata. Callers sign in with OAuth through the Factory's auth provider and
214
+ * get the tools a web UI session would, under the same permissions.
215
+ */
216
+ function buildFactoryMcpRoutes(deps) {
217
+ const handler = (c) => handleMcp(deps, c);
218
+ return [...[
219
+ "POST",
220
+ "GET",
221
+ "DELETE"
222
+ ].map((method) => registerApiRoute(FACTORY_MCP_PATH, {
223
+ method,
224
+ requiresAuth: false,
225
+ handler
226
+ })), registerApiRoute(`${PROTECTED_RESOURCE_PREFIX}${FACTORY_MCP_PATH}`, {
227
+ method: "GET",
228
+ requiresAuth: false,
229
+ handler: (c) => {
230
+ const mcpPath = new URL(c.req.url).pathname.slice(37);
231
+ return c.json({
232
+ resource: `${deps.publicOrigin}${mcpPath}`,
233
+ authorization_servers: deps.authorizationServers,
234
+ bearer_methods_supported: ["header"],
235
+ resource_name: "Mastra Factory"
236
+ });
237
+ }
238
+ })];
239
+ }
240
+ //#endregion
241
+ export { FACTORY_MCP_PATH, PLATFORM_MCP_AUTHORIZATION_SERVER, buildFactoryMcpRoutes, connectorSessionId, createFactoryMcpTools, resolveMcpAuthorizationServers };
242
+
243
+ //# sourceMappingURL=routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.js","names":[],"sources":["../../src/mcp/routes.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\n\nimport type { ApiRoute } from '@mastra/core/server';\nimport { registerApiRoute } from '@mastra/core/server';\nimport { createTool } from '@mastra/core/tools';\nimport type { Tool } from '@mastra/core/tools';\nimport { MCPServer } from '@mastra/mcp';\nimport { toFetchResponse, toReqRes } from 'fetch-to-node';\nimport type { Context } from 'hono';\nimport { z } from 'zod';\n\nimport { getFactoryAuthUser } from '../auth.js';\nimport { PLATFORM_AUTH_PROVIDER } from '../platform-auth-host.js';\nimport type { RouteAuth } from '../routes/route.js';\nimport { ensureFactorySourceSession, FactorySourceSessionResolutionError } from '../session/factory-session.js';\nimport { auditActorProfile } from '../storage/domains/audit/base.js';\nimport type { FactoryProjectsStorage } from '../storage/domains/projects/base.js';\nimport type { SourceControlStorageHandle } from '../storage/domains/source-control/base.js';\nimport { FACTORY_TOOL_ANNOTATIONS } from '../tools/annotations.js';\nimport { createFactoryTools } from '../tools/index.js';\nimport type { FactoryToolDependencies, FactoryToolScope } from '../tools/index.js';\nimport { propagateClientDisconnect } from './disconnect.js';\n\nexport const FACTORY_MCP_PATH = '/web/factory/projects/:projectId/mcp';\nconst PROTECTED_RESOURCE_PREFIX = '/.well-known/oauth-protected-resource';\n\n/**\n * Tools an MCP client never gets. `factory_message_origin` answers the session\n * that started this one; a connector session is started by a person, not a session.\n */\nconst EXCLUDED_TOOLS = new Set(['factory_message_origin']);\n\nexport interface FactoryMcpConfig {\n /** Serve the MCP endpoint. Default `true`; it is never served without an auth provider. */\n enabled?: boolean;\n /**\n * OAuth authorization servers advertised in the protected-resource metadata\n * (RFC 9728) so clients like ChatGPT know where to sign in. Defaults to the\n * `MASTRA_FACTORY_MCP_AUTHORIZATION_SERVER` env var, then to Mastra Platform's\n * AuthKit when the Factory uses Platform auth. The endpoint isn't served\n * without one, since clients would have nowhere to sign in.\n */\n authorizationServers?: string[];\n}\n\n/** Mastra Platform's WorkOS AuthKit, which issues tokens Platform's `/auth/verify` accepts. */\nexport const PLATFORM_MCP_AUTHORIZATION_SERVER = 'https://auth-kit.mastra.ai';\n\nexport function resolveMcpAuthorizationServers(\n providerName: string | undefined,\n config: FactoryMcpConfig | undefined,\n env: Record<string, string | undefined> = process.env,\n): string[] {\n if (config?.authorizationServers?.length) return config.authorizationServers;\n const fromEnv = env.MASTRA_FACTORY_MCP_AUTHORIZATION_SERVER?.trim();\n if (fromEnv) return [fromEnv];\n if (providerName === PLATFORM_AUTH_PROVIDER) return [PLATFORM_MCP_AUTHORIZATION_SERVER];\n return [];\n}\n\nexport interface FactoryMcpRoutesDeps {\n auth: RouteAuth;\n publicOrigin: string;\n authorizationServers: string[];\n projects: Pick<FactoryProjectsStorage, 'get'>;\n /** Source-control handles a connector session can be filed under, tried in order. */\n sourceControls: readonly SourceControlStorageHandle[];\n tools: FactoryToolDependencies;\n version?: string;\n}\n\n/** The connector session a user's MCP client acts from: one per user and Factory, stable across requests. */\nexport function connectorSessionId(orgId: string, userId: string, factoryProjectId: string): string {\n const hex = createHash('sha256').update(`mcp\\0${orgId}\\0${userId}\\0${factoryProjectId}`).digest('hex');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;\n}\n\n/**\n * Get or create the private user session behind a person's MCP client, so\n * session-scoped tools (start/signal sessions, follow work items) have a real\n * session to act from. A Factory without a connected repository has nowhere to\n * file one; its tools still run against the stable id.\n */\nasync function ensureConnectorSession(\n deps: FactoryMcpRoutesDeps,\n args: { orgId: string; userId: string; factoryProjectId: string },\n): Promise<{ sessionId: string; persisted: boolean }> {\n const sessionId = connectorSessionId(args.orgId, args.userId, args.factoryProjectId);\n if (await deps.tools.sessions.getBySessionId(sessionId)) return { sessionId, persisted: true };\n for (const sourceControl of deps.sourceControls) {\n try {\n await ensureFactorySourceSession({\n sourceControl,\n orgId: args.orgId,\n factoryProjectId: args.factoryProjectId,\n branch: `factory/mcp-${sessionId.slice(0, 8)}`,\n attributeToUserId: args.userId,\n sessionId,\n visibility: 'private',\n });\n return { sessionId, persisted: true };\n } catch (error) {\n if (!(error instanceof FactorySourceSessionResolutionError)) throw error;\n }\n }\n return { sessionId, persisted: false };\n}\n\nfunction workItemUrl(publicOrigin: string, factoryProjectId: string, workItemId: string): string {\n return `${publicOrigin}/factory/${encodeURIComponent(factoryProjectId)}/work-items/${encodeURIComponent(workItemId)}`;\n}\n\n/**\n * `search` and `fetch`, the two tools ChatGPT's connectors and deep research\n * look for, answered from the Factory's work items.\n */\nfunction createSearchFetchTools(\n scope: FactoryToolScope,\n deps: FactoryMcpRoutesDeps,\n inspect: Tool<any, any, any, any> | undefined,\n): Record<string, Tool<any, any, any, any>> {\n const project = { orgId: scope.orgId, factoryProjectId: scope.factoryProjectId };\n return {\n search: createTool({\n id: 'search',\n description:\n 'Search this Factory’s work items by title or id. Returns matching items with ids to pass to `fetch`.',\n inputSchema: z.object({ query: z.string().describe('Words to look for in work item titles.') }),\n execute: async ({ query }) => {\n const terms = query.toLowerCase().split(/\\s+/).filter(Boolean);\n const items = await deps.tools.workItems.list(project);\n const results = items\n .filter(item => {\n const haystack = `${item.id} ${item.title}`.toLowerCase();\n return terms.every(term => haystack.includes(term));\n })\n .slice(0, 20)\n .map(item => ({\n id: item.id,\n title: item.title,\n url: workItemUrl(deps.publicOrigin, scope.factoryProjectId, item.id),\n }));\n return { results };\n },\n }),\n fetch: createTool({\n id: 'fetch',\n description: 'Fetch everything the Factory knows about one work item, by the id `search` returned.',\n inputSchema: z.object({ id: z.string().describe('Work item id.') }),\n execute: async ({ id }, context) => {\n if (!inspect?.execute) throw new Error('Work item details are unavailable.');\n const details = (await inspect.execute({ workItem: id }, context)) as { item?: { title?: string } };\n return {\n id,\n title: details.item?.title ?? id,\n text: JSON.stringify(details, null, 2),\n url: workItemUrl(deps.publicOrigin, scope.factoryProjectId, id),\n metadata: { factoryProjectId: scope.factoryProjectId },\n };\n },\n }),\n };\n}\n\n/** The tools a signed-in person's MCP client sees for one Factory. */\nexport function createFactoryMcpTools(\n scope: FactoryToolScope,\n deps: FactoryMcpRoutesDeps,\n options: { persistedSession?: boolean } = {},\n): Record<string, Tool<any, any, any, any>> {\n const factoryTools = createFactoryTools({ scope, deps: deps.tools }) as Record<string, Tool<any, any, any, any>>;\n const tools: Record<string, Tool<any, any, any, any>> = {};\n for (const [name, tool] of Object.entries(factoryTools)) {\n if (EXCLUDED_TOOLS.has(name)) continue;\n // Starting a sub-session needs a real parent session row to file it under.\n if (name === 'factory_start_session' && options.persistedSession === false) continue;\n tools[name] = tool;\n }\n Object.assign(tools, createSearchFetchTools(scope, deps, factoryTools.factory_inspect_work_item));\n for (const [name, tool] of Object.entries(tools)) {\n const annotations = FACTORY_TOOL_ANNOTATIONS[name];\n if (annotations) tool.mcp = { ...tool.mcp, annotations };\n }\n return tools;\n}\n\nfunction resourceMetadataUrl(deps: FactoryMcpRoutesDeps, mcpPath: string): string {\n return `${deps.publicOrigin}${PROTECTED_RESOURCE_PREFIX}${mcpPath}`;\n}\n\nfunction unauthorized(deps: FactoryMcpRoutesDeps, mcpPath: string, error = 'unauthorized'): Response {\n return new Response(JSON.stringify({ error }), {\n status: 401,\n headers: {\n 'Content-Type': 'application/json',\n 'WWW-Authenticate': `Bearer resource_metadata=\"${resourceMetadataUrl(deps, mcpPath)}\"`,\n },\n });\n}\n\nasync function handleMcp(deps: FactoryMcpRoutesDeps, c: Context): Promise<Response> {\n const factoryProjectId = c.req.param('projectId') ?? '';\n const mcpPath = new URL(c.req.url).pathname;\n\n await deps.auth.ensureUser(c);\n const tenant = deps.auth.tenant(c);\n if (!tenant) return unauthorized(deps, mcpPath);\n if (!tenant.orgId) return c.json({ error: 'organization_required' }, 403);\n\n const project = await deps.projects.get({ orgId: tenant.orgId, id: factoryProjectId });\n if (!project) return c.json({ error: 'not_found' }, 404);\n\n const { sessionId, persisted } = await ensureConnectorSession(deps, {\n orgId: tenant.orgId,\n userId: tenant.userId,\n factoryProjectId,\n });\n const actorProfile = auditActorProfile(getFactoryAuthUser(c));\n const scope: FactoryToolScope = {\n orgId: tenant.orgId,\n factoryProjectId,\n userId: tenant.userId,\n sessionId,\n resourceId: sessionId,\n threadId: sessionId,\n kind: 'user',\n // A signed-in person is driving the client: the same reach as a web UI turn.\n interactive: true,\n ...(actorProfile ? { actorProfile } : {}),\n };\n\n const server = new MCPServer({\n id: `factory-${factoryProjectId}`,\n name: `${project.name ?? 'Mastra'} Factory`,\n version: deps.version ?? '1.0.0',\n description: 'Manage work items, sessions and decisions in a Mastra Factory.',\n tools: createFactoryMcpTools(scope, deps, { persistedSession: persisted }),\n });\n\n const { req, res } = toReqRes(c.req.raw);\n const done = server\n .startHTTP({ url: new URL(c.req.url), httpPath: mcpPath, req, res })\n .catch(() => {\n if (!res.headersSent) {\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null }),\n );\n }\n })\n .finally(() => void server.close());\n const response = propagateClientDisconnect(await toFetchResponse(res), res);\n if (res.writableEnded) await done;\n return response;\n}\n\n/**\n * The Factory's MCP endpoint: Streamable HTTP at\n * `/web/factory/projects/:projectId/mcp`, plus its RFC 9728 protected-resource\n * metadata. Callers sign in with OAuth through the Factory's auth provider and\n * get the tools a web UI session would, under the same permissions.\n */\nexport function buildFactoryMcpRoutes(deps: FactoryMcpRoutesDeps): ApiRoute[] {\n const handler = (c: unknown) => handleMcp(deps, c as Context);\n return [\n ...(['POST', 'GET', 'DELETE'] as const).map(method =>\n registerApiRoute(FACTORY_MCP_PATH, { method, requiresAuth: false, handler }),\n ),\n registerApiRoute(`${PROTECTED_RESOURCE_PREFIX}${FACTORY_MCP_PATH}`, {\n method: 'GET',\n requiresAuth: false,\n handler: c => {\n const mcpPath = new URL(c.req.url).pathname.slice(PROTECTED_RESOURCE_PREFIX.length);\n return c.json({\n resource: `${deps.publicOrigin}${mcpPath}`,\n authorization_servers: deps.authorizationServers,\n bearer_methods_supported: ['header'],\n resource_name: 'Mastra Factory',\n });\n },\n }),\n ];\n}\n"],"mappings":"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"}
@@ -3,4 +3,15 @@
3
3
  * regardless of the markdown it is wrapped in (`**Verdict:** request changes`, `# Verdict: ...`).
4
4
  */
5
5
  export declare function normalizedVerdictLine(body: string | undefined): string | undefined;
6
+ /**
7
+ * Detects the `factory-review` handoff verdict in a comment body.
8
+ *
9
+ * GitHub forbids an app from reviewing a pull request it authored, so on
10
+ * Factory-authored PRs the review skill falls back to posting its verdict as a
11
+ * plain comment. That comment is the only signal the authoring agent gets, so
12
+ * it has to be readable back out. The skill's handoff contract puts the verdict
13
+ * on the first line (`Verdict: request changes`), so only that line is
14
+ * inspected — a verdict quoted later in the findings must not count.
15
+ */
16
+ export declare function requestsChangesVerdict(body: string | undefined): boolean;
6
17
  //# sourceMappingURL=review-verdict.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"review-verdict.d.ts","sourceRoot":"","sources":["../src/review-verdict.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAUlF"}
1
+ {"version":3,"file":"review-verdict.d.ts","sourceRoot":"","sources":["../src/review-verdict.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAUlF;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAOxE"}
@@ -8,7 +8,22 @@ function normalizedVerdictLine(body) {
8
8
  if (!firstLine) return void 0;
9
9
  return firstLine.replaceAll(/[*_`#>\s]+/g, " ").trim().toLowerCase();
10
10
  }
11
+ /**
12
+ * Detects the `factory-review` handoff verdict in a comment body.
13
+ *
14
+ * GitHub forbids an app from reviewing a pull request it authored, so on
15
+ * Factory-authored PRs the review skill falls back to posting its verdict as a
16
+ * plain comment. That comment is the only signal the authoring agent gets, so
17
+ * it has to be readable back out. The skill's handoff contract puts the verdict
18
+ * on the first line (`Verdict: request changes`), so only that line is
19
+ * inspected — a verdict quoted later in the findings must not count.
20
+ */
21
+ function requestsChangesVerdict(body) {
22
+ const normalized = normalizedVerdictLine(body);
23
+ if (!normalized) return false;
24
+ return /^verdict: ?(request changes|changes requested)$/.test(normalized);
25
+ }
11
26
  //#endregion
12
- export { normalizedVerdictLine };
27
+ export { normalizedVerdictLine, requestsChangesVerdict };
13
28
 
14
29
  //# sourceMappingURL=review-verdict.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"review-verdict.js","names":[],"sources":["../src/review-verdict.ts"],"sourcesContent":["/**\n * Normalizes the first non-empty line of a review body so its `Verdict:` line can be read\n * regardless of the markdown it is wrapped in (`**Verdict:** request changes`, `# Verdict: ...`).\n */\nexport function normalizedVerdictLine(body: string | undefined): string | undefined {\n const firstLine = body\n ?.split('\\n')\n .map(line => line.trim())\n .find(line => line.length > 0);\n if (!firstLine) return undefined;\n return firstLine\n .replaceAll(/[*_`#>\\s]+/g, ' ')\n .trim()\n .toLowerCase();\n}\n"],"mappings":";;;;;AAIA,SAAgB,sBAAsB,MAA8C;CAClF,MAAM,YAAY,MACd,MAAM,IAAI,CAAC,CACZ,KAAI,SAAQ,KAAK,KAAK,CAAC,CAAC,CACxB,MAAK,SAAQ,KAAK,SAAS,CAAC;CAC/B,IAAI,CAAC,WAAW,OAAO,KAAA;CACvB,OAAO,UACJ,WAAW,eAAe,GAAG,CAAC,CAC9B,KAAK,CAAC,CACN,YAAY;AACjB"}
1
+ {"version":3,"file":"review-verdict.js","names":[],"sources":["../src/review-verdict.ts"],"sourcesContent":["/**\n * Normalizes the first non-empty line of a review body so its `Verdict:` line can be read\n * regardless of the markdown it is wrapped in (`**Verdict:** request changes`, `# Verdict: ...`).\n */\nexport function normalizedVerdictLine(body: string | undefined): string | undefined {\n const firstLine = body\n ?.split('\\n')\n .map(line => line.trim())\n .find(line => line.length > 0);\n if (!firstLine) return undefined;\n return firstLine\n .replaceAll(/[*_`#>\\s]+/g, ' ')\n .trim()\n .toLowerCase();\n}\n\n/**\n * Detects the `factory-review` handoff verdict in a comment body.\n *\n * GitHub forbids an app from reviewing a pull request it authored, so on\n * Factory-authored PRs the review skill falls back to posting its verdict as a\n * plain comment. That comment is the only signal the authoring agent gets, so\n * it has to be readable back out. The skill's handoff contract puts the verdict\n * on the first line (`Verdict: request changes`), so only that line is\n * inspected — a verdict quoted later in the findings must not count.\n */\nexport function requestsChangesVerdict(body: string | undefined): boolean {\n // Tolerate the markdown the skill wraps the line in (`**Verdict: ...**`).\n const normalized = normalizedVerdictLine(body);\n if (!normalized) return false;\n // Match the verdict exactly so negated phrasings (\"Verdict: do not request\n // changes\") cannot wake the author.\n return /^verdict: ?(request changes|changes requested)$/.test(normalized);\n}\n"],"mappings":";;;;;AAIA,SAAgB,sBAAsB,MAA8C;CAClF,MAAM,YAAY,MACd,MAAM,IAAI,CAAC,CACZ,KAAI,SAAQ,KAAK,KAAK,CAAC,CAAC,CACxB,MAAK,SAAQ,KAAK,SAAS,CAAC;CAC/B,IAAI,CAAC,WAAW,OAAO,KAAA;CACvB,OAAO,UACJ,WAAW,eAAe,GAAG,CAAC,CAC9B,KAAK,CAAC,CACN,YAAY;AACjB;;;;;;;;;;;AAYA,SAAgB,uBAAuB,MAAmC;CAExE,MAAM,aAAa,sBAAsB,IAAI;CAC7C,IAAI,CAAC,YAAY,OAAO;CAGxB,OAAO,kDAAkD,KAAK,UAAU;AAC1E"}
@@ -1,6 +1,7 @@
1
1
  import type { MountedMastraCode } from '@mastra/code-sdk';
2
2
  import type { ApiRoute, IUserProvider } from '@mastra/core/server';
3
3
  import type { SessionRetirementCoordinator } from '../sandbox/session-retirement.js';
4
+ import type { FilesystemStorage } from '../storage/domains/filesystem/base.js';
4
5
  import type { MemorySettingsStorage } from '../storage/domains/memory-settings/base.js';
5
6
  import type { FactoryProjectsStorage } from '../storage/domains/projects/base.js';
6
7
  import type { SourceControlStorageHandle } from '../storage/domains/source-control/base.js';
@@ -16,6 +17,12 @@ export interface SourceControlSessionRoutesOptions {
16
17
  controller?: MountedMastraCode['controller'];
17
18
  memorySettings: Pick<MemorySettingsStorage, 'get'>;
18
19
  sessionRetirement?: SessionRetirementCoordinator;
20
+ /** Whether the host sandbox can recreate a session's sandbox (false for local). */
21
+ sandboxRecreateSupported?: boolean;
22
+ /** Turn-end file listings, cleared on recreate so the panel stops showing the old sandbox's files. */
23
+ filesystem?: Pick<FilesystemStorage, 'deleteResourceFiles'>;
24
+ /** How long recreate waits for an aborted run to settle before destroying. */
25
+ abortSettleTimeoutMs?: number;
19
26
  workItems?: Pick<WorkItemsStorage, 'clearSessionReferences'>;
20
27
  }
21
28
  export declare function buildSourceControlSessionRoutes(options: SourceControlSessionRoutesOptions): ApiRoute[];
@@ -1 +1 @@
1
- {"version":3,"file":"source-control-sessions.d.ts","sourceRoot":"","sources":["../../src/routes/source-control-sessions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAG1D,OAAO,KAAK,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAOnE,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,kCAAkC,CAAC;AAGrF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4CAA4C,CAAC;AACxF,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qCAAqC,CAAC;AAClF,OAAO,KAAK,EAGV,0BAA0B,EAC3B,MAAM,2CAA2C,CAAC;AACnD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uCAAuC,CAAC;AAC9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAQ5C,eAAO,MAAM,0BAA0B,kBAAkB,CAAC;AAY1D,KAAK,wBAAwB,GAAG,IAAI,CAAC,aAAa,EAAE,SAAS,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC,CAAC;AAE1G,MAAM,WAAW,iCAAiC;IAChD,IAAI,EAAE,SAAS,CAAC;IAChB,cAAc,EAAE,SAAS,0BAA0B,EAAE,CAAC;IACtD,QAAQ,EAAE,IAAI,CAAC,sBAAsB,EAAE,KAAK,CAAC,CAAC;IAC9C,KAAK,CAAC,EAAE,wBAAwB,CAAC;IACjC,UAAU,CAAC,EAAE,iBAAiB,CAAC,YAAY,CAAC,CAAC;IAC7C,cAAc,EAAE,IAAI,CAAC,qBAAqB,EAAE,KAAK,CAAC,CAAC;IACnD,iBAAiB,CAAC,EAAE,4BAA4B,CAAC;IACjD,SAAS,CAAC,EAAE,IAAI,CAAC,gBAAgB,EAAE,wBAAwB,CAAC,CAAC;CAC9D;AA0RD,wBAAgB,+BAA+B,CAAC,OAAO,EAAE,iCAAiC,GAAG,QAAQ,EAAE,CA6ItG"}
1
+ {"version":3,"file":"source-control-sessions.d.ts","sourceRoot":"","sources":["../../src/routes/source-control-sessions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAG1D,OAAO,KAAK,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAOnE,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,kCAAkC,CAAC;AAIrF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4CAA4C,CAAC;AACxF,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qCAAqC,CAAC;AAClF,OAAO,KAAK,EAGV,0BAA0B,EAC3B,MAAM,2CAA2C,CAAC;AACnD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uCAAuC,CAAC;AAC9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAQ5C,eAAO,MAAM,0BAA0B,kBAAkB,CAAC;AAY1D,KAAK,wBAAwB,GAAG,IAAI,CAAC,aAAa,EAAE,SAAS,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC,CAAC;AAE1G,MAAM,WAAW,iCAAiC;IAChD,IAAI,EAAE,SAAS,CAAC;IAChB,cAAc,EAAE,SAAS,0BAA0B,EAAE,CAAC;IACtD,QAAQ,EAAE,IAAI,CAAC,sBAAsB,EAAE,KAAK,CAAC,CAAC;IAC9C,KAAK,CAAC,EAAE,wBAAwB,CAAC;IACjC,UAAU,CAAC,EAAE,iBAAiB,CAAC,YAAY,CAAC,CAAC;IAC7C,cAAc,EAAE,IAAI,CAAC,qBAAqB,EAAE,KAAK,CAAC,CAAC;IACnD,iBAAiB,CAAC,EAAE,4BAA4B,CAAC;IACjD,mFAAmF;IACnF,wBAAwB,CAAC,EAAE,OAAO,CAAC;IACnC,sGAAsG;IACtG,UAAU,CAAC,EAAE,IAAI,CAAC,iBAAiB,EAAE,qBAAqB,CAAC,CAAC;IAC5D,8EAA8E;IAC9E,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,SAAS,CAAC,EAAE,IAAI,CAAC,gBAAgB,EAAE,wBAAwB,CAAC,CAAC;CAC9D;AAoXD,wBAAgB,+BAA+B,CAAC,OAAO,EAAE,iCAAiC,GAAG,QAAQ,EAAE,CAsMtG"}
@@ -2,6 +2,7 @@ import { isValidGitRef } from "../sandbox/git-ref.js";
2
2
  import { waitForPendingFilesystemCapture } from "../session/filesystem-capture.js";
3
3
  import { normalizeSessionTitle } from "../session/session-title.js";
4
4
  import { reclaimDeletedSessionSandbox } from "../integrations/github/sandbox-release.js";
5
+ import { seedSessionOrg } from "../session/org-seed.js";
5
6
  import { RequestContext } from "@mastra/core/request-context";
6
7
  import { registerApiRoute } from "@mastra/core/server";
7
8
  import { UniqueViolationError } from "@mastra/core/storage";
@@ -15,6 +16,48 @@ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3
15
16
  const MAX_SESSION_OWNER_PROFILES = 100;
16
17
  const MAX_SESSION_OWNER_PROFILE_CACHE_ENTRIES = 500;
17
18
  const SESSION_OWNER_PROFILE_TTL_MS = 5 * 6e4;
19
+ const ABORT_SETTLE_TIMEOUT_MS = 5e3;
20
+ const ABORT_SETTLE_POLL_MS = 50;
21
+ /**
22
+ * Who may manage a session's sandbox: its owner, or an org admin. Private
23
+ * sessions stay owner-only and look missing to everyone else, matching the
24
+ * session GET. The admin check is a provider call, so owners skip it.
25
+ */
26
+ async function sessionManagePermission(c, auth, tenant, session) {
27
+ if (session.orgId !== tenant.orgId) return "not-found";
28
+ const isOwner = session.userId === tenant.userId;
29
+ if (session.visibility === "private" && !isOwner) return "not-found";
30
+ if (isOwner) return "allowed";
31
+ return await auth.isOrganizationAdmin(c, session.orgId) ? "allowed" : "forbidden";
32
+ }
33
+ function canRecreateSandbox(options) {
34
+ return Boolean(options.sessionRetirement) && options.sandboxRecreateSupported === true;
35
+ }
36
+ function withCapabilities(session, recreateSandbox) {
37
+ return {
38
+ ...session,
39
+ capabilities: { recreateSandbox }
40
+ };
41
+ }
42
+ /** Abort the session's active run and wait (bounded) for it to stop. Returns whether a run was aborted. */
43
+ async function abortActiveRun(controller, sessionId, settleTimeoutMs) {
44
+ if (!controller) return false;
45
+ try {
46
+ const live = await controller.getSessionByResource(sessionId);
47
+ if (!live?.stream.isActive()) return false;
48
+ live.abort();
49
+ const deadline = Date.now() + settleTimeoutMs;
50
+ while (live.stream.isActive() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, ABORT_SETTLE_POLL_MS));
51
+ if (live.stream.isActive()) console.warn("[Factory Sessions] Aborted run did not settle before sandbox recreate", { sessionId });
52
+ return true;
53
+ } catch (error) {
54
+ console.warn("[Factory Sessions] Failed to abort the active run before sandbox recreate", {
55
+ sessionId,
56
+ error
57
+ });
58
+ return false;
59
+ }
60
+ }
18
61
  async function resolveOrgTenant(c, auth) {
19
62
  await auth.ensureUser(c);
20
63
  const tenant = auth.tenant(c);
@@ -66,6 +109,32 @@ async function resolveSession(sourceControls, sessionId) {
66
109
  if (matches.length > 1) throw new Error("Factory session exists in multiple source-control providers.");
67
110
  return matches[0] ?? null;
68
111
  }
112
+ /**
113
+ * Start the live conversation for a user session on its own thread (thread id === session id). Agent-controller
114
+ * routes only act on started sessions, so the first prompt the page sends right after creation always lands on the
115
+ * thread the page reads, with the org and Factory address already in state. Get-or-create, so retried or repeated
116
+ * creates are safe.
117
+ */
118
+ async function startUserSessionConversation(controller, session) {
119
+ if (!controller) return;
120
+ const requestContext = new RequestContext();
121
+ requestContext.set("user", {
122
+ workosId: session.userId,
123
+ organizationId: session.orgId
124
+ });
125
+ const live = await controller.createSession({
126
+ id: session.sessionId,
127
+ resourceId: session.sessionId,
128
+ threadId: session.sessionId,
129
+ ownerId: session.userId,
130
+ requestContext
131
+ });
132
+ await seedSessionOrg(live, session.orgId);
133
+ if (session.factoryProjectId) await live.state.set({
134
+ factoryProjectId: session.factoryProjectId,
135
+ ...session.projectRepositoryId ? { projectRepositoryId: session.projectRepositoryId } : {}
136
+ });
137
+ }
69
138
  function isJsonObject(value) {
70
139
  return typeof value === "object" && value !== null && !Array.isArray(value);
71
140
  }
@@ -193,7 +262,8 @@ function projectSessionRoutes(path, options, resolveOwnerProfiles) {
193
262
  const existing = await project.sourceControl.sessions.getBySessionId(sessionId);
194
263
  if (existing) {
195
264
  if (existing.projectRepositoryId !== project.project.id || existing.orgId !== orgId || existing.userId !== userId || existing.branch !== branch) return c.json({ error: "Session ID conflict" }, 409);
196
- return c.json({ session: existing });
265
+ await startUserSessionConversation(options.controller, existing);
266
+ return c.json({ session: withCapabilities(existing, canRecreateSandbox(options)) });
197
267
  }
198
268
  }
199
269
  const session = await project.sourceControl.sessions.create({
@@ -213,7 +283,8 @@ function projectSessionRoutes(path, options, resolveOwnerProfiles) {
213
283
  return conflict;
214
284
  });
215
285
  if (requestedSessionId && (session.sessionId !== sessionId || session.projectRepositoryId !== project.project.id || session.orgId !== orgId || session.userId !== userId || session.branch !== branch)) return c.json({ error: "Session ID conflict" }, 409);
216
- return c.json({ session });
286
+ await startUserSessionConversation(options.controller, session);
287
+ return c.json({ session: withCapabilities(session, canRecreateSandbox(options)) });
217
288
  }
218
289
  })];
219
290
  }
@@ -254,7 +325,54 @@ function buildSourceControlSessionRoutes(options) {
254
325
  const session = (await resolveSession(options.sourceControls, c.req.param("sessionId")))?.session;
255
326
  if (!session || session.orgId !== resolved.tenant.orgId || session.visibility === "private" && session.userId !== resolved.tenant.userId) return c.json({ error: "Session not found" }, 404);
256
327
  const owners = await resolveOwnerProfiles([session.userId]);
257
- return c.json({ session: withOwnerProfile(session, owners) });
328
+ const recreateSandbox = canRecreateSandbox(options) && await sessionManagePermission(loose(c), options.auth, resolved.tenant, session) === "allowed";
329
+ return c.json({ session: withCapabilities(withOwnerProfile(session, owners), recreateSandbox) });
330
+ }
331
+ }),
332
+ registerApiRoute("/web/user-sessions/:sessionId/sandbox/recreate", {
333
+ method: "POST",
334
+ requiresAuth: false,
335
+ handler: async (c) => {
336
+ const resolved = await resolveOrgTenant(loose(c), options.auth);
337
+ if ("response" in resolved) return resolved.response;
338
+ const match = await resolveSession(options.sourceControls, c.req.param("sessionId"));
339
+ const session = match?.session;
340
+ if (!match || !session) return c.json({ error: "Session not found" }, 404);
341
+ const permission = await sessionManagePermission(loose(c), options.auth, resolved.tenant, session);
342
+ if (permission === "not-found") return c.json({ error: "Session not found" }, 404);
343
+ if (permission === "forbidden") return c.json({ error: "Only the session's owner or an organization admin can recreate its sandbox." }, 403);
344
+ const sessionRetirement = options.sessionRetirement;
345
+ if (!sessionRetirement || !canRecreateSandbox(options)) return c.json({ error: "Recreating sandboxes is not available on this server." }, 503);
346
+ const aborted = await abortActiveRun(options.controller, session.sessionId, options.abortSettleTimeoutMs ?? ABORT_SETTLE_TIMEOUT_MS);
347
+ await waitForPendingFilesystemCapture(session.sessionId).catch((error) => {
348
+ console.warn("[Factory Sessions] Failed to drain filesystem capture before sandbox recreate", {
349
+ sessionId: session.sessionId,
350
+ error
351
+ });
352
+ });
353
+ try {
354
+ if (await sessionRetirement.recreateSessionSandbox({
355
+ sourceControl: match.sourceControl,
356
+ orgId: session.orgId,
357
+ sessionId: session.sessionId
358
+ }) === "not-found") return c.json({ error: "Session not found" }, 404);
359
+ await options.filesystem?.deleteResourceFiles({ resourceId: session.sessionId }).catch((error) => {
360
+ console.warn("[Factory Sessions] Failed to clear file listings after sandbox recreate", {
361
+ sessionId: session.sessionId,
362
+ error
363
+ });
364
+ });
365
+ return c.json({
366
+ recreated: true,
367
+ aborted
368
+ });
369
+ } catch (error) {
370
+ console.error("[Factory Sessions] Failed to recreate session sandbox", {
371
+ sessionId: session.sessionId,
372
+ error
373
+ });
374
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 500);
375
+ }
258
376
  }
259
377
  }),
260
378
  registerApiRoute("/web/user-sessions/:sessionId", {