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,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 | }; | ||
| @@ -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 | }, |
| @@ -797,6 +797,7 @@ · 796 unmodified lines | |||
| 797 | "devDependencies": { | 797 | "devDependencies": { |
| 798 | "@effect/atom-react": "catalog:", | 798 | "@effect/atom-react": "catalog:", |
| 799 | "@effect/vitest": "catalog:", | 799 | "@effect/vitest": "catalog:", |
| 800 | "@executor-js/api": "workspace:*", | ||
| 800 | "@executor-js/react": "workspace:*", | 801 | "@executor-js/react": "workspace:*", |
| 801 | "@types/node": "catalog:", | 802 | "@types/node": "catalog:", |
| 802 | "@types/react": "catalog:", | 803 | "@types/react": "catalog:", |
| @@ -808,12 +809,14 @@ · 5 unmodified lines | |||
| 808 | }, | 809 | }, |
| 809 | "peerDependencies": { | 810 | "peerDependencies": { |
| 810 | "@effect/atom-react": "catalog:", | 811 | "@effect/atom-react": "catalog:", |
| 812 | "@executor-js/api": "workspace:*", | ||
| 811 | "@executor-js/react": "workspace:*", | 813 | "@executor-js/react": "workspace:*", |
| 812 | "effect": "catalog:", | 814 | "effect": "catalog:", |
| 813 | "react": "catalog:", | 815 | "react": "catalog:", |
| 814 | }, | 816 | }, |
| 815 | "optionalPeers": [ | 817 | "optionalPeers": [ |
| 816 | "@effect/atom-react", | 818 | "@effect/atom-react", |
| 819 | "@executor-js/api", | ||
| 817 | "@executor-js/react", | 820 | "@executor-js/react", |
| 818 | "react", | 821 | "react", |
| 819 | ], | 822 | ], |
| @@ -40,6 +40,7 @@ · 39 unmodified lines | |||
| 40 | "devDependencies": { | 40 | "devDependencies": { |
| 41 | "@effect/atom-react": "catalog:", | 41 | "@effect/atom-react": "catalog:", |
| 42 | "@effect/vitest": "catalog:", | 42 | "@effect/vitest": "catalog:", |
| 43 | "@executor-js/api": "workspace:*", | ||
| 43 | "@executor-js/react": "workspace:*", | 44 | "@executor-js/react": "workspace:*", |
| 44 | "@types/node": "catalog:", | 45 | "@types/node": "catalog:", |
| 45 | "@types/react": "catalog:", | 46 | "@types/react": "catalog:", |
| @@ -51,6 +52,7 @@ · 5 unmodified lines | |||
| 51 | }, | 52 | }, |
| 52 | "peerDependencies": { | 53 | "peerDependencies": { |
| 53 | "@effect/atom-react": "catalog:", | 54 | "@effect/atom-react": "catalog:", |
| 55 | "@executor-js/api": "workspace:*", | ||
| 54 | "@executor-js/react": "workspace:*", | 56 | "@executor-js/react": "workspace:*", |
| 55 | "effect": "catalog:", | 57 | "effect": "catalog:", |
| 56 | "react": "catalog:" | 58 | "react": "catalog:" |
| @@ -59,6 +61,9 @@ · 2 unmodified lines | |||
| 59 | "@effect/atom-react": { | 61 | "@effect/atom-react": { |
| 60 | "optional": true | 62 | "optional": true |
| 61 | }, | 63 | }, |
| 64 | "@executor-js/api": { | ||
| 65 | "optional": true | ||
| 66 | }, | ||
| 62 | "@executor-js/react": { | 67 | "@executor-js/react": { |
| 63 | "optional": true | 68 | "optional": true |
| 64 | }, | 69 | }, |
| @@ -1,16 +1,35 @@ | |||
| 1 | // Host-facing plugin surface for @executor-js/plugin-apps. | 1 | // Host-facing plugin surface for @executor-js/plugin-apps. |
| 2 | export { | 2 | import { definePlugin } from "@executor-js/sdk/core"; |
| 3 | appsPlugin, | 3 | |
| 4 | import { | ||
| 5 | appsPlugin as appsCorePlugin, | ||
| 4 | APPS_INTEGRATION_SLUG, | 6 | APPS_INTEGRATION_SLUG, |
| 5 | APPS_PLUGIN_ID, | 7 | APPS_PLUGIN_ID, |
| 6 | type AppsPluginOptions, | 8 | type AppsPluginOptions, |
| 7 | } from "./plugin/apps-plugin"; | 9 | } from "./plugin/apps-plugin"; |
| 10 | import { AppsGroup } from "./plugin/routes"; | ||
| 11 | import { AppsHandlers, AppsExtensionService } from "./plugin/handlers"; | ||
| 12 | |||
| 13 | export const appsPlugin = definePlugin((options?: AppsPluginOptions) => ({ | ||
| 14 | ...appsCorePlugin(options), | ||
| 15 | routes: () => AppsGroup, | ||
| 16 | handlers: () => AppsHandlers, | ||
| 17 | extensionService: AppsExtensionService, | ||
| 18 | })); | ||
| 19 | |||
| 20 | export { APPS_INTEGRATION_SLUG, APPS_PLUGIN_ID, type AppsPluginOptions }; | ||
| 21 | export { AppsGroup } from "./plugin/routes"; | ||
| 22 | export { AppsHandlers, AppsExtensionService } from "./plugin/handlers"; | ||
| 8 | export { | 23 | export { |
| 9 | makeSelfHostAppsRuntime, | 24 | makeSelfHostAppsRuntime, |
| 10 | type SelfHostAppsRuntime, | 25 | type SelfHostAppsRuntime, |
| 11 | type SelfHostAppsRuntimeOptions, | 26 | type SelfHostAppsRuntimeOptions, |
| 12 | } from "./plugin/self-host-runtime"; | 27 | } from "./plugin/self-host-runtime"; |
| 13 | export { type AppsRuntime, type GitHubCustomToolsSourceSummary } from "./plugin/runtime"; | 28 | export { type AppsRuntime, type GitHubCustomToolsSourceSummary } from "./plugin/runtime"; |
| 29 | export { makeAppsRuntimeFromBackings, type AppsBackings } from "./plugin/backings"; | ||
| 30 | export { makeGitArtifactStore } from "./backing/git-artifact-store"; | ||
| 31 | export { makeLibsqlScopeDb } from "./backing/libsql-scope-db"; | ||
| 32 | export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox"; | ||
| 14 | export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; | 33 | export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; |
| 15 | export { | 34 | export { |
| 16 | BindingError, | 35 | BindingError, |
| @@ -18,6 +37,7 @@ export { · 1 unmodified line | |||
| 18 | type ConnectionCandidate, | 37 | type ConnectionCandidate, |
| 19 | type RoleBindings, | 38 | type RoleBindings, |
| 20 | } from "./plugin/bindings"; | 39 | } from "./plugin/bindings"; |
| 40 | export { makePluginCtxAppsResolver } from "./plugin/resolver"; | ||
| 21 | export { | 41 | export { |
| 22 | fetchGitHubSource, | 42 | fetchGitHubSource, |
| 23 | parseGitHubSourceUrl, | 43 | parseGitHubSourceUrl, |
| @@ -28,6 +28,7 @@ export { · 27 unmodified lines | |||
| 28 | } from "./source/github-source"; | 28 | } from "./source/github-source"; |
| 29 | 29 | ||
| 30 | export { makeAppsRuntime, type AppsRuntime, type AppsRuntimeDeps } from "./plugin/runtime"; | 30 | export { makeAppsRuntime, type AppsRuntime, type AppsRuntimeDeps } from "./plugin/runtime"; |
| 31 | export { makeAppsRuntimeFromBackings, type AppsBackings } from "./plugin/backings"; | ||
| 31 | export { | 32 | export { |
| 32 | makeAppsStore, | 33 | makeAppsStore, |
| 33 | type AppsStore, | 34 | type AppsStore, |
| @@ -50,6 +51,7 @@ export { · 16 unmodified lines | |||
| 50 | type ClientResolver, | 51 | type ClientResolver, |
| 51 | type BindingContext, | 52 | type BindingContext, |
| 52 | } from "./plugin/bindings"; | 53 | } from "./plugin/bindings"; |
| 54 | export { makePluginCtxAppsResolver } from "./plugin/resolver"; | ||
| 53 | export { | 55 | export { |
| 54 | makeSelfHostAppsRuntime, | 56 | makeSelfHostAppsRuntime, |
| 55 | type SelfHostAppsRuntime, | 57 | type SelfHostAppsRuntime, |
| @@ -62,7 +62,7 @@ describe("appsPlugin custom-tools contract", () => { · 61 unmodified lines | |||
| 62 | inMemory: true, | 62 | inMemory: true, |
| 63 | }); | 63 | }); |
| 64 | const runtime = host.runtime; | 64 | const runtime = host.runtime; |
| 65 | const plugin = appsPlugin({ runtime }); | 65 | const plugin = appsPlugin({ backings: host.backings }); |
| 66 | const appIntegration = IntegrationSlug.make("rhys-tools"); | 66 | const appIntegration = IntegrationSlug.make("rhys-tools"); |
| 67 | const appConfig = { | 67 | const appConfig = { |
| 68 | kind: "github", | 68 | kind: "github", |
| @@ -14,11 +14,17 @@ import { · 13 unmodified lines | |||
| 14 | type PluginCtx, | 14 | type PluginCtx, |
| 15 | type ResolveToolsInput, | 15 | type ResolveToolsInput, |
| 16 | type ResolveToolsResult, | 16 | type ResolveToolsResult, |
| 17 | StorageError, | ||
| 18 | type StorageFailure, | ||
| 17 | type ToolDef, | 19 | type ToolDef, |
| 20 | type IntegrationRemovalNotAllowedError, | ||
| 18 | } from "@executor-js/sdk"; | 21 | } from "@executor-js/sdk"; |
| 19 | 22 | ||
| 20 | import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor"; | 23 | import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor"; |
| 21 | import { PublishError } from "../pipeline/discover"; | 24 | import { PublishError } from "../pipeline/discover"; |
| 25 | import { ArtifactStoreError } from "../seams/artifact-store"; | ||
| 26 | import { ScopeDbError } from "../seams/scope-db"; | ||
| 27 | import { ToolSandboxError } from "../seams/tool-sandbox"; | ||
| 22 | import { | 28 | import { |
| 23 | parseGitHubSourceUrl, | 29 | parseGitHubSourceUrl, |
| 24 | syncGitHubSource, | 30 | syncGitHubSource, |
| @@ -28,7 +34,9 @@ import { · 3 unmodified lines | |||
| 28 | import { slugifyCustomToolsAppName, validateCustomToolsAppSlug } from "../source/app-slug"; | 34 | import { slugifyCustomToolsAppName, validateCustomToolsAppSlug } from "../source/app-slug"; |
| 29 | import type { AppsRuntime, GitHubCustomToolsSourceSummary } from "./runtime"; | 35 | import type { AppsRuntime, GitHubCustomToolsSourceSummary } from "./runtime"; |
| 30 | import { makeAppsStore, type AppDescriptorRecord, type GitHubSourceTokenRef } from "./store"; | 36 | import { makeAppsStore, type AppDescriptorRecord, type GitHubSourceTokenRef } from "./store"; |
| 31 | import type { ClientResolver, ConnectionCandidate } from "./bindings"; | 37 | import { BindingError, type ClientResolver, type ConnectionCandidate } from "./bindings"; |
| 38 | import { makePluginCtxAppsResolver } from "./resolver"; | ||
| 39 | import { makeAppsRuntimeFromBackings, type AppsBackings } from "./backings"; | ||
| 32 | 40 | ||
| 33 | export const APPS_INTEGRATION_SLUG = "apps"; | 41 | export const APPS_INTEGRATION_SLUG = "apps"; |
| 34 | export const APPS_PLUGIN_ID = "apps"; | 42 | export const APPS_PLUGIN_ID = "apps"; |
| @@ -44,17 +52,43 @@ interface AppsGitHubSourceConfig { · 9 unmodified lines | |||
| 44 | readonly token?: GitHubSourceTokenRef; | 52 | readonly token?: GitHubSourceTokenRef; |
| 45 | } | 53 | } |
| 46 | 54 | ||
| 47 | export interface AppsPluginOptions { | 55 | type ResolverFactory = (input: { |
| 56 | readonly ctx: unknown; | ||
| 57 | readonly scope: string; | ||
| 58 | readonly tool: string; | ||
| 59 | }) => ClientResolver; | ||
| 60 | |||
| 61 | export type AppsPluginOptions = | ||
| 62 | | { | ||
| 63 | readonly backings: AppsBackings; | ||
| 64 | readonly runtime?: never; | ||
| 65 | readonly makeResolver?: ResolverFactory; | ||
| 66 | } | ||
| 67 | | { | ||
| 68 | readonly runtime: AppsRuntime; | ||
| 69 | readonly backings?: never; | ||
| 70 | readonly makeResolver?: ResolverFactory; | ||
| 71 | }; | ||
| 72 | |||
| 73 | interface AppsStoreShape { | ||
| 48 | readonly runtime: AppsRuntime; | 74 | readonly runtime: AppsRuntime; |
| 49 | readonly makeResolver?: (input: { | ||
| 50 | readonly ctx: unknown; | ||
| 51 | readonly scope: string; | ||
| 52 | readonly tool: string; | ||
| 53 | }) => ClientResolver; | ||
| 54 | } | 75 | } |
| 55 | 76 | ||
| 56 | interface AppsStoreShape { | 77 | export interface AppsPluginExtension { |
| 57 | readonly runtime: AppsRuntime; | 78 | readonly runtime: AppsRuntime; |
| 79 | readonly syncGitHubSource: (input: unknown) => Effect.Effect<GitHubSyncResult, StorageFailure>; | ||
| 80 | readonly listGitHubSources: () => Effect.Effect<{ | ||
| 81 | readonly sources: readonly GitHubCustomToolsSourceSummary[]; | ||
| 82 | }>; | ||
| 83 | readonly getGitHubSource: (slug: string) => Effect.Effect<{ | ||
| 84 | readonly source: GitHubCustomToolsSourceSummary | null; | ||
| 85 | }>; | ||
| 86 | readonly removeGitHubSource: ( | ||
| 87 | slug: string, | ||
| 88 | ) => Effect.Effect< | ||
| 89 | { readonly removed: true }, | ||
| 90 | StorageFailure | IntegrationRemovalNotAllowedError | ||
| 91 | >; | ||
| 58 | } | 92 | } |
| 59 | 93 | ||
| 60 | const isRecord = (value: unknown): value is Record<string, unknown> => | 94 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| @@ -65,6 +99,13 @@ const asString = (value: unknown): string | undefined => · 4 unmodified lines | |||
| 65 | 99 | ||
| 66 | const unique = (values: readonly string[]): readonly string[] => [...new Set(values)]; | 100 | const unique = (values: readonly string[]): readonly string[] => [...new Set(values)]; |
| 67 | 101 | ||
| 102 | const isPluginCtx = (value: unknown): value is PluginCtx => | ||
| 103 | isRecord(value) && | ||
| 104 | isRecord(value.connections) && | ||
| 105 | typeof value.connections.list === "function" && | ||
| 106 | typeof value.connections.get === "function" && | ||
| 107 | typeof value.execute === "function"; | ||
| 108 | |||
| 68 | const syncFailure = (message: string, path?: string): GitHubSyncResult => ({ | 109 | const syncFailure = (message: string, path?: string): GitHubSyncResult => ({ |
| 69 | status: "failed", | 110 | status: "failed", |
| 70 | tools: [], | 111 | tools: [], |
| @@ -78,6 +119,98 @@ const syncFailure = (message: string, path?: string): GitHubSyncResult => ({ · 7 unmodified lines | |||
| 78 | ], | 119 | ], |
| 79 | }); | 120 | }); |
| 80 | 121 | ||
| 122 | const missingResolver = (): ClientResolver => ({ | ||
| 123 | listConnections: () => Effect.succeed([]), | ||
| 124 | resolveConnection: () => Effect.succeed(null), | ||
| 125 | call: ({ integration, connection }) => | ||
| 126 | Effect.fail( | ||
| 127 | new BindingError({ | ||
| 128 | role: integration, | ||
| 129 | integration, | ||
| 130 | requestedConnection: connection, | ||
| 131 | message: "apps resolver is unavailable outside a request-scoped executor", | ||
| 132 | }), | ||
| 133 | ), | ||
| 134 | }); | ||
| 135 | |||
| 136 | const missingBackingsMessage = "apps plugin requires backings or runtime"; | ||
| 137 | |||
| 138 | const missingPublishError = (): PublishError => | ||
| 139 | new PublishError({ | ||
| 140 | message: missingBackingsMessage, | ||
| 141 | stage: "project", | ||
| 142 | diagnostics: [], | ||
| 143 | }); | ||
| 144 | |||
| 145 | const missingStorageError = (): StorageError => | ||
| 146 | new StorageError({ | ||
| 147 | message: missingBackingsMessage, | ||
| 148 | cause: missingBackingsMessage, | ||
| 149 | }); | ||
| 150 | |||
| 151 | const missingRuntime = (): AppsRuntime => ({ | ||
| 152 | publish: () => Effect.fail(missingPublishError()), | ||
| 153 | getDescriptor: () => Effect.succeed(null), | ||
| 154 | listGitHubSources: () => Effect.succeed([]), | ||
| 155 | removeSource: () => Effect.fail(missingPublishError()), | ||
| 156 | repair: () => Effect.fail(missingPublishError()), | ||
| 157 | invokeTool: () => Effect.fail(missingPublishError()), | ||
| 158 | deps: { | ||
| 159 | artifactStore: { | ||
| 160 | forScope: () => | ||
| 161 | Effect.fail( | ||
| 162 | new ArtifactStoreError({ | ||
| 163 | message: missingBackingsMessage, | ||
| 164 | }), | ||
| 165 | ), | ||
| 166 | removeScope: () => | ||
| 167 | Effect.fail( | ||
| 168 | new ArtifactStoreError({ | ||
| 169 | message: missingBackingsMessage, | ||
| 170 | }), | ||
| 171 | ), | ||
| 172 | }, | ||
| 173 | scopeDb: { | ||
| 174 | forScope: () => | ||
| 175 | Effect.fail( | ||
| 176 | new ScopeDbError({ | ||
| 177 | message: missingBackingsMessage, | ||
| 178 | }), | ||
| 179 | ), | ||
| 180 | removeScope: () => | ||
| 181 | Effect.fail( | ||
| 182 | new ScopeDbError({ | ||
| 183 | message: missingBackingsMessage, | ||
| 184 | }), | ||
| 185 | ), | ||
| 186 | close: () => Effect.void, | ||
| 187 | }, | ||
| 188 | sandbox: { | ||
| 189 | collect: () => | ||
| 190 | Effect.fail( | ||
| 191 | new ToolSandboxError({ | ||
| 192 | message: missingBackingsMessage, | ||
| 193 | kind: "collect", | ||
| 194 | }), | ||
| 195 | ), | ||
| 196 | invoke: () => | ||
| 197 | Effect.fail( | ||
| 198 | new ToolSandboxError({ | ||
| 199 | message: missingBackingsMessage, | ||
| 200 | kind: "invoke", | ||
| 201 | }), | ||
| 202 | ), | ||
| 203 | }, | ||
| 204 | store: { | ||
| 205 | putDescriptor: () => Effect.fail(missingStorageError()), | ||
| 206 | getDescriptor: () => Effect.succeed(null), | ||
| 207 | removeDescriptor: () => Effect.fail(missingStorageError()), | ||
| 208 | listDescriptors: () => Effect.succeed([]), | ||
| 209 | }, | ||
| 210 | resolver: missingResolver(), | ||
| 211 | }, | ||
| 212 | }); | ||
| 213 | |||
| 81 | const sourceTokenItemId = (tenant: string, scope: string): ProviderItemId => | 214 | const sourceTokenItemId = (tenant: string, scope: string): ProviderItemId => |
| 82 | ProviderItemId.make(`apps:github-source:${tenant}:${scope}:token`); | 215 | ProviderItemId.make(`apps:github-source:${tenant}:${scope}:token`); |
| 83 | 216 | ||
| @@ -160,7 +293,11 @@ const projectTool = (descriptor: ToolDescriptor): ToolDef => ({ · 76 unmodified lines | |||
| 160 | }); | 293 | }); |
| 161 | 294 | ||
| 162 | export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { | 295 | export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { |
| 163 | const runtime = options?.runtime as AppsRuntime; | 296 | const runtime = |
| 297 | options?.runtime ?? | ||
| 298 | (options?.backings | ||
| 299 | ? makeAppsRuntimeFromBackings(options.backings, missingResolver()) | ||
| 300 | : missingRuntime()); | ||
| 164 | const makeResolver = options?.makeResolver; | 301 | const makeResolver = options?.makeResolver; |
| 165 | 302 | ||
| 166 | const tenantFor = (ctx: Pick<PluginCtx, "owner"> | undefined): string => { | 303 | const tenantFor = (ctx: Pick<PluginCtx, "owner"> | undefined): string => { |
| @@ -436,6 +573,66 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 269 unmodified lines | |||
| 436 | Effect.orElseSucceed(() => null), | 573 | Effect.orElseSucceed(() => null), |
| 437 | ); | 574 | ); |
| 438 | 575 | ||
| 576 | const requestResolver = (input: { | ||
| 577 | readonly ctx: unknown; | ||
| 578 | readonly scope: string; | ||
| 579 | readonly tool: string; | ||
| 580 | }): ClientResolver => | ||
| 581 | makeResolver | ||
| 582 | ? makeResolver(input) | ||
| 583 | : isPluginCtx(input.ctx) | ||
| 584 | ? makePluginCtxAppsResolver({ ctx: input.ctx }) | ||
| 585 | : runtime.deps.resolver; | ||
| 586 | |||
| 587 | const syncSourceFromPayload = (ctx: PluginCtx<AppsStoreShape>, args: unknown) => | ||
| 588 | Effect.gen(function* () { | ||
| 589 | const payload = isRecord(args) ? args : {}; | ||
| 590 | const url = | ||
| 591 | typeof payload.url === "string" | ||
| 592 | ? payload.url.trim() | ||
| 593 | : typeof payload.repo === "string" | ||
| 594 | ? payload.repo.trim() | ||
| 595 | : ""; | ||
| 596 | const ref = asString(payload.ref); | ||
| 597 | const providedToken = asString(payload.token) ?? null; | ||
| 598 | const slug = asString(payload.slug); | ||
| 599 | if (slug && !url) return yield* syncExistingSource(ctx, slug, providedToken); | ||
| 600 | if (!url) return syncFailure('sync_github_source requires "url"'); | ||
| 601 | return yield* addSource(ctx, { | ||
| 602 | url, | ||
| 603 | ...(ref ? { ref } : {}), | ||
| 604 | ...(asString(payload.name) ? { name: asString(payload.name) } : {}), | ||
| 605 | token: providedToken, | ||
| 606 | }); | ||
| 607 | }).pipe( | ||
| 608 | Effect.catchTags({ | ||
| 609 | CredentialProviderNotRegisteredError: (err) => Effect.succeed(syncFailure(err.message)), | ||
| 610 | IntegrationNotFoundError: (err) => Effect.succeed(syncFailure(err.message)), | ||
| 611 | InvalidConnectionInputError: (err) => Effect.succeed(syncFailure(err.message)), | ||
| 612 | }), | ||
| 613 | ); | ||
| 614 | |||
| 615 | const extensionFor = (ctx: PluginCtx<AppsStoreShape>): AppsPluginExtension => ({ | ||
| 616 | runtime, | ||
| 617 | syncGitHubSource: (input) => syncSourceFromPayload(ctx, input), | ||
| 618 | listGitHubSources: () => | ||
| 619 | Effect.gen(function* () { | ||
| 620 | const sources = yield* listSources(ctx); | ||
| 621 | return { sources }; | ||
| 622 | }), | ||
| 623 | getGitHubSource: (slug) => | ||
| 624 | Effect.gen(function* () { | ||
| 625 | if (!slug) return { source: null }; | ||
| 626 | const source = yield* getSource(ctx, slug); | ||
| 627 | return { source }; | ||
| 628 | }), | ||
| 629 | removeGitHubSource: (slug) => | ||
| 630 | Effect.gen(function* () { | ||
| 631 | yield* ctx.core.integrations.remove(IntegrationSlug.make(slug)); | ||
| 632 | return { removed: true as const }; | ||
| 633 | }), | ||
| 634 | }); | ||
| 635 | |||
| 439 | return { | 636 | return { |
| 440 | id: APPS_PLUGIN_ID as "apps", | 637 | id: APPS_PLUGIN_ID as "apps", |
| 441 | packageName: "@executor-js/plugin-apps", | 638 | packageName: "@executor-js/plugin-apps", |
| @@ -455,9 +652,9 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 13 unmodified lines | |||
| 455 | }, | 652 | }, |
| 456 | }, | 653 | }, |
| 457 | 654 | ||
| 458 | extension: () => ({ runtime }), | 655 | extension: extensionFor, |
| 459 | 656 | ||
| 460 | staticSources: () => [ | 657 | staticSources: (self) => [ |
| 461 | { | 658 | { |
| 462 | id: APPS_PLUGIN_ID, | 659 | id: APPS_PLUGIN_ID, |
| 463 | kind: "executor", | 660 | kind: "executor", |
| @@ -466,47 +663,18 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 2 unmodified lines | |||
| 466 | tool<AppsStoreShape>({ | 663 | tool<AppsStoreShape>({ |
| 467 | name: "sync_github_source", | 664 | name: "sync_github_source", |
| 468 | description: "Sync a GitHub repository containing custom tool source files.", | 665 | description: "Sync a GitHub repository containing custom tool source files.", |
| 469 | execute: (args, { ctx }) => | 666 | execute: (args) => self.syncGitHubSource(args), |
| 470 | Effect.gen(function* () { | ||
| 471 | const payload = isRecord(args) ? args : {}; | ||
| 472 | const url = | ||
| 473 | typeof payload.url === "string" | ||
| 474 | ? payload.url.trim() | ||
| 475 | : typeof payload.repo === "string" | ||
| 476 | ? payload.repo.trim() | ||
| 477 | : ""; | ||
| 478 | const ref = asString(payload.ref); | ||
| 479 | const providedToken = asString(payload.token) ?? null; | ||
| 480 | const slug = asString(payload.slug); | ||
| 481 | if (slug && !url) return yield* syncExistingSource(ctx, slug, providedToken); | ||
| 482 | if (!url) return syncFailure('sync_github_source requires "url"'); | ||
| 483 | return yield* addSource(ctx, { | ||
| 484 | url, | ||
| 485 | ...(ref ? { ref } : {}), | ||
| 486 | ...(asString(payload.name) ? { name: asString(payload.name) } : {}), | ||
| 487 | token: providedToken, | ||
| 488 | }); | ||
| 489 | }), | ||
| 490 | }), | 667 | }), |
| 491 | tool<AppsStoreShape>({ | 668 | tool<AppsStoreShape>({ |
| 492 | name: "list_github_sources", | 669 | name: "list_github_sources", |
| 493 | description: "List synced GitHub repositories that publish custom tools.", | 670 | description: "List synced GitHub repositories that publish custom tools.", |
| 494 | execute: (_args, { ctx }) => | 671 | execute: () => self.listGitHubSources(), |
| 495 | Effect.gen(function* () { | ||
| 496 | const sources = yield* listSources(ctx); | ||
| 497 | return { sources }; | ||
| 498 | }), | ||
| 499 | }), | 672 | }), |
| 500 | tool<AppsStoreShape>({ | 673 | tool<AppsStoreShape>({ |
| 501 | name: "get_github_source", | 674 | name: "get_github_source", |
| 502 | description: "Read one synced GitHub custom-tools source.", | 675 | description: "Read one synced GitHub custom-tools source.", |
| 503 | execute: (args, { ctx }) => | 676 | execute: (args) => |
| 504 | Effect.gen(function* () { | 677 | self.getGitHubSource(isRecord(args) ? (asString(args.slug) ?? "") : ""), |
| 505 | const slug = isRecord(args) ? asString(args.slug) : undefined; | ||
| 506 | if (!slug) return { source: null }; | ||
| 507 | const source = yield* getSource(ctx, slug); | ||
| 508 | return { source }; | ||
| 509 | }), | ||
| 510 | }), | 678 | }), |
| 511 | ], | 679 | ], |
| 512 | }, | 680 | }, |
| @@ -547,9 +715,7 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 34 unmodified lines | |||
| 547 | const descriptor = yield* runtime.getDescriptor(tenant, source.scope); | 715 | const descriptor = yield* runtime.getDescriptor(tenant, source.scope); |
| 548 | const toolDesc = descriptor?.tools.find((t) => t.name === toolRow.name); | 716 | const toolDesc = descriptor?.tools.find((t) => t.name === toolRow.name); |
| 549 | if (!toolDesc) return { inputSchema, outputSchema }; | 717 | if (!toolDesc) return { inputSchema, outputSchema }; |
| 550 | const resolver = makeResolver | 718 | const resolver = requestResolver({ ctx, scope: source.scope, tool: toolRow.name }); |
| 551 | ? makeResolver({ ctx, scope: source.scope, tool: toolRow.name }) | ||
| 552 | : runtime.deps.resolver; | ||
| 553 | const byRole: Record<string, readonly ConnectionCandidate[]> = {}; | 719 | const byRole: Record<string, readonly ConnectionCandidate[]> = {}; |
| 554 | for (const [role, decl] of Object.entries(toolDesc.integrations)) { | 720 | for (const [role, decl] of Object.entries(toolDesc.integrations)) { |
| 555 | byRole[role] = yield* resolver | 721 | byRole[role] = yield* resolver |
| @@ -589,9 +755,7 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 33 unmodified lines | |||
| 589 | diagnostics: [], | 755 | diagnostics: [], |
| 590 | }); | 756 | }); |
| 591 | } | 757 | } |
| 592 | const resolver = makeResolver | 758 | const resolver = requestResolver({ ctx, scope: source.scope, tool: toolRow.name }); |
| 593 | ? makeResolver({ ctx, scope: source.scope, tool: toolRow.name }) | ||
| 594 | : undefined; | ||
| 595 | return yield* runtime.invokeTool({ | 759 | return yield* runtime.invokeTool({ |
| 596 | tenant, | 760 | tenant, |
| 597 | scope: source.scope, | 761 | scope: source.scope, |
| @@ -0,0 +1,28 @@ | |||
| 1 | import type { ArtifactStore } from "../seams/artifact-store"; | ||
| 2 | import type { ScopeDb } from "../seams/scope-db"; | ||
| 3 | import type { ToolSandbox } from "../seams/tool-sandbox"; | ||
| 4 | import { makeAppsRuntime, type AppsRuntime } from "./runtime"; | ||
| 5 | import type { AppsStore } from "./store"; | ||
| 6 | import type { ClientResolver } from "./bindings"; | ||
| 7 | |||
| 8 | export interface AppsBackings { | ||
| 9 | readonly artifactStore: ArtifactStore; | ||
| 10 | readonly scopeDb: ScopeDb; | ||
| 11 | readonly sandbox: ToolSandbox; | ||
| 12 | readonly store: AppsStore; | ||
| 13 | readonly defaultTenant?: string; | ||
| 14 | readonly resolver?: ClientResolver; | ||
| 15 | } | ||
| 16 | |||
| 17 | export const makeAppsRuntimeFromBackings = ( | ||
| 18 | backings: AppsBackings, | ||
| 19 | fallbackResolver: ClientResolver, | ||
| 20 | ): AppsRuntime => | ||
| 21 | makeAppsRuntime({ | ||
| 22 | artifactStore: backings.artifactStore, | ||
| 23 | scopeDb: backings.scopeDb, | ||
| 24 | sandbox: backings.sandbox, | ||
| 25 | store: backings.store, | ||
| 26 | resolver: backings.resolver ?? fallbackResolver, | ||
| 27 | defaultTenant: backings.defaultTenant, | ||
| 28 | }); | ||
| @@ -0,0 +1,49 @@ | |||
| 1 | import { Context, Effect } from "effect"; | ||
| 2 | import { HttpApiBuilder } from "effect/unstable/httpapi"; | ||
| 3 | |||
| 4 | import { addGroup, capture } from "@executor-js/api"; | ||
| 5 | import type { AppsPluginExtension } from "./apps-plugin"; | ||
| 6 | import { AppsGroup } from "./routes"; | ||
| 7 | |||
| 8 | export class AppsExtensionService extends Context.Service< | ||
| 9 | AppsExtensionService, | ||
| 10 | AppsPluginExtension | ||
| 11 | >()("AppsExtensionService") {} | ||
| 12 | |||
| 13 | const ExecutorApiWithApps = addGroup(AppsGroup); | ||
| 14 | |||
| 15 | export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (handlers) => | ||
| 16 | handlers | ||
| 17 | .handle("listGithubSources", () => | ||
| 18 | capture( | ||
| 19 | Effect.gen(function* () { | ||
| 20 | const ext = yield* AppsExtensionService; | ||
| 21 | return yield* ext.listGitHubSources(); | ||
| 22 | }), | ||
| 23 | ), | ||
| 24 | ) | ||
| 25 | .handle("syncGithubSource", ({ payload }) => | ||
| 26 | capture( | ||
| 27 | Effect.gen(function* () { | ||
| 28 | const ext = yield* AppsExtensionService; | ||
| 29 | return yield* ext.syncGitHubSource(payload); | ||
| 30 | }), | ||
| 31 | ), | ||
| 32 | ) | ||
| 33 | .handle("getGithubSource", ({ params }) => | ||
| 34 | capture( | ||
| 35 | Effect.gen(function* () { | ||
| 36 | const ext = yield* AppsExtensionService; | ||
| 37 | return yield* ext.getGitHubSource(params.slug); | ||
| 38 | }), | ||
| 39 | ), | ||
| 40 | ) | ||
| 41 | .handle("removeGithubSource", ({ params }) => | ||
| 42 | capture( | ||
| 43 | Effect.gen(function* () { | ||
| 44 | const ext = yield* AppsExtensionService; | ||
| 45 | return yield* ext.removeGitHubSource(params.slug); | ||
| 46 | }), | ||
| 47 | ), | ||
| 48 | ), | ||
| 49 | ); | ||
| @@ -9,11 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | type Owner, | 9 | type Owner, |
| 10 | type PluginCtx, | 10 | type PluginCtx, |
| 11 | } from "@executor-js/sdk"; | 11 | } from "@executor-js/sdk"; |
| 12 | import { | 12 | import { BindingError, type ClientResolver, type ConnectionCandidate } from "./bindings"; |
| 13 | BindingError, | ||
| 14 | type ClientResolver, | ||
| 15 | type ConnectionCandidate, | ||
| 16 | } from "@executor-js/plugin-apps/api"; | ||
| 17 | 13 | ||
| 18 | const parseConnectionAddress = (address: string): ConnectionRef | null => { | 14 | const parseConnectionAddress = (address: string): ConnectionRef | null => { |
| 19 | const parts = address.split("."); | 15 | const parts = address.split("."); |
| @@ -40,7 +36,7 @@ const toCandidate = (connection: { · 20 unmodified lines | |||
| 40 | ...(connection.owner !== undefined ? { owner: String(connection.owner) } : {}), | 36 | ...(connection.owner !== undefined ? { owner: String(connection.owner) } : {}), |
| 41 | }); | 37 | }); |
| 42 | 38 | ||
| 43 | export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): ClientResolver => ({ | 39 | export const makePluginCtxAppsResolver = (input: { readonly ctx: PluginCtx }): ClientResolver => ({ |
| 44 | listConnections: ({ integration }) => | 40 | listConnections: ({ integration }) => |
| 45 | input.ctx.connections.list({ integration: IntegrationSlug.make(integration) }).pipe( | 41 | input.ctx.connections.list({ integration: IntegrationSlug.make(integration) }).pipe( |
| 46 | Effect.map((connections) => connections.map(toCandidate)), | 42 | Effect.map((connections) => connections.map(toCandidate)), |
| @@ -0,0 +1,104 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | ||
| 3 | import { IntegrationRemovalNotAllowedError, InternalError } from "@executor-js/sdk/shared"; | ||
| 4 | |||
| 5 | const GitHubSourcePayload = Schema.Struct({ | ||
| 6 | url: Schema.optional(Schema.String), | ||
| 7 | repo: Schema.optional(Schema.String), | ||
| 8 | name: Schema.optional(Schema.String), | ||
| 9 | slug: Schema.optional(Schema.String), | ||
| 10 | ref: Schema.optional(Schema.String), | ||
| 11 | token: Schema.optional(Schema.String), | ||
| 12 | }); | ||
| 13 | |||
| 14 | const SourceSlugParams = { | ||
| 15 | slug: Schema.String, | ||
| 16 | }; | ||
| 17 | |||
| 18 | const SkippedArtifact = Schema.Struct({ | ||
| 19 | path: Schema.String, | ||
| 20 | reason: Schema.Literals(["not supported yet", "unsupported file type", "ignored"]), | ||
| 21 | }); | ||
| 22 | |||
| 23 | const FileDiagnostic = Schema.Struct({ | ||
| 24 | path: Schema.String, | ||
| 25 | message: Schema.String, | ||
| 26 | }); | ||
| 27 | |||
| 28 | const SyncErrorData = Schema.Struct({ | ||
| 29 | stage: Schema.Literals(["source", "discover", "bundle", "collect", "project"]), | ||
| 30 | message: Schema.String, | ||
| 31 | diagnostics: Schema.optional(Schema.Array(FileDiagnostic)), | ||
| 32 | }); | ||
| 33 | |||
| 34 | const GitHubSyncResponse = Schema.Struct({ | ||
| 35 | status: Schema.Literals(["published", "up-to-date", "failed"]), | ||
| 36 | snapshotId: Schema.optional(Schema.String), | ||
| 37 | upstreamSha: Schema.optional(Schema.String), | ||
| 38 | tools: Schema.Array(Schema.String), | ||
| 39 | skipped: Schema.Array(SkippedArtifact), | ||
| 40 | errors: Schema.optional(Schema.Array(SyncErrorData)), | ||
| 41 | }); | ||
| 42 | |||
| 43 | const GitHubSourceSummary = Schema.Struct({ | ||
| 44 | slug: Schema.String, | ||
| 45 | name: Schema.String, | ||
| 46 | scope: Schema.String, | ||
| 47 | url: Schema.String, | ||
| 48 | repo: Schema.String, | ||
| 49 | ref: Schema.String, | ||
| 50 | hasToken: Schema.Boolean, | ||
| 51 | upstreamSha: Schema.String, | ||
| 52 | snapshotId: Schema.String, | ||
| 53 | description: Schema.optional(Schema.String), | ||
| 54 | publishedAt: Schema.String, | ||
| 55 | tools: Schema.Array(Schema.String), | ||
| 56 | skipped: Schema.Array(SkippedArtifact), | ||
| 57 | }); | ||
| 58 | |||
| 59 | const GitHubSourcesResponse = Schema.Struct({ | ||
| 60 | sources: Schema.Array(GitHubSourceSummary), | ||
| 61 | }); | ||
| 62 | |||
| 63 | const GitHubSourceResponse = Schema.Struct({ | ||
| 64 | source: Schema.NullOr(GitHubSourceSummary), | ||
| 65 | }); | ||
| 66 | |||
| 67 | const RemoveSourceResponse = Schema.Struct({ | ||
| 68 | removed: Schema.Boolean, | ||
| 69 | }); | ||
| 70 | |||
| 71 | const DomainErrors = [InternalError] as const; | ||
| 72 | const IntegrationRemovalNotAllowed = IntegrationRemovalNotAllowedError.annotate({ | ||
| 73 | httpApiStatus: 409, | ||
| 74 | }); | ||
| 75 | const RemoveSourceErrors = [InternalError, IntegrationRemovalNotAllowed] as const; | ||
| 76 | |||
| 77 | export const AppsGroup = HttpApiGroup.make("apps") | ||
| 78 | .add( | ||
| 79 | HttpApiEndpoint.get("listGithubSources", "/apps/sources/github", { | ||
| 80 | success: GitHubSourcesResponse, | ||
| 81 | error: DomainErrors, | ||
| 82 | }), | ||
| 83 | ) | ||
| 84 | .add( | ||
| 85 | HttpApiEndpoint.post("syncGithubSource", "/apps/sources/github/sync", { | ||
| 86 | payload: GitHubSourcePayload, | ||
| 87 | success: GitHubSyncResponse, | ||
| 88 | error: DomainErrors, | ||
| 89 | }), | ||
| 90 | ) | ||
| 91 | .add( | ||
| 92 | HttpApiEndpoint.get("getGithubSource", "/apps/sources/github/:slug", { | ||
| 93 | params: SourceSlugParams, | ||
| 94 | success: GitHubSourceResponse, | ||
| 95 | error: DomainErrors, | ||
| 96 | }), | ||
| 97 | ) | ||
| 98 | .add( | ||
| 99 | HttpApiEndpoint.delete("removeGithubSource", "/apps/sources/github/:slug", { | ||
| 100 | params: SourceSlugParams, | ||
| 101 | success: RemoveSourceResponse, | ||
| 102 | error: RemoveSourceErrors, | ||
| 103 | }), | ||
| 104 | ); | ||
| @@ -6,9 +6,10 @@ import { makeGitArtifactStore } from "../backing/git-artifact-store"; · 5 unmodified lines | |||
| 6 | import { makeLibsqlScopeDb } from "../backing/libsql-scope-db"; | 6 | import { makeLibsqlScopeDb } from "../backing/libsql-scope-db"; |
| 7 | import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox"; | 7 | import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox"; |
| 8 | import type { ScopeDb } from "../seams/scope-db"; | 8 | import type { ScopeDb } from "../seams/scope-db"; |
| 9 | import { makeAppsRuntime, type AppsRuntime } from "./runtime"; | 9 | import { type AppsRuntime } from "./runtime"; |
| 10 | import type { AppsStore } from "./store"; | 10 | import type { AppsStore } from "./store"; |
| 11 | import type { ClientResolver } from "./bindings"; | 11 | import type { ClientResolver } from "./bindings"; |
| 12 | import { makeAppsRuntimeFromBackings, type AppsBackings } from "./backings"; | ||
| 12 | 13 | ||
| 13 | export interface SelfHostAppsRuntimeOptions { | 14 | export interface SelfHostAppsRuntimeOptions { |
| 14 | /** Data dir root; `<root>/artifacts` and `<root>/scope-db`. */ | 15 | /** Data dir root; `<root>/artifacts` and `<root>/scope-db`. */ |
| @@ -24,6 +25,7 @@ export interface SelfHostAppsRuntimeOptions { · 9 unmodified lines | |||
| 24 | 25 | ||
| 25 | export interface SelfHostAppsRuntime { | 26 | export interface SelfHostAppsRuntime { |
| 26 | readonly runtime: AppsRuntime; | 27 | readonly runtime: AppsRuntime; |
| 28 | readonly backings: AppsBackings; | ||
| 27 | readonly scopeDb: ScopeDb; | 29 | readonly scopeDb: ScopeDb; |
| 28 | readonly close: () => Promise<void>; | 30 | readonly close: () => Promise<void>; |
| 29 | } | 31 | } |
| @@ -39,17 +41,19 @@ export const makeSelfHostAppsRuntime = ( · 9 unmodified lines | |||
| 39 | root: inMem ? ":memory:" : join(options.dataDir, "scope-db"), | 41 | root: inMem ? ":memory:" : join(options.dataDir, "scope-db"), |
| 40 | }); | 42 | }); |
| 41 | const sandbox = makeQuickjsToolSandbox(); | 43 | const sandbox = makeQuickjsToolSandbox(); |
| 42 | const runtime = makeAppsRuntime({ | 44 | const backings: AppsBackings = { |
| 43 | artifactStore, | 45 | artifactStore, |
| 44 | scopeDb, | 46 | scopeDb, |
| 45 | sandbox, | 47 | sandbox, |
| 46 | store: options.store, | 48 | store: options.store, |
| 47 | resolver: options.resolver, | 49 | resolver: options.resolver, |
| 48 | defaultTenant: options.tenant, | 50 | defaultTenant: options.tenant, |
| 49 | }); | 51 | }; |
| 52 | const runtime = makeAppsRuntimeFromBackings(backings, options.resolver); | ||
| 50 | 53 | ||
| 51 | return { | 54 | return { |
| 52 | runtime, | 55 | runtime, |
| 56 | backings, | ||
| 53 | scopeDb, | 57 | scopeDb, |
| 54 | close: async () => { | 58 | close: async () => { |
| 55 | await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined))); | 59 | await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined))); |
| @@ -12,7 +12,7 @@ import appsClientPlugin, { · 11 unmodified lines | |||
| 12 | syncCustomToolSource, | 12 | syncCustomToolSource, |
| 13 | syncStatusLabel, | 13 | syncStatusLabel, |
| 14 | validateGitHubSourceUrl, | 14 | validateGitHubSourceUrl, |
| 15 | } from "@executor-js/plugin-apps/client"; | 15 | } from "./plugin-client"; |
| 16 | 16 | ||
| 17 | const jsonResponse = (body: unknown, init?: ResponseInit): Response => | 17 | const jsonResponse = (body: unknown, init?: ResponseInit): Response => |
| 18 | new Response(JSON.stringify(body), { | 18 | new Response(JSON.stringify(body), { |