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 | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| 8 | 8 | import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; |
| 9 | +import { appsPlugin } from "@executor-js/plugin-apps/api"; | |
| 9 | 10 | |
| 10 | 11 | import { resolveSecretKey } from "./src/config"; |
| 11 | −import { getSelfHostAppsSubsystem } from "./src/apps"; | |
| 12 | +import { getSelfHostAppsBackings } from "./src/apps-backings"; | |
| 12 | 13 | |
| 13 | 14 | // --------------------------------------------------------------------------- |
| 14 | 15 | // Single source of truth for the self-hosted app's plugin list. |
| @@ -37,7 +38,7 @@ export default defineExecutorConfig({ · 22 unmodified lines | ||
| 37 | 38 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 38 | 39 | graphqlHttpPlugin(), |
| 39 | 40 | toolkitsPlugin({ activeToolkitSlug }), |
| 40 | − getSelfHostAppsSubsystem().plugin, | |
| 41 | + appsPlugin({ backings: getSelfHostAppsBackings() }), | |
| 41 | 42 | // First writable secret provider -> the default for `secrets.set`. |
| 42 | 43 | encryptedSecretsPlugin({ key: resolveSecretKey() }), |
| 43 | 44 | ] as const, |
| @@ -22,8 +22,7 @@ import { makeSelfHostMcpSeams } from "./mcp"; · 21 unmodified lines | ||
| 22 | 22 | import { selfHostPlugins } from "./plugins"; |
| 23 | 23 | import { ErrorCaptureLive } from "./observability"; |
| 24 | 24 | import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login"; |
| 25 | −import { closeSelfHostAppsSubsystem } from "./apps"; | |
| 26 | −import { makeSelfHostAppsSyncRoute } from "./apps-route"; | |
| 25 | +import { closeSelfHostAppsBackings } from "./apps-backings"; | |
| 27 | 26 | |
| 28 | 27 | // =========================================================================== |
| 29 | 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 | 120 | makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), |
| 122 | 121 | // Public system API: /api/health + /api/setup-status (unauthenticated). |
| 123 | 122 | makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), |
| 124 | − makeSelfHostAppsSyncRoute({ identity: identityLayer, db: dbHandle }), | |
| 125 | 123 | // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). |
| 126 | 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 | 140 | toWebHandler, |
| 143 | 141 | betterAuth, |
| 144 | 142 | closeDb: async () => { |
| 145 | − await closeSelfHostAppsSubsystem(); | |
| 143 | + await closeSelfHostAppsBackings(); | |
| 146 | 144 | await mcp.close(); |
| 147 | 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 | 30 | } from "../mcp/session-store"; |
| 31 | 31 | import { selfHostPlugins } from "../plugins"; |
| 32 | 32 | import { ErrorCaptureLive } from "../observability"; |
| 33 | −import { closeSelfHostAppsSubsystem } from "../apps"; | |
| 34 | −import { makeSelfHostAppsSyncRoute } from "../apps-route"; | |
| 33 | +import { closeSelfHostAppsBackings } from "../apps-backings"; | |
| 35 | 34 | |
| 36 | 35 | // =========================================================================== |
| 37 | 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 | 204 | extensions: { |
| 206 | 205 | routes: [ |
| 207 | − makeSelfHostAppsSyncRoute({ identity: options.identity, db: dbHandle }), | |
| 208 | 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 | 218 | handler: web.handler, |
| 221 | 219 | dispose: async () => { |
| 222 | 220 | await web.dispose(); |
| 223 | − await closeSelfHostAppsSubsystem(); | |
| 221 | + await closeSelfHostAppsBackings(); | |
| 224 | 222 | await sessionStore.close(); |
| 225 | 223 | await dbHandle.close(); |
| 226 | 224 | }, |