@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.
- package/README.md +23 -0
- package/dist/auth.d.ts +6 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +31 -9
- package/dist/auth.js.map +1 -1
- package/dist/factory.d.ts +8 -0
- package/dist/factory.d.ts.map +1 -1
- package/dist/factory.js +50 -31
- package/dist/factory.js.map +1 -1
- package/dist/integrations/github/default-rules.d.ts.map +1 -1
- package/dist/integrations/github/default-rules.js +1 -16
- package/dist/integrations/github/default-rules.js.map +1 -1
- package/dist/integrations/github/sandbox-release.d.ts +15 -3
- package/dist/integrations/github/sandbox-release.d.ts.map +1 -1
- package/dist/integrations/github/sandbox-release.js +20 -3
- package/dist/integrations/github/sandbox-release.js.map +1 -1
- package/dist/integrations/github/webhook.d.ts +4 -2
- package/dist/integrations/github/webhook.d.ts.map +1 -1
- package/dist/integrations/github/webhook.js +59 -36
- package/dist/integrations/github/webhook.js.map +1 -1
- package/dist/integrations/gitlab/api.d.ts +1 -1
- package/dist/integrations/gitlab/api.d.ts.map +1 -1
- package/dist/integrations/gitlab/api.js.map +1 -1
- package/dist/integrations/incidentio/intake.d.ts +1 -1
- package/dist/integrations/incidentio/intake.d.ts.map +1 -1
- package/dist/integrations/incidentio/intake.js.map +1 -1
- package/dist/integrations/platform/connect/routes.d.ts +1 -1
- package/dist/integrations/platform/connect/routes.d.ts.map +1 -1
- package/dist/integrations/platform/connect/routes.js.map +1 -1
- package/dist/integrations/platform/incidentio/integration.d.ts +1 -1
- package/dist/integrations/platform/incidentio/integration.d.ts.map +1 -1
- package/dist/integrations/platform/incidentio/integration.js.map +1 -1
- package/dist/integrations/platform/jira/integration.d.ts +1 -1
- package/dist/integrations/platform/jira/integration.d.ts.map +1 -1
- package/dist/integrations/platform/jira/integration.js.map +1 -1
- package/dist/integrations/platform/linear/event-worker.d.ts +1 -1
- package/dist/integrations/platform/linear/event-worker.d.ts.map +1 -1
- package/dist/integrations/platform/linear/event-worker.js.map +1 -1
- package/dist/mcp/disconnect.d.ts +10 -0
- package/dist/mcp/disconnect.d.ts.map +1 -0
- package/dist/mcp/disconnect.js +44 -0
- package/dist/mcp/disconnect.js.map +1 -0
- package/dist/mcp/routes.d.ts +46 -0
- package/dist/mcp/routes.d.ts.map +1 -0
- package/dist/mcp/routes.js +243 -0
- package/dist/mcp/routes.js.map +1 -0
- package/dist/review-verdict.d.ts +11 -0
- package/dist/review-verdict.d.ts.map +1 -1
- package/dist/review-verdict.js +16 -1
- package/dist/review-verdict.js.map +1 -1
- package/dist/routes/source-control-sessions.d.ts +7 -0
- package/dist/routes/source-control-sessions.d.ts.map +1 -1
- package/dist/routes/source-control-sessions.js +121 -3
- package/dist/routes/source-control-sessions.js.map +1 -1
- package/dist/routes/surface.d.ts +2 -2
- package/dist/routes/surface.d.ts.map +1 -1
- package/dist/routes/surface.js +5 -3
- package/dist/routes/surface.js.map +1 -1
- package/dist/routes/work-items.js +6 -6
- package/dist/rules/dispatcher.d.ts.map +1 -1
- package/dist/rules/dispatcher.js +12 -3
- package/dist/rules/dispatcher.js.map +1 -1
- package/dist/rules/types.d.ts +0 -4
- package/dist/rules/types.d.ts.map +1 -1
- package/dist/rules/types.js +1 -4
- package/dist/rules/types.js.map +1 -1
- package/dist/sandbox/session-retirement.d.ts +27 -3
- package/dist/sandbox/session-retirement.d.ts.map +1 -1
- package/dist/sandbox/session-retirement.js +158 -20
- package/dist/sandbox/session-retirement.js.map +1 -1
- package/dist/session/credential-policy.d.ts +1 -1
- package/dist/session/credential-policy.d.ts.map +1 -1
- package/dist/session/credential-policy.js.map +1 -1
- package/dist/session/factory-session.d.ts +1 -1
- package/dist/session/factory-session.d.ts.map +1 -1
- package/dist/session/factory-session.js.map +1 -1
- package/dist/session/work-item-repository.d.ts +1 -1
- package/dist/session/work-item-repository.d.ts.map +1 -1
- package/dist/session/work-item-repository.js.map +1 -1
- package/dist/storage/domains/audit/domain.js +1 -1
- package/dist/storage/domains/filesystem/base.d.ts +4 -0
- package/dist/storage/domains/filesystem/base.d.ts.map +1 -1
- package/dist/storage/domains/filesystem/base.js +5 -0
- package/dist/storage/domains/filesystem/base.js.map +1 -1
- package/dist/storage/domains/work-items/base.d.ts +7 -5
- package/dist/storage/domains/work-items/base.d.ts.map +1 -1
- package/dist/storage/domains/work-items/base.js +5 -5
- package/dist/storage/domains/work-items/base.js.map +1 -1
- package/dist/timing.d.ts +23 -0
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +85 -6
- package/dist/timing.js.map +1 -1
- package/dist/tools/annotations.d.ts +7 -0
- package/dist/tools/annotations.d.ts.map +1 -0
- package/dist/tools/annotations.js +102 -0
- package/dist/tools/annotations.js.map +1 -0
- package/dist/tools/reports.d.ts +11 -0
- package/dist/tools/reports.d.ts.map +1 -1
- package/dist/tools/reports.js +20 -1
- package/dist/tools/reports.js.map +1 -1
- package/dist/tools/run-watch.d.ts +1 -1
- package/dist/tools/run-watch.d.ts.map +1 -1
- package/dist/tools/run-watch.js.map +1 -1
- package/dist/tools/session-tools.d.ts +1 -1
- package/dist/tools/session-tools.d.ts.map +1 -1
- package/dist/tools/session-tools.js +3 -3
- package/dist/tools/session-tools.js.map +1 -1
- package/dist/tools/work-item-tools.js +2 -2
- package/dist/workspace.d.ts +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +10 -4
- package/dist/workspace.js.map +1 -1
- 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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"}
|
package/dist/review-verdict.d.ts
CHANGED
|
@@ -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"}
|
package/dist/review-verdict.js
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", {
|