History

Consolidate custom tools plugin wiring

Rhys Sullivan committed 4 months ago

Commit 3dba8101 parent aeb483318 files · +497 −310Browse files

Unified · Split

18 files
  1. apps/host-selfhost/executor.config.ts +3 −2
  2. apps/host-selfhost/src/app.ts +2 −4
  3. apps/host-selfhost/src/apps-backings.ts +52 −0
  4. apps/host-selfhost/src/apps-route.ts +0 −166
  5. apps/host-selfhost/src/apps.ts +0 −71
  6. apps/host-selfhost/src/testing/test-app.ts +2 −4
  7. bun.lock +3 −0
  8. packages/plugins/apps/package.json +5 −0
  9. packages/plugins/apps/src/api.ts +22 −2
  10. packages/plugins/apps/src/index.ts +2 −0
  11. packages/plugins/apps/src/plugin/apps-plugin.test.ts +1 −1
  12. packages/plugins/apps/src/plugin/apps-plugin.ts +214 −50
  13. packages/plugins/apps/src/plugin/backings.ts +28 −0
  14. packages/plugins/apps/src/plugin/handlers.ts +49 −0
  15. packages/plugins/apps/src/plugin/resolver.ts +2 −6
  16. packages/plugins/apps/src/plugin/routes.ts +104 −0
  17. packages/plugins/apps/src/plugin/self-host-runtime.ts +7 −3
  18. packages/plugins/apps/src/react/custom-tools-client.web.test.ts +1 −1
apps/host-selfhost/executor.config.ts+3−2
@@ -6,9 +6,10 @@ import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; · 5 unmodified lines
66 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
77 import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
88 import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
9+import { appsPlugin } from "@executor-js/plugin-apps/api";
910
1011 import { resolveSecretKey } from "./src/config";
11−import { getSelfHostAppsSubsystem } from "./src/apps";
12+import { getSelfHostAppsBackings } from "./src/apps-backings";
1213
1314 // ---------------------------------------------------------------------------
1415 // Single source of truth for the self-hosted app's plugin list.
@@ -37,7 +38,7 @@ export default defineExecutorConfig({ · 22 unmodified lines
3738 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
3839 graphqlHttpPlugin(),
3940 toolkitsPlugin({ activeToolkitSlug }),
40− getSelfHostAppsSubsystem().plugin,
41+ appsPlugin({ backings: getSelfHostAppsBackings() }),
4142 // First writable secret provider -> the default for `secrets.set`.
4243 encryptedSecretsPlugin({ key: resolveSecretKey() }),
4344 ] as const,
apps/host-selfhost/src/app.ts+2−4
@@ -22,8 +22,7 @@ import { makeSelfHostMcpSeams } from "./mcp"; · 21 unmodified lines
2222 import { selfHostPlugins } from "./plugins";
2323 import { ErrorCaptureLive } from "./observability";
2424 import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login";
25−import { closeSelfHostAppsSubsystem } from "./apps";
26−import { makeSelfHostAppsSyncRoute } from "./apps-route";
25+import { closeSelfHostAppsBackings } from "./apps-backings";
2726
2827 // ===========================================================================
2928 // The self-hosted Executor app, as ONE `ExecutorApp.make` call.
@@ -121,7 +120,6 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 91 unmodified lines
121120 makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
122121 // Public system API: /api/health + /api/setup-status (unauthenticated).
123122 makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
124− makeSelfHostAppsSyncRoute({ identity: identityLayer, db: dbHandle }),
125123 // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths).
126124 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),
127125 ],
@@ -142,7 +140,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 14 unmodified lines
142140 toWebHandler,
143141 betterAuth,
144142 closeDb: async () => {
145− await closeSelfHostAppsSubsystem();
143+ await closeSelfHostAppsBackings();
146144 await mcp.close();
147145 await dbHandle.close();
148146 },
apps/host-selfhost/src/apps-backings.ts+52−0
@@ -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+};
apps/host-selfhost/src/apps-route.ts+0−166
@@ -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−};
apps/host-selfhost/src/apps.ts+0−71
@@ -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−};
apps/host-selfhost/src/testing/test-app.ts+2−4
@@ -30,8 +30,7 @@ import { · 29 unmodified lines
3030 } from "../mcp/session-store";
3131 import { selfHostPlugins } from "../plugins";
3232 import { ErrorCaptureLive } from "../observability";
33−import { closeSelfHostAppsSubsystem } from "../apps";
34−import { makeSelfHostAppsSyncRoute } from "../apps-route";
33+import { closeSelfHostAppsBackings } from "../apps-backings";
3534
3635 // ===========================================================================
3736 // Self-host TEST harness — the throwaway composition tests use to exercise the
@@ -204,7 +203,6 @@ export const makeSelfHostTestApp = async ( · 166 unmodified lines
204203 },
205204 extensions: {
206205 routes: [
207− makeSelfHostAppsSyncRoute({ identity: options.identity, db: dbHandle }),
208206 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),
209207 ],
210208 },
@@ -220,7 +218,7 @@ export const makeSelfHostTestApp = async ( · 9 unmodified lines
220218 handler: web.handler,
221219 dispose: async () => {
222220 await web.dispose();
223− await closeSelfHostAppsSubsystem();
221+ await closeSelfHostAppsBackings();
224222 await sessionStore.close();
225223 await dbHandle.close();
226224 },
bun.lock+3−0
@@ -797,6 +797,7 @@ · 796 unmodified lines
797797 "devDependencies": {
798798 "@effect/atom-react": "catalog:",
799799 "@effect/vitest": "catalog:",
800+ "@executor-js/api": "workspace:*",
800801 "@executor-js/react": "workspace:*",
801802 "@types/node": "catalog:",
802803 "@types/react": "catalog:",
@@ -808,12 +809,14 @@ · 5 unmodified lines
808809 },
809810 "peerDependencies": {
810811 "@effect/atom-react": "catalog:",
812+ "@executor-js/api": "workspace:*",
811813 "@executor-js/react": "workspace:*",
812814 "effect": "catalog:",
813815 "react": "catalog:",
814816 },
815817 "optionalPeers": [
816818 "@effect/atom-react",
819+ "@executor-js/api",
817820 "@executor-js/react",
818821 "react",
819822 ],
packages/plugins/apps/package.json+5−0
@@ -40,6 +40,7 @@ · 39 unmodified lines
4040 "devDependencies": {
4141 "@effect/atom-react": "catalog:",
4242 "@effect/vitest": "catalog:",
43+ "@executor-js/api": "workspace:*",
4344 "@executor-js/react": "workspace:*",
4445 "@types/node": "catalog:",
4546 "@types/react": "catalog:",
@@ -51,6 +52,7 @@ · 5 unmodified lines
5152 },
5253 "peerDependencies": {
5354 "@effect/atom-react": "catalog:",
55+ "@executor-js/api": "workspace:*",
5456 "@executor-js/react": "workspace:*",
5557 "effect": "catalog:",
5658 "react": "catalog:"
@@ -59,6 +61,9 @@ · 2 unmodified lines
5961 "@effect/atom-react": {
6062 "optional": true
6163 },
64+ "@executor-js/api": {
65+ "optional": true
66+ },
6267 "@executor-js/react": {
6368 "optional": true
6469 },
packages/plugins/apps/src/api.ts+22−2
@@ -1,16 +1,35 @@
11 // Host-facing plugin surface for @executor-js/plugin-apps.
2−export {
3− appsPlugin,
2+import { definePlugin } from "@executor-js/sdk/core";
3+
4+import {
5+ appsPlugin as appsCorePlugin,
46 APPS_INTEGRATION_SLUG,
57 APPS_PLUGIN_ID,
68 type AppsPluginOptions,
79 } 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";
823 export {
924 makeSelfHostAppsRuntime,
1025 type SelfHostAppsRuntime,
1126 type SelfHostAppsRuntimeOptions,
1227 } from "./plugin/self-host-runtime";
1328 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";
1433 export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";
1534 export {
1635 BindingError,
@@ -18,6 +37,7 @@ export { · 1 unmodified line
1837 type ConnectionCandidate,
1938 type RoleBindings,
2039 } from "./plugin/bindings";
40+export { makePluginCtxAppsResolver } from "./plugin/resolver";
2141 export {
2242 fetchGitHubSource,
2343 parseGitHubSourceUrl,
packages/plugins/apps/src/index.ts+2−0
@@ -28,6 +28,7 @@ export { · 27 unmodified lines
2828 } from "./source/github-source";
2929
3030 export { makeAppsRuntime, type AppsRuntime, type AppsRuntimeDeps } from "./plugin/runtime";
31+export { makeAppsRuntimeFromBackings, type AppsBackings } from "./plugin/backings";
3132 export {
3233 makeAppsStore,
3334 type AppsStore,
@@ -50,6 +51,7 @@ export { · 16 unmodified lines
5051 type ClientResolver,
5152 type BindingContext,
5253 } from "./plugin/bindings";
54+export { makePluginCtxAppsResolver } from "./plugin/resolver";
5355 export {
5456 makeSelfHostAppsRuntime,
5557 type SelfHostAppsRuntime,
packages/plugins/apps/src/plugin/apps-plugin.test.ts+1−1
@@ -62,7 +62,7 @@ describe("appsPlugin custom-tools contract", () => { · 61 unmodified lines
6262 inMemory: true,
6363 });
6464 const runtime = host.runtime;
65− const plugin = appsPlugin({ runtime });
65+ const plugin = appsPlugin({ backings: host.backings });
6666 const appIntegration = IntegrationSlug.make("rhys-tools");
6767 const appConfig = {
6868 kind: "github",
packages/plugins/apps/src/plugin/apps-plugin.ts+214−50
@@ -14,11 +14,17 @@ import { · 13 unmodified lines
1414 type PluginCtx,
1515 type ResolveToolsInput,
1616 type ResolveToolsResult,
17+ StorageError,
18+ type StorageFailure,
1719 type ToolDef,
20+ type IntegrationRemovalNotAllowedError,
1821 } from "@executor-js/sdk";
1922
2023 import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor";
2124 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";
2228 import {
2329 parseGitHubSourceUrl,
2430 syncGitHubSource,
@@ -28,7 +34,9 @@ import { · 3 unmodified lines
2834 import { slugifyCustomToolsAppName, validateCustomToolsAppSlug } from "../source/app-slug";
2935 import type { AppsRuntime, GitHubCustomToolsSourceSummary } from "./runtime";
3036 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";
3240
3341 export const APPS_INTEGRATION_SLUG = "apps";
3442 export const APPS_PLUGIN_ID = "apps";
@@ -44,17 +52,43 @@ interface AppsGitHubSourceConfig { · 9 unmodified lines
4452 readonly token?: GitHubSourceTokenRef;
4553 }
4654
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 {
4874 readonly runtime: AppsRuntime;
49− readonly makeResolver?: (input: {
50− readonly ctx: unknown;
51− readonly scope: string;
52− readonly tool: string;
53− }) => ClientResolver;
5475 }
5576
56−interface AppsStoreShape {
77+export interface AppsPluginExtension {
5778 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+ >;
5892 }
5993
6094 const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -65,6 +99,13 @@ const asString = (value: unknown): string | undefined => · 4 unmodified lines
6599
66100 const unique = (values: readonly string[]): readonly string[] => [...new Set(values)];
67101
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+
68109 const syncFailure = (message: string, path?: string): GitHubSyncResult => ({
69110 status: "failed",
70111 tools: [],
@@ -78,6 +119,98 @@ const syncFailure = (message: string, path?: string): GitHubSyncResult => ({ · 7 unmodified lines
78119 ],
79120 });
80121
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+
81214 const sourceTokenItemId = (tenant: string, scope: string): ProviderItemId =>
82215 ProviderItemId.make(`apps:github-source:${tenant}:${scope}:token`);
83216
@@ -160,7 +293,11 @@ const projectTool = (descriptor: ToolDescriptor): ToolDef => ({ · 76 unmodified lines
160293 });
161294
162295 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());
164301 const makeResolver = options?.makeResolver;
165302
166303 const tenantFor = (ctx: Pick<PluginCtx, "owner"> | undefined): string => {
@@ -436,6 +573,66 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 269 unmodified lines
436573 Effect.orElseSucceed(() => null),
437574 );
438575
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+
439636 return {
440637 id: APPS_PLUGIN_ID as "apps",
441638 packageName: "@executor-js/plugin-apps",
@@ -455,9 +652,9 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 13 unmodified lines
455652 },
456653 },
457654
458− extension: () => ({ runtime }),
655+ extension: extensionFor,
459656
460− staticSources: () => [
657+ staticSources: (self) => [
461658 {
462659 id: APPS_PLUGIN_ID,
463660 kind: "executor",
@@ -466,47 +663,18 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 2 unmodified lines
466663 tool<AppsStoreShape>({
467664 name: "sync_github_source",
468665 description: "Sync a GitHub repository containing custom tool source files.",
469− execute: (args, { ctx }) =>
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− }),
666+ execute: (args) => self.syncGitHubSource(args),
490667 }),
491668 tool<AppsStoreShape>({
492669 name: "list_github_sources",
493670 description: "List synced GitHub repositories that publish custom tools.",
494− execute: (_args, { ctx }) =>
495− Effect.gen(function* () {
496− const sources = yield* listSources(ctx);
497− return { sources };
498− }),
671+ execute: () => self.listGitHubSources(),
499672 }),
500673 tool<AppsStoreShape>({
501674 name: "get_github_source",
502675 description: "Read one synced GitHub custom-tools source.",
503− execute: (args, { ctx }) =>
504− Effect.gen(function* () {
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− }),
676+ execute: (args) =>
677+ self.getGitHubSource(isRecord(args) ? (asString(args.slug) ?? "") : ""),
510678 }),
511679 ],
512680 },
@@ -547,9 +715,7 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 34 unmodified lines
547715 const descriptor = yield* runtime.getDescriptor(tenant, source.scope);
548716 const toolDesc = descriptor?.tools.find((t) => t.name === toolRow.name);
549717 if (!toolDesc) return { inputSchema, outputSchema };
550− const resolver = makeResolver
551− ? makeResolver({ ctx, scope: source.scope, tool: toolRow.name })
552− : runtime.deps.resolver;
718+ const resolver = requestResolver({ ctx, scope: source.scope, tool: toolRow.name });
553719 const byRole: Record<string, readonly ConnectionCandidate[]> = {};
554720 for (const [role, decl] of Object.entries(toolDesc.integrations)) {
555721 byRole[role] = yield* resolver
@@ -589,9 +755,7 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 33 unmodified lines
589755 diagnostics: [],
590756 });
591757 }
592− const resolver = makeResolver
593− ? makeResolver({ ctx, scope: source.scope, tool: toolRow.name })
594− : undefined;
758+ const resolver = requestResolver({ ctx, scope: source.scope, tool: toolRow.name });
595759 return yield* runtime.invokeTool({
596760 tenant,
597761 scope: source.scope,
packages/plugins/apps/src/plugin/backings.ts+28−0
@@ -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+ });
packages/plugins/apps/src/plugin/handlers.ts+49−0
@@ -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+);
packages/plugins/apps/src/plugin/resolver.tsrenamed from apps/host-selfhost/src/apps-resolver.ts+2−6
@@ -9,11 +9,7 @@ import { · 8 unmodified lines
99 type Owner,
1010 type PluginCtx,
1111 } from "@executor-js/sdk";
12−import {
13− BindingError,
14− type ClientResolver,
15− type ConnectionCandidate,
16−} from "@executor-js/plugin-apps/api";
12+import { BindingError, type ClientResolver, type ConnectionCandidate } from "./bindings";
1713
1814 const parseConnectionAddress = (address: string): ConnectionRef | null => {
1915 const parts = address.split(".");
@@ -40,7 +36,7 @@ const toCandidate = (connection: { · 20 unmodified lines
4036 ...(connection.owner !== undefined ? { owner: String(connection.owner) } : {}),
4137 });
4238
43−export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): ClientResolver => ({
39+export const makePluginCtxAppsResolver = (input: { readonly ctx: PluginCtx }): ClientResolver => ({
4440 listConnections: ({ integration }) =>
4541 input.ctx.connections.list({ integration: IntegrationSlug.make(integration) }).pipe(
4642 Effect.map((connections) => connections.map(toCandidate)),
packages/plugins/apps/src/plugin/routes.ts+104−0
@@ -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+ );
packages/plugins/apps/src/plugin/self-host-runtime.ts+7−3
@@ -6,9 +6,10 @@ import { makeGitArtifactStore } from "../backing/git-artifact-store"; · 5 unmodified lines
66 import { makeLibsqlScopeDb } from "../backing/libsql-scope-db";
77 import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox";
88 import type { ScopeDb } from "../seams/scope-db";
9−import { makeAppsRuntime, type AppsRuntime } from "./runtime";
9+import { type AppsRuntime } from "./runtime";
1010 import type { AppsStore } from "./store";
1111 import type { ClientResolver } from "./bindings";
12+import { makeAppsRuntimeFromBackings, type AppsBackings } from "./backings";
1213
1314 export interface SelfHostAppsRuntimeOptions {
1415 /** Data dir root; `<root>/artifacts` and `<root>/scope-db`. */
@@ -24,6 +25,7 @@ export interface SelfHostAppsRuntimeOptions { · 9 unmodified lines
2425
2526 export interface SelfHostAppsRuntime {
2627 readonly runtime: AppsRuntime;
28+ readonly backings: AppsBackings;
2729 readonly scopeDb: ScopeDb;
2830 readonly close: () => Promise<void>;
2931 }
@@ -39,17 +41,19 @@ export const makeSelfHostAppsRuntime = ( · 9 unmodified lines
3941 root: inMem ? ":memory:" : join(options.dataDir, "scope-db"),
4042 });
4143 const sandbox = makeQuickjsToolSandbox();
42− const runtime = makeAppsRuntime({
44+ const backings: AppsBackings = {
4345 artifactStore,
4446 scopeDb,
4547 sandbox,
4648 store: options.store,
4749 resolver: options.resolver,
4850 defaultTenant: options.tenant,
49− });
51+ };
52+ const runtime = makeAppsRuntimeFromBackings(backings, options.resolver);
5053
5154 return {
5255 runtime,
56+ backings,
5357 scopeDb,
5458 close: async () => {
5559 await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined)));
packages/plugins/apps/src/react/custom-tools-client.web.test.tsrenamed from apps/host-selfhost/src/custom-tools-console.web.test.ts+1−1
@@ -12,7 +12,7 @@ import appsClientPlugin, { · 11 unmodified lines
1212 syncCustomToolSource,
1313 syncStatusLabel,
1414 validateGitHubSourceUrl,
15−} from "@executor-js/plugin-apps/client";
15+} from "./plugin-client";
1616
1717 const jsonResponse = (body: unknown, init?: ResponseInit): Response =>
1818 new Response(JSON.stringify(body), {