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
7const BASE_URL = `http://${HOST}:${PORT}`;7const BASE_URL = `http://${HOST}:${PORT}`;
8const ORIGIN = "http://custom-tools-demo.local";8const ORIGIN = "http://custom-tools-demo.local";
9const DEMO_REPO = "RhysSullivan/executor-custom-tools-demo";9const DEMO_REPO = "RhysSullivan/executor-custom-tools-demo";
10const DEMO_APP = "demo-tools";
10const ADMIN_EMAIL = "admin@custom-tools-demo.local";11const ADMIN_EMAIL = "admin@custom-tools-demo.local";
11const ADMIN_PASSWORD = "admin-pass-123456";12const ADMIN_PASSWORD = "admin-pass-123456";
12const USER_EMAIL = "rhys@custom-tools-demo.local";13const USER_EMAIL = "rhys@custom-tools-demo.local";
@@ -232,7 +233,7 @@ const htmlEscape = (value: string): string => · 219 unmodified lines
232 .replaceAll('"', """);233 .replaceAll('"', """);
233 234
234const toolInvokeCode = (toolName: string, args: unknown): string => `235const toolInvokeCode = (toolName: string, args: unknown): string => `
235const found = await tools.search({ namespace: "apps", query: ${JSON.stringify(toolName)}, limit: 20 });236const found = await tools.search({ namespace: ${JSON.stringify(DEMO_APP)}, query: ${JSON.stringify(toolName)}, limit: 20 });
236const item = found.items.find((candidate) => candidate.path.endsWith(${JSON.stringify(toolName)}));237const item = found.items.find((candidate) => candidate.path.endsWith(${JSON.stringify(toolName)}));
237if (!item) return { ok: false, missing: ${JSON.stringify(toolName)}, found };238if (!item) return { ok: false, missing: ${JSON.stringify(toolName)}, found };
238let fn = tools;239let fn = tools;
@@ -251,10 +252,11 @@ const buildBanner = (input: { · 12 unmodified lines
251 readonly consoleUi: string;252 readonly consoleUi: string;
252}): string => {253}): string => {
253 const sync = curlJson("/api/apps/sources/github/sync", {254 const sync = curlJson("/api/apps/sources/github/sync", {
255 name: DEMO_APP,
254 url: input.repoUrl,256 url: input.repoUrl,
255 token: input.githubToken,257 token: input.githubToken,
256 });258 });
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`;
258 const repoSummary = curlJson("/api/executions", {260 const repoSummary = curlJson("/api/executions", {
259 code: toolInvokeCode("repo-summary", {261 code: toolInvokeCode("repo-summary", {
260 github: input.connectionAddress,262 github: input.connectionAddress,
@@ -290,6 +292,9 @@ Auth: · 29 unmodified lines
290GitHub invocation connection:292GitHub invocation connection:
291 ${input.connectionAddress}293 ${input.connectionAddress}
292 294
295Custom tools app:
296 ${DEMO_APP}
297
293Sync the repo:298Sync the repo:
294 ${sync}299 ${sync}
295 300
apps/host-selfhost/src/apps-route.ts+45−1
@@ -8,7 +8,7 @@ import { · 7 unmodified lines
8 makeScopedExecutor,8 makeScopedExecutor,
9 type Principal,9 type Principal,
10} from "@executor-js/api/server";10} from "@executor-js/api/server";
11import { ToolAddress } from "@executor-js/sdk";11import { IntegrationSlug, ToolAddress } from "@executor-js/sdk";
12 12
13import { SelfHostScopedExecutorSeams } from "./execution";13import { SelfHostScopedExecutorSeams } from "./execution";
14import { SelfHostDb, type SelfHostDbHandle } from "./db/self-host-db";14import { SelfHostDb, type SelfHostDbHandle } from "./db/self-host-db";
@@ -43,6 +43,21 @@ const buildExecutor = (principal: Principal, request: Request) => { · 28 unmodified lines
43 : withOrigin;43 : withOrigin;
44};44};
45 45
46const 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
46export interface SelfHostAppsSyncRouteDeps {61export interface SelfHostAppsSyncRouteDeps {
47 readonly identity: Layer.Layer<IdentityProvider>;62 readonly identity: Layer.Layer<IdentityProvider>;
48 readonly db: SelfHostDbHandle;63 readonly db: SelfHostDbHandle;
@@ -118,5 +133,34 @@ export const makeSelfHostAppsSyncRoute = ({ identity, db }: SelfHostAppsSyncRout · 69 unmodified lines
118 }),133 }),
119 ),134 ),
120 ),135 ),
136 HttpRouter.add(
137 "GET",
138 "/api/apps/sources/github/:slug",
139 routeHandler((request) =>
140 Effect.gen(function* () {
141 const identityProvider = yield* IdentityProvider;
142 const principal = yield* identityProvider.authenticate(request);
143 const slug = yield* sourceSlugFromRoute();
144 const executor = yield* buildExecutor(principal, request);
145 return yield* executor.execute(ToolAddress.make("executor.apps.get_github_source"), {
146 slug,
147 });
148 }),
149 ),
150 ),
151 HttpRouter.add(
152 "DELETE",
153 "/api/apps/sources/github/:slug",
154 routeHandler((request) =>
155 Effect.gen(function* () {
156 const identityProvider = yield* IdentityProvider;
157 const principal = yield* identityProvider.authenticate(request);
158 const slug = yield* sourceSlugFromRoute();
159 const executor = yield* buildExecutor(principal, request);
160 yield* executor.integrations.remove(IntegrationSlug.make(slug));
161 return { removed: true };
162 }),
163 ),
164 ),
121 ).pipe(HttpRouter.provideRequest(services));165 ).pipe(HttpRouter.provideRequest(services));
122};166};
apps/host-selfhost/src/apps-wire.node.test.ts+241−30
@@ -1,13 +1,14 @@
1import { randomBytes } from "node:crypto";1import { randomBytes } from "node:crypto";
2import { mkdtempSync } from "node:fs";2import { existsSync, mkdtempSync } from "node:fs";
3import { createServer, type IncomingMessage, type ServerResponse } from "node:http";3import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4import { createServer as createNetServer } from "node:net";4import { createServer as createNetServer } from "node:net";
5import { tmpdir } from "node:os";5import { tmpdir } from "node:os";
6import { join } from "node:path";6import { join } from "node:path";
7 7
8import { afterAll, beforeAll, expect, test } from "@effect/vitest";8import { afterAll, beforeAll, expect, test } from "@effect/vitest";
9import { Effect, Schema } from "effect";9import { Effect, Option, Schema } from "effect";
10import { createEmulator, type Emulator } from "@executor-js/emulate";10import { createEmulator, type Emulator } from "@executor-js/emulate";
11import { createClient } from "@libsql/client";
11 12
12const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-"));13const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-"));
13const dbPath = join(dataDir, "data.db");14const dbPath = join(dataDir, "data.db");
@@ -21,6 +22,9 @@ const OWNER = "syncer"; · 7 unmodified lines
21const REPO = "custom-tools";22const REPO = "custom-tools";
22const REPO_FULL_NAME = `${OWNER}/${REPO}`;23const REPO_FULL_NAME = `${OWNER}/${REPO}`;
23const GITHUB_CONNECTION = "tools.github.user.main";24const GITHUB_CONNECTION = "tools.github.user.main";
25const APP_SLUG = "custom-tools-app";
26const SCHEMA_APP_SLUG = "schema-tools-app";
27const APPROVAL_APP_SLUG = "approval-tools-app";
24 28
25interface TestHttpServer {29interface TestHttpServer {
26 readonly baseUrl: string;30 readonly baseUrl: string;
@@ -33,7 +37,7 @@ interface SyncResult { · 6 unmodified lines
33 readonly upstreamSha?: string;37 readonly upstreamSha?: string;
34 readonly tools: readonly string[];38 readonly tools: readonly string[];
35 readonly skipped: readonly { readonly path: string; readonly reason: string }[];39 readonly skipped: readonly { readonly path: string; readonly reason: string }[];
36 readonly errors?: readonly unknown[];40 readonly errors?: readonly { readonly message?: string }[];
37}41}
38 42
39interface ToolRow {43interface ToolRow {
@@ -46,6 +50,14 @@ interface ConnectionRow { · 6 unmodified lines
46 readonly address: string;50 readonly address: string;
47}51}
48 52
53interface IntegrationRow {
54 readonly slug: string;
55 readonly name: string;
56 readonly kind: string;
57 readonly canRemove: boolean;
58 readonly displayUrl?: string;
59}
60
49interface ExecuteResponse {61interface ExecuteResponse {
50 readonly status: "completed" | "paused";62 readonly status: "completed" | "paused";
51 readonly text: string;63 readonly text: string;
@@ -194,6 +206,48 @@ const postJson = <T>(path: string, body: unknown, expectedStatus = 200): Promise · 142 unmodified lines
194 expectedStatus,206 expectedStatus,
195 );207 );
196 208
209const 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
219const 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
230const sourceStorageKey = (scope: string): string =>
231 `v2-${Buffer.from(JSON.stringify([TEST_ORG, scope]), "utf8").toString("hex")}`;
232
233const sourceTokenItemId = (scope: string): string =>
234 `apps:github-source:${TEST_ORG}:${scope}:token`;
235
236const DbJsonObject = Schema.Record(Schema.String, Schema.Unknown);
237const DbJsonObjectFromString = Schema.fromJsonString(DbJsonObject);
238const decodeDbJsonObject = Schema.decodeUnknownOption(DbJsonObject);
239const decodeDbJsonObjectFromString = Schema.decodeUnknownOption(DbJsonObjectFromString);
240
241const 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
197const githubFetch = async <T>(251const githubFetch = async <T>(
198 emulator: Emulator,252 emulator: Emulator,
199 token: string,253 token: string,
@@ -236,7 +290,7 @@ const putRepoFiles = async ( · 36 unmodified lines
236 const tree = await githubFetch<{ sha: string }>(290 const tree = await githubFetch<{ sha: string }>(
237 emulator,291 emulator,
238 token,292 token,
239 `/repos/${OWNER}/${REPO}/git/trees`,293 `/repos/${OWNER}/${repo}/git/trees`,
240 {294 {
241 method: "POST",295 method: "POST",
242 body: JSON.stringify({296 body: JSON.stringify({
@@ -252,7 +306,7 @@ const putRepoFiles = async ( · 9 unmodified lines
252 const commit = await githubFetch<{ sha: string }>(306 const commit = await githubFetch<{ sha: string }>(
253 emulator,307 emulator,
254 token,308 token,
255 `/repos/${OWNER}/${REPO}/git/commits`,309 `/repos/${OWNER}/${repo}/git/commits`,
256 {310 {
257 method: "POST",311 method: "POST",
258 body: JSON.stringify({312 body: JSON.stringify({
@@ -469,19 +523,36 @@ const registerGitHubIntegration = async (emulator: Emulator, token: string): Pro · 210 unmodified lines
469 523
470const sourceUrl = (repo = REPO_FULL_NAME): string => `https://github.com/${repo}`;524const sourceUrl = (repo = REPO_FULL_NAME): string => `https://github.com/${repo}`;
471 525
472const syncSource = (526const addSource = (
473 input: { readonly repo?: string; readonly token?: string } = {},527 input: { readonly name?: string; readonly repo?: string; readonly token?: string } = {},
474): Promise<SyncResult> =>528): Promise<SyncResult> =>
475 postJson<SyncResult>("/api/apps/sources/github/sync", {529 postJson<SyncResult>("/api/apps/sources/github/sync", {
530 name: input.name ?? APP_SLUG,
476 url: sourceUrl(input.repo),531 url: sourceUrl(input.repo),
477 ...(input.token ? { token: input.token } : {}),532 ...(input.token ? { token: input.token } : {}),
478 });533 });
479 534
480const listAppTools = (): Promise<readonly ToolRow[]> =>535const syncSource = (
481 requestJson<readonly ToolRow[]>("/api/tools?integration=apps", {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
543const listAppTools = (app = APP_SLUG): Promise<readonly ToolRow[]> =>
544 requestJson<readonly ToolRow[]>(`/api/tools?integration=${encodeURIComponent(app)}`, {
482 headers: jsonHeaders,545 headers: jsonHeaders,
483 });546 });
484 547
548const 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
553const removeSource = (app: string): Promise<{ readonly removed: boolean }> =>
554 deleteJson(`/api/apps/sources/github/${encodeURIComponent(app)}`);
555
485const execute = (code: string): Promise<ExecuteResponse> =>556const execute = (code: string): Promise<ExecuteResponse> =>
486 postJson<ExecuteResponse>("/api/executions", { code, autoApprove: true });557 postJson<ExecuteResponse>("/api/executions", { code, autoApprove: true });
487 558
@@ -495,8 +566,8 @@ const executeResult = async (code: string): Promise<unknown> => { · 7 unmodified lines
495 return response.structured.result;566 return response.structured.result;
496};567};
497 568
498const callAppToolCode = (toolName: string, args: unknown): string => `569const callAppToolCode = (toolName: string, args: unknown, namespace = APP_SLUG): string => `
499const found = await tools.search({ namespace: "apps", query: ${JSON.stringify(toolName)}, limit: 20 });570const found = await tools.search({ namespace: ${JSON.stringify(namespace)}, query: ${JSON.stringify(toolName)}, limit: 20 });
500const item = found.items.find((candidate) => candidate.path.endsWith(${JSON.stringify(toolName)}));571const item = found.items.find((candidate) => candidate.path.endsWith(${JSON.stringify(toolName)}));
501if (!item) return { ok: false, missing: ${JSON.stringify(toolName)}, found };572if (!item) return { ok: false, missing: ${JSON.stringify(toolName)}, found };
502let fn = tools;573let fn = tools;
@@ -549,10 +620,10 @@ test("custom tool connection schema updates when connections change after sync", · 46 unmodified lines
549 expect(initialConnections).toEqual([]);620 expect(initialConnections).toEqual([]);
550 await putRepoFiles(github, token!, initialFiles());621 await putRepoFiles(github, token!, initialFiles());
551 622
552 const published = await syncSource();623 const published = await addSource({ name: SCHEMA_APP_SLUG });
553 expect(published.status).toBe("published");624 expect(published.status).toBe("published");
554 625
555 const listed = await listAppTools();626 const listed = await listAppTools(SCHEMA_APP_SLUG);
556 const dealSync = listed.find((tool) => tool.name === "deal-pipeline-sync");627 const dealSync = listed.find((tool) => tool.name === "deal-pipeline-sync");
557 expect(dealSync).toBeTruthy();628 expect(dealSync).toBeTruthy();
558 const beforeConnection = await requestJson<{629 const beforeConnection = await requestJson<{
@@ -580,10 +651,14 @@ test("custom tool connection schema updates when connections change after sync", · 21 unmodified lines
580 expect(afterConnection.inputSchema.required ?? []).not.toContain("github");651 expect(afterConnection.inputSchema.required ?? []).not.toContain("github");
581 652
582 const defaultInvoke = (await executeResult(653 const defaultInvoke = (await executeResult(
583 callAppToolCode("deal-pipeline-sync", {654 callAppToolCode(
584 owner: OWNER,655 "deal-pipeline-sync",
585 repo: REPO,656 {
586 }),657 owner: OWNER,
658 repo: REPO,
659 },
660 SCHEMA_APP_SLUG,
661 ),
587 )) as { result?: { ok?: boolean; data?: { synced?: number }; error?: unknown } };662 )) as { result?: { ok?: boolean; data?: { synced?: number }; error?: unknown } };
588 expect(defaultInvoke.result?.ok).toBe(true);663 expect(defaultInvoke.result?.ok).toBe(true);
589 expect(defaultInvoke.result?.data?.synced).toBe(0);664 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
609 });684 });
610 expect(unauthorized.status).toBe(401);685 expect(unauthorized.status).toBe(401);
611 686
612 const published = await syncSource();687 const published = await addSource({ name: APP_SLUG, token: token! });
613 expect(published.status).toBe("published");688 expect(published.status).toBe("published");
614 expect(JSON.stringify(published)).not.toContain(token!);689 expect(JSON.stringify(published)).not.toContain(token!);
615 expect(published.tools).toEqual(["deal-pipeline-sync", "find-deal-docs"]);690 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
621 expect(tokenUpToDate.status).toBe("up-to-date");696 expect(tokenUpToDate.status).toBe("up-to-date");
622 expect(JSON.stringify(tokenUpToDate)).not.toContain(token!);697 expect(JSON.stringify(tokenUpToDate)).not.toContain(token!);
623 698
624 const sourcesList = await requestJson<{ sources: readonly { hasToken: boolean }[] }>(699 const sourcesList = await requestJson<{
625 "/api/apps/sources/github",700 sources: readonly { readonly slug: string; readonly hasToken: boolean }[];
626 { headers: jsonHeaders },701 }>("/api/apps/sources/github", { headers: jsonHeaders });
627 );702 expect(sourcesList.sources.find((source) => source.slug === APP_SLUG)?.hasToken).toBe(true);
628 expect(sourcesList.sources[0]?.hasToken).toBe(true);
629 expect(JSON.stringify(sourcesList)).not.toContain(token!);703 expect(JSON.stringify(sourcesList)).not.toContain(token!);
630 704
631 const listed = await listAppTools();705 const listed = await listAppTools();
632 expect(listed.map((tool) => tool.name).sort()).toEqual(["deal-pipeline-sync", "find-deal-docs"]);706 expect(listed.map((tool) => tool.name).sort()).toEqual(["deal-pipeline-sync", "find-deal-docs"]);
633 707
634 const searchResult = await executeResult(708 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);`,
636 );710 );
637 expect(JSON.stringify(searchResult)).toContain("deal-pipeline-sync");711 expect(JSON.stringify(searchResult)).toContain("deal-pipeline-sync");
638 712
@@ -760,6 +834,139 @@ test("GitHub source sync publishes and invokes custom tools through self-host HT · 121 unmodified lines
760 expect(afterSkipped.some((tool) => tool.address.includes("workflow"))).toBe(false);834 expect(afterSkipped.some((tool) => tool.address.includes("workflow"))).toBe(false);
761});835});
762 836
837test("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
763test("bridged integration calls inherit the caller approval handler", async () => {970test("bridged integration calls inherit the caller approval handler", async () => {
764 const credential = await github.credentials.mint({971 const credential = await github.credentials.mint({
765 type: "api-key",972 type: "api-key",
@@ -771,7 +978,7 @@ test("bridged integration calls inherit the caller approval handler", async () = · 5 unmodified lines
771 await registerGitHubIntegration(github, token!);978 await registerGitHubIntegration(github, token!);
772 await createIssue(github, token!, "Approval-gated issue");979 await createIssue(github, token!, "Approval-gated issue");
773 await putRepoFiles(github, token!, approvalFiles());980 await putRepoFiles(github, token!, approvalFiles());
774 const published = await syncSource({ token: token! });981 const published = await addSource({ name: APPROVAL_APP_SLUG, token: token! });
775 expect(published.status).toBe("published");982 expect(published.status).toBe("published");
776 983
777 const githubTools = await requestJson<readonly ToolRow[]>("/api/tools?integration=github", {984 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
792 ).length;999 ).length;
793 1000
794 const response = await executeWithApprovalPause(1001 const response = await executeWithApprovalPause(
795 callAppToolCode("approval-bridge", {1002 callAppToolCode(
796 github: GITHUB_CONNECTION,1003 "approval-bridge",
797 owner: OWNER,1004 {
798 repo: REPO,1005 github: GITHUB_CONNECTION,
799 }),1006 owner: OWNER,
1007 repo: REPO,
1008 },
1009 APPROVAL_APP_SLUG,
1010 ),
800 );1011 );
801 expect(response.status).toBe("paused");1012 expect(response.status).toBe("paused");
802 1013
apps/host-selfhost/src/custom-tools-console.web.test.ts+35−1
@@ -6,7 +6,9 @@ import appsClientPlugin, { · 5 unmodified lines
6 type CustomToolsFetch,6 type CustomToolsFetch,
7 appsIntegrationPlugin,7 appsIntegrationPlugin,
8 formatSyncErrors,8 formatSyncErrors,
9 getCustomToolSource,
9 listCustomToolSources,10 listCustomToolSources,
11 removeCustomToolSource,
10 syncCustomToolSource,12 syncCustomToolSource,
11 syncStatusLabel,13 syncStatusLabel,
12 validateGitHubSourceUrl,14 validateGitHubSourceUrl,
@@ -48,6 +50,7 @@ describe("custom tools console client", () => { · 35 unmodified lines
48 50
49 it("surfaces successful sync and source detail data", async () => {51 it("surfaces successful sync and source detail data", async () => {
50 let syncBody = "";52 let syncBody = "";
53 let removed = false;
51 const fetchImpl: CustomToolsFetch = async (input, init) => {54 const fetchImpl: CustomToolsFetch = async (input, init) => {
52 const url = String(input);55 const url = String(input);
53 if (url.endsWith("/sync")) {56 if (url.endsWith("/sync")) {
@@ -60,10 +63,34 @@ describe("custom tools console client", () => { · 6 unmodified lines
60 skipped: [],63 skipped: [],
61 });64 });
62 }65 }
66 if (url.endsWith("/demo-tools")) {
67 if (init?.method === "DELETE") {
68 removed = true;
69 return jsonResponse({ removed: true });
70 }
71 return jsonResponse({
72 source: {
73 slug: "demo-tools",
74 name: "demo-tools",
75 scope: "demo-tools",
76 url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
77 repo: "RhysSullivan/executor-custom-tools-demo",
78 ref: "main",
79 hasToken: true,
80 upstreamSha: "abc123",
81 snapshotId: "snap1",
82 publishedAt: "2026-07-06T12:00:00.000Z",
83 tools: ["repo-summary", "stale-issues"],
84 skipped: [],
85 },
86 });
87 }
63 return jsonResponse({88 return jsonResponse({
64 sources: [89 sources: [
65 {90 {
66 scope: "github:RhysSullivan/executor-custom-tools-demo",91 slug: "demo-tools",
92 name: "demo-tools",
93 scope: "demo-tools",
67 url: "https://github.com/RhysSullivan/executor-custom-tools-demo",94 url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
68 repo: "RhysSullivan/executor-custom-tools-demo",95 repo: "RhysSullivan/executor-custom-tools-demo",
69 ref: "main",96 ref: "main",
@@ -80,18 +107,25 @@ describe("custom tools console client", () => { · 10 unmodified lines
80 107
81 const syncResult = await syncCustomToolSource(108 const syncResult = await syncCustomToolSource(
82 {109 {
110 name: "demo-tools",
83 url: "https://github.com/RhysSullivan/executor-custom-tools-demo",111 url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
84 token: "ghp_demo",112 token: "ghp_demo",
85 },113 },
86 fetchImpl,114 fetchImpl,
87 );115 );
88 const listed = await listCustomToolSources(fetchImpl);116 const listed = await listCustomToolSources(fetchImpl);
117 const detail = await getCustomToolSource("demo-tools", fetchImpl);
118 const remove = await removeCustomToolSource("demo-tools", fetchImpl);
89 119
90 expect(syncStatusLabel(syncResult)).toBe("Published 2 tools.");120 expect(syncStatusLabel(syncResult)).toBe("Published 2 tools.");
91 expect(syncBody).toContain("ghp_demo");121 expect(syncBody).toContain("ghp_demo");
122 expect(syncBody).toContain("demo-tools");
92 expect(syncResult.tools).toEqual(["repo-summary", "stale-issues"]);123 expect(syncResult.tools).toEqual(["repo-summary", "stale-issues"]);
124 expect(detail.source?.slug).toBe("demo-tools");
93 expect(listed.sources[0]?.hasToken).toBe(true);125 expect(listed.sources[0]?.hasToken).toBe(true);
94 expect(listed.sources[0]?.tools).toEqual(["repo-summary", "stale-issues"]);126 expect(listed.sources[0]?.tools).toEqual(["repo-summary", "stale-issues"]);
127 expect(remove).toEqual({ removed: true });
128 expect(removed).toBe(true);
95 });129 });
96 130
97 it("renders failed sync errors readably", async () => {131 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
1927 if (!existing.can_remove) {1927 if (!existing.can_remove) {
1928 return yield* new IntegrationRemovalNotAllowedError({ slug });1928 return yield* new IntegrationRemovalNotAllowedError({ slug });
1929 }1929 }
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 }
1930 // Drop owned connections / tools / definitions for this integration.1943 // Drop owned connections / tools / definitions for this integration.
1931 const where = (b: AnyCb) => b("integration", "=", String(slug));1944 const where = (b: AnyCb) => b("integration", "=", String(slug));
1932 yield* core.deleteMany("tool", { where });1945 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
523 readonly connection: ConnectionRef;523 readonly connection: ConnectionRef;
524}524}
525 525
526export interface IntegrationLifecycleInput<TStore = unknown> {
527 readonly ctx: PluginCtx<TStore>;
528 readonly integration: IntegrationRecord;
529}
530
526export interface ConfigureIntegrationHandlerInput<TStore = unknown> {531export interface ConfigureIntegrationHandlerInput<TStore = unknown> {
527 readonly ctx: PluginCtx<TStore>;532 readonly ctx: PluginCtx<TStore>;
528 readonly integration: IntegrationSlug;533 readonly integration: IntegrationSlug;
@@ -668,6 +673,12 @@ export interface PluginSpec< · 139 unmodified lines
668 input: ConnectionLifecycleInput<TStore>,673 input: ConnectionLifecycleInput<TStore>,
669 ) => Effect.Effect<void, unknown>;674 ) => Effect.Effect<void, unknown>;
670 675
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
671 /** Core-dispatched integration configuration (beyond auth). */682 /** Core-dispatched integration configuration (beyond auth). */
672 readonly integrationConfigure?: IntegrationConfigureDecl<TStore>;683 readonly integrationConfigure?: IntegrationConfigureDecl<TStore>;
673 684
packages/plugins/apps/src/api.ts+0−3
@@ -3,9 +3,6 @@ export { · 2 unmodified lines
3 appsPlugin,3 appsPlugin,
4 APPS_INTEGRATION_SLUG,4 APPS_INTEGRATION_SLUG,
5 APPS_PLUGIN_ID,5 APPS_PLUGIN_ID,
6 APPS_CONNECTION_PREFIX,
7 connectionNameForScope,
8 scopeFromConnection,
9 type AppsPluginOptions,6 type AppsPluginOptions,
10} from "./plugin/apps-plugin";7} from "./plugin/apps-plugin";
11export {8export {
packages/plugins/apps/src/backing/git-artifact-store.ts+14−1
@@ -1,5 +1,5 @@
1import { execFile } from "node:child_process";1import { execFile } from "node:child_process";
2import { mkdir } from "node:fs/promises";2import { mkdir, rm } from "node:fs/promises";
3import { join } from "node:path";3import { join } from "node:path";
4 4
5import { Effect } from "effect";5import { Effect } from "effect";
@@ -301,5 +301,18 @@ export const makeGitArtifactStore = (options: GitArtifactStoreOptions): Artifact · 295 unmodified lines
301 cause,301 cause,
302 }),302 }),
303 }),303 }),
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 }),
304 };317 };
305};318};
packages/plugins/apps/src/backing/libsql-scope-db.ts+19−1
@@ -1,4 +1,4 @@
1import { mkdirSync } from "node:fs";1import { mkdirSync, rmSync } from "node:fs";
2import { join, resolve } from "node:path";2import { join, resolve } from "node:path";
3 3
4import { createClient, type Client } from "@libsql/client";4import { createClient, type Client } from "@libsql/client";
@@ -160,6 +160,24 @@ export const makeLibsqlScopeDb = (options: LibsqlScopeDbOptions): ScopeDb => { · 155 unmodified lines
160 cause,160 cause,
161 }),161 }),
162 }),162 }),
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 }),
163 close: () =>181 close: () =>
164 Effect.sync(() => {182 Effect.sync(() => {
165 for (const client of clients.values()) client.close();183 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
7 7
8import { makeLibsqlScopeDb } from "./libsql-scope-db";8import { makeLibsqlScopeDb } from "./libsql-scope-db";
9import { makeSqliteAppsStore } from "./sqlite-apps-store";9import { makeSqliteAppsStore } from "./sqlite-apps-store";
10import { DESCRIPTOR_VERSION, type AppDescriptor } from "../pipeline/descriptor";
10import { scopeAddress } from "../seams/scope-address";11import { scopeAddress } from "../seams/scope-address";
11 12
12const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);13const 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
46 await run(db.close());47 await run(db.close());
47 });48 });
48 49
49 it("routes distinct scopes through the explicit connection->scope mapping", async () => {50 it("stores distinct descriptor scopes independently", async () => {
50 const store = makeSqliteAppsStore({ path: ":memory:" });51 const store = makeSqliteAppsStore({ path: ":memory:" });
51 // Both scopes' connection names normalize to the SAME identifier52 const descriptor = (scope: string): AppDescriptor => ({
52 // ("appsMyScope"), but the explicit mapping keys by the ACTUAL stored name,53 version: DESCRIPTOR_VERSION,
53 // so each scope's connection maps to the right scope. Simulate two DISTINCT54 tenant: "org",
54 // connection names being recorded for two distinct scopes.55 scope,
55 await run(store.putScopeForConnection("org", "appsMyScopeDash", "my-scope"));56 description: "test",
56 await run(store.putScopeForConnection("org", "appsMyScopeUnderscore", "my_scope"));57 snapshotId: `${scope}-snapshot`,
57 58 toolchain: { bundler: "esbuild", bundlerVersion: "test", target: "test" },
58 expect(await run(store.getScopeForConnection("org", "appsMyScopeDash"))).toBe("my-scope");59 tools: [],
59 expect(await run(store.getScopeForConnection("org", "appsMyScopeUnderscore"))).toBe("my_scope");60 workflows: [],
60 // An unmapped name returns null (caller falls back to legacy parse).61 ui: [],
61 expect(await run(store.getScopeForConnection("org", "unknown"))).toBeNull();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");
62 });71 });
63});72});
packages/plugins/apps/src/backing/sqlite-apps-store.ts+5−48
@@ -7,19 +7,17 @@ import { Effect, Schema } from "effect"; · 6 unmodified lines
7import { StorageError, type StorageFailure } from "@executor-js/sdk";7import { StorageError, type StorageFailure } from "@executor-js/sdk";
8 8
9import type { AppDescriptor } from "../pipeline/descriptor";9import type { AppDescriptor } from "../pipeline/descriptor";
10import type { AppsStore, GitHubSourceTokenRef } from "../plugin/store";10import type { AppsStore } from "../plugin/store";
11 11
12// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
13// SQLite-backed AppsStore (self-hosted). One small SQLite file stores the13// 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.
15// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
16 16
17const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`);17const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`);
18 18
19const SCHEMA = `19const SCHEMA = `
20CREATE 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));20CREATE 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));
21CREATE TABLE IF NOT EXISTS scope_connections (tenant TEXT NOT NULL, connection_name TEXT NOT NULL, scope TEXT NOT NULL, PRIMARY KEY (tenant, connection_name));
22CREATE 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));
23`;21`;
24 22
25const storageFail = (message: string, cause: unknown): StorageFailure =>23const storageFail = (message: string, cause: unknown): StorageFailure =>
@@ -118,57 +116,16 @@ export const makeSqliteAppsStore = (options: SqliteAppsStoreOptions): AppsStore · 92 unmodified lines
118 ),116 ),
119 ),117 ),
120 ),118 ),
121 putScopeForConnection: (tenant, connectionName, scope) =>119 removeDescriptor: (tenant, scope) =>
122 Effect.tryPromise({120 Effect.tryPromise({
123 try: async () => {121 try: async () => {
124 await init();122 await init();
125 await client.execute({123 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",124 sql: "DELETE FROM descriptors WHERE tenant = ? AND 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 = ?",
161 args: [tenant, scope],125 args: [tenant, scope],
162 });126 });
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 };
170 },127 },
171 catch: (cause) => storageFail("getGitHubSourceTokenRef failed", cause),128 catch: (cause) => storageFail("removeDescriptor failed", cause),
172 }),129 }),
173 };130 };
174};131};
packages/plugins/apps/src/index.ts+5−2
@@ -34,9 +34,12 @@ export { · 33 unmodified lines
34 type AppsStoreDeps,34 type AppsStoreDeps,
35 type GitHubSourceTokenRef,35 type GitHubSourceTokenRef,
36 descriptorCollection,36 descriptorCollection,
37 githubSourceTokenCollection,
38 scopeConnectionCollection,
39} from "./plugin/store";37} from "./plugin/store";
38export {
39 slugifyCustomToolsAppName,
40 validateCustomToolsAppSlug,
41 CUSTOM_TOOLS_APP_SLUG_PATTERN,
42} from "./source/app-slug";
40export {43export {
41 buildBridge,44 buildBridge,
42 rootsFor,45 rootsFor,
packages/plugins/apps/src/plugin/apps-plugin.test.ts+40−7
@@ -4,8 +4,9 @@ import { join } from "node:path"; · 3 unmodified lines
4 4
5import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
6import { Effect } from "effect";6import { Effect } from "effect";
7import { IntegrationSlug } from "@executor-js/sdk";
7 8
8import { appsPlugin, connectionNameForScope } from "./apps-plugin";9import { appsPlugin } from "./apps-plugin";
9import { makeSelfHostAppsRuntime } from "./self-host-runtime";10import { makeSelfHostAppsRuntime } from "./self-host-runtime";
10import { makeInMemoryAppsStore, makeTestResolver } from "../testing";11import { makeInMemoryAppsStore, makeTestResolver } from "../testing";
11 12
@@ -62,13 +63,43 @@ describe("appsPlugin custom-tools contract", () => { · 50 unmodified lines
62 });63 });
63 const runtime = host.runtime;64 const runtime = host.runtime;
64 const plugin = appsPlugin({ runtime });65 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 };
66 95
67 await run(runtime.publish({ scope: "rhys", files: prototypeFileSet() }));96 await run(runtime.publish({ scope: "rhys", files: prototypeFileSet() }));
68 97
69 const resolved = await run(98 const resolved = await run(
70 plugin.resolveTools!({99 plugin.resolveTools!({
71 connection: { name: appConnection },100 ctx,
101 config: appConfig,
102 connection: { name: "main" },
72 } as never),103 } as never),
73 );104 );
74 const syncTool = resolved.tools.find((tool) => String(tool.name) === "deal-pipeline-sync");105 const syncTool = resolved.tools.find((tool) => String(tool.name) === "deal-pipeline-sync");
@@ -80,10 +111,11 @@ describe("appsPlugin custom-tools contract", () => { · 5 unmodified lines
80 111
81 const projected = await run(112 const projected = await run(
82 plugin.projectToolSchema!({113 plugin.projectToolSchema!({
83 ctx: { owner: { tenant: "org" } },114 ctx,
84 toolRow: {115 toolRow: {
85 name: "deal-pipeline-sync",116 name: "deal-pipeline-sync",
86 connection: appConnection,117 integration: appIntegration,
118 connection: "main",
87 },119 },
88 inputSchema: syncTool!.inputSchema,120 inputSchema: syncTool!.inputSchema,
89 outputSchema: syncTool!.outputSchema,121 outputSchema: syncTool!.outputSchema,
@@ -100,10 +132,11 @@ describe("appsPlugin custom-tools contract", () => { · 10 unmodified lines
100 132
101 const output = await run(133 const output = await run(
102 plugin.invokeTool!({134 plugin.invokeTool!({
103 ctx: {},135 ctx,
104 toolRow: {136 toolRow: {
105 name: "deal-pipeline-sync",137 name: "deal-pipeline-sync",
106 connection: appConnection,138 integration: appIntegration,
139 connection: "main",
107 },140 },
108 args: {141 args: {
109 crm: "tools.dealcloud.user.crm-main",142 crm: "tools.dealcloud.user.crm-main",
packages/plugins/apps/src/plugin/apps-plugin.ts+354−179
@@ -1,36 +1,48 @@
1import { Effect, Result } from "effect";1import { Effect, Result } from "effect";
2 2
3import {3import {
4 AuthTemplateSlug,
4 definePlugin,5 definePlugin,
5 tool,
6 ToolName,
7 IntegrationSlug,6 IntegrationSlug,
8 AuthTemplateSlug,
9 connectionIdentifier,
10 ProviderItemId,7 ProviderItemId,
11 ProviderKey,8 ProviderKey,
9 ToolName,
10 connectionIdentifier,
11 tool,
12 type IntegrationRecord,
13 type InvokeToolInput,
12 type PluginCtx,14 type PluginCtx,
13 type ResolveToolsInput,15 type ResolveToolsInput,
14 type ResolveToolsResult,16 type ResolveToolsResult,
15 type InvokeToolInput,
16 type ToolDef,17 type ToolDef,
17} from "@executor-js/sdk";18} from "@executor-js/sdk";
18 19
19import type { AppsRuntime } from "./runtime";
20import { makeAppsStore } from "./store";
21import type { ClientResolver, ConnectionCandidate } from "./bindings";
22import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor";20import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor";
23import { PublishError } from "../pipeline/discover";21import { PublishError } from "../pipeline/discover";
24import {22import {
25 parseGitHubSourceUrl,23 parseGitHubSourceUrl,
26 syncGitHubSource,24 syncGitHubSource,
25 type GitHubSkippedArtifact,
27 type GitHubSyncResult,26 type GitHubSyncResult,
28} from "../source/github-source";27} from "../source/github-source";
28import { slugifyCustomToolsAppName, validateCustomToolsAppSlug } from "../source/app-slug";
29import type { AppsRuntime, GitHubCustomToolsSourceSummary } from "./runtime";
30import { makeAppsStore, type AppDescriptorRecord, type GitHubSourceTokenRef } from "./store";
31import type { ClientResolver, ConnectionCandidate } from "./bindings";
29 32
30export const APPS_INTEGRATION_SLUG = "apps";33export const APPS_INTEGRATION_SLUG = "apps";
31export const APPS_PLUGIN_ID = "apps";34export const APPS_PLUGIN_ID = "apps";
32 35
33const DEFAULT_CATALOG_SCOPE = "default";36const APP_CONNECTION_NAME = connectionIdentifier("main");
37
38interface 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}
34 46
35export interface AppsPluginOptions {47export interface AppsPluginOptions {
36 readonly runtime: AppsRuntime;48 readonly runtime: AppsRuntime;
@@ -48,6 +60,9 @@ interface AppsStoreShape { · 11 unmodified lines
48const isRecord = (value: unknown): value is Record<string, unknown> =>60const isRecord = (value: unknown): value is Record<string, unknown> =>
49 value !== null && typeof value === "object" && !Array.isArray(value);61 value !== null && typeof value === "object" && !Array.isArray(value);
50 62
63const asString = (value: unknown): string | undefined =>
64 typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
65
51const unique = (values: readonly string[]): readonly string[] => [...new Set(values)];66const unique = (values: readonly string[]): readonly string[] => [...new Set(values)];
52 67
53const syncFailure = (message: string, path?: string): GitHubSyncResult => ({68const syncFailure = (message: string, path?: string): GitHubSyncResult => ({
@@ -63,9 +78,6 @@ const syncFailure = (message: string, path?: string): GitHubSyncResult => ({ · 9 unmodified lines
63 ],78 ],
64});79});
65 80
66const sourceScopeFor = (repo: string): string =>
67 String(connectionIdentifier(`github ${repo}`, "githubSource"));
68
69const sourceTokenItemId = (tenant: string, scope: string): ProviderItemId =>81const sourceTokenItemId = (tenant: string, scope: string): ProviderItemId =>
70 ProviderItemId.make(`apps:github-source:${tenant}:${scope}:token`);82 ProviderItemId.make(`apps:github-source:${tenant}:${scope}:token`);
71 83
@@ -74,6 +86,30 @@ const configBaseUrl = (config: unknown): string | undefined => · 2 unmodified lines
74 ? config.baseUrl86 ? config.baseUrl
75 : undefined;87 : undefined;
76 88
89const 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
77const projectInputSchema = (113const projectInputSchema = (
78 schema: unknown,114 schema: unknown,
79 integrations: Readonly<Record<string, IntegrationDecl>>,115 integrations: Readonly<Record<string, IntegrationDecl>>,
@@ -113,6 +149,16 @@ const projectInputSchema = ( · 33 unmodified lines
113 return projected;149 return projected;
114};150};
115 151
152const 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
116export const appsPlugin = definePlugin((options?: AppsPluginOptions) => {162export const appsPlugin = definePlugin((options?: AppsPluginOptions) => {
117 const runtime = options?.runtime as AppsRuntime;163 const runtime = options?.runtime as AppsRuntime;
118 const makeResolver = options?.makeResolver;164 const makeResolver = options?.makeResolver;
@@ -122,91 +168,274 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 3 unmodified lines
122 return tenant === undefined ? "org" : String(tenant);168 return tenant === undefined ? "org" : String(tenant);
123 };169 };
124 170
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
131 const storeSourceToken = (171 const storeSourceToken = (
132 ctx: PluginCtx<AppsStoreShape>,172 ctx: PluginCtx<AppsStoreShape>,
133 tenant: string,173 tenant: string,
134 scope: string,174 scope: string,
135 token: string,175 token: string,
136 ): Effect.Effect<boolean> =>176 ): Effect.Effect<GitHubSourceTokenRef | null> =>
137 Effect.gen(function* () {177 Effect.gen(function* () {
138 const itemId = sourceTokenItemId(tenant, scope);178 const itemId = sourceTokenItemId(tenant, scope);
139 const provider = yield* ctx.providers.setDefault(itemId, token).pipe(Effect.result);179 const provider = yield* ctx.providers.setDefault(itemId, token).pipe(Effect.result);
140 if (Result.isFailure(provider)) return false;180 if (Result.isFailure(provider)) return null;
141 const stored = yield* runtime.deps.store181 return {
142 .putGitHubSourceTokenRef(tenant, scope, {182 provider: String(provider.success),
143 provider: String(provider.success),183 itemId: String(itemId),
144 itemId: String(itemId),184 updatedAt: Date.now(),
145 updatedAt: Date.now(),185 };
146 })
147 .pipe(Effect.result);
148 return Result.isSuccess(stored);
149 });186 });
150 187
151 const storedSourceToken = (188 const storedSourceToken = (
152 ctx: PluginCtx<AppsStoreShape>,189 ctx: PluginCtx<AppsStoreShape>,
153 tenant: string,190 ref: GitHubSourceTokenRef | undefined,
154 scope: string,191 ): Effect.Effect<string | null> => {
155 ): Effect.Effect<string | null> =>192 if (!ref) return Effect.succeed(null);
156 Effect.gen(function* () {193 return ctx.providers
157 const ref = yield* runtime.deps.store194 .get(ProviderKey.make(ref.provider), ProviderItemId.make(ref.itemId))
158 .getGitHubSourceTokenRef(tenant, scope)195 .pipe(Effect.orElseSucceed(() => null));
159 .pipe(Effect.orElseSucceed(() => null));196 };
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 });
165 197
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>) =>
167 Effect.gen(function* () {207 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);
185 const conns = yield* ctx.connections208 const conns = yield* ctx.connections
186 .list({ integration: slug })209 .list({ integration: slug })
187 .pipe(Effect.orElseSucceed(() => []));210 .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))) {
189 yield* ctx.connections.create({212 yield* ctx.connections.create({
190 owner: "user",213 owner: "user",
191 name: connName,214 name: APP_CONNECTION_NAME,
192 integration: slug,215 integration: slug,
193 template: AuthTemplateSlug.make("none"),216 template: AuthTemplateSlug.make("none"),
194 value: "",217 value: "",
195 });218 });
196 }219 }
197 yield* runtime.deps.store220 return { owner: "user" as const, integration: slug, name: APP_CONNECTION_NAME };
198 .putScopeForConnection(tenant, String(rawName), scope)221 });
199 .pipe(Effect.orElseSucceed(() => undefined));222
200 yield* runtime.deps.store223 const refreshAppConnection = (slug: IntegrationSlug, ctx: PluginCtx<AppsStoreShape>) =>
201 .putScopeForConnection(tenant, String(connName), scope)224 Effect.gen(function* () {
202 .pipe(Effect.orElseSucceed(() => undefined));225 const ref = yield* ensureAppConnection(slug, ctx);
203 return {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,
204 scope,410 scope,
205 connection: String(connName),411 ...(source?.ref ? { ref: source.ref } : input.ref ? { ref: input.ref } : {}),
206 ref: { owner: "user" as const, integration: slug, name: connName },412 ...(tokenRef ? { token: tokenRef } : {}),
207 };413 };
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;
208 });426 });
209 427
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
210 return {439 return {
211 id: APPS_PLUGIN_ID as "apps",440 id: APPS_PLUGIN_ID as "apps",
212 packageName: "@executor-js/plugin-apps",441 packageName: "@executor-js/plugin-apps",
@@ -224,16 +453,6 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 11 unmodified lines
224 schema: { Type: {} as Record<string, unknown> },453 schema: { Type: {} as Record<string, unknown> },
225 indexes: [],454 indexes: [],
226 },455 },
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 },
237 },456 },
238 457
239 extension: () => ({ runtime }),458 extension: () => ({ runtime }),
@@ -244,19 +463,6 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 4 unmodified lines
244 kind: "executor",463 kind: "executor",
245 name: "Apps",464 name: "Apps",
246 tools: [465 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 }),
260 tool<AppsStoreShape>({466 tool<AppsStoreShape>({
261 name: "sync_github_source",467 name: "sync_github_source",
262 description: "Sync a GitHub repository containing custom tool source files.",468 description: "Sync a GitHub repository containing custom tool source files.",
@@ -265,51 +471,21 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 2 unmodified lines
265 const payload = isRecord(args) ? args : {};471 const payload = isRecord(args) ? args : {};
266 const url =472 const url =
267 typeof payload.url === "string"473 typeof payload.url === "string"
268 ? payload.url474 ? payload.url.trim()
269 : typeof payload.repo === "string"475 : typeof payload.repo === "string"
270 ? payload.repo476 ? payload.repo.trim()
271 : "";477 : "";
272 const ref =478 const ref = asString(payload.ref);
273 typeof payload.ref === "string" && payload.ref.trim().length > 0479 const providedToken = asString(payload.token) ?? null;
274 ? payload.ref.trim()480 const slug = asString(payload.slug);
275 : undefined;481 if (slug && !url) return yield* syncExistingSource(ctx, slug, providedToken);
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;
281 if (!url) return syncFailure('sync_github_source requires "url"');482 if (!url) return syncFailure('sync_github_source requires "url"');
282 483 return yield* addSource(ctx, {
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,
303 url,484 url,
304 ref,485 ...(ref ? { ref } : {}),
305 token,486 ...(asString(payload.name) ? { name: asString(payload.name) } : {}),
306 baseUrl: configBaseUrl(integration?.config),487 token: providedToken,
307 });488 });
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;
313 }),489 }),
314 }),490 }),
315 tool<AppsStoreShape>({491 tool<AppsStoreShape>({
@@ -317,20 +493,44 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 1 unmodified line
317 description: "List synced GitHub repositories that publish custom tools.",493 description: "List synced GitHub repositories that publish custom tools.",
318 execute: (_args, { ctx }) =>494 execute: (_args, { ctx }) =>
319 Effect.gen(function* () {495 Effect.gen(function* () {
320 const tenant = tenantFor(ctx);496 const sources = yield* listSources(ctx);
321 const sources = yield* runtime.listGitHubSources(tenant);
322 return { sources };497 return { sources };
323 }),498 }),
324 }),499 }),
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 }),
325 ],511 ],
326 },512 },
327 ],513 ],
328 514
329 resolveTools: ({ ctx, connection }: ResolveToolsInput<AppsStoreShape>) =>515 removeIntegration: ({ ctx, integration }) =>
330 Effect.gen(function* () {516 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;
331 const tenant = ctx ? tenantFor(ctx) : "org";532 const tenant = ctx ? tenantFor(ctx) : "org";
332 const scope = yield* scopeFor(tenant, String(connection.name));533 const descriptor = yield* runtime.getDescriptor(tenant, source.scope);
333 const descriptor = yield* runtime.getDescriptor(tenant, scope);
334 if (!descriptor) return { tools: [] } satisfies ResolveToolsResult;534 if (!descriptor) return { tools: [] } satisfies ResolveToolsResult;
335 const tools: ToolDef[] = [];535 const tools: ToolDef[] = [];
336 for (const t of descriptor.tools) {536 for (const t of descriptor.tools) {
@@ -342,12 +542,13 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 5 unmodified lines
342 projectToolSchema: ({ ctx, toolRow, inputSchema, outputSchema }) =>542 projectToolSchema: ({ ctx, toolRow, inputSchema, outputSchema }) =>
343 Effect.gen(function* () {543 Effect.gen(function* () {
344 const tenant = tenantFor(ctx);544 const tenant = tenantFor(ctx);
345 const scope = yield* scopeFor(tenant, String(toolRow.connection));545 const source = yield* configForToolRow(ctx, IntegrationSlug.make(toolRow.integration));
346 const descriptor = yield* runtime.getDescriptor(tenant, scope);546 if (!source) return { inputSchema, outputSchema };
547 const descriptor = yield* runtime.getDescriptor(tenant, source.scope);
347 const toolDesc = descriptor?.tools.find((t) => t.name === toolRow.name);548 const toolDesc = descriptor?.tools.find((t) => t.name === toolRow.name);
348 if (!toolDesc) return { inputSchema, outputSchema };549 if (!toolDesc) return { inputSchema, outputSchema };
349 const resolver = makeResolver550 const resolver = makeResolver
350 ? makeResolver({ ctx, scope, tool: toolRow.name })551 ? makeResolver({ ctx, scope: source.scope, tool: toolRow.name })
351 : runtime.deps.resolver;552 : runtime.deps.resolver;
352 const byRole: Record<string, readonly ConnectionCandidate[]> = {};553 const byRole: Record<string, readonly ConnectionCandidate[]> = {};
353 for (const [role, decl] of Object.entries(toolDesc.integrations)) {554 for (const [role, decl] of Object.entries(toolDesc.integrations)) {
@@ -364,11 +565,18 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 10 unmodified lines
364 invokeTool: ({ ctx, toolRow, args, invokeOptions }: InvokeToolInput<AppsStoreShape>) =>565 invokeTool: ({ ctx, toolRow, args, invokeOptions }: InvokeToolInput<AppsStoreShape>) =>
365 Effect.gen(function* () {566 Effect.gen(function* () {
366 const tenant = tenantFor(ctx);567 const tenant = tenantFor(ctx);
367 const scope = yield* scopeFor(tenant, String(toolRow.connection));568 const source = yield* configForToolRow(ctx, IntegrationSlug.make(toolRow.integration));
368 const descriptor = yield* runtime.getDescriptor(tenant, scope);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);
369 if (!descriptor) {577 if (!descriptor) {
370 return yield* new PublishError({578 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`,
372 stage: "project",580 stage: "project",
373 diagnostics: [],581 diagnostics: [],
374 });582 });
@@ -376,17 +584,17 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 1 unmodified line
376 const toolDesc = descriptor.tools.find((t) => t.name === toolRow.name);584 const toolDesc = descriptor.tools.find((t) => t.name === toolRow.name);
377 if (!toolDesc) {585 if (!toolDesc) {
378 return yield* new PublishError({586 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}"`,
380 stage: "project",588 stage: "project",
381 diagnostics: [],589 diagnostics: [],
382 });590 });
383 }591 }
384 const resolver = makeResolver592 const resolver = makeResolver
385 ? makeResolver({ ctx, scope, tool: toolRow.name })593 ? makeResolver({ ctx, scope: source.scope, tool: toolRow.name })
386 : undefined;594 : undefined;
387 return yield* runtime.invokeTool({595 return yield* runtime.invokeTool({
388 tenant,596 tenant,
389 scope,597 scope: source.scope,
390 tool: toolRow.name,598 tool: toolRow.name,
391 args,599 args,
392 resolver,600 resolver,
@@ -395,36 +603,3 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 2 unmodified lines
395 }),603 }),
396 };604 };
397});605});
398
399const 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
409export const APPS_CONNECTION_PREFIX = APPS_INTEGRATION_SLUG;
410
411const pascal = (value: string): string =>
412 value.length === 0 ? value : `${value[0]!.toUpperCase()}${value.slice(1)}`;
413
414export const connectionNameForScope = (scope: string): string =>
415 `${APPS_CONNECTION_PREFIX}${pascal(scope)}`;
416
417export 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
42}42}
43 43
44export interface GitHubCustomToolsSourceSummary {44export interface GitHubCustomToolsSourceSummary {
45 readonly slug: string;
46 readonly name: string;
45 readonly scope: string;47 readonly scope: string;
46 readonly url: string;48 readonly url: string;
47 readonly repo: string;49 readonly repo: string;
@@ -71,6 +73,10 @@ export interface AppsRuntime { · 23 unmodified lines
71 readonly listGitHubSources: (73 readonly listGitHubSources: (
72 tenant?: string,74 tenant?: string,
73 ) => Effect.Effect<readonly GitHubCustomToolsSourceSummary[]>;75 ) => Effect.Effect<readonly GitHubCustomToolsSourceSummary[]>;
76 readonly removeSource: (input: {
77 readonly tenant?: string;
78 readonly scope: string;
79 }) => Effect.Effect<void, PublishError>;
74 /** Re-derive the published-descriptor pointer from the latest committed80 /** Re-derive the published-descriptor pointer from the latest committed
75 * snapshot. */81 * snapshot. */
76 readonly repair: (82 readonly repair: (
@@ -382,30 +388,27 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 305 unmodified lines
382 const source = record.descriptor.source;388 const source = record.descriptor.source;
383 return source?.kind === "github" ? [{ record, source }] : [];389 return source?.kind === "github" ? [{ record, source }] : [];
384 });390 });
385 return Effect.forEach(githubRecords, ({ record, source }) =>391 return Effect.forEach(githubRecords, ({ record, source }) => {
386 Effect.gen(function* () {392 const { descriptor, publishedAt } = record;
387 const { descriptor, publishedAt } = record;393 return Effect.succeed({
388 const tokenRef = yield* deps.store394 slug: descriptor.scope,
389 .getGitHubSourceTokenRef(tenant, descriptor.scope)395 name: source.repo.split("/").at(-1) ?? descriptor.scope,
390 .pipe(Effect.orElseSucceed(() => null));396 scope: descriptor.scope,
391 return {397 url: source.url,
392 scope: descriptor.scope,398 repo: source.repo,
393 url: source.url,399 ref: source.ref,
394 repo: source.repo,400 hasToken: false,
395 ref: source.ref,401 upstreamSha: source.upstreamSha,
396 hasToken: tokenRef !== null,402 snapshotId: descriptor.snapshotId,
397 upstreamSha: source.upstreamSha,403 ...(descriptor.description ? { description: descriptor.description } : {}),
398 snapshotId: descriptor.snapshotId,404 publishedAt: new Date(publishedAt).toISOString(),
399 ...(descriptor.description ? { description: descriptor.description } : {}),405 tools: descriptor.tools.map((tool) => tool.name),
400 publishedAt: new Date(publishedAt).toISOString(),406 skipped: [
401 tools: descriptor.tools.map((tool) => tool.name),407 ...(source.skipped ?? []),
402 skipped: [408 ...(descriptor.skipped as readonly GitHubSkippedArtifact[]),
403 ...(source.skipped ?? []),409 ],
404 ...(descriptor.skipped as readonly GitHubSkippedArtifact[]),410 } satisfies GitHubCustomToolsSourceSummary);
405 ],411 });
406 } satisfies GitHubCustomToolsSourceSummary;
407 }),
408 );
409 }),412 }),
410 );413 );
411 },414 },
@@ -415,6 +418,28 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 3 unmodified lines
415 return repairScope(tenant, scope);418 return repairScope(tenant, scope);
416 },419 },
417 420
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
418 invokeTool: (input) =>443 invokeTool: (input) =>
419 Effect.gen(function* () {444 Effect.gen(function* () {
420 const tenant = resolveTenant(input.tenant);445 const tenant = resolveTenant(input.tenant);
packages/plugins/apps/src/plugin/store.ts+4−66
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
9import type { AppDescriptor } from "../pipeline/descriptor";9import type { AppDescriptor } from "../pipeline/descriptor";
10 10
11// ---------------------------------------------------------------------------11// ---------------------------------------------------------------------------
12// AppsStore: descriptor pointer persistence and connection-to-scope mapping.12// AppsStore: descriptor pointer persistence.
13// ---------------------------------------------------------------------------13// ---------------------------------------------------------------------------
14 14
15export const descriptorCollection = definePluginStorageCollection("published_descriptor", {15export const descriptorCollection = definePluginStorageCollection("published_descriptor", {
@@ -22,27 +22,6 @@ export const descriptorCollection = definePluginStorageCollection("published_des · 6 unmodified lines
22 },22 },
23});23});
24 24
25export const scopeConnectionCollection = definePluginStorageCollection("apps_scope_connection", {
26 Type: {} as {
27 readonly tenant: string;
28 readonly connectionName: string;
29 readonly scope: string;
30 },
31});
32
33export 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
46export interface AppDescriptorRecord {25export interface AppDescriptorRecord {
47 readonly descriptor: AppDescriptor;26 readonly descriptor: AppDescriptor;
48 readonly publishedAt: number;27 readonly publishedAt: number;
@@ -64,27 +43,10 @@ export interface AppsStore { · 15 unmodified lines
64 tenant: string,43 tenant: string,
65 scope: string,44 scope: string,
66 ) => Effect.Effect<AppDescriptor | null, StorageFailure>;45 ) => Effect.Effect<AppDescriptor | null, StorageFailure>;
46 readonly removeDescriptor: (tenant: string, scope: string) => Effect.Effect<void, StorageFailure>;
67 readonly listDescriptors: (47 readonly listDescriptors: (
68 tenant: string,48 tenant: string,
69 ) => Effect.Effect<readonly AppDescriptorRecord[], StorageFailure>;49 ) => 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>;
88}50}
89 51
90export interface AppsStoreDeps {52export interface AppsStoreDeps {
@@ -93,36 +55,10 @@ export interface AppsStoreDeps { · 2 unmodified lines
93 55
94export const makeAppsStore = (deps: AppsStoreDeps): AppsStore => {56export const makeAppsStore = (deps: AppsStoreDeps): AppsStore => {
95 const descriptors = deps.pluginStorage.collection(descriptorCollection);57 const descriptors = deps.pluginStorage.collection(descriptorCollection);
96 const scopeConnections = deps.pluginStorage.collection(scopeConnectionCollection);
97 const githubSourceTokens = deps.pluginStorage.collection(githubSourceTokenCollection);
98 // Tenant is now part of apps storage keys. The apps subsystem had not shipped58 // Tenant is now part of apps storage keys. The apps subsystem had not shipped
99 // before this key shape, so no migration from the old scope-only keys is needed.59 // before this key shape, so no migration from the old scope-only keys is needed.
100 const keyFor = (tenant: string, key: string): string => `${tenant}:${key}`;60 const keyFor = (tenant: string, key: string): string => `${tenant}:${key}`;
101 return {61 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)),
126 putDescriptor: (tenant, owner, descriptor) =>62 putDescriptor: (tenant, owner, descriptor) =>
127 descriptors63 descriptors
128 .put({64 .put({
@@ -141,6 +77,8 @@ export const makeAppsStore = (deps: AppsStoreDeps): AppsStore => { · 12 unmodified lines
141 descriptors77 descriptors
142 .get({ key: keyFor(tenant, scope) })78 .get({ key: keyFor(tenant, scope) })
143 .pipe(Effect.map((entry) => entry?.data.descriptor ?? null)),79 .pipe(Effect.map((entry) => entry?.data.descriptor ?? null)),
80 removeDescriptor: (tenant, scope) =>
81 descriptors.remove({ owner: "org", key: keyFor(tenant, scope) }),
144 listDescriptors: (tenant) =>82 listDescriptors: (tenant) =>
145 descriptors.list({ keyPrefix: `${tenant}:` }).pipe(83 descriptors.list({ keyPrefix: `${tenant}:` }).pipe(
146 Effect.map((entries) =>84 Effect.map((entries) =>
packages/plugins/apps/src/react/AddCustomToolsSource.tsx+48−3
@@ -10,12 +10,15 @@ import { · 9 unmodified lines
10import { FloatActions } from "@executor-js/react/components/float-actions";10import { FloatActions } from "@executor-js/react/components/float-actions";
11import { Input } from "@executor-js/react/components/input";11import { Input } from "@executor-js/react/components/input";
12import { toast } from "@executor-js/react/components/sonner";12import { toast } from "@executor-js/react/components/sonner";
13import { FormErrorAlert } from "@executor-js/react/lib/integration-add";13import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add";
14 14
15import {15import {
16 formatSyncErrors,16 formatSyncErrors,
17 slugifyCustomToolsAppName,
18 suggestCustomToolsAppName,
17 syncCustomToolSourceEffect,19 syncCustomToolSourceEffect,
18 syncStatusLabel,20 syncStatusLabel,
21 validateCustomToolsAppSlug,
19 validateGitHubSourceUrl,22 validateGitHubSourceUrl,
20} from "./custom-tools-client";23} from "./custom-tools-client";
21 24
@@ -24,21 +27,36 @@ export default function AddCustomToolsSource(props: { · 2 unmodified lines
24 readonly onCancel: () => void;27 readonly onCancel: () => void;
25}) {28}) {
26 const [url, setUrl] = useState("");29 const [url, setUrl] = useState("");
30 const [name, setName] = useState("");
31 const [nameTouched, setNameTouched] = useState(false);
27 const [token, setToken] = useState("");32 const [token, setToken] = useState("");
28 const [tokenRevealed, setTokenRevealed] = useState(false);33 const [tokenRevealed, setTokenRevealed] = useState(false);
29 const [urlError, setUrlError] = useState<string | null>(null);34 const [urlError, setUrlError] = useState<string | null>(null);
35 const [nameError, setNameError] = useState<string | null>(null);
30 const [syncing, setSyncing] = useState(false);36 const [syncing, setSyncing] = useState(false);
31 const [syncError, setSyncError] = useState<string | null>(null);37 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);
32 43
33 const submit = async () => {44 const submit = async () => {
34 const validation = validateGitHubSourceUrl(url);45 const validation = validateGitHubSourceUrl(url);
46 const nextNameError = validateCustomToolsAppSlug(slug);
35 setUrlError(validation);47 setUrlError(validation);
48 setNameError(nextNameError);
36 setSyncError(null);49 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 }
38 55
39 setSyncing(true);56 setSyncing(true);
40 const exit = await Effect.runPromiseExit(57 const exit = await Effect.runPromiseExit(
41 syncCustomToolSourceEffect({58 syncCustomToolSourceEffect({
59 name: slug,
42 url,60 url,
43 token,61 token,
44 }),62 }),
@@ -55,7 +73,7 @@ export default function AddCustomToolsSource(props: { · 10 unmodified lines
55 return;73 return;
56 }74 }
57 toast.success(syncStatusLabel(result));75 toast.success(syncStatusLabel(result));
58 props.onComplete("apps");76 props.onComplete(slug);
59 };77 };
60 78
61 return (79 return (
@@ -79,6 +97,7 @@ export default function AddCustomToolsSource(props: { · 17 unmodified lines
79 onChange={(event) => {97 onChange={(event) => {
80 setUrl((event.target as HTMLInputElement).value);98 setUrl((event.target as HTMLInputElement).value);
81 setUrlError(null);99 setUrlError(null);
100 setNameError(null);
82 setSyncError(null);101 setSyncError(null);
83 }}102 }}
84 onBlur={() => setUrlError(validateGitHubSourceUrl(url))}103 onBlur={() => setUrlError(validateGitHubSourceUrl(url))}
@@ -90,6 +109,32 @@ export default function AddCustomToolsSource(props: { · 5 unmodified lines
90 </div>109 </div>
91 </CardStackEntryField>110 </CardStackEntryField>
92 111
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
93 <CardStackEntryField138 <CardStackEntryField
94 label="Access token (optional)"139 label="Access token (optional)"
95 description="- Needed only for private repositories or higher GitHub API limits."140 description="- Needed only for private repositories or higher GitHub API limits."
packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx+121−127
@@ -1,24 +1,27 @@
1import { useEffect, useState, type ReactNode } from "react";1import { useEffect, useState, type ReactNode } from "react";
2import { Effect, Exit } from "effect";2import { Effect, Exit } from "effect";
3 3
4import {
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";
4import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";15import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
5import { Badge } from "@executor-js/react/components/badge";16import { Badge } from "@executor-js/react/components/badge";
6import { Button } from "@executor-js/react/components/button";17import { Button } from "@executor-js/react/components/button";
7import {
8 CardStack,
9 CardStackContent,
10 CardStackEntry,
11 CardStackEntryActions,
12 CardStackEntryContent,
13 CardStackEntryDescription,
14 CardStackEntryTitle,
15} from "@executor-js/react/components/card-stack";
16 18
17import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api";19import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api";
18import {20import {
19 consoleIntegrationHref,21 consoleIntegrationHref,
20 formatSyncErrors,22 formatSyncErrors,
21 listCustomToolSourcesEffect,23 getCustomToolSourceEffect,
24 removeCustomToolSourceEffect,
22 syncCustomToolSourceEffect,25 syncCustomToolSourceEffect,
23 syncStatusLabel,26 syncStatusLabel,
24 toolDiff,27 toolDiff,
@@ -27,7 +30,8 @@ import { · 2 unmodified lines
27type LoadState =30type LoadState =
28 | { readonly status: "loading" }31 | { readonly status: "loading" }
29 | { readonly status: "error"; readonly message: string }32 | { 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 };
31 35
32interface SyncNotice {36interface SyncNotice {
33 readonly status: GitHubSyncResult["status"];37 readonly status: GitHubSyncResult["status"];
@@ -42,61 +46,49 @@ const formatDate = (value: string): string => { · 8 unmodified lines
42 return Number.isNaN(date.getTime()) ? value : date.toLocaleString();46 return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
43};47};
44 48
45export default function CustomToolsAccountsPanel() {49export default function CustomToolsAccountsPanel(props: {
50 readonly sourceId: string;
51 readonly integrationName: string;
52}) {
46 const [loadState, setLoadState] = useState<LoadState>({ status: "loading" });53 const [loadState, setLoadState] = useState<LoadState>({ status: "loading" });
47 const [selectedScope, setSelectedScope] = useState<string | null>(null);54 const [syncing, setSyncing] = useState(false);
48 const [syncingScope, setSyncingScope] = useState<string | null>(null);55 const [removing, setRemoving] = useState(false);
49 const [notice, setNotice] = useState<SyncNotice | null>(null);56 const [notice, setNotice] = useState<SyncNotice | null>(null);
57 const [removeError, setRemoveError] = useState<string | null>(null);
50 58
51 const loadSources = async () => {59 const loadSource = async () => {
52 setLoadState({ status: "loading" });60 setLoadState({ status: "loading" });
53 const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect());61 const exit = await Effect.runPromiseExit(getCustomToolSourceEffect(props.sourceId));
54 if (Exit.isFailure(exit)) {62 if (Exit.isFailure(exit)) {
55 setLoadState({63 setLoadState({ status: "error", message: "Failed to load custom tools source." });
56 status: "error",
57 message: "Failed to load custom tool sources.",
58 });
59 return;64 return;
60 }65 }
61 const result = exit.value;66 const source = exit.value.source;
62 setLoadState({ status: "ready", sources: result.sources });67 setLoadState(source ? { status: "ready", source } : { status: "missing" });
63 setSelectedScope((current) => current ?? result.sources[0]?.scope ?? null);
64 };68 };
65 69
66 useEffect(() => {70 useEffect(() => {
67 let active = true;71 let active = true;
68 void (async () => {72 void (async () => {
69 const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect());73 const exit = await Effect.runPromiseExit(getCustomToolSourceEffect(props.sourceId));
70 if (Exit.isSuccess(exit)) {74 if (!active) return;
71 if (!active) return;75 if (Exit.isFailure(exit)) {
72 const result = exit.value;76 setLoadState({ status: "error", message: "Failed to load custom tools source." });
73 setLoadState({ status: "ready", sources: result.sources });77 return;
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 });
81 }78 }
79 const source = exit.value.source;
80 setLoadState(source ? { status: "ready", source } : { status: "missing" });
82 })();81 })();
83 return () => {82 return () => {
84 active = false;83 active = false;
85 };84 };
86 }, []);85 }, [props.sourceId]);
87
88 const sources = loadState.status === "ready" ? loadState.sources : [];
89 const selected = sources.find((source) => source.scope === selectedScope) ?? sources[0] ?? null;
90 86
91 const syncSelected = async (source: GitHubCustomToolsSourceSummary) => {87 const syncSource = async (source: GitHubCustomToolsSourceSummary) => {
92 setSyncingScope(source.scope);88 setSyncing(true);
93 setNotice(null);89 setNotice(null);
94 const beforeTools = source.tools;90 const beforeTools = source.tools;
95 const exit = await Effect.runPromiseExit(91 const exit = await Effect.runPromiseExit(syncCustomToolSourceEffect({ slug: source.slug }));
96 syncCustomToolSourceEffect({
97 url: source.url,
98 }),
99 );
100 if (Exit.isFailure(exit)) {92 if (Exit.isFailure(exit)) {
101 setNotice({93 setNotice({
102 status: "failed",94 status: "failed",
@@ -105,7 +97,7 @@ export default function CustomToolsAccountsPanel() { · 2 unmodified lines
105 removed: [],97 removed: [],
106 errors: ["Failed to sync custom tools."],98 errors: ["Failed to sync custom tools."],
107 });99 });
108 setSyncingScope(null);100 setSyncing(false);
109 return;101 return;
110 }102 }
111 const result = exit.value;103 const result = exit.value;
@@ -118,94 +110,53 @@ export default function CustomToolsAccountsPanel() { · 6 unmodified lines
118 removed: diff.removed,110 removed: diff.removed,
119 errors: formatSyncErrors(result),111 errors: formatSyncErrors(result),
120 });112 });
121 if (result.status !== "failed") {113 if (result.status !== "failed") await loadSource();
122 await loadSources();114 setSyncing(false);
123 setSelectedScope(source.scope);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;
124 }125 }
125 setSyncingScope(null);126 window.location.assign(consoleIntegrationHref("/integrations"));
126 };127 };
127 128
128 return (129 return (
129 <div className="mx-auto max-w-5xl space-y-6 px-6 py-8">130 <div className="mx-auto max-w-3xl 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
142 {loadState.status === "loading" && (131 {loadState.status === "loading" && (
143 <div className="rounded-lg border border-border p-4 text-sm text-muted-foreground">132 <div className="rounded-lg border border-border p-4 text-sm text-muted-foreground">
144 Loading sources...133 Loading source...
145 </div>134 </div>
146 )}135 )}
147 136
148 {loadState.status === "error" && (137 {loadState.status === "error" && (
149 <ErrorWithRetry message={loadState.message} onRetry={() => void loadSources()} />138 <ErrorWithRetry message={loadState.message} onRetry={() => void loadSource()} />
150 )}139 )}
151 140
152 {loadState.status === "ready" && sources.length === 0 && (141 {loadState.status === "missing" && (
153 <div className="rounded-lg border border-dashed border-border p-8 text-center">142 <Alert variant="destructive">
154 <p className="text-sm font-medium text-foreground">No custom tools sources</p>143 <AlertTitle>Source not found</AlertTitle>
155 <p className="mt-1 text-sm text-muted-foreground">144 <AlertDescription>
156 Add a GitHub repo to publish its tools.145 The custom tools source for {props.integrationName} is no longer available.
157 </p>146 </AlertDescription>
158 <Button asChild size="sm" className="mt-4">147 </Alert>
159 <a href={consoleIntegrationHref("/integrations/add/apps")}>Add source</a>
160 </Button>
161 </div>
162 )}148 )}
163 149
164 {loadState.status === "ready" && sources.length > 0 && (150 {loadState.status === "ready" && (
165 <div className="grid min-h-[28rem] gap-4 lg:grid-cols-[18rem_1fr]">151 <SourceDetail
166 <CardStack searchable>152 source={loadState.source}
167 <CardStackContent>153 notice={notice}
168 {sources.map((source) => (154 removeError={removeError}
169 <CardStackEntry155 syncing={syncing}
170 key={source.scope}156 removing={removing}
171 asChild157 onSync={() => void syncSource(loadState.source)}
172 searchText={`${source.repo} ${source.ref} ${source.url}`}158 onRemove={() => void removeSource(loadState.source)}
173 >159 />
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>
209 )}160 )}
210 </div>161 </div>
211 );162 );
@@ -214,7 +165,7 @@ export default function CustomToolsAccountsPanel() { · 2 unmodified lines
214function ErrorWithRetry(props: { readonly message: string; readonly onRetry: () => void }) {165function ErrorWithRetry(props: { readonly message: string; readonly onRetry: () => void }) {
215 return (166 return (
216 <Alert variant="destructive">167 <Alert variant="destructive">
217 <AlertTitle>Failed to load sources</AlertTitle>168 <AlertTitle>Failed to load source</AlertTitle>
218 <AlertDescription>169 <AlertDescription>
219 <div className="space-y-3">170 <div className="space-y-3">
220 <p>{props.message}</p>171 <p>{props.message}</p>
@@ -230,20 +181,53 @@ function ErrorWithRetry(props: { readonly message: string; readonly onRetry: () · 9 unmodified lines
230function SourceDetail(props: {181function SourceDetail(props: {
231 readonly source: GitHubCustomToolsSourceSummary;182 readonly source: GitHubCustomToolsSourceSummary;
232 readonly notice: SyncNotice | null;183 readonly notice: SyncNotice | null;
184 readonly removeError: string | null;
233 readonly syncing: boolean;185 readonly syncing: boolean;
186 readonly removing: boolean;
234 readonly onSync: () => void;187 readonly onSync: () => void;
188 readonly onRemove: () => void;
235}) {189}) {
236 const { source, notice } = props;190 const { source, notice } = props;
237 return (191 return (
238 <div className="min-w-0 rounded-lg border border-border bg-card">192 <div className="min-w-0 rounded-lg border border-border bg-card">
239 <div className="flex items-start justify-between gap-4 border-b border-border p-4">193 <div className="flex items-start justify-between gap-4 border-b border-border p-4">
240 <div className="min-w-0">194 <div className="min-w-0">
241 <h3 className="truncate text-sm font-medium text-foreground">{source.repo}</h3>195 <h3 className="truncate text-sm font-medium text-foreground">{source.name}</h3>
242 <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{source.scope}</p>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>
243 </div>230 </div>
244 <Button type="button" size="sm" onClick={props.onSync} loading={props.syncing}>
245 Sync
246 </Button>
247 </div>231 </div>
248 232
249 <div className="space-y-6 p-4">233 <div className="space-y-6 p-4">
@@ -272,6 +256,8 @@ function SourceDetail(props: { · 22 unmodified lines
272 </Alert>256 </Alert>
273 )}257 )}
274 258
259 {props.removeError && <FormErrorMessage message={props.removeError} />}
260
275 <Section title="Published tools">261 <Section title="Published tools">
276 {source.tools.length > 0 ? (262 {source.tools.length > 0 ? (
277 <div className="flex flex-wrap gap-2">263 <div className="flex flex-wrap gap-2">
@@ -333,6 +319,14 @@ function Field(props: { readonly label: string; readonly value: string; readonly · 55 unmodified lines
333 );319 );
334}320}
335 321
322function 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
336function Section(props: { readonly title: string; readonly children: ReactNode }) {330function Section(props: { readonly title: string; readonly children: ReactNode }) {
337 return (331 return (
338 <section className="space-y-2">332 <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
2 2
3import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api";3import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api";
4import { parseGitHubSourceUrl } from "../source/github-url";4import { parseGitHubSourceUrl } from "../source/github-url";
5import { slugifyCustomToolsAppName, validateCustomToolsAppSlug } from "../source/app-slug";
5 6
6export const CUSTOM_TOOLS_PLUGIN_KEY = "apps";7export const CUSTOM_TOOLS_PLUGIN_KEY = "apps";
7export const CUSTOM_TOOLS_LABEL = "Custom tools";8export const CUSTOM_TOOLS_LABEL = "Custom tools";
@@ -10,8 +11,14 @@ export interface GitHubSourcesListResponse { · 2 unmodified lines
10 readonly sources: readonly GitHubCustomToolsSourceSummary[];11 readonly sources: readonly GitHubCustomToolsSourceSummary[];
11}12}
12 13
14export interface GitHubSourceDetailResponse {
15 readonly source: GitHubCustomToolsSourceSummary | null;
16}
17
13export interface SyncGitHubSourceRequest {18export interface SyncGitHubSourceRequest {
14 readonly url: string;19 readonly url?: string;
20 readonly name?: string;
21 readonly slug?: string;
15 readonly ref?: string;22 readonly ref?: string;
16 readonly token?: string;23 readonly token?: string;
17}24}
@@ -23,6 +30,12 @@ export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientE · 5 unmodified lines
23}> {}30}> {}
24 31
25export { parseGitHubSourceUrl };32export { parseGitHubSourceUrl };
33export { slugifyCustomToolsAppName, validateCustomToolsAppSlug };
34
35export const suggestCustomToolsAppName = (url: string): string => {
36 const parsed = parseGitHubSourceUrl(url);
37 return parsed.ok ? parsed.value.name : "";
38};
26 39
27export const validateGitHubSourceUrl = (url: string): string | null => {40export const validateGitHubSourceUrl = (url: string): string | null => {
28 const parsed = parseGitHubSourceUrl(url);41 const parsed = parseGitHubSourceUrl(url);
@@ -81,6 +94,31 @@ export const listCustomToolSources = ( · 52 unmodified lines
81 fetchImpl: CustomToolsFetch = fetch,94 fetchImpl: CustomToolsFetch = fetch,
82): Promise<GitHubSourcesListResponse> => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl));95): Promise<GitHubSourcesListResponse> => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl));
83 96
97export 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
116export const getCustomToolSource = (
117 slug: string,
118 fetchImpl: CustomToolsFetch = fetch,
119): Promise<GitHubSourceDetailResponse> =>
120 Effect.runPromise(getCustomToolSourceEffect(slug, fetchImpl));
121
84export const syncCustomToolSourceEffect = (122export const syncCustomToolSourceEffect = (
85 input: SyncGitHubSourceRequest,123 input: SyncGitHubSourceRequest,
86 fetchImpl: CustomToolsFetch = fetch,124 fetchImpl: CustomToolsFetch = fetch,
@@ -92,7 +130,9 @@ export const syncCustomToolSourceEffect = ( · 5 unmodified lines
92 headers: { "content-type": "application/json" },130 headers: { "content-type": "application/json" },
93 credentials: "same-origin",131 credentials: "same-origin",
94 body: JSON.stringify({132 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() } : {}),
96 ...(input.ref?.trim() ? { ref: input.ref.trim() } : {}),136 ...(input.ref?.trim() ? { ref: input.ref.trim() } : {}),
97 ...(input.token?.trim() ? { token: input.token.trim() } : {}),137 ...(input.token?.trim() ? { token: input.token.trim() } : {}),
98 }),138 }),
@@ -109,6 +149,32 @@ export const syncCustomToolSource = ( · 10 unmodified lines
109 fetchImpl: CustomToolsFetch = fetch,149 fetchImpl: CustomToolsFetch = fetch,
110): Promise<GitHubSyncResult> => Effect.runPromise(syncCustomToolSourceEffect(input, fetchImpl));150): Promise<GitHubSyncResult> => Effect.runPromise(syncCustomToolSourceEffect(input, fetchImpl));
111 151
152export 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
172export const removeCustomToolSource = (
173 slug: string,
174 fetchImpl: CustomToolsFetch = fetch,
175): Promise<{ readonly removed: boolean }> =>
176 Effect.runPromise(removeCustomToolSourceEffect(slug, fetchImpl));
177
112export const syncStatusLabel = (result: GitHubSyncResult): string => {178export const syncStatusLabel = (result: GitHubSyncResult): string => {
113 if (result.status === "published") return `Published ${result.tools.length} tools.`;179 if (result.status === "published") return `Published ${result.tools.length} tools.`;
114 if (result.status === "up-to-date") return "Already up to date.";180 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
64 readonly forScope: (64 readonly forScope: (
65 address: ScopeAddress,65 address: ScopeAddress,
66 ) => Effect.Effect<ScopeArtifactStore, ArtifactStoreError>;66 ) => Effect.Effect<ScopeArtifactStore, ArtifactStoreError>;
67 readonly removeScope: (address: ScopeAddress) => Effect.Effect<void, ArtifactStoreError>;
67}68}
packages/plugins/apps/src/seams/scope-db.ts+1−0
@@ -39,5 +39,6 @@ export interface ScopeDbHandle { · 38 unmodified lines
39 39
40export interface ScopeDb {40export interface ScopeDb {
41 readonly forScope: (address: ScopeAddress) => Effect.Effect<ScopeDbHandle, ScopeDbError>;41 readonly forScope: (address: ScopeAddress) => Effect.Effect<ScopeDbHandle, ScopeDbError>;
42 readonly removeScope: (address: ScopeAddress) => Effect.Effect<void, ScopeDbError>;
42 readonly close: () => Effect.Effect<void, ScopeDbError>;43 readonly close: () => Effect.Effect<void, ScopeDbError>;
43}44}
packages/plugins/apps/src/source/app-slug.ts+17−0
@@ -0,0 +1,17 @@
1export const CUSTOM_TOOLS_APP_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
2
3export 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
12export 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
20} => {20} => {
21 const descriptors = new Map<string, AppDescriptor>();21 const descriptors = new Map<string, AppDescriptor>();
22 const publishedAt = new Map<string, number>();22 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 >();
28 const keyFor = (tenant: string, key: string): string => `${tenant}:${key}`;23 const keyFor = (tenant: string, key: string): string => `${tenant}:${key}`;
29 return {24 return {
30 descriptors,25 descriptors,
@@ -36,6 +31,12 @@ export const makeInMemoryAppsStore = (): AppsStore & { · 5 unmodified lines
36 }),31 }),
37 getDescriptor: (tenant, scope) =>32 getDescriptor: (tenant, scope) =>
38 Effect.sync(() => descriptors.get(keyFor(tenant, scope)) ?? null),33 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 }),
39 listDescriptors: (tenant) =>40 listDescriptors: (tenant) =>
40 Effect.sync(() =>41 Effect.sync(() =>
41 [...descriptors.entries()]42 [...descriptors.entries()]
@@ -45,14 +46,6 @@ export const makeInMemoryAppsStore = (): AppsStore & { · 3 unmodified lines
45 publishedAt: publishedAt.get(key) ?? 0,46 publishedAt: publishedAt.get(key) ?? 0,
46 })),47 })),
47 ),48 ),
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),
56 };49 };
57};50};
58 51
@@ -94,7 +87,15 @@ export const makeInMemoryArtifactStore = (): ArtifactStore => { · 35 unmodified lines
94 ),87 ),
95 };88 };
96 };89 };
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 };
98};99};
99 100
100export type { SnapshotId };101export type { SnapshotId };
packages/react/src/pages/integration-detail.tsx+12−4
@@ -104,6 +104,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 103 unmodified lines
104 const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null;104 const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null;
105 useExecutorDocumentTitle(integrationData?.name || namespace);105 useExecutorDocumentTitle(integrationData?.name || namespace);
106 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";106 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";
107 const isAppsIntegration = integrationData?.kind === "apps";
107 const currentTab = isBuiltInIntegration ? "tools" : activeTab;108 const currentTab = isBuiltInIntegration ? "tools" : activeTab;
108 const canRefresh = integrationData?.canRefresh ?? false;109 const canRefresh = integrationData?.canRefresh ?? false;
109 const canRemove = integrationData?.canRemove ?? false;110 const canRemove = integrationData?.canRemove ?? false;
@@ -153,9 +154,11 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 43 unmodified lines
153 // Find the plugin edit component based on integration kind154 // Find the plugin edit component based on integration kind
154 const editPlugin = useMemo(() => {155 const editPlugin = useMemo(() => {
155 if (!integrationData) return null;156 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 }
157 return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null;160 return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null;
158 }, [integrationData, integrationPlugins, namespace]);161 }, [integrationData, integrationPlugins]);
159 162
160 // Policies are pre-sorted by the server in evaluation order (owner rank, then163 // Policies are pre-sorted by the server in evaluation order (owner rank, then
161 // position ASC). The matcher walks the list and stops at the first hit per164 // 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
364 </div>367 </div>
365 368
366 <div className="flex shrink-0 items-center gap-2">369 <div className="flex shrink-0 items-center gap-2">
367 {!confirmDelete && !isBuiltInIntegration && integrationData && (370 {!confirmDelete && !isBuiltInIntegration && !isAppsIntegration && integrationData && (
368 <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>371 <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>
369 Edit372 Edit
370 </Button>373 </Button>
@@ -382,6 +385,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 11 unmodified lines
382 )}385 )}
383 386
384 {canRemove &&387 {canRemove &&
388 !isAppsIntegration &&
385 (confirmDelete ? (389 (confirmDelete ? (
386 <div className="flex items-center gap-2">390 <div className="flex items-center gap-2">
387 <Button391 <Button
@@ -421,7 +425,11 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 33 unmodified lines
421 >425 >
422 <div className="shrink-0 border-b border-border/60 px-4 py-2">426 <div className="shrink-0 border-b border-border/60 px-4 py-2">
423 <TabsList variant="line">427 <TabsList variant="line">
424 {!isBuiltInIntegration && <TabsTrigger value="accounts">Accounts</TabsTrigger>}428 {!isBuiltInIntegration && (
429 <TabsTrigger value="accounts">
430 {isAppsIntegration ? "Source" : "Accounts"}
431 </TabsTrigger>
432 )}
425 <TabsTrigger value="tools">Tools</TabsTrigger>433 <TabsTrigger value="tools">Tools</TabsTrigger>
426 </TabsList>434 </TabsList>
427 </div>435 </div>