Unified · Split
| @@ -7,6 +7,7 @@ const HOST = "127.0.0.1"; · 6 unmodified lines | ||
| 7 | 7 | const BASE_URL = `http://${HOST}:${PORT}`; |
| 8 | 8 | const ORIGIN = "http://custom-tools-demo.local"; |
| 9 | 9 | const DEMO_REPO = "RhysSullivan/executor-custom-tools-demo"; |
| 10 | +const DEMO_APP = "demo-tools"; | |
| 10 | 11 | const ADMIN_EMAIL = "admin@custom-tools-demo.local"; |
| 11 | 12 | const ADMIN_PASSWORD = "admin-pass-123456"; |
| 12 | 13 | const USER_EMAIL = "rhys@custom-tools-demo.local"; |
| @@ -232,7 +233,7 @@ const htmlEscape = (value: string): string => · 219 unmodified lines | ||
| 232 | 233 | .replaceAll('"', """); |
| 233 | 234 | |
| 234 | 235 | const toolInvokeCode = (toolName: string, args: unknown): string => ` |
| 235 | −const found = await tools.search({ namespace: "apps", query: ${JSON.stringify(toolName)}, limit: 20 }); | |
| 236 | +const found = await tools.search({ namespace: ${JSON.stringify(DEMO_APP)}, query: ${JSON.stringify(toolName)}, limit: 20 }); | |
| 236 | 237 | const item = found.items.find((candidate) => candidate.path.endsWith(${JSON.stringify(toolName)})); |
| 237 | 238 | if (!item) return { ok: false, missing: ${JSON.stringify(toolName)}, found }; |
| 238 | 239 | let fn = tools; |
| @@ -251,10 +252,11 @@ const buildBanner = (input: { · 12 unmodified lines | ||
| 251 | 252 | readonly consoleUi: string; |
| 252 | 253 | }): string => { |
| 253 | 254 | const sync = curlJson("/api/apps/sources/github/sync", { |
| 255 | + name: DEMO_APP, | |
| 254 | 256 | url: input.repoUrl, |
| 255 | 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 | 260 | const repoSummary = curlJson("/api/executions", { |
| 259 | 261 | code: toolInvokeCode("repo-summary", { |
| 260 | 262 | github: input.connectionAddress, |
| @@ -290,6 +292,9 @@ Auth: · 29 unmodified lines | ||
| 290 | 292 | GitHub invocation connection: |
| 291 | 293 | ${input.connectionAddress} |
| 292 | 294 | |
| 295 | +Custom tools app: | |
| 296 | + ${DEMO_APP} | |
| 297 | + | |
| 293 | 298 | Sync the repo: |
| 294 | 299 | ${sync} |
| 295 | 300 | |
| @@ -8,7 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | makeScopedExecutor, |
| 9 | 9 | type Principal, |
| 10 | 10 | } from "@executor-js/api/server"; |
| 11 | −import { ToolAddress } from "@executor-js/sdk"; | |
| 11 | +import { IntegrationSlug, ToolAddress } from "@executor-js/sdk"; | |
| 12 | 12 | |
| 13 | 13 | import { SelfHostScopedExecutorSeams } from "./execution"; |
| 14 | 14 | import { SelfHostDb, type SelfHostDbHandle } from "./db/self-host-db"; |
| @@ -43,6 +43,21 @@ const buildExecutor = (principal: Principal, request: Request) => { · 28 unmodified lines | ||
| 43 | 43 | : withOrigin; |
| 44 | 44 | }; |
| 45 | 45 | |
| 46 | +const sourceSlugFromRoute = () => | |
| 47 | + HttpRouter.params.pipe( | |
| 48 | + Effect.map((params) => params.slug ?? ""), | |
| 49 | + Effect.flatMap((slug) => | |
| 50 | + slug.length > 0 | |
| 51 | + ? Effect.succeed(slug) | |
| 52 | + : Effect.fail( | |
| 53 | + new AppsSyncRouteError({ | |
| 54 | + status: 400, | |
| 55 | + message: "Missing custom tools source slug", | |
| 56 | + }), | |
| 57 | + ), | |
| 58 | + ), | |
| 59 | + ); | |
| 60 | + | |
| 46 | 61 | export interface SelfHostAppsSyncRouteDeps { |
| 47 | 62 | readonly identity: Layer.Layer<IdentityProvider>; |
| 48 | 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 | 165 | ).pipe(HttpRouter.provideRequest(services)); |
| 122 | 166 | }; |
| @@ -1,13 +1,14 @@ | ||
| 1 | 1 | import { randomBytes } from "node:crypto"; |
| 2 | −import { mkdtempSync } from "node:fs"; | |
| 2 | +import { existsSync, mkdtempSync } from "node:fs"; | |
| 3 | 3 | import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; |
| 4 | 4 | import { createServer as createNetServer } from "node:net"; |
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| 7 | 7 | |
| 8 | 8 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; |
| 9 | −import { Effect, Schema } from "effect"; | |
| 9 | +import { Effect, Option, Schema } from "effect"; | |
| 10 | 10 | import { createEmulator, type Emulator } from "@executor-js/emulate"; |
| 11 | +import { createClient } from "@libsql/client"; | |
| 11 | 12 | |
| 12 | 13 | const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-")); |
| 13 | 14 | const dbPath = join(dataDir, "data.db"); |
| @@ -21,6 +22,9 @@ const OWNER = "syncer"; · 7 unmodified lines | ||
| 21 | 22 | const REPO = "custom-tools"; |
| 22 | 23 | const REPO_FULL_NAME = `${OWNER}/${REPO}`; |
| 23 | 24 | const GITHUB_CONNECTION = "tools.github.user.main"; |
| 25 | +const APP_SLUG = "custom-tools-app"; | |
| 26 | +const SCHEMA_APP_SLUG = "schema-tools-app"; | |
| 27 | +const APPROVAL_APP_SLUG = "approval-tools-app"; | |
| 24 | 28 | |
| 25 | 29 | interface TestHttpServer { |
| 26 | 30 | readonly baseUrl: string; |
| @@ -33,7 +37,7 @@ interface SyncResult { · 6 unmodified lines | ||
| 33 | 37 | readonly upstreamSha?: string; |
| 34 | 38 | readonly tools: readonly string[]; |
| 35 | 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 | |
| 39 | 43 | interface ToolRow { |
| @@ -46,6 +50,14 @@ interface ConnectionRow { · 6 unmodified lines | ||
| 46 | 50 | readonly address: string; |
| 47 | 51 | } |
| 48 | 52 | |
| 53 | +interface IntegrationRow { | |
| 54 | + readonly slug: string; | |
| 55 | + readonly name: string; | |
| 56 | + readonly kind: string; | |
| 57 | + readonly canRemove: boolean; | |
| 58 | + readonly displayUrl?: string; | |
| 59 | +} | |
| 60 | + | |
| 49 | 61 | interface ExecuteResponse { |
| 50 | 62 | readonly status: "completed" | "paused"; |
| 51 | 63 | readonly text: string; |
| @@ -194,6 +206,48 @@ const postJson = <T>(path: string, body: unknown, expectedStatus = 200): Promise · 142 unmodified lines | ||
| 194 | 206 | expectedStatus, |
| 195 | 207 | ); |
| 196 | 208 | |
| 209 | +const deleteJson = <T>(path: string, expectedStatus = 200): Promise<T> => | |
| 210 | + requestJson<T>( | |
| 211 | + path, | |
| 212 | + { | |
| 213 | + method: "DELETE", | |
| 214 | + headers: jsonHeaders, | |
| 215 | + }, | |
| 216 | + expectedStatus, | |
| 217 | + ); | |
| 218 | + | |
| 219 | +const querySqliteRows = async ( | |
| 220 | + path: string, | |
| 221 | + sql: string, | |
| 222 | + args: readonly unknown[] = [], | |
| 223 | +): Promise<readonly Record<string, unknown>[]> => { | |
| 224 | + const client = createClient({ url: `file:${path}` }); | |
| 225 | + const result = await client.execute({ sql, args: [...args] as never }); | |
| 226 | + client.close(); | |
| 227 | + return result.rows.map((row) => ({ ...row })); | |
| 228 | +}; | |
| 229 | + | |
| 230 | +const sourceStorageKey = (scope: string): string => | |
| 231 | + `v2-${Buffer.from(JSON.stringify([TEST_ORG, scope]), "utf8").toString("hex")}`; | |
| 232 | + | |
| 233 | +const sourceTokenItemId = (scope: string): string => | |
| 234 | + `apps:github-source:${TEST_ORG}:${scope}:token`; | |
| 235 | + | |
| 236 | +const DbJsonObject = Schema.Record(Schema.String, Schema.Unknown); | |
| 237 | +const DbJsonObjectFromString = Schema.fromJsonString(DbJsonObject); | |
| 238 | +const decodeDbJsonObject = Schema.decodeUnknownOption(DbJsonObject); | |
| 239 | +const decodeDbJsonObjectFromString = Schema.decodeUnknownOption(DbJsonObjectFromString); | |
| 240 | + | |
| 241 | +const decodeDbJson = (value: unknown): Record<string, unknown> => { | |
| 242 | + if (value instanceof ArrayBuffer) { | |
| 243 | + return decodeDbJson(Buffer.from(value).toString("utf8")); | |
| 244 | + } | |
| 245 | + if (typeof value === "string") { | |
| 246 | + return Option.getOrElse(decodeDbJsonObjectFromString(value), () => ({})); | |
| 247 | + } | |
| 248 | + return Option.getOrElse(decodeDbJsonObject(value), () => ({})); | |
| 249 | +}; | |
| 250 | + | |
| 197 | 251 | const githubFetch = async <T>( |
| 198 | 252 | emulator: Emulator, |
| 199 | 253 | token: string, |
| @@ -236,7 +290,7 @@ const putRepoFiles = async ( · 36 unmodified lines | ||
| 236 | 290 | const tree = await githubFetch<{ sha: string }>( |
| 237 | 291 | emulator, |
| 238 | 292 | token, |
| 239 | − `/repos/${OWNER}/${REPO}/git/trees`, | |
| 293 | + `/repos/${OWNER}/${repo}/git/trees`, | |
| 240 | 294 | { |
| 241 | 295 | method: "POST", |
| 242 | 296 | body: JSON.stringify({ |
| @@ -252,7 +306,7 @@ const putRepoFiles = async ( · 9 unmodified lines | ||
| 252 | 306 | const commit = await githubFetch<{ sha: string }>( |
| 253 | 307 | emulator, |
| 254 | 308 | token, |
| 255 | − `/repos/${OWNER}/${REPO}/git/commits`, | |
| 309 | + `/repos/${OWNER}/${repo}/git/commits`, | |
| 256 | 310 | { |
| 257 | 311 | method: "POST", |
| 258 | 312 | body: JSON.stringify({ |
| @@ -469,19 +523,36 @@ const registerGitHubIntegration = async (emulator: Emulator, token: string): Pro · 210 unmodified lines | ||
| 469 | 523 | |
| 470 | 524 | const sourceUrl = (repo = REPO_FULL_NAME): string => `https://github.com/${repo}`; |
| 471 | 525 | |
| 472 | −const syncSource = ( | |
| 473 | − input: { readonly repo?: string; readonly token?: string } = {}, | |
| 526 | +const addSource = ( | |
| 527 | + input: { readonly name?: string; readonly repo?: string; readonly token?: string } = {}, | |
| 474 | 528 | ): Promise<SyncResult> => |
| 475 | 529 | postJson<SyncResult>("/api/apps/sources/github/sync", { |
| 530 | + name: input.name ?? APP_SLUG, | |
| 476 | 531 | url: sourceUrl(input.repo), |
| 477 | 532 | ...(input.token ? { token: input.token } : {}), |
| 478 | 533 | }); |
| 479 | 534 | |
| 480 | −const listAppTools = (): Promise<readonly ToolRow[]> => | |
| 481 | − requestJson<readonly ToolRow[]>("/api/tools?integration=apps", { | |
| 535 | +const syncSource = ( | |
| 536 | + input: { readonly name?: string; readonly token?: string } = {}, | |
| 537 | +): Promise<SyncResult> => | |
| 538 | + postJson<SyncResult>("/api/apps/sources/github/sync", { | |
| 539 | + slug: input.name ?? APP_SLUG, | |
| 540 | + ...(input.token ? { token: input.token } : {}), | |
| 541 | + }); | |
| 542 | + | |
| 543 | +const listAppTools = (app = APP_SLUG): Promise<readonly ToolRow[]> => | |
| 544 | + requestJson<readonly ToolRow[]>(`/api/tools?integration=${encodeURIComponent(app)}`, { | |
| 482 | 545 | headers: jsonHeaders, |
| 483 | 546 | }); |
| 484 | 547 | |
| 548 | +const getSource = ( | |
| 549 | + app: string, | |
| 550 | +): Promise<{ readonly source: { readonly slug: string; readonly name: string } | null }> => | |
| 551 | + requestJson(`/api/apps/sources/github/${encodeURIComponent(app)}`, { headers: jsonHeaders }); | |
| 552 | + | |
| 553 | +const removeSource = (app: string): Promise<{ readonly removed: boolean }> => | |
| 554 | + deleteJson(`/api/apps/sources/github/${encodeURIComponent(app)}`); | |
| 555 | + | |
| 485 | 556 | const execute = (code: string): Promise<ExecuteResponse> => |
| 486 | 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 | 566 | return response.structured.result; |
| 496 | 567 | }; |
| 497 | 568 | |
| 498 | −const callAppToolCode = (toolName: string, args: unknown): string => ` | |
| 499 | −const found = await tools.search({ namespace: "apps", query: ${JSON.stringify(toolName)}, limit: 20 }); | |
| 569 | +const callAppToolCode = (toolName: string, args: unknown, namespace = APP_SLUG): string => ` | |
| 570 | +const found = await tools.search({ namespace: ${JSON.stringify(namespace)}, query: ${JSON.stringify(toolName)}, limit: 20 }); | |
| 500 | 571 | const item = found.items.find((candidate) => candidate.path.endsWith(${JSON.stringify(toolName)})); |
| 501 | 572 | if (!item) return { ok: false, missing: ${JSON.stringify(toolName)}, found }; |
| 502 | 573 | let fn = tools; |
| @@ -549,10 +620,10 @@ test("custom tool connection schema updates when connections change after sync", · 46 unmodified lines | ||
| 549 | 620 | expect(initialConnections).toEqual([]); |
| 550 | 621 | await putRepoFiles(github, token!, initialFiles()); |
| 551 | 622 | |
| 552 | − const published = await syncSource(); | |
| 623 | + const published = await addSource({ name: SCHEMA_APP_SLUG }); | |
| 553 | 624 | expect(published.status).toBe("published"); |
| 554 | 625 | |
| 555 | − const listed = await listAppTools(); | |
| 626 | + const listed = await listAppTools(SCHEMA_APP_SLUG); | |
| 556 | 627 | const dealSync = listed.find((tool) => tool.name === "deal-pipeline-sync"); |
| 557 | 628 | expect(dealSync).toBeTruthy(); |
| 558 | 629 | const beforeConnection = await requestJson<{ |
| @@ -580,10 +651,14 @@ test("custom tool connection schema updates when connections change after sync", · 21 unmodified lines | ||
| 580 | 651 | expect(afterConnection.inputSchema.required ?? []).not.toContain("github"); |
| 581 | 652 | |
| 582 | 653 | const defaultInvoke = (await executeResult( |
| 583 | − callAppToolCode("deal-pipeline-sync", { | |
| 584 | − owner: OWNER, | |
| 585 | − repo: REPO, | |
| 586 | − }), | |
| 654 | + callAppToolCode( | |
| 655 | + "deal-pipeline-sync", | |
| 656 | + { | |
| 657 | + owner: OWNER, | |
| 658 | + repo: REPO, | |
| 659 | + }, | |
| 660 | + SCHEMA_APP_SLUG, | |
| 661 | + ), | |
| 587 | 662 | )) as { result?: { ok?: boolean; data?: { synced?: number }; error?: unknown } }; |
| 588 | 663 | expect(defaultInvoke.result?.ok).toBe(true); |
| 589 | 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 | 685 | expect(unauthorized.status).toBe(401); |
| 611 | 686 | |
| 612 | − const published = await syncSource(); | |
| 687 | + const published = await addSource({ name: APP_SLUG, token: token! }); | |
| 613 | 688 | expect(published.status).toBe("published"); |
| 614 | 689 | expect(JSON.stringify(published)).not.toContain(token!); |
| 615 | 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 | 696 | expect(tokenUpToDate.status).toBe("up-to-date"); |
| 622 | 697 | expect(JSON.stringify(tokenUpToDate)).not.toContain(token!); |
| 623 | 698 | |
| 624 | − const sourcesList = await requestJson<{ sources: readonly { hasToken: boolean }[] }>( | |
| 625 | − "/api/apps/sources/github", | |
| 626 | − { headers: jsonHeaders }, | |
| 627 | − ); | |
| 628 | − expect(sourcesList.sources[0]?.hasToken).toBe(true); | |
| 699 | + const sourcesList = await requestJson<{ | |
| 700 | + sources: readonly { readonly slug: string; readonly hasToken: boolean }[]; | |
| 701 | + }>("/api/apps/sources/github", { headers: jsonHeaders }); | |
| 702 | + expect(sourcesList.sources.find((source) => source.slug === APP_SLUG)?.hasToken).toBe(true); | |
| 629 | 703 | expect(JSON.stringify(sourcesList)).not.toContain(token!); |
| 630 | 704 | |
| 631 | 705 | const listed = await listAppTools(); |
| 632 | 706 | expect(listed.map((tool) => tool.name).sort()).toEqual(["deal-pipeline-sync", "find-deal-docs"]); |
| 633 | 707 | |
| 634 | 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 | 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 | 834 | expect(afterSkipped.some((tool) => tool.address.includes("workflow"))).toBe(false); |
| 761 | 835 | }); |
| 762 | 836 | |
| 837 | +test("custom tools sources are per-integration and remove tears down owned state", async () => { | |
| 838 | + const credential = await github.credentials.mint({ | |
| 839 | + type: "api-key", | |
| 840 | + login: OWNER, | |
| 841 | + scopes: ["repo", "user"], | |
| 842 | + }); | |
| 843 | + const token = credential.token; | |
| 844 | + expect(token).toBeTruthy(); | |
| 845 | + await registerGitHubIntegration(github, token!); | |
| 846 | + await putRepoFiles(github, token!, initialFiles()); | |
| 847 | + | |
| 848 | + const firstApp = "side-tools-a"; | |
| 849 | + const secondApp = "side-tools-b"; | |
| 850 | + const first = await addSource({ name: firstApp, token: token! }); | |
| 851 | + const second = await addSource({ name: secondApp, token: token! }); | |
| 852 | + expect(first.status).toBe("published"); | |
| 853 | + expect(second.status).toBe("published"); | |
| 854 | + | |
| 855 | + const collision = await addSource({ name: firstApp, token: token! }); | |
| 856 | + expect(collision.status).toBe("failed"); | |
| 857 | + expect(collision.errors?.[0]?.message).toContain( | |
| 858 | + `Integration "${firstApp}" already exists. Choose another name.`, | |
| 859 | + ); | |
| 860 | + | |
| 861 | + const integrations = await requestJson<readonly IntegrationRow[]>("/api/integrations", { | |
| 862 | + headers: jsonHeaders, | |
| 863 | + }); | |
| 864 | + expect(integrations).toEqual( | |
| 865 | + expect.arrayContaining([ | |
| 866 | + expect.objectContaining({ | |
| 867 | + slug: firstApp, | |
| 868 | + name: firstApp, | |
| 869 | + kind: "apps", | |
| 870 | + canRemove: true, | |
| 871 | + displayUrl: sourceUrl(), | |
| 872 | + }), | |
| 873 | + expect.objectContaining({ | |
| 874 | + slug: secondApp, | |
| 875 | + name: secondApp, | |
| 876 | + kind: "apps", | |
| 877 | + canRemove: true, | |
| 878 | + displayUrl: sourceUrl(), | |
| 879 | + }), | |
| 880 | + ]), | |
| 881 | + ); | |
| 882 | + | |
| 883 | + const configRows = await querySqliteRows( | |
| 884 | + dbPath, | |
| 885 | + "SELECT slug, plugin_id, config FROM integration WHERE slug IN (?, ?) ORDER BY slug", | |
| 886 | + [firstApp, secondApp], | |
| 887 | + ); | |
| 888 | + expect(configRows).toHaveLength(2); | |
| 889 | + for (const row of configRows) { | |
| 890 | + expect(row.plugin_id).toBe("apps"); | |
| 891 | + const config = decodeDbJson(row.config); | |
| 892 | + expect(config).toMatchObject({ | |
| 893 | + kind: "github", | |
| 894 | + repoUrl: sourceUrl(), | |
| 895 | + repo: REPO_FULL_NAME, | |
| 896 | + scope: row.slug, | |
| 897 | + }); | |
| 898 | + expect(JSON.stringify(config)).not.toContain(token!); | |
| 899 | + } | |
| 900 | + | |
| 901 | + expect((await listAppTools(firstApp)).map((tool) => tool.address).sort()).toEqual([ | |
| 902 | + `tools.${firstApp}.user.main.deal-pipeline-sync`, | |
| 903 | + `tools.${firstApp}.user.main.find-deal-docs`, | |
| 904 | + ]); | |
| 905 | + expect((await listAppTools(secondApp)).map((tool) => tool.address).sort()).toEqual([ | |
| 906 | + `tools.${secondApp}.user.main.deal-pipeline-sync`, | |
| 907 | + `tools.${secondApp}.user.main.find-deal-docs`, | |
| 908 | + ]); | |
| 909 | + | |
| 910 | + const secondInvoke = (await executeResult( | |
| 911 | + callAppToolCode( | |
| 912 | + "deal-pipeline-sync", | |
| 913 | + { | |
| 914 | + owner: OWNER, | |
| 915 | + repo: REPO, | |
| 916 | + }, | |
| 917 | + secondApp, | |
| 918 | + ), | |
| 919 | + )) as { result?: { ok?: boolean; data?: { synced?: number }; error?: unknown } }; | |
| 920 | + expect(secondInvoke.result?.ok).toBe(true); | |
| 921 | + | |
| 922 | + const secondKey = sourceStorageKey(secondApp); | |
| 923 | + const secondArtifactDir = join(dataDir, "apps", "artifacts", `${secondKey}.git`); | |
| 924 | + const secondScopeDb = join(dataDir, "apps", "scope-db", `${secondKey}.db`); | |
| 925 | + expect(existsSync(secondArtifactDir)).toBe(true); | |
| 926 | + expect(existsSync(secondScopeDb)).toBe(true); | |
| 927 | + | |
| 928 | + const tokenRowsBefore = await querySqliteRows( | |
| 929 | + dbPath, | |
| 930 | + "SELECT key FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", | |
| 931 | + ["encryptedSecrets", "secrets", sourceTokenItemId(secondApp)], | |
| 932 | + ); | |
| 933 | + expect(tokenRowsBefore).toHaveLength(1); | |
| 934 | + | |
| 935 | + expect(await getSource(secondApp)).toMatchObject({ | |
| 936 | + source: { slug: secondApp, name: secondApp }, | |
| 937 | + }); | |
| 938 | + expect(await removeSource(secondApp)).toEqual({ removed: true }); | |
| 939 | + expect(await getSource(secondApp)).toEqual({ source: null }); | |
| 940 | + | |
| 941 | + const secondIntegration = await fetch( | |
| 942 | + `${server.baseUrl}/api/integrations/${encodeURIComponent(secondApp)}`, | |
| 943 | + { headers: jsonHeaders }, | |
| 944 | + ); | |
| 945 | + expect(secondIntegration.status).toBe(404); | |
| 946 | + await secondIntegration.text(); | |
| 947 | + | |
| 948 | + expect(await listAppTools(secondApp)).toEqual([]); | |
| 949 | + expect((await listAppTools(firstApp)).map((tool) => tool.address).sort()).toEqual([ | |
| 950 | + `tools.${firstApp}.user.main.deal-pipeline-sync`, | |
| 951 | + `tools.${firstApp}.user.main.find-deal-docs`, | |
| 952 | + ]); | |
| 953 | + | |
| 954 | + const descriptorRows = await querySqliteRows( | |
| 955 | + join(dataDir, "apps", "store.sqlite"), | |
| 956 | + "SELECT scope FROM descriptors WHERE tenant = ? AND scope = ?", | |
| 957 | + [TEST_ORG, secondApp], | |
| 958 | + ); | |
| 959 | + expect(descriptorRows).toEqual([]); | |
| 960 | + const tokenRowsAfter = await querySqliteRows( | |
| 961 | + dbPath, | |
| 962 | + "SELECT key FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", | |
| 963 | + ["encryptedSecrets", "secrets", sourceTokenItemId(secondApp)], | |
| 964 | + ); | |
| 965 | + expect(tokenRowsAfter).toEqual([]); | |
| 966 | + expect(existsSync(secondArtifactDir)).toBe(false); | |
| 967 | + expect(existsSync(secondScopeDb)).toBe(false); | |
| 968 | +}); | |
| 969 | + | |
| 763 | 970 | test("bridged integration calls inherit the caller approval handler", async () => { |
| 764 | 971 | const credential = await github.credentials.mint({ |
| 765 | 972 | type: "api-key", |
| @@ -771,7 +978,7 @@ test("bridged integration calls inherit the caller approval handler", async () = · 5 unmodified lines | ||
| 771 | 978 | await registerGitHubIntegration(github, token!); |
| 772 | 979 | await createIssue(github, token!, "Approval-gated issue"); |
| 773 | 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 | 982 | expect(published.status).toBe("published"); |
| 776 | 983 | |
| 777 | 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 | 999 | ).length; |
| 793 | 1000 | |
| 794 | 1001 | const response = await executeWithApprovalPause( |
| 795 | − callAppToolCode("approval-bridge", { | |
| 796 | − github: GITHUB_CONNECTION, | |
| 797 | − owner: OWNER, | |
| 798 | − repo: REPO, | |
| 799 | − }), | |
| 1002 | + callAppToolCode( | |
| 1003 | + "approval-bridge", | |
| 1004 | + { | |
| 1005 | + github: GITHUB_CONNECTION, | |
| 1006 | + owner: OWNER, | |
| 1007 | + repo: REPO, | |
| 1008 | + }, | |
| 1009 | + APPROVAL_APP_SLUG, | |
| 1010 | + ), | |
| 800 | 1011 | ); |
| 801 | 1012 | expect(response.status).toBe("paused"); |
| 802 | 1013 | |
| @@ -6,7 +6,9 @@ import appsClientPlugin, { · 5 unmodified lines | ||
| 6 | 6 | type CustomToolsFetch, |
| 7 | 7 | appsIntegrationPlugin, |
| 8 | 8 | formatSyncErrors, |
| 9 | + getCustomToolSource, | |
| 9 | 10 | listCustomToolSources, |
| 11 | + removeCustomToolSource, | |
| 10 | 12 | syncCustomToolSource, |
| 11 | 13 | syncStatusLabel, |
| 12 | 14 | validateGitHubSourceUrl, |
| @@ -48,6 +50,7 @@ describe("custom tools console client", () => { · 35 unmodified lines | ||
| 48 | 50 | |
| 49 | 51 | it("surfaces successful sync and source detail data", async () => { |
| 50 | 52 | let syncBody = ""; |
| 53 | + let removed = false; | |
| 51 | 54 | const fetchImpl: CustomToolsFetch = async (input, init) => { |
| 52 | 55 | const url = String(input); |
| 53 | 56 | if (url.endsWith("/sync")) { |
| @@ -60,10 +63,34 @@ describe("custom tools console client", () => { · 6 unmodified lines | ||
| 60 | 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 | 88 | return jsonResponse({ |
| 64 | 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 | 94 | url: "https://github.com/RhysSullivan/executor-custom-tools-demo", |
| 68 | 95 | repo: "RhysSullivan/executor-custom-tools-demo", |
| 69 | 96 | ref: "main", |
| @@ -80,18 +107,25 @@ describe("custom tools console client", () => { · 10 unmodified lines | ||
| 80 | 107 | |
| 81 | 108 | const syncResult = await syncCustomToolSource( |
| 82 | 109 | { |
| 110 | + name: "demo-tools", | |
| 83 | 111 | url: "https://github.com/RhysSullivan/executor-custom-tools-demo", |
| 84 | 112 | token: "ghp_demo", |
| 85 | 113 | }, |
| 86 | 114 | fetchImpl, |
| 87 | 115 | ); |
| 88 | 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 | 120 | expect(syncStatusLabel(syncResult)).toBe("Published 2 tools."); |
| 91 | 121 | expect(syncBody).toContain("ghp_demo"); |
| 122 | + expect(syncBody).toContain("demo-tools"); | |
| 92 | 123 | expect(syncResult.tools).toEqual(["repo-summary", "stale-issues"]); |
| 124 | + expect(detail.source?.slug).toBe("demo-tools"); | |
| 93 | 125 | expect(listed.sources[0]?.hasToken).toBe(true); |
| 94 | 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 | 131 | it("renders failed sync errors readably", async () => { |
| @@ -1927,6 +1927,19 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1926 unmodified lines | ||
| 1927 | 1927 | if (!existing.can_remove) { |
| 1928 | 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 | 1943 | // Drop owned connections / tools / definitions for this integration. |
| 1931 | 1944 | const where = (b: AnyCb) => b("integration", "=", String(slug)); |
| 1932 | 1945 | yield* core.deleteMany("tool", { where }); |
| @@ -523,6 +523,11 @@ export interface ConnectionLifecycleInput<TStore = unknown> { · 522 unmodified lines | ||
| 523 | 523 | readonly connection: ConnectionRef; |
| 524 | 524 | } |
| 525 | 525 | |
| 526 | +export interface IntegrationLifecycleInput<TStore = unknown> { | |
| 527 | + readonly ctx: PluginCtx<TStore>; | |
| 528 | + readonly integration: IntegrationRecord; | |
| 529 | +} | |
| 530 | + | |
| 526 | 531 | export interface ConfigureIntegrationHandlerInput<TStore = unknown> { |
| 527 | 532 | readonly ctx: PluginCtx<TStore>; |
| 528 | 533 | readonly integration: IntegrationSlug; |
| @@ -668,6 +673,12 @@ export interface PluginSpec< · 139 unmodified lines | ||
| 668 | 673 | input: ConnectionLifecycleInput<TStore>, |
| 669 | 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 | 682 | /** Core-dispatched integration configuration (beyond auth). */ |
| 672 | 683 | readonly integrationConfigure?: IntegrationConfigureDecl<TStore>; |
| 673 | 684 | |
| @@ -3,9 +3,6 @@ export { · 2 unmodified lines | ||
| 3 | 3 | appsPlugin, |
| 4 | 4 | APPS_INTEGRATION_SLUG, |
| 5 | 5 | APPS_PLUGIN_ID, |
| 6 | − APPS_CONNECTION_PREFIX, | |
| 7 | − connectionNameForScope, | |
| 8 | − scopeFromConnection, | |
| 9 | 6 | type AppsPluginOptions, |
| 10 | 7 | } from "./plugin/apps-plugin"; |
| 11 | 8 | export { |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { execFile } from "node:child_process"; |
| 2 | −import { mkdir } from "node:fs/promises"; | |
| 2 | +import { mkdir, rm } from "node:fs/promises"; | |
| 3 | 3 | import { join } from "node:path"; |
| 4 | 4 | |
| 5 | 5 | import { Effect } from "effect"; |
| @@ -301,5 +301,18 @@ export const makeGitArtifactStore = (options: GitArtifactStoreOptions): Artifact · 295 unmodified lines | ||
| 301 | 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 | }; |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { mkdirSync } from "node:fs"; | |
| 1 | +import { mkdirSync, rmSync } from "node:fs"; | |
| 2 | 2 | import { join, resolve } from "node:path"; |
| 3 | 3 | |
| 4 | 4 | import { createClient, type Client } from "@libsql/client"; |
| @@ -160,6 +160,24 @@ export const makeLibsqlScopeDb = (options: LibsqlScopeDbOptions): ScopeDb => { · 155 unmodified lines | ||
| 160 | 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 | 181 | close: () => |
| 164 | 182 | Effect.sync(() => { |
| 165 | 183 | for (const client of clients.values()) client.close(); |
| @@ -7,6 +7,7 @@ import { Effect } from "effect"; · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | import { makeLibsqlScopeDb } from "./libsql-scope-db"; |
| 9 | 9 | import { makeSqliteAppsStore } from "./sqlite-apps-store"; |
| 10 | +import { DESCRIPTOR_VERSION, type AppDescriptor } from "../pipeline/descriptor"; | |
| 10 | 11 | import { scopeAddress } from "../seams/scope-address"; |
| 11 | 12 | |
| 12 | 13 | const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); |
| @@ -46,18 +47,26 @@ describe("scope collision (Fix 9)", () => { · 33 unmodified lines | ||
| 46 | 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 | 51 | const store = makeSqliteAppsStore({ path: ":memory:" }); |
| 51 | − // Both scopes' connection names normalize to the SAME identifier | |
| 52 | − // ("appsMyScope"), but the explicit mapping keys by the ACTUAL stored name, | |
| 53 | − // so each scope's connection maps to the right scope. Simulate two DISTINCT | |
| 54 | − // connection names being recorded for two distinct scopes. | |
| 55 | − await run(store.putScopeForConnection("org", "appsMyScopeDash", "my-scope")); | |
| 56 | − await run(store.putScopeForConnection("org", "appsMyScopeUnderscore", "my_scope")); | |
| 57 | − | |
| 58 | − expect(await run(store.getScopeForConnection("org", "appsMyScopeDash"))).toBe("my-scope"); | |
| 59 | − expect(await run(store.getScopeForConnection("org", "appsMyScopeUnderscore"))).toBe("my_scope"); | |
| 60 | − // An unmapped name returns null (caller falls back to legacy parse). | |
| 61 | − expect(await run(store.getScopeForConnection("org", "unknown"))).toBeNull(); | |
| 52 | + const descriptor = (scope: string): AppDescriptor => ({ | |
| 53 | + version: DESCRIPTOR_VERSION, | |
| 54 | + tenant: "org", | |
| 55 | + scope, | |
| 56 | + description: "test", | |
| 57 | + snapshotId: `${scope}-snapshot`, | |
| 58 | + toolchain: { bundler: "esbuild", bundlerVersion: "test", target: "test" }, | |
| 59 | + tools: [], | |
| 60 | + workflows: [], | |
| 61 | + ui: [], | |
| 62 | + skills: [], | |
| 63 | + skipped: [], | |
| 64 | + }); | |
| 65 | + | |
| 66 | + await run(store.putDescriptor("org", "org", descriptor("my-scope"))); | |
| 67 | + await run(store.putDescriptor("org", "org", descriptor("my_scope"))); | |
| 68 | + | |
| 69 | + expect((await run(store.getDescriptor("org", "my-scope")))?.scope).toBe("my-scope"); | |
| 70 | + expect((await run(store.getDescriptor("org", "my_scope")))?.scope).toBe("my_scope"); | |
| 62 | 71 | }); |
| 63 | 72 | }); |
| @@ -7,19 +7,17 @@ import { Effect, Schema } from "effect"; · 6 unmodified lines | ||
| 7 | 7 | import { StorageError, type StorageFailure } from "@executor-js/sdk"; |
| 8 | 8 | |
| 9 | 9 | import type { AppDescriptor } from "../pipeline/descriptor"; |
| 10 | −import type { AppsStore, GitHubSourceTokenRef } from "../plugin/store"; | |
| 10 | +import type { AppsStore } from "../plugin/store"; | |
| 11 | 11 | |
| 12 | 12 | // --------------------------------------------------------------------------- |
| 13 | 13 | // 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 | |
| 17 | 17 | const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`); |
| 18 | 18 | |
| 19 | 19 | const SCHEMA = ` |
| 20 | 20 | CREATE TABLE IF NOT EXISTS descriptors (tenant TEXT NOT NULL, scope TEXT NOT NULL, snapshot_id TEXT NOT NULL, descriptor TEXT NOT NULL, published_at INTEGER NOT NULL, PRIMARY KEY (tenant, scope)); |
| 21 | −CREATE TABLE IF NOT EXISTS scope_connections (tenant TEXT NOT NULL, connection_name TEXT NOT NULL, scope TEXT NOT NULL, PRIMARY KEY (tenant, connection_name)); | |
| 22 | −CREATE TABLE IF NOT EXISTS github_source_tokens (tenant TEXT NOT NULL, scope TEXT NOT NULL, provider TEXT NOT NULL, item_id TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (tenant, scope)); | |
| 23 | 21 | `; |
| 24 | 22 | |
| 25 | 23 | const 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 | 120 | Effect.tryPromise({ |
| 123 | 121 | try: async () => { |
| 124 | 122 | await init(); |
| 125 | 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", | |
| 127 | − args: [tenant, connectionName, scope], | |
| 128 | − }); | |
| 129 | − }, | |
| 130 | − catch: (cause) => storageFail("putScopeForConnection failed", cause), | |
| 131 | − }), | |
| 132 | − getScopeForConnection: (tenant, connectionName) => | |
| 133 | − Effect.tryPromise({ | |
| 134 | − try: async () => { | |
| 135 | − await init(); | |
| 136 | − const res = await client.execute({ | |
| 137 | − sql: "SELECT scope FROM scope_connections WHERE tenant = ? AND connection_name = ?", | |
| 138 | − args: [tenant, connectionName], | |
| 139 | − }); | |
| 140 | − return res.rows[0] ? String(res.rows[0].scope) : null; | |
| 141 | − }, | |
| 142 | − catch: (cause) => storageFail("getScopeForConnection failed", cause), | |
| 143 | − }), | |
| 144 | − putGitHubSourceTokenRef: (tenant, scope, ref) => | |
| 145 | − Effect.tryPromise({ | |
| 146 | − try: async () => { | |
| 147 | − await init(); | |
| 148 | − await client.execute({ | |
| 149 | − sql: "INSERT INTO github_source_tokens (tenant, scope, provider, item_id, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(tenant, scope) DO UPDATE SET provider=excluded.provider, item_id=excluded.item_id, updated_at=excluded.updated_at", | |
| 150 | − args: [tenant, scope, ref.provider, ref.itemId, ref.updatedAt], | |
| 151 | − }); | |
| 152 | − }, | |
| 153 | − catch: (cause) => storageFail("putGitHubSourceTokenRef failed", cause), | |
| 154 | − }), | |
| 155 | − getGitHubSourceTokenRef: (tenant, scope) => | |
| 156 | − Effect.tryPromise({ | |
| 157 | − try: async (): Promise<GitHubSourceTokenRef | null> => { | |
| 158 | − await init(); | |
| 159 | − const res = await client.execute({ | |
| 160 | − sql: "SELECT provider, item_id, updated_at FROM github_source_tokens WHERE tenant = ? AND scope = ?", | |
| 124 | + sql: "DELETE FROM descriptors WHERE tenant = ? AND scope = ?", | |
| 161 | 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 | }; |
| @@ -34,9 +34,12 @@ export { · 33 unmodified lines | ||
| 34 | 34 | type AppsStoreDeps, |
| 35 | 35 | type GitHubSourceTokenRef, |
| 36 | 36 | descriptorCollection, |
| 37 | − githubSourceTokenCollection, | |
| 38 | − scopeConnectionCollection, | |
| 39 | 37 | } from "./plugin/store"; |
| 38 | +export { | |
| 39 | + slugifyCustomToolsAppName, | |
| 40 | + validateCustomToolsAppSlug, | |
| 41 | + CUSTOM_TOOLS_APP_SLUG_PATTERN, | |
| 42 | +} from "./source/app-slug"; | |
| 40 | 43 | export { |
| 41 | 44 | buildBridge, |
| 42 | 45 | rootsFor, |
| @@ -4,8 +4,9 @@ import { join } from "node:path"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | 6 | import { Effect } from "effect"; |
| 7 | +import { IntegrationSlug } from "@executor-js/sdk"; | |
| 7 | 8 | |
| 8 | −import { appsPlugin, connectionNameForScope } from "./apps-plugin"; | |
| 9 | +import { appsPlugin } from "./apps-plugin"; | |
| 9 | 10 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; |
| 10 | 11 | import { makeInMemoryAppsStore, makeTestResolver } from "../testing"; |
| 11 | 12 | |
| @@ -62,13 +63,43 @@ describe("appsPlugin custom-tools contract", () => { · 50 unmodified lines | ||
| 62 | 63 | }); |
| 63 | 64 | const runtime = host.runtime; |
| 64 | 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 | 96 | await run(runtime.publish({ scope: "rhys", files: prototypeFileSet() })); |
| 68 | 97 | |
| 69 | 98 | const resolved = await run( |
| 70 | 99 | plugin.resolveTools!({ |
| 71 | − connection: { name: appConnection }, | |
| 100 | + ctx, | |
| 101 | + config: appConfig, | |
| 102 | + connection: { name: "main" }, | |
| 72 | 103 | } as never), |
| 73 | 104 | ); |
| 74 | 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 | 112 | const projected = await run( |
| 82 | 113 | plugin.projectToolSchema!({ |
| 83 | − ctx: { owner: { tenant: "org" } }, | |
| 114 | + ctx, | |
| 84 | 115 | toolRow: { |
| 85 | 116 | name: "deal-pipeline-sync", |
| 86 | − connection: appConnection, | |
| 117 | + integration: appIntegration, | |
| 118 | + connection: "main", | |
| 87 | 119 | }, |
| 88 | 120 | inputSchema: syncTool!.inputSchema, |
| 89 | 121 | outputSchema: syncTool!.outputSchema, |
| @@ -100,10 +132,11 @@ describe("appsPlugin custom-tools contract", () => { · 10 unmodified lines | ||
| 100 | 132 | |
| 101 | 133 | const output = await run( |
| 102 | 134 | plugin.invokeTool!({ |
| 103 | − ctx: {}, | |
| 135 | + ctx, | |
| 104 | 136 | toolRow: { |
| 105 | 137 | name: "deal-pipeline-sync", |
| 106 | − connection: appConnection, | |
| 138 | + integration: appIntegration, | |
| 139 | + connection: "main", | |
| 107 | 140 | }, |
| 108 | 141 | args: { |
| 109 | 142 | crm: "tools.dealcloud.user.crm-main", |
| @@ -1,36 +1,48 @@ | ||
| 1 | 1 | import { Effect, Result } from "effect"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | + AuthTemplateSlug, | |
| 4 | 5 | definePlugin, |
| 5 | − tool, | |
| 6 | − ToolName, | |
| 7 | 6 | IntegrationSlug, |
| 8 | − AuthTemplateSlug, | |
| 9 | − connectionIdentifier, | |
| 10 | 7 | ProviderItemId, |
| 11 | 8 | ProviderKey, |
| 9 | + ToolName, | |
| 10 | + connectionIdentifier, | |
| 11 | + tool, | |
| 12 | + type IntegrationRecord, | |
| 13 | + type InvokeToolInput, | |
| 12 | 14 | type PluginCtx, |
| 13 | 15 | type ResolveToolsInput, |
| 14 | 16 | type ResolveToolsResult, |
| 15 | − type InvokeToolInput, | |
| 16 | 17 | type ToolDef, |
| 17 | 18 | } from "@executor-js/sdk"; |
| 18 | 19 | |
| 19 | −import type { AppsRuntime } from "./runtime"; | |
| 20 | −import { makeAppsStore } from "./store"; | |
| 21 | −import type { ClientResolver, ConnectionCandidate } from "./bindings"; | |
| 22 | 20 | import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor"; |
| 23 | 21 | import { PublishError } from "../pipeline/discover"; |
| 24 | 22 | import { |
| 25 | 23 | parseGitHubSourceUrl, |
| 26 | 24 | syncGitHubSource, |
| 25 | + type GitHubSkippedArtifact, | |
| 27 | 26 | type GitHubSyncResult, |
| 28 | 27 | } from "../source/github-source"; |
| 28 | +import { slugifyCustomToolsAppName, validateCustomToolsAppSlug } from "../source/app-slug"; | |
| 29 | +import type { AppsRuntime, GitHubCustomToolsSourceSummary } from "./runtime"; | |
| 30 | +import { makeAppsStore, type AppDescriptorRecord, type GitHubSourceTokenRef } from "./store"; | |
| 31 | +import type { ClientResolver, ConnectionCandidate } from "./bindings"; | |
| 29 | 32 | |
| 30 | 33 | export const APPS_INTEGRATION_SLUG = "apps"; |
| 31 | 34 | export const APPS_PLUGIN_ID = "apps"; |
| 32 | 35 | |
| 33 | −const DEFAULT_CATALOG_SCOPE = "default"; | |
| 36 | +const APP_CONNECTION_NAME = connectionIdentifier("main"); | |
| 37 | + | |
| 38 | +interface AppsGitHubSourceConfig { | |
| 39 | + readonly kind: "github"; | |
| 40 | + readonly repoUrl: string; | |
| 41 | + readonly repo: string; | |
| 42 | + readonly scope: string; | |
| 43 | + readonly ref?: string; | |
| 44 | + readonly token?: GitHubSourceTokenRef; | |
| 45 | +} | |
| 34 | 46 | |
| 35 | 47 | export interface AppsPluginOptions { |
| 36 | 48 | readonly runtime: AppsRuntime; |
| @@ -48,6 +60,9 @@ interface AppsStoreShape { · 11 unmodified lines | ||
| 48 | 60 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 49 | 61 | value !== null && typeof value === "object" && !Array.isArray(value); |
| 50 | 62 | |
| 63 | +const asString = (value: unknown): string | undefined => | |
| 64 | + typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; | |
| 65 | + | |
| 51 | 66 | const unique = (values: readonly string[]): readonly string[] => [...new Set(values)]; |
| 52 | 67 | |
| 53 | 68 | const 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 | |
| 66 | −const sourceScopeFor = (repo: string): string => | |
| 67 | − String(connectionIdentifier(`github ${repo}`, "githubSource")); | |
| 68 | − | |
| 69 | 81 | const sourceTokenItemId = (tenant: string, scope: string): ProviderItemId => |
| 70 | 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 | 86 | ? config.baseUrl |
| 75 | 87 | : undefined; |
| 76 | 88 | |
| 89 | +const decodeSourceConfig = (config: unknown): AppsGitHubSourceConfig | null => { | |
| 90 | + if (!isRecord(config) || config.kind !== "github") return null; | |
| 91 | + const repoUrl = asString(config.repoUrl); | |
| 92 | + const repo = asString(config.repo); | |
| 93 | + const scope = asString(config.scope); | |
| 94 | + if (!repoUrl || !repo || !scope) return null; | |
| 95 | + const ref = asString(config.ref); | |
| 96 | + const token = isRecord(config.token) | |
| 97 | + ? { | |
| 98 | + provider: asString(config.token.provider) ?? "", | |
| 99 | + itemId: asString(config.token.itemId) ?? "", | |
| 100 | + updatedAt: typeof config.token.updatedAt === "number" ? config.token.updatedAt : 0, | |
| 101 | + } | |
| 102 | + : undefined; | |
| 103 | + return { | |
| 104 | + kind: "github", | |
| 105 | + repoUrl, | |
| 106 | + repo, | |
| 107 | + scope, | |
| 108 | + ...(ref ? { ref } : {}), | |
| 109 | + ...(token && token.provider && token.itemId ? { token } : {}), | |
| 110 | + }; | |
| 111 | +}; | |
| 112 | + | |
| 77 | 113 | const projectInputSchema = ( |
| 78 | 114 | schema: unknown, |
| 79 | 115 | integrations: Readonly<Record<string, IntegrationDecl>>, |
| @@ -113,6 +149,16 @@ const projectInputSchema = ( · 33 unmodified lines | ||
| 113 | 149 | return projected; |
| 114 | 150 | }; |
| 115 | 151 | |
| 152 | +const projectTool = (descriptor: ToolDescriptor): ToolDef => ({ | |
| 153 | + name: ToolName.make(descriptor.name), | |
| 154 | + description: descriptor.description, | |
| 155 | + inputSchema: descriptor.inputSchema, | |
| 156 | + outputSchema: descriptor.outputSchema, | |
| 157 | + annotations: { | |
| 158 | + requiresApproval: descriptor.annotations?.destructive === true, | |
| 159 | + }, | |
| 160 | +}); | |
| 161 | + | |
| 116 | 162 | export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { |
| 117 | 163 | const runtime = options?.runtime as AppsRuntime; |
| 118 | 164 | const makeResolver = options?.makeResolver; |
| @@ -122,91 +168,274 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 3 unmodified lines | ||
| 122 | 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 | 171 | const storeSourceToken = ( |
| 132 | 172 | ctx: PluginCtx<AppsStoreShape>, |
| 133 | 173 | tenant: string, |
| 134 | 174 | scope: string, |
| 135 | 175 | token: string, |
| 136 | − ): Effect.Effect<boolean> => | |
| 176 | + ): Effect.Effect<GitHubSourceTokenRef | null> => | |
| 137 | 177 | Effect.gen(function* () { |
| 138 | 178 | const itemId = sourceTokenItemId(tenant, scope); |
| 139 | 179 | const provider = yield* ctx.providers.setDefault(itemId, token).pipe(Effect.result); |
| 140 | − if (Result.isFailure(provider)) return false; | |
| 141 | − const stored = yield* runtime.deps.store | |
| 142 | − .putGitHubSourceTokenRef(tenant, scope, { | |
| 143 | − provider: String(provider.success), | |
| 144 | − itemId: String(itemId), | |
| 145 | − updatedAt: Date.now(), | |
| 146 | − }) | |
| 147 | − .pipe(Effect.result); | |
| 148 | − return Result.isSuccess(stored); | |
| 180 | + if (Result.isFailure(provider)) return null; | |
| 181 | + return { | |
| 182 | + provider: String(provider.success), | |
| 183 | + itemId: String(itemId), | |
| 184 | + updatedAt: Date.now(), | |
| 185 | + }; | |
| 149 | 186 | }); |
| 150 | 187 | |
| 151 | 188 | const storedSourceToken = ( |
| 152 | 189 | ctx: PluginCtx<AppsStoreShape>, |
| 153 | − tenant: string, | |
| 154 | − scope: string, | |
| 155 | − ): Effect.Effect<string | null> => | |
| 156 | − Effect.gen(function* () { | |
| 157 | − const ref = yield* runtime.deps.store | |
| 158 | − .getGitHubSourceTokenRef(tenant, scope) | |
| 159 | − .pipe(Effect.orElseSucceed(() => null)); | |
| 160 | − if (!ref) return null; | |
| 161 | − return yield* ctx.providers | |
| 162 | − .get(ProviderKey.make(ref.provider), ProviderItemId.make(ref.itemId)) | |
| 163 | − .pipe(Effect.orElseSucceed(() => null)); | |
| 164 | − }); | |
| 190 | + ref: GitHubSourceTokenRef | undefined, | |
| 191 | + ): Effect.Effect<string | null> => { | |
| 192 | + if (!ref) return Effect.succeed(null); | |
| 193 | + return ctx.providers | |
| 194 | + .get(ProviderKey.make(ref.provider), ProviderItemId.make(ref.itemId)) | |
| 195 | + .pipe(Effect.orElseSucceed(() => null)); | |
| 196 | + }; | |
| 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 | 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 | 208 | const conns = yield* ctx.connections |
| 186 | 209 | .list({ integration: slug }) |
| 187 | 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 | 212 | yield* ctx.connections.create({ |
| 190 | 213 | owner: "user", |
| 191 | − name: connName, | |
| 214 | + name: APP_CONNECTION_NAME, | |
| 192 | 215 | integration: slug, |
| 193 | 216 | template: AuthTemplateSlug.make("none"), |
| 194 | 217 | value: "", |
| 195 | 218 | }); |
| 196 | 219 | } |
| 197 | − yield* runtime.deps.store | |
| 198 | − .putScopeForConnection(tenant, String(rawName), scope) | |
| 199 | − .pipe(Effect.orElseSucceed(() => undefined)); | |
| 200 | − yield* runtime.deps.store | |
| 201 | − .putScopeForConnection(tenant, String(connName), scope) | |
| 202 | − .pipe(Effect.orElseSucceed(() => undefined)); | |
| 203 | − return { | |
| 220 | + return { owner: "user" as const, integration: slug, name: APP_CONNECTION_NAME }; | |
| 221 | + }); | |
| 222 | + | |
| 223 | + const refreshAppConnection = (slug: IntegrationSlug, ctx: PluginCtx<AppsStoreShape>) => | |
| 224 | + Effect.gen(function* () { | |
| 225 | + const ref = yield* ensureAppConnection(slug, ctx); | |
| 226 | + yield* ctx.connections.refresh(ref).pipe(Effect.orElseSucceed(() => [])); | |
| 227 | + return ref; | |
| 228 | + }); | |
| 229 | + | |
| 230 | + const descriptorRecordByScope = (tenant: string) => | |
| 231 | + runtime.deps.store.listDescriptors(tenant).pipe( | |
| 232 | + Effect.orElseSucceed(() => []), | |
| 233 | + Effect.map((records) => new Map(records.map((record) => [record.descriptor.scope, record]))), | |
| 234 | + ); | |
| 235 | + | |
| 236 | + const sourceSummaryFor = ( | |
| 237 | + integration: IntegrationRecord, | |
| 238 | + recordsByScope: ReadonlyMap<string, AppDescriptorRecord>, | |
| 239 | + ): GitHubCustomToolsSourceSummary | null => { | |
| 240 | + const config = decodeSourceConfig(integration.config); | |
| 241 | + if (!config) return null; | |
| 242 | + const record = recordsByScope.get(config.scope); | |
| 243 | + if (!record) return null; | |
| 244 | + const descriptor = record.descriptor; | |
| 245 | + const source = descriptor?.source; | |
| 246 | + if (source?.kind !== "github") return null; | |
| 247 | + return { | |
| 248 | + slug: String(integration.slug), | |
| 249 | + name: integration.name, | |
| 250 | + scope: descriptor.scope, | |
| 251 | + url: source.url, | |
| 252 | + repo: source.repo, | |
| 253 | + ref: source.ref, | |
| 254 | + hasToken: config.token !== undefined, | |
| 255 | + upstreamSha: source.upstreamSha, | |
| 256 | + snapshotId: descriptor.snapshotId, | |
| 257 | + ...(descriptor.description ? { description: descriptor.description } : {}), | |
| 258 | + publishedAt: new Date(record.publishedAt).toISOString(), | |
| 259 | + tools: descriptor.tools.map((toolDesc) => toolDesc.name), | |
| 260 | + skipped: [ | |
| 261 | + ...(source.skipped ?? []), | |
| 262 | + ...(descriptor.skipped as readonly GitHubSkippedArtifact[]), | |
| 263 | + ], | |
| 264 | + }; | |
| 265 | + }; | |
| 266 | + | |
| 267 | + const listSources = (ctx: PluginCtx<AppsStoreShape>) => | |
| 268 | + Effect.gen(function* () { | |
| 269 | + const tenant = tenantFor(ctx); | |
| 270 | + const recordsByScope = yield* descriptorRecordByScope(tenant); | |
| 271 | + const integrations = yield* ctx.core.integrations.list().pipe(Effect.orElseSucceed(() => [])); | |
| 272 | + const sources: GitHubCustomToolsSourceSummary[] = []; | |
| 273 | + for (const integration of integrations) { | |
| 274 | + if (integration.kind !== APPS_PLUGIN_ID) continue; | |
| 275 | + const record = yield* ctx.core.integrations | |
| 276 | + .get(integration.slug) | |
| 277 | + .pipe(Effect.orElseSucceed(() => null)); | |
| 278 | + if (!record) continue; | |
| 279 | + const summary = sourceSummaryFor(record, recordsByScope); | |
| 280 | + if (summary) sources.push(summary); | |
| 281 | + } | |
| 282 | + return sources.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)); | |
| 283 | + }); | |
| 284 | + | |
| 285 | + const getSource = (ctx: PluginCtx<AppsStoreShape>, slug: string) => | |
| 286 | + Effect.gen(function* () { | |
| 287 | + const tenant = tenantFor(ctx); | |
| 288 | + const recordsByScope = yield* descriptorRecordByScope(tenant); | |
| 289 | + const record = yield* ctx.core.integrations | |
| 290 | + .get(IntegrationSlug.make(slug)) | |
| 291 | + .pipe(Effect.orElseSucceed(() => null)); | |
| 292 | + return record && record.kind === APPS_PLUGIN_ID | |
| 293 | + ? sourceSummaryFor(record, recordsByScope) | |
| 294 | + : null; | |
| 295 | + }); | |
| 296 | + | |
| 297 | + const syncExistingSource = ( | |
| 298 | + ctx: PluginCtx<AppsStoreShape>, | |
| 299 | + slugValue: string, | |
| 300 | + providedToken: string | null, | |
| 301 | + ) => | |
| 302 | + Effect.gen(function* () { | |
| 303 | + const slug = IntegrationSlug.make(slugValue); | |
| 304 | + const record = yield* ctx.core.integrations.get(slug).pipe(Effect.orElseSucceed(() => null)); | |
| 305 | + const config = record?.kind === APPS_PLUGIN_ID ? decodeSourceConfig(record.config) : null; | |
| 306 | + if (!record || !config) { | |
| 307 | + return syncFailure(`Custom tools source "${slugValue}" does not exist.`); | |
| 308 | + } | |
| 309 | + const github = yield* ctx.core.integrations | |
| 310 | + .get(IntegrationSlug.make("github")) | |
| 311 | + .pipe(Effect.orElseSucceed(() => null)); | |
| 312 | + const tenant = tenantFor(ctx); | |
| 313 | + const token = providedToken ?? (yield* storedSourceToken(ctx, config.token)); | |
| 314 | + const result = yield* syncGitHubSource({ | |
| 315 | + runtime, | |
| 316 | + tenant, | |
| 317 | + scope: config.scope, | |
| 318 | + url: config.repoUrl, | |
| 319 | + ...(config.ref ? { ref: config.ref } : {}), | |
| 320 | + token, | |
| 321 | + baseUrl: configBaseUrl(github?.config), | |
| 322 | + }); | |
| 323 | + if (result.status === "failed") return result; | |
| 324 | + | |
| 325 | + const tokenRef = providedToken | |
| 326 | + ? yield* storeSourceToken(ctx, tenant, config.scope, providedToken) | |
| 327 | + : config.token; | |
| 328 | + if (providedToken && !tokenRef) { | |
| 329 | + return syncFailure( | |
| 330 | + "No writable credential provider is available to store the GitHub source token.", | |
| 331 | + ); | |
| 332 | + } | |
| 333 | + const descriptor = yield* runtime.getDescriptor(tenant, config.scope); | |
| 334 | + const source = descriptor?.source?.kind === "github" ? descriptor.source : null; | |
| 335 | + const nextConfig: AppsGitHubSourceConfig = { | |
| 336 | + ...config, | |
| 337 | + ...(source | |
| 338 | + ? { | |
| 339 | + repoUrl: source.url, | |
| 340 | + repo: source.repo, | |
| 341 | + ref: source.ref, | |
| 342 | + } | |
| 343 | + : {}), | |
| 344 | + ...(tokenRef ? { token: tokenRef } : {}), | |
| 345 | + }; | |
| 346 | + yield* ctx.core.integrations.update(slug, { | |
| 347 | + ...(descriptor?.description ? { description: descriptor.description } : {}), | |
| 348 | + config: nextConfig, | |
| 349 | + }); | |
| 350 | + yield* refreshAppConnection(slug, ctx); | |
| 351 | + return result; | |
| 352 | + }); | |
| 353 | + | |
| 354 | + const addSource = ( | |
| 355 | + ctx: PluginCtx<AppsStoreShape>, | |
| 356 | + input: { | |
| 357 | + readonly url: string; | |
| 358 | + readonly ref?: string; | |
| 359 | + readonly name?: string; | |
| 360 | + readonly token: string | null; | |
| 361 | + }, | |
| 362 | + ) => | |
| 363 | + Effect.gen(function* () { | |
| 364 | + const parsed = parseGitHubSourceUrl(input.url, { ref: input.ref }); | |
| 365 | + if (!parsed.ok) return syncFailure(parsed.message, input.url); | |
| 366 | + const rawName = input.name && input.name.trim().length > 0 ? input.name : parsed.value.name; | |
| 367 | + const slugValue = slugifyCustomToolsAppName(rawName); | |
| 368 | + const slugError = validateCustomToolsAppSlug(slugValue); | |
| 369 | + if (slugError) return syncFailure(slugError); | |
| 370 | + const slug = IntegrationSlug.make(slugValue); | |
| 371 | + const existing = yield* ctx.core.integrations | |
| 372 | + .get(slug) | |
| 373 | + .pipe(Effect.orElseSucceed(() => null)); | |
| 374 | + if (existing) { | |
| 375 | + return syncFailure(`Integration "${slugValue}" already exists. Choose another name.`); | |
| 376 | + } | |
| 377 | + | |
| 378 | + const github = yield* ctx.core.integrations | |
| 379 | + .get(IntegrationSlug.make("github")) | |
| 380 | + .pipe(Effect.orElseSucceed(() => null)); | |
| 381 | + const tenant = tenantFor(ctx); | |
| 382 | + const scope = slugValue; | |
| 383 | + const result = yield* syncGitHubSource({ | |
| 384 | + runtime, | |
| 385 | + tenant, | |
| 386 | + scope, | |
| 387 | + url: input.url, | |
| 388 | + ...(input.ref ? { ref: input.ref } : {}), | |
| 389 | + token: input.token, | |
| 390 | + baseUrl: configBaseUrl(github?.config), | |
| 391 | + }); | |
| 392 | + if (result.status === "failed") return result; | |
| 393 | + | |
| 394 | + const tokenRef = input.token | |
| 395 | + ? yield* storeSourceToken(ctx, tenant, scope, input.token) | |
| 396 | + : undefined; | |
| 397 | + if (input.token && !tokenRef) { | |
| 398 | + yield* runtime.removeSource({ tenant, scope }).pipe(Effect.orElseSucceed(() => undefined)); | |
| 399 | + return syncFailure( | |
| 400 | + "No writable credential provider is available to store the GitHub source token.", | |
| 401 | + ); | |
| 402 | + } | |
| 403 | + | |
| 404 | + const descriptor = yield* runtime.getDescriptor(tenant, scope); | |
| 405 | + const source = descriptor?.source?.kind === "github" ? descriptor.source : null; | |
| 406 | + const config: AppsGitHubSourceConfig = { | |
| 407 | + kind: "github", | |
| 408 | + repoUrl: source?.url ?? input.url.trim(), | |
| 409 | + repo: source?.repo ?? parsed.value.repo, | |
| 204 | 410 | scope, |
| 205 | − connection: String(connName), | |
| 206 | − ref: { owner: "user" as const, integration: slug, name: connName }, | |
| 411 | + ...(source?.ref ? { ref: source.ref } : input.ref ? { ref: input.ref } : {}), | |
| 412 | + ...(tokenRef ? { token: tokenRef } : {}), | |
| 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 | 439 | return { |
| 211 | 440 | id: APPS_PLUGIN_ID as "apps", |
| 212 | 441 | packageName: "@executor-js/plugin-apps", |
| @@ -224,16 +453,6 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 11 unmodified lines | ||
| 224 | 453 | schema: { Type: {} as Record<string, unknown> }, |
| 225 | 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 | 458 | extension: () => ({ runtime }), |
| @@ -244,19 +463,6 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 4 unmodified lines | ||
| 244 | 463 | kind: "executor", |
| 245 | 464 | name: "Apps", |
| 246 | 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 | 466 | tool<AppsStoreShape>({ |
| 261 | 467 | name: "sync_github_source", |
| 262 | 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 | 471 | const payload = isRecord(args) ? args : {}; |
| 266 | 472 | const url = |
| 267 | 473 | typeof payload.url === "string" |
| 268 | − ? payload.url | |
| 474 | + ? payload.url.trim() | |
| 269 | 475 | : typeof payload.repo === "string" |
| 270 | − ? payload.repo | |
| 476 | + ? payload.repo.trim() | |
| 271 | 477 | : ""; |
| 272 | − const ref = | |
| 273 | − typeof payload.ref === "string" && payload.ref.trim().length > 0 | |
| 274 | − ? payload.ref.trim() | |
| 275 | − : undefined; | |
| 276 | − const providedToken = | |
| 277 | − typeof payload.token === "string" && payload.token.trim().length > 0 | |
| 278 | − ? payload.token.trim() | |
| 279 | − : null; | |
| 280 | − const explicitScope = typeof payload.scope === "string" ? payload.scope : undefined; | |
| 478 | + const ref = asString(payload.ref); | |
| 479 | + const providedToken = asString(payload.token) ?? null; | |
| 480 | + const slug = asString(payload.slug); | |
| 481 | + if (slug && !url) return yield* syncExistingSource(ctx, slug, providedToken); | |
| 281 | 482 | if (!url) return syncFailure('sync_github_source requires "url"'); |
| 282 | − | |
| 283 | − const parsed = parseGitHubSourceUrl(url, { ref }); | |
| 284 | − if (!parsed.ok) return syncFailure(parsed.message, url); | |
| 285 | − const integration = yield* ctx.core.integrations | |
| 286 | − .get(IntegrationSlug.make("github")) | |
| 287 | − .pipe(Effect.orElseSucceed(() => null)); | |
| 288 | − const tenant = tenantFor(ctx); | |
| 289 | − const scope = explicitScope ?? sourceScopeFor(parsed.value.repo); | |
| 290 | − if (providedToken) { | |
| 291 | − const stored = yield* storeSourceToken(ctx, tenant, scope, providedToken); | |
| 292 | − if (!stored) { | |
| 293 | − return syncFailure( | |
| 294 | − "No writable credential provider is available to store the GitHub source token.", | |
| 295 | − ); | |
| 296 | − } | |
| 297 | − } | |
| 298 | − const token = providedToken ?? (yield* storedSourceToken(ctx, tenant, scope)); | |
| 299 | − const result = yield* syncGitHubSource({ | |
| 300 | − runtime, | |
| 301 | − tenant, | |
| 302 | − scope, | |
| 483 | + return yield* addSource(ctx, { | |
| 303 | 484 | url, |
| 304 | − ref, | |
| 305 | − token, | |
| 306 | − baseUrl: configBaseUrl(integration?.config), | |
| 485 | + ...(ref ? { ref } : {}), | |
| 486 | + ...(asString(payload.name) ? { name: asString(payload.name) } : {}), | |
| 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 | 491 | tool<AppsStoreShape>({ |
| @@ -317,20 +493,44 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 1 unmodified line | ||
| 317 | 493 | description: "List synced GitHub repositories that publish custom tools.", |
| 318 | 494 | execute: (_args, { ctx }) => |
| 319 | 495 | Effect.gen(function* () { |
| 320 | − const tenant = tenantFor(ctx); | |
| 321 | − const sources = yield* runtime.listGitHubSources(tenant); | |
| 496 | + const sources = yield* listSources(ctx); | |
| 322 | 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 | 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 | 532 | const tenant = ctx ? tenantFor(ctx) : "org"; |
| 332 | − const scope = yield* scopeFor(tenant, String(connection.name)); | |
| 333 | − const descriptor = yield* runtime.getDescriptor(tenant, scope); | |
| 533 | + const descriptor = yield* runtime.getDescriptor(tenant, source.scope); | |
| 334 | 534 | if (!descriptor) return { tools: [] } satisfies ResolveToolsResult; |
| 335 | 535 | const tools: ToolDef[] = []; |
| 336 | 536 | for (const t of descriptor.tools) { |
| @@ -342,12 +542,13 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 5 unmodified lines | ||
| 342 | 542 | projectToolSchema: ({ ctx, toolRow, inputSchema, outputSchema }) => |
| 343 | 543 | Effect.gen(function* () { |
| 344 | 544 | const tenant = tenantFor(ctx); |
| 345 | − const scope = yield* scopeFor(tenant, String(toolRow.connection)); | |
| 346 | − const descriptor = yield* runtime.getDescriptor(tenant, scope); | |
| 545 | + const source = yield* configForToolRow(ctx, IntegrationSlug.make(toolRow.integration)); | |
| 546 | + if (!source) return { inputSchema, outputSchema }; | |
| 547 | + const descriptor = yield* runtime.getDescriptor(tenant, source.scope); | |
| 347 | 548 | const toolDesc = descriptor?.tools.find((t) => t.name === toolRow.name); |
| 348 | 549 | if (!toolDesc) return { inputSchema, outputSchema }; |
| 349 | 550 | const resolver = makeResolver |
| 350 | − ? makeResolver({ ctx, scope, tool: toolRow.name }) | |
| 551 | + ? makeResolver({ ctx, scope: source.scope, tool: toolRow.name }) | |
| 351 | 552 | : runtime.deps.resolver; |
| 352 | 553 | const byRole: Record<string, readonly ConnectionCandidate[]> = {}; |
| 353 | 554 | for (const [role, decl] of Object.entries(toolDesc.integrations)) { |
| @@ -364,11 +565,18 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 10 unmodified lines | ||
| 364 | 565 | invokeTool: ({ ctx, toolRow, args, invokeOptions }: InvokeToolInput<AppsStoreShape>) => |
| 365 | 566 | Effect.gen(function* () { |
| 366 | 567 | const tenant = tenantFor(ctx); |
| 367 | − const scope = yield* scopeFor(tenant, String(toolRow.connection)); | |
| 368 | − const descriptor = yield* runtime.getDescriptor(tenant, scope); | |
| 568 | + const source = yield* configForToolRow(ctx, IntegrationSlug.make(toolRow.integration)); | |
| 569 | + if (!source) { | |
| 570 | + return yield* new PublishError({ | |
| 571 | + message: `apps integration "${toolRow.integration}" has no custom-tools source config`, | |
| 572 | + stage: "project", | |
| 573 | + diagnostics: [], | |
| 574 | + }); | |
| 575 | + } | |
| 576 | + const descriptor = yield* runtime.getDescriptor(tenant, source.scope); | |
| 369 | 577 | if (!descriptor) { |
| 370 | 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 | 580 | stage: "project", |
| 373 | 581 | diagnostics: [], |
| 374 | 582 | }); |
| @@ -376,17 +584,17 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 1 unmodified line | ||
| 376 | 584 | const toolDesc = descriptor.tools.find((t) => t.name === toolRow.name); |
| 377 | 585 | if (!toolDesc) { |
| 378 | 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 | 588 | stage: "project", |
| 381 | 589 | diagnostics: [], |
| 382 | 590 | }); |
| 383 | 591 | } |
| 384 | 592 | const resolver = makeResolver |
| 385 | − ? makeResolver({ ctx, scope, tool: toolRow.name }) | |
| 593 | + ? makeResolver({ ctx, scope: source.scope, tool: toolRow.name }) | |
| 386 | 594 | : undefined; |
| 387 | 595 | return yield* runtime.invokeTool({ |
| 388 | 596 | tenant, |
| 389 | − scope, | |
| 597 | + scope: source.scope, | |
| 390 | 598 | tool: toolRow.name, |
| 391 | 599 | args, |
| 392 | 600 | resolver, |
| @@ -395,36 +603,3 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 2 unmodified lines | ||
| 395 | 603 | }), |
| 396 | 604 | }; |
| 397 | 605 | }); |
| 398 | − | |
| 399 | −const projectTool = (descriptor: ToolDescriptor): ToolDef => ({ | |
| 400 | − name: ToolName.make(descriptor.name), | |
| 401 | − description: descriptor.description, | |
| 402 | − inputSchema: descriptor.inputSchema, | |
| 403 | − outputSchema: descriptor.outputSchema, | |
| 404 | − annotations: { | |
| 405 | − requiresApproval: descriptor.annotations?.destructive === true, | |
| 406 | − }, | |
| 407 | −}); | |
| 408 | − | |
| 409 | −export const APPS_CONNECTION_PREFIX = APPS_INTEGRATION_SLUG; | |
| 410 | − | |
| 411 | −const pascal = (value: string): string => | |
| 412 | − value.length === 0 ? value : `${value[0]!.toUpperCase()}${value.slice(1)}`; | |
| 413 | − | |
| 414 | −export const connectionNameForScope = (scope: string): string => | |
| 415 | − `${APPS_CONNECTION_PREFIX}${pascal(scope)}`; | |
| 416 | − | |
| 417 | −export const scopeFromConnection = (connectionName: string): string => { | |
| 418 | − if (connectionName.startsWith(`${APPS_INTEGRATION_SLUG}/`)) { | |
| 419 | − return connectionName.slice(APPS_INTEGRATION_SLUG.length + 1); | |
| 420 | − } | |
| 421 | − if ( | |
| 422 | − connectionName.startsWith(APPS_CONNECTION_PREFIX) && | |
| 423 | − connectionName.length > APPS_CONNECTION_PREFIX.length | |
| 424 | − ) { | |
| 425 | − const rest = connectionName.slice(APPS_CONNECTION_PREFIX.length); | |
| 426 | − return `${rest[0]!.toLowerCase()}${rest.slice(1)}`; | |
| 427 | − } | |
| 428 | − const slash = connectionName.indexOf("/"); | |
| 429 | − return slash === -1 ? connectionName : connectionName.slice(slash + 1); | |
| 430 | −}; | |
| @@ -42,6 +42,8 @@ export interface AppsRuntimeDeps { · 41 unmodified lines | ||
| 42 | 42 | } |
| 43 | 43 | |
| 44 | 44 | export interface GitHubCustomToolsSourceSummary { |
| 45 | + readonly slug: string; | |
| 46 | + readonly name: string; | |
| 45 | 47 | readonly scope: string; |
| 46 | 48 | readonly url: string; |
| 47 | 49 | readonly repo: string; |
| @@ -71,6 +73,10 @@ export interface AppsRuntime { · 23 unmodified lines | ||
| 71 | 73 | readonly listGitHubSources: ( |
| 72 | 74 | tenant?: string, |
| 73 | 75 | ) => Effect.Effect<readonly GitHubCustomToolsSourceSummary[]>; |
| 76 | + readonly removeSource: (input: { | |
| 77 | + readonly tenant?: string; | |
| 78 | + readonly scope: string; | |
| 79 | + }) => Effect.Effect<void, PublishError>; | |
| 74 | 80 | /** Re-derive the published-descriptor pointer from the latest committed |
| 75 | 81 | * snapshot. */ |
| 76 | 82 | readonly repair: ( |
| @@ -382,30 +388,27 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 305 unmodified lines | ||
| 382 | 388 | const source = record.descriptor.source; |
| 383 | 389 | return source?.kind === "github" ? [{ record, source }] : []; |
| 384 | 390 | }); |
| 385 | − return Effect.forEach(githubRecords, ({ record, source }) => | |
| 386 | − Effect.gen(function* () { | |
| 387 | − const { descriptor, publishedAt } = record; | |
| 388 | − const tokenRef = yield* deps.store | |
| 389 | − .getGitHubSourceTokenRef(tenant, descriptor.scope) | |
| 390 | − .pipe(Effect.orElseSucceed(() => null)); | |
| 391 | − return { | |
| 392 | − scope: descriptor.scope, | |
| 393 | − url: source.url, | |
| 394 | − repo: source.repo, | |
| 395 | − ref: source.ref, | |
| 396 | − hasToken: tokenRef !== null, | |
| 397 | − upstreamSha: source.upstreamSha, | |
| 398 | − snapshotId: descriptor.snapshotId, | |
| 399 | − ...(descriptor.description ? { description: descriptor.description } : {}), | |
| 400 | − publishedAt: new Date(publishedAt).toISOString(), | |
| 401 | − tools: descriptor.tools.map((tool) => tool.name), | |
| 402 | − skipped: [ | |
| 403 | − ...(source.skipped ?? []), | |
| 404 | − ...(descriptor.skipped as readonly GitHubSkippedArtifact[]), | |
| 405 | − ], | |
| 406 | − } satisfies GitHubCustomToolsSourceSummary; | |
| 407 | − }), | |
| 408 | − ); | |
| 391 | + return Effect.forEach(githubRecords, ({ record, source }) => { | |
| 392 | + const { descriptor, publishedAt } = record; | |
| 393 | + return Effect.succeed({ | |
| 394 | + slug: descriptor.scope, | |
| 395 | + name: source.repo.split("/").at(-1) ?? descriptor.scope, | |
| 396 | + scope: descriptor.scope, | |
| 397 | + url: source.url, | |
| 398 | + repo: source.repo, | |
| 399 | + ref: source.ref, | |
| 400 | + hasToken: false, | |
| 401 | + upstreamSha: source.upstreamSha, | |
| 402 | + snapshotId: descriptor.snapshotId, | |
| 403 | + ...(descriptor.description ? { description: descriptor.description } : {}), | |
| 404 | + publishedAt: new Date(publishedAt).toISOString(), | |
| 405 | + tools: descriptor.tools.map((tool) => tool.name), | |
| 406 | + skipped: [ | |
| 407 | + ...(source.skipped ?? []), | |
| 408 | + ...(descriptor.skipped as readonly GitHubSkippedArtifact[]), | |
| 409 | + ], | |
| 410 | + } satisfies GitHubCustomToolsSourceSummary); | |
| 411 | + }); | |
| 409 | 412 | }), |
| 410 | 413 | ); |
| 411 | 414 | }, |
| @@ -415,6 +418,28 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 3 unmodified lines | ||
| 415 | 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 | 443 | invokeTool: (input) => |
| 419 | 444 | Effect.gen(function* () { |
| 420 | 445 | const tenant = resolveTenant(input.tenant); |
| @@ -9,7 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | import 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 | |
| 15 | 15 | export const descriptorCollection = definePluginStorageCollection("published_descriptor", { |
| @@ -22,27 +22,6 @@ export const descriptorCollection = definePluginStorageCollection("published_des · 6 unmodified lines | ||
| 22 | 22 | }, |
| 23 | 23 | }); |
| 24 | 24 | |
| 25 | −export const scopeConnectionCollection = definePluginStorageCollection("apps_scope_connection", { | |
| 26 | − Type: {} as { | |
| 27 | − readonly tenant: string; | |
| 28 | − readonly connectionName: string; | |
| 29 | − readonly scope: string; | |
| 30 | − }, | |
| 31 | −}); | |
| 32 | − | |
| 33 | −export const githubSourceTokenCollection = definePluginStorageCollection( | |
| 34 | − "apps_github_source_token", | |
| 35 | − { | |
| 36 | − Type: {} as { | |
| 37 | − readonly tenant: string; | |
| 38 | − readonly scope: string; | |
| 39 | − readonly provider: string; | |
| 40 | − readonly itemId: string; | |
| 41 | − readonly updatedAt: number; | |
| 42 | − }, | |
| 43 | − }, | |
| 44 | −); | |
| 45 | − | |
| 46 | 25 | export interface AppDescriptorRecord { |
| 47 | 26 | readonly descriptor: AppDescriptor; |
| 48 | 27 | readonly publishedAt: number; |
| @@ -64,27 +43,10 @@ export interface AppsStore { · 15 unmodified lines | ||
| 64 | 43 | tenant: string, |
| 65 | 44 | scope: string, |
| 66 | 45 | ) => Effect.Effect<AppDescriptor | null, StorageFailure>; |
| 46 | + readonly removeDescriptor: (tenant: string, scope: string) => Effect.Effect<void, StorageFailure>; | |
| 67 | 47 | readonly listDescriptors: ( |
| 68 | 48 | tenant: string, |
| 69 | 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 | |
| 90 | 52 | export interface AppsStoreDeps { |
| @@ -93,36 +55,10 @@ export interface AppsStoreDeps { · 2 unmodified lines | ||
| 93 | 55 | |
| 94 | 56 | export const makeAppsStore = (deps: AppsStoreDeps): AppsStore => { |
| 95 | 57 | const descriptors = deps.pluginStorage.collection(descriptorCollection); |
| 96 | − const scopeConnections = deps.pluginStorage.collection(scopeConnectionCollection); | |
| 97 | − const githubSourceTokens = deps.pluginStorage.collection(githubSourceTokenCollection); | |
| 98 | 58 | // Tenant is now part of apps storage keys. The apps subsystem had not shipped |
| 99 | 59 | // before this key shape, so no migration from the old scope-only keys is needed. |
| 100 | 60 | const keyFor = (tenant: string, key: string): string => `${tenant}:${key}`; |
| 101 | 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 | 62 | putDescriptor: (tenant, owner, descriptor) => |
| 127 | 63 | descriptors |
| 128 | 64 | .put({ |
| @@ -141,6 +77,8 @@ export const makeAppsStore = (deps: AppsStoreDeps): AppsStore => { · 12 unmodified lines | ||
| 141 | 77 | descriptors |
| 142 | 78 | .get({ key: keyFor(tenant, scope) }) |
| 143 | 79 | .pipe(Effect.map((entry) => entry?.data.descriptor ?? null)), |
| 80 | + removeDescriptor: (tenant, scope) => | |
| 81 | + descriptors.remove({ owner: "org", key: keyFor(tenant, scope) }), | |
| 144 | 82 | listDescriptors: (tenant) => |
| 145 | 83 | descriptors.list({ keyPrefix: `${tenant}:` }).pipe( |
| 146 | 84 | Effect.map((entries) => |
| @@ -10,12 +10,15 @@ import { · 9 unmodified lines | ||
| 10 | 10 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 11 | 11 | import { Input } from "@executor-js/react/components/input"; |
| 12 | 12 | import { toast } from "@executor-js/react/components/sonner"; |
| 13 | −import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; | |
| 13 | +import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add"; | |
| 14 | 14 | |
| 15 | 15 | import { |
| 16 | 16 | formatSyncErrors, |
| 17 | + slugifyCustomToolsAppName, | |
| 18 | + suggestCustomToolsAppName, | |
| 17 | 19 | syncCustomToolSourceEffect, |
| 18 | 20 | syncStatusLabel, |
| 21 | + validateCustomToolsAppSlug, | |
| 19 | 22 | validateGitHubSourceUrl, |
| 20 | 23 | } from "./custom-tools-client"; |
| 21 | 24 | |
| @@ -24,21 +27,36 @@ export default function AddCustomToolsSource(props: { · 2 unmodified lines | ||
| 24 | 27 | readonly onCancel: () => void; |
| 25 | 28 | }) { |
| 26 | 29 | const [url, setUrl] = useState(""); |
| 30 | + const [name, setName] = useState(""); | |
| 31 | + const [nameTouched, setNameTouched] = useState(false); | |
| 27 | 32 | const [token, setToken] = useState(""); |
| 28 | 33 | const [tokenRevealed, setTokenRevealed] = useState(false); |
| 29 | 34 | const [urlError, setUrlError] = useState<string | null>(null); |
| 35 | + const [nameError, setNameError] = useState<string | null>(null); | |
| 30 | 36 | const [syncing, setSyncing] = useState(false); |
| 31 | 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 | 44 | const submit = async () => { |
| 34 | 45 | const validation = validateGitHubSourceUrl(url); |
| 46 | + const nextNameError = validateCustomToolsAppSlug(slug); | |
| 35 | 47 | setUrlError(validation); |
| 48 | + setNameError(nextNameError); | |
| 36 | 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 | 56 | setSyncing(true); |
| 40 | 57 | const exit = await Effect.runPromiseExit( |
| 41 | 58 | syncCustomToolSourceEffect({ |
| 59 | + name: slug, | |
| 42 | 60 | url, |
| 43 | 61 | token, |
| 44 | 62 | }), |
| @@ -55,7 +73,7 @@ export default function AddCustomToolsSource(props: { · 10 unmodified lines | ||
| 55 | 73 | return; |
| 56 | 74 | } |
| 57 | 75 | toast.success(syncStatusLabel(result)); |
| 58 | − props.onComplete("apps"); | |
| 76 | + props.onComplete(slug); | |
| 59 | 77 | }; |
| 60 | 78 | |
| 61 | 79 | return ( |
| @@ -79,6 +97,7 @@ export default function AddCustomToolsSource(props: { · 17 unmodified lines | ||
| 79 | 97 | onChange={(event) => { |
| 80 | 98 | setUrl((event.target as HTMLInputElement).value); |
| 81 | 99 | setUrlError(null); |
| 100 | + setNameError(null); | |
| 82 | 101 | setSyncError(null); |
| 83 | 102 | }} |
| 84 | 103 | onBlur={() => setUrlError(validateGitHubSourceUrl(url))} |
| @@ -90,6 +109,32 @@ export default function AddCustomToolsSource(props: { · 5 unmodified lines | ||
| 90 | 109 | </div> |
| 91 | 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 "{slug}" already exists. | |
| 133 | + </p> | |
| 134 | + )} | |
| 135 | + </div> | |
| 136 | + </CardStackEntryField> | |
| 137 | + | |
| 93 | 138 | <CardStackEntryField |
| 94 | 139 | label="Access token (optional)" |
| 95 | 140 | description="- Needed only for private repositories or higher GitHub API limits." |
| @@ -1,24 +1,27 @@ | ||
| 1 | 1 | import { useEffect, useState, type ReactNode } from "react"; |
| 2 | 2 | import { Effect, Exit } from "effect"; |
| 3 | 3 | |
| 4 | +import { | |
| 5 | + AlertDialog, | |
| 6 | + AlertDialogAction, | |
| 7 | + AlertDialogCancel, | |
| 8 | + AlertDialogContent, | |
| 9 | + AlertDialogDescription, | |
| 10 | + AlertDialogFooter, | |
| 11 | + AlertDialogHeader, | |
| 12 | + AlertDialogTitle, | |
| 13 | + AlertDialogTrigger, | |
| 14 | +} from "@executor-js/react/components/alert-dialog"; | |
| 4 | 15 | import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert"; |
| 5 | 16 | import { Badge } from "@executor-js/react/components/badge"; |
| 6 | 17 | import { Button } from "@executor-js/react/components/button"; |
| 7 | −import { | |
| 8 | − CardStack, | |
| 9 | − CardStackContent, | |
| 10 | − CardStackEntry, | |
| 11 | − CardStackEntryActions, | |
| 12 | − CardStackEntryContent, | |
| 13 | − CardStackEntryDescription, | |
| 14 | − CardStackEntryTitle, | |
| 15 | −} from "@executor-js/react/components/card-stack"; | |
| 16 | 18 | |
| 17 | 19 | import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api"; |
| 18 | 20 | import { |
| 19 | 21 | consoleIntegrationHref, |
| 20 | 22 | formatSyncErrors, |
| 21 | − listCustomToolSourcesEffect, | |
| 23 | + getCustomToolSourceEffect, | |
| 24 | + removeCustomToolSourceEffect, | |
| 22 | 25 | syncCustomToolSourceEffect, |
| 23 | 26 | syncStatusLabel, |
| 24 | 27 | toolDiff, |
| @@ -27,7 +30,8 @@ import { · 2 unmodified lines | ||
| 27 | 30 | type LoadState = |
| 28 | 31 | | { readonly status: "loading" } |
| 29 | 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 | |
| 32 | 36 | interface SyncNotice { |
| 33 | 37 | readonly status: GitHubSyncResult["status"]; |
| @@ -42,61 +46,49 @@ const formatDate = (value: string): string => { · 8 unmodified lines | ||
| 42 | 46 | return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); |
| 43 | 47 | }; |
| 44 | 48 | |
| 45 | −export default function CustomToolsAccountsPanel() { | |
| 49 | +export default function CustomToolsAccountsPanel(props: { | |
| 50 | + readonly sourceId: string; | |
| 51 | + readonly integrationName: string; | |
| 52 | +}) { | |
| 46 | 53 | const [loadState, setLoadState] = useState<LoadState>({ status: "loading" }); |
| 47 | − const [selectedScope, setSelectedScope] = useState<string | null>(null); | |
| 48 | − const [syncingScope, setSyncingScope] = useState<string | null>(null); | |
| 54 | + const [syncing, setSyncing] = useState(false); | |
| 55 | + const [removing, setRemoving] = useState(false); | |
| 49 | 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 | 60 | setLoadState({ status: "loading" }); |
| 53 | − const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect()); | |
| 61 | + const exit = await Effect.runPromiseExit(getCustomToolSourceEffect(props.sourceId)); | |
| 54 | 62 | if (Exit.isFailure(exit)) { |
| 55 | − setLoadState({ | |
| 56 | − status: "error", | |
| 57 | − message: "Failed to load custom tool sources.", | |
| 58 | − }); | |
| 63 | + setLoadState({ status: "error", message: "Failed to load custom tools source." }); | |
| 59 | 64 | return; |
| 60 | 65 | } |
| 61 | − const result = exit.value; | |
| 62 | − setLoadState({ status: "ready", sources: result.sources }); | |
| 63 | − setSelectedScope((current) => current ?? result.sources[0]?.scope ?? null); | |
| 66 | + const source = exit.value.source; | |
| 67 | + setLoadState(source ? { status: "ready", source } : { status: "missing" }); | |
| 64 | 68 | }; |
| 65 | 69 | |
| 66 | 70 | useEffect(() => { |
| 67 | 71 | let active = true; |
| 68 | 72 | void (async () => { |
| 69 | − const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect()); | |
| 70 | − if (Exit.isSuccess(exit)) { | |
| 71 | − if (!active) return; | |
| 72 | − const result = exit.value; | |
| 73 | − setLoadState({ status: "ready", sources: result.sources }); | |
| 74 | − setSelectedScope(result.sources[0]?.scope ?? null); | |
| 75 | − } else { | |
| 76 | − if (!active) return; | |
| 77 | − setLoadState({ | |
| 78 | − status: "error", | |
| 79 | − message: "Failed to load custom tool sources.", | |
| 80 | − }); | |
| 73 | + const exit = await Effect.runPromiseExit(getCustomToolSourceEffect(props.sourceId)); | |
| 74 | + if (!active) return; | |
| 75 | + if (Exit.isFailure(exit)) { | |
| 76 | + setLoadState({ status: "error", message: "Failed to load custom tools source." }); | |
| 77 | + return; | |
| 81 | 78 | } |
| 79 | + const source = exit.value.source; | |
| 80 | + setLoadState(source ? { status: "ready", source } : { status: "missing" }); | |
| 82 | 81 | })(); |
| 83 | 82 | return () => { |
| 84 | 83 | active = false; |
| 85 | 84 | }; |
| 86 | − }, []); | |
| 87 | − | |
| 88 | − const sources = loadState.status === "ready" ? loadState.sources : []; | |
| 89 | − const selected = sources.find((source) => source.scope === selectedScope) ?? sources[0] ?? null; | |
| 85 | + }, [props.sourceId]); | |
| 90 | 86 | |
| 91 | − const syncSelected = async (source: GitHubCustomToolsSourceSummary) => { | |
| 92 | − setSyncingScope(source.scope); | |
| 87 | + const syncSource = async (source: GitHubCustomToolsSourceSummary) => { | |
| 88 | + setSyncing(true); | |
| 93 | 89 | setNotice(null); |
| 94 | 90 | const beforeTools = source.tools; |
| 95 | − const exit = await Effect.runPromiseExit( | |
| 96 | − syncCustomToolSourceEffect({ | |
| 97 | − url: source.url, | |
| 98 | − }), | |
| 99 | − ); | |
| 91 | + const exit = await Effect.runPromiseExit(syncCustomToolSourceEffect({ slug: source.slug })); | |
| 100 | 92 | if (Exit.isFailure(exit)) { |
| 101 | 93 | setNotice({ |
| 102 | 94 | status: "failed", |
| @@ -105,7 +97,7 @@ export default function CustomToolsAccountsPanel() { · 2 unmodified lines | ||
| 105 | 97 | removed: [], |
| 106 | 98 | errors: ["Failed to sync custom tools."], |
| 107 | 99 | }); |
| 108 | − setSyncingScope(null); | |
| 100 | + setSyncing(false); | |
| 109 | 101 | return; |
| 110 | 102 | } |
| 111 | 103 | const result = exit.value; |
| @@ -118,94 +110,53 @@ export default function CustomToolsAccountsPanel() { · 6 unmodified lines | ||
| 118 | 110 | removed: diff.removed, |
| 119 | 111 | errors: formatSyncErrors(result), |
| 120 | 112 | }); |
| 121 | − if (result.status !== "failed") { | |
| 122 | − await loadSources(); | |
| 123 | − setSelectedScope(source.scope); | |
| 113 | + if (result.status !== "failed") await loadSource(); | |
| 114 | + setSyncing(false); | |
| 115 | + }; | |
| 116 | + | |
| 117 | + const removeSource = async (source: GitHubCustomToolsSourceSummary) => { | |
| 118 | + setRemoving(true); | |
| 119 | + setRemoveError(null); | |
| 120 | + const exit = await Effect.runPromiseExit(removeCustomToolSourceEffect(source.slug)); | |
| 121 | + if (Exit.isFailure(exit)) { | |
| 122 | + setRemoveError("Failed to remove custom tools source."); | |
| 123 | + setRemoving(false); | |
| 124 | + return; | |
| 124 | 125 | } |
| 125 | − setSyncingScope(null); | |
| 126 | + window.location.assign(consoleIntegrationHref("/integrations")); | |
| 126 | 127 | }; |
| 127 | 128 | |
| 128 | 129 | return ( |
| 129 | − <div className="mx-auto max-w-5xl space-y-6 px-6 py-8"> | |
| 130 | − <div className="flex items-center justify-between gap-3"> | |
| 131 | − <div> | |
| 132 | − <h2 className="text-sm font-medium text-foreground">Custom tools sources</h2> | |
| 133 | − <p className="mt-1 text-xs text-muted-foreground"> | |
| 134 | − GitHub repositories synced into the executor tool catalog. | |
| 135 | − </p> | |
| 136 | − </div> | |
| 137 | − <Button asChild size="sm" variant="outline"> | |
| 138 | − <a href={consoleIntegrationHref("/integrations/add/apps")}>Add source</a> | |
| 139 | − </Button> | |
| 140 | − </div> | |
| 141 | − | |
| 130 | + <div className="mx-auto max-w-3xl space-y-6 px-6 py-8"> | |
| 142 | 131 | {loadState.status === "loading" && ( |
| 143 | 132 | <div className="rounded-lg border border-border p-4 text-sm text-muted-foreground"> |
| 144 | − Loading sources... | |
| 133 | + Loading source... | |
| 145 | 134 | </div> |
| 146 | 135 | )} |
| 147 | 136 | |
| 148 | 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 && ( | |
| 153 | − <div className="rounded-lg border border-dashed border-border p-8 text-center"> | |
| 154 | − <p className="text-sm font-medium text-foreground">No custom tools sources</p> | |
| 155 | − <p className="mt-1 text-sm text-muted-foreground"> | |
| 156 | − Add a GitHub repo to publish its tools. | |
| 157 | − </p> | |
| 158 | − <Button asChild size="sm" className="mt-4"> | |
| 159 | − <a href={consoleIntegrationHref("/integrations/add/apps")}>Add source</a> | |
| 160 | − </Button> | |
| 161 | − </div> | |
| 141 | + {loadState.status === "missing" && ( | |
| 142 | + <Alert variant="destructive"> | |
| 143 | + <AlertTitle>Source not found</AlertTitle> | |
| 144 | + <AlertDescription> | |
| 145 | + The custom tools source for {props.integrationName} is no longer available. | |
| 146 | + </AlertDescription> | |
| 147 | + </Alert> | |
| 162 | 148 | )} |
| 163 | 149 | |
| 164 | − {loadState.status === "ready" && sources.length > 0 && ( | |
| 165 | − <div className="grid min-h-[28rem] gap-4 lg:grid-cols-[18rem_1fr]"> | |
| 166 | − <CardStack searchable> | |
| 167 | − <CardStackContent> | |
| 168 | − {sources.map((source) => ( | |
| 169 | − <CardStackEntry | |
| 170 | − key={source.scope} | |
| 171 | − asChild | |
| 172 | − searchText={`${source.repo} ${source.ref} ${source.url}`} | |
| 173 | − > | |
| 174 | − <Button | |
| 175 | − type="button" | |
| 176 | − variant="ghost" | |
| 177 | − onClick={() => { | |
| 178 | − setSelectedScope(source.scope); | |
| 179 | − setNotice(null); | |
| 180 | − }} | |
| 181 | − className={ | |
| 182 | − selected?.scope === source.scope | |
| 183 | − ? "h-auto w-full justify-start bg-muted/70 p-0 text-left" | |
| 184 | − : "h-auto w-full justify-start bg-transparent p-0 text-left" | |
| 185 | − } | |
| 186 | − > | |
| 187 | − <CardStackEntryContent> | |
| 188 | − <CardStackEntryTitle>{source.repo}</CardStackEntryTitle> | |
| 189 | − <CardStackEntryDescription>{source.ref}</CardStackEntryDescription> | |
| 190 | − </CardStackEntryContent> | |
| 191 | − <CardStackEntryActions> | |
| 192 | − <Badge variant="secondary">{source.tools.length}</Badge> | |
| 193 | − </CardStackEntryActions> | |
| 194 | − </Button> | |
| 195 | − </CardStackEntry> | |
| 196 | − ))} | |
| 197 | − </CardStackContent> | |
| 198 | − </CardStack> | |
| 199 | − | |
| 200 | − {selected && ( | |
| 201 | − <SourceDetail | |
| 202 | − source={selected} | |
| 203 | − notice={notice} | |
| 204 | − syncing={syncingScope === selected.scope} | |
| 205 | − onSync={() => void syncSelected(selected)} | |
| 206 | − /> | |
| 207 | − )} | |
| 208 | − </div> | |
| 150 | + {loadState.status === "ready" && ( | |
| 151 | + <SourceDetail | |
| 152 | + source={loadState.source} | |
| 153 | + notice={notice} | |
| 154 | + removeError={removeError} | |
| 155 | + syncing={syncing} | |
| 156 | + removing={removing} | |
| 157 | + onSync={() => void syncSource(loadState.source)} | |
| 158 | + onRemove={() => void removeSource(loadState.source)} | |
| 159 | + /> | |
| 209 | 160 | )} |
| 210 | 161 | </div> |
| 211 | 162 | ); |
| @@ -214,7 +165,7 @@ export default function CustomToolsAccountsPanel() { · 2 unmodified lines | ||
| 214 | 165 | function ErrorWithRetry(props: { readonly message: string; readonly onRetry: () => void }) { |
| 215 | 166 | return ( |
| 216 | 167 | <Alert variant="destructive"> |
| 217 | − <AlertTitle>Failed to load sources</AlertTitle> | |
| 168 | + <AlertTitle>Failed to load source</AlertTitle> | |
| 218 | 169 | <AlertDescription> |
| 219 | 170 | <div className="space-y-3"> |
| 220 | 171 | <p>{props.message}</p> |
| @@ -230,20 +181,53 @@ function ErrorWithRetry(props: { readonly message: string; readonly onRetry: () · 9 unmodified lines | ||
| 230 | 181 | function SourceDetail(props: { |
| 231 | 182 | readonly source: GitHubCustomToolsSourceSummary; |
| 232 | 183 | readonly notice: SyncNotice | null; |
| 184 | + readonly removeError: string | null; | |
| 233 | 185 | readonly syncing: boolean; |
| 186 | + readonly removing: boolean; | |
| 234 | 187 | readonly onSync: () => void; |
| 188 | + readonly onRemove: () => void; | |
| 235 | 189 | }) { |
| 236 | 190 | const { source, notice } = props; |
| 237 | 191 | return ( |
| 238 | 192 | <div className="min-w-0 rounded-lg border border-border bg-card"> |
| 239 | 193 | <div className="flex items-start justify-between gap-4 border-b border-border p-4"> |
| 240 | 194 | <div className="min-w-0"> |
| 241 | − <h3 className="truncate text-sm font-medium text-foreground">{source.repo}</h3> | |
| 242 | − <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{source.scope}</p> | |
| 195 | + <h3 className="truncate text-sm font-medium text-foreground">{source.name}</h3> | |
| 196 | + <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{source.slug}</p> | |
| 197 | + </div> | |
| 198 | + <div className="flex shrink-0 gap-2"> | |
| 199 | + <Button type="button" size="sm" onClick={props.onSync} loading={props.syncing}> | |
| 200 | + Sync | |
| 201 | + </Button> | |
| 202 | + <AlertDialog> | |
| 203 | + <AlertDialogTrigger asChild> | |
| 204 | + <Button | |
| 205 | + type="button" | |
| 206 | + size="sm" | |
| 207 | + variant="outline" | |
| 208 | + className="border-destructive/30 text-destructive hover:bg-destructive/10" | |
| 209 | + disabled={props.removing} | |
| 210 | + > | |
| 211 | + Remove | |
| 212 | + </Button> | |
| 213 | + </AlertDialogTrigger> | |
| 214 | + <AlertDialogContent size="sm"> | |
| 215 | + <AlertDialogHeader> | |
| 216 | + <AlertDialogTitle>Remove {source.name}?</AlertDialogTitle> | |
| 217 | + <AlertDialogDescription> | |
| 218 | + This removes {source.tools.length} {source.tools.length === 1 ? "tool" : "tools"}{" "} | |
| 219 | + from the catalog. The GitHub repository is untouched; re-add it to sync again. | |
| 220 | + </AlertDialogDescription> | |
| 221 | + </AlertDialogHeader> | |
| 222 | + <AlertDialogFooter> | |
| 223 | + <AlertDialogCancel>Cancel</AlertDialogCancel> | |
| 224 | + <AlertDialogAction variant="destructive" onClick={props.onRemove}> | |
| 225 | + {props.removing ? "Removing..." : "Remove source"} | |
| 226 | + </AlertDialogAction> | |
| 227 | + </AlertDialogFooter> | |
| 228 | + </AlertDialogContent> | |
| 229 | + </AlertDialog> | |
| 243 | 230 | </div> |
| 244 | − <Button type="button" size="sm" onClick={props.onSync} loading={props.syncing}> | |
| 245 | − Sync | |
| 246 | − </Button> | |
| 247 | 231 | </div> |
| 248 | 232 | |
| 249 | 233 | <div className="space-y-6 p-4"> |
| @@ -272,6 +256,8 @@ function SourceDetail(props: { · 22 unmodified lines | ||
| 272 | 256 | </Alert> |
| 273 | 257 | )} |
| 274 | 258 | |
| 259 | + {props.removeError && <FormErrorMessage message={props.removeError} />} | |
| 260 | + | |
| 275 | 261 | <Section title="Published tools"> |
| 276 | 262 | {source.tools.length > 0 ? ( |
| 277 | 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 | |
| 322 | +function FormErrorMessage(props: { readonly message: string }) { | |
| 323 | + return ( | |
| 324 | + <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | |
| 325 | + <p className="text-[12px] text-destructive">{props.message}</p> | |
| 326 | + </div> | |
| 327 | + ); | |
| 328 | +} | |
| 329 | + | |
| 336 | 330 | function Section(props: { readonly title: string; readonly children: ReactNode }) { |
| 337 | 331 | return ( |
| 338 | 332 | <section className="space-y-2"> |
| @@ -2,6 +2,7 @@ import { Data, Effect, Schema } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api"; |
| 4 | 4 | import { parseGitHubSourceUrl } from "../source/github-url"; |
| 5 | +import { slugifyCustomToolsAppName, validateCustomToolsAppSlug } from "../source/app-slug"; | |
| 5 | 6 | |
| 6 | 7 | export const CUSTOM_TOOLS_PLUGIN_KEY = "apps"; |
| 7 | 8 | export const CUSTOM_TOOLS_LABEL = "Custom tools"; |
| @@ -10,8 +11,14 @@ export interface GitHubSourcesListResponse { · 2 unmodified lines | ||
| 10 | 11 | readonly sources: readonly GitHubCustomToolsSourceSummary[]; |
| 11 | 12 | } |
| 12 | 13 | |
| 14 | +export interface GitHubSourceDetailResponse { | |
| 15 | + readonly source: GitHubCustomToolsSourceSummary | null; | |
| 16 | +} | |
| 17 | + | |
| 13 | 18 | export interface SyncGitHubSourceRequest { |
| 14 | − readonly url: string; | |
| 19 | + readonly url?: string; | |
| 20 | + readonly name?: string; | |
| 21 | + readonly slug?: string; | |
| 15 | 22 | readonly ref?: string; |
| 16 | 23 | readonly token?: string; |
| 17 | 24 | } |
| @@ -23,6 +30,12 @@ export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientE · 5 unmodified lines | ||
| 23 | 30 | }> {} |
| 24 | 31 | |
| 25 | 32 | export { parseGitHubSourceUrl }; |
| 33 | +export { slugifyCustomToolsAppName, validateCustomToolsAppSlug }; | |
| 34 | + | |
| 35 | +export const suggestCustomToolsAppName = (url: string): string => { | |
| 36 | + const parsed = parseGitHubSourceUrl(url); | |
| 37 | + return parsed.ok ? parsed.value.name : ""; | |
| 38 | +}; | |
| 26 | 39 | |
| 27 | 40 | export const validateGitHubSourceUrl = (url: string): string | null => { |
| 28 | 41 | const parsed = parseGitHubSourceUrl(url); |
| @@ -81,6 +94,31 @@ export const listCustomToolSources = ( · 52 unmodified lines | ||
| 81 | 94 | fetchImpl: CustomToolsFetch = fetch, |
| 82 | 95 | ): Promise<GitHubSourcesListResponse> => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl)); |
| 83 | 96 | |
| 97 | +export const getCustomToolSourceEffect = ( | |
| 98 | + slug: string, | |
| 99 | + fetchImpl: CustomToolsFetch = fetch, | |
| 100 | +): Effect.Effect<GitHubSourceDetailResponse, CustomToolsClientError> => | |
| 101 | + Effect.tryPromise({ | |
| 102 | + try: () => | |
| 103 | + fetchImpl(`/api/apps/sources/github/${encodeURIComponent(slug)}`, { | |
| 104 | + credentials: "same-origin", | |
| 105 | + }), | |
| 106 | + catch: () => new CustomToolsClientError({ message: "Failed to load custom tools source." }), | |
| 107 | + }).pipe( | |
| 108 | + Effect.flatMap((response) => | |
| 109 | + parseJsonResponseEffect<GitHubSourceDetailResponse>( | |
| 110 | + response, | |
| 111 | + "Failed to load custom tools source.", | |
| 112 | + ), | |
| 113 | + ), | |
| 114 | + ); | |
| 115 | + | |
| 116 | +export const getCustomToolSource = ( | |
| 117 | + slug: string, | |
| 118 | + fetchImpl: CustomToolsFetch = fetch, | |
| 119 | +): Promise<GitHubSourceDetailResponse> => | |
| 120 | + Effect.runPromise(getCustomToolSourceEffect(slug, fetchImpl)); | |
| 121 | + | |
| 84 | 122 | export const syncCustomToolSourceEffect = ( |
| 85 | 123 | input: SyncGitHubSourceRequest, |
| 86 | 124 | fetchImpl: CustomToolsFetch = fetch, |
| @@ -92,7 +130,9 @@ export const syncCustomToolSourceEffect = ( · 5 unmodified lines | ||
| 92 | 130 | headers: { "content-type": "application/json" }, |
| 93 | 131 | credentials: "same-origin", |
| 94 | 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 | 136 | ...(input.ref?.trim() ? { ref: input.ref.trim() } : {}), |
| 97 | 137 | ...(input.token?.trim() ? { token: input.token.trim() } : {}), |
| 98 | 138 | }), |
| @@ -109,6 +149,32 @@ export const syncCustomToolSource = ( · 10 unmodified lines | ||
| 109 | 149 | fetchImpl: CustomToolsFetch = fetch, |
| 110 | 150 | ): Promise<GitHubSyncResult> => Effect.runPromise(syncCustomToolSourceEffect(input, fetchImpl)); |
| 111 | 151 | |
| 152 | +export const removeCustomToolSourceEffect = ( | |
| 153 | + slug: string, | |
| 154 | + fetchImpl: CustomToolsFetch = fetch, | |
| 155 | +): Effect.Effect<{ readonly removed: boolean }, CustomToolsClientError> => | |
| 156 | + Effect.tryPromise({ | |
| 157 | + try: () => | |
| 158 | + fetchImpl(`/api/apps/sources/github/${encodeURIComponent(slug)}`, { | |
| 159 | + method: "DELETE", | |
| 160 | + credentials: "same-origin", | |
| 161 | + }), | |
| 162 | + catch: () => new CustomToolsClientError({ message: "Failed to remove custom tools source." }), | |
| 163 | + }).pipe( | |
| 164 | + Effect.flatMap((response) => | |
| 165 | + parseJsonResponseEffect<{ readonly removed: boolean }>( | |
| 166 | + response, | |
| 167 | + "Failed to remove custom tools source.", | |
| 168 | + ), | |
| 169 | + ), | |
| 170 | + ); | |
| 171 | + | |
| 172 | +export const removeCustomToolSource = ( | |
| 173 | + slug: string, | |
| 174 | + fetchImpl: CustomToolsFetch = fetch, | |
| 175 | +): Promise<{ readonly removed: boolean }> => | |
| 176 | + Effect.runPromise(removeCustomToolSourceEffect(slug, fetchImpl)); | |
| 177 | + | |
| 112 | 178 | export const syncStatusLabel = (result: GitHubSyncResult): string => { |
| 113 | 179 | if (result.status === "published") return `Published ${result.tools.length} tools.`; |
| 114 | 180 | if (result.status === "up-to-date") return "Already up to date."; |
| @@ -64,4 +64,5 @@ export interface ArtifactStore { · 63 unmodified lines | ||
| 64 | 64 | readonly forScope: ( |
| 65 | 65 | address: ScopeAddress, |
| 66 | 66 | ) => Effect.Effect<ScopeArtifactStore, ArtifactStoreError>; |
| 67 | + readonly removeScope: (address: ScopeAddress) => Effect.Effect<void, ArtifactStoreError>; | |
| 67 | 68 | } |
| @@ -39,5 +39,6 @@ export interface ScopeDbHandle { · 38 unmodified lines | ||
| 39 | 39 | |
| 40 | 40 | export interface ScopeDb { |
| 41 | 41 | readonly forScope: (address: ScopeAddress) => Effect.Effect<ScopeDbHandle, ScopeDbError>; |
| 42 | + readonly removeScope: (address: ScopeAddress) => Effect.Effect<void, ScopeDbError>; | |
| 42 | 43 | readonly close: () => Effect.Effect<void, ScopeDbError>; |
| 43 | 44 | } |
| @@ -0,0 +1,17 @@ | ||
| 1 | +export const CUSTOM_TOOLS_APP_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; | |
| 2 | + | |
| 3 | +export const slugifyCustomToolsAppName = (input: string): string => { | |
| 4 | + const base = input | |
| 5 | + .trim() | |
| 6 | + .toLowerCase() | |
| 7 | + .replace(/[^a-z0-9]+/g, "-") | |
| 8 | + .replace(/^-+|-+$/g, ""); | |
| 9 | + return base.slice(0, 63).replace(/-+$/g, ""); | |
| 10 | +}; | |
| 11 | + | |
| 12 | +export const validateCustomToolsAppSlug = (slug: string): string | null => { | |
| 13 | + if (slug.length === 0) return "Enter a name with at least one letter or number."; | |
| 14 | + return CUSTOM_TOOLS_APP_SLUG_PATTERN.test(slug) | |
| 15 | + ? null | |
| 16 | + : "Use lowercase letters, numbers, and hyphens. Start and end with a letter or number."; | |
| 17 | +}; | |
| @@ -20,11 +20,6 @@ export const makeInMemoryAppsStore = (): AppsStore & { · 19 unmodified lines | ||
| 20 | 20 | } => { |
| 21 | 21 | const descriptors = new Map<string, AppDescriptor>(); |
| 22 | 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 | 23 | const keyFor = (tenant: string, key: string): string => `${tenant}:${key}`; |
| 29 | 24 | return { |
| 30 | 25 | descriptors, |
| @@ -36,6 +31,12 @@ export const makeInMemoryAppsStore = (): AppsStore & { · 5 unmodified lines | ||
| 36 | 31 | }), |
| 37 | 32 | getDescriptor: (tenant, scope) => |
| 38 | 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 | 40 | listDescriptors: (tenant) => |
| 40 | 41 | Effect.sync(() => |
| 41 | 42 | [...descriptors.entries()] |
| @@ -45,14 +46,6 @@ export const makeInMemoryAppsStore = (): AppsStore & { · 3 unmodified lines | ||
| 45 | 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 | |
| 100 | 101 | export type { SnapshotId }; |
| @@ -104,6 +104,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 103 unmodified lines | ||
| 104 | 104 | const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null; |
| 105 | 105 | useExecutorDocumentTitle(integrationData?.name || namespace); |
| 106 | 106 | const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in"; |
| 107 | + const isAppsIntegration = integrationData?.kind === "apps"; | |
| 107 | 108 | const currentTab = isBuiltInIntegration ? "tools" : activeTab; |
| 108 | 109 | const canRefresh = integrationData?.canRefresh ?? false; |
| 109 | 110 | const canRemove = integrationData?.canRemove ?? false; |
| @@ -153,9 +154,11 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 43 unmodified lines | ||
| 153 | 154 | // Find the plugin edit component based on integration kind |
| 154 | 155 | const editPlugin = useMemo(() => { |
| 155 | 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 | 160 | return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null; |
| 158 | − }, [integrationData, integrationPlugins, namespace]); | |
| 161 | + }, [integrationData, integrationPlugins]); | |
| 159 | 162 | |
| 160 | 163 | // Policies are pre-sorted by the server in evaluation order (owner rank, then |
| 161 | 164 | // 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 | 367 | </div> |
| 365 | 368 | |
| 366 | 369 | <div className="flex shrink-0 items-center gap-2"> |
| 367 | − {!confirmDelete && !isBuiltInIntegration && integrationData && ( | |
| 370 | + {!confirmDelete && !isBuiltInIntegration && !isAppsIntegration && integrationData && ( | |
| 368 | 371 | <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}> |
| 369 | 372 | Edit |
| 370 | 373 | </Button> |
| @@ -382,6 +385,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 11 unmodified lines | ||
| 382 | 385 | )} |
| 383 | 386 | |
| 384 | 387 | {canRemove && |
| 388 | + !isAppsIntegration && | |
| 385 | 389 | (confirmDelete ? ( |
| 386 | 390 | <div className="flex items-center gap-2"> |
| 387 | 391 | <Button |
| @@ -421,7 +425,11 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 33 unmodified lines | ||
| 421 | 425 | > |
| 422 | 426 | <div className="shrink-0 border-b border-border/60 px-4 py-2"> |
| 423 | 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 | 433 | <TabsTrigger value="tools">Tools</TabsTrigger> |
| 426 | 434 | </TabsList> |
| 427 | 435 | </div> |