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