Showing the changes to apps (8 of 18 files). Show all
Unified · Split
| @@ -6,9 +6,10 @@ import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; · 5 unmodified lines | |||
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; | 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| 8 | import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | 8 | import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; |
| 9 | import { appsPlugin } from "@executor-js/plugin-apps/api"; | ||
| 9 | 10 | ||
| 10 | import { resolveSecretKey } from "./src/config"; | 11 | import { resolveSecretKey } from "./src/config"; |
| 11 | import { getSelfHostAppsSubsystem } from "./src/apps"; | 12 | import { getSelfHostAppsBackings } from "./src/apps-backings"; |
| 12 | 13 | ||
| 13 | // --------------------------------------------------------------------------- | 14 | // --------------------------------------------------------------------------- |
| 14 | // Single source of truth for the self-hosted app's plugin list. | 15 | // Single source of truth for the self-hosted app's plugin list. |
| @@ -37,7 +38,7 @@ export default defineExecutorConfig({ · 22 unmodified lines | |||
| 37 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), | 38 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 38 | graphqlHttpPlugin(), | 39 | graphqlHttpPlugin(), |
| 39 | toolkitsPlugin({ activeToolkitSlug }), | 40 | toolkitsPlugin({ activeToolkitSlug }), |
| 40 | getSelfHostAppsSubsystem().plugin, | 41 | appsPlugin({ backings: getSelfHostAppsBackings() }), |
| 41 | // First writable secret provider -> the default for `secrets.set`. | 42 | // First writable secret provider -> the default for `secrets.set`. |
| 42 | encryptedSecretsPlugin({ key: resolveSecretKey() }), | 43 | encryptedSecretsPlugin({ key: resolveSecretKey() }), |
| 43 | ] as const, | 44 | ] as const, |
| @@ -22,8 +22,7 @@ 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"; | 25 | import { closeSelfHostAppsBackings } from "./apps-backings"; |
| 26 | import { makeSelfHostAppsSyncRoute } from "./apps-route"; | ||
| 27 | 26 | ||
| 28 | // =========================================================================== | 27 | // =========================================================================== |
| 29 | // The self-hosted Executor app, as ONE `ExecutorApp.make` call. | 28 | // The self-hosted Executor app, as ONE `ExecutorApp.make` call. |
| @@ -121,7 +120,6 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 91 unmodified lines | |||
| 121 | makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), | 120 | makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), |
| 122 | // Public system API: /api/health + /api/setup-status (unauthenticated). | 121 | // Public system API: /api/health + /api/setup-status (unauthenticated). |
| 123 | makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), | 122 | makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), |
| 124 | makeSelfHostAppsSyncRoute({ identity: identityLayer, db: dbHandle }), | ||
| 125 | // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). | 123 | // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). |
| 126 | HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), | 124 | HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), |
| 127 | ], | 125 | ], |
| @@ -142,7 +140,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 14 unmodified lines | |||
| 142 | toWebHandler, | 140 | toWebHandler, |
| 143 | betterAuth, | 141 | betterAuth, |
| 144 | closeDb: async () => { | 142 | closeDb: async () => { |
| 145 | await closeSelfHostAppsSubsystem(); | 143 | await closeSelfHostAppsBackings(); |
| 146 | await mcp.close(); | 144 | await mcp.close(); |
| 147 | await dbHandle.close(); | 145 | await dbHandle.close(); |
| 148 | }, | 146 | }, |
| @@ -0,0 +1,52 @@ | |||
| 1 | import { join } from "node:path"; | ||
| 2 | |||
| 3 | import { Effect } from "effect"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | makeGitArtifactStore, | ||
| 7 | makeLibsqlScopeDb, | ||
| 8 | makeQuickjsToolSandbox, | ||
| 9 | makeSqliteAppsStore, | ||
| 10 | type AppsBackings, | ||
| 11 | } from "@executor-js/plugin-apps/api"; | ||
| 12 | |||
| 13 | import { resolveDataDir } from "./config"; | ||
| 14 | |||
| 15 | interface SelfHostAppsBackings { | ||
| 16 | readonly backings: AppsBackings; | ||
| 17 | readonly close: () => Promise<void>; | ||
| 18 | } | ||
| 19 | |||
| 20 | const createBackings = (dataDir: string): SelfHostAppsBackings => { | ||
| 21 | const appsDir = join(dataDir, "apps"); | ||
| 22 | const scopeDb = makeLibsqlScopeDb({ | ||
| 23 | root: join(appsDir, "scope-db"), | ||
| 24 | }); | ||
| 25 | return { | ||
| 26 | backings: { | ||
| 27 | artifactStore: makeGitArtifactStore({ root: join(appsDir, "artifacts") }), | ||
| 28 | scopeDb, | ||
| 29 | sandbox: makeQuickjsToolSandbox(), | ||
| 30 | store: makeSqliteAppsStore({ path: join(appsDir, "store.sqlite") }), | ||
| 31 | }, | ||
| 32 | close: async () => { | ||
| 33 | await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined))); | ||
| 34 | }, | ||
| 35 | }; | ||
| 36 | }; | ||
| 37 | |||
| 38 | let current: { readonly dataDir: string; readonly value: SelfHostAppsBackings } | undefined; | ||
| 39 | |||
| 40 | export const getSelfHostAppsBackings = (): AppsBackings => { | ||
| 41 | const dataDir = resolveDataDir(); | ||
| 42 | if (current && current.dataDir === dataDir) return current.value.backings; | ||
| 43 | const value = createBackings(dataDir); | ||
| 44 | current = { dataDir, value }; | ||
| 45 | return value.backings; | ||
| 46 | }; | ||
| 47 | |||
| 48 | export const closeSelfHostAppsBackings = async (): Promise<void> => { | ||
| 49 | const value = current?.value; | ||
| 50 | current = undefined; | ||
| 51 | await value?.close(); | ||
| 52 | }; | ||
| @@ -1,120 +0,0 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | ConnectionName, | ||
| 5 | IntegrationSlug, | ||
| 6 | ToolAddress, | ||
| 7 | isToolResult, | ||
| 8 | type ConnectionRef, | ||
| 9 | type Owner, | ||
| 10 | type PluginCtx, | ||
| 11 | } from "@executor-js/sdk"; | ||
| 12 | import { | ||
| 13 | BindingError, | ||
| 14 | type ClientResolver, | ||
| 15 | type ConnectionCandidate, | ||
| 16 | } from "@executor-js/plugin-apps/api"; | ||
| 17 | |||
| 18 | const parseConnectionAddress = (address: string): ConnectionRef | null => { | ||
| 19 | const parts = address.split("."); | ||
| 20 | if (parts.length !== 4 || parts[0] !== "tools") return null; | ||
| 21 | const [, integration, owner, name] = parts; | ||
| 22 | if (!integration || !name) return null; | ||
| 23 | if (owner !== "org" && owner !== "user") return null; | ||
| 24 | return { | ||
| 25 | owner: owner as Owner, | ||
| 26 | integration: IntegrationSlug.make(integration), | ||
| 27 | name: ConnectionName.make(name), | ||
| 28 | }; | ||
| 29 | }; | ||
| 30 | |||
| 31 | const toCandidate = (connection: { | ||
| 32 | readonly address: unknown; | ||
| 33 | readonly integration: unknown; | ||
| 34 | readonly name?: unknown; | ||
| 35 | readonly owner?: unknown; | ||
| 36 | }): ConnectionCandidate => ({ | ||
| 37 | address: String(connection.address), | ||
| 38 | integration: String(connection.integration), | ||
| 39 | ...(connection.name !== undefined ? { name: String(connection.name) } : {}), | ||
| 40 | ...(connection.owner !== undefined ? { owner: String(connection.owner) } : {}), | ||
| 41 | }); | ||
| 42 | |||
| 43 | export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): ClientResolver => ({ | ||
| 44 | listConnections: ({ integration }) => | ||
| 45 | input.ctx.connections.list({ integration: IntegrationSlug.make(integration) }).pipe( | ||
| 46 | Effect.map((connections) => connections.map(toCandidate)), | ||
| 47 | Effect.mapError( | ||
| 48 | () => | ||
| 49 | new BindingError({ | ||
| 50 | role: integration, | ||
| 51 | integration, | ||
| 52 | message: `failed to list ${integration} connections`, | ||
| 53 | }), | ||
| 54 | ), | ||
| 55 | ), | ||
| 56 | |||
| 57 | resolveConnection: ({ connection }) => | ||
| 58 | Effect.gen(function* () { | ||
| 59 | const ref = parseConnectionAddress(connection); | ||
| 60 | if (!ref) return null; | ||
| 61 | const row = yield* input.ctx.connections.get(ref).pipe( | ||
| 62 | Effect.mapError( | ||
| 63 | () => | ||
| 64 | new BindingError({ | ||
| 65 | role: String(ref.integration), | ||
| 66 | integration: String(ref.integration), | ||
| 67 | requestedConnection: connection, | ||
| 68 | message: `failed to resolve connection ${connection}`, | ||
| 69 | }), | ||
| 70 | ), | ||
| 71 | ); | ||
| 72 | return row ? toCandidate(row) : null; | ||
| 73 | }), | ||
| 74 | |||
| 75 | call: ({ integration, connection, path, args, invokeOptions }) => | ||
| 76 | Effect.gen(function* () { | ||
| 77 | const ref = parseConnectionAddress(connection); | ||
| 78 | if (!ref) { | ||
| 79 | return yield* new BindingError({ | ||
| 80 | role: integration, | ||
| 81 | integration, | ||
| 82 | requestedConnection: connection, | ||
| 83 | message: `invalid connection address ${connection}`, | ||
| 84 | }); | ||
| 85 | } | ||
| 86 | if (String(ref.integration) !== integration) { | ||
| 87 | return yield* new BindingError({ | ||
| 88 | role: integration, | ||
| 89 | integration, | ||
| 90 | requestedConnection: connection, | ||
| 91 | message: `connection "${connection}" belongs to integration "${ref.integration}", not "${integration}"`, | ||
| 92 | }); | ||
| 93 | } | ||
| 94 | const tool = path.join("."); | ||
| 95 | const address = ToolAddress.make(`${connection}.${tool}`); | ||
| 96 | const payload = args[0] ?? {}; | ||
| 97 | const result = yield* input.ctx.execute(address, payload, invokeOptions).pipe( | ||
| 98 | Effect.mapError( | ||
| 99 | () => | ||
| 100 | new BindingError({ | ||
| 101 | role: integration, | ||
| 102 | integration, | ||
| 103 | requestedConnection: connection, | ||
| 104 | message: `failed to execute ${address}`, | ||
| 105 | }), | ||
| 106 | ), | ||
| 107 | ); | ||
| 108 | if (isToolResult(result)) { | ||
| 109 | if (result.ok) return result.data; | ||
| 110 | const { message } = result.error; | ||
| 111 | return yield* new BindingError({ | ||
| 112 | role: integration, | ||
| 113 | integration, | ||
| 114 | requestedConnection: connection, | ||
| 115 | message, | ||
| 116 | }); | ||
| 117 | } | ||
| 118 | return result; | ||
| 119 | }), | ||
| 120 | }); | ||
| @@ -1,166 +0,0 @@ | |||
| 1 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | ||
| 2 | import { Data, Effect, Layer, Predicate } from "effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | IdentityProvider, | ||
| 6 | RequestOrgSlug, | ||
| 7 | RequestWebOrigin, | ||
| 8 | makeScopedExecutor, | ||
| 9 | type Principal, | ||
| 10 | } from "@executor-js/api/server"; | ||
| 11 | import { IntegrationSlug, 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 isAppsSyncRouteError = (failure: unknown): failure is AppsSyncRouteError => | ||
| 23 | Predicate.isTagged("AppsSyncRouteError")(failure); | ||
| 24 | |||
| 25 | const parseJson = (request: Request): Effect.Effect<unknown, AppsSyncRouteError> => | ||
| 26 | Effect.tryPromise({ | ||
| 27 | try: () => request.json() as Promise<unknown>, | ||
| 28 | catch: () => | ||
| 29 | new AppsSyncRouteError({ | ||
| 30 | status: 400, | ||
| 31 | message: "Invalid JSON body", | ||
| 32 | }), | ||
| 33 | }); | ||
| 34 | |||
| 35 | const buildExecutor = (principal: Principal, request: Request) => { | ||
| 36 | const withOrigin = makeScopedExecutor<SelfHostPlugins>( | ||
| 37 | principal.accountId, | ||
| 38 | principal.organizationId, | ||
| 39 | principal.organizationName, | ||
| 40 | ).pipe(Effect.provideService(RequestWebOrigin, { origin: new URL(request.url).origin })); | ||
| 41 | return principal.organizationSlug | ||
| 42 | ? withOrigin.pipe(Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug })) | ||
| 43 | : withOrigin; | ||
| 44 | }; | ||
| 45 | |||
| 46 | const sourceSlugFromRoute = () => | ||
| 47 | HttpRouter.params.pipe( | ||
| 48 | Effect.map((params) => params.slug ?? ""), | ||
| 49 | Effect.flatMap((slug) => | ||
| 50 | slug.length > 0 | ||
| 51 | ? Effect.succeed(slug) | ||
| 52 | : Effect.fail( | ||
| 53 | new AppsSyncRouteError({ | ||
| 54 | status: 400, | ||
| 55 | message: "Missing custom tools source slug", | ||
| 56 | }), | ||
| 57 | ), | ||
| 58 | ), | ||
| 59 | ); | ||
| 60 | |||
| 61 | export interface SelfHostAppsSyncRouteDeps { | ||
| 62 | readonly identity: Layer.Layer<IdentityProvider>; | ||
| 63 | readonly db: SelfHostDbHandle; | ||
| 64 | } | ||
| 65 | |||
| 66 | const routeHandler = ( | ||
| 67 | run: (request: Request) => Effect.Effect<unknown, unknown, unknown>, | ||
| 68 | ): Effect.Effect<HttpServerResponse.HttpServerResponse, never, unknown> => | ||
| 69 | Effect.gen(function* () { | ||
| 70 | const httpRequest = yield* HttpServerRequest.HttpServerRequest; | ||
| 71 | const request = yield* HttpServerRequest.toWeb(httpRequest).pipe( | ||
| 72 | Effect.catch(() => Effect.succeed(null)), | ||
| 73 | ); | ||
| 74 | if (request === null) { | ||
| 75 | return HttpServerResponse.text("Internal Server Error", { status: 500 }); | ||
| 76 | } | ||
| 77 | return yield* run(request).pipe( | ||
| 78 | Effect.map((body) => HttpServerResponse.jsonUnsafe(body)), | ||
| 79 | Effect.catch((failure: unknown) => { | ||
| 80 | if (Predicate.isTagged("Unauthorized")(failure)) { | ||
| 81 | return Effect.succeed(HttpServerResponse.text("Unauthorized", { status: 401 })); | ||
| 82 | } | ||
| 83 | if (Predicate.isTagged("NoOrganization")(failure)) { | ||
| 84 | return Effect.succeed(HttpServerResponse.text("Forbidden", { status: 403 })); | ||
| 85 | } | ||
| 86 | if (Predicate.isTagged("Unavailable")(failure)) { | ||
| 87 | return Effect.succeed(HttpServerResponse.text("Unavailable", { status: 503 })); | ||
| 88 | } | ||
| 89 | if (isAppsSyncRouteError(failure)) { | ||
| 90 | const message = failure.message; | ||
| 91 | const status = failure.status; | ||
| 92 | return Effect.succeed(HttpServerResponse.jsonUnsafe({ error: message }, { status })); | ||
| 93 | } | ||
| 94 | return Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })); | ||
| 95 | }), | ||
| 96 | Effect.catchCause(() => | ||
| 97 | Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), | ||
| 98 | ), | ||
| 99 | ); | ||
| 100 | }); | ||
| 101 | |||
| 102 | export const makeSelfHostAppsSyncRoute = ({ identity, db }: SelfHostAppsSyncRouteDeps) => { | ||
| 103 | const services = Layer.merge( | ||
| 104 | SelfHostScopedExecutorSeams.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), | ||
| 105 | identity, | ||
| 106 | ); | ||
| 107 | return Layer.mergeAll( | ||
| 108 | HttpRouter.add( | ||
| 109 | "GET", | ||
| 110 | "/api/apps/sources/github", | ||
| 111 | routeHandler((request) => | ||
| 112 | Effect.gen(function* () { | ||
| 113 | const identityProvider = yield* IdentityProvider; | ||
| 114 | const principal = yield* identityProvider.authenticate(request); | ||
| 115 | const executor = yield* buildExecutor(principal, request); | ||
| 116 | return yield* executor.execute(ToolAddress.make("executor.apps.list_github_sources"), {}); | ||
| 117 | }), | ||
| 118 | ), | ||
| 119 | ), | ||
| 120 | HttpRouter.add( | ||
| 121 | "POST", | ||
| 122 | "/api/apps/sources/github/sync", | ||
| 123 | routeHandler((request) => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const identityProvider = yield* IdentityProvider; | ||
| 126 | const principal = yield* identityProvider.authenticate(request); | ||
| 127 | const payload = yield* parseJson(request); | ||
| 128 | const executor = yield* buildExecutor(principal, request); | ||
| 129 | return yield* executor.execute( | ||
| 130 | ToolAddress.make("executor.apps.sync_github_source"), | ||
| 131 | payload, | ||
| 132 | ); | ||
| 133 | }), | ||
| 134 | ), | ||
| 135 | ), | ||
| 136 | HttpRouter.add( | ||
| 137 | "GET", | ||
| 138 | "/api/apps/sources/github/:slug", | ||
| 139 | routeHandler((request) => | ||
| 140 | Effect.gen(function* () { | ||
| 141 | const identityProvider = yield* IdentityProvider; | ||
| 142 | const principal = yield* identityProvider.authenticate(request); | ||
| 143 | const slug = yield* sourceSlugFromRoute(); | ||
| 144 | const executor = yield* buildExecutor(principal, request); | ||
| 145 | return yield* executor.execute(ToolAddress.make("executor.apps.get_github_source"), { | ||
| 146 | slug, | ||
| 147 | }); | ||
| 148 | }), | ||
| 149 | ), | ||
| 150 | ), | ||
| 151 | HttpRouter.add( | ||
| 152 | "DELETE", | ||
| 153 | "/api/apps/sources/github/:slug", | ||
| 154 | routeHandler((request) => | ||
| 155 | Effect.gen(function* () { | ||
| 156 | const identityProvider = yield* IdentityProvider; | ||
| 157 | const principal = yield* identityProvider.authenticate(request); | ||
| 158 | const slug = yield* sourceSlugFromRoute(); | ||
| 159 | const executor = yield* buildExecutor(principal, request); | ||
| 160 | yield* executor.integrations.remove(IntegrationSlug.make(slug)); | ||
| 161 | return { removed: true }; | ||
| 162 | }), | ||
| 163 | ), | ||
| 164 | ), | ||
| 165 | ).pipe(HttpRouter.provideRequest(services)); | ||
| 166 | }; | ||
| @@ -1,71 +0,0 @@ | |||
| 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 | }; | ||
| @@ -1,177 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import appsClientPlugin, { | ||
| 4 | CUSTOM_TOOLS_LABEL, | ||
| 5 | CUSTOM_TOOLS_PLUGIN_KEY, | ||
| 6 | type CustomToolsFetch, | ||
| 7 | appsIntegrationPlugin, | ||
| 8 | formatSyncErrors, | ||
| 9 | getCustomToolSource, | ||
| 10 | listCustomToolSources, | ||
| 11 | removeCustomToolSource, | ||
| 12 | syncCustomToolSource, | ||
| 13 | syncStatusLabel, | ||
| 14 | validateGitHubSourceUrl, | ||
| 15 | } from "@executor-js/plugin-apps/client"; | ||
| 16 | |||
| 17 | const jsonResponse = (body: unknown, init?: ResponseInit): Response => | ||
| 18 | new Response(JSON.stringify(body), { | ||
| 19 | status: 200, | ||
| 20 | headers: { "content-type": "application/json" }, | ||
| 21 | ...init, | ||
| 22 | }); | ||
| 23 | |||
| 24 | describe("custom tools console client", () => { | ||
| 25 | it("registers the manual Custom tools tile", () => { | ||
| 26 | const manualTiles = [appsIntegrationPlugin].map((plugin) => ({ | ||
| 27 | href: `/integrations/add/${plugin.key}`, | ||
| 28 | label: plugin.label, | ||
| 29 | })); | ||
| 30 | |||
| 31 | expect(appsClientPlugin.id).toBe(CUSTOM_TOOLS_PLUGIN_KEY); | ||
| 32 | expect(appsClientPlugin.integrationPlugin?.label).toBe(CUSTOM_TOOLS_LABEL); | ||
| 33 | expect(manualTiles).toContainEqual({ | ||
| 34 | href: "/integrations/add/apps", | ||
| 35 | label: "Custom tools", | ||
| 36 | }); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("validates the GitHub URL shape", () => { | ||
| 40 | expect(validateGitHubSourceUrl("https://github.com/UsefulSoftwareCo/executor")).toBeNull(); | ||
| 41 | expect(validateGitHubSourceUrl("UsefulSoftwareCo/executor")).toBeNull(); | ||
| 42 | expect(validateGitHubSourceUrl("https://gitlab.com/UsefulSoftwareCo/executor")).toBe( | ||
| 43 | "GitHub source URLs must use github.com.", | ||
| 44 | ); | ||
| 45 | expect(validateGitHubSourceUrl("UsefulSoftwareCo")).toBe( | ||
| 46 | "Use a GitHub repo URL like https://github.com/owner/repo, optionally with /tree/<ref> or /commit/<sha>.", | ||
| 47 | ); | ||
| 48 | expect(validateGitHubSourceUrl("")).toBe("Enter a GitHub URL."); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("surfaces successful sync and source detail data", async () => { | ||
| 52 | let syncBody = ""; | ||
| 53 | let removed = false; | ||
| 54 | const fetchImpl: CustomToolsFetch = async (input, init) => { | ||
| 55 | const url = String(input); | ||
| 56 | if (url.endsWith("/sync")) { | ||
| 57 | syncBody = String(init?.body ?? ""); | ||
| 58 | return jsonResponse({ | ||
| 59 | status: "published", | ||
| 60 | snapshotId: "snap1", | ||
| 61 | upstreamSha: "abc123", | ||
| 62 | tools: ["repo-summary", "stale-issues"], | ||
| 63 | skipped: [], | ||
| 64 | }); | ||
| 65 | } | ||
| 66 | if (url.endsWith("/demo-tools")) { | ||
| 67 | if (init?.method === "DELETE") { | ||
| 68 | removed = true; | ||
| 69 | return jsonResponse({ removed: true }); | ||
| 70 | } | ||
| 71 | return jsonResponse({ | ||
| 72 | source: { | ||
| 73 | slug: "demo-tools", | ||
| 74 | name: "demo-tools", | ||
| 75 | scope: "demo-tools", | ||
| 76 | url: "https://github.com/RhysSullivan/executor-custom-tools-demo", | ||
| 77 | repo: "RhysSullivan/executor-custom-tools-demo", | ||
| 78 | ref: "main", | ||
| 79 | hasToken: true, | ||
| 80 | upstreamSha: "abc123", | ||
| 81 | snapshotId: "snap1", | ||
| 82 | publishedAt: "2026-07-06T12:00:00.000Z", | ||
| 83 | tools: ["repo-summary", "stale-issues"], | ||
| 84 | skipped: [], | ||
| 85 | }, | ||
| 86 | }); | ||
| 87 | } | ||
| 88 | return jsonResponse({ | ||
| 89 | sources: [ | ||
| 90 | { | ||
| 91 | slug: "demo-tools", | ||
| 92 | name: "demo-tools", | ||
| 93 | scope: "demo-tools", | ||
| 94 | url: "https://github.com/RhysSullivan/executor-custom-tools-demo", | ||
| 95 | repo: "RhysSullivan/executor-custom-tools-demo", | ||
| 96 | ref: "main", | ||
| 97 | hasToken: true, | ||
| 98 | upstreamSha: "abc123", | ||
| 99 | snapshotId: "snap1", | ||
| 100 | publishedAt: "2026-07-06T12:00:00.000Z", | ||
| 101 | tools: ["repo-summary", "stale-issues"], | ||
| 102 | skipped: [], | ||
| 103 | }, | ||
| 104 | ], | ||
| 105 | }); | ||
| 106 | }; | ||
| 107 | |||
| 108 | const syncResult = await syncCustomToolSource( | ||
| 109 | { | ||
| 110 | name: "demo-tools", | ||
| 111 | url: "https://github.com/RhysSullivan/executor-custom-tools-demo", | ||
| 112 | token: "ghp_demo", | ||
| 113 | }, | ||
| 114 | fetchImpl, | ||
| 115 | ); | ||
| 116 | const listed = await listCustomToolSources(fetchImpl); | ||
| 117 | const detail = await getCustomToolSource("demo-tools", fetchImpl); | ||
| 118 | const remove = await removeCustomToolSource("demo-tools", fetchImpl); | ||
| 119 | |||
| 120 | expect(syncStatusLabel(syncResult)).toBe("Published 2 tools."); | ||
| 121 | expect(syncBody).toContain("ghp_demo"); | ||
| 122 | expect(syncBody).toContain("demo-tools"); | ||
| 123 | expect(syncResult.tools).toEqual(["repo-summary", "stale-issues"]); | ||
| 124 | expect(detail.source?.slug).toBe("demo-tools"); | ||
| 125 | expect(listed.sources[0]?.hasToken).toBe(true); | ||
| 126 | expect(listed.sources[0]?.tools).toEqual(["repo-summary", "stale-issues"]); | ||
| 127 | expect(remove).toEqual({ removed: true }); | ||
| 128 | expect(removed).toBe(true); | ||
| 129 | }); | ||
| 130 | |||
| 131 | it("renders failed sync errors readably", async () => { | ||
| 132 | const fetchImpl: CustomToolsFetch = async () => | ||
| 133 | jsonResponse({ | ||
| 134 | status: "failed", | ||
| 135 | tools: [], | ||
| 136 | skipped: [], | ||
| 137 | errors: [ | ||
| 138 | { | ||
| 139 | stage: "collect", | ||
| 140 | message: "schema library not supported for schema export", | ||
| 141 | diagnostics: [{ path: "tools/x.ts", message: "vendor: nope" }], | ||
| 142 | }, | ||
| 143 | ], | ||
| 144 | }); | ||
| 145 | |||
| 146 | const result = await syncCustomToolSource( | ||
| 147 | { | ||
| 148 | url: "https://github.com/RhysSullivan/executor-custom-tools-demo", | ||
| 149 | }, | ||
| 150 | fetchImpl, | ||
| 151 | ); | ||
| 152 | |||
| 153 | expect(syncStatusLabel(result)).toBe("Sync failed."); | ||
| 154 | expect(formatSyncErrors(result)).toEqual([ | ||
| 155 | "collect: schema library not supported for schema export (tools/x.ts: vendor: nope)", | ||
| 156 | ]); | ||
| 157 | }); | ||
| 158 | |||
| 159 | it("shows the up-to-date sync state", async () => { | ||
| 160 | const fetchImpl: CustomToolsFetch = async () => | ||
| 161 | jsonResponse({ | ||
| 162 | status: "up-to-date", | ||
| 163 | upstreamSha: "abc123", | ||
| 164 | tools: ["repo-summary"], | ||
| 165 | skipped: [], | ||
| 166 | }); | ||
| 167 | |||
| 168 | const result = await syncCustomToolSource( | ||
| 169 | { | ||
| 170 | url: "https://github.com/RhysSullivan/executor-custom-tools-demo", | ||
| 171 | }, | ||
| 172 | fetchImpl, | ||
| 173 | ); | ||
| 174 | |||
| 175 | expect(syncStatusLabel(result)).toBe("Already up to date."); | ||
| 176 | }); | ||
| 177 | }); | ||
| @@ -30,8 +30,7 @@ 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"; | 33 | import { closeSelfHostAppsBackings } from "../apps-backings"; |
| 34 | import { makeSelfHostAppsSyncRoute } from "../apps-route"; | ||
| 35 | 34 | ||
| 36 | // =========================================================================== | 35 | // =========================================================================== |
| 37 | // Self-host TEST harness — the throwaway composition tests use to exercise the | 36 | // Self-host TEST harness — the throwaway composition tests use to exercise the |
| @@ -204,7 +203,6 @@ export const makeSelfHostTestApp = async ( · 166 unmodified lines | |||
| 204 | }, | 203 | }, |
| 205 | extensions: { | 204 | extensions: { |
| 206 | routes: [ | 205 | routes: [ |
| 207 | makeSelfHostAppsSyncRoute({ identity: options.identity, db: dbHandle }), | ||
| 208 | HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), | 206 | HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), |
| 209 | ], | 207 | ], |
| 210 | }, | 208 | }, |
| @@ -220,7 +218,7 @@ export const makeSelfHostTestApp = async ( · 9 unmodified lines | |||
| 220 | handler: web.handler, | 218 | handler: web.handler, |
| 221 | dispose: async () => { | 219 | dispose: async () => { |
| 222 | await web.dispose(); | 220 | await web.dispose(); |
| 223 | await closeSelfHostAppsSubsystem(); | 221 | await closeSelfHostAppsBackings(); |
| 224 | await sessionStore.close(); | 222 | await sessionStore.close(); |
| 225 | await dbHandle.close(); | 223 | await dbHandle.close(); |
| 226 | }, | 224 | }, |