History

Consolidate custom tools plugin wiring

Rhys Sullivan committed 4 months ago

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

Showing the changes to packages/plugins (11 of 18 files). Show all

Unified · Split

11 files
  1. packages/plugins/apps/package.json +5 −0
  2. packages/plugins/apps/src/api.ts +22 −2
  3. packages/plugins/apps/src/index.ts +2 −0
  4. packages/plugins/apps/src/plugin/apps-plugin.test.ts +1 −1
  5. packages/plugins/apps/src/plugin/apps-plugin.ts +214 −50
  6. packages/plugins/apps/src/plugin/backings.ts +28 −0
  7. packages/plugins/apps/src/plugin/handlers.ts +49 −0
  8. packages/plugins/apps/src/plugin/resolver.ts +116 −0
  9. packages/plugins/apps/src/plugin/routes.ts +104 −0
  10. packages/plugins/apps/src/plugin/self-host-runtime.ts +7 −3
  11. packages/plugins/apps/src/react/custom-tools-client.web.test.ts +177 −0
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.ts+116−0
@@ -0,0 +1,116 @@
1+import { Effect } from "effect";
2+
3+import {
4+ ConnectionName,
5+ IntegrationSlug,
6+ ToolAddress,
7+ isToolResult,
8+ type ConnectionRef,
9+ type Owner,
10+ type PluginCtx,
11+} from "@executor-js/sdk";
12+import { BindingError, type ClientResolver, type ConnectionCandidate } from "./bindings";
13+
14+const parseConnectionAddress = (address: string): ConnectionRef | null => {
15+ const parts = address.split(".");
16+ if (parts.length !== 4 || parts[0] !== "tools") return null;
17+ const [, integration, owner, name] = parts;
18+ if (!integration || !name) return null;
19+ if (owner !== "org" && owner !== "user") return null;
20+ return {
21+ owner: owner as Owner,
22+ integration: IntegrationSlug.make(integration),
23+ name: ConnectionName.make(name),
24+ };
25+};
26+
27+const toCandidate = (connection: {
28+ readonly address: unknown;
29+ readonly integration: unknown;
30+ readonly name?: unknown;
31+ readonly owner?: unknown;
32+}): ConnectionCandidate => ({
33+ address: String(connection.address),
34+ integration: String(connection.integration),
35+ ...(connection.name !== undefined ? { name: String(connection.name) } : {}),
36+ ...(connection.owner !== undefined ? { owner: String(connection.owner) } : {}),
37+});
38+
39+export const makePluginCtxAppsResolver = (input: { readonly ctx: PluginCtx }): ClientResolver => ({
40+ listConnections: ({ integration }) =>
41+ input.ctx.connections.list({ integration: IntegrationSlug.make(integration) }).pipe(
42+ Effect.map((connections) => connections.map(toCandidate)),
43+ Effect.mapError(
44+ () =>
45+ new BindingError({
46+ role: integration,
47+ integration,
48+ message: `failed to list ${integration} connections`,
49+ }),
50+ ),
51+ ),
52+
53+ resolveConnection: ({ connection }) =>
54+ Effect.gen(function* () {
55+ const ref = parseConnectionAddress(connection);
56+ if (!ref) return null;
57+ const row = yield* input.ctx.connections.get(ref).pipe(
58+ Effect.mapError(
59+ () =>
60+ new BindingError({
61+ role: String(ref.integration),
62+ integration: String(ref.integration),
63+ requestedConnection: connection,
64+ message: `failed to resolve connection ${connection}`,
65+ }),
66+ ),
67+ );
68+ return row ? toCandidate(row) : null;
69+ }),
70+
71+ call: ({ integration, connection, path, args, invokeOptions }) =>
72+ Effect.gen(function* () {
73+ const ref = parseConnectionAddress(connection);
74+ if (!ref) {
75+ return yield* new BindingError({
76+ role: integration,
77+ integration,
78+ requestedConnection: connection,
79+ message: `invalid connection address ${connection}`,
80+ });
81+ }
82+ if (String(ref.integration) !== integration) {
83+ return yield* new BindingError({
84+ role: integration,
85+ integration,
86+ requestedConnection: connection,
87+ message: `connection "${connection}" belongs to integration "${ref.integration}", not "${integration}"`,
88+ });
89+ }
90+ const tool = path.join(".");
91+ const address = ToolAddress.make(`${connection}.${tool}`);
92+ const payload = args[0] ?? {};
93+ const result = yield* input.ctx.execute(address, payload, invokeOptions).pipe(
94+ Effect.mapError(
95+ () =>
96+ new BindingError({
97+ role: integration,
98+ integration,
99+ requestedConnection: connection,
100+ message: `failed to execute ${address}`,
101+ }),
102+ ),
103+ );
104+ if (isToolResult(result)) {
105+ if (result.ok) return result.data;
106+ const { message } = result.error;
107+ return yield* new BindingError({
108+ role: integration,
109+ integration,
110+ requestedConnection: connection,
111+ message,
112+ });
113+ }
114+ return result;
115+ }),
116+});
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.ts+177−0
@@ -0,0 +1,177 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import appsClientPlugin, {
4+ CUSTOM_TOOLS_LABEL,
5+ CUSTOM_TOOLS_PLUGIN_KEY,
6+ type CustomToolsFetch,
7+ appsIntegrationPlugin,
8+ formatSyncErrors,
9+ getCustomToolSource,
10+ listCustomToolSources,
11+ removeCustomToolSource,
12+ syncCustomToolSource,
13+ syncStatusLabel,
14+ validateGitHubSourceUrl,
15+} from "./plugin-client";
16+
17+const jsonResponse = (body: unknown, init?: ResponseInit): Response =>
18+ new Response(JSON.stringify(body), {
19+ status: 200,
20+ headers: { "content-type": "application/json" },
21+ ...init,
22+ });
23+
24+describe("custom tools console client", () => {
25+ it("registers the manual Custom tools tile", () => {
26+ const manualTiles = [appsIntegrationPlugin].map((plugin) => ({
27+ href: `/integrations/add/${plugin.key}`,
28+ label: plugin.label,
29+ }));
30+
31+ expect(appsClientPlugin.id).toBe(CUSTOM_TOOLS_PLUGIN_KEY);
32+ expect(appsClientPlugin.integrationPlugin?.label).toBe(CUSTOM_TOOLS_LABEL);
33+ expect(manualTiles).toContainEqual({
34+ href: "/integrations/add/apps",
35+ label: "Custom tools",
36+ });
37+ });
38+
39+ it("validates the GitHub URL shape", () => {
40+ expect(validateGitHubSourceUrl("https://github.com/UsefulSoftwareCo/executor")).toBeNull();
41+ expect(validateGitHubSourceUrl("UsefulSoftwareCo/executor")).toBeNull();
42+ expect(validateGitHubSourceUrl("https://gitlab.com/UsefulSoftwareCo/executor")).toBe(
43+ "GitHub source URLs must use github.com.",
44+ );
45+ expect(validateGitHubSourceUrl("UsefulSoftwareCo")).toBe(
46+ "Use a GitHub repo URL like https://github.com/owner/repo, optionally with /tree/<ref> or /commit/<sha>.",
47+ );
48+ expect(validateGitHubSourceUrl("")).toBe("Enter a GitHub URL.");
49+ });
50+
51+ it("surfaces successful sync and source detail data", async () => {
52+ let syncBody = "";
53+ let removed = false;
54+ const fetchImpl: CustomToolsFetch = async (input, init) => {
55+ const url = String(input);
56+ if (url.endsWith("/sync")) {
57+ syncBody = String(init?.body ?? "");
58+ return jsonResponse({
59+ status: "published",
60+ snapshotId: "snap1",
61+ upstreamSha: "abc123",
62+ tools: ["repo-summary", "stale-issues"],
63+ skipped: [],
64+ });
65+ }
66+ if (url.endsWith("/demo-tools")) {
67+ if (init?.method === "DELETE") {
68+ removed = true;
69+ return jsonResponse({ removed: true });
70+ }
71+ return jsonResponse({
72+ source: {
73+ slug: "demo-tools",
74+ name: "demo-tools",
75+ scope: "demo-tools",
76+ url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
77+ repo: "RhysSullivan/executor-custom-tools-demo",
78+ ref: "main",
79+ hasToken: true,
80+ upstreamSha: "abc123",
81+ snapshotId: "snap1",
82+ publishedAt: "2026-07-06T12:00:00.000Z",
83+ tools: ["repo-summary", "stale-issues"],
84+ skipped: [],
85+ },
86+ });
87+ }
88+ return jsonResponse({
89+ sources: [
90+ {
91+ slug: "demo-tools",
92+ name: "demo-tools",
93+ scope: "demo-tools",
94+ url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
95+ repo: "RhysSullivan/executor-custom-tools-demo",
96+ ref: "main",
97+ hasToken: true,
98+ upstreamSha: "abc123",
99+ snapshotId: "snap1",
100+ publishedAt: "2026-07-06T12:00:00.000Z",
101+ tools: ["repo-summary", "stale-issues"],
102+ skipped: [],
103+ },
104+ ],
105+ });
106+ };
107+
108+ const syncResult = await syncCustomToolSource(
109+ {
110+ name: "demo-tools",
111+ url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
112+ token: "ghp_demo",
113+ },
114+ fetchImpl,
115+ );
116+ const listed = await listCustomToolSources(fetchImpl);
117+ const detail = await getCustomToolSource("demo-tools", fetchImpl);
118+ const remove = await removeCustomToolSource("demo-tools", fetchImpl);
119+
120+ expect(syncStatusLabel(syncResult)).toBe("Published 2 tools.");
121+ expect(syncBody).toContain("ghp_demo");
122+ expect(syncBody).toContain("demo-tools");
123+ expect(syncResult.tools).toEqual(["repo-summary", "stale-issues"]);
124+ expect(detail.source?.slug).toBe("demo-tools");
125+ expect(listed.sources[0]?.hasToken).toBe(true);
126+ expect(listed.sources[0]?.tools).toEqual(["repo-summary", "stale-issues"]);
127+ expect(remove).toEqual({ removed: true });
128+ expect(removed).toBe(true);
129+ });
130+
131+ it("renders failed sync errors readably", async () => {
132+ const fetchImpl: CustomToolsFetch = async () =>
133+ jsonResponse({
134+ status: "failed",
135+ tools: [],
136+ skipped: [],
137+ errors: [
138+ {
139+ stage: "collect",
140+ message: "schema library not supported for schema export",
141+ diagnostics: [{ path: "tools/x.ts", message: "vendor: nope" }],
142+ },
143+ ],
144+ });
145+
146+ const result = await syncCustomToolSource(
147+ {
148+ url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
149+ },
150+ fetchImpl,
151+ );
152+
153+ expect(syncStatusLabel(result)).toBe("Sync failed.");
154+ expect(formatSyncErrors(result)).toEqual([
155+ "collect: schema library not supported for schema export (tools/x.ts: vendor: nope)",
156+ ]);
157+ });
158+
159+ it("shows the up-to-date sync state", async () => {
160+ const fetchImpl: CustomToolsFetch = async () =>
161+ jsonResponse({
162+ status: "up-to-date",
163+ upstreamSha: "abc123",
164+ tools: ["repo-summary"],
165+ skipped: [],
166+ });
167+
168+ const result = await syncCustomToolSource(
169+ {
170+ url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
171+ },
172+ fetchImpl,
173+ );
174+
175+ expect(syncStatusLabel(result)).toBe("Already up to date.");
176+ });
177+});