Unified · Split
| @@ -8,6 +8,7 @@ import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; · 7 unmodified lines | |||
| 8 | import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | 8 | import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; |
| 9 | 9 | ||
| 10 | import { resolveSecretKey } from "./src/config"; | 10 | import { resolveSecretKey } from "./src/config"; |
| 11 | import { getSelfHostAppsSubsystem } from "./src/apps"; | ||
| 11 | 12 | ||
| 12 | // --------------------------------------------------------------------------- | 13 | // --------------------------------------------------------------------------- |
| 13 | // Single source of truth for the self-hosted app's plugin list. | 14 | // Single source of truth for the self-hosted app's plugin list. |
| @@ -36,6 +37,7 @@ export default defineExecutorConfig({ · 22 unmodified lines | |||
| 36 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), | 37 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 37 | graphqlHttpPlugin(), | 38 | graphqlHttpPlugin(), |
| 38 | toolkitsPlugin({ activeToolkitSlug }), | 39 | toolkitsPlugin({ activeToolkitSlug }), |
| 40 | getSelfHostAppsSubsystem().plugin, | ||
| 39 | // First writable secret provider -> the default for `secrets.set`. | 41 | // First writable secret provider -> the default for `secrets.set`. |
| 40 | encryptedSecretsPlugin({ key: resolveSecretKey() }), | 42 | encryptedSecretsPlugin({ key: resolveSecretKey() }), |
| 41 | ] as const, | 43 | ] as const, |
| @@ -26,6 +26,7 @@ · 25 unmodified lines | |||
| 26 | "@executor-js/execution": "workspace:*", | 26 | "@executor-js/execution": "workspace:*", |
| 27 | "@executor-js/fumadb": "workspace:*", | 27 | "@executor-js/fumadb": "workspace:*", |
| 28 | "@executor-js/host-mcp": "workspace:*", | 28 | "@executor-js/host-mcp": "workspace:*", |
| 29 | "@executor-js/plugin-apps": "workspace:*", | ||
| 29 | "@executor-js/plugin-encrypted-secrets": "workspace:*", | 30 | "@executor-js/plugin-encrypted-secrets": "workspace:*", |
| 30 | "@executor-js/plugin-google": "workspace:*", | 31 | "@executor-js/plugin-google": "workspace:*", |
| 31 | "@executor-js/plugin-graphql": "workspace:*", | 32 | "@executor-js/plugin-graphql": "workspace:*", |
| @@ -48,6 +49,7 @@ · 16 unmodified lines | |||
| 48 | }, | 49 | }, |
| 49 | "devDependencies": { | 50 | "devDependencies": { |
| 50 | "@effect/vitest": "catalog:", | 51 | "@effect/vitest": "catalog:", |
| 52 | "@executor-js/emulate": "^0.13.2", | ||
| 51 | "@executor-js/vite-plugin": "workspace:*", | 53 | "@executor-js/vite-plugin": "workspace:*", |
| 52 | "@tailwindcss/vite": "catalog:", | 54 | "@tailwindcss/vite": "catalog:", |
| 53 | "@tanstack/router-plugin": "^1.167.12", | 55 | "@tanstack/router-plugin": "^1.167.12", |
| @@ -22,6 +22,8 @@ import { makeSelfHostMcpSeams } from "./mcp"; · 21 unmodified lines | |||
| 22 | import { selfHostPlugins } from "./plugins"; | 22 | import { selfHostPlugins } from "./plugins"; |
| 23 | import { ErrorCaptureLive } from "./observability"; | 23 | import { ErrorCaptureLive } from "./observability"; |
| 24 | import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login"; | 24 | import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login"; |
| 25 | import { closeSelfHostAppsSubsystem } from "./apps"; | ||
| 26 | import { makeSelfHostAppsSyncRoute } from "./apps-route"; | ||
| 25 | 27 | ||
| 26 | // =========================================================================== | 28 | // =========================================================================== |
| 27 | // The self-hosted Executor app, as ONE `ExecutorApp.make` call. | 29 | // The self-hosted Executor app, as ONE `ExecutorApp.make` call. |
| @@ -119,6 +121,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 91 unmodified lines | |||
| 119 | makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), | 121 | makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), |
| 120 | // Public system API: /api/health + /api/setup-status (unauthenticated). | 122 | // Public system API: /api/health + /api/setup-status (unauthenticated). |
| 121 | makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), | 123 | makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), |
| 124 | makeSelfHostAppsSyncRoute({ identity: identityLayer, db: dbHandle }), | ||
| 122 | // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). | 125 | // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). |
| 123 | HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), | 126 | HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), |
| 124 | ], | 127 | ], |
| @@ -139,6 +142,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 14 unmodified lines | |||
| 139 | toWebHandler, | 142 | toWebHandler, |
| 140 | betterAuth, | 143 | betterAuth, |
| 141 | closeDb: async () => { | 144 | closeDb: async () => { |
| 145 | await closeSelfHostAppsSubsystem(); | ||
| 142 | await mcp.close(); | 146 | await mcp.close(); |
| 143 | await dbHandle.close(); | 147 | await dbHandle.close(); |
| 144 | }, | 148 | }, |
| @@ -0,0 +1,130 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | ConnectionName, | ||
| 5 | IntegrationSlug, | ||
| 6 | ToolAddress, | ||
| 7 | isToolResult, | ||
| 8 | type ConnectionRef, | ||
| 9 | type ExecuteError, | ||
| 10 | type Owner, | ||
| 11 | type PluginCtx, | ||
| 12 | } from "@executor-js/sdk"; | ||
| 13 | import { | ||
| 14 | BindingError, | ||
| 15 | type ClientResolver, | ||
| 16 | type ConnectionCandidate, | ||
| 17 | } from "@executor-js/plugin-apps/api"; | ||
| 18 | |||
| 19 | const parseConnectionAddress = (address: string): ConnectionRef | null => { | ||
| 20 | const parts = address.split("."); | ||
| 21 | if (parts.length !== 4 || parts[0] !== "tools") return null; | ||
| 22 | const [, integration, owner, name] = parts; | ||
| 23 | if (!integration || !name) return null; | ||
| 24 | if (owner !== "org" && owner !== "user") return null; | ||
| 25 | return { | ||
| 26 | owner: owner as Owner, | ||
| 27 | integration: IntegrationSlug.make(integration), | ||
| 28 | name: ConnectionName.make(name), | ||
| 29 | }; | ||
| 30 | }; | ||
| 31 | |||
| 32 | const bindingError = (input: { | ||
| 33 | readonly integration: string; | ||
| 34 | readonly connection?: string; | ||
| 35 | readonly message: string; | ||
| 36 | readonly cause?: unknown; | ||
| 37 | }): BindingError => | ||
| 38 | new BindingError({ | ||
| 39 | role: input.integration, | ||
| 40 | integration: input.integration, | ||
| 41 | requestedConnection: input.connection, | ||
| 42 | message: input.message, | ||
| 43 | }); | ||
| 44 | |||
| 45 | const executeErrorMessage = (cause: ExecuteError): string => | ||
| 46 | typeof cause.message === "string" && cause.message.length > 0 ? cause.message : cause._tag; | ||
| 47 | |||
| 48 | const toCandidate = (connection: { | ||
| 49 | readonly address: unknown; | ||
| 50 | readonly integration: unknown; | ||
| 51 | readonly name?: unknown; | ||
| 52 | readonly owner?: unknown; | ||
| 53 | }): ConnectionCandidate => ({ | ||
| 54 | address: String(connection.address), | ||
| 55 | integration: String(connection.integration), | ||
| 56 | ...(connection.name !== undefined ? { name: String(connection.name) } : {}), | ||
| 57 | ...(connection.owner !== undefined ? { owner: String(connection.owner) } : {}), | ||
| 58 | }); | ||
| 59 | |||
| 60 | export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): ClientResolver => ({ | ||
| 61 | listConnections: ({ integration }) => | ||
| 62 | input.ctx.connections.list({ integration: IntegrationSlug.make(integration) }).pipe( | ||
| 63 | Effect.map((connections) => connections.map(toCandidate)), | ||
| 64 | Effect.mapError((cause) => | ||
| 65 | bindingError({ | ||
| 66 | integration, | ||
| 67 | message: `failed to list ${integration} connections`, | ||
| 68 | cause, | ||
| 69 | }), | ||
| 70 | ), | ||
| 71 | ), | ||
| 72 | |||
| 73 | resolveConnection: ({ connection }) => | ||
| 74 | Effect.gen(function* () { | ||
| 75 | const ref = parseConnectionAddress(connection); | ||
| 76 | if (!ref) return null; | ||
| 77 | const row = yield* input.ctx.connections.get(ref).pipe( | ||
| 78 | Effect.mapError((cause) => | ||
| 79 | bindingError({ | ||
| 80 | integration: String(ref.integration), | ||
| 81 | connection, | ||
| 82 | message: `failed to resolve connection ${connection}`, | ||
| 83 | cause, | ||
| 84 | }), | ||
| 85 | ), | ||
| 86 | ); | ||
| 87 | return row ? toCandidate(row) : null; | ||
| 88 | }), | ||
| 89 | |||
| 90 | call: ({ integration, connection, path, args }) => | ||
| 91 | Effect.gen(function* () { | ||
| 92 | const ref = parseConnectionAddress(connection); | ||
| 93 | if (!ref) { | ||
| 94 | return yield* bindingError({ | ||
| 95 | integration, | ||
| 96 | connection, | ||
| 97 | message: `invalid connection address ${connection}`, | ||
| 98 | }); | ||
| 99 | } | ||
| 100 | if (String(ref.integration) !== integration) { | ||
| 101 | return yield* bindingError({ | ||
| 102 | integration, | ||
| 103 | connection, | ||
| 104 | message: `connection "${connection}" belongs to integration "${ref.integration}", not "${integration}"`, | ||
| 105 | }); | ||
| 106 | } | ||
| 107 | const tool = path.join("."); | ||
| 108 | const address = ToolAddress.make(`${connection}.${tool}`); | ||
| 109 | const payload = args[0] ?? {}; | ||
| 110 | const result = yield* input.ctx.execute(address, payload).pipe( | ||
| 111 | Effect.mapError((cause) => | ||
| 112 | bindingError({ | ||
| 113 | integration, | ||
| 114 | connection, | ||
| 115 | message: executeErrorMessage(cause), | ||
| 116 | cause, | ||
| 117 | }), | ||
| 118 | ), | ||
| 119 | ); | ||
| 120 | if (isToolResult(result)) { | ||
| 121 | if (result.ok) return result.data; | ||
| 122 | return yield* bindingError({ | ||
| 123 | integration, | ||
| 124 | connection, | ||
| 125 | message: result.error.message, | ||
| 126 | }); | ||
| 127 | } | ||
| 128 | return result; | ||
| 129 | }), | ||
| 130 | }); | ||
| @@ -0,0 +1,91 @@ | |||
| 1 | import { HttpEffect, HttpRouter } from "effect/unstable/http"; | ||
| 2 | import { Data, Effect, Layer } from "effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | IdentityProvider, | ||
| 6 | RequestOrgSlug, | ||
| 7 | RequestWebOrigin, | ||
| 8 | makeScopedExecutor, | ||
| 9 | type Principal, | ||
| 10 | } from "@executor-js/api/server"; | ||
| 11 | import { ToolAddress } from "@executor-js/sdk"; | ||
| 12 | |||
| 13 | import { SelfHostScopedExecutorSeams } from "./execution"; | ||
| 14 | import { SelfHostDb, type SelfHostDbHandle } from "./db/self-host-db"; | ||
| 15 | import type { SelfHostPlugins } from "./plugins"; | ||
| 16 | |||
| 17 | class AppsSyncRouteError extends Data.TaggedError("AppsSyncRouteError")<{ | ||
| 18 | readonly status: number; | ||
| 19 | readonly message: string; | ||
| 20 | }> {} | ||
| 21 | |||
| 22 | const jsonResponse = (body: unknown, status = 200): Response => | ||
| 23 | new Response(JSON.stringify(body), { | ||
| 24 | status, | ||
| 25 | headers: { "content-type": "application/json" }, | ||
| 26 | }); | ||
| 27 | |||
| 28 | const parseJson = (request: Request): Effect.Effect<unknown, AppsSyncRouteError> => | ||
| 29 | Effect.tryPromise({ | ||
| 30 | try: () => request.json() as Promise<unknown>, | ||
| 31 | catch: () => | ||
| 32 | new AppsSyncRouteError({ | ||
| 33 | status: 400, | ||
| 34 | message: "Invalid JSON body", | ||
| 35 | }), | ||
| 36 | }); | ||
| 37 | |||
| 38 | const buildExecutor = (principal: Principal, request: Request) => { | ||
| 39 | const withOrigin = makeScopedExecutor<SelfHostPlugins>( | ||
| 40 | principal.accountId, | ||
| 41 | principal.organizationId, | ||
| 42 | principal.organizationName, | ||
| 43 | ).pipe(Effect.provideService(RequestWebOrigin, { origin: new URL(request.url).origin })); | ||
| 44 | return principal.organizationSlug | ||
| 45 | ? withOrigin.pipe(Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug })) | ||
| 46 | : withOrigin; | ||
| 47 | }; | ||
| 48 | |||
| 49 | export interface SelfHostAppsSyncRouteDeps { | ||
| 50 | readonly identity: Layer.Layer<IdentityProvider>; | ||
| 51 | readonly db: SelfHostDbHandle; | ||
| 52 | } | ||
| 53 | |||
| 54 | export const makeSelfHostAppsSyncRoute = ({ identity, db }: SelfHostAppsSyncRouteDeps) => { | ||
| 55 | const services = Layer.merge( | ||
| 56 | SelfHostScopedExecutorSeams.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), | ||
| 57 | identity, | ||
| 58 | ); | ||
| 59 | return HttpRouter.add( | ||
| 60 | "POST", | ||
| 61 | "/api/apps/sources/github/sync", | ||
| 62 | HttpEffect.fromWebHandler( | ||
| 63 | (request): Promise<Response> => | ||
| 64 | Effect.runPromise( | ||
| 65 | Effect.gen(function* () { | ||
| 66 | const identityProvider = yield* IdentityProvider; | ||
| 67 | const principal = yield* identityProvider.authenticate(request); | ||
| 68 | const payload = yield* parseJson(request); | ||
| 69 | const executor = yield* buildExecutor(principal, request); | ||
| 70 | const result = yield* executor.execute( | ||
| 71 | ToolAddress.make("executor.apps.sync_github_source"), | ||
| 72 | payload, | ||
| 73 | ); | ||
| 74 | return jsonResponse(result); | ||
| 75 | }).pipe( | ||
| 76 | Effect.catchTags({ | ||
| 77 | Unauthorized: () => Effect.succeed(new Response("Unauthorized", { status: 401 })), | ||
| 78 | NoOrganization: () => Effect.succeed(new Response("Forbidden", { status: 403 })), | ||
| 79 | Unavailable: () => Effect.succeed(new Response("Unavailable", { status: 503 })), | ||
| 80 | AppsSyncRouteError: (error) => | ||
| 81 | Effect.succeed(jsonResponse({ error: error.message }, error.status)), | ||
| 82 | }), | ||
| 83 | Effect.catchCause(() => | ||
| 84 | Effect.succeed(new Response("Internal Server Error", { status: 500 })), | ||
| 85 | ), | ||
| 86 | Effect.provide(services), | ||
| 87 | ), | ||
| 88 | ), | ||
| 89 | ), | ||
| 90 | ); | ||
| 91 | }; | ||
| @@ -0,0 +1,549 @@ | |||
| 1 | import { randomBytes } from "node:crypto"; | ||
| 2 | import { mkdtempSync } from "node:fs"; | ||
| 3 | import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | ||
| 4 | import { createServer as createNetServer } from "node:net"; | ||
| 5 | import { tmpdir } from "node:os"; | ||
| 6 | import { join } from "node:path"; | ||
| 7 | |||
| 8 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; | ||
| 9 | import { createEmulator, type Emulator } from "@executor-js/emulate"; | ||
| 10 | |||
| 11 | const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-")); | ||
| 12 | const dbPath = join(dataDir, "data.db"); | ||
| 13 | process.env.EXECUTOR_DATA_DIR = dataDir; | ||
| 14 | process.env.EXECUTOR_SECRET_KEY = "apps-wire-secret-key"; | ||
| 15 | process.env.EXECUTOR_ALLOW_LOCAL_NETWORK = "true"; | ||
| 16 | |||
| 17 | const TEST_USER = "apps-user"; | ||
| 18 | const TEST_ORG = "apps-org"; | ||
| 19 | const OWNER = "syncer"; | ||
| 20 | const REPO = "custom-tools"; | ||
| 21 | const REPO_FULL_NAME = `${OWNER}/${REPO}`; | ||
| 22 | const GITHUB_CONNECTION = "tools.github.user.main"; | ||
| 23 | |||
| 24 | interface TestHttpServer { | ||
| 25 | readonly baseUrl: string; | ||
| 26 | readonly close: () => Promise<void>; | ||
| 27 | } | ||
| 28 | |||
| 29 | interface SyncResult { | ||
| 30 | readonly status: "published" | "up-to-date" | "failed"; | ||
| 31 | readonly snapshotId?: string; | ||
| 32 | readonly upstreamSha?: string; | ||
| 33 | readonly tools: readonly string[]; | ||
| 34 | readonly skipped: readonly { readonly path: string; readonly reason: string }[]; | ||
| 35 | readonly errors?: readonly unknown[]; | ||
| 36 | } | ||
| 37 | |||
| 38 | interface ToolRow { | ||
| 39 | readonly address: string; | ||
| 40 | readonly name: string; | ||
| 41 | readonly integration: string; | ||
| 42 | } | ||
| 43 | |||
| 44 | interface ExecuteResponse { | ||
| 45 | readonly status: "completed" | "paused"; | ||
| 46 | readonly text: string; | ||
| 47 | readonly structured: { | ||
| 48 | readonly status?: string; | ||
| 49 | readonly result?: unknown; | ||
| 50 | readonly error?: unknown; | ||
| 51 | }; | ||
| 52 | readonly isError?: boolean; | ||
| 53 | } | ||
| 54 | |||
| 55 | const bearerTemplate = { | ||
| 56 | slug: "bearer", | ||
| 57 | type: "apiKey", | ||
| 58 | label: "Bearer token", | ||
| 59 | headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] }, | ||
| 60 | }; | ||
| 61 | |||
| 62 | const jsonHeaders = { | ||
| 63 | "content-type": "application/json", | ||
| 64 | "x-test-user": TEST_USER, | ||
| 65 | "x-test-org": TEST_ORG, | ||
| 66 | }; | ||
| 67 | |||
| 68 | const findFreePort = (): Promise<number> => | ||
| 69 | new Promise((resolve, reject) => { | ||
| 70 | const probe = createNetServer(); | ||
| 71 | probe.once("error", reject); | ||
| 72 | probe.listen(0, "127.0.0.1", () => { | ||
| 73 | const address = probe.address(); | ||
| 74 | const port = typeof address === "object" && address ? address.port : 0; | ||
| 75 | probe.close((error) => (error ? reject(error) : resolve(port))); | ||
| 76 | }); | ||
| 77 | }); | ||
| 78 | |||
| 79 | const readRequestBody = async (request: IncomingMessage): Promise<Buffer | undefined> => { | ||
| 80 | if (request.method === "GET" || request.method === "HEAD") return undefined; | ||
| 81 | const chunks: Buffer[] = []; | ||
| 82 | for await (const chunk of request) { | ||
| 83 | chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | ||
| 84 | } | ||
| 85 | const body = Buffer.concat(chunks); | ||
| 86 | return body.length > 0 ? body : undefined; | ||
| 87 | }; | ||
| 88 | |||
| 89 | const webRequestFromNode = async (request: IncomingMessage, port: number): Promise<Request> => { | ||
| 90 | const headers = new Headers(); | ||
| 91 | for (const [name, raw] of Object.entries(request.headers)) { | ||
| 92 | if (Array.isArray(raw)) { | ||
| 93 | for (const value of raw) headers.append(name, value); | ||
| 94 | } else if (raw !== undefined) { | ||
| 95 | headers.set(name, raw); | ||
| 96 | } | ||
| 97 | } | ||
| 98 | const body = await readRequestBody(request); | ||
| 99 | return new Request(`http://127.0.0.1:${port}${request.url ?? "/"}`, { | ||
| 100 | method: request.method, | ||
| 101 | headers, | ||
| 102 | body: body ? new Uint8Array(body) : undefined, | ||
| 103 | }); | ||
| 104 | }; | ||
| 105 | |||
| 106 | const sendWebResponse = async (webResponse: Response, response: ServerResponse): Promise<void> => { | ||
| 107 | response.statusCode = webResponse.status; | ||
| 108 | webResponse.headers.forEach((value, name) => response.setHeader(name, value)); | ||
| 109 | response.end(Buffer.from(await webResponse.arrayBuffer())); | ||
| 110 | }; | ||
| 111 | |||
| 112 | const startHttpServer = async ( | ||
| 113 | handler: (request: Request) => Promise<Response>, | ||
| 114 | ): Promise<TestHttpServer> => { | ||
| 115 | let port = 0; | ||
| 116 | const server = createServer((request, response) => { | ||
| 117 | void (async () => { | ||
| 118 | try { | ||
| 119 | await sendWebResponse(await handler(await webRequestFromNode(request, port)), response); | ||
| 120 | } catch (error) { | ||
| 121 | response.statusCode = 500; | ||
| 122 | response.end(error instanceof Error ? error.message : String(error)); | ||
| 123 | } | ||
| 124 | })(); | ||
| 125 | }); | ||
| 126 | await new Promise<void>((resolve, reject) => { | ||
| 127 | server.once("error", reject); | ||
| 128 | server.listen(0, "127.0.0.1", () => { | ||
| 129 | const address = server.address(); | ||
| 130 | port = typeof address === "object" && address ? address.port : 0; | ||
| 131 | resolve(); | ||
| 132 | }); | ||
| 133 | }); | ||
| 134 | return { | ||
| 135 | baseUrl: `http://127.0.0.1:${port}`, | ||
| 136 | close: () => | ||
| 137 | new Promise((resolve, reject) => { | ||
| 138 | server.close((error) => (error ? reject(error) : resolve())); | ||
| 139 | }), | ||
| 140 | }; | ||
| 141 | }; | ||
| 142 | |||
| 143 | const requestJson = async <T>( | ||
| 144 | path: string, | ||
| 145 | init: RequestInit = {}, | ||
| 146 | expectedStatus = 200, | ||
| 147 | ): Promise<T> => { | ||
| 148 | const response = await fetch(`${server.baseUrl}${path}`, init); | ||
| 149 | const text = await response.text(); | ||
| 150 | expect(response.status).toBe(expectedStatus); | ||
| 151 | return (text.length > 0 ? JSON.parse(text) : null) as T; | ||
| 152 | }; | ||
| 153 | |||
| 154 | const postJson = <T>(path: string, body: unknown, expectedStatus = 200): Promise<T> => | ||
| 155 | requestJson<T>( | ||
| 156 | path, | ||
| 157 | { | ||
| 158 | method: "POST", | ||
| 159 | headers: jsonHeaders, | ||
| 160 | body: JSON.stringify(body), | ||
| 161 | }, | ||
| 162 | expectedStatus, | ||
| 163 | ); | ||
| 164 | |||
| 165 | const githubFetch = async <T>( | ||
| 166 | emulator: Emulator, | ||
| 167 | token: string, | ||
| 168 | path: string, | ||
| 169 | init: RequestInit = {}, | ||
| 170 | ): Promise<T> => { | ||
| 171 | const headers = new Headers(init.headers); | ||
| 172 | headers.set("authorization", `Bearer ${token}`); | ||
| 173 | headers.set("accept", "application/vnd.github+json"); | ||
| 174 | if (init.body !== undefined) headers.set("content-type", "application/json"); | ||
| 175 | const response = await fetch(`${emulator.url}${path}`, { ...init, headers }); | ||
| 176 | const text = await response.text(); | ||
| 177 | expect(response.ok).toBe(true); | ||
| 178 | return (text.length > 0 ? JSON.parse(text) : null) as T; | ||
| 179 | }; | ||
| 180 | |||
| 181 | const createIssue = (emulator: Emulator, token: string, title: string): Promise<unknown> => | ||
| 182 | githubFetch(emulator, token, `/repos/${OWNER}/${REPO}/issues`, { | ||
| 183 | method: "POST", | ||
| 184 | body: JSON.stringify({ title }), | ||
| 185 | }); | ||
| 186 | |||
| 187 | const putRepoFiles = async ( | ||
| 188 | emulator: Emulator, | ||
| 189 | token: string, | ||
| 190 | files: Readonly<Record<string, string>>, | ||
| 191 | ): Promise<string> => { | ||
| 192 | const ref = await githubFetch<{ object: { sha: string } }>( | ||
| 193 | emulator, | ||
| 194 | token, | ||
| 195 | `/repos/${OWNER}/${REPO}/git/ref/heads/main`, | ||
| 196 | ); | ||
| 197 | const parentSha = ref.object.sha; | ||
| 198 | const tree = await githubFetch<{ sha: string }>( | ||
| 199 | emulator, | ||
| 200 | token, | ||
| 201 | `/repos/${OWNER}/${REPO}/git/trees`, | ||
| 202 | { | ||
| 203 | method: "POST", | ||
| 204 | body: JSON.stringify({ | ||
| 205 | tree: Object.entries(files).map(([path, content]) => ({ | ||
| 206 | path, | ||
| 207 | mode: "100644", | ||
| 208 | type: "blob", | ||
| 209 | content, | ||
| 210 | })), | ||
| 211 | }), | ||
| 212 | }, | ||
| 213 | ); | ||
| 214 | const commit = await githubFetch<{ sha: string }>( | ||
| 215 | emulator, | ||
| 216 | token, | ||
| 217 | `/repos/${OWNER}/${REPO}/git/commits`, | ||
| 218 | { | ||
| 219 | method: "POST", | ||
| 220 | body: JSON.stringify({ | ||
| 221 | message: `Update custom tools ${randomBytes(3).toString("hex")}`, | ||
| 222 | tree: tree.sha, | ||
| 223 | parents: [parentSha], | ||
| 224 | }), | ||
| 225 | }, | ||
| 226 | ); | ||
| 227 | await githubFetch(emulator, token, `/repos/${OWNER}/${REPO}/git/refs/heads/main`, { | ||
| 228 | method: "PATCH", | ||
| 229 | body: JSON.stringify({ sha: commit.sha }), | ||
| 230 | }); | ||
| 231 | return commit.sha; | ||
| 232 | }; | ||
| 233 | |||
| 234 | const executorJson = JSON.stringify( | ||
| 235 | { | ||
| 236 | $schema: "https://executor.sh/schemas/executor.json", | ||
| 237 | description: "Curated tools wrapping our raw integration surface.", | ||
| 238 | }, | ||
| 239 | null, | ||
| 240 | 2, | ||
| 241 | ); | ||
| 242 | |||
| 243 | const dealPipelineSyncSource = `import { z } from "zod"; | ||
| 244 | import { defineTool, integration } from "executor:app"; | ||
| 245 | |||
| 246 | /** | ||
| 247 | * The deterministic-program case: pull GitHub issues into the scope database | ||
| 248 | * so agents query a table instead of paging the raw API. | ||
| 249 | */ | ||
| 250 | export default defineTool({ | ||
| 251 | description: | ||
| 252 | "Refresh the deals table from GitHub issues. Syncs issue numbers and titles " + | ||
| 253 | "for pipeline questions.", | ||
| 254 | |||
| 255 | integrations: { | ||
| 256 | github: integration("github"), | ||
| 257 | }, | ||
| 258 | |||
| 259 | input: z.object({ | ||
| 260 | owner: z.string(), | ||
| 261 | repo: z.string(), | ||
| 262 | }), | ||
| 263 | |||
| 264 | output: z.object({ synced: z.number() }), | ||
| 265 | |||
| 266 | annotations: { readOnly: false, destructive: false }, | ||
| 267 | |||
| 268 | async handler({ owner, repo }, { github, db }) { | ||
| 269 | await db.sql\` | ||
| 270 | CREATE TABLE IF NOT EXISTS deals ( | ||
| 271 | id TEXT PRIMARY KEY, | ||
| 272 | name TEXT NOT NULL, | ||
| 273 | stage TEXT NOT NULL | ||
| 274 | )\`; | ||
| 275 | |||
| 276 | const issues = await github.repos.listIssues({ owner, repo }); | ||
| 277 | |||
| 278 | let synced = 0; | ||
| 279 | for (const issue of issues) { | ||
| 280 | await db.sql\` | ||
| 281 | INSERT INTO deals (id, name, stage) | ||
| 282 | VALUES (\${String(issue.number)}, \${issue.title}, 'open') | ||
| 283 | ON CONFLICT (id) DO UPDATE SET | ||
| 284 | name = excluded.name, | ||
| 285 | stage = excluded.stage\`; | ||
| 286 | synced++; | ||
| 287 | } | ||
| 288 | |||
| 289 | return { synced }; | ||
| 290 | }, | ||
| 291 | }); | ||
| 292 | `; | ||
| 293 | |||
| 294 | const findDealDocsSource = `import { z } from "zod"; | ||
| 295 | import { defineTool, integration } from "executor:app"; | ||
| 296 | |||
| 297 | /** | ||
| 298 | * The curation case: a read-only query over the scope database populated by | ||
| 299 | * the sync tool. | ||
| 300 | */ | ||
| 301 | export default defineTool({ | ||
| 302 | description: | ||
| 303 | "Find documents for a deal across synced pipeline records. Searches " + | ||
| 304 | "stored issue titles.", | ||
| 305 | |||
| 306 | integrations: { | ||
| 307 | github: integration("github"), | ||
| 308 | }, | ||
| 309 | |||
| 310 | input: z.object({ | ||
| 311 | limit: z.number().int().max(50).default(20), | ||
| 312 | }), | ||
| 313 | |||
| 314 | output: z.object({ | ||
| 315 | documents: z.array( | ||
| 316 | z.object({ | ||
| 317 | name: z.string(), | ||
| 318 | stage: z.string(), | ||
| 319 | }), | ||
| 320 | ), | ||
| 321 | }), | ||
| 322 | |||
| 323 | annotations: { readOnly: true, destructive: false }, | ||
| 324 | |||
| 325 | async handler({ limit }, { db }) { | ||
| 326 | await db.sql\` | ||
| 327 | CREATE TABLE IF NOT EXISTS deals ( | ||
| 328 | id TEXT PRIMARY KEY, | ||
| 329 | name TEXT NOT NULL, | ||
| 330 | stage TEXT NOT NULL | ||
| 331 | )\`; | ||
| 332 | const rows = await db.sql\` | ||
| 333 | SELECT name, stage FROM deals ORDER BY id LIMIT \${limit}\`; | ||
| 334 | |||
| 335 | return { | ||
| 336 | documents: rows.map((row) => ({ | ||
| 337 | name: String(row.name), | ||
| 338 | stage: String(row.stage), | ||
| 339 | })), | ||
| 340 | }; | ||
| 341 | }, | ||
| 342 | }); | ||
| 343 | `; | ||
| 344 | |||
| 345 | const extraToolSource = `import { z } from "zod"; | ||
| 346 | import { defineTool } from "executor:app"; | ||
| 347 | |||
| 348 | export default defineTool({ | ||
| 349 | description: "Return a static custom-tools health marker.", | ||
| 350 | input: z.object({}), | ||
| 351 | output: z.object({ ok: z.boolean() }), | ||
| 352 | annotations: { readOnly: true, destructive: false }, | ||
| 353 | async handler() { | ||
| 354 | return { ok: true }; | ||
| 355 | }, | ||
| 356 | }); | ||
| 357 | `; | ||
| 358 | |||
| 359 | const initialFiles = (): Record<string, string> => ({ | ||
| 360 | "executor.json": executorJson, | ||
| 361 | "tools/deal-pipeline-sync.ts": dealPipelineSyncSource, | ||
| 362 | "tools/find-deal-docs.ts": findDealDocsSource, | ||
| 363 | }); | ||
| 364 | |||
| 365 | const registerGitHubIntegration = async (emulator: Emulator, token: string): Promise<void> => { | ||
| 366 | await postJson("/api/openapi/specs", { | ||
| 367 | spec: { kind: "url", url: emulator.openapiUrl }, | ||
| 368 | slug: "github", | ||
| 369 | baseUrl: emulator.url, | ||
| 370 | authenticationTemplate: [bearerTemplate], | ||
| 371 | }); | ||
| 372 | const created = await postJson<{ address: string }>("/api/connections", { | ||
| 373 | owner: "user", | ||
| 374 | name: "main", | ||
| 375 | integration: "github", | ||
| 376 | template: "bearer", | ||
| 377 | value: token, | ||
| 378 | }); | ||
| 379 | expect(created.address).toBe(GITHUB_CONNECTION); | ||
| 380 | }; | ||
| 381 | |||
| 382 | const syncSource = (repo = REPO_FULL_NAME): Promise<SyncResult> => | ||
| 383 | postJson<SyncResult>("/api/apps/sources/github/sync", { | ||
| 384 | repo, | ||
| 385 | connection: GITHUB_CONNECTION, | ||
| 386 | }); | ||
| 387 | |||
| 388 | const listAppTools = (): Promise<readonly ToolRow[]> => | ||
| 389 | requestJson<readonly ToolRow[]>("/api/tools?integration=apps", { | ||
| 390 | headers: jsonHeaders, | ||
| 391 | }); | ||
| 392 | |||
| 393 | const execute = (code: string): Promise<ExecuteResponse> => | ||
| 394 | postJson<ExecuteResponse>("/api/executions", { code, autoApprove: true }); | ||
| 395 | |||
| 396 | const executeResult = async (code: string): Promise<unknown> => { | ||
| 397 | const response = await execute(code); | ||
| 398 | expect(response.status).toBe("completed"); | ||
| 399 | expect(response.isError).toBe(false); | ||
| 400 | return response.structured.result; | ||
| 401 | }; | ||
| 402 | |||
| 403 | const callAppToolCode = (toolName: string, args: unknown): string => ` | ||
| 404 | const found = await tools.search({ namespace: "apps", query: ${JSON.stringify(toolName)}, limit: 20 }); | ||
| 405 | const item = found.items.find((candidate) => candidate.path.endsWith(${JSON.stringify(toolName)})); | ||
| 406 | if (!item) return { ok: false, missing: ${JSON.stringify(toolName)}, found }; | ||
| 407 | let fn = tools; | ||
| 408 | for (const segment of item.path.split(".")) fn = fn[segment]; | ||
| 409 | const result = await fn(${JSON.stringify(args)}); | ||
| 410 | return { path: item.path, result }; | ||
| 411 | `; | ||
| 412 | |||
| 413 | let github!: Emulator; | ||
| 414 | let server!: TestHttpServer; | ||
| 415 | let disposeApp: () => Promise<void> = async () => {}; | ||
| 416 | |||
| 417 | beforeAll(async () => { | ||
| 418 | const emulatorPort = await findFreePort(); | ||
| 419 | github = await createEmulator({ | ||
| 420 | service: "github", | ||
| 421 | port: emulatorPort, | ||
| 422 | seed: { | ||
| 423 | github: { | ||
| 424 | users: [{ login: OWNER, name: "Syncer" }], | ||
| 425 | repos: [{ owner: OWNER, name: REPO, auto_init: true }], | ||
| 426 | }, | ||
| 427 | }, | ||
| 428 | }); | ||
| 429 | const { makeSelfHostTestApp, headerIdentityLayer } = await import("./testing/test-app"); | ||
| 430 | const app = await makeSelfHostTestApp({ identity: headerIdentityLayer, dbPath }); | ||
| 431 | disposeApp = app.dispose; | ||
| 432 | server = await startHttpServer(app.handler); | ||
| 433 | }); | ||
| 434 | |||
| 435 | afterAll(async () => { | ||
| 436 | await server?.close(); | ||
| 437 | await disposeApp(); | ||
| 438 | await github?.close(); | ||
| 439 | }); | ||
| 440 | |||
| 441 | test("GitHub source sync publishes and invokes custom tools through self-host HTTP", async () => { | ||
| 442 | const credential = await github.credentials.mint({ | ||
| 443 | type: "api-key", | ||
| 444 | login: OWNER, | ||
| 445 | scopes: ["repo", "user"], | ||
| 446 | }); | ||
| 447 | const token = credential.token; | ||
| 448 | expect(token).toBeTruthy(); | ||
| 449 | await registerGitHubIntegration(github, token!); | ||
| 450 | await createIssue(github, token!, "Acme renewal diligence"); | ||
| 451 | await createIssue(github, token!, "Beta pipeline memo"); | ||
| 452 | await putRepoFiles(github, token!, initialFiles()); | ||
| 453 | |||
| 454 | const unauthorized = await fetch(`${server.baseUrl}/api/apps/sources/github/sync`, { | ||
| 455 | method: "POST", | ||
| 456 | headers: { "content-type": "application/json" }, | ||
| 457 | body: JSON.stringify({ repo: REPO_FULL_NAME, connection: GITHUB_CONNECTION }), | ||
| 458 | }); | ||
| 459 | expect(unauthorized.status).toBe(401); | ||
| 460 | |||
| 461 | const published = await syncSource(); | ||
| 462 | expect(published.status).toBe("published"); | ||
| 463 | expect(published.tools).toEqual(["deal-pipeline-sync", "find-deal-docs"]); | ||
| 464 | expect(published.skipped).toEqual([]); | ||
| 465 | const firstSnapshot = published.snapshotId; | ||
| 466 | expect(firstSnapshot).toBeTruthy(); | ||
| 467 | |||
| 468 | const listed = await listAppTools(); | ||
| 469 | expect(listed.map((tool) => tool.name).sort()).toEqual(["deal-pipeline-sync", "find-deal-docs"]); | ||
| 470 | |||
| 471 | const searchResult = await executeResult( | ||
| 472 | 'const found = await tools.search({ namespace: "apps", query: "pipeline", limit: 10 }); return found.items.map((item) => item.path);', | ||
| 473 | ); | ||
| 474 | expect(JSON.stringify(searchResult)).toContain("deal-pipeline-sync"); | ||
| 475 | |||
| 476 | const schema = await requestJson<{ inputSchema: { properties?: Record<string, unknown> } }>( | ||
| 477 | `/api/tools/schema?address=${encodeURIComponent(listed.find((tool) => tool.name === "deal-pipeline-sync")!.address)}`, | ||
| 478 | { headers: jsonHeaders }, | ||
| 479 | ); | ||
| 480 | expect(schema.inputSchema.properties?.github).toMatchObject({ | ||
| 481 | type: "string", | ||
| 482 | enum: [GITHUB_CONNECTION], | ||
| 483 | }); | ||
| 484 | |||
| 485 | const invoke = (await executeResult( | ||
| 486 | callAppToolCode("deal-pipeline-sync", { | ||
| 487 | github: GITHUB_CONNECTION, | ||
| 488 | owner: OWNER, | ||
| 489 | repo: REPO, | ||
| 490 | }), | ||
| 491 | )) as { result?: { ok?: boolean; data?: { synced?: number }; error?: unknown } }; | ||
| 492 | expect(invoke.result?.ok).toBe(true); | ||
| 493 | expect(invoke.result?.data?.synced).toBe(2); | ||
| 494 | |||
| 495 | const ledger = await github.ledger.list(); | ||
| 496 | const issueList = ledger.find( | ||
| 497 | (entry) => | ||
| 498 | entry.operationId === "issues/listForRepo" && entry.path === `/repos/${OWNER}/${REPO}/issues`, | ||
| 499 | ); | ||
| 500 | expect(issueList?.identity.user?.login).toBe(OWNER); | ||
| 501 | |||
| 502 | const readDb = (await executeResult(callAppToolCode("find-deal-docs", { limit: 10 }))) as { | ||
| 503 | result?: { ok?: boolean; data?: { documents?: readonly { name: string }[] } }; | ||
| 504 | }; | ||
| 505 | expect(readDb.result?.ok).toBe(true); | ||
| 506 | expect(readDb.result?.data?.documents?.map((row) => row.name).sort()).toEqual([ | ||
| 507 | "Acme renewal diligence", | ||
| 508 | "Beta pipeline memo", | ||
| 509 | ]); | ||
| 510 | |||
| 511 | const upToDate = await syncSource(); | ||
| 512 | expect(upToDate.status).toBe("up-to-date"); | ||
| 513 | expect("snapshotId" in upToDate).toBe(false); | ||
| 514 | |||
| 515 | await putRepoFiles(github, token!, { | ||
| 516 | ...initialFiles(), | ||
| 517 | "tools/extra-tool.ts": extraToolSource, | ||
| 518 | }); | ||
| 519 | const withExtra = await syncSource(); | ||
| 520 | expect(withExtra.status).toBe("published"); | ||
| 521 | expect(withExtra.snapshotId).not.toBe(firstSnapshot); | ||
| 522 | expect((await listAppTools()).map((tool) => tool.name).sort()).toEqual([ | ||
| 523 | "deal-pipeline-sync", | ||
| 524 | "extra-tool", | ||
| 525 | "find-deal-docs", | ||
| 526 | ]); | ||
| 527 | |||
| 528 | await putRepoFiles(github, token!, initialFiles()); | ||
| 529 | const removed = await syncSource(); | ||
| 530 | expect(removed.status).toBe("published"); | ||
| 531 | expect((await listAppTools()).map((tool) => tool.name).sort()).toEqual([ | ||
| 532 | "deal-pipeline-sync", | ||
| 533 | "find-deal-docs", | ||
| 534 | ]); | ||
| 535 | |||
| 536 | await putRepoFiles(github, token!, { | ||
| 537 | ...initialFiles(), | ||
| 538 | "workflows/x.ts": "export default {};", | ||
| 539 | }); | ||
| 540 | const skipped = await syncSource(); | ||
| 541 | expect(skipped.status).toBe("published"); | ||
| 542 | expect(skipped.skipped).toEqual([{ path: "workflows/x.ts", reason: "not supported yet" }]); | ||
| 543 | const afterSkipped = await listAppTools(); | ||
| 544 | expect(afterSkipped.map((tool) => tool.name).sort()).toEqual([ | ||
| 545 | "deal-pipeline-sync", | ||
| 546 | "find-deal-docs", | ||
| 547 | ]); | ||
| 548 | expect(afterSkipped.some((tool) => tool.address.includes("workflow"))).toBe(false); | ||
| 549 | }); | ||
| @@ -0,0 +1,71 @@ | |||
| 1 | import { join } from "node:path"; | ||
| 2 | |||
| 3 | import { Effect } from "effect"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | appsPlugin, | ||
| 7 | BindingError, | ||
| 8 | makeSelfHostAppsRuntime, | ||
| 9 | makeSqliteAppsStore, | ||
| 10 | type AppsRuntime, | ||
| 11 | type ClientResolver, | ||
| 12 | } from "@executor-js/plugin-apps/api"; | ||
| 13 | import type { PluginCtx } from "@executor-js/sdk"; | ||
| 14 | |||
| 15 | import { resolveDataDir } from "./config"; | ||
| 16 | import { makeSelfHostAppsResolver } from "./apps-resolver"; | ||
| 17 | |||
| 18 | const missingResolver = (): ClientResolver => ({ | ||
| 19 | listConnections: () => Effect.succeed([]), | ||
| 20 | resolveConnection: () => Effect.succeed(null), | ||
| 21 | call: ({ integration, connection }) => | ||
| 22 | Effect.fail( | ||
| 23 | new BindingError({ | ||
| 24 | role: integration, | ||
| 25 | integration, | ||
| 26 | requestedConnection: connection, | ||
| 27 | message: "apps resolver is unavailable outside a request-scoped executor", | ||
| 28 | }), | ||
| 29 | ), | ||
| 30 | }); | ||
| 31 | |||
| 32 | const createSubsystem = (dataDir: string) => { | ||
| 33 | const appsDir = join(dataDir, "apps"); | ||
| 34 | const store = makeSqliteAppsStore({ path: join(appsDir, "store.sqlite") }); | ||
| 35 | const host = makeSelfHostAppsRuntime({ | ||
| 36 | dataDir: appsDir, | ||
| 37 | store, | ||
| 38 | resolver: missingResolver(), | ||
| 39 | }); | ||
| 40 | const plugin = appsPlugin({ | ||
| 41 | runtime: host.runtime, | ||
| 42 | makeResolver: ({ ctx }) => makeSelfHostAppsResolver({ ctx: ctx as PluginCtx }), | ||
| 43 | }); | ||
| 44 | return { | ||
| 45 | runtime: host.runtime, | ||
| 46 | plugin, | ||
| 47 | close: host.close, | ||
| 48 | }; | ||
| 49 | }; | ||
| 50 | |||
| 51 | export interface SelfHostAppsSubsystem { | ||
| 52 | readonly runtime: AppsRuntime; | ||
| 53 | readonly plugin: ReturnType<typeof appsPlugin>; | ||
| 54 | readonly close: () => Promise<void>; | ||
| 55 | } | ||
| 56 | |||
| 57 | let current: { readonly dataDir: string; readonly subsystem: SelfHostAppsSubsystem } | undefined; | ||
| 58 | |||
| 59 | export const getSelfHostAppsSubsystem = (): SelfHostAppsSubsystem => { | ||
| 60 | const dataDir = resolveDataDir(); | ||
| 61 | if (current && current.dataDir === dataDir) return current.subsystem; | ||
| 62 | const subsystem = createSubsystem(dataDir); | ||
| 63 | current = { dataDir, subsystem }; | ||
| 64 | return subsystem; | ||
| 65 | }; | ||
| 66 | |||
| 67 | export const closeSelfHostAppsSubsystem = async (): Promise<void> => { | ||
| 68 | const subsystem = current?.subsystem; | ||
| 69 | current = undefined; | ||
| 70 | await subsystem?.close(); | ||
| 71 | }; | ||
| @@ -30,6 +30,8 @@ import { · 29 unmodified lines | |||
| 30 | } from "../mcp/session-store"; | 30 | } from "../mcp/session-store"; |
| 31 | import { selfHostPlugins } from "../plugins"; | 31 | import { selfHostPlugins } from "../plugins"; |
| 32 | import { ErrorCaptureLive } from "../observability"; | 32 | import { ErrorCaptureLive } from "../observability"; |
| 33 | import { closeSelfHostAppsSubsystem } from "../apps"; | ||
| 34 | import { makeSelfHostAppsSyncRoute } from "../apps-route"; | ||
| 33 | 35 | ||
| 34 | // =========================================================================== | 36 | // =========================================================================== |
| 35 | // Self-host TEST harness — the throwaway composition tests use to exercise the | 37 | // Self-host TEST harness — the throwaway composition tests use to exercise the |
| @@ -202,6 +204,7 @@ export const makeSelfHostTestApp = async ( · 166 unmodified lines | |||
| 202 | }, | 204 | }, |
| 203 | extensions: { | 205 | extensions: { |
| 204 | routes: [ | 206 | routes: [ |
| 207 | makeSelfHostAppsSyncRoute({ identity: options.identity, db: dbHandle }), | ||
| 205 | HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), | 208 | HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), |
| 206 | ], | 209 | ], |
| 207 | }, | 210 | }, |
| @@ -217,6 +220,7 @@ export const makeSelfHostTestApp = async ( · 9 unmodified lines | |||
| 217 | handler: web.handler, | 220 | handler: web.handler, |
| 218 | dispose: async () => { | 221 | dispose: async () => { |
| 219 | await web.dispose(); | 222 | await web.dispose(); |
| 223 | await closeSelfHostAppsSubsystem(); | ||
| 220 | await sessionStore.close(); | 224 | await sessionStore.close(); |
| 221 | await dbHandle.close(); | 225 | await dbHandle.close(); |
| 222 | }, | 226 | }, |
| @@ -224,6 +224,7 @@ · 223 unmodified lines | |||
| 224 | "@executor-js/execution": "workspace:*", | 224 | "@executor-js/execution": "workspace:*", |
| 225 | "@executor-js/fumadb": "workspace:*", | 225 | "@executor-js/fumadb": "workspace:*", |
| 226 | "@executor-js/host-mcp": "workspace:*", | 226 | "@executor-js/host-mcp": "workspace:*", |
| 227 | "@executor-js/plugin-apps": "workspace:*", | ||
| 227 | "@executor-js/plugin-encrypted-secrets": "workspace:*", | 228 | "@executor-js/plugin-encrypted-secrets": "workspace:*", |
| 228 | "@executor-js/plugin-google": "workspace:*", | 229 | "@executor-js/plugin-google": "workspace:*", |
| 229 | "@executor-js/plugin-graphql": "workspace:*", | 230 | "@executor-js/plugin-graphql": "workspace:*", |
| @@ -246,6 +247,7 @@ · 16 unmodified lines | |||
| 246 | }, | 247 | }, |
| 247 | "devDependencies": { | 248 | "devDependencies": { |
| 248 | "@effect/vitest": "catalog:", | 249 | "@effect/vitest": "catalog:", |
| 250 | "@executor-js/emulate": "^0.13.2", | ||
| 249 | "@executor-js/vite-plugin": "workspace:*", | 251 | "@executor-js/vite-plugin": "workspace:*", |
| 250 | "@tailwindcss/vite": "catalog:", | 252 | "@tailwindcss/vite": "catalog:", |
| 251 | "@tanstack/router-plugin": "^1.167.12", | 253 | "@tanstack/router-plugin": "^1.167.12", |
| @@ -2071,6 +2071,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2070 unmodified lines | |||
| 2071 | 2071 | ||
| 2072 | const result: ResolveToolsResult = yield* runtime.plugin | 2072 | const result: ResolveToolsResult = yield* runtime.plugin |
| 2073 | .resolveTools({ | 2073 | .resolveTools({ |
| 2074 | ctx: runtime.ctx, | ||
| 2074 | integration: rowToIntegration(integrationRow), | 2075 | integration: rowToIntegration(integrationRow), |
| 2075 | config: decodeJsonColumn(integrationRow.config), | 2076 | config: decodeJsonColumn(integrationRow.config), |
| 2076 | httpClientLayer: runtime.ctx.httpClientLayer, | 2077 | httpClientLayer: runtime.ctx.httpClientLayer, |
| @@ -3732,6 +3733,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1655 unmodified lines | |||
| 3732 | items: (key) => providersItems(key), | 3733 | items: (key) => providersItems(key), |
| 3733 | }, | 3734 | }, |
| 3734 | oauth, | 3735 | oauth, |
| 3736 | execute: (address, args) => execute(address, args), | ||
| 3735 | transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect), | 3737 | transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect), |
| 3736 | }; | 3738 | }; |
| 3737 | 3739 | ||
| @@ -29,6 +29,7 @@ import type { · 28 unmodified lines | |||
| 29 | ProviderKey, | 29 | ProviderKey, |
| 30 | Subject, | 30 | Subject, |
| 31 | Tenant, | 31 | Tenant, |
| 32 | ToolAddress, | ||
| 32 | } from "./ids"; | 33 | } from "./ids"; |
| 33 | import type { IntegrationDetectionResult } from "./types"; | 34 | import type { IntegrationDetectionResult } from "./types"; |
| 34 | import type { | 35 | import type { |
| @@ -38,6 +39,7 @@ import type { · 3 unmodified lines | |||
| 38 | ElicitationResponse, | 39 | ElicitationResponse, |
| 39 | } from "./elicitation"; | 40 | } from "./elicitation"; |
| 40 | import type { | 41 | import type { |
| 42 | ExecuteError, | ||
| 41 | ConnectionNotFoundError, | 43 | ConnectionNotFoundError, |
| 42 | CredentialProviderNotRegisteredError, | 44 | CredentialProviderNotRegisteredError, |
| 43 | IntegrationNotFoundError, | 45 | IntegrationNotFoundError, |
| @@ -249,6 +251,12 @@ export interface PluginCtx<TStore = unknown> { · 205 unmodified lines | |||
| 249 | /** Shared OAuth service. */ | 251 | /** Shared OAuth service. */ |
| 250 | readonly oauth: OAuthService; | 252 | readonly oauth: OAuthService; |
| 251 | 253 | ||
| 254 | /** Invoke another catalog tool through the same executor request context: | ||
| 255 | * policy, approval, credential resolution, and plugin dispatch all stay in | ||
| 256 | * the core path. Intended for plugin sandboxes that expose higher-level | ||
| 257 | * virtual tools over existing integration tools. */ | ||
| 258 | readonly execute: (address: ToolAddress, args: unknown) => Effect.Effect<unknown, ExecuteError>; | ||
| 259 | |||
| 252 | /** Run `effect` inside a FumaDB transaction (atomic across plugin storage + | 260 | /** Run `effect` inside a FumaDB transaction (atomic across plugin storage + |
| 253 | * core integration/tool writes). */ | 261 | * core integration/tool writes). */ |
| 254 | readonly transaction: <A, E>(effect: Effect.Effect<A, E>) => Effect.Effect<A, E | StorageFailure>; | 262 | readonly transaction: <A, E>(effect: Effect.Effect<A, E>) => Effect.Effect<A, E | StorageFailure>; |
| @@ -261,6 +269,7 @@ export interface PluginCtx<TStore = unknown> { · 6 unmodified lines | |||
| 261 | // --------------------------------------------------------------------------- | 269 | // --------------------------------------------------------------------------- |
| 262 | 270 | ||
| 263 | export interface ResolveToolsInput<TStore = unknown> { | 271 | export interface ResolveToolsInput<TStore = unknown> { |
| 272 | readonly ctx?: PluginCtx<TStore>; | ||
| 264 | /** The catalog record (public projection) whose connection is being resolved. */ | 273 | /** The catalog record (public projection) whose connection is being resolved. */ |
| 265 | readonly integration: Integration; | 274 | readonly integration: Integration; |
| 266 | /** The plugin's stored opaque config for that integration. */ | 275 | /** The plugin's stored opaque config for that integration. */ |
| @@ -13,6 +13,7 @@ export { · 12 unmodified lines | |||
| 13 | type SelfHostAppsRuntime, | 13 | type SelfHostAppsRuntime, |
| 14 | type SelfHostAppsRuntimeOptions, | 14 | type SelfHostAppsRuntimeOptions, |
| 15 | } from "./plugin/self-host-runtime"; | 15 | } from "./plugin/self-host-runtime"; |
| 16 | export { type AppsRuntime } from "./plugin/runtime"; | ||
| 16 | export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; | 17 | export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; |
| 17 | export { | 18 | export { |
| 18 | BindingError, | 19 | BindingError, |
| @@ -20,3 +21,13 @@ export { · 1 unmodified line | |||
| 20 | type ConnectionCandidate, | 21 | type ConnectionCandidate, |
| 21 | type RoleBindings, | 22 | type RoleBindings, |
| 22 | } from "./plugin/bindings"; | 23 | } from "./plugin/bindings"; |
| 24 | export { | ||
| 25 | fetchGitHubSource, | ||
| 26 | syncGitHubSource, | ||
| 27 | GitHubSourceError, | ||
| 28 | type GitHubSourceInput, | ||
| 29 | type GitHubSourceSnapshot, | ||
| 30 | type GitHubSyncResult, | ||
| 31 | type SyncErrorData, | ||
| 32 | type SyncGitHubSourceInput, | ||
| 33 | } from "./source/github-source"; | ||
| @@ -16,6 +16,16 @@ export { · 15 unmodified lines | |||
| 16 | type PublishOutput, | 16 | type PublishOutput, |
| 17 | type PublishDeps, | 17 | type PublishDeps, |
| 18 | } from "./pipeline/publish"; | 18 | } from "./pipeline/publish"; |
| 19 | export { | ||
| 20 | fetchGitHubSource, | ||
| 21 | syncGitHubSource, | ||
| 22 | GitHubSourceError, | ||
| 23 | type GitHubSourceInput, | ||
| 24 | type GitHubSourceSnapshot, | ||
| 25 | type GitHubSyncResult, | ||
| 26 | type SyncErrorData, | ||
| 27 | type SyncGitHubSourceInput, | ||
| 28 | } from "./source/github-source"; | ||
| 19 | 29 | ||
| 20 | export { makeAppsRuntime, type AppsRuntime, type AppsRuntimeDeps } from "./plugin/runtime"; | 30 | export { makeAppsRuntime, type AppsRuntime, type AppsRuntimeDeps } from "./plugin/runtime"; |
| 21 | export { | 31 | export { |
| @@ -7,7 +7,7 @@ · 6 unmodified lines | |||
| 7 | 7 | ||
| 8 | /** Descriptor schema version. Bumped on any breaking shape change. A reader | 8 | /** Descriptor schema version. Bumped on any breaking shape change. A reader |
| 9 | * refuses a descriptor from a version it does not understand. */ | 9 | * refuses a descriptor from a version it does not understand. */ |
| 10 | export const DESCRIPTOR_VERSION = 2 as const; | 10 | export const DESCRIPTOR_VERSION = 3 as const; |
| 11 | 11 | ||
| 12 | /** Where an entry came from: path + content hash. Lets a projection point back | 12 | /** Where an entry came from: path + content hash. Lets a projection point back |
| 13 | * at the exact source bytes without re-reading the snapshot, and makes the | 13 | * at the exact source bytes without re-reading the snapshot, and makes the |
| @@ -31,6 +31,15 @@ export interface IntegrationDecl { · 17 unmodified lines | |||
| 31 | readonly integration: string; | 31 | readonly integration: string; |
| 32 | } | 32 | } |
| 33 | 33 | ||
| 34 | export interface GitHubSourceRef { | ||
| 35 | readonly kind: "github"; | ||
| 36 | readonly repo: string; | ||
| 37 | readonly ref: string; | ||
| 38 | readonly upstreamSha: string; | ||
| 39 | } | ||
| 40 | |||
| 41 | export type AppSourceRef = GitHubSourceRef; | ||
| 42 | |||
| 34 | export interface ToolDescriptor { | 43 | export interface ToolDescriptor { |
| 35 | /** Path identity, e.g. `issues-sync` (from `tools/issues-sync.ts`). */ | 44 | /** Path identity, e.g. `issues-sync` (from `tools/issues-sync.ts`). */ |
| 36 | readonly name: string; | 45 | readonly name: string; |
| @@ -62,6 +71,8 @@ export const GUIDE_ENTRIES_KEY = "skills"; · 25 unmodified lines | |||
| 62 | export interface AppDescriptor { | 71 | export interface AppDescriptor { |
| 63 | readonly version: typeof DESCRIPTOR_VERSION; | 72 | readonly version: typeof DESCRIPTOR_VERSION; |
| 64 | readonly scope: string; | 73 | readonly scope: string; |
| 74 | readonly description?: string; | ||
| 75 | readonly source?: AppSourceRef; | ||
| 65 | /** The snapshot (commit hash) this descriptor was extracted from. */ | 76 | /** The snapshot (commit hash) this descriptor was extracted from. */ |
| 66 | readonly snapshotId: string; | 77 | readonly snapshotId: string; |
| 67 | /** The toolchain that produced the compiled bundles. */ | 78 | /** The toolchain that produced the compiled bundles. */ |
| @@ -70,6 +81,10 @@ export interface AppDescriptor { · 2 unmodified lines | |||
| 70 | readonly [FLOW_ENTRIES_KEY]: readonly DeferredDescriptor[]; | 81 | readonly [FLOW_ENTRIES_KEY]: readonly DeferredDescriptor[]; |
| 71 | readonly ui: readonly DeferredDescriptor[]; | 82 | readonly ui: readonly DeferredDescriptor[]; |
| 72 | readonly [GUIDE_ENTRIES_KEY]: readonly DeferredDescriptor[]; | 83 | readonly [GUIDE_ENTRIES_KEY]: readonly DeferredDescriptor[]; |
| 84 | readonly skipped: readonly { | ||
| 85 | readonly path: string; | ||
| 86 | readonly reason: "not supported yet"; | ||
| 87 | }[]; | ||
| 73 | /** Shared JSON-schema `$defs` reachable from tool schemas. */ | 88 | /** Shared JSON-schema `$defs` reachable from tool schemas. */ |
| 74 | readonly definitions?: Record<string, unknown>; | 89 | readonly definitions?: Record<string, unknown>; |
| 75 | } | 90 | } |
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | |||
| 10 | GUIDE_ENTRIES_KEY, | 10 | GUIDE_ENTRIES_KEY, |
| 11 | stableStringify, | 11 | stableStringify, |
| 12 | type AppDescriptor, | 12 | type AppDescriptor, |
| 13 | type AppSourceRef, | ||
| 13 | type IntegrationDecl, | 14 | type IntegrationDecl, |
| 14 | type ModuleSourceRef, | 15 | type ModuleSourceRef, |
| 15 | type ToolDescriptor, | 16 | type ToolDescriptor, |
| @@ -73,6 +74,8 @@ export interface PublishInput { · 57 unmodified lines | |||
| 73 | readonly scope: string; | 74 | readonly scope: string; |
| 74 | readonly files: FileSet; | 75 | readonly files: FileSet; |
| 75 | readonly commitMessage?: string; | 76 | readonly commitMessage?: string; |
| 77 | readonly description?: string; | ||
| 78 | readonly source?: AppSourceRef; | ||
| 76 | } | 79 | } |
| 77 | 80 | ||
| 78 | export interface PublishOutput { | 81 | export interface PublishOutput { |
| @@ -219,14 +222,18 @@ const assemble = (deps: PublishDeps, files: FileSet): Effect.Effect<AssembledApp · 140 unmodified lines | |||
| 219 | const descriptorBody = ( | 222 | const descriptorBody = ( |
| 220 | scope: string, | 223 | scope: string, |
| 221 | assembled: AssembledApp, | 224 | assembled: AssembledApp, |
| 225 | input: Pick<PublishInput, "description" | "source">, | ||
| 222 | ): Omit<AppDescriptor, "snapshotId"> => ({ | 226 | ): Omit<AppDescriptor, "snapshotId"> => ({ |
| 223 | version: DESCRIPTOR_VERSION, | 227 | version: DESCRIPTOR_VERSION, |
| 224 | scope, | 228 | scope, |
| 229 | ...(input.description !== undefined ? { description: input.description } : {}), | ||
| 230 | ...(input.source !== undefined ? { source: input.source } : {}), | ||
| 225 | toolchain: toolchainRef(), | 231 | toolchain: toolchainRef(), |
| 226 | tools: assembled.tools, | 232 | tools: assembled.tools, |
| 227 | [FLOW_ENTRIES_KEY]: [], | 233 | [FLOW_ENTRIES_KEY]: [], |
| 228 | ui: [], | 234 | ui: [], |
| 229 | [GUIDE_ENTRIES_KEY]: [], | 235 | [GUIDE_ENTRIES_KEY]: [], |
| 236 | skipped: assembled.skipped, | ||
| 230 | }); | 237 | }); |
| 231 | 238 | ||
| 232 | export const publish = ( | 239 | export const publish = ( |
| @@ -238,7 +245,7 @@ export const publish = ( · 5 unmodified lines | |||
| 238 | if (overLimit) return yield* Effect.fail(overLimit); | 245 | if (overLimit) return yield* Effect.fail(overLimit); |
| 239 | 246 | ||
| 240 | const assembled = yield* assemble(deps, input.files); | 247 | const assembled = yield* assemble(deps, input.files); |
| 241 | const body = descriptorBody(input.scope, assembled); | 248 | const body = descriptorBody(input.scope, assembled, input); |
| 242 | 249 | ||
| 243 | const scopeStore = yield* deps.artifactStore | 250 | const scopeStore = yield* deps.artifactStore |
| 244 | .forScope(input.scope) | 251 | .forScope(input.scope) |
| @@ -4,10 +4,12 @@ import { · 3 unmodified lines | |||
| 4 | definePlugin, | 4 | definePlugin, |
| 5 | tool, | 5 | tool, |
| 6 | ToolName, | 6 | ToolName, |
| 7 | ConnectionName, | ||
| 8 | IntegrationSlug, | 7 | IntegrationSlug, |
| 9 | AuthTemplateSlug, | 8 | AuthTemplateSlug, |
| 10 | connectionIdentifier, | 9 | connectionIdentifier, |
| 10 | type ConnectionRef, | ||
| 11 | type Owner, | ||
| 12 | type PluginCtx, | ||
| 11 | type ResolveToolsInput, | 13 | type ResolveToolsInput, |
| 12 | type ResolveToolsResult, | 14 | type ResolveToolsResult, |
| 13 | type InvokeToolInput, | 15 | type InvokeToolInput, |
| @@ -18,6 +20,7 @@ import type { AppsRuntime } from "./runtime"; · 4 unmodified lines | |||
| 18 | import { makeAppsStore } from "./store"; | 20 | import { makeAppsStore } from "./store"; |
| 19 | import type { ClientResolver, ConnectionCandidate } from "./bindings"; | 21 | import type { ClientResolver, ConnectionCandidate } from "./bindings"; |
| 20 | import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor"; | 22 | import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor"; |
| 23 | import { syncGitHubSource, type GitHubSyncResult } from "../source/github-source"; | ||
| 21 | 24 | ||
| 22 | export const APPS_INTEGRATION_SLUG = "apps"; | 25 | export const APPS_INTEGRATION_SLUG = "apps"; |
| 23 | export const APPS_PLUGIN_ID = "apps"; | 26 | export const APPS_PLUGIN_ID = "apps"; |
| @@ -42,6 +45,40 @@ const isRecord = (value: unknown): value is Record<string, unknown> => · 18 unmodified lines | |||
| 42 | 45 | ||
| 43 | const unique = (values: readonly string[]): readonly string[] => [...new Set(values)]; | 46 | const unique = (values: readonly string[]): readonly string[] => [...new Set(values)]; |
| 44 | 47 | ||
| 48 | const syncFailure = (message: string, path?: string): GitHubSyncResult => ({ | ||
| 49 | status: "failed", | ||
| 50 | tools: [], | ||
| 51 | skipped: [], | ||
| 52 | errors: [ | ||
| 53 | { | ||
| 54 | stage: "source", | ||
| 55 | message, | ||
| 56 | ...(path ? { diagnostics: [{ path, message }] } : {}), | ||
| 57 | }, | ||
| 58 | ], | ||
| 59 | }); | ||
| 60 | |||
| 61 | const sourceScopeFor = (input: { repo: string; connection: string }): string => | ||
| 62 | String(connectionIdentifier(`github ${input.connection} ${input.repo}`, "githubSource")); | ||
| 63 | |||
| 64 | const parseConnectionAddress = (address: string): ConnectionRef | null => { | ||
| 65 | const parts = address.split("."); | ||
| 66 | if (parts.length !== 4 || parts[0] !== "tools") return null; | ||
| 67 | const [, integration, owner, name] = parts; | ||
| 68 | if (!integration || !name) return null; | ||
| 69 | if (owner !== "org" && owner !== "user") return null; | ||
| 70 | return { | ||
| 71 | owner: owner as Owner, | ||
| 72 | integration: IntegrationSlug.make(integration), | ||
| 73 | name: connectionIdentifier(name), | ||
| 74 | }; | ||
| 75 | }; | ||
| 76 | |||
| 77 | const configBaseUrl = (config: unknown): string | undefined => | ||
| 78 | isRecord(config) && typeof config.baseUrl === "string" && config.baseUrl.length > 0 | ||
| 79 | ? config.baseUrl | ||
| 80 | : undefined; | ||
| 81 | |||
| 45 | const projectInputSchema = ( | 82 | const projectInputSchema = ( |
| 46 | schema: unknown, | 83 | schema: unknown, |
| 47 | integrations: Readonly<Record<string, IntegrationDecl>>, | 84 | integrations: Readonly<Record<string, IntegrationDecl>>, |
| @@ -94,6 +131,49 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 46 unmodified lines | |||
| 94 | Effect.map((mapped) => mapped ?? scopeFromConnection(connectionName)), | 131 | Effect.map((mapped) => mapped ?? scopeFromConnection(connectionName)), |
| 95 | ); | 132 | ); |
| 96 | 133 | ||
| 134 | const ensureCatalogConnection = (scope: string, ctx: PluginCtx<AppsStoreShape>) => | ||
| 135 | Effect.gen(function* () { | ||
| 136 | const slug = IntegrationSlug.make(APPS_INTEGRATION_SLUG); | ||
| 137 | const existing = yield* ctx.core.integrations | ||
| 138 | .get(slug) | ||
| 139 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 140 | if (!existing) { | ||
| 141 | yield* ctx.core.integrations.register({ | ||
| 142 | slug, | ||
| 143 | name: "Apps", | ||
| 144 | description: "User-authored, published custom tools.", | ||
| 145 | config: {}, | ||
| 146 | canRemove: false, | ||
| 147 | canRefresh: true, | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | const rawName = connectionNameForScope(scope); | ||
| 151 | const connName = connectionIdentifier(rawName); | ||
| 152 | const conns = yield* ctx.connections | ||
| 153 | .list({ integration: slug }) | ||
| 154 | .pipe(Effect.orElseSucceed(() => [])); | ||
| 155 | if (!conns.some((c) => String(c.name) === String(connName))) { | ||
| 156 | yield* ctx.connections.create({ | ||
| 157 | owner: "user", | ||
| 158 | name: connName, | ||
| 159 | integration: slug, | ||
| 160 | template: AuthTemplateSlug.make("none"), | ||
| 161 | value: "", | ||
| 162 | }); | ||
| 163 | } | ||
| 164 | yield* runtime.deps.store | ||
| 165 | .putScopeForConnection(String(rawName), scope) | ||
| 166 | .pipe(Effect.orElseSucceed(() => undefined)); | ||
| 167 | yield* runtime.deps.store | ||
| 168 | .putScopeForConnection(String(connName), scope) | ||
| 169 | .pipe(Effect.orElseSucceed(() => undefined)); | ||
| 170 | return { | ||
| 171 | scope, | ||
| 172 | connection: String(connName), | ||
| 173 | ref: { owner: "user" as const, integration: slug, name: connName }, | ||
| 174 | }; | ||
| 175 | }); | ||
| 176 | |||
| 97 | return { | 177 | return { |
| 98 | id: APPS_PLUGIN_ID as "apps", | 178 | id: APPS_PLUGIN_ID as "apps", |
| 99 | packageName: "@executor-js/plugin-apps", | 179 | packageName: "@executor-js/plugin-apps", |
| @@ -135,59 +215,78 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 35 unmodified lines | |||
| 135 | Effect.gen(function* () { | 215 | Effect.gen(function* () { |
| 136 | const scope = | 216 | const scope = |
| 137 | (args as { scope?: string } | undefined)?.scope ?? DEFAULT_CATALOG_SCOPE; | 217 | (args as { scope?: string } | undefined)?.scope ?? DEFAULT_CATALOG_SCOPE; |
| 138 | const slug = IntegrationSlug.make(APPS_INTEGRATION_SLUG); | 218 | const connected = yield* ensureCatalogConnection(scope, ctx); |
| 139 | const existing = yield* ctx.core.integrations | 219 | return { scope, connection: connected.connection }; |
| 140 | .get(slug) | 220 | }), |
| 141 | .pipe(Effect.orElseSucceed(() => null)); | 221 | }), |
| 142 | if (!existing) { | 222 | tool<AppsStoreShape>({ |
| 143 | yield* ctx.core.integrations.register({ | 223 | name: "sync_github_source", |
| 144 | slug, | 224 | description: "Sync a GitHub repository containing custom tool source files.", |
| 145 | name: "Apps", | 225 | execute: (args, { ctx }) => |
| 146 | description: "User-authored, published custom tools.", | 226 | Effect.gen(function* () { |
| 147 | config: {}, | 227 | const payload = isRecord(args) ? args : {}; |
| 148 | canRemove: false, | 228 | const repo = typeof payload.repo === "string" ? payload.repo : ""; |
| 149 | canRefresh: true, | 229 | const connection = typeof payload.connection === "string" ? payload.connection : ""; |
| 150 | }); | 230 | const ref = typeof payload.ref === "string" ? payload.ref : undefined; |
| 231 | const explicitScope = typeof payload.scope === "string" ? payload.scope : undefined; | ||
| 232 | if (!repo) return syncFailure('sync_github_source requires "repo"'); | ||
| 233 | if (!connection) return syncFailure('sync_github_source requires "connection"'); | ||
| 234 | |||
| 235 | const sourceConnection = parseConnectionAddress(connection); | ||
| 236 | if (!sourceConnection) { | ||
| 237 | return syncFailure( | ||
| 238 | `GitHub connection must be a connection address like tools.github.user.main; got "${connection}"`, | ||
| 239 | connection, | ||
| 240 | ); | ||
| 151 | } | 241 | } |
| 152 | const connName = ConnectionName.make(connectionNameForScope(scope)); | 242 | if (String(sourceConnection.integration) !== "github") { |
| 153 | const conns = yield* ctx.connections | 243 | return syncFailure( |
| 154 | .list({ integration: slug }) | 244 | `GitHub source sync requires a github connection; got "${sourceConnection.integration}"`, |
| 155 | .pipe(Effect.orElseSucceed(() => [])); | 245 | connection, |
| 156 | if (!conns.some((c) => String(c.name) === String(connName))) { | 246 | ); |
| 157 | yield* ctx.connections.create({ | ||
| 158 | owner: "user", | ||
| 159 | name: connName, | ||
| 160 | integration: slug, | ||
| 161 | template: AuthTemplateSlug.make("none"), | ||
| 162 | value: "", | ||
| 163 | }); | ||
| 164 | } | 247 | } |
| 165 | yield* runtime.deps.store | 248 | const existing = yield* ctx.connections |
| 166 | .putScopeForConnection(String(connName), scope) | 249 | .get(sourceConnection) |
| 167 | .pipe(Effect.orElseSucceed(() => undefined)); | 250 | .pipe(Effect.orElseSucceed(() => null)); |
| 168 | const normalized = connectionIdentifier(String(connName)); | 251 | if (!existing) return syncFailure(`GitHub connection not found: ${connection}`); |
| 169 | if (normalized !== String(connName)) { | 252 | const token = yield* ctx.connections |
| 170 | yield* runtime.deps.store | 253 | .resolveValue(sourceConnection) |
| 171 | .putScopeForConnection(normalized, scope) | 254 | .pipe(Effect.orElseSucceed(() => null)); |
| 172 | .pipe(Effect.orElseSucceed(() => undefined)); | 255 | const integration = yield* ctx.core.integrations |
| 256 | .get(sourceConnection.integration) | ||
| 257 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 258 | const scope = explicitScope ?? sourceScopeFor({ repo, connection }); | ||
| 259 | const result = yield* syncGitHubSource({ | ||
| 260 | runtime, | ||
| 261 | scope, | ||
| 262 | repo, | ||
| 263 | ref, | ||
| 264 | token, | ||
| 265 | baseUrl: configBaseUrl(integration?.config), | ||
| 266 | }); | ||
| 267 | if (result.status === "published") { | ||
| 268 | const catalog = yield* ensureCatalogConnection(scope, ctx); | ||
| 269 | yield* ctx.connections.refresh(catalog.ref).pipe(Effect.orElseSucceed(() => [])); | ||
| 173 | } | 270 | } |
| 174 | return { scope, connection: String(connName) }; | 271 | return result; |
| 175 | }), | 272 | }), |
| 176 | }), | 273 | }), |
| 177 | ], | 274 | ], |
| 178 | }, | 275 | }, |
| 179 | ], | 276 | ], |
| 180 | 277 | ||
| 181 | resolveTools: ({ connection }: ResolveToolsInput<AppsStoreShape>) => | 278 | resolveTools: ({ ctx, connection }: ResolveToolsInput<AppsStoreShape>) => |
| 182 | Effect.gen(function* () { | 279 | Effect.gen(function* () { |
| 183 | const scope = yield* scopeFor(String(connection.name)); | 280 | const scope = yield* scopeFor(String(connection.name)); |
| 184 | const descriptor = yield* runtime.getDescriptor(scope); | 281 | const descriptor = yield* runtime.getDescriptor(scope); |
| 185 | if (!descriptor) return { tools: [] } satisfies ResolveToolsResult; | 282 | if (!descriptor) return { tools: [] } satisfies ResolveToolsResult; |
| 283 | const resolver = | ||
| 284 | makeResolver && ctx ? makeResolver({ ctx, scope, tool: "*" }) : runtime.deps.resolver; | ||
| 186 | const tools: ToolDef[] = []; | 285 | const tools: ToolDef[] = []; |
| 187 | for (const t of descriptor.tools) { | 286 | for (const t of descriptor.tools) { |
| 188 | const byRole: Record<string, readonly ConnectionCandidate[]> = {}; | 287 | const byRole: Record<string, readonly ConnectionCandidate[]> = {}; |
| 189 | for (const [role, decl] of Object.entries(t.integrations)) { | 288 | for (const [role, decl] of Object.entries(t.integrations)) { |
| 190 | byRole[role] = yield* runtime.deps.resolver | 289 | byRole[role] = yield* resolver |
| 191 | .listConnections({ integration: decl.integration }) | 290 | .listConnections({ integration: decl.integration }) |
| 192 | .pipe(Effect.orElseSucceed(() => [])); | 291 | .pipe(Effect.orElseSucceed(() => [])); |
| 193 | } | 292 | } |
| @@ -13,7 +13,7 @@ import { · 12 unmodified lines | |||
| 13 | type PublishOutput, | 13 | type PublishOutput, |
| 14 | } from "../pipeline/publish"; | 14 | } from "../pipeline/publish"; |
| 15 | import { PublishError } from "../pipeline/discover"; | 15 | import { PublishError } from "../pipeline/discover"; |
| 16 | import type { AppDescriptor, ToolDescriptor } from "../pipeline/descriptor"; | 16 | import type { AppDescriptor, AppSourceRef, ToolDescriptor } from "../pipeline/descriptor"; |
| 17 | import { bundleEntry } from "../pipeline/bundle"; | 17 | import { bundleEntry } from "../pipeline/bundle"; |
| 18 | import { | 18 | import { |
| 19 | buildBridge, | 19 | buildBridge, |
| @@ -42,6 +42,8 @@ export interface AppsRuntime { · 22 unmodified lines | |||
| 42 | readonly scope: string; | 42 | readonly scope: string; |
| 43 | readonly files: ReadonlyMap<string, string>; | 43 | readonly files: ReadonlyMap<string, string>; |
| 44 | readonly message?: string; | 44 | readonly message?: string; |
| 45 | readonly description?: string; | ||
| 46 | readonly source?: AppSourceRef; | ||
| 45 | }) => Effect.Effect<PublishOutput, PublishError>; | 47 | }) => Effect.Effect<PublishOutput, PublishError>; |
| 46 | readonly getDescriptor: (scope: string) => Effect.Effect<AppDescriptor | null>; | 48 | readonly getDescriptor: (scope: string) => Effect.Effect<AppDescriptor | null>; |
| 47 | /** Re-derive the published-descriptor pointer from the latest committed | 49 | /** Re-derive the published-descriptor pointer from the latest committed |
| @@ -264,6 +266,8 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 216 unmodified lines | |||
| 264 | scope: input.scope, | 266 | scope: input.scope, |
| 265 | files: input.files, | 267 | files: input.files, |
| 266 | commitMessage: input.message, | 268 | commitMessage: input.message, |
| 269 | description: input.description, | ||
| 270 | source: input.source, | ||
| 267 | }, | 271 | }, |
| 268 | ); | 272 | ); |
| 269 | yield* putDescriptorPointer(out.descriptor); | 273 | yield* putDescriptorPointer(out.descriptor); |
| @@ -0,0 +1,211 @@ | |||
| 1 | import { mkdtempSync } from "node:fs"; | ||
| 2 | import { tmpdir } from "node:os"; | ||
| 3 | import { join } from "node:path"; | ||
| 4 | |||
| 5 | import { describe, expect, it } from "vitest"; | ||
| 6 | import { Effect } from "effect"; | ||
| 7 | |||
| 8 | import { makeSelfHostAppsRuntime } from "../plugin/self-host-runtime"; | ||
| 9 | import { makeInMemoryAppsStore, makeTestResolver } from "../testing"; | ||
| 10 | import { PUBLISH_LIMITS } from "../pipeline/publish"; | ||
| 11 | import { fetchGitHubSource, syncGitHubSource } from "./github-source"; | ||
| 12 | |||
| 13 | const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); | ||
| 14 | |||
| 15 | const toolSource = (name = "ok"): string => `import { defineTool } from "executor:app"; | ||
| 16 | import { z } from "zod"; | ||
| 17 | export default defineTool({ | ||
| 18 | description: "${name}", | ||
| 19 | input: z.object({ value: z.string().default("${name}") }), | ||
| 20 | async handler(input) { return { value: input.value }; }, | ||
| 21 | });`; | ||
| 22 | |||
| 23 | const makeRuntime = () => | ||
| 24 | makeSelfHostAppsRuntime({ | ||
| 25 | dataDir: mkdtempSync(join(tmpdir(), "apps-gh-src-")), | ||
| 26 | store: makeInMemoryAppsStore(), | ||
| 27 | resolver: makeTestResolver({}), | ||
| 28 | inMemory: true, | ||
| 29 | }); | ||
| 30 | |||
| 31 | const json = (value: unknown, status = 200): Response => | ||
| 32 | new Response(JSON.stringify(value), { | ||
| 33 | status, | ||
| 34 | headers: { "content-type": "application/json" }, | ||
| 35 | }); | ||
| 36 | |||
| 37 | const makeGitHubFetch = (input: { | ||
| 38 | readonly files: ReadonlyMap<string, string>; | ||
| 39 | readonly upstreamSha?: string; | ||
| 40 | readonly treeSha?: string; | ||
| 41 | }) => { | ||
| 42 | const repoPath = "/repos/acme/tools"; | ||
| 43 | const upstreamSha = input.upstreamSha ?? "commit-1"; | ||
| 44 | const treeSha = input.treeSha ?? "tree-1"; | ||
| 45 | const blobBySha = new Map<string, { path: string; content: string }>(); | ||
| 46 | let index = 0; | ||
| 47 | for (const [path, content] of input.files) { | ||
| 48 | blobBySha.set(`blob-${++index}`, { path, content }); | ||
| 49 | } | ||
| 50 | let blobCalls = 0; | ||
| 51 | const fetch = (async (rawUrl: string) => { | ||
| 52 | const url = new URL(rawUrl); | ||
| 53 | if (url.pathname === repoPath) return json({ default_branch: "main" }); | ||
| 54 | if (url.pathname === `${repoPath}/git/ref/heads%2Fmain`) { | ||
| 55 | return json({ object: { sha: upstreamSha } }); | ||
| 56 | } | ||
| 57 | if (url.pathname === `${repoPath}/git/commits/${upstreamSha}`) { | ||
| 58 | return json({ sha: upstreamSha, tree: { sha: treeSha } }); | ||
| 59 | } | ||
| 60 | if (url.pathname === `${repoPath}/git/trees/${treeSha}`) { | ||
| 61 | return json({ | ||
| 62 | tree: [...blobBySha.entries()].map(([sha, blob]) => ({ | ||
| 63 | path: blob.path, | ||
| 64 | type: "blob", | ||
| 65 | sha, | ||
| 66 | size: Buffer.byteLength(blob.content, "utf8"), | ||
| 67 | })), | ||
| 68 | }); | ||
| 69 | } | ||
| 70 | const blobPrefix = `${repoPath}/git/blobs/`; | ||
| 71 | if (url.pathname.startsWith(blobPrefix)) { | ||
| 72 | blobCalls++; | ||
| 73 | const sha = decodeURIComponent(url.pathname.slice(blobPrefix.length)); | ||
| 74 | const blob = blobBySha.get(sha); | ||
| 75 | if (!blob) return json({ message: "not found" }, 404); | ||
| 76 | return json({ | ||
| 77 | encoding: "base64", | ||
| 78 | content: Buffer.from(blob.content, "utf8").toString("base64"), | ||
| 79 | }); | ||
| 80 | } | ||
| 81 | return json({ message: "not found" }, 404); | ||
| 82 | }) as typeof globalThis.fetch; | ||
| 83 | return { | ||
| 84 | fetch, | ||
| 85 | blobCalls: () => blobCalls, | ||
| 86 | }; | ||
| 87 | }; | ||
| 88 | |||
| 89 | describe("GitHub custom-tools source", () => { | ||
| 90 | it("fetches a repo fileset and publishes provenance, description, and skipped entries", async () => { | ||
| 91 | const files = new Map<string, string>([ | ||
| 92 | [ | ||
| 93 | "executor.json", | ||
| 94 | JSON.stringify({ | ||
| 95 | $schema: "https://example.test/schema", | ||
| 96 | description: "Acme tools", | ||
| 97 | ignored: true, | ||
| 98 | }), | ||
| 99 | ], | ||
| 100 | ["tools/hello.ts", toolSource("hello")], | ||
| 101 | ["workflows/deferred.ts", "export default {};"], | ||
| 102 | ["docs/readme.md", "ignored"], | ||
| 103 | ]); | ||
| 104 | const github = makeGitHubFetch({ files, upstreamSha: "commit-a" }); | ||
| 105 | const snapshot = await run(fetchGitHubSource({ repo: "acme/tools", fetch: github.fetch })); | ||
| 106 | expect([...snapshot.files.keys()].sort()).toEqual([ | ||
| 107 | "executor.json", | ||
| 108 | "tools/hello.ts", | ||
| 109 | "workflows/deferred.ts", | ||
| 110 | ]); | ||
| 111 | expect(snapshot.description).toBe("Acme tools"); | ||
| 112 | |||
| 113 | const host = makeRuntime(); | ||
| 114 | const result = await run( | ||
| 115 | syncGitHubSource({ | ||
| 116 | runtime: host.runtime, | ||
| 117 | scope: "githubTools", | ||
| 118 | repo: "acme/tools", | ||
| 119 | fetch: github.fetch, | ||
| 120 | }), | ||
| 121 | ); | ||
| 122 | expect(result.status).toBe("published"); | ||
| 123 | expect(result.tools).toEqual(["hello"]); | ||
| 124 | expect(result.skipped).toEqual([ | ||
| 125 | { path: "workflows/deferred.ts", reason: "not supported yet" }, | ||
| 126 | ]); | ||
| 127 | const descriptor = await run(host.runtime.getDescriptor("githubTools")); | ||
| 128 | expect(descriptor?.description).toBe("Acme tools"); | ||
| 129 | expect(descriptor?.source).toEqual({ | ||
| 130 | kind: "github", | ||
| 131 | repo: "acme/tools", | ||
| 132 | ref: "main", | ||
| 133 | upstreamSha: "commit-a", | ||
| 134 | }); | ||
| 135 | await host.close(); | ||
| 136 | }); | ||
| 137 | |||
| 138 | it("reports up-to-date when the upstream commit SHA is unchanged", async () => { | ||
| 139 | const github = makeGitHubFetch({ | ||
| 140 | files: new Map([["tools/hello.ts", toolSource("hello")]]), | ||
| 141 | upstreamSha: "same-sha", | ||
| 142 | }); | ||
| 143 | const host = makeRuntime(); | ||
| 144 | const first = await run( | ||
| 145 | syncGitHubSource({ | ||
| 146 | runtime: host.runtime, | ||
| 147 | scope: "githubTools", | ||
| 148 | repo: "acme/tools", | ||
| 149 | fetch: github.fetch, | ||
| 150 | }), | ||
| 151 | ); | ||
| 152 | const second = await run( | ||
| 153 | syncGitHubSource({ | ||
| 154 | runtime: host.runtime, | ||
| 155 | scope: "githubTools", | ||
| 156 | repo: "acme/tools", | ||
| 157 | fetch: github.fetch, | ||
| 158 | }), | ||
| 159 | ); | ||
| 160 | expect(first.status).toBe("published"); | ||
| 161 | expect(second).toEqual({ | ||
| 162 | status: "up-to-date", | ||
| 163 | upstreamSha: "same-sha", | ||
| 164 | tools: ["hello"], | ||
| 165 | skipped: [], | ||
| 166 | }); | ||
| 167 | await host.close(); | ||
| 168 | }); | ||
| 169 | |||
| 170 | it("rejects oversized trees before fetching blobs", async () => { | ||
| 171 | const files = new Map<string, string>(); | ||
| 172 | for (let i = 0; i < PUBLISH_LIMITS.maxFiles + 1; i++) { | ||
| 173 | files.set(`tools/t${i}.ts`, toolSource(`t${i}`)); | ||
| 174 | } | ||
| 175 | const github = makeGitHubFetch({ files }); | ||
| 176 | const exit = await Effect.runPromiseExit( | ||
| 177 | fetchGitHubSource({ repo: "acme/tools", fetch: github.fetch }), | ||
| 178 | ); | ||
| 179 | expect(exit._tag).toBe("Failure"); | ||
| 180 | expect(JSON.stringify(exit)).toContain("exceeding the limit"); | ||
| 181 | expect(github.blobCalls()).toBe(0); | ||
| 182 | }); | ||
| 183 | |||
| 184 | it("returns typed failure data for publish errors", async () => { | ||
| 185 | const github = makeGitHubFetch({ | ||
| 186 | files: new Map([ | ||
| 187 | [ | ||
| 188 | "tools/bad.ts", | ||
| 189 | `import { defineTool } from "executor:app"; | ||
| 190 | import { chunk } from "lodash"; | ||
| 191 | export default defineTool({ description: "bad", input: { type: "object" }, async handler(){ return chunk([1], 1); } });`, | ||
| 192 | ], | ||
| 193 | ]), | ||
| 194 | upstreamSha: "bad-sha", | ||
| 195 | }); | ||
| 196 | const host = makeRuntime(); | ||
| 197 | const result = await run( | ||
| 198 | syncGitHubSource({ | ||
| 199 | runtime: host.runtime, | ||
| 200 | scope: "githubTools", | ||
| 201 | repo: "acme/tools", | ||
| 202 | fetch: github.fetch, | ||
| 203 | }), | ||
| 204 | ); | ||
| 205 | expect(result.status).toBe("failed"); | ||
| 206 | expect(result.upstreamSha).toBe("bad-sha"); | ||
| 207 | expect(result.errors?.[0]?.stage).toBe("bundle"); | ||
| 208 | expect(result.errors?.[0]?.message).toContain('bare import "lodash" is not allowed'); | ||
| 209 | await host.close(); | ||
| 210 | }); | ||
| 211 | }); | ||
| @@ -0,0 +1,402 @@ | |||
| 1 | import { Data, Effect, Result } from "effect"; | ||
| 2 | |||
| 3 | import { PublishError, type FileDiagnostic, type SkippedArtifact } from "../pipeline/discover"; | ||
| 4 | import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish"; | ||
| 5 | import type { AppSourceRef } from "../pipeline/descriptor"; | ||
| 6 | import type { AppsRuntime } from "../plugin/runtime"; | ||
| 7 | import type { FileSet, SnapshotId } from "../seams/artifact-store"; | ||
| 8 | |||
| 9 | export interface GitHubSourceInput { | ||
| 10 | readonly repo: string; | ||
| 11 | readonly ref?: string; | ||
| 12 | readonly token?: string | null; | ||
| 13 | readonly baseUrl?: string; | ||
| 14 | readonly fetch?: typeof globalThis.fetch; | ||
| 15 | } | ||
| 16 | |||
| 17 | export interface GitHubSourceSnapshot { | ||
| 18 | readonly files: FileSet; | ||
| 19 | readonly repo: string; | ||
| 20 | readonly ref: string; | ||
| 21 | readonly upstreamSha: string; | ||
| 22 | readonly description?: string; | ||
| 23 | } | ||
| 24 | |||
| 25 | export class GitHubSourceError extends Data.TaggedError("GitHubSourceError")<{ | ||
| 26 | readonly message: string; | ||
| 27 | readonly status?: number; | ||
| 28 | readonly path?: string; | ||
| 29 | readonly cause?: unknown; | ||
| 30 | }> {} | ||
| 31 | |||
| 32 | export interface SyncErrorData { | ||
| 33 | readonly stage: "source" | "discover" | "bundle" | "collect" | "project"; | ||
| 34 | readonly message: string; | ||
| 35 | readonly diagnostics?: readonly FileDiagnostic[]; | ||
| 36 | } | ||
| 37 | |||
| 38 | export type GitHubSyncResult = | ||
| 39 | | { | ||
| 40 | readonly status: "published"; | ||
| 41 | readonly snapshotId: SnapshotId; | ||
| 42 | readonly upstreamSha: string; | ||
| 43 | readonly tools: readonly string[]; | ||
| 44 | readonly skipped: readonly SkippedArtifact[]; | ||
| 45 | readonly errors?: undefined; | ||
| 46 | } | ||
| 47 | | { | ||
| 48 | readonly status: "up-to-date"; | ||
| 49 | readonly upstreamSha: string; | ||
| 50 | readonly tools: readonly string[]; | ||
| 51 | readonly skipped: readonly SkippedArtifact[]; | ||
| 52 | readonly errors?: undefined; | ||
| 53 | } | ||
| 54 | | { | ||
| 55 | readonly status: "failed"; | ||
| 56 | readonly upstreamSha?: string; | ||
| 57 | readonly tools: readonly string[]; | ||
| 58 | readonly skipped: readonly SkippedArtifact[]; | ||
| 59 | readonly errors: readonly SyncErrorData[]; | ||
| 60 | }; | ||
| 61 | |||
| 62 | export interface SyncGitHubSourceInput extends GitHubSourceInput { | ||
| 63 | readonly runtime: AppsRuntime; | ||
| 64 | readonly scope: string; | ||
| 65 | } | ||
| 66 | |||
| 67 | const MATERIALIZED_RE = /^(tools|workflows|ui|skills)\//; | ||
| 68 | |||
| 69 | const trimBaseUrl = (baseUrl: string): string => baseUrl.replace(/\/+$/, ""); | ||
| 70 | |||
| 71 | const repoParts = (repo: string): { owner: string; name: string } | null => { | ||
| 72 | const [owner, name, ...rest] = repo.split("/"); | ||
| 73 | if (!owner || !name || rest.length > 0) return null; | ||
| 74 | return { owner, name }; | ||
| 75 | }; | ||
| 76 | |||
| 77 | const encodedRepoPath = (repo: string): Effect.Effect<string, GitHubSourceError> => { | ||
| 78 | const parsed = repoParts(repo); | ||
| 79 | if (!parsed) { | ||
| 80 | return Effect.fail( | ||
| 81 | new GitHubSourceError({ | ||
| 82 | message: `GitHub repo must be "owner/name"; got "${repo}"`, | ||
| 83 | path: repo, | ||
| 84 | }), | ||
| 85 | ); | ||
| 86 | } | ||
| 87 | return Effect.succeed(`${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}`); | ||
| 88 | }; | ||
| 89 | |||
| 90 | const materializes = (path: string): boolean => | ||
| 91 | path === "executor.json" || MATERIALIZED_RE.test(path); | ||
| 92 | |||
| 93 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 94 | value !== null && typeof value === "object" && !Array.isArray(value); | ||
| 95 | |||
| 96 | const asString = (value: unknown): string | undefined => | ||
| 97 | typeof value === "string" && value.length > 0 ? value : undefined; | ||
| 98 | |||
| 99 | const requestJson = <A>( | ||
| 100 | input: GitHubSourceInput, | ||
| 101 | path: string, | ||
| 102 | ): Effect.Effect<A, GitHubSourceError> => | ||
| 103 | Effect.tryPromise({ | ||
| 104 | try: async () => { | ||
| 105 | const fetchImpl = input.fetch ?? globalThis.fetch; | ||
| 106 | const headers: Record<string, string> = { | ||
| 107 | accept: "application/vnd.github+json", | ||
| 108 | "user-agent": "executor-apps-github-source", | ||
| 109 | }; | ||
| 110 | if (input.token) headers.authorization = `Bearer ${input.token}`; | ||
| 111 | const response = await fetchImpl( | ||
| 112 | `${trimBaseUrl(input.baseUrl ?? "https://api.github.com")}${path}`, | ||
| 113 | { | ||
| 114 | headers, | ||
| 115 | }, | ||
| 116 | ); | ||
| 117 | if (!response.ok) { | ||
| 118 | throw new GitHubSourceError({ | ||
| 119 | message: `GitHub request failed: GET ${path} -> ${response.status}`, | ||
| 120 | status: response.status, | ||
| 121 | path, | ||
| 122 | }); | ||
| 123 | } | ||
| 124 | return (await response.json()) as A; | ||
| 125 | }, | ||
| 126 | catch: (cause) => | ||
| 127 | cause instanceof GitHubSourceError | ||
| 128 | ? cause | ||
| 129 | : new GitHubSourceError({ | ||
| 130 | message: `GitHub request failed: GET ${path}`, | ||
| 131 | path, | ||
| 132 | cause, | ||
| 133 | }), | ||
| 134 | }); | ||
| 135 | |||
| 136 | interface RepoResponse { | ||
| 137 | readonly default_branch?: unknown; | ||
| 138 | } | ||
| 139 | |||
| 140 | interface CommitResponse { | ||
| 141 | readonly sha?: unknown; | ||
| 142 | readonly commit?: { | ||
| 143 | readonly tree?: { | ||
| 144 | readonly sha?: unknown; | ||
| 145 | }; | ||
| 146 | }; | ||
| 147 | } | ||
| 148 | |||
| 149 | interface GitCommitResponse { | ||
| 150 | readonly sha?: unknown; | ||
| 151 | readonly tree?: { | ||
| 152 | readonly sha?: unknown; | ||
| 153 | }; | ||
| 154 | } | ||
| 155 | |||
| 156 | interface RefResponse { | ||
| 157 | readonly object?: { | ||
| 158 | readonly sha?: unknown; | ||
| 159 | }; | ||
| 160 | } | ||
| 161 | |||
| 162 | interface TreeEntry { | ||
| 163 | readonly path?: unknown; | ||
| 164 | readonly type?: unknown; | ||
| 165 | readonly sha?: unknown; | ||
| 166 | readonly size?: unknown; | ||
| 167 | } | ||
| 168 | |||
| 169 | interface TreeResponse { | ||
| 170 | readonly tree?: readonly TreeEntry[]; | ||
| 171 | readonly truncated?: unknown; | ||
| 172 | } | ||
| 173 | |||
| 174 | interface BlobResponse { | ||
| 175 | readonly content?: unknown; | ||
| 176 | readonly encoding?: unknown; | ||
| 177 | } | ||
| 178 | |||
| 179 | const commitTreeSha = (commit: CommitResponse | GitCommitResponse): string | undefined => { | ||
| 180 | if ("commit" in commit) return asString(commit.commit?.tree?.sha); | ||
| 181 | if ("tree" in commit) return asString(commit.tree?.sha); | ||
| 182 | return undefined; | ||
| 183 | }; | ||
| 184 | |||
| 185 | const limitError = (diagnostics: readonly FileDiagnostic[]): PublishError => | ||
| 186 | new PublishError({ | ||
| 187 | message: `publish payload exceeds limits (${diagnostics.length} problem(s))`, | ||
| 188 | stage: "discover", | ||
| 189 | diagnostics, | ||
| 190 | }); | ||
| 191 | |||
| 192 | const checkTreeLimits = (entries: readonly TreeEntry[]): PublishError | null => { | ||
| 193 | const diagnostics: FileDiagnostic[] = []; | ||
| 194 | if (entries.length > PUBLISH_LIMITS.maxFiles) { | ||
| 195 | diagnostics.push({ | ||
| 196 | path: "", | ||
| 197 | message: `publish has ${entries.length} files, exceeding the limit of ${PUBLISH_LIMITS.maxFiles}`, | ||
| 198 | }); | ||
| 199 | } | ||
| 200 | let total = 0; | ||
| 201 | for (const entry of entries) { | ||
| 202 | const path = String(entry.path ?? ""); | ||
| 203 | const size = typeof entry.size === "number" ? entry.size : 0; | ||
| 204 | total += size; | ||
| 205 | if (size > PUBLISH_LIMITS.maxFileBytes) { | ||
| 206 | diagnostics.push({ | ||
| 207 | path, | ||
| 208 | message: `file is ${size} bytes, exceeding the per-file limit of ${PUBLISH_LIMITS.maxFileBytes} bytes`, | ||
| 209 | }); | ||
| 210 | } | ||
| 211 | } | ||
| 212 | if (total > PUBLISH_LIMITS.maxTotalBytes) { | ||
| 213 | diagnostics.push({ | ||
| 214 | path: "", | ||
| 215 | message: `publish total is ${total} bytes, exceeding the total limit of ${PUBLISH_LIMITS.maxTotalBytes} bytes`, | ||
| 216 | }); | ||
| 217 | } | ||
| 218 | return diagnostics.length === 0 ? null : limitError(diagnostics); | ||
| 219 | }; | ||
| 220 | |||
| 221 | const decodeBlob = (path: string, blob: BlobResponse): Effect.Effect<string, GitHubSourceError> => | ||
| 222 | Effect.try({ | ||
| 223 | try: () => { | ||
| 224 | const encoding = asString(blob.encoding); | ||
| 225 | const content = asString(blob.content); | ||
| 226 | if (!content || encoding !== "base64") { | ||
| 227 | throw new GitHubSourceError({ | ||
| 228 | message: `GitHub blob ${path} did not return base64 content`, | ||
| 229 | path, | ||
| 230 | }); | ||
| 231 | } | ||
| 232 | return Buffer.from(content.replace(/\s/g, ""), "base64").toString("utf8"); | ||
| 233 | }, | ||
| 234 | catch: (cause) => | ||
| 235 | cause instanceof GitHubSourceError | ||
| 236 | ? cause | ||
| 237 | : new GitHubSourceError({ message: `Failed to decode GitHub blob ${path}`, path, cause }), | ||
| 238 | }); | ||
| 239 | |||
| 240 | const executorDescription = ( | ||
| 241 | files: FileSet, | ||
| 242 | ): Effect.Effect<string | undefined, GitHubSourceError> => | ||
| 243 | Effect.try({ | ||
| 244 | try: () => { | ||
| 245 | const raw = files.get("executor.json"); | ||
| 246 | if (!raw) return undefined; | ||
| 247 | const parsed = JSON.parse(raw) as unknown; | ||
| 248 | if (!isRecord(parsed)) return undefined; | ||
| 249 | const description = parsed.description; | ||
| 250 | return typeof description === "string" ? description : undefined; | ||
| 251 | }, | ||
| 252 | catch: (cause) => | ||
| 253 | new GitHubSourceError({ | ||
| 254 | message: "executor.json is not valid JSON", | ||
| 255 | path: "executor.json", | ||
| 256 | cause, | ||
| 257 | }), | ||
| 258 | }); | ||
| 259 | |||
| 260 | export const fetchGitHubSource = ( | ||
| 261 | input: GitHubSourceInput, | ||
| 262 | ): Effect.Effect<GitHubSourceSnapshot, GitHubSourceError | PublishError> => | ||
| 263 | Effect.gen(function* () { | ||
| 264 | const repoPath = yield* encodedRepoPath(input.repo); | ||
| 265 | const repo = yield* requestJson<RepoResponse>(input, `/repos/${repoPath}`); | ||
| 266 | const ref = input.ref ?? asString(repo.default_branch) ?? "main"; | ||
| 267 | const branchRef = yield* requestJson<RefResponse>( | ||
| 268 | input, | ||
| 269 | `/repos/${repoPath}/git/ref/${encodeURIComponent(`heads/${ref}`)}`, | ||
| 270 | ).pipe(Effect.result); | ||
| 271 | const commitPath = | ||
| 272 | Result.isSuccess(branchRef) && asString(branchRef.success.object?.sha) | ||
| 273 | ? `/repos/${repoPath}/git/commits/${encodeURIComponent(String(branchRef.success.object?.sha))}` | ||
| 274 | : `/repos/${repoPath}/commits/${encodeURIComponent(ref)}`; | ||
| 275 | const commit = yield* requestJson<CommitResponse | GitCommitResponse>(input, commitPath); | ||
| 276 | const upstreamSha = asString(commit.sha); | ||
| 277 | const treeSha = commitTreeSha(commit); | ||
| 278 | if (!upstreamSha || !treeSha) { | ||
| 279 | return yield* new GitHubSourceError({ | ||
| 280 | message: `GitHub commit ${ref} did not include a commit SHA and tree SHA`, | ||
| 281 | path: commitPath, | ||
| 282 | }); | ||
| 283 | } | ||
| 284 | |||
| 285 | const tree = yield* requestJson<TreeResponse>( | ||
| 286 | input, | ||
| 287 | `/repos/${repoPath}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, | ||
| 288 | ); | ||
| 289 | if (tree.truncated === true) { | ||
| 290 | return yield* new GitHubSourceError({ | ||
| 291 | message: "GitHub returned a truncated repository tree; custom tools source is too large", | ||
| 292 | path: `/repos/${repoPath}/git/trees/${treeSha}`, | ||
| 293 | }); | ||
| 294 | } | ||
| 295 | const entries = (tree.tree ?? []).filter( | ||
| 296 | (entry) => | ||
| 297 | entry.type === "blob" && | ||
| 298 | typeof entry.path === "string" && | ||
| 299 | typeof entry.sha === "string" && | ||
| 300 | materializes(entry.path), | ||
| 301 | ); | ||
| 302 | const treeLimitError = checkTreeLimits(entries); | ||
| 303 | if (treeLimitError) return yield* Effect.fail(treeLimitError); | ||
| 304 | |||
| 305 | const files = new Map<string, string>(); | ||
| 306 | for (const entry of entries) { | ||
| 307 | const path = String(entry.path); | ||
| 308 | const blob = yield* requestJson<BlobResponse>( | ||
| 309 | input, | ||
| 310 | `/repos/${repoPath}/git/blobs/${encodeURIComponent(String(entry.sha))}`, | ||
| 311 | ); | ||
| 312 | files.set(path, yield* decodeBlob(path, blob)); | ||
| 313 | } | ||
| 314 | const payloadLimitError = enforcePublishLimits(files); | ||
| 315 | if (payloadLimitError) return yield* Effect.fail(payloadLimitError); | ||
| 316 | |||
| 317 | return { | ||
| 318 | files, | ||
| 319 | repo: input.repo, | ||
| 320 | ref, | ||
| 321 | upstreamSha, | ||
| 322 | description: yield* executorDescription(files), | ||
| 323 | }; | ||
| 324 | }); | ||
| 325 | |||
| 326 | const publishErrorToSyncError = (error: PublishError): SyncErrorData => ({ | ||
| 327 | stage: error.stage, | ||
| 328 | message: error.message, | ||
| 329 | diagnostics: error.diagnostics, | ||
| 330 | }); | ||
| 331 | |||
| 332 | const sourceErrorToSyncError = (error: GitHubSourceError): SyncErrorData => ({ | ||
| 333 | stage: "source", | ||
| 334 | message: error.message, | ||
| 335 | diagnostics: error.path ? [{ path: error.path, message: error.message }] : [], | ||
| 336 | }); | ||
| 337 | |||
| 338 | const sourceRef = (snapshot: GitHubSourceSnapshot): AppSourceRef => ({ | ||
| 339 | kind: "github", | ||
| 340 | repo: snapshot.repo, | ||
| 341 | ref: snapshot.ref, | ||
| 342 | upstreamSha: snapshot.upstreamSha, | ||
| 343 | }); | ||
| 344 | |||
| 345 | export const syncGitHubSource = (input: SyncGitHubSourceInput): Effect.Effect<GitHubSyncResult> => | ||
| 346 | Effect.gen(function* () { | ||
| 347 | const fetched = yield* fetchGitHubSource(input).pipe(Effect.result); | ||
| 348 | if (Result.isFailure(fetched)) { | ||
| 349 | const error = fetched.failure; | ||
| 350 | return { | ||
| 351 | status: "failed", | ||
| 352 | tools: [], | ||
| 353 | skipped: [], | ||
| 354 | errors: [ | ||
| 355 | error instanceof PublishError | ||
| 356 | ? publishErrorToSyncError(error) | ||
| 357 | : sourceErrorToSyncError(error), | ||
| 358 | ], | ||
| 359 | } satisfies GitHubSyncResult; | ||
| 360 | } | ||
| 361 | const snapshot = fetched.success; | ||
| 362 | const current = yield* input.runtime.getDescriptor(input.scope); | ||
| 363 | if ( | ||
| 364 | current?.source?.kind === "github" && | ||
| 365 | current.source.repo === snapshot.repo && | ||
| 366 | current.source.upstreamSha === snapshot.upstreamSha | ||
| 367 | ) { | ||
| 368 | return { | ||
| 369 | status: "up-to-date", | ||
| 370 | upstreamSha: snapshot.upstreamSha, | ||
| 371 | tools: current.tools.map((tool) => tool.name), | ||
| 372 | skipped: current.skipped ?? [], | ||
| 373 | } satisfies GitHubSyncResult; | ||
| 374 | } | ||
| 375 | |||
| 376 | const published = yield* input.runtime | ||
| 377 | .publish({ | ||
| 378 | scope: input.scope, | ||
| 379 | files: snapshot.files, | ||
| 380 | description: snapshot.description, | ||
| 381 | source: sourceRef(snapshot), | ||
| 382 | message: `sync ${snapshot.repo}@${snapshot.upstreamSha}`, | ||
| 383 | }) | ||
| 384 | .pipe(Effect.result); | ||
| 385 | if (Result.isFailure(published)) { | ||
| 386 | return { | ||
| 387 | status: "failed", | ||
| 388 | upstreamSha: snapshot.upstreamSha, | ||
| 389 | tools: [], | ||
| 390 | skipped: [], | ||
| 391 | errors: [publishErrorToSyncError(published.failure)], | ||
| 392 | } satisfies GitHubSyncResult; | ||
| 393 | } | ||
| 394 | |||
| 395 | return { | ||
| 396 | status: "published", | ||
| 397 | snapshotId: published.success.snapshotId, | ||
| 398 | upstreamSha: snapshot.upstreamSha, | ||
| 399 | tools: published.success.descriptor.tools.map((tool) => tool.name), | ||
| 400 | skipped: published.success.skipped, | ||
| 401 | } satisfies GitHubSyncResult; | ||
| 402 | }); | ||