History

Make custom tools sources first-class integrations

Rhys Sullivan committed 4 months ago

Commit aeb48331 parent 6e2010f24 files · +1146 −527Browse files

Unified · Split

24 files
  1. apps/host-selfhost/scripts/custom-tools-demo.ts +7 −2
  2. apps/host-selfhost/src/apps-route.ts +45 −1
  3. apps/host-selfhost/src/apps-wire.node.test.ts +241 −30
  4. apps/host-selfhost/src/custom-tools-console.web.test.ts +35 −1
  5. packages/core/sdk/src/executor.ts +13 −0
  6. packages/core/sdk/src/plugin.ts +11 −0
  7. packages/plugins/apps/src/api.ts +0 −3
  8. packages/plugins/apps/src/backing/git-artifact-store.ts +14 −1
  9. packages/plugins/apps/src/backing/libsql-scope-db.ts +19 −1
  10. packages/plugins/apps/src/backing/scope-collision.test.ts +21 −12
  11. packages/plugins/apps/src/backing/sqlite-apps-store.ts +5 −48
  12. packages/plugins/apps/src/index.ts +5 −2
  13. packages/plugins/apps/src/plugin/apps-plugin.test.ts +40 −7
  14. packages/plugins/apps/src/plugin/apps-plugin.ts +354 −179
  15. packages/plugins/apps/src/plugin/runtime.ts +49 −24
  16. packages/plugins/apps/src/plugin/store.ts +4 −66
  17. packages/plugins/apps/src/react/AddCustomToolsSource.tsx +48 −3
  18. packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx +121 −127
  19. packages/plugins/apps/src/react/custom-tools-client.ts +68 −2
  20. packages/plugins/apps/src/seams/artifact-store.ts +1 −0
  21. packages/plugins/apps/src/seams/scope-db.ts +1 −0
  22. packages/plugins/apps/src/source/app-slug.ts +17 −0
  23. packages/plugins/apps/src/testing/index.ts +15 −14
  24. packages/react/src/pages/integration-detail.tsx +12 −4
apps/host-selfhost/scripts/custom-tools-demo.ts+7−2
@@ -7,6 +7,7 @@ const HOST = "127.0.0.1"; · 6 unmodified lines
77 const BASE_URL = `http://${HOST}:${PORT}`;
88 const ORIGIN = "http://custom-tools-demo.local";
99 const DEMO_REPO = "RhysSullivan/executor-custom-tools-demo";
10+const DEMO_APP = "demo-tools";
1011 const ADMIN_EMAIL = "admin@custom-tools-demo.local";
1112 const ADMIN_PASSWORD = "admin-pass-123456";
1213 const USER_EMAIL = "rhys@custom-tools-demo.local";
@@ -232,7 +233,7 @@ const htmlEscape = (value: string): string => · 219 unmodified lines
232233 .replaceAll('"', """);
233234
234235 const toolInvokeCode = (toolName: string, args: unknown): string => `
235−const found = await tools.search({ namespace: "apps", query: ${JSON.stringify(toolName)}, limit: 20 });
236+const found = await tools.search({ namespace: ${JSON.stringify(DEMO_APP)}, query: ${JSON.stringify(toolName)}, limit: 20 });
236237 const item = found.items.find((candidate) => candidate.path.endsWith(${JSON.stringify(toolName)}));
237238 if (!item) return { ok: false, missing: ${JSON.stringify(toolName)}, found };
238239 let fn = tools;
@@ -251,10 +252,11 @@ const buildBanner = (input: { · 12 unmodified lines
251252 readonly consoleUi: string;
252253 }): string => {
253254 const sync = curlJson("/api/apps/sources/github/sync", {
255+ name: DEMO_APP,
254256 url: input.repoUrl,
255257 token: input.githubToken,
256258 });
257− const list = `curl -sS '${BASE_URL}/api/tools?integration=apps' | jq`;
259+ const list = `curl -sS '${BASE_URL}/api/tools?integration=${DEMO_APP}' | jq`;
258260 const repoSummary = curlJson("/api/executions", {
259261 code: toolInvokeCode("repo-summary", {
260262 github: input.connectionAddress,
@@ -290,6 +292,9 @@ Auth: · 29 unmodified lines
290292 GitHub invocation connection:
291293 ${input.connectionAddress}
292294
295+Custom tools app:
296+ ${DEMO_APP}
297+
293298 Sync the repo:
294299 ${sync}
295300
apps/host-selfhost/src/apps-route.ts+45−1
@@ -8,7 +8,7 @@ import { · 7 unmodified lines
88 makeScopedExecutor,
99 type Principal,
1010 } from "@executor-js/api/server";
11−import { ToolAddress } from "@executor-js/sdk";
11+import { IntegrationSlug, ToolAddress } from "@executor-js/sdk";
1212
1313 import { SelfHostScopedExecutorSeams } from "./execution";
1414 import { SelfHostDb, type SelfHostDbHandle } from "./db/self-host-db";
@@ -43,6 +43,21 @@ const buildExecutor = (principal: Principal, request: Request) => { · 28 unmodified lines
4343 : withOrigin;
4444 };
4545
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+
4661 export interface SelfHostAppsSyncRouteDeps {
4762 readonly identity: Layer.Layer<IdentityProvider>;
4863 readonly db: SelfHostDbHandle;
@@ -118,5 +133,34 @@ export const makeSelfHostAppsSyncRoute = ({ identity, db }: SelfHostAppsSyncRout · 69 unmodified lines
118133 }),
119134 ),
120135 ),
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+ ),
121165 ).pipe(HttpRouter.provideRequest(services));
122166 };
apps/host-selfhost/src/apps-wire.node.test.ts+241−30
@@ -1,13 +1,14 @@
11 import { randomBytes } from "node:crypto";
2−import { mkdtempSync } from "node:fs";
2+import { existsSync, mkdtempSync } from "node:fs";
33 import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
44 import { createServer as createNetServer } from "node:net";
55 import { tmpdir } from "node:os";
66 import { join } from "node:path";
77
88 import { afterAll, beforeAll, expect, test } from "@effect/vitest";
9−import { Effect, Schema } from "effect";
9+import { Effect, Option, Schema } from "effect";
1010 import { createEmulator, type Emulator } from "@executor-js/emulate";
11+import { createClient } from "@libsql/client";
1112
1213 const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-"));
1314 const dbPath = join(dataDir, "data.db");
@@ -21,6 +22,9 @@ const OWNER = "syncer"; · 7 unmodified lines
2122 const REPO = "custom-tools";
2223 const REPO_FULL_NAME = `${OWNER}/${REPO}`;
2324 const GITHUB_CONNECTION = "tools.github.user.main";
25+const APP_SLUG = "custom-tools-app";
26+const SCHEMA_APP_SLUG = "schema-tools-app";
27+const APPROVAL_APP_SLUG = "approval-tools-app";
2428
2529 interface TestHttpServer {
2630 readonly baseUrl: string;
@@ -33,7 +37,7 @@ interface SyncResult { · 6 unmodified lines
3337 readonly upstreamSha?: string;
3438 readonly tools: readonly string[];
3539 readonly skipped: readonly { readonly path: string; readonly reason: string }[];
36− readonly errors?: readonly unknown[];
40+ readonly errors?: readonly { readonly message?: string }[];
3741 }
3842
3943 interface ToolRow {
@@ -46,6 +50,14 @@ interface ConnectionRow { · 6 unmodified lines
4650 readonly address: string;
4751 }
4852
53+interface IntegrationRow {
54+ readonly slug: string;
55+ readonly name: string;
56+ readonly kind: string;
57+ readonly canRemove: boolean;
58+ readonly displayUrl?: string;
59+}
60+
4961 interface ExecuteResponse {
5062 readonly status: "completed" | "paused";
5163 readonly text: string;
@@ -194,6 +206,48 @@ const postJson = <T>(path: string, body: unknown, expectedStatus = 200): Promise · 142 unmodified lines
194206 expectedStatus,
195207 );
196208
209+const deleteJson = <T>(path: string, expectedStatus = 200): Promise<T> =>
210+ requestJson<T>(
211+ path,
212+ {
213+ method: "DELETE",
214+ headers: jsonHeaders,
215+ },
216+ expectedStatus,
217+ );
218+
219+const querySqliteRows = async (
220+ path: string,
221+ sql: string,
222+ args: readonly unknown[] = [],
223+): Promise<readonly Record<string, unknown>[]> => {
224+ const client = createClient({ url: `file:${path}` });
225+ const result = await client.execute({ sql, args: [...args] as never });
226+ client.close();
227+ return result.rows.map((row) => ({ ...row }));
228+};
229+
230+const sourceStorageKey = (scope: string): string =>
231+ `v2-${Buffer.from(JSON.stringify([TEST_ORG, scope]), "utf8").toString("hex")}`;
232+
233+const sourceTokenItemId = (scope: string): string =>
234+ `apps:github-source:${TEST_ORG}:${scope}:token`;
235+
236+const DbJsonObject = Schema.Record(Schema.String, Schema.Unknown);
237+const DbJsonObjectFromString = Schema.fromJsonString(DbJsonObject);
238+const decodeDbJsonObject = Schema.decodeUnknownOption(DbJsonObject);
239+const decodeDbJsonObjectFromString = Schema.decodeUnknownOption(DbJsonObjectFromString);
240+
241+const decodeDbJson = (value: unknown): Record<string, unknown> => {
242+ if (value instanceof ArrayBuffer) {
243+ return decodeDbJson(Buffer.from(value).toString("utf8"));
244+ }
245+ if (typeof value === "string") {
246+ return Option.getOrElse(decodeDbJsonObjectFromString(value), () => ({}));
247+ }
248+ return Option.getOrElse(decodeDbJsonObject(value), () => ({}));
249+};
250+
197251 const githubFetch = async <T>(
198252 emulator: Emulator,
199253 token: string,
@@ -236,7 +290,7 @@ const putRepoFiles = async ( · 36 unmodified lines
236290 const tree = await githubFetch<{ sha: string }>(
237291 emulator,
238292 token,
239− `/repos/${OWNER}/${REPO}/git/trees`,
293+ `/repos/${OWNER}/${repo}/git/trees`,
240294 {
241295 method: "POST",
242296 body: JSON.stringify({
@@ -252,7 +306,7 @@ const putRepoFiles = async ( · 9 unmodified lines
252306 const commit = await githubFetch<{ sha: string }>(
253307 emulator,
254308 token,
255− `/repos/${OWNER}/${REPO}/git/commits`,
309+ `/repos/${OWNER}/${repo}/git/commits`,
256310 {
257311 method: "POST",
258312 body: JSON.stringify({
@@ -469,19 +523,36 @@ const registerGitHubIntegration = async (emulator: Emulator, token: string): Pro · 210 unmodified lines
469523
470524 const sourceUrl = (repo = REPO_FULL_NAME): string => `https://github.com/${repo}`;
471525
472−const syncSource = (
473− input: { readonly repo?: string; readonly token?: string } = {},
526+const addSource = (
527+ input: { readonly name?: string; readonly repo?: string; readonly token?: string } = {},
474528 ): Promise<SyncResult> =>
475529 postJson<SyncResult>("/api/apps/sources/github/sync", {
530+ name: input.name ?? APP_SLUG,
476531 url: sourceUrl(input.repo),
477532 ...(input.token ? { token: input.token } : {}),
478533 });
479534
480−const listAppTools = (): Promise<readonly ToolRow[]> =>
481− requestJson<readonly ToolRow[]>("/api/tools?integration=apps", {
535+const syncSource = (
536+ input: { readonly name?: string; readonly token?: string } = {},
537+): Promise<SyncResult> =>
538+ postJson<SyncResult>("/api/apps/sources/github/sync", {
539+ slug: input.name ?? APP_SLUG,
540+ ...(input.token ? { token: input.token } : {}),
541+ });
542+
543+const listAppTools = (app = APP_SLUG): Promise<readonly ToolRow[]> =>
544+ requestJson<readonly ToolRow[]>(`/api/tools?integration=${encodeURIComponent(app)}`, {
482545 headers: jsonHeaders,
483546 });
484547
548+const getSource = (
549+ app: string,
550+): Promise<{ readonly source: { readonly slug: string; readonly name: string } | null }> =>
551+ requestJson(`/api/apps/sources/github/${encodeURIComponent(app)}`, { headers: jsonHeaders });
552+
553+const removeSource = (app: string): Promise<{ readonly removed: boolean }> =>
554+ deleteJson(`/api/apps/sources/github/${encodeURIComponent(app)}`);
555+
485556 const execute = (code: string): Promise<ExecuteResponse> =>
486557 postJson<ExecuteResponse>("/api/executions", { code, autoApprove: true });
487558
@@ -495,8 +566,8 @@ const executeResult = async (code: string): Promise<unknown> => { · 7 unmodified lines
495566 return response.structured.result;
496567 };
497568
498−const callAppToolCode = (toolName: string, args: unknown): string => `
499−const found = await tools.search({ namespace: "apps", query: ${JSON.stringify(toolName)}, limit: 20 });
569+const callAppToolCode = (toolName: string, args: unknown, namespace = APP_SLUG): string => `
570+const found = await tools.search({ namespace: ${JSON.stringify(namespace)}, query: ${JSON.stringify(toolName)}, limit: 20 });
500571 const item = found.items.find((candidate) => candidate.path.endsWith(${JSON.stringify(toolName)}));
501572 if (!item) return { ok: false, missing: ${JSON.stringify(toolName)}, found };
502573 let fn = tools;
@@ -549,10 +620,10 @@ test("custom tool connection schema updates when connections change after sync", · 46 unmodified lines
549620 expect(initialConnections).toEqual([]);
550621 await putRepoFiles(github, token!, initialFiles());
551622
552− const published = await syncSource();
623+ const published = await addSource({ name: SCHEMA_APP_SLUG });
553624 expect(published.status).toBe("published");
554625
555− const listed = await listAppTools();
626+ const listed = await listAppTools(SCHEMA_APP_SLUG);
556627 const dealSync = listed.find((tool) => tool.name === "deal-pipeline-sync");
557628 expect(dealSync).toBeTruthy();
558629 const beforeConnection = await requestJson<{
@@ -580,10 +651,14 @@ test("custom tool connection schema updates when connections change after sync", · 21 unmodified lines
580651 expect(afterConnection.inputSchema.required ?? []).not.toContain("github");
581652
582653 const defaultInvoke = (await executeResult(
583− callAppToolCode("deal-pipeline-sync", {
584− owner: OWNER,
585− repo: REPO,
586− }),
654+ callAppToolCode(
655+ "deal-pipeline-sync",
656+ {
657+ owner: OWNER,
658+ repo: REPO,
659+ },
660+ SCHEMA_APP_SLUG,
661+ ),
587662 )) as { result?: { ok?: boolean; data?: { synced?: number }; error?: unknown } };
588663 expect(defaultInvoke.result?.ok).toBe(true);
589664 expect(defaultInvoke.result?.data?.synced).toBe(0);
@@ -609,7 +684,7 @@ test("GitHub source sync publishes and invokes custom tools through self-host HT · 19 unmodified lines
609684 });
610685 expect(unauthorized.status).toBe(401);
611686
612− const published = await syncSource();
687+ const published = await addSource({ name: APP_SLUG, token: token! });
613688 expect(published.status).toBe("published");
614689 expect(JSON.stringify(published)).not.toContain(token!);
615690 expect(published.tools).toEqual(["deal-pipeline-sync", "find-deal-docs"]);
@@ -621,18 +696,17 @@ test("GitHub source sync publishes and invokes custom tools through self-host HT · 5 unmodified lines
621696 expect(tokenUpToDate.status).toBe("up-to-date");
622697 expect(JSON.stringify(tokenUpToDate)).not.toContain(token!);
623698
624− const sourcesList = await requestJson<{ sources: readonly { hasToken: boolean }[] }>(
625− "/api/apps/sources/github",
626− { headers: jsonHeaders },
627− );
628− expect(sourcesList.sources[0]?.hasToken).toBe(true);
699+ const sourcesList = await requestJson<{
700+ sources: readonly { readonly slug: string; readonly hasToken: boolean }[];
701+ }>("/api/apps/sources/github", { headers: jsonHeaders });
702+ expect(sourcesList.sources.find((source) => source.slug === APP_SLUG)?.hasToken).toBe(true);
629703 expect(JSON.stringify(sourcesList)).not.toContain(token!);
630704
631705 const listed = await listAppTools();
632706 expect(listed.map((tool) => tool.name).sort()).toEqual(["deal-pipeline-sync", "find-deal-docs"]);
633707
634708 const searchResult = await executeResult(
635− 'const found = await tools.search({ namespace: "apps", query: "pipeline", limit: 10 }); return found.items.map((item) => item.path);',
709+ `const found = await tools.search({ namespace: ${JSON.stringify(APP_SLUG)}, query: "pipeline", limit: 10 }); return found.items.map((item) => item.path);`,
636710 );
637711 expect(JSON.stringify(searchResult)).toContain("deal-pipeline-sync");
638712
@@ -760,6 +834,139 @@ test("GitHub source sync publishes and invokes custom tools through self-host HT · 121 unmodified lines
760834 expect(afterSkipped.some((tool) => tool.address.includes("workflow"))).toBe(false);
761835 });
762836
837+test("custom tools sources are per-integration and remove tears down owned state", async () => {
838+ const credential = await github.credentials.mint({
839+ type: "api-key",
840+ login: OWNER,
841+ scopes: ["repo", "user"],
842+ });
843+ const token = credential.token;
844+ expect(token).toBeTruthy();
845+ await registerGitHubIntegration(github, token!);
846+ await putRepoFiles(github, token!, initialFiles());
847+
848+ const firstApp = "side-tools-a";
849+ const secondApp = "side-tools-b";
850+ const first = await addSource({ name: firstApp, token: token! });
851+ const second = await addSource({ name: secondApp, token: token! });
852+ expect(first.status).toBe("published");
853+ expect(second.status).toBe("published");
854+
855+ const collision = await addSource({ name: firstApp, token: token! });
856+ expect(collision.status).toBe("failed");
857+ expect(collision.errors?.[0]?.message).toContain(
858+ `Integration "${firstApp}" already exists. Choose another name.`,
859+ );
860+
861+ const integrations = await requestJson<readonly IntegrationRow[]>("/api/integrations", {
862+ headers: jsonHeaders,
863+ });
864+ expect(integrations).toEqual(
865+ expect.arrayContaining([
866+ expect.objectContaining({
867+ slug: firstApp,
868+ name: firstApp,
869+ kind: "apps",
870+ canRemove: true,
871+ displayUrl: sourceUrl(),
872+ }),
873+ expect.objectContaining({
874+ slug: secondApp,
875+ name: secondApp,
876+ kind: "apps",
877+ canRemove: true,
878+ displayUrl: sourceUrl(),
879+ }),
880+ ]),
881+ );
882+
883+ const configRows = await querySqliteRows(
884+ dbPath,
885+ "SELECT slug, plugin_id, config FROM integration WHERE slug IN (?, ?) ORDER BY slug",
886+ [firstApp, secondApp],
887+ );
888+ expect(configRows).toHaveLength(2);
889+ for (const row of configRows) {
890+ expect(row.plugin_id).toBe("apps");
891+ const config = decodeDbJson(row.config);
892+ expect(config).toMatchObject({
893+ kind: "github",
894+ repoUrl: sourceUrl(),
895+ repo: REPO_FULL_NAME,
896+ scope: row.slug,
897+ });
898+ expect(JSON.stringify(config)).not.toContain(token!);
899+ }
900+
901+ expect((await listAppTools(firstApp)).map((tool) => tool.address).sort()).toEqual([
902+ `tools.${firstApp}.user.main.deal-pipeline-sync`,
903+ `tools.${firstApp}.user.main.find-deal-docs`,
904+ ]);
905+ expect((await listAppTools(secondApp)).map((tool) => tool.address).sort()).toEqual([
906+ `tools.${secondApp}.user.main.deal-pipeline-sync`,
907+ `tools.${secondApp}.user.main.find-deal-docs`,
908+ ]);
909+
910+ const secondInvoke = (await executeResult(
911+ callAppToolCode(
912+ "deal-pipeline-sync",
913+ {
914+ owner: OWNER,
915+ repo: REPO,
916+ },
917+ secondApp,
918+ ),
919+ )) as { result?: { ok?: boolean; data?: { synced?: number }; error?: unknown } };
920+ expect(secondInvoke.result?.ok).toBe(true);
921+
922+ const secondKey = sourceStorageKey(secondApp);
923+ const secondArtifactDir = join(dataDir, "apps", "artifacts", `${secondKey}.git`);
924+ const secondScopeDb = join(dataDir, "apps", "scope-db", `${secondKey}.db`);
925+ expect(existsSync(secondArtifactDir)).toBe(true);
926+ expect(existsSync(secondScopeDb)).toBe(true);
927+
928+ const tokenRowsBefore = await querySqliteRows(
929+ dbPath,
930+ "SELECT key FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?",
931+ ["encryptedSecrets", "secrets", sourceTokenItemId(secondApp)],
932+ );
933+ expect(tokenRowsBefore).toHaveLength(1);
934+
935+ expect(await getSource(secondApp)).toMatchObject({
936+ source: { slug: secondApp, name: secondApp },
937+ });
938+ expect(await removeSource(secondApp)).toEqual({ removed: true });
939+ expect(await getSource(secondApp)).toEqual({ source: null });
940+
941+ const secondIntegration = await fetch(
942+ `${server.baseUrl}/api/integrations/${encodeURIComponent(secondApp)}`,
943+ { headers: jsonHeaders },
944+ );
945+ expect(secondIntegration.status).toBe(404);
946+ await secondIntegration.text();
947+
948+ expect(await listAppTools(secondApp)).toEqual([]);
949+ expect((await listAppTools(firstApp)).map((tool) => tool.address).sort()).toEqual([
950+ `tools.${firstApp}.user.main.deal-pipeline-sync`,
951+ `tools.${firstApp}.user.main.find-deal-docs`,
952+ ]);
953+
954+ const descriptorRows = await querySqliteRows(
955+ join(dataDir, "apps", "store.sqlite"),
956+ "SELECT scope FROM descriptors WHERE tenant = ? AND scope = ?",
957+ [TEST_ORG, secondApp],
958+ );
959+ expect(descriptorRows).toEqual([]);
960+ const tokenRowsAfter = await querySqliteRows(
961+ dbPath,
962+ "SELECT key FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?",
963+ ["encryptedSecrets", "secrets", sourceTokenItemId(secondApp)],
964+ );
965+ expect(tokenRowsAfter).toEqual([]);
966+ expect(existsSync(secondArtifactDir)).toBe(false);
967+ expect(existsSync(secondScopeDb)).toBe(false);
968+});
969+
763970 test("bridged integration calls inherit the caller approval handler", async () => {
764971 const credential = await github.credentials.mint({
765972 type: "api-key",
@@ -771,7 +978,7 @@ test("bridged integration calls inherit the caller approval handler", async () = · 5 unmodified lines
771978 await registerGitHubIntegration(github, token!);
772979 await createIssue(github, token!, "Approval-gated issue");
773980 await putRepoFiles(github, token!, approvalFiles());
774− const published = await syncSource({ token: token! });
981+ const published = await addSource({ name: APPROVAL_APP_SLUG, token: token! });
775982 expect(published.status).toBe("published");
776983
777984 const githubTools = await requestJson<readonly ToolRow[]>("/api/tools?integration=github", {
@@ -792,11 +999,15 @@ test("bridged integration calls inherit the caller approval handler", async () = · 14 unmodified lines
792999 ).length;
7931000
7941001 const response = await executeWithApprovalPause(
795− callAppToolCode("approval-bridge", {
796− github: GITHUB_CONNECTION,
797− owner: OWNER,
798− repo: REPO,
799− }),
1002+ callAppToolCode(
1003+ "approval-bridge",
1004+ {
1005+ github: GITHUB_CONNECTION,
1006+ owner: OWNER,
1007+ repo: REPO,
1008+ },
1009+ APPROVAL_APP_SLUG,
1010+ ),
8001011 );
8011012 expect(response.status).toBe("paused");
8021013
apps/host-selfhost/src/custom-tools-console.web.test.ts+35−1
@@ -6,7 +6,9 @@ import appsClientPlugin, { · 5 unmodified lines
66 type CustomToolsFetch,
77 appsIntegrationPlugin,
88 formatSyncErrors,
9+ getCustomToolSource,
910 listCustomToolSources,
11+ removeCustomToolSource,
1012 syncCustomToolSource,
1113 syncStatusLabel,
1214 validateGitHubSourceUrl,
@@ -48,6 +50,7 @@ describe("custom tools console client", () => { · 35 unmodified lines
4850
4951 it("surfaces successful sync and source detail data", async () => {
5052 let syncBody = "";
53+ let removed = false;
5154 const fetchImpl: CustomToolsFetch = async (input, init) => {
5255 const url = String(input);
5356 if (url.endsWith("/sync")) {
@@ -60,10 +63,34 @@ describe("custom tools console client", () => { · 6 unmodified lines
6063 skipped: [],
6164 });
6265 }
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+ }
6388 return jsonResponse({
6489 sources: [
6590 {
66− scope: "github:RhysSullivan/executor-custom-tools-demo",
91+ slug: "demo-tools",
92+ name: "demo-tools",
93+ scope: "demo-tools",
6794 url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
6895 repo: "RhysSullivan/executor-custom-tools-demo",
6996 ref: "main",
@@ -80,18 +107,25 @@ describe("custom tools console client", () => { · 10 unmodified lines
80107
81108 const syncResult = await syncCustomToolSource(
82109 {
110+ name: "demo-tools",
83111 url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
84112 token: "ghp_demo",
85113 },
86114 fetchImpl,
87115 );
88116 const listed = await listCustomToolSources(fetchImpl);
117+ const detail = await getCustomToolSource("demo-tools", fetchImpl);
118+ const remove = await removeCustomToolSource("demo-tools", fetchImpl);
89119
90120 expect(syncStatusLabel(syncResult)).toBe("Published 2 tools.");
91121 expect(syncBody).toContain("ghp_demo");
122+ expect(syncBody).toContain("demo-tools");
92123 expect(syncResult.tools).toEqual(["repo-summary", "stale-issues"]);
124+ expect(detail.source?.slug).toBe("demo-tools");
93125 expect(listed.sources[0]?.hasToken).toBe(true);
94126 expect(listed.sources[0]?.tools).toEqual(["repo-summary", "stale-issues"]);
127+ expect(remove).toEqual({ removed: true });
128+ expect(removed).toBe(true);
95129 });
96130
97131 it("renders failed sync errors readably", async () => {
packages/core/sdk/src/executor.ts+13−0
@@ -1927,6 +1927,19 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1926 unmodified lines
19271927 if (!existing.can_remove) {
19281928 return yield* new IntegrationRemovalNotAllowedError({ slug });
19291929 }
1930+ const runtime = runtimes.get(existing.plugin_id);
1931+ if (runtime?.plugin.removeIntegration) {
1932+ yield* runtime.plugin
1933+ .removeIntegration({
1934+ ctx: runtime.ctx,
1935+ integration: rowToIntegrationRecord(existing, describeAuthMethodsForRow(existing)),
1936+ })
1937+ .pipe(
1938+ Effect.mapError((cause) =>
1939+ pluginStorageFailure(existing.plugin_id, "removeIntegration", cause),
1940+ ),
1941+ );
1942+ }
19301943 // Drop owned connections / tools / definitions for this integration.
19311944 const where = (b: AnyCb) => b("integration", "=", String(slug));
19321945 yield* core.deleteMany("tool", { where });
packages/core/sdk/src/plugin.ts+11−0
@@ -523,6 +523,11 @@ export interface ConnectionLifecycleInput<TStore = unknown> { · 522 unmodified lines
523523 readonly connection: ConnectionRef;
524524 }
525525
526+export interface IntegrationLifecycleInput<TStore = unknown> {
527+ readonly ctx: PluginCtx<TStore>;
528+ readonly integration: IntegrationRecord;
529+}
530+
526531 export interface ConfigureIntegrationHandlerInput<TStore = unknown> {
527532 readonly ctx: PluginCtx<TStore>;
528533 readonly integration: IntegrationSlug;
@@ -668,6 +673,12 @@ export interface PluginSpec< · 139 unmodified lines
668673 input: ConnectionLifecycleInput<TStore>,
669674 ) => Effect.Effect<void, unknown>;
670675
676+ /** Plugin-side cleanup when a removable integration is removed. Core still
677+ * owns deleting the integration, connection, tool, and definition rows. */
678+ readonly removeIntegration?: (
679+ input: IntegrationLifecycleInput<TStore>,
680+ ) => Effect.Effect<void, unknown>;
681+
671682 /** Core-dispatched integration configuration (beyond auth). */
672683 readonly integrationConfigure?: IntegrationConfigureDecl<TStore>;
673684
packages/plugins/apps/src/api.ts+0−3
@@ -3,9 +3,6 @@ export { · 2 unmodified lines
33 appsPlugin,
44 APPS_INTEGRATION_SLUG,
55 APPS_PLUGIN_ID,
6− APPS_CONNECTION_PREFIX,
7− connectionNameForScope,
8− scopeFromConnection,
96 type AppsPluginOptions,
107 } from "./plugin/apps-plugin";
118 export {
packages/plugins/apps/src/backing/git-artifact-store.ts+14−1
@@ -1,5 +1,5 @@
11 import { execFile } from "node:child_process";
2−import { mkdir } from "node:fs/promises";
2+import { mkdir, rm } from "node:fs/promises";
33 import { join } from "node:path";
44
55 import { Effect } from "effect";
@@ -301,5 +301,18 @@ export const makeGitArtifactStore = (options: GitArtifactStoreOptions): Artifact · 295 unmodified lines
301301 cause,
302302 }),
303303 }),
304+ removeScope: (address) =>
305+ Effect.tryPromise({
306+ try: async () => {
307+ const key = scopeAddressStorageKey(address);
308+ initialized.delete(key);
309+ await rm(join(options.root, `${key}.git`), { recursive: true, force: true });
310+ },
311+ catch: (cause) =>
312+ new ArtifactStoreError({
313+ message: `failed to remove scope repo: ${address.tenant}/${address.scope}`,
314+ cause,
315+ }),
316+ }),
304317 };
305318 };
packages/plugins/apps/src/backing/libsql-scope-db.ts+19−1
@@ -1,4 +1,4 @@
1−import { mkdirSync } from "node:fs";
1+import { mkdirSync, rmSync } from "node:fs";
22 import { join, resolve } from "node:path";
33
44 import { createClient, type Client } from "@libsql/client";
@@ -160,6 +160,24 @@ export const makeLibsqlScopeDb = (options: LibsqlScopeDbOptions): ScopeDb => { · 155 unmodified lines
160160 cause,
161161 }),
162162 }),
163+ removeScope: (address) =>
164+ Effect.try({
165+ try: () => {
166+ const key = scopeAddressStorageKey(address);
167+ clients.get(key)?.close();
168+ clients.delete(key);
169+ if (options.root === ":memory:") return;
170+ const dbPath = join(options.root, `${key}.db`);
171+ for (const path of [dbPath, `${dbPath}-shm`, `${dbPath}-wal`]) {
172+ rmSync(path, { force: true });
173+ }
174+ },
175+ catch: (cause) =>
176+ new ScopeDbError({
177+ message: `failed to remove scope db ${address.tenant}/${address.scope}`,
178+ cause,
179+ }),
180+ }),
163181 close: () =>
164182 Effect.sync(() => {
165183 for (const client of clients.values()) client.close();
packages/plugins/apps/src/backing/scope-collision.test.ts+21−12
@@ -7,6 +7,7 @@ import { Effect } from "effect"; · 6 unmodified lines
77
88 import { makeLibsqlScopeDb } from "./libsql-scope-db";
99 import { makeSqliteAppsStore } from "./sqlite-apps-store";
10+import { DESCRIPTOR_VERSION, type AppDescriptor } from "../pipeline/descriptor";
1011 import { scopeAddress } from "../seams/scope-address";
1112
1213 const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
@@ -46,18 +47,26 @@ describe("scope collision (Fix 9)", () => { · 33 unmodified lines
4647 await run(db.close());
4748 });
4849
49− it("routes distinct scopes through the explicit connection->scope mapping", async () => {
50+ it("stores distinct descriptor scopes independently", async () => {
5051 const store = makeSqliteAppsStore({ path: ":memory:" });
51− // Both scopes' connection names normalize to the SAME identifier
52− // ("appsMyScope"), but the explicit mapping keys by the ACTUAL stored name,
53− // so each scope's connection maps to the right scope. Simulate two DISTINCT
54− // connection names being recorded for two distinct scopes.
55− await run(store.putScopeForConnection("org", "appsMyScopeDash", "my-scope"));
56− await run(store.putScopeForConnection("org", "appsMyScopeUnderscore", "my_scope"));
57−
58− expect(await run(store.getScopeForConnection("org", "appsMyScopeDash"))).toBe("my-scope");
59− expect(await run(store.getScopeForConnection("org", "appsMyScopeUnderscore"))).toBe("my_scope");
60− // An unmapped name returns null (caller falls back to legacy parse).
61− expect(await run(store.getScopeForConnection("org", "unknown"))).toBeNull();
52+ const descriptor = (scope: string): AppDescriptor => ({
53+ version: DESCRIPTOR_VERSION,
54+ tenant: "org",
55+ scope,
56+ description: "test",
57+ snapshotId: `${scope}-snapshot`,
58+ toolchain: { bundler: "esbuild", bundlerVersion: "test", target: "test" },
59+ tools: [],
60+ workflows: [],
61+ ui: [],
62+ skills: [],
63+ skipped: [],
64+ });
65+
66+ await run(store.putDescriptor("org", "org", descriptor("my-scope")));
67+ await run(store.putDescriptor("org", "org", descriptor("my_scope")));
68+
69+ expect((await run(store.getDescriptor("org", "my-scope")))?.scope).toBe("my-scope");
70+ expect((await run(store.getDescriptor("org", "my_scope")))?.scope).toBe("my_scope");
6271 });
6372 });
packages/plugins/apps/src/backing/sqlite-apps-store.ts+5−48
@@ -7,19 +7,17 @@ import { Effect, Schema } from "effect"; · 6 unmodified lines
77 import { StorageError, type StorageFailure } from "@executor-js/sdk";
88
99 import type { AppDescriptor } from "../pipeline/descriptor";
10−import type { AppsStore, GitHubSourceTokenRef } from "../plugin/store";
10+import type { AppsStore } from "../plugin/store";
1111
1212 // ---------------------------------------------------------------------------
1313 // SQLite-backed AppsStore (self-hosted). One small SQLite file stores the
14−// published descriptor per scope and the exact connection-to-scope mapping.
14+// published descriptor per source scope.
1515 // ---------------------------------------------------------------------------
1616
1717 const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`);
1818
1919 const SCHEMA = `
2020 CREATE TABLE IF NOT EXISTS descriptors (tenant TEXT NOT NULL, scope TEXT NOT NULL, snapshot_id TEXT NOT NULL, descriptor TEXT NOT NULL, published_at INTEGER NOT NULL, PRIMARY KEY (tenant, scope));
21−CREATE TABLE IF NOT EXISTS scope_connections (tenant TEXT NOT NULL, connection_name TEXT NOT NULL, scope TEXT NOT NULL, PRIMARY KEY (tenant, connection_name));
22−CREATE TABLE IF NOT EXISTS github_source_tokens (tenant TEXT NOT NULL, scope TEXT NOT NULL, provider TEXT NOT NULL, item_id TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (tenant, scope));
2321 `;
2422
2523 const storageFail = (message: string, cause: unknown): StorageFailure =>
@@ -118,57 +116,16 @@ export const makeSqliteAppsStore = (options: SqliteAppsStoreOptions): AppsStore · 92 unmodified lines
118116 ),
119117 ),
120118 ),
121− putScopeForConnection: (tenant, connectionName, scope) =>
119+ removeDescriptor: (tenant, scope) =>
122120 Effect.tryPromise({
123121 try: async () => {
124122 await init();
125123 await client.execute({
126− sql: "INSERT INTO scope_connections (tenant, connection_name, scope) VALUES (?, ?, ?) ON CONFLICT(tenant, connection_name) DO UPDATE SET scope=excluded.scope",
127− args: [tenant, connectionName, scope],
128− });
129− },
130− catch: (cause) => storageFail("putScopeForConnection failed", cause),
131− }),
132− getScopeForConnection: (tenant, connectionName) =>
133− Effect.tryPromise({
134− try: async () => {
135− await init();
136− const res = await client.execute({
137− sql: "SELECT scope FROM scope_connections WHERE tenant = ? AND connection_name = ?",
138− args: [tenant, connectionName],
139− });
140− return res.rows[0] ? String(res.rows[0].scope) : null;
141− },
142− catch: (cause) => storageFail("getScopeForConnection failed", cause),
143− }),
144− putGitHubSourceTokenRef: (tenant, scope, ref) =>
145− Effect.tryPromise({
146− try: async () => {
147− await init();
148− await client.execute({
149− sql: "INSERT INTO github_source_tokens (tenant, scope, provider, item_id, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(tenant, scope) DO UPDATE SET provider=excluded.provider, item_id=excluded.item_id, updated_at=excluded.updated_at",
150− args: [tenant, scope, ref.provider, ref.itemId, ref.updatedAt],
151− });
152− },
153− catch: (cause) => storageFail("putGitHubSourceTokenRef failed", cause),
154− }),
155− getGitHubSourceTokenRef: (tenant, scope) =>
156− Effect.tryPromise({
157− try: async (): Promise<GitHubSourceTokenRef | null> => {
158− await init();
159− const res = await client.execute({
160− sql: "SELECT provider, item_id, updated_at FROM github_source_tokens WHERE tenant = ? AND scope = ?",
124+ sql: "DELETE FROM descriptors WHERE tenant = ? AND scope = ?",
161125 args: [tenant, scope],
162126 });
163− const row = res.rows[0];
164− if (!row) return null;
165− return {
166− provider: String(row.provider),
167− itemId: String(row.item_id),
168− updatedAt: Number(row.updated_at),
169− };
170127 },
171− catch: (cause) => storageFail("getGitHubSourceTokenRef failed", cause),
128+ catch: (cause) => storageFail("removeDescriptor failed", cause),
172129 }),
173130 };
174131 };
packages/plugins/apps/src/index.ts+5−2
@@ -34,9 +34,12 @@ export { · 33 unmodified lines
3434 type AppsStoreDeps,
3535 type GitHubSourceTokenRef,
3636 descriptorCollection,
37− githubSourceTokenCollection,
38− scopeConnectionCollection,
3937 } from "./plugin/store";
38+export {
39+ slugifyCustomToolsAppName,
40+ validateCustomToolsAppSlug,
41+ CUSTOM_TOOLS_APP_SLUG_PATTERN,
42+} from "./source/app-slug";
4043 export {
4144 buildBridge,
4245 rootsFor,
packages/plugins/apps/src/plugin/apps-plugin.test.ts+40−7
@@ -4,8 +4,9 @@ import { join } from "node:path"; · 3 unmodified lines
44
55 import { describe, expect, it } from "@effect/vitest";
66 import { Effect } from "effect";
7+import { IntegrationSlug } from "@executor-js/sdk";
78
8−import { appsPlugin, connectionNameForScope } from "./apps-plugin";
9+import { appsPlugin } from "./apps-plugin";
910 import { makeSelfHostAppsRuntime } from "./self-host-runtime";
1011 import { makeInMemoryAppsStore, makeTestResolver } from "../testing";
1112
@@ -62,13 +63,43 @@ describe("appsPlugin custom-tools contract", () => { · 50 unmodified lines
6263 });
6364 const runtime = host.runtime;
6465 const plugin = appsPlugin({ runtime });
65− const appConnection = connectionNameForScope("rhys");
66+ const appIntegration = IntegrationSlug.make("rhys-tools");
67+ const appConfig = {
68+ kind: "github",
69+ repoUrl: "https://github.com/rhys/tools",
70+ repo: "rhys/tools",
71+ scope: "rhys",
72+ };
73+ const ctx = {
74+ owner: { tenant: "org" },
75+ core: {
76+ integrations: {
77+ get: (slug: IntegrationSlug) =>
78+ Effect.succeed(
79+ String(slug) === String(appIntegration)
80+ ? {
81+ slug: appIntegration,
82+ name: "Rhys tools",
83+ description: "Rhys tools",
84+ kind: "apps",
85+ canRemove: true,
86+ canRefresh: false,
87+ authMethods: [],
88+ config: appConfig,
89+ }
90+ : null,
91+ ),
92+ },
93+ },
94+ };
6695
6796 await run(runtime.publish({ scope: "rhys", files: prototypeFileSet() }));
6897
6998 const resolved = await run(
7099 plugin.resolveTools!({
71− connection: { name: appConnection },
100+ ctx,
101+ config: appConfig,
102+ connection: { name: "main" },
72103 } as never),
73104 );
74105 const syncTool = resolved.tools.find((tool) => String(tool.name) === "deal-pipeline-sync");
@@ -80,10 +111,11 @@ describe("appsPlugin custom-tools contract", () => { · 5 unmodified lines
80111
81112 const projected = await run(
82113 plugin.projectToolSchema!({
83− ctx: { owner: { tenant: "org" } },
114+ ctx,
84115 toolRow: {
85116 name: "deal-pipeline-sync",
86− connection: appConnection,
117+ integration: appIntegration,
118+ connection: "main",
87119 },
88120 inputSchema: syncTool!.inputSchema,
89121 outputSchema: syncTool!.outputSchema,
@@ -100,10 +132,11 @@ describe("appsPlugin custom-tools contract", () => { · 10 unmodified lines
100132
101133 const output = await run(
102134 plugin.invokeTool!({
103− ctx: {},
135+ ctx,
104136 toolRow: {
105137 name: "deal-pipeline-sync",
106− connection: appConnection,
138+ integration: appIntegration,
139+ connection: "main",
107140 },
108141 args: {
109142 crm: "tools.dealcloud.user.crm-main",
packages/plugins/apps/src/plugin/apps-plugin.ts+354−179
@@ -1,36 +1,48 @@
11 import { Effect, Result } from "effect";
22
33 import {
4+ AuthTemplateSlug,
45 definePlugin,
5− tool,
6− ToolName,
76 IntegrationSlug,
8− AuthTemplateSlug,
9− connectionIdentifier,
107 ProviderItemId,
118 ProviderKey,
9+ ToolName,
10+ connectionIdentifier,
11+ tool,
12+ type IntegrationRecord,
13+ type InvokeToolInput,
1214 type PluginCtx,
1315 type ResolveToolsInput,
1416 type ResolveToolsResult,
15− type InvokeToolInput,
1617 type ToolDef,
1718 } from "@executor-js/sdk";
1819
19−import type { AppsRuntime } from "./runtime";
20−import { makeAppsStore } from "./store";
21−import type { ClientResolver, ConnectionCandidate } from "./bindings";
2220 import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor";
2321 import { PublishError } from "../pipeline/discover";
2422 import {
2523 parseGitHubSourceUrl,
2624 syncGitHubSource,
25+ type GitHubSkippedArtifact,
2726 type GitHubSyncResult,
2827 } from "../source/github-source";
28+import { slugifyCustomToolsAppName, validateCustomToolsAppSlug } from "../source/app-slug";
29+import type { AppsRuntime, GitHubCustomToolsSourceSummary } from "./runtime";
30+import { makeAppsStore, type AppDescriptorRecord, type GitHubSourceTokenRef } from "./store";
31+import type { ClientResolver, ConnectionCandidate } from "./bindings";
2932
3033 export const APPS_INTEGRATION_SLUG = "apps";
3134 export const APPS_PLUGIN_ID = "apps";
3235
33−const DEFAULT_CATALOG_SCOPE = "default";
36+const APP_CONNECTION_NAME = connectionIdentifier("main");
37+
38+interface AppsGitHubSourceConfig {
39+ readonly kind: "github";
40+ readonly repoUrl: string;
41+ readonly repo: string;
42+ readonly scope: string;
43+ readonly ref?: string;
44+ readonly token?: GitHubSourceTokenRef;
45+}
3446
3547 export interface AppsPluginOptions {
3648 readonly runtime: AppsRuntime;
@@ -48,6 +60,9 @@ interface AppsStoreShape { · 11 unmodified lines
4860 const isRecord = (value: unknown): value is Record<string, unknown> =>
4961 value !== null && typeof value === "object" && !Array.isArray(value);
5062
63+const asString = (value: unknown): string | undefined =>
64+ typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
65+
5166 const unique = (values: readonly string[]): readonly string[] => [...new Set(values)];
5267
5368 const syncFailure = (message: string, path?: string): GitHubSyncResult => ({
@@ -63,9 +78,6 @@ const syncFailure = (message: string, path?: string): GitHubSyncResult => ({ · 9 unmodified lines
6378 ],
6479 });
6580
66−const sourceScopeFor = (repo: string): string =>
67− String(connectionIdentifier(`github ${repo}`, "githubSource"));
68−
6981 const sourceTokenItemId = (tenant: string, scope: string): ProviderItemId =>
7082 ProviderItemId.make(`apps:github-source:${tenant}:${scope}:token`);
7183
@@ -74,6 +86,30 @@ const configBaseUrl = (config: unknown): string | undefined => · 2 unmodified lines
7486 ? config.baseUrl
7587 : undefined;
7688
89+const decodeSourceConfig = (config: unknown): AppsGitHubSourceConfig | null => {
90+ if (!isRecord(config) || config.kind !== "github") return null;
91+ const repoUrl = asString(config.repoUrl);
92+ const repo = asString(config.repo);
93+ const scope = asString(config.scope);
94+ if (!repoUrl || !repo || !scope) return null;
95+ const ref = asString(config.ref);
96+ const token = isRecord(config.token)
97+ ? {
98+ provider: asString(config.token.provider) ?? "",
99+ itemId: asString(config.token.itemId) ?? "",
100+ updatedAt: typeof config.token.updatedAt === "number" ? config.token.updatedAt : 0,
101+ }
102+ : undefined;
103+ return {
104+ kind: "github",
105+ repoUrl,
106+ repo,
107+ scope,
108+ ...(ref ? { ref } : {}),
109+ ...(token && token.provider && token.itemId ? { token } : {}),
110+ };
111+};
112+
77113 const projectInputSchema = (
78114 schema: unknown,
79115 integrations: Readonly<Record<string, IntegrationDecl>>,
@@ -113,6 +149,16 @@ const projectInputSchema = ( · 33 unmodified lines
113149 return projected;
114150 };
115151
152+const projectTool = (descriptor: ToolDescriptor): ToolDef => ({
153+ name: ToolName.make(descriptor.name),
154+ description: descriptor.description,
155+ inputSchema: descriptor.inputSchema,
156+ outputSchema: descriptor.outputSchema,
157+ annotations: {
158+ requiresApproval: descriptor.annotations?.destructive === true,
159+ },
160+});
161+
116162 export const appsPlugin = definePlugin((options?: AppsPluginOptions) => {
117163 const runtime = options?.runtime as AppsRuntime;
118164 const makeResolver = options?.makeResolver;
@@ -122,91 +168,274 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 3 unmodified lines
122168 return tenant === undefined ? "org" : String(tenant);
123169 };
124170
125− const scopeFor = (tenant: string, connectionName: string): Effect.Effect<string> =>
126− runtime.deps.store.getScopeForConnection(tenant, connectionName).pipe(
127− Effect.orElseSucceed(() => null),
128− Effect.map((mapped) => mapped ?? scopeFromConnection(connectionName)),
129− );
130−
131171 const storeSourceToken = (
132172 ctx: PluginCtx<AppsStoreShape>,
133173 tenant: string,
134174 scope: string,
135175 token: string,
136− ): Effect.Effect<boolean> =>
176+ ): Effect.Effect<GitHubSourceTokenRef | null> =>
137177 Effect.gen(function* () {
138178 const itemId = sourceTokenItemId(tenant, scope);
139179 const provider = yield* ctx.providers.setDefault(itemId, token).pipe(Effect.result);
140− if (Result.isFailure(provider)) return false;
141− const stored = yield* runtime.deps.store
142− .putGitHubSourceTokenRef(tenant, scope, {
143− provider: String(provider.success),
144− itemId: String(itemId),
145− updatedAt: Date.now(),
146− })
147− .pipe(Effect.result);
148− return Result.isSuccess(stored);
180+ if (Result.isFailure(provider)) return null;
181+ return {
182+ provider: String(provider.success),
183+ itemId: String(itemId),
184+ updatedAt: Date.now(),
185+ };
149186 });
150187
151188 const storedSourceToken = (
152189 ctx: PluginCtx<AppsStoreShape>,
153− tenant: string,
154− scope: string,
155− ): Effect.Effect<string | null> =>
156− Effect.gen(function* () {
157− const ref = yield* runtime.deps.store
158− .getGitHubSourceTokenRef(tenant, scope)
159− .pipe(Effect.orElseSucceed(() => null));
160− if (!ref) return null;
161− return yield* ctx.providers
162− .get(ProviderKey.make(ref.provider), ProviderItemId.make(ref.itemId))
163− .pipe(Effect.orElseSucceed(() => null));
164− });
190+ ref: GitHubSourceTokenRef | undefined,
191+ ): Effect.Effect<string | null> => {
192+ if (!ref) return Effect.succeed(null);
193+ return ctx.providers
194+ .get(ProviderKey.make(ref.provider), ProviderItemId.make(ref.itemId))
195+ .pipe(Effect.orElseSucceed(() => null));
196+ };
165197
166− const ensureCatalogConnection = (scope: string, ctx: PluginCtx<AppsStoreShape>) =>
198+ const removeStoredSourceToken = (
199+ ctx: PluginCtx<AppsStoreShape>,
200+ ref: GitHubSourceTokenRef | undefined,
201+ ): Effect.Effect<void, unknown> =>
202+ ref
203+ ? ctx.providers.remove(ProviderKey.make(ref.provider), ProviderItemId.make(ref.itemId))
204+ : Effect.void;
205+
206+ const ensureAppConnection = (slug: IntegrationSlug, ctx: PluginCtx<AppsStoreShape>) =>
167207 Effect.gen(function* () {
168− const tenant = tenantFor(ctx);
169− const slug = IntegrationSlug.make(APPS_INTEGRATION_SLUG);
170− const existing = yield* ctx.core.integrations
171− .get(slug)
172− .pipe(Effect.orElseSucceed(() => null));
173− if (!existing) {
174− yield* ctx.core.integrations.register({
175− slug,
176− name: "Apps",
177− description: "User-authored, published custom tools.",
178− config: {},
179− canRemove: false,
180− canRefresh: true,
181− });
182− }
183− const rawName = connectionNameForScope(scope);
184− const connName = connectionIdentifier(rawName);
185208 const conns = yield* ctx.connections
186209 .list({ integration: slug })
187210 .pipe(Effect.orElseSucceed(() => []));
188− if (!conns.some((c) => String(c.name) === String(connName))) {
211+ if (!conns.some((connection) => String(connection.name) === String(APP_CONNECTION_NAME))) {
189212 yield* ctx.connections.create({
190213 owner: "user",
191− name: connName,
214+ name: APP_CONNECTION_NAME,
192215 integration: slug,
193216 template: AuthTemplateSlug.make("none"),
194217 value: "",
195218 });
196219 }
197− yield* runtime.deps.store
198− .putScopeForConnection(tenant, String(rawName), scope)
199− .pipe(Effect.orElseSucceed(() => undefined));
200− yield* runtime.deps.store
201− .putScopeForConnection(tenant, String(connName), scope)
202− .pipe(Effect.orElseSucceed(() => undefined));
203− return {
220+ return { owner: "user" as const, integration: slug, name: APP_CONNECTION_NAME };
221+ });
222+
223+ const refreshAppConnection = (slug: IntegrationSlug, ctx: PluginCtx<AppsStoreShape>) =>
224+ Effect.gen(function* () {
225+ const ref = yield* ensureAppConnection(slug, ctx);
226+ yield* ctx.connections.refresh(ref).pipe(Effect.orElseSucceed(() => []));
227+ return ref;
228+ });
229+
230+ const descriptorRecordByScope = (tenant: string) =>
231+ runtime.deps.store.listDescriptors(tenant).pipe(
232+ Effect.orElseSucceed(() => []),
233+ Effect.map((records) => new Map(records.map((record) => [record.descriptor.scope, record]))),
234+ );
235+
236+ const sourceSummaryFor = (
237+ integration: IntegrationRecord,
238+ recordsByScope: ReadonlyMap<string, AppDescriptorRecord>,
239+ ): GitHubCustomToolsSourceSummary | null => {
240+ const config = decodeSourceConfig(integration.config);
241+ if (!config) return null;
242+ const record = recordsByScope.get(config.scope);
243+ if (!record) return null;
244+ const descriptor = record.descriptor;
245+ const source = descriptor?.source;
246+ if (source?.kind !== "github") return null;
247+ return {
248+ slug: String(integration.slug),
249+ name: integration.name,
250+ scope: descriptor.scope,
251+ url: source.url,
252+ repo: source.repo,
253+ ref: source.ref,
254+ hasToken: config.token !== undefined,
255+ upstreamSha: source.upstreamSha,
256+ snapshotId: descriptor.snapshotId,
257+ ...(descriptor.description ? { description: descriptor.description } : {}),
258+ publishedAt: new Date(record.publishedAt).toISOString(),
259+ tools: descriptor.tools.map((toolDesc) => toolDesc.name),
260+ skipped: [
261+ ...(source.skipped ?? []),
262+ ...(descriptor.skipped as readonly GitHubSkippedArtifact[]),
263+ ],
264+ };
265+ };
266+
267+ const listSources = (ctx: PluginCtx<AppsStoreShape>) =>
268+ Effect.gen(function* () {
269+ const tenant = tenantFor(ctx);
270+ const recordsByScope = yield* descriptorRecordByScope(tenant);
271+ const integrations = yield* ctx.core.integrations.list().pipe(Effect.orElseSucceed(() => []));
272+ const sources: GitHubCustomToolsSourceSummary[] = [];
273+ for (const integration of integrations) {
274+ if (integration.kind !== APPS_PLUGIN_ID) continue;
275+ const record = yield* ctx.core.integrations
276+ .get(integration.slug)
277+ .pipe(Effect.orElseSucceed(() => null));
278+ if (!record) continue;
279+ const summary = sourceSummaryFor(record, recordsByScope);
280+ if (summary) sources.push(summary);
281+ }
282+ return sources.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
283+ });
284+
285+ const getSource = (ctx: PluginCtx<AppsStoreShape>, slug: string) =>
286+ Effect.gen(function* () {
287+ const tenant = tenantFor(ctx);
288+ const recordsByScope = yield* descriptorRecordByScope(tenant);
289+ const record = yield* ctx.core.integrations
290+ .get(IntegrationSlug.make(slug))
291+ .pipe(Effect.orElseSucceed(() => null));
292+ return record && record.kind === APPS_PLUGIN_ID
293+ ? sourceSummaryFor(record, recordsByScope)
294+ : null;
295+ });
296+
297+ const syncExistingSource = (
298+ ctx: PluginCtx<AppsStoreShape>,
299+ slugValue: string,
300+ providedToken: string | null,
301+ ) =>
302+ Effect.gen(function* () {
303+ const slug = IntegrationSlug.make(slugValue);
304+ const record = yield* ctx.core.integrations.get(slug).pipe(Effect.orElseSucceed(() => null));
305+ const config = record?.kind === APPS_PLUGIN_ID ? decodeSourceConfig(record.config) : null;
306+ if (!record || !config) {
307+ return syncFailure(`Custom tools source "${slugValue}" does not exist.`);
308+ }
309+ const github = yield* ctx.core.integrations
310+ .get(IntegrationSlug.make("github"))
311+ .pipe(Effect.orElseSucceed(() => null));
312+ const tenant = tenantFor(ctx);
313+ const token = providedToken ?? (yield* storedSourceToken(ctx, config.token));
314+ const result = yield* syncGitHubSource({
315+ runtime,
316+ tenant,
317+ scope: config.scope,
318+ url: config.repoUrl,
319+ ...(config.ref ? { ref: config.ref } : {}),
320+ token,
321+ baseUrl: configBaseUrl(github?.config),
322+ });
323+ if (result.status === "failed") return result;
324+
325+ const tokenRef = providedToken
326+ ? yield* storeSourceToken(ctx, tenant, config.scope, providedToken)
327+ : config.token;
328+ if (providedToken && !tokenRef) {
329+ return syncFailure(
330+ "No writable credential provider is available to store the GitHub source token.",
331+ );
332+ }
333+ const descriptor = yield* runtime.getDescriptor(tenant, config.scope);
334+ const source = descriptor?.source?.kind === "github" ? descriptor.source : null;
335+ const nextConfig: AppsGitHubSourceConfig = {
336+ ...config,
337+ ...(source
338+ ? {
339+ repoUrl: source.url,
340+ repo: source.repo,
341+ ref: source.ref,
342+ }
343+ : {}),
344+ ...(tokenRef ? { token: tokenRef } : {}),
345+ };
346+ yield* ctx.core.integrations.update(slug, {
347+ ...(descriptor?.description ? { description: descriptor.description } : {}),
348+ config: nextConfig,
349+ });
350+ yield* refreshAppConnection(slug, ctx);
351+ return result;
352+ });
353+
354+ const addSource = (
355+ ctx: PluginCtx<AppsStoreShape>,
356+ input: {
357+ readonly url: string;
358+ readonly ref?: string;
359+ readonly name?: string;
360+ readonly token: string | null;
361+ },
362+ ) =>
363+ Effect.gen(function* () {
364+ const parsed = parseGitHubSourceUrl(input.url, { ref: input.ref });
365+ if (!parsed.ok) return syncFailure(parsed.message, input.url);
366+ const rawName = input.name && input.name.trim().length > 0 ? input.name : parsed.value.name;
367+ const slugValue = slugifyCustomToolsAppName(rawName);
368+ const slugError = validateCustomToolsAppSlug(slugValue);
369+ if (slugError) return syncFailure(slugError);
370+ const slug = IntegrationSlug.make(slugValue);
371+ const existing = yield* ctx.core.integrations
372+ .get(slug)
373+ .pipe(Effect.orElseSucceed(() => null));
374+ if (existing) {
375+ return syncFailure(`Integration "${slugValue}" already exists. Choose another name.`);
376+ }
377+
378+ const github = yield* ctx.core.integrations
379+ .get(IntegrationSlug.make("github"))
380+ .pipe(Effect.orElseSucceed(() => null));
381+ const tenant = tenantFor(ctx);
382+ const scope = slugValue;
383+ const result = yield* syncGitHubSource({
384+ runtime,
385+ tenant,
386+ scope,
387+ url: input.url,
388+ ...(input.ref ? { ref: input.ref } : {}),
389+ token: input.token,
390+ baseUrl: configBaseUrl(github?.config),
391+ });
392+ if (result.status === "failed") return result;
393+
394+ const tokenRef = input.token
395+ ? yield* storeSourceToken(ctx, tenant, scope, input.token)
396+ : undefined;
397+ if (input.token && !tokenRef) {
398+ yield* runtime.removeSource({ tenant, scope }).pipe(Effect.orElseSucceed(() => undefined));
399+ return syncFailure(
400+ "No writable credential provider is available to store the GitHub source token.",
401+ );
402+ }
403+
404+ const descriptor = yield* runtime.getDescriptor(tenant, scope);
405+ const source = descriptor?.source?.kind === "github" ? descriptor.source : null;
406+ const config: AppsGitHubSourceConfig = {
407+ kind: "github",
408+ repoUrl: source?.url ?? input.url.trim(),
409+ repo: source?.repo ?? parsed.value.repo,
204410 scope,
205− connection: String(connName),
206− ref: { owner: "user" as const, integration: slug, name: connName },
411+ ...(source?.ref ? { ref: source.ref } : input.ref ? { ref: input.ref } : {}),
412+ ...(tokenRef ? { token: tokenRef } : {}),
207413 };
414+ yield* ctx.core.integrations.register({
415+ slug,
416+ name: slugValue,
417+ description:
418+ descriptor?.description ??
419+ `Custom tools synced from ${source?.repo ?? parsed.value.repo}.`,
420+ config,
421+ canRemove: true,
422+ canRefresh: false,
423+ });
424+ yield* refreshAppConnection(slug, ctx);
425+ return result;
208426 });
209427
428+ const configForToolRow = (
429+ ctx: PluginCtx<AppsStoreShape>,
430+ integration: IntegrationSlug,
431+ ): Effect.Effect<AppsGitHubSourceConfig | null> =>
432+ ctx.core.integrations.get(integration).pipe(
433+ Effect.map((record) =>
434+ record && record.kind === APPS_PLUGIN_ID ? decodeSourceConfig(record.config) : null,
435+ ),
436+ Effect.orElseSucceed(() => null),
437+ );
438+
210439 return {
211440 id: APPS_PLUGIN_ID as "apps",
212441 packageName: "@executor-js/plugin-apps",
@@ -224,16 +453,6 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 11 unmodified lines
224453 schema: { Type: {} as Record<string, unknown> },
225454 indexes: [],
226455 },
227− apps_scope_connection: {
228− name: "apps_scope_connection",
229− schema: { Type: {} as Record<string, unknown> },
230− indexes: [],
231− },
232− apps_github_source_token: {
233− name: "apps_github_source_token",
234− schema: { Type: {} as Record<string, unknown> },
235− indexes: [],
236− },
237456 },
238457
239458 extension: () => ({ runtime }),
@@ -244,19 +463,6 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 4 unmodified lines
244463 kind: "executor",
245464 name: "Apps",
246465 tools: [
247− tool<AppsStoreShape>({
248− name: "connect_catalog",
249− description:
250− "Wire this scope's published custom tools into the tool catalog. " +
251− "Idempotent; call once per scope after publishing.",
252− execute: (args, { ctx }) =>
253− Effect.gen(function* () {
254− const scope =
255− (args as { scope?: string } | undefined)?.scope ?? DEFAULT_CATALOG_SCOPE;
256− const connected = yield* ensureCatalogConnection(scope, ctx);
257− return { scope, connection: connected.connection };
258− }),
259− }),
260466 tool<AppsStoreShape>({
261467 name: "sync_github_source",
262468 description: "Sync a GitHub repository containing custom tool source files.",
@@ -265,51 +471,21 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 2 unmodified lines
265471 const payload = isRecord(args) ? args : {};
266472 const url =
267473 typeof payload.url === "string"
268− ? payload.url
474+ ? payload.url.trim()
269475 : typeof payload.repo === "string"
270− ? payload.repo
476+ ? payload.repo.trim()
271477 : "";
272− const ref =
273− typeof payload.ref === "string" && payload.ref.trim().length > 0
274− ? payload.ref.trim()
275− : undefined;
276− const providedToken =
277− typeof payload.token === "string" && payload.token.trim().length > 0
278− ? payload.token.trim()
279− : null;
280− const explicitScope = typeof payload.scope === "string" ? payload.scope : undefined;
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);
281482 if (!url) return syncFailure('sync_github_source requires "url"');
282−
283− const parsed = parseGitHubSourceUrl(url, { ref });
284− if (!parsed.ok) return syncFailure(parsed.message, url);
285− const integration = yield* ctx.core.integrations
286− .get(IntegrationSlug.make("github"))
287− .pipe(Effect.orElseSucceed(() => null));
288− const tenant = tenantFor(ctx);
289− const scope = explicitScope ?? sourceScopeFor(parsed.value.repo);
290− if (providedToken) {
291− const stored = yield* storeSourceToken(ctx, tenant, scope, providedToken);
292− if (!stored) {
293− return syncFailure(
294− "No writable credential provider is available to store the GitHub source token.",
295− );
296− }
297− }
298− const token = providedToken ?? (yield* storedSourceToken(ctx, tenant, scope));
299− const result = yield* syncGitHubSource({
300− runtime,
301− tenant,
302− scope,
483+ return yield* addSource(ctx, {
303484 url,
304− ref,
305− token,
306− baseUrl: configBaseUrl(integration?.config),
485+ ...(ref ? { ref } : {}),
486+ ...(asString(payload.name) ? { name: asString(payload.name) } : {}),
487+ token: providedToken,
307488 });
308− if (result.status === "published") {
309− const catalog = yield* ensureCatalogConnection(scope, ctx);
310− yield* ctx.connections.refresh(catalog.ref).pipe(Effect.orElseSucceed(() => []));
311− }
312− return result;
313489 }),
314490 }),
315491 tool<AppsStoreShape>({
@@ -317,20 +493,44 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 1 unmodified line
317493 description: "List synced GitHub repositories that publish custom tools.",
318494 execute: (_args, { ctx }) =>
319495 Effect.gen(function* () {
320− const tenant = tenantFor(ctx);
321− const sources = yield* runtime.listGitHubSources(tenant);
496+ const sources = yield* listSources(ctx);
322497 return { sources };
323498 }),
324499 }),
500+ tool<AppsStoreShape>({
501+ name: "get_github_source",
502+ 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+ }),
510+ }),
325511 ],
326512 },
327513 ],
328514
329− resolveTools: ({ ctx, connection }: ResolveToolsInput<AppsStoreShape>) =>
515+ removeIntegration: ({ ctx, integration }) =>
330516 Effect.gen(function* () {
517+ const config = decodeSourceConfig(integration.config);
518+ if (!config) return;
519+ yield* removeStoredSourceToken(ctx, config.token);
520+ yield* runtime.removeSource({ tenant: tenantFor(ctx), scope: config.scope });
521+ }),
522+
523+ describeIntegrationDisplay: (integration) => {
524+ const config = decodeSourceConfig(integration.config);
525+ return config ? { url: config.repoUrl } : {};
526+ },
527+
528+ resolveTools: ({ ctx, config }: ResolveToolsInput<AppsStoreShape>) =>
529+ Effect.gen(function* () {
530+ const source = decodeSourceConfig(config);
531+ if (!source) return { tools: [] } satisfies ResolveToolsResult;
331532 const tenant = ctx ? tenantFor(ctx) : "org";
332− const scope = yield* scopeFor(tenant, String(connection.name));
333− const descriptor = yield* runtime.getDescriptor(tenant, scope);
533+ const descriptor = yield* runtime.getDescriptor(tenant, source.scope);
334534 if (!descriptor) return { tools: [] } satisfies ResolveToolsResult;
335535 const tools: ToolDef[] = [];
336536 for (const t of descriptor.tools) {
@@ -342,12 +542,13 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 5 unmodified lines
342542 projectToolSchema: ({ ctx, toolRow, inputSchema, outputSchema }) =>
343543 Effect.gen(function* () {
344544 const tenant = tenantFor(ctx);
345− const scope = yield* scopeFor(tenant, String(toolRow.connection));
346− const descriptor = yield* runtime.getDescriptor(tenant, scope);
545+ const source = yield* configForToolRow(ctx, IntegrationSlug.make(toolRow.integration));
546+ if (!source) return { inputSchema, outputSchema };
547+ const descriptor = yield* runtime.getDescriptor(tenant, source.scope);
347548 const toolDesc = descriptor?.tools.find((t) => t.name === toolRow.name);
348549 if (!toolDesc) return { inputSchema, outputSchema };
349550 const resolver = makeResolver
350− ? makeResolver({ ctx, scope, tool: toolRow.name })
551+ ? makeResolver({ ctx, scope: source.scope, tool: toolRow.name })
351552 : runtime.deps.resolver;
352553 const byRole: Record<string, readonly ConnectionCandidate[]> = {};
353554 for (const [role, decl] of Object.entries(toolDesc.integrations)) {
@@ -364,11 +565,18 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 10 unmodified lines
364565 invokeTool: ({ ctx, toolRow, args, invokeOptions }: InvokeToolInput<AppsStoreShape>) =>
365566 Effect.gen(function* () {
366567 const tenant = tenantFor(ctx);
367− const scope = yield* scopeFor(tenant, String(toolRow.connection));
368− const descriptor = yield* runtime.getDescriptor(tenant, scope);
568+ const source = yield* configForToolRow(ctx, IntegrationSlug.make(toolRow.integration));
569+ if (!source) {
570+ return yield* new PublishError({
571+ message: `apps integration "${toolRow.integration}" has no custom-tools source config`,
572+ stage: "project",
573+ diagnostics: [],
574+ });
575+ }
576+ const descriptor = yield* runtime.getDescriptor(tenant, source.scope);
369577 if (!descriptor) {
370578 return yield* new PublishError({
371− message: `apps scope "${scope}" has no published app (connection "${toolRow.connection}")`,
579+ message: `apps scope "${source.scope}" has no published app`,
372580 stage: "project",
373581 diagnostics: [],
374582 });
@@ -376,17 +584,17 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 1 unmodified line
376584 const toolDesc = descriptor.tools.find((t) => t.name === toolRow.name);
377585 if (!toolDesc) {
378586 return yield* new PublishError({
379− message: `apps tool "${toolRow.name}" is not published in scope "${scope}"`,
587+ message: `apps tool "${toolRow.name}" is not published in scope "${source.scope}"`,
380588 stage: "project",
381589 diagnostics: [],
382590 });
383591 }
384592 const resolver = makeResolver
385− ? makeResolver({ ctx, scope, tool: toolRow.name })
593+ ? makeResolver({ ctx, scope: source.scope, tool: toolRow.name })
386594 : undefined;
387595 return yield* runtime.invokeTool({
388596 tenant,
389− scope,
597+ scope: source.scope,
390598 tool: toolRow.name,
391599 args,
392600 resolver,
@@ -395,36 +603,3 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 2 unmodified lines
395603 }),
396604 };
397605 });
398−
399−const projectTool = (descriptor: ToolDescriptor): ToolDef => ({
400− name: ToolName.make(descriptor.name),
401− description: descriptor.description,
402− inputSchema: descriptor.inputSchema,
403− outputSchema: descriptor.outputSchema,
404− annotations: {
405− requiresApproval: descriptor.annotations?.destructive === true,
406− },
407−});
408−
409−export const APPS_CONNECTION_PREFIX = APPS_INTEGRATION_SLUG;
410−
411−const pascal = (value: string): string =>
412− value.length === 0 ? value : `${value[0]!.toUpperCase()}${value.slice(1)}`;
413−
414−export const connectionNameForScope = (scope: string): string =>
415− `${APPS_CONNECTION_PREFIX}${pascal(scope)}`;
416−
417−export const scopeFromConnection = (connectionName: string): string => {
418− if (connectionName.startsWith(`${APPS_INTEGRATION_SLUG}/`)) {
419− return connectionName.slice(APPS_INTEGRATION_SLUG.length + 1);
420− }
421− if (
422− connectionName.startsWith(APPS_CONNECTION_PREFIX) &&
423− connectionName.length > APPS_CONNECTION_PREFIX.length
424− ) {
425− const rest = connectionName.slice(APPS_CONNECTION_PREFIX.length);
426− return `${rest[0]!.toLowerCase()}${rest.slice(1)}`;
427− }
428− const slash = connectionName.indexOf("/");
429− return slash === -1 ? connectionName : connectionName.slice(slash + 1);
430−};
packages/plugins/apps/src/plugin/runtime.ts+49−24
@@ -42,6 +42,8 @@ export interface AppsRuntimeDeps { · 41 unmodified lines
4242 }
4343
4444 export interface GitHubCustomToolsSourceSummary {
45+ readonly slug: string;
46+ readonly name: string;
4547 readonly scope: string;
4648 readonly url: string;
4749 readonly repo: string;
@@ -71,6 +73,10 @@ export interface AppsRuntime { · 23 unmodified lines
7173 readonly listGitHubSources: (
7274 tenant?: string,
7375 ) => Effect.Effect<readonly GitHubCustomToolsSourceSummary[]>;
76+ readonly removeSource: (input: {
77+ readonly tenant?: string;
78+ readonly scope: string;
79+ }) => Effect.Effect<void, PublishError>;
7480 /** Re-derive the published-descriptor pointer from the latest committed
7581 * snapshot. */
7682 readonly repair: (
@@ -382,30 +388,27 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 305 unmodified lines
382388 const source = record.descriptor.source;
383389 return source?.kind === "github" ? [{ record, source }] : [];
384390 });
385− return Effect.forEach(githubRecords, ({ record, source }) =>
386− Effect.gen(function* () {
387− const { descriptor, publishedAt } = record;
388− const tokenRef = yield* deps.store
389− .getGitHubSourceTokenRef(tenant, descriptor.scope)
390− .pipe(Effect.orElseSucceed(() => null));
391− return {
392− scope: descriptor.scope,
393− url: source.url,
394− repo: source.repo,
395− ref: source.ref,
396− hasToken: tokenRef !== null,
397− upstreamSha: source.upstreamSha,
398− snapshotId: descriptor.snapshotId,
399− ...(descriptor.description ? { description: descriptor.description } : {}),
400− publishedAt: new Date(publishedAt).toISOString(),
401− tools: descriptor.tools.map((tool) => tool.name),
402− skipped: [
403− ...(source.skipped ?? []),
404− ...(descriptor.skipped as readonly GitHubSkippedArtifact[]),
405− ],
406− } satisfies GitHubCustomToolsSourceSummary;
407− }),
408− );
391+ return Effect.forEach(githubRecords, ({ record, source }) => {
392+ const { descriptor, publishedAt } = record;
393+ return Effect.succeed({
394+ slug: descriptor.scope,
395+ name: source.repo.split("/").at(-1) ?? descriptor.scope,
396+ scope: descriptor.scope,
397+ url: source.url,
398+ repo: source.repo,
399+ ref: source.ref,
400+ hasToken: false,
401+ upstreamSha: source.upstreamSha,
402+ snapshotId: descriptor.snapshotId,
403+ ...(descriptor.description ? { description: descriptor.description } : {}),
404+ publishedAt: new Date(publishedAt).toISOString(),
405+ tools: descriptor.tools.map((tool) => tool.name),
406+ skipped: [
407+ ...(source.skipped ?? []),
408+ ...(descriptor.skipped as readonly GitHubSkippedArtifact[]),
409+ ],
410+ } satisfies GitHubCustomToolsSourceSummary);
411+ });
409412 }),
410413 );
411414 },
@@ -415,6 +418,28 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 3 unmodified lines
415418 return repairScope(tenant, scope);
416419 },
417420
421+ removeSource: (input) => {
422+ const tenant = resolveTenant(input.tenant);
423+ const address = scopeAddress(tenant, input.scope);
424+ const toPublishError = (message: string) => (_cause: unknown) =>
425+ new PublishError({
426+ message,
427+ stage: "project",
428+ diagnostics: [],
429+ });
430+ return Effect.gen(function* () {
431+ yield* deps.store
432+ .removeDescriptor(tenant, input.scope)
433+ .pipe(Effect.mapError(toPublishError("removeDescriptor failed")));
434+ yield* deps.artifactStore
435+ .removeScope(address)
436+ .pipe(Effect.mapError(toPublishError("remove artifact scope failed")));
437+ yield* deps.scopeDb
438+ .removeScope(address)
439+ .pipe(Effect.mapError(toPublishError("remove scope db failed")));
440+ });
441+ },
442+
418443 invokeTool: (input) =>
419444 Effect.gen(function* () {
420445 const tenant = resolveTenant(input.tenant);
packages/plugins/apps/src/plugin/store.ts+4−66
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
99 import type { AppDescriptor } from "../pipeline/descriptor";
1010
1111 // ---------------------------------------------------------------------------
12−// AppsStore: descriptor pointer persistence and connection-to-scope mapping.
12+// AppsStore: descriptor pointer persistence.
1313 // ---------------------------------------------------------------------------
1414
1515 export const descriptorCollection = definePluginStorageCollection("published_descriptor", {
@@ -22,27 +22,6 @@ export const descriptorCollection = definePluginStorageCollection("published_des · 6 unmodified lines
2222 },
2323 });
2424
25−export const scopeConnectionCollection = definePluginStorageCollection("apps_scope_connection", {
26− Type: {} as {
27− readonly tenant: string;
28− readonly connectionName: string;
29− readonly scope: string;
30− },
31−});
32−
33−export const githubSourceTokenCollection = definePluginStorageCollection(
34− "apps_github_source_token",
35− {
36− Type: {} as {
37− readonly tenant: string;
38− readonly scope: string;
39− readonly provider: string;
40− readonly itemId: string;
41− readonly updatedAt: number;
42− },
43− },
44−);
45−
4625 export interface AppDescriptorRecord {
4726 readonly descriptor: AppDescriptor;
4827 readonly publishedAt: number;
@@ -64,27 +43,10 @@ export interface AppsStore { · 15 unmodified lines
6443 tenant: string,
6544 scope: string,
6645 ) => Effect.Effect<AppDescriptor | null, StorageFailure>;
46+ readonly removeDescriptor: (tenant: string, scope: string) => Effect.Effect<void, StorageFailure>;
6747 readonly listDescriptors: (
6848 tenant: string,
6949 ) => Effect.Effect<readonly AppDescriptorRecord[], StorageFailure>;
70− readonly putScopeForConnection: (
71− tenant: string,
72− connectionName: string,
73− scope: string,
74− ) => Effect.Effect<void, StorageFailure>;
75− readonly getScopeForConnection: (
76− tenant: string,
77− connectionName: string,
78− ) => Effect.Effect<string | null, StorageFailure>;
79− readonly putGitHubSourceTokenRef: (
80− tenant: string,
81− scope: string,
82− ref: GitHubSourceTokenRef,
83− ) => Effect.Effect<void, StorageFailure>;
84− readonly getGitHubSourceTokenRef: (
85− tenant: string,
86− scope: string,
87− ) => Effect.Effect<GitHubSourceTokenRef | null, StorageFailure>;
8850 }
8951
9052 export interface AppsStoreDeps {
@@ -93,36 +55,10 @@ export interface AppsStoreDeps { · 2 unmodified lines
9355
9456 export const makeAppsStore = (deps: AppsStoreDeps): AppsStore => {
9557 const descriptors = deps.pluginStorage.collection(descriptorCollection);
96− const scopeConnections = deps.pluginStorage.collection(scopeConnectionCollection);
97− const githubSourceTokens = deps.pluginStorage.collection(githubSourceTokenCollection);
9858 // Tenant is now part of apps storage keys. The apps subsystem had not shipped
9959 // before this key shape, so no migration from the old scope-only keys is needed.
10060 const keyFor = (tenant: string, key: string): string => `${tenant}:${key}`;
10161 return {
102− putScopeForConnection: (tenant, connectionName, scope) =>
103− scopeConnections
104− .put({
105− owner: "org",
106− key: keyFor(tenant, connectionName),
107− data: { tenant, connectionName, scope },
108− })
109− .pipe(Effect.asVoid),
110− getScopeForConnection: (tenant, connectionName) =>
111− scopeConnections
112− .get({ key: keyFor(tenant, connectionName) })
113− .pipe(Effect.map((entry) => entry?.data.scope ?? null)),
114− putGitHubSourceTokenRef: (tenant, scope, ref) =>
115− githubSourceTokens
116− .put({
117− owner: "org",
118− key: keyFor(tenant, scope),
119− data: { tenant, scope, ...ref },
120− })
121− .pipe(Effect.asVoid),
122− getGitHubSourceTokenRef: (tenant, scope) =>
123− githubSourceTokens
124− .get({ key: keyFor(tenant, scope) })
125− .pipe(Effect.map((entry) => entry?.data ?? null)),
12662 putDescriptor: (tenant, owner, descriptor) =>
12763 descriptors
12864 .put({
@@ -141,6 +77,8 @@ export const makeAppsStore = (deps: AppsStoreDeps): AppsStore => { · 12 unmodified lines
14177 descriptors
14278 .get({ key: keyFor(tenant, scope) })
14379 .pipe(Effect.map((entry) => entry?.data.descriptor ?? null)),
80+ removeDescriptor: (tenant, scope) =>
81+ descriptors.remove({ owner: "org", key: keyFor(tenant, scope) }),
14482 listDescriptors: (tenant) =>
14583 descriptors.list({ keyPrefix: `${tenant}:` }).pipe(
14684 Effect.map((entries) =>
packages/plugins/apps/src/react/AddCustomToolsSource.tsx+48−3
@@ -10,12 +10,15 @@ import { · 9 unmodified lines
1010 import { FloatActions } from "@executor-js/react/components/float-actions";
1111 import { Input } from "@executor-js/react/components/input";
1212 import { toast } from "@executor-js/react/components/sonner";
13−import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
13+import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add";
1414
1515 import {
1616 formatSyncErrors,
17+ slugifyCustomToolsAppName,
18+ suggestCustomToolsAppName,
1719 syncCustomToolSourceEffect,
1820 syncStatusLabel,
21+ validateCustomToolsAppSlug,
1922 validateGitHubSourceUrl,
2023 } from "./custom-tools-client";
2124
@@ -24,21 +27,36 @@ export default function AddCustomToolsSource(props: { · 2 unmodified lines
2427 readonly onCancel: () => void;
2528 }) {
2629 const [url, setUrl] = useState("");
30+ const [name, setName] = useState("");
31+ const [nameTouched, setNameTouched] = useState(false);
2732 const [token, setToken] = useState("");
2833 const [tokenRevealed, setTokenRevealed] = useState(false);
2934 const [urlError, setUrlError] = useState<string | null>(null);
35+ const [nameError, setNameError] = useState<string | null>(null);
3036 const [syncing, setSyncing] = useState(false);
3137 const [syncError, setSyncError] = useState<string | null>(null);
38+ const effectiveName = nameTouched
39+ ? name
40+ : slugifyCustomToolsAppName(suggestCustomToolsAppName(url));
41+ const slug = effectiveName;
42+ const slugAlreadyExists = useSlugAlreadyExists(slug);
3243
3344 const submit = async () => {
3445 const validation = validateGitHubSourceUrl(url);
46+ const nextNameError = validateCustomToolsAppSlug(slug);
3547 setUrlError(validation);
48+ setNameError(nextNameError);
3649 setSyncError(null);
37− if (validation) return;
50+ if (validation || nextNameError) return;
51+ if (slugAlreadyExists) {
52+ setSyncError(`An integration named "${slug}" already exists. Choose another name.`);
53+ return;
54+ }
3855
3956 setSyncing(true);
4057 const exit = await Effect.runPromiseExit(
4158 syncCustomToolSourceEffect({
59+ name: slug,
4260 url,
4361 token,
4462 }),
@@ -55,7 +73,7 @@ export default function AddCustomToolsSource(props: { · 10 unmodified lines
5573 return;
5674 }
5775 toast.success(syncStatusLabel(result));
58− props.onComplete("apps");
76+ props.onComplete(slug);
5977 };
6078
6179 return (
@@ -79,6 +97,7 @@ export default function AddCustomToolsSource(props: { · 17 unmodified lines
7997 onChange={(event) => {
8098 setUrl((event.target as HTMLInputElement).value);
8199 setUrlError(null);
100+ setNameError(null);
82101 setSyncError(null);
83102 }}
84103 onBlur={() => setUrlError(validateGitHubSourceUrl(url))}
@@ -90,6 +109,32 @@ export default function AddCustomToolsSource(props: { · 5 unmodified lines
90109 </div>
91110 </CardStackEntryField>
92111
112+ <CardStackEntryField
113+ label="Name"
114+ description="- Lowercase letters, numbers, and hyphens. Addresses use this name."
115+ >
116+ <div className="space-y-1.5">
117+ <Input
118+ value={effectiveName}
119+ onChange={(event) => {
120+ setNameTouched(true);
121+ setName(slugifyCustomToolsAppName((event.target as HTMLInputElement).value));
122+ setNameError(null);
123+ setSyncError(null);
124+ }}
125+ placeholder="executor-custom-tools-demo"
126+ className="text-sm"
127+ aria-invalid={nameError ? true : undefined}
128+ />
129+ {nameError && <p className="text-xs text-destructive">{nameError}</p>}
130+ {slugAlreadyExists && !syncing && !nameError && (
131+ <p className="text-xs text-destructive">
132+ An integration named &quot;{slug}&quot; already exists.
133+ </p>
134+ )}
135+ </div>
136+ </CardStackEntryField>
137+
93138 <CardStackEntryField
94139 label="Access token (optional)"
95140 description="- Needed only for private repositories or higher GitHub API limits."
packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx+121−127
@@ -1,24 +1,27 @@
11 import { useEffect, useState, type ReactNode } from "react";
22 import { Effect, Exit } from "effect";
33
4+import {
5+ AlertDialog,
6+ AlertDialogAction,
7+ AlertDialogCancel,
8+ AlertDialogContent,
9+ AlertDialogDescription,
10+ AlertDialogFooter,
11+ AlertDialogHeader,
12+ AlertDialogTitle,
13+ AlertDialogTrigger,
14+} from "@executor-js/react/components/alert-dialog";
415 import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
516 import { Badge } from "@executor-js/react/components/badge";
617 import { Button } from "@executor-js/react/components/button";
7−import {
8− CardStack,
9− CardStackContent,
10− CardStackEntry,
11− CardStackEntryActions,
12− CardStackEntryContent,
13− CardStackEntryDescription,
14− CardStackEntryTitle,
15−} from "@executor-js/react/components/card-stack";
1618
1719 import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api";
1820 import {
1921 consoleIntegrationHref,
2022 formatSyncErrors,
21− listCustomToolSourcesEffect,
23+ getCustomToolSourceEffect,
24+ removeCustomToolSourceEffect,
2225 syncCustomToolSourceEffect,
2326 syncStatusLabel,
2427 toolDiff,
@@ -27,7 +30,8 @@ import { · 2 unmodified lines
2730 type LoadState =
2831 | { readonly status: "loading" }
2932 | { readonly status: "error"; readonly message: string }
30− | { readonly status: "ready"; readonly sources: readonly GitHubCustomToolsSourceSummary[] };
33+ | { readonly status: "missing" }
34+ | { readonly status: "ready"; readonly source: GitHubCustomToolsSourceSummary };
3135
3236 interface SyncNotice {
3337 readonly status: GitHubSyncResult["status"];
@@ -42,61 +46,49 @@ const formatDate = (value: string): string => { · 8 unmodified lines
4246 return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
4347 };
4448
45−export default function CustomToolsAccountsPanel() {
49+export default function CustomToolsAccountsPanel(props: {
50+ readonly sourceId: string;
51+ readonly integrationName: string;
52+}) {
4653 const [loadState, setLoadState] = useState<LoadState>({ status: "loading" });
47− const [selectedScope, setSelectedScope] = useState<string | null>(null);
48− const [syncingScope, setSyncingScope] = useState<string | null>(null);
54+ const [syncing, setSyncing] = useState(false);
55+ const [removing, setRemoving] = useState(false);
4956 const [notice, setNotice] = useState<SyncNotice | null>(null);
57+ const [removeError, setRemoveError] = useState<string | null>(null);
5058
51− const loadSources = async () => {
59+ const loadSource = async () => {
5260 setLoadState({ status: "loading" });
53− const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect());
61+ const exit = await Effect.runPromiseExit(getCustomToolSourceEffect(props.sourceId));
5462 if (Exit.isFailure(exit)) {
55− setLoadState({
56− status: "error",
57− message: "Failed to load custom tool sources.",
58− });
63+ setLoadState({ status: "error", message: "Failed to load custom tools source." });
5964 return;
6065 }
61− const result = exit.value;
62− setLoadState({ status: "ready", sources: result.sources });
63− setSelectedScope((current) => current ?? result.sources[0]?.scope ?? null);
66+ const source = exit.value.source;
67+ setLoadState(source ? { status: "ready", source } : { status: "missing" });
6468 };
6569
6670 useEffect(() => {
6771 let active = true;
6872 void (async () => {
69− const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect());
70− if (Exit.isSuccess(exit)) {
71− if (!active) return;
72− const result = exit.value;
73− setLoadState({ status: "ready", sources: result.sources });
74− setSelectedScope(result.sources[0]?.scope ?? null);
75− } else {
76− if (!active) return;
77− setLoadState({
78− status: "error",
79− message: "Failed to load custom tool sources.",
80− });
73+ const exit = await Effect.runPromiseExit(getCustomToolSourceEffect(props.sourceId));
74+ if (!active) return;
75+ if (Exit.isFailure(exit)) {
76+ setLoadState({ status: "error", message: "Failed to load custom tools source." });
77+ return;
8178 }
79+ const source = exit.value.source;
80+ setLoadState(source ? { status: "ready", source } : { status: "missing" });
8281 })();
8382 return () => {
8483 active = false;
8584 };
86− }, []);
87−
88− const sources = loadState.status === "ready" ? loadState.sources : [];
89− const selected = sources.find((source) => source.scope === selectedScope) ?? sources[0] ?? null;
85+ }, [props.sourceId]);
9086
91− const syncSelected = async (source: GitHubCustomToolsSourceSummary) => {
92− setSyncingScope(source.scope);
87+ const syncSource = async (source: GitHubCustomToolsSourceSummary) => {
88+ setSyncing(true);
9389 setNotice(null);
9490 const beforeTools = source.tools;
95− const exit = await Effect.runPromiseExit(
96− syncCustomToolSourceEffect({
97− url: source.url,
98− }),
99− );
91+ const exit = await Effect.runPromiseExit(syncCustomToolSourceEffect({ slug: source.slug }));
10092 if (Exit.isFailure(exit)) {
10193 setNotice({
10294 status: "failed",
@@ -105,7 +97,7 @@ export default function CustomToolsAccountsPanel() { · 2 unmodified lines
10597 removed: [],
10698 errors: ["Failed to sync custom tools."],
10799 });
108− setSyncingScope(null);
100+ setSyncing(false);
109101 return;
110102 }
111103 const result = exit.value;
@@ -118,94 +110,53 @@ export default function CustomToolsAccountsPanel() { · 6 unmodified lines
118110 removed: diff.removed,
119111 errors: formatSyncErrors(result),
120112 });
121− if (result.status !== "failed") {
122− await loadSources();
123− setSelectedScope(source.scope);
113+ if (result.status !== "failed") await loadSource();
114+ setSyncing(false);
115+ };
116+
117+ const removeSource = async (source: GitHubCustomToolsSourceSummary) => {
118+ setRemoving(true);
119+ setRemoveError(null);
120+ const exit = await Effect.runPromiseExit(removeCustomToolSourceEffect(source.slug));
121+ if (Exit.isFailure(exit)) {
122+ setRemoveError("Failed to remove custom tools source.");
123+ setRemoving(false);
124+ return;
124125 }
125− setSyncingScope(null);
126+ window.location.assign(consoleIntegrationHref("/integrations"));
126127 };
127128
128129 return (
129− <div className="mx-auto max-w-5xl space-y-6 px-6 py-8">
130− <div className="flex items-center justify-between gap-3">
131− <div>
132− <h2 className="text-sm font-medium text-foreground">Custom tools sources</h2>
133− <p className="mt-1 text-xs text-muted-foreground">
134− GitHub repositories synced into the executor tool catalog.
135− </p>
136− </div>
137− <Button asChild size="sm" variant="outline">
138− <a href={consoleIntegrationHref("/integrations/add/apps")}>Add source</a>
139− </Button>
140− </div>
141−
130+ <div className="mx-auto max-w-3xl space-y-6 px-6 py-8">
142131 {loadState.status === "loading" && (
143132 <div className="rounded-lg border border-border p-4 text-sm text-muted-foreground">
144− Loading sources...
133+ Loading source...
145134 </div>
146135 )}
147136
148137 {loadState.status === "error" && (
149− <ErrorWithRetry message={loadState.message} onRetry={() => void loadSources()} />
138+ <ErrorWithRetry message={loadState.message} onRetry={() => void loadSource()} />
150139 )}
151140
152− {loadState.status === "ready" && sources.length === 0 && (
153− <div className="rounded-lg border border-dashed border-border p-8 text-center">
154− <p className="text-sm font-medium text-foreground">No custom tools sources</p>
155− <p className="mt-1 text-sm text-muted-foreground">
156− Add a GitHub repo to publish its tools.
157− </p>
158− <Button asChild size="sm" className="mt-4">
159− <a href={consoleIntegrationHref("/integrations/add/apps")}>Add source</a>
160− </Button>
161− </div>
141+ {loadState.status === "missing" && (
142+ <Alert variant="destructive">
143+ <AlertTitle>Source not found</AlertTitle>
144+ <AlertDescription>
145+ The custom tools source for {props.integrationName} is no longer available.
146+ </AlertDescription>
147+ </Alert>
162148 )}
163149
164− {loadState.status === "ready" && sources.length > 0 && (
165− <div className="grid min-h-[28rem] gap-4 lg:grid-cols-[18rem_1fr]">
166− <CardStack searchable>
167− <CardStackContent>
168− {sources.map((source) => (
169− <CardStackEntry
170− key={source.scope}
171− asChild
172− searchText={`${source.repo} ${source.ref} ${source.url}`}
173− >
174− <Button
175− type="button"
176− variant="ghost"
177− onClick={() => {
178− setSelectedScope(source.scope);
179− setNotice(null);
180− }}
181− className={
182− selected?.scope === source.scope
183− ? "h-auto w-full justify-start bg-muted/70 p-0 text-left"
184− : "h-auto w-full justify-start bg-transparent p-0 text-left"
185− }
186− >
187− <CardStackEntryContent>
188− <CardStackEntryTitle>{source.repo}</CardStackEntryTitle>
189− <CardStackEntryDescription>{source.ref}</CardStackEntryDescription>
190− </CardStackEntryContent>
191− <CardStackEntryActions>
192− <Badge variant="secondary">{source.tools.length}</Badge>
193− </CardStackEntryActions>
194− </Button>
195− </CardStackEntry>
196− ))}
197− </CardStackContent>
198− </CardStack>
199−
200− {selected && (
201− <SourceDetail
202− source={selected}
203− notice={notice}
204− syncing={syncingScope === selected.scope}
205− onSync={() => void syncSelected(selected)}
206− />
207− )}
208− </div>
150+ {loadState.status === "ready" && (
151+ <SourceDetail
152+ source={loadState.source}
153+ notice={notice}
154+ removeError={removeError}
155+ syncing={syncing}
156+ removing={removing}
157+ onSync={() => void syncSource(loadState.source)}
158+ onRemove={() => void removeSource(loadState.source)}
159+ />
209160 )}
210161 </div>
211162 );
@@ -214,7 +165,7 @@ export default function CustomToolsAccountsPanel() { · 2 unmodified lines
214165 function ErrorWithRetry(props: { readonly message: string; readonly onRetry: () => void }) {
215166 return (
216167 <Alert variant="destructive">
217− <AlertTitle>Failed to load sources</AlertTitle>
168+ <AlertTitle>Failed to load source</AlertTitle>
218169 <AlertDescription>
219170 <div className="space-y-3">
220171 <p>{props.message}</p>
@@ -230,20 +181,53 @@ function ErrorWithRetry(props: { readonly message: string; readonly onRetry: () · 9 unmodified lines
230181 function SourceDetail(props: {
231182 readonly source: GitHubCustomToolsSourceSummary;
232183 readonly notice: SyncNotice | null;
184+ readonly removeError: string | null;
233185 readonly syncing: boolean;
186+ readonly removing: boolean;
234187 readonly onSync: () => void;
188+ readonly onRemove: () => void;
235189 }) {
236190 const { source, notice } = props;
237191 return (
238192 <div className="min-w-0 rounded-lg border border-border bg-card">
239193 <div className="flex items-start justify-between gap-4 border-b border-border p-4">
240194 <div className="min-w-0">
241− <h3 className="truncate text-sm font-medium text-foreground">{source.repo}</h3>
242− <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{source.scope}</p>
195+ <h3 className="truncate text-sm font-medium text-foreground">{source.name}</h3>
196+ <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{source.slug}</p>
197+ </div>
198+ <div className="flex shrink-0 gap-2">
199+ <Button type="button" size="sm" onClick={props.onSync} loading={props.syncing}>
200+ Sync
201+ </Button>
202+ <AlertDialog>
203+ <AlertDialogTrigger asChild>
204+ <Button
205+ type="button"
206+ size="sm"
207+ variant="outline"
208+ className="border-destructive/30 text-destructive hover:bg-destructive/10"
209+ disabled={props.removing}
210+ >
211+ Remove
212+ </Button>
213+ </AlertDialogTrigger>
214+ <AlertDialogContent size="sm">
215+ <AlertDialogHeader>
216+ <AlertDialogTitle>Remove {source.name}?</AlertDialogTitle>
217+ <AlertDialogDescription>
218+ This removes {source.tools.length} {source.tools.length === 1 ? "tool" : "tools"}{" "}
219+ from the catalog. The GitHub repository is untouched; re-add it to sync again.
220+ </AlertDialogDescription>
221+ </AlertDialogHeader>
222+ <AlertDialogFooter>
223+ <AlertDialogCancel>Cancel</AlertDialogCancel>
224+ <AlertDialogAction variant="destructive" onClick={props.onRemove}>
225+ {props.removing ? "Removing..." : "Remove source"}
226+ </AlertDialogAction>
227+ </AlertDialogFooter>
228+ </AlertDialogContent>
229+ </AlertDialog>
243230 </div>
244− <Button type="button" size="sm" onClick={props.onSync} loading={props.syncing}>
245− Sync
246− </Button>
247231 </div>
248232
249233 <div className="space-y-6 p-4">
@@ -272,6 +256,8 @@ function SourceDetail(props: { · 22 unmodified lines
272256 </Alert>
273257 )}
274258
259+ {props.removeError && <FormErrorMessage message={props.removeError} />}
260+
275261 <Section title="Published tools">
276262 {source.tools.length > 0 ? (
277263 <div className="flex flex-wrap gap-2">
@@ -333,6 +319,14 @@ function Field(props: { readonly label: string; readonly value: string; readonly · 55 unmodified lines
333319 );
334320 }
335321
322+function FormErrorMessage(props: { readonly message: string }) {
323+ return (
324+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
325+ <p className="text-[12px] text-destructive">{props.message}</p>
326+ </div>
327+ );
328+}
329+
336330 function Section(props: { readonly title: string; readonly children: ReactNode }) {
337331 return (
338332 <section className="space-y-2">
packages/plugins/apps/src/react/custom-tools-client.ts+68−2
@@ -2,6 +2,7 @@ import { Data, Effect, Schema } from "effect"; · 1 unmodified line
22
33 import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api";
44 import { parseGitHubSourceUrl } from "../source/github-url";
5+import { slugifyCustomToolsAppName, validateCustomToolsAppSlug } from "../source/app-slug";
56
67 export const CUSTOM_TOOLS_PLUGIN_KEY = "apps";
78 export const CUSTOM_TOOLS_LABEL = "Custom tools";
@@ -10,8 +11,14 @@ export interface GitHubSourcesListResponse { · 2 unmodified lines
1011 readonly sources: readonly GitHubCustomToolsSourceSummary[];
1112 }
1213
14+export interface GitHubSourceDetailResponse {
15+ readonly source: GitHubCustomToolsSourceSummary | null;
16+}
17+
1318 export interface SyncGitHubSourceRequest {
14− readonly url: string;
19+ readonly url?: string;
20+ readonly name?: string;
21+ readonly slug?: string;
1522 readonly ref?: string;
1623 readonly token?: string;
1724 }
@@ -23,6 +30,12 @@ export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientE · 5 unmodified lines
2330 }> {}
2431
2532 export { parseGitHubSourceUrl };
33+export { slugifyCustomToolsAppName, validateCustomToolsAppSlug };
34+
35+export const suggestCustomToolsAppName = (url: string): string => {
36+ const parsed = parseGitHubSourceUrl(url);
37+ return parsed.ok ? parsed.value.name : "";
38+};
2639
2740 export const validateGitHubSourceUrl = (url: string): string | null => {
2841 const parsed = parseGitHubSourceUrl(url);
@@ -81,6 +94,31 @@ export const listCustomToolSources = ( · 52 unmodified lines
8194 fetchImpl: CustomToolsFetch = fetch,
8295 ): Promise<GitHubSourcesListResponse> => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl));
8396
97+export const getCustomToolSourceEffect = (
98+ slug: string,
99+ fetchImpl: CustomToolsFetch = fetch,
100+): Effect.Effect<GitHubSourceDetailResponse, CustomToolsClientError> =>
101+ Effect.tryPromise({
102+ try: () =>
103+ fetchImpl(`/api/apps/sources/github/${encodeURIComponent(slug)}`, {
104+ credentials: "same-origin",
105+ }),
106+ catch: () => new CustomToolsClientError({ message: "Failed to load custom tools source." }),
107+ }).pipe(
108+ Effect.flatMap((response) =>
109+ parseJsonResponseEffect<GitHubSourceDetailResponse>(
110+ response,
111+ "Failed to load custom tools source.",
112+ ),
113+ ),
114+ );
115+
116+export const getCustomToolSource = (
117+ slug: string,
118+ fetchImpl: CustomToolsFetch = fetch,
119+): Promise<GitHubSourceDetailResponse> =>
120+ Effect.runPromise(getCustomToolSourceEffect(slug, fetchImpl));
121+
84122 export const syncCustomToolSourceEffect = (
85123 input: SyncGitHubSourceRequest,
86124 fetchImpl: CustomToolsFetch = fetch,
@@ -92,7 +130,9 @@ export const syncCustomToolSourceEffect = ( · 5 unmodified lines
92130 headers: { "content-type": "application/json" },
93131 credentials: "same-origin",
94132 body: JSON.stringify({
95− url: input.url.trim(),
133+ ...(input.url?.trim() ? { url: input.url.trim() } : {}),
134+ ...(input.name?.trim() ? { name: input.name.trim() } : {}),
135+ ...(input.slug?.trim() ? { slug: input.slug.trim() } : {}),
96136 ...(input.ref?.trim() ? { ref: input.ref.trim() } : {}),
97137 ...(input.token?.trim() ? { token: input.token.trim() } : {}),
98138 }),
@@ -109,6 +149,32 @@ export const syncCustomToolSource = ( · 10 unmodified lines
109149 fetchImpl: CustomToolsFetch = fetch,
110150 ): Promise<GitHubSyncResult> => Effect.runPromise(syncCustomToolSourceEffect(input, fetchImpl));
111151
152+export const removeCustomToolSourceEffect = (
153+ slug: string,
154+ fetchImpl: CustomToolsFetch = fetch,
155+): Effect.Effect<{ readonly removed: boolean }, CustomToolsClientError> =>
156+ Effect.tryPromise({
157+ try: () =>
158+ fetchImpl(`/api/apps/sources/github/${encodeURIComponent(slug)}`, {
159+ method: "DELETE",
160+ credentials: "same-origin",
161+ }),
162+ catch: () => new CustomToolsClientError({ message: "Failed to remove custom tools source." }),
163+ }).pipe(
164+ Effect.flatMap((response) =>
165+ parseJsonResponseEffect<{ readonly removed: boolean }>(
166+ response,
167+ "Failed to remove custom tools source.",
168+ ),
169+ ),
170+ );
171+
172+export const removeCustomToolSource = (
173+ slug: string,
174+ fetchImpl: CustomToolsFetch = fetch,
175+): Promise<{ readonly removed: boolean }> =>
176+ Effect.runPromise(removeCustomToolSourceEffect(slug, fetchImpl));
177+
112178 export const syncStatusLabel = (result: GitHubSyncResult): string => {
113179 if (result.status === "published") return `Published ${result.tools.length} tools.`;
114180 if (result.status === "up-to-date") return "Already up to date.";
packages/plugins/apps/src/seams/artifact-store.ts+1−0
@@ -64,4 +64,5 @@ export interface ArtifactStore { · 63 unmodified lines
6464 readonly forScope: (
6565 address: ScopeAddress,
6666 ) => Effect.Effect<ScopeArtifactStore, ArtifactStoreError>;
67+ readonly removeScope: (address: ScopeAddress) => Effect.Effect<void, ArtifactStoreError>;
6768 }
packages/plugins/apps/src/seams/scope-db.ts+1−0
@@ -39,5 +39,6 @@ export interface ScopeDbHandle { · 38 unmodified lines
3939
4040 export interface ScopeDb {
4141 readonly forScope: (address: ScopeAddress) => Effect.Effect<ScopeDbHandle, ScopeDbError>;
42+ readonly removeScope: (address: ScopeAddress) => Effect.Effect<void, ScopeDbError>;
4243 readonly close: () => Effect.Effect<void, ScopeDbError>;
4344 }
packages/plugins/apps/src/source/app-slug.ts+17−0
@@ -0,0 +1,17 @@
1+export const CUSTOM_TOOLS_APP_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
2+
3+export const slugifyCustomToolsAppName = (input: string): string => {
4+ const base = input
5+ .trim()
6+ .toLowerCase()
7+ .replace(/[^a-z0-9]+/g, "-")
8+ .replace(/^-+|-+$/g, "");
9+ return base.slice(0, 63).replace(/-+$/g, "");
10+};
11+
12+export const validateCustomToolsAppSlug = (slug: string): string | null => {
13+ if (slug.length === 0) return "Enter a name with at least one letter or number.";
14+ return CUSTOM_TOOLS_APP_SLUG_PATTERN.test(slug)
15+ ? null
16+ : "Use lowercase letters, numbers, and hyphens. Start and end with a letter or number.";
17+};
packages/plugins/apps/src/testing/index.ts+15−14
@@ -20,11 +20,6 @@ export const makeInMemoryAppsStore = (): AppsStore & { · 19 unmodified lines
2020 } => {
2121 const descriptors = new Map<string, AppDescriptor>();
2222 const publishedAt = new Map<string, number>();
23− const scopeConnections = new Map<string, string>();
24− const githubSourceTokens = new Map<
25− string,
26− { provider: string; itemId: string; updatedAt: number }
27− >();
2823 const keyFor = (tenant: string, key: string): string => `${tenant}:${key}`;
2924 return {
3025 descriptors,
@@ -36,6 +31,12 @@ export const makeInMemoryAppsStore = (): AppsStore & { · 5 unmodified lines
3631 }),
3732 getDescriptor: (tenant, scope) =>
3833 Effect.sync(() => descriptors.get(keyFor(tenant, scope)) ?? null),
34+ removeDescriptor: (tenant, scope) =>
35+ Effect.sync(() => {
36+ const key = keyFor(tenant, scope);
37+ descriptors.delete(key);
38+ publishedAt.delete(key);
39+ }),
3940 listDescriptors: (tenant) =>
4041 Effect.sync(() =>
4142 [...descriptors.entries()]
@@ -45,14 +46,6 @@ export const makeInMemoryAppsStore = (): AppsStore & { · 3 unmodified lines
4546 publishedAt: publishedAt.get(key) ?? 0,
4647 })),
4748 ),
48− putScopeForConnection: (tenant, connectionName, scope) =>
49− Effect.sync(() => void scopeConnections.set(keyFor(tenant, connectionName), scope)),
50− getScopeForConnection: (tenant, connectionName) =>
51− Effect.sync(() => scopeConnections.get(keyFor(tenant, connectionName)) ?? null),
52− putGitHubSourceTokenRef: (tenant, scope, ref) =>
53− Effect.sync(() => void githubSourceTokens.set(keyFor(tenant, scope), ref)),
54− getGitHubSourceTokenRef: (tenant, scope) =>
55− Effect.sync(() => githubSourceTokens.get(keyFor(tenant, scope)) ?? null),
5649 };
5750 };
5851
@@ -94,7 +87,15 @@ export const makeInMemoryArtifactStore = (): ArtifactStore => { · 35 unmodified lines
9487 ),
9588 };
9689 };
97− return { forScope: (address) => Effect.succeed(forScope(scopeAddressStorageKey(address))) };
90+ return {
91+ forScope: (address) => Effect.succeed(forScope(scopeAddressStorageKey(address))),
92+ removeScope: (address) =>
93+ Effect.sync(() => {
94+ const key = scopeAddressStorageKey(address);
95+ scopes.delete(key);
96+ order.delete(key);
97+ }),
98+ };
9899 };
99100
100101 export type { SnapshotId };
packages/react/src/pages/integration-detail.tsx+12−4
@@ -104,6 +104,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 103 unmodified lines
104104 const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null;
105105 useExecutorDocumentTitle(integrationData?.name || namespace);
106106 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";
107+ const isAppsIntegration = integrationData?.kind === "apps";
107108 const currentTab = isBuiltInIntegration ? "tools" : activeTab;
108109 const canRefresh = integrationData?.canRefresh ?? false;
109110 const canRemove = integrationData?.canRemove ?? false;
@@ -153,9 +154,11 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 43 unmodified lines
153154 // Find the plugin edit component based on integration kind
154155 const editPlugin = useMemo(() => {
155156 if (!integrationData) return null;
156− if (namespace === "apps") return integrationPlugins.find((p) => p.key === "apps") ?? null;
157+ if (integrationData.kind === "apps") {
158+ return integrationPlugins.find((p) => p.key === "apps") ?? null;
159+ }
157160 return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null;
158− }, [integrationData, integrationPlugins, namespace]);
161+ }, [integrationData, integrationPlugins]);
159162
160163 // Policies are pre-sorted by the server in evaluation order (owner rank, then
161164 // position ASC). The matcher walks the list and stops at the first hit per
@@ -364,7 +367,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 202 unmodified lines
364367 </div>
365368
366369 <div className="flex shrink-0 items-center gap-2">
367− {!confirmDelete && !isBuiltInIntegration && integrationData && (
370+ {!confirmDelete && !isBuiltInIntegration && !isAppsIntegration && integrationData && (
368371 <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>
369372 Edit
370373 </Button>
@@ -382,6 +385,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 11 unmodified lines
382385 )}
383386
384387 {canRemove &&
388+ !isAppsIntegration &&
385389 (confirmDelete ? (
386390 <div className="flex items-center gap-2">
387391 <Button
@@ -421,7 +425,11 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 33 unmodified lines
421425 >
422426 <div className="shrink-0 border-b border-border/60 px-4 py-2">
423427 <TabsList variant="line">
424− {!isBuiltInIntegration && <TabsTrigger value="accounts">Accounts</TabsTrigger>}
428+ {!isBuiltInIntegration && (
429+ <TabsTrigger value="accounts">
430+ {isAppsIntegration ? "Source" : "Accounts"}
431+ </TabsTrigger>
432+ )}
425433 <TabsTrigger value="tools">Tools</TabsTrigger>
426434 </TabsList>
427435 </div>