Unified · Split
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { HttpEffect, HttpRouter } from "effect/unstable/http"; | |
| 1 | +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 2 | 2 | import { Data, Effect, Layer } from "effect"; |
| 3 | 3 | |
| 4 | 4 | import { |
| @@ -19,12 +19,6 @@ class AppsSyncRouteError extends Data.TaggedError("AppsSyncRouteError")<{ · 14 unmodified lines | ||
| 19 | 19 | readonly message: string; |
| 20 | 20 | }> {} |
| 21 | 21 | |
| 22 | −const jsonResponse = (body: unknown, status = 200): Response => | |
| 23 | − new Response(JSON.stringify(body), { | |
| 24 | − status, | |
| 25 | − headers: { "content-type": "application/json" }, | |
| 26 | − }); | |
| 27 | − | |
| 28 | 22 | const parseJson = (request: Request): Effect.Effect<unknown, AppsSyncRouteError> => |
| 29 | 23 | Effect.tryPromise({ |
| 30 | 24 | try: () => request.json() as Promise<unknown>, |
| @@ -51,41 +45,76 @@ export interface SelfHostAppsSyncRouteDeps { · 20 unmodified lines | ||
| 51 | 45 | readonly db: SelfHostDbHandle; |
| 52 | 46 | } |
| 53 | 47 | |
| 48 | +const routeHandler = ( | |
| 49 | + run: (request: Request) => Effect.Effect<unknown, unknown, unknown>, | |
| 50 | +): Effect.Effect<HttpServerResponse.HttpServerResponse, never, unknown> => | |
| 51 | + Effect.gen(function* () { | |
| 52 | + const httpRequest = yield* HttpServerRequest.HttpServerRequest; | |
| 53 | + const request = yield* HttpServerRequest.toWeb(httpRequest).pipe(Effect.orDie); | |
| 54 | + return yield* run(request).pipe( | |
| 55 | + Effect.map((body) => HttpServerResponse.jsonUnsafe(body)), | |
| 56 | + Effect.catch((error: unknown) => { | |
| 57 | + const tag = | |
| 58 | + error && typeof error === "object" && "_tag" in error ? String(error._tag) : null; | |
| 59 | + if (tag === "Unauthorized") { | |
| 60 | + return Effect.succeed(HttpServerResponse.text("Unauthorized", { status: 401 })); | |
| 61 | + } | |
| 62 | + if (tag === "NoOrganization") { | |
| 63 | + return Effect.succeed(HttpServerResponse.text("Forbidden", { status: 403 })); | |
| 64 | + } | |
| 65 | + if (tag === "Unavailable") { | |
| 66 | + return Effect.succeed(HttpServerResponse.text("Unavailable", { status: 503 })); | |
| 67 | + } | |
| 68 | + if (tag === "AppsSyncRouteError") { | |
| 69 | + const routeError = error as AppsSyncRouteError; | |
| 70 | + return Effect.succeed( | |
| 71 | + HttpServerResponse.jsonUnsafe( | |
| 72 | + { error: routeError.message }, | |
| 73 | + { status: routeError.status }, | |
| 74 | + ), | |
| 75 | + ); | |
| 76 | + } | |
| 77 | + return Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })); | |
| 78 | + }), | |
| 79 | + Effect.catchCause(() => | |
| 80 | + Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), | |
| 81 | + ), | |
| 82 | + ); | |
| 83 | + }); | |
| 84 | + | |
| 54 | 85 | export const makeSelfHostAppsSyncRoute = ({ identity, db }: SelfHostAppsSyncRouteDeps) => { |
| 55 | 86 | const services = Layer.merge( |
| 56 | 87 | SelfHostScopedExecutorSeams.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), |
| 57 | 88 | identity, |
| 58 | 89 | ); |
| 59 | − return HttpRouter.add( | |
| 60 | − "POST", | |
| 61 | − "/api/apps/sources/github/sync", | |
| 62 | − HttpEffect.fromWebHandler( | |
| 63 | − (request): Promise<Response> => | |
| 64 | − Effect.runPromise( | |
| 65 | − Effect.gen(function* () { | |
| 66 | − const identityProvider = yield* IdentityProvider; | |
| 67 | − const principal = yield* identityProvider.authenticate(request); | |
| 68 | − const payload = yield* parseJson(request); | |
| 69 | − const executor = yield* buildExecutor(principal, request); | |
| 70 | − const result = yield* executor.execute( | |
| 71 | − ToolAddress.make("executor.apps.sync_github_source"), | |
| 72 | − payload, | |
| 73 | − ); | |
| 74 | − return jsonResponse(result); | |
| 75 | − }).pipe( | |
| 76 | − Effect.catchTags({ | |
| 77 | − Unauthorized: () => Effect.succeed(new Response("Unauthorized", { status: 401 })), | |
| 78 | − NoOrganization: () => Effect.succeed(new Response("Forbidden", { status: 403 })), | |
| 79 | − Unavailable: () => Effect.succeed(new Response("Unavailable", { status: 503 })), | |
| 80 | − AppsSyncRouteError: (error) => | |
| 81 | − Effect.succeed(jsonResponse({ error: error.message }, error.status)), | |
| 82 | − }), | |
| 83 | − Effect.catchCause(() => | |
| 84 | − Effect.succeed(new Response("Internal Server Error", { status: 500 })), | |
| 85 | − ), | |
| 86 | − Effect.provide(services), | |
| 87 | − ), | |
| 88 | − ), | |
| 90 | + return Layer.mergeAll( | |
| 91 | + HttpRouter.add( | |
| 92 | + "GET", | |
| 93 | + "/api/apps/sources/github", | |
| 94 | + routeHandler((request) => | |
| 95 | + Effect.gen(function* () { | |
| 96 | + const identityProvider = yield* IdentityProvider; | |
| 97 | + const principal = yield* identityProvider.authenticate(request); | |
| 98 | + const executor = yield* buildExecutor(principal, request); | |
| 99 | + return yield* executor.execute(ToolAddress.make("executor.apps.list_github_sources"), {}); | |
| 100 | + }), | |
| 101 | + ), | |
| 89 | 102 | ), |
| 90 | − ); | |
| 103 | + HttpRouter.add( | |
| 104 | + "POST", | |
| 105 | + "/api/apps/sources/github/sync", | |
| 106 | + routeHandler((request) => | |
| 107 | + Effect.gen(function* () { | |
| 108 | + const identityProvider = yield* IdentityProvider; | |
| 109 | + const principal = yield* identityProvider.authenticate(request); | |
| 110 | + const payload = yield* parseJson(request); | |
| 111 | + const executor = yield* buildExecutor(principal, request); | |
| 112 | + return yield* executor.execute( | |
| 113 | + ToolAddress.make("executor.apps.sync_github_source"), | |
| 114 | + payload, | |
| 115 | + ); | |
| 116 | + }), | |
| 117 | + ), | |
| 118 | + ), | |
| 119 | + ).pipe(HttpRouter.provideRequest(services)); | |
| 91 | 120 | }; |
| @@ -0,0 +1,137 @@ | ||
| 1 | +import { describe, expect, it, vi } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import appsClientPlugin, { | |
| 4 | + CUSTOM_TOOLS_LABEL, | |
| 5 | + CUSTOM_TOOLS_PLUGIN_KEY, | |
| 6 | + appsIntegrationPlugin, | |
| 7 | + formatSyncErrors, | |
| 8 | + listCustomToolSources, | |
| 9 | + syncCustomToolSource, | |
| 10 | + syncStatusLabel, | |
| 11 | + validateGitHubRepo, | |
| 12 | +} from "@executor-js/plugin-apps/client"; | |
| 13 | + | |
| 14 | +const jsonResponse = (body: unknown, init?: ResponseInit): Response => | |
| 15 | + new Response(JSON.stringify(body), { | |
| 16 | + status: 200, | |
| 17 | + headers: { "content-type": "application/json" }, | |
| 18 | + ...init, | |
| 19 | + }); | |
| 20 | + | |
| 21 | +describe("custom tools console client", () => { | |
| 22 | + it("registers the manual Custom tools tile", () => { | |
| 23 | + const manualTiles = [appsIntegrationPlugin].map((plugin) => ({ | |
| 24 | + href: `/integrations/add/${plugin.key}`, | |
| 25 | + label: plugin.label, | |
| 26 | + })); | |
| 27 | + | |
| 28 | + expect(appsClientPlugin.id).toBe(CUSTOM_TOOLS_PLUGIN_KEY); | |
| 29 | + expect(appsClientPlugin.integrationPlugin?.label).toBe(CUSTOM_TOOLS_LABEL); | |
| 30 | + expect(manualTiles).toContainEqual({ | |
| 31 | + href: "/integrations/add/apps", | |
| 32 | + label: "Custom tools", | |
| 33 | + }); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + it("validates the GitHub repo shape", () => { | |
| 37 | + expect(validateGitHubRepo("UsefulSoftwareCo/executor")).toBeNull(); | |
| 38 | + expect(validateGitHubRepo("UsefulSoftwareCo")).toBe( | |
| 39 | + "Use owner/name, for example UsefulSoftwareCo/executor.", | |
| 40 | + ); | |
| 41 | + expect(validateGitHubRepo("")).toBe("Enter a GitHub repo."); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + it("surfaces successful sync and source detail data", async () => { | |
| 45 | + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { | |
| 46 | + const url = String(input); | |
| 47 | + if (url.endsWith("/sync")) { | |
| 48 | + return jsonResponse({ | |
| 49 | + status: "published", | |
| 50 | + snapshotId: "snap1", | |
| 51 | + upstreamSha: "abc123", | |
| 52 | + tools: ["repo-summary", "stale-issues"], | |
| 53 | + skipped: [], | |
| 54 | + }); | |
| 55 | + } | |
| 56 | + return jsonResponse({ | |
| 57 | + sources: [ | |
| 58 | + { | |
| 59 | + scope: "github:RhysSullivan/executor-custom-tools-demo", | |
| 60 | + repo: "RhysSullivan/executor-custom-tools-demo", | |
| 61 | + ref: "main", | |
| 62 | + connection: "tools.github.user.demo", | |
| 63 | + upstreamSha: "abc123", | |
| 64 | + snapshotId: "snap1", | |
| 65 | + publishedAt: "2026-07-06T12:00:00.000Z", | |
| 66 | + tools: ["repo-summary", "stale-issues"], | |
| 67 | + skipped: [], | |
| 68 | + }, | |
| 69 | + ], | |
| 70 | + }); | |
| 71 | + }) as unknown as typeof fetch; | |
| 72 | + | |
| 73 | + const syncResult = await syncCustomToolSource( | |
| 74 | + { | |
| 75 | + repo: "RhysSullivan/executor-custom-tools-demo", | |
| 76 | + connection: "tools.github.user.demo", | |
| 77 | + }, | |
| 78 | + fetchImpl, | |
| 79 | + ); | |
| 80 | + const listed = await listCustomToolSources(fetchImpl); | |
| 81 | + | |
| 82 | + expect(syncStatusLabel(syncResult)).toBe("Published 2 tools."); | |
| 83 | + expect(syncResult.tools).toEqual(["repo-summary", "stale-issues"]); | |
| 84 | + expect(listed.sources[0]?.tools).toEqual(["repo-summary", "stale-issues"]); | |
| 85 | + }); | |
| 86 | + | |
| 87 | + it("renders failed sync errors readably", async () => { | |
| 88 | + const fetchImpl = vi.fn(async () => | |
| 89 | + jsonResponse({ | |
| 90 | + status: "failed", | |
| 91 | + tools: [], | |
| 92 | + skipped: [], | |
| 93 | + errors: [ | |
| 94 | + { | |
| 95 | + stage: "collect", | |
| 96 | + message: "schema library not supported for schema export", | |
| 97 | + diagnostics: [{ path: "tools/x.ts", message: "vendor: nope" }], | |
| 98 | + }, | |
| 99 | + ], | |
| 100 | + }), | |
| 101 | + ) as unknown as typeof fetch; | |
| 102 | + | |
| 103 | + const result = await syncCustomToolSource( | |
| 104 | + { | |
| 105 | + repo: "RhysSullivan/executor-custom-tools-demo", | |
| 106 | + connection: "tools.github.user.demo", | |
| 107 | + }, | |
| 108 | + fetchImpl, | |
| 109 | + ); | |
| 110 | + | |
| 111 | + expect(syncStatusLabel(result)).toBe("Sync failed."); | |
| 112 | + expect(formatSyncErrors(result)).toEqual([ | |
| 113 | + "collect: schema library not supported for schema export (tools/x.ts: vendor: nope)", | |
| 114 | + ]); | |
| 115 | + }); | |
| 116 | + | |
| 117 | + it("shows the up-to-date sync state", async () => { | |
| 118 | + const fetchImpl = vi.fn(async () => | |
| 119 | + jsonResponse({ | |
| 120 | + status: "up-to-date", | |
| 121 | + upstreamSha: "abc123", | |
| 122 | + tools: ["repo-summary"], | |
| 123 | + skipped: [], | |
| 124 | + }), | |
| 125 | + ) as unknown as typeof fetch; | |
| 126 | + | |
| 127 | + const result = await syncCustomToolSource( | |
| 128 | + { | |
| 129 | + repo: "RhysSullivan/executor-custom-tools-demo", | |
| 130 | + connection: "tools.github.user.demo", | |
| 131 | + }, | |
| 132 | + fetchImpl, | |
| 133 | + ); | |
| 134 | + | |
| 135 | + expect(syncStatusLabel(result)).toBe("Already up to date."); | |
| 136 | + }); | |
| 137 | +}); | |
| @@ -6,6 +6,7 @@ · 5 unmodified lines | ||
| 6 | 6 | "strict": true, |
| 7 | 7 | "esModuleInterop": true, |
| 8 | 8 | "skipLibCheck": true, |
| 9 | + "jsx": "react-jsx", | |
| 9 | 10 | "types": ["bun-types"], |
| 10 | 11 | "noUnusedLocals": true, |
| 11 | 12 | "noImplicitOverride": true, |
| @@ -795,16 +795,28 @@ · 794 unmodified lines | ||
| 795 | 795 | "zod": "4.3.6", |
| 796 | 796 | }, |
| 797 | 797 | "devDependencies": { |
| 798 | + "@effect/atom-react": "catalog:", | |
| 798 | 799 | "@effect/vitest": "catalog:", |
| 800 | + "@executor-js/react": "workspace:*", | |
| 799 | 801 | "@types/node": "catalog:", |
| 802 | + "@types/react": "catalog:", | |
| 800 | 803 | "bun-types": "catalog:", |
| 801 | 804 | "effect": "catalog:", |
| 805 | + "react": "catalog:", | |
| 802 | 806 | "tsup": "catalog:", |
| 803 | 807 | "vitest": "catalog:", |
| 804 | 808 | }, |
| 805 | 809 | "peerDependencies": { |
| 810 | + "@effect/atom-react": "catalog:", | |
| 811 | + "@executor-js/react": "workspace:*", | |
| 806 | 812 | "effect": "catalog:", |
| 813 | + "react": "catalog:", | |
| 807 | 814 | }, |
| 815 | + "optionalPeers": [ | |
| 816 | + "@effect/atom-react", | |
| 817 | + "@executor-js/react", | |
| 818 | + "react", | |
| 819 | + ], | |
| 808 | 820 | }, |
| 809 | 821 | "packages/plugins/desktop-settings": { |
| 810 | 822 | "name": "@executor-js/plugin-desktop-settings", |
| @@ -1240,9 +1252,9 @@ · 429 unmodified lines | ||
| 1240 | 1252 | }, |
| 1241 | 1253 | }, |
| 1242 | 1254 | "patchedDependencies": { |
| 1243 | − "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", | |
| 1244 | 1255 | "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch", |
| 1245 | 1256 | "libsql@0.5.29": "patches/libsql@0.5.29.patch", |
| 1257 | + "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", | |
| 1246 | 1258 | "agents@0.17.3": "patches/agents@0.17.3.patch", |
| 1247 | 1259 | "postgres@3.4.9": "patches/postgres@3.4.9.patch", |
| 1248 | 1260 | }, |
| @@ -18,6 +18,7 @@ · 17 unmodified lines | ||
| 18 | 18 | "exports": { |
| 19 | 19 | ".": "./src/index.ts", |
| 20 | 20 | "./api": "./src/api.ts", |
| 21 | + "./client": "./src/react/plugin-client.tsx", | |
| 21 | 22 | "./seams": "./src/seams/index.ts", |
| 22 | 23 | "./testing": "./src/testing/index.ts" |
| 23 | 24 | }, |
| @@ -37,14 +38,32 @@ · 13 unmodified lines | ||
| 37 | 38 | "zod": "4.3.6" |
| 38 | 39 | }, |
| 39 | 40 | "devDependencies": { |
| 41 | + "@effect/atom-react": "catalog:", | |
| 40 | 42 | "@effect/vitest": "catalog:", |
| 43 | + "@executor-js/react": "workspace:*", | |
| 41 | 44 | "@types/node": "catalog:", |
| 45 | + "@types/react": "catalog:", | |
| 42 | 46 | "bun-types": "catalog:", |
| 43 | 47 | "effect": "catalog:", |
| 48 | + "react": "catalog:", | |
| 44 | 49 | "tsup": "catalog:", |
| 45 | 50 | "vitest": "catalog:" |
| 46 | 51 | }, |
| 47 | 52 | "peerDependencies": { |
| 48 | − "effect": "catalog:" | |
| 53 | + "@effect/atom-react": "catalog:", | |
| 54 | + "@executor-js/react": "workspace:*", | |
| 55 | + "effect": "catalog:", | |
| 56 | + "react": "catalog:" | |
| 57 | + }, | |
| 58 | + "peerDependenciesMeta": { | |
| 59 | + "@effect/atom-react": { | |
| 60 | + "optional": true | |
| 61 | + }, | |
| 62 | + "@executor-js/react": { | |
| 63 | + "optional": true | |
| 64 | + }, | |
| 65 | + "react": { | |
| 66 | + "optional": true | |
| 67 | + } | |
| 49 | 68 | } |
| 50 | 69 | } |
| @@ -13,7 +13,7 @@ export { · 12 unmodified lines | ||
| 13 | 13 | type SelfHostAppsRuntime, |
| 14 | 14 | type SelfHostAppsRuntimeOptions, |
| 15 | 15 | } from "./plugin/self-host-runtime"; |
| 16 | −export { type AppsRuntime } from "./plugin/runtime"; | |
| 16 | +export { type AppsRuntime, type GitHubCustomToolsSourceSummary } from "./plugin/runtime"; | |
| 17 | 17 | export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; |
| 18 | 18 | export { |
| 19 | 19 | BindingError, |
| @@ -77,6 +77,21 @@ export const makeSqliteAppsStore = (options: SqliteAppsStoreOptions): AppsStore · 76 unmodified lines | ||
| 77 | 77 | }, |
| 78 | 78 | catch: (cause) => storageFail("getDescriptor failed", cause), |
| 79 | 79 | }), |
| 80 | + listDescriptors: (tenant) => | |
| 81 | + Effect.tryPromise({ | |
| 82 | + try: async () => { | |
| 83 | + await init(); | |
| 84 | + const res = await client.execute({ | |
| 85 | + sql: "SELECT descriptor, published_at FROM descriptors WHERE tenant = ? ORDER BY published_at DESC", | |
| 86 | + args: [tenant], | |
| 87 | + }); | |
| 88 | + return res.rows.map((row) => ({ | |
| 89 | + descriptor: JSON.parse(String(row.descriptor)) as AppDescriptor, | |
| 90 | + publishedAt: Number(row.published_at), | |
| 91 | + })); | |
| 92 | + }, | |
| 93 | + catch: (cause) => storageFail("listDescriptors failed", cause), | |
| 94 | + }), | |
| 80 | 95 | putScopeForConnection: (tenant, connectionName, scope) => |
| 81 | 96 | Effect.tryPromise({ |
| 82 | 97 | try: async () => { |
| @@ -31,11 +31,18 @@ export interface IntegrationDecl { · 30 unmodified lines | ||
| 31 | 31 | readonly integration: string; |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | +export interface SourceSkippedArtifact { | |
| 35 | + readonly path: string; | |
| 36 | + readonly reason: "not supported yet" | "unsupported file type" | "ignored"; | |
| 37 | +} | |
| 38 | + | |
| 34 | 39 | export interface GitHubSourceRef { |
| 35 | 40 | readonly kind: "github"; |
| 36 | 41 | readonly repo: string; |
| 37 | 42 | readonly ref: string; |
| 38 | 43 | readonly upstreamSha: string; |
| 44 | + readonly connection?: string; | |
| 45 | + readonly skipped?: readonly SourceSkippedArtifact[]; | |
| 39 | 46 | } |
| 40 | 47 | |
| 41 | 48 | export type AppSourceRef = GitHubSourceRef; |
| @@ -269,6 +269,7 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 268 unmodified lines | ||
| 269 | 269 | scope, |
| 270 | 270 | repo, |
| 271 | 271 | ref, |
| 272 | + connection, | |
| 272 | 273 | token, |
| 273 | 274 | baseUrl: configBaseUrl(integration?.config), |
| 274 | 275 | }); |
| @@ -279,6 +280,16 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 4 unmodified lines | ||
| 279 | 280 | return result; |
| 280 | 281 | }), |
| 281 | 282 | }), |
| 283 | + tool<AppsStoreShape>({ | |
| 284 | + name: "list_github_sources", | |
| 285 | + description: "List synced GitHub repositories that publish custom tools.", | |
| 286 | + execute: (_args, { ctx }) => | |
| 287 | + Effect.gen(function* () { | |
| 288 | + const tenant = tenantFor(ctx); | |
| 289 | + const sources = yield* runtime.listGitHubSources(tenant); | |
| 290 | + return { sources }; | |
| 291 | + }), | |
| 292 | + }), | |
| 282 | 293 | ], |
| 283 | 294 | }, |
| 284 | 295 | ], |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | ||
| 25 | 25 | } from "./bindings"; |
| 26 | 26 | import type { AppsStore } from "./store"; |
| 27 | 27 | import { scopeAddress } from "../seams/scope-address"; |
| 28 | +import type { GitHubSkippedArtifact } from "../source/github-source"; | |
| 28 | 29 | |
| 29 | 30 | // --------------------------------------------------------------------------- |
| 30 | 31 | // AppsRuntime: the substrate-neutral core for published custom tools. |
| @@ -40,6 +41,19 @@ export interface AppsRuntimeDeps { · 9 unmodified lines | ||
| 40 | 41 | readonly defaultTenant?: string; |
| 41 | 42 | } |
| 42 | 43 | |
| 44 | +export interface GitHubCustomToolsSourceSummary { | |
| 45 | + readonly scope: string; | |
| 46 | + readonly repo: string; | |
| 47 | + readonly ref: string; | |
| 48 | + readonly connection?: string; | |
| 49 | + readonly upstreamSha: string; | |
| 50 | + readonly snapshotId: string; | |
| 51 | + readonly description?: string; | |
| 52 | + readonly publishedAt: string; | |
| 53 | + readonly tools: readonly string[]; | |
| 54 | + readonly skipped: readonly GitHubSkippedArtifact[]; | |
| 55 | +} | |
| 56 | + | |
| 43 | 57 | export interface AppsRuntime { |
| 44 | 58 | readonly publish: (input: { |
| 45 | 59 | readonly tenant?: string; |
| @@ -53,6 +67,9 @@ export interface AppsRuntime { · 7 unmodified lines | ||
| 53 | 67 | tenantOrScope: string, |
| 54 | 68 | scope?: string, |
| 55 | 69 | ) => Effect.Effect<AppDescriptor | null>; |
| 70 | + readonly listGitHubSources: ( | |
| 71 | + tenant?: string, | |
| 72 | + ) => Effect.Effect<readonly GitHubCustomToolsSourceSummary[]>; | |
| 56 | 73 | /** Re-derive the published-descriptor pointer from the latest committed |
| 57 | 74 | * snapshot. */ |
| 58 | 75 | readonly repair: ( |
| @@ -345,6 +362,37 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 286 unmodified lines | ||
| 345 | 362 | ); |
| 346 | 363 | }, |
| 347 | 364 | |
| 365 | + listGitHubSources: (tenantInput) => { | |
| 366 | + const tenant = resolveTenant(tenantInput); | |
| 367 | + return deps.store.listDescriptors(tenant).pipe( | |
| 368 | + Effect.orElseSucceed(() => []), | |
| 369 | + Effect.map((records) => | |
| 370 | + records.flatMap((record): GitHubCustomToolsSourceSummary[] => { | |
| 371 | + const { descriptor, publishedAt } = record; | |
| 372 | + const source = descriptor.source; | |
| 373 | + if (source?.kind !== "github") return []; | |
| 374 | + return [ | |
| 375 | + { | |
| 376 | + scope: descriptor.scope, | |
| 377 | + repo: source.repo, | |
| 378 | + ref: source.ref, | |
| 379 | + ...(source.connection ? { connection: source.connection } : {}), | |
| 380 | + upstreamSha: source.upstreamSha, | |
| 381 | + snapshotId: descriptor.snapshotId, | |
| 382 | + ...(descriptor.description ? { description: descriptor.description } : {}), | |
| 383 | + publishedAt: new Date(publishedAt).toISOString(), | |
| 384 | + tools: descriptor.tools.map((tool) => tool.name), | |
| 385 | + skipped: [ | |
| 386 | + ...(source.skipped ?? []), | |
| 387 | + ...(descriptor.skipped as readonly GitHubSkippedArtifact[]), | |
| 388 | + ], | |
| 389 | + }, | |
| 390 | + ]; | |
| 391 | + }), | |
| 392 | + ), | |
| 393 | + ); | |
| 394 | + }, | |
| 395 | + | |
| 348 | 396 | repair: (tenantOrScope, maybeScope) => { |
| 349 | 397 | const { tenant, scope } = resolveTenantScope(tenantOrScope, maybeScope); |
| 350 | 398 | return repairScope(tenant, scope); |
| @@ -30,6 +30,11 @@ export const scopeConnectionCollection = definePluginStorageCollection("apps_sco · 29 unmodified lines | ||
| 30 | 30 | }, |
| 31 | 31 | }); |
| 32 | 32 | |
| 33 | +export interface AppDescriptorRecord { | |
| 34 | + readonly descriptor: AppDescriptor; | |
| 35 | + readonly publishedAt: number; | |
| 36 | +} | |
| 37 | + | |
| 33 | 38 | export interface AppsStore { |
| 34 | 39 | readonly putDescriptor: ( |
| 35 | 40 | tenant: string, |
| @@ -40,6 +45,9 @@ export interface AppsStore { · 4 unmodified lines | ||
| 40 | 45 | tenant: string, |
| 41 | 46 | scope: string, |
| 42 | 47 | ) => Effect.Effect<AppDescriptor | null, StorageFailure>; |
| 48 | + readonly listDescriptors: ( | |
| 49 | + tenant: string, | |
| 50 | + ) => Effect.Effect<readonly AppDescriptorRecord[], StorageFailure>; | |
| 43 | 51 | readonly putScopeForConnection: ( |
| 44 | 52 | tenant: string, |
| 45 | 53 | connectionName: string, |
| @@ -92,5 +100,14 @@ export const makeAppsStore = (deps: AppsStoreDeps): AppsStore => { · 46 unmodified lines | ||
| 92 | 100 | descriptors |
| 93 | 101 | .get({ key: keyFor(tenant, scope) }) |
| 94 | 102 | .pipe(Effect.map((entry) => entry?.data.descriptor ?? null)), |
| 103 | + listDescriptors: (tenant) => | |
| 104 | + descriptors.list({ keyPrefix: `${tenant}:` }).pipe( | |
| 105 | + Effect.map((entries) => | |
| 106 | + entries.map((entry) => ({ | |
| 107 | + descriptor: entry.data.descriptor, | |
| 108 | + publishedAt: entry.data.publishedAt, | |
| 109 | + })), | |
| 110 | + ), | |
| 111 | + ), | |
| 95 | 112 | }; |
| 96 | 113 | }; |
| @@ -0,0 +1,163 @@ | ||
| 1 | +import { useMemo, useState } from "react"; | |
| 2 | +import { useAtomValue } from "@effect/atom-react"; | |
| 3 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 4 | + | |
| 5 | +import { connectionsAllAtom } from "@executor-js/react/api/atoms"; | |
| 6 | +import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert"; | |
| 7 | +import { Button } from "@executor-js/react/components/button"; | |
| 8 | +import { | |
| 9 | + CardStack, | |
| 10 | + CardStackContent, | |
| 11 | + CardStackEntryField, | |
| 12 | +} from "@executor-js/react/components/card-stack"; | |
| 13 | +import { FloatActions } from "@executor-js/react/components/float-actions"; | |
| 14 | +import { Input } from "@executor-js/react/components/input"; | |
| 15 | +import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; | |
| 16 | +import { toast } from "@executor-js/react/components/sonner"; | |
| 17 | +import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; | |
| 18 | + | |
| 19 | +import { | |
| 20 | + consoleIntegrationHref, | |
| 21 | + formatSyncErrors, | |
| 22 | + githubConnections, | |
| 23 | + syncCustomToolSource, | |
| 24 | + syncStatusLabel, | |
| 25 | + validateGitHubRepo, | |
| 26 | +} from "./custom-tools-client"; | |
| 27 | + | |
| 28 | +export default function AddCustomToolsSource(props: { | |
| 29 | + readonly onComplete: (slug?: string) => void; | |
| 30 | + readonly onCancel: () => void; | |
| 31 | +}) { | |
| 32 | + const connectionsResult = useAtomValue(connectionsAllAtom); | |
| 33 | + const connections = useMemo( | |
| 34 | + () => | |
| 35 | + AsyncResult.isSuccess(connectionsResult) ? githubConnections(connectionsResult.value) : [], | |
| 36 | + [connectionsResult], | |
| 37 | + ); | |
| 38 | + const [repo, setRepo] = useState(""); | |
| 39 | + const [ref, setRef] = useState(""); | |
| 40 | + const [connection, setConnection] = useState(""); | |
| 41 | + const [repoError, setRepoError] = useState<string | null>(null); | |
| 42 | + const [syncing, setSyncing] = useState(false); | |
| 43 | + const [syncError, setSyncError] = useState<string | null>(null); | |
| 44 | + | |
| 45 | + const selectedConnection = connection || connections[0]?.address || ""; | |
| 46 | + const loadingConnections = !AsyncResult.isSuccess(connectionsResult); | |
| 47 | + const canSubmit = | |
| 48 | + repo.trim().length > 0 && selectedConnection.length > 0 && !syncing && !loadingConnections; | |
| 49 | + | |
| 50 | + const submit = async () => { | |
| 51 | + const validation = validateGitHubRepo(repo); | |
| 52 | + setRepoError(validation); | |
| 53 | + setSyncError(null); | |
| 54 | + if (validation || !selectedConnection) return; | |
| 55 | + | |
| 56 | + setSyncing(true); | |
| 57 | + try { | |
| 58 | + const result = await syncCustomToolSource({ | |
| 59 | + repo, | |
| 60 | + ref, | |
| 61 | + connection: selectedConnection, | |
| 62 | + }); | |
| 63 | + if (result.status === "failed") { | |
| 64 | + setSyncError(formatSyncErrors(result).join("\n") || "Sync failed."); | |
| 65 | + setSyncing(false); | |
| 66 | + return; | |
| 67 | + } | |
| 68 | + toast.success(syncStatusLabel(result)); | |
| 69 | + props.onComplete("apps"); | |
| 70 | + } catch (error) { | |
| 71 | + setSyncError(error instanceof Error ? error.message : "Failed to sync custom tools."); | |
| 72 | + setSyncing(false); | |
| 73 | + } | |
| 74 | + }; | |
| 75 | + | |
| 76 | + return ( | |
| 77 | + <div className="flex flex-1 flex-col gap-6"> | |
| 78 | + <div> | |
| 79 | + <h1 className="text-xl font-semibold text-foreground">Add custom tools</h1> | |
| 80 | + <p className="mt-1 text-[13px] text-muted-foreground"> | |
| 81 | + Sync a GitHub repository that defines tools with executor:app. | |
| 82 | + </p> | |
| 83 | + </div> | |
| 84 | + | |
| 85 | + <CardStack> | |
| 86 | + <CardStackContent className="border-t-0"> | |
| 87 | + <CardStackEntryField label="GitHub repo" description="- Owner and repository name."> | |
| 88 | + <div className="space-y-1.5"> | |
| 89 | + <Input | |
| 90 | + value={repo} | |
| 91 | + onChange={(event) => { | |
| 92 | + setRepo((event.target as HTMLInputElement).value); | |
| 93 | + setRepoError(null); | |
| 94 | + setSyncError(null); | |
| 95 | + }} | |
| 96 | + onBlur={() => setRepoError(validateGitHubRepo(repo))} | |
| 97 | + placeholder="owner/name" | |
| 98 | + className="font-mono text-sm" | |
| 99 | + aria-invalid={repoError ? true : undefined} | |
| 100 | + /> | |
| 101 | + {repoError && <p className="text-xs text-destructive">{repoError}</p>} | |
| 102 | + </div> | |
| 103 | + </CardStackEntryField> | |
| 104 | + | |
| 105 | + <CardStackEntryField label="Ref" description="- Branch, tag, or commit SHA."> | |
| 106 | + <Input | |
| 107 | + value={ref} | |
| 108 | + onChange={(event) => setRef((event.target as HTMLInputElement).value)} | |
| 109 | + placeholder="default branch" | |
| 110 | + className="font-mono text-sm" | |
| 111 | + /> | |
| 112 | + </CardStackEntryField> | |
| 113 | + | |
| 114 | + <CardStackEntryField | |
| 115 | + label="GitHub connection" | |
| 116 | + description="- Used to fetch the repo and later resync it." | |
| 117 | + > | |
| 118 | + {connections.length > 0 ? ( | |
| 119 | + <NativeSelect | |
| 120 | + value={selectedConnection} | |
| 121 | + onChange={(event) => setConnection((event.target as HTMLSelectElement).value)} | |
| 122 | + className="w-full font-mono text-sm" | |
| 123 | + disabled={syncing} | |
| 124 | + > | |
| 125 | + {connections.map((candidate) => ( | |
| 126 | + <NativeSelectOption key={candidate.address} value={candidate.address}> | |
| 127 | + {candidate.address} | |
| 128 | + </NativeSelectOption> | |
| 129 | + ))} | |
| 130 | + </NativeSelect> | |
| 131 | + ) : ( | |
| 132 | + <Alert> | |
| 133 | + <AlertTitle>No GitHub connections</AlertTitle> | |
| 134 | + <AlertDescription> | |
| 135 | + <span> | |
| 136 | + Connect GitHub first, then return here to sync a custom-tools repo.{" "} | |
| 137 | + <a | |
| 138 | + className="font-medium text-foreground underline" | |
| 139 | + href={consoleIntegrationHref("/integrations/github")} | |
| 140 | + > | |
| 141 | + Open GitHub | |
| 142 | + </a> | |
| 143 | + </span> | |
| 144 | + </AlertDescription> | |
| 145 | + </Alert> | |
| 146 | + )} | |
| 147 | + </CardStackEntryField> | |
| 148 | + </CardStackContent> | |
| 149 | + </CardStack> | |
| 150 | + | |
| 151 | + {syncError && <FormErrorAlert message={syncError} />} | |
| 152 | + | |
| 153 | + <FloatActions> | |
| 154 | + <Button type="button" variant="ghost" onClick={() => props.onCancel()} disabled={syncing}> | |
| 155 | + Cancel | |
| 156 | + </Button> | |
| 157 | + <Button type="button" onClick={() => void submit()} disabled={!canSubmit} loading={syncing}> | |
| 158 | + Sync repo | |
| 159 | + </Button> | |
| 160 | + </FloatActions> | |
| 161 | + </div> | |
| 162 | + ); | |
| 163 | +} | |
| @@ -0,0 +1,344 @@ | ||
| 1 | +import { useEffect, useMemo, useState, type ReactNode } from "react"; | |
| 2 | + | |
| 3 | +import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert"; | |
| 4 | +import { Badge } from "@executor-js/react/components/badge"; | |
| 5 | +import { Button } from "@executor-js/react/components/button"; | |
| 6 | +import { | |
| 7 | + CardStack, | |
| 8 | + CardStackContent, | |
| 9 | + CardStackEntry, | |
| 10 | + CardStackEntryActions, | |
| 11 | + CardStackEntryContent, | |
| 12 | + CardStackEntryDescription, | |
| 13 | + CardStackEntryTitle, | |
| 14 | +} from "@executor-js/react/components/card-stack"; | |
| 15 | + | |
| 16 | +import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api"; | |
| 17 | +import { | |
| 18 | + consoleIntegrationHref, | |
| 19 | + formatSyncErrors, | |
| 20 | + listCustomToolSources, | |
| 21 | + syncCustomToolSource, | |
| 22 | + syncStatusLabel, | |
| 23 | + toolDiff, | |
| 24 | +} from "./custom-tools-client"; | |
| 25 | + | |
| 26 | +type LoadState = | |
| 27 | + | { readonly status: "loading" } | |
| 28 | + | { readonly status: "error"; readonly message: string } | |
| 29 | + | { readonly status: "ready"; readonly sources: readonly GitHubCustomToolsSourceSummary[] }; | |
| 30 | + | |
| 31 | +interface SyncNotice { | |
| 32 | + readonly status: GitHubSyncResult["status"]; | |
| 33 | + readonly message: string; | |
| 34 | + readonly added: readonly string[]; | |
| 35 | + readonly removed: readonly string[]; | |
| 36 | + readonly errors: readonly string[]; | |
| 37 | +} | |
| 38 | + | |
| 39 | +const formatDate = (value: string): string => { | |
| 40 | + const date = new Date(value); | |
| 41 | + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); | |
| 42 | +}; | |
| 43 | + | |
| 44 | +export default function CustomToolsAccountsPanel() { | |
| 45 | + const [loadState, setLoadState] = useState<LoadState>({ status: "loading" }); | |
| 46 | + const [selectedScope, setSelectedScope] = useState<string | null>(null); | |
| 47 | + const [syncingScope, setSyncingScope] = useState<string | null>(null); | |
| 48 | + const [notice, setNotice] = useState<SyncNotice | null>(null); | |
| 49 | + | |
| 50 | + const loadSources = async () => { | |
| 51 | + setLoadState({ status: "loading" }); | |
| 52 | + try { | |
| 53 | + const result = await listCustomToolSources(); | |
| 54 | + setLoadState({ status: "ready", sources: result.sources }); | |
| 55 | + setSelectedScope((current) => current ?? result.sources[0]?.scope ?? null); | |
| 56 | + } catch (error) { | |
| 57 | + setLoadState({ | |
| 58 | + status: "error", | |
| 59 | + message: error instanceof Error ? error.message : "Failed to load custom tool sources.", | |
| 60 | + }); | |
| 61 | + } | |
| 62 | + }; | |
| 63 | + | |
| 64 | + useEffect(() => { | |
| 65 | + let active = true; | |
| 66 | + void (async () => { | |
| 67 | + try { | |
| 68 | + const result = await listCustomToolSources(); | |
| 69 | + if (!active) return; | |
| 70 | + setLoadState({ status: "ready", sources: result.sources }); | |
| 71 | + setSelectedScope(result.sources[0]?.scope ?? null); | |
| 72 | + } catch (error) { | |
| 73 | + if (!active) return; | |
| 74 | + setLoadState({ | |
| 75 | + status: "error", | |
| 76 | + message: error instanceof Error ? error.message : "Failed to load custom tool sources.", | |
| 77 | + }); | |
| 78 | + } | |
| 79 | + })(); | |
| 80 | + return () => { | |
| 81 | + active = false; | |
| 82 | + }; | |
| 83 | + }, []); | |
| 84 | + | |
| 85 | + const sources = loadState.status === "ready" ? loadState.sources : []; | |
| 86 | + const selected = useMemo( | |
| 87 | + () => sources.find((source) => source.scope === selectedScope) ?? sources[0] ?? null, | |
| 88 | + [sources, selectedScope], | |
| 89 | + ); | |
| 90 | + | |
| 91 | + const syncSelected = async (source: GitHubCustomToolsSourceSummary) => { | |
| 92 | + if (!source.connection) { | |
| 93 | + setNotice({ | |
| 94 | + status: "failed", | |
| 95 | + message: "Sync failed.", | |
| 96 | + added: [], | |
| 97 | + removed: [], | |
| 98 | + errors: ["This source does not have a recorded GitHub connection."], | |
| 99 | + }); | |
| 100 | + return; | |
| 101 | + } | |
| 102 | + setSyncingScope(source.scope); | |
| 103 | + setNotice(null); | |
| 104 | + const beforeTools = source.tools; | |
| 105 | + try { | |
| 106 | + const result = await syncCustomToolSource({ | |
| 107 | + repo: source.repo, | |
| 108 | + ref: source.ref, | |
| 109 | + connection: source.connection, | |
| 110 | + }); | |
| 111 | + const diff = | |
| 112 | + result.status === "failed" | |
| 113 | + ? { added: [], removed: [] } | |
| 114 | + : toolDiff(beforeTools, result.tools); | |
| 115 | + setNotice({ | |
| 116 | + status: result.status, | |
| 117 | + message: syncStatusLabel(result), | |
| 118 | + added: diff.added, | |
| 119 | + removed: diff.removed, | |
| 120 | + errors: formatSyncErrors(result), | |
| 121 | + }); | |
| 122 | + if (result.status !== "failed") { | |
| 123 | + await loadSources(); | |
| 124 | + setSelectedScope(source.scope); | |
| 125 | + } | |
| 126 | + } catch (error) { | |
| 127 | + setNotice({ | |
| 128 | + status: "failed", | |
| 129 | + message: "Sync failed.", | |
| 130 | + added: [], | |
| 131 | + removed: [], | |
| 132 | + errors: [error instanceof Error ? error.message : "Failed to sync custom tools."], | |
| 133 | + }); | |
| 134 | + } | |
| 135 | + setSyncingScope(null); | |
| 136 | + }; | |
| 137 | + | |
| 138 | + return ( | |
| 139 | + <div className="mx-auto max-w-5xl space-y-6 px-6 py-8"> | |
| 140 | + <div className="flex items-center justify-between gap-3"> | |
| 141 | + <div> | |
| 142 | + <h2 className="text-sm font-medium text-foreground">Custom tools sources</h2> | |
| 143 | + <p className="mt-1 text-xs text-muted-foreground"> | |
| 144 | + GitHub repositories synced into the executor tool catalog. | |
| 145 | + </p> | |
| 146 | + </div> | |
| 147 | + <Button asChild size="sm" variant="outline"> | |
| 148 | + <a href={consoleIntegrationHref("/integrations/add/apps")}>Add source</a> | |
| 149 | + </Button> | |
| 150 | + </div> | |
| 151 | + | |
| 152 | + {loadState.status === "loading" && ( | |
| 153 | + <div className="rounded-lg border border-border p-4 text-sm text-muted-foreground"> | |
| 154 | + Loading sources... | |
| 155 | + </div> | |
| 156 | + )} | |
| 157 | + | |
| 158 | + {loadState.status === "error" && ( | |
| 159 | + <ErrorWithRetry message={loadState.message} onRetry={() => void loadSources()} /> | |
| 160 | + )} | |
| 161 | + | |
| 162 | + {loadState.status === "ready" && sources.length === 0 && ( | |
| 163 | + <div className="rounded-lg border border-dashed border-border p-8 text-center"> | |
| 164 | + <p className="text-sm font-medium text-foreground">No custom tools sources</p> | |
| 165 | + <p className="mt-1 text-sm text-muted-foreground"> | |
| 166 | + Add a GitHub repo to publish its tools. | |
| 167 | + </p> | |
| 168 | + <Button asChild size="sm" className="mt-4"> | |
| 169 | + <a href={consoleIntegrationHref("/integrations/add/apps")}>Add source</a> | |
| 170 | + </Button> | |
| 171 | + </div> | |
| 172 | + )} | |
| 173 | + | |
| 174 | + {loadState.status === "ready" && sources.length > 0 && ( | |
| 175 | + <div className="grid min-h-[28rem] gap-4 lg:grid-cols-[18rem_1fr]"> | |
| 176 | + <CardStack searchable> | |
| 177 | + <CardStackContent> | |
| 178 | + {sources.map((source) => ( | |
| 179 | + <CardStackEntry | |
| 180 | + key={source.scope} | |
| 181 | + asChild | |
| 182 | + searchText={`${source.repo} ${source.ref}`} | |
| 183 | + > | |
| 184 | + <button | |
| 185 | + type="button" | |
| 186 | + onClick={() => { | |
| 187 | + setSelectedScope(source.scope); | |
| 188 | + setNotice(null); | |
| 189 | + }} | |
| 190 | + className={ | |
| 191 | + selected?.scope === source.scope | |
| 192 | + ? "bg-muted/70 text-left" | |
| 193 | + : "bg-transparent text-left" | |
| 194 | + } | |
| 195 | + > | |
| 196 | + <CardStackEntryContent> | |
| 197 | + <CardStackEntryTitle>{source.repo}</CardStackEntryTitle> | |
| 198 | + <CardStackEntryDescription>{source.ref}</CardStackEntryDescription> | |
| 199 | + </CardStackEntryContent> | |
| 200 | + <CardStackEntryActions> | |
| 201 | + <Badge variant="secondary">{source.tools.length}</Badge> | |
| 202 | + </CardStackEntryActions> | |
| 203 | + </button> | |
| 204 | + </CardStackEntry> | |
| 205 | + ))} | |
| 206 | + </CardStackContent> | |
| 207 | + </CardStack> | |
| 208 | + | |
| 209 | + {selected && ( | |
| 210 | + <SourceDetail | |
| 211 | + source={selected} | |
| 212 | + notice={notice} | |
| 213 | + syncing={syncingScope === selected.scope} | |
| 214 | + onSync={() => void syncSelected(selected)} | |
| 215 | + /> | |
| 216 | + )} | |
| 217 | + </div> | |
| 218 | + )} | |
| 219 | + </div> | |
| 220 | + ); | |
| 221 | +} | |
| 222 | + | |
| 223 | +function ErrorWithRetry(props: { readonly message: string; readonly onRetry: () => void }) { | |
| 224 | + return ( | |
| 225 | + <Alert variant="destructive"> | |
| 226 | + <AlertTitle>Failed to load sources</AlertTitle> | |
| 227 | + <AlertDescription> | |
| 228 | + <div className="space-y-3"> | |
| 229 | + <p>{props.message}</p> | |
| 230 | + <Button type="button" size="sm" variant="outline" onClick={props.onRetry}> | |
| 231 | + Retry | |
| 232 | + </Button> | |
| 233 | + </div> | |
| 234 | + </AlertDescription> | |
| 235 | + </Alert> | |
| 236 | + ); | |
| 237 | +} | |
| 238 | + | |
| 239 | +function SourceDetail(props: { | |
| 240 | + readonly source: GitHubCustomToolsSourceSummary; | |
| 241 | + readonly notice: SyncNotice | null; | |
| 242 | + readonly syncing: boolean; | |
| 243 | + readonly onSync: () => void; | |
| 244 | +}) { | |
| 245 | + const { source, notice } = props; | |
| 246 | + return ( | |
| 247 | + <div className="min-w-0 rounded-lg border border-border bg-card"> | |
| 248 | + <div className="flex items-start justify-between gap-4 border-b border-border p-4"> | |
| 249 | + <div className="min-w-0"> | |
| 250 | + <h3 className="truncate text-sm font-medium text-foreground">{source.repo}</h3> | |
| 251 | + <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{source.scope}</p> | |
| 252 | + </div> | |
| 253 | + <Button | |
| 254 | + type="button" | |
| 255 | + size="sm" | |
| 256 | + onClick={props.onSync} | |
| 257 | + loading={props.syncing} | |
| 258 | + disabled={!source.connection} | |
| 259 | + > | |
| 260 | + Sync | |
| 261 | + </Button> | |
| 262 | + </div> | |
| 263 | + | |
| 264 | + <div className="space-y-6 p-4"> | |
| 265 | + <dl className="grid gap-3 text-sm sm:grid-cols-2"> | |
| 266 | + <Field label="Ref" value={source.ref} /> | |
| 267 | + <Field label="Upstream SHA" value={source.upstreamSha} mono /> | |
| 268 | + <Field label="Last synced" value={formatDate(source.publishedAt)} /> | |
| 269 | + <Field label="GitHub connection" value={source.connection ?? "Not recorded"} mono /> | |
| 270 | + </dl> | |
| 271 | + | |
| 272 | + {notice && ( | |
| 273 | + <Alert variant={notice.status === "failed" ? "destructive" : "default"}> | |
| 274 | + <AlertTitle>{notice.message}</AlertTitle> | |
| 275 | + {(notice.added.length > 0 || notice.removed.length > 0 || notice.errors.length > 0) && ( | |
| 276 | + <AlertDescription> | |
| 277 | + <div className="space-y-1"> | |
| 278 | + {notice.added.length > 0 && <p>Added: {notice.added.join(", ")}</p>} | |
| 279 | + {notice.removed.length > 0 && <p>Removed: {notice.removed.join(", ")}</p>} | |
| 280 | + {notice.errors.map((error) => ( | |
| 281 | + <p key={error}>{error}</p> | |
| 282 | + ))} | |
| 283 | + </div> | |
| 284 | + </AlertDescription> | |
| 285 | + )} | |
| 286 | + </Alert> | |
| 287 | + )} | |
| 288 | + | |
| 289 | + <Section title="Published tools"> | |
| 290 | + {source.tools.length > 0 ? ( | |
| 291 | + <div className="flex flex-wrap gap-2"> | |
| 292 | + {source.tools.map((tool) => ( | |
| 293 | + <Badge key={tool} variant="secondary"> | |
| 294 | + {tool} | |
| 295 | + </Badge> | |
| 296 | + ))} | |
| 297 | + </div> | |
| 298 | + ) : ( | |
| 299 | + <p className="text-sm text-muted-foreground">No tools published.</p> | |
| 300 | + )} | |
| 301 | + </Section> | |
| 302 | + | |
| 303 | + <Section title="Skipped entries"> | |
| 304 | + {source.skipped.length > 0 ? ( | |
| 305 | + <div className="divide-y divide-border rounded-md border border-border"> | |
| 306 | + {source.skipped.map((entry) => ( | |
| 307 | + <div key={`${entry.path}:${entry.reason}`} className="flex gap-3 px-3 py-2 text-sm"> | |
| 308 | + <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground"> | |
| 309 | + {entry.path} | |
| 310 | + </span> | |
| 311 | + <span className="shrink-0 text-xs text-muted-foreground">{entry.reason}</span> | |
| 312 | + </div> | |
| 313 | + ))} | |
| 314 | + </div> | |
| 315 | + ) : ( | |
| 316 | + <p className="text-sm text-muted-foreground">Nothing skipped.</p> | |
| 317 | + )} | |
| 318 | + </Section> | |
| 319 | + </div> | |
| 320 | + </div> | |
| 321 | + ); | |
| 322 | +} | |
| 323 | + | |
| 324 | +function Field(props: { readonly label: string; readonly value: string; readonly mono?: boolean }) { | |
| 325 | + return ( | |
| 326 | + <div className="min-w-0"> | |
| 327 | + <dt className="text-xs font-medium text-muted-foreground">{props.label}</dt> | |
| 328 | + <dd | |
| 329 | + className={`mt-1 truncate text-sm text-foreground ${props.mono ? "font-mono text-xs" : ""}`} | |
| 330 | + > | |
| 331 | + {props.value} | |
| 332 | + </dd> | |
| 333 | + </div> | |
| 334 | + ); | |
| 335 | +} | |
| 336 | + | |
| 337 | +function Section(props: { readonly title: string; readonly children: ReactNode }) { | |
| 338 | + return ( | |
| 339 | + <section className="space-y-2"> | |
| 340 | + <h4 className="text-xs font-medium text-muted-foreground">{props.title}</h4> | |
| 341 | + {props.children} | |
| 342 | + </section> | |
| 343 | + ); | |
| 344 | +} | |
| @@ -0,0 +1,113 @@ | ||
| 1 | +import type { Connection } from "@executor-js/sdk/shared"; | |
| 2 | + | |
| 3 | +import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api"; | |
| 4 | + | |
| 5 | +export const CUSTOM_TOOLS_PLUGIN_KEY = "apps"; | |
| 6 | +export const CUSTOM_TOOLS_LABEL = "Custom tools"; | |
| 7 | + | |
| 8 | +export interface GitHubSourcesListResponse { | |
| 9 | + readonly sources: readonly GitHubCustomToolsSourceSummary[]; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export interface SyncGitHubSourceRequest { | |
| 13 | + readonly repo: string; | |
| 14 | + readonly ref?: string; | |
| 15 | + readonly connection: string; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export class CustomToolsClientError extends Error { | |
| 19 | + constructor(message: string) { | |
| 20 | + super(message); | |
| 21 | + this.name = "CustomToolsClientError"; | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 25 | +const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; | |
| 26 | + | |
| 27 | +export const validateGitHubRepo = (repo: string): string | null => { | |
| 28 | + const trimmed = repo.trim(); | |
| 29 | + if (trimmed.length === 0) return "Enter a GitHub repo."; | |
| 30 | + if (!REPO_RE.test(trimmed)) return "Use owner/name, for example UsefulSoftwareCo/executor."; | |
| 31 | + return null; | |
| 32 | +}; | |
| 33 | + | |
| 34 | +export const githubConnections = (connections: readonly Connection[]): readonly Connection[] => | |
| 35 | + connections.filter((connection) => String(connection.integration) === "github"); | |
| 36 | + | |
| 37 | +const parseJsonResponse = async <A>(response: Response, fallback: string): Promise<A> => { | |
| 38 | + const text = await response.text(); | |
| 39 | + const body = text.length > 0 ? (JSON.parse(text) as unknown) : null; | |
| 40 | + if (!response.ok) { | |
| 41 | + const message = | |
| 42 | + body && | |
| 43 | + typeof body === "object" && | |
| 44 | + "error" in body && | |
| 45 | + typeof (body as { error?: unknown }).error === "string" | |
| 46 | + ? (body as { error: string }).error | |
| 47 | + : fallback; | |
| 48 | + throw new CustomToolsClientError(message); | |
| 49 | + } | |
| 50 | + return body as A; | |
| 51 | +}; | |
| 52 | + | |
| 53 | +export const listCustomToolSources = async ( | |
| 54 | + fetchImpl: typeof fetch = fetch, | |
| 55 | +): Promise<GitHubSourcesListResponse> => { | |
| 56 | + const response = await fetchImpl("/api/apps/sources/github", { | |
| 57 | + credentials: "same-origin", | |
| 58 | + }); | |
| 59 | + return parseJsonResponse<GitHubSourcesListResponse>(response, "Failed to load custom tools."); | |
| 60 | +}; | |
| 61 | + | |
| 62 | +export const syncCustomToolSource = async ( | |
| 63 | + input: SyncGitHubSourceRequest, | |
| 64 | + fetchImpl: typeof fetch = fetch, | |
| 65 | +): Promise<GitHubSyncResult> => { | |
| 66 | + const response = await fetchImpl("/api/apps/sources/github/sync", { | |
| 67 | + method: "POST", | |
| 68 | + headers: { "content-type": "application/json" }, | |
| 69 | + credentials: "same-origin", | |
| 70 | + body: JSON.stringify({ | |
| 71 | + repo: input.repo.trim(), | |
| 72 | + ...(input.ref?.trim() ? { ref: input.ref.trim() } : {}), | |
| 73 | + connection: input.connection, | |
| 74 | + }), | |
| 75 | + }); | |
| 76 | + return parseJsonResponse<GitHubSyncResult>(response, "Failed to sync custom tools."); | |
| 77 | +}; | |
| 78 | + | |
| 79 | +export const syncStatusLabel = (result: GitHubSyncResult): string => { | |
| 80 | + if (result.status === "published") return `Published ${result.tools.length} tools.`; | |
| 81 | + if (result.status === "up-to-date") return "Already up to date."; | |
| 82 | + return "Sync failed."; | |
| 83 | +}; | |
| 84 | + | |
| 85 | +export const formatSyncErrors = (result: GitHubSyncResult): readonly string[] => { | |
| 86 | + if (result.status !== "failed") return []; | |
| 87 | + return result.errors.map((error) => { | |
| 88 | + const details = error.diagnostics?.map((d) => `${d.path}: ${d.message}`).join("; "); | |
| 89 | + return details | |
| 90 | + ? `${error.stage}: ${error.message} (${details})` | |
| 91 | + : `${error.stage}: ${error.message}`; | |
| 92 | + }); | |
| 93 | +}; | |
| 94 | + | |
| 95 | +export const toolDiff = ( | |
| 96 | + before: readonly string[], | |
| 97 | + after: readonly string[], | |
| 98 | +): { readonly added: readonly string[]; readonly removed: readonly string[] } => { | |
| 99 | + const beforeSet = new Set(before); | |
| 100 | + const afterSet = new Set(after); | |
| 101 | + return { | |
| 102 | + added: after.filter((tool) => !beforeSet.has(tool)), | |
| 103 | + removed: before.filter((tool) => !afterSet.has(tool)), | |
| 104 | + }; | |
| 105 | +}; | |
| 106 | + | |
| 107 | +export const consoleIntegrationHref = (path: string): string => { | |
| 108 | + if (typeof window === "undefined") return path; | |
| 109 | + const marker = "/integrations"; | |
| 110 | + const index = window.location.pathname.indexOf(marker); | |
| 111 | + const prefix = index === -1 ? "" : window.location.pathname.slice(0, index); | |
| 112 | + return `${prefix}${path}`; | |
| 113 | +}; | |
| @@ -0,0 +1,11 @@ | ||
| 1 | +import { defineClientPlugin } from "@executor-js/sdk/client"; | |
| 2 | + | |
| 3 | +import { appsIntegrationPlugin } from "./source-plugin"; | |
| 4 | + | |
| 5 | +export { appsIntegrationPlugin } from "./source-plugin"; | |
| 6 | +export * from "./custom-tools-client"; | |
| 7 | + | |
| 8 | +export default defineClientPlugin({ | |
| 9 | + id: "apps", | |
| 10 | + integrationPlugin: appsIntegrationPlugin, | |
| 11 | +}); | |
| @@ -0,0 +1,18 @@ | ||
| 1 | +import { lazy } from "react"; | |
| 2 | +import type { IntegrationPlugin } from "@executor-js/sdk/client"; | |
| 3 | + | |
| 4 | +import { CUSTOM_TOOLS_LABEL, CUSTOM_TOOLS_PLUGIN_KEY } from "./custom-tools-client"; | |
| 5 | + | |
| 6 | +const importAdd = () => import("./AddCustomToolsSource"); | |
| 7 | +const importAccounts = () => import("./CustomToolsAccountsPanel"); | |
| 8 | + | |
| 9 | +export const appsIntegrationPlugin: IntegrationPlugin = { | |
| 10 | + key: CUSTOM_TOOLS_PLUGIN_KEY, | |
| 11 | + label: CUSTOM_TOOLS_LABEL, | |
| 12 | + add: lazy(importAdd), | |
| 13 | + accounts: lazy(importAccounts), | |
| 14 | + preload: () => { | |
| 15 | + void importAdd(); | |
| 16 | + void importAccounts(); | |
| 17 | + }, | |
| 18 | +}; | |
| @@ -159,6 +159,10 @@ describe("GitHub custom-tools source", () => { · 158 unmodified lines | ||
| 159 | 159 | repo: "acme/tools", |
| 160 | 160 | ref: "main", |
| 161 | 161 | upstreamSha: "commit-a", |
| 162 | + skipped: [ | |
| 163 | + { path: "workflows/deferred.ts", reason: "not supported yet" }, | |
| 164 | + { path: "docs/readme.md", reason: "ignored" }, | |
| 165 | + ], | |
| 162 | 166 | }); |
| 163 | 167 | await host.close(); |
| 164 | 168 | }); |
| @@ -2,7 +2,7 @@ import { Data, Effect, Result } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { PublishError, type FileDiagnostic } from "../pipeline/discover"; |
| 4 | 4 | import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish"; |
| 5 | −import type { AppSourceRef } from "../pipeline/descriptor"; | |
| 5 | +import type { AppSourceRef, SourceSkippedArtifact } from "../pipeline/descriptor"; | |
| 6 | 6 | import type { AppsRuntime } from "../plugin/runtime"; |
| 7 | 7 | import type { FileSet, SnapshotId } from "../seams/artifact-store"; |
| 8 | 8 | |
| @@ -36,10 +36,7 @@ export interface SyncErrorData { · 27 unmodified lines | ||
| 36 | 36 | readonly diagnostics?: readonly FileDiagnostic[]; |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | −export interface GitHubSkippedArtifact { | |
| 40 | − readonly path: string; | |
| 41 | − readonly reason: "not supported yet" | "unsupported file type" | "ignored"; | |
| 42 | −} | |
| 39 | +export type GitHubSkippedArtifact = SourceSkippedArtifact; | |
| 43 | 40 | |
| 44 | 41 | export type GitHubSyncResult = |
| 45 | 42 | | { |
| @@ -69,6 +66,7 @@ export interface SyncGitHubSourceInput extends GitHubSourceInput { · 23 unmodified lines | ||
| 69 | 66 | readonly runtime: AppsRuntime; |
| 70 | 67 | readonly tenant?: string; |
| 71 | 68 | readonly scope: string; |
| 69 | + readonly connection?: string; | |
| 72 | 70 | } |
| 73 | 71 | |
| 74 | 72 | const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/; |
| @@ -374,11 +372,13 @@ const sourceErrorToSyncError = (error: GitHubSourceError): SyncErrorData => ({ · 299 unmodified lines | ||
| 374 | 372 | diagnostics: error.path ? [{ path: error.path, message: error.message }] : [], |
| 375 | 373 | }); |
| 376 | 374 | |
| 377 | −const sourceRef = (snapshot: GitHubSourceSnapshot): AppSourceRef => ({ | |
| 375 | +const sourceRef = (snapshot: GitHubSourceSnapshot, connection?: string): AppSourceRef => ({ | |
| 378 | 376 | kind: "github", |
| 379 | 377 | repo: snapshot.repo, |
| 380 | 378 | ref: snapshot.ref, |
| 381 | 379 | upstreamSha: snapshot.upstreamSha, |
| 380 | + ...(connection ? { connection } : {}), | |
| 381 | + skipped: snapshot.skipped, | |
| 382 | 382 | }); |
| 383 | 383 | |
| 384 | 384 | export const syncGitHubSource = (input: SyncGitHubSourceInput): Effect.Effect<GitHubSyncResult> => |
| @@ -420,7 +420,7 @@ export const syncGitHubSource = (input: SyncGitHubSourceInput): Effect.Effect<Gi · 35 unmodified lines | ||
| 420 | 420 | scope: input.scope, |
| 421 | 421 | files: snapshot.files, |
| 422 | 422 | description: snapshot.description, |
| 423 | − source: sourceRef(snapshot), | |
| 423 | + source: sourceRef(snapshot, input.connection), | |
| 424 | 424 | message: `sync ${snapshot.repo}@${snapshot.upstreamSha}`, |
| 425 | 425 | }) |
| 426 | 426 | .pipe(Effect.result); |
| @@ -19,14 +19,28 @@ export const makeInMemoryAppsStore = (): AppsStore & { · 18 unmodified lines | ||
| 19 | 19 | readonly descriptors: Map<string, AppDescriptor>; |
| 20 | 20 | } => { |
| 21 | 21 | const descriptors = new Map<string, AppDescriptor>(); |
| 22 | + const publishedAt = new Map<string, number>(); | |
| 22 | 23 | const scopeConnections = new Map<string, string>(); |
| 23 | 24 | const keyFor = (tenant: string, key: string): string => `${tenant}:${key}`; |
| 24 | 25 | return { |
| 25 | 26 | descriptors, |
| 26 | 27 | putDescriptor: (tenant, _owner, descriptor) => |
| 27 | − Effect.sync(() => void descriptors.set(keyFor(tenant, descriptor.scope), descriptor)), | |
| 28 | + Effect.sync(() => { | |
| 29 | + const key = keyFor(tenant, descriptor.scope); | |
| 30 | + descriptors.set(key, descriptor); | |
| 31 | + publishedAt.set(key, Date.now()); | |
| 32 | + }), | |
| 28 | 33 | getDescriptor: (tenant, scope) => |
| 29 | 34 | Effect.sync(() => descriptors.get(keyFor(tenant, scope)) ?? null), |
| 35 | + listDescriptors: (tenant) => | |
| 36 | + Effect.sync(() => | |
| 37 | + [...descriptors.entries()] | |
| 38 | + .filter(([key]) => key.startsWith(`${tenant}:`)) | |
| 39 | + .map(([key, descriptor]) => ({ | |
| 40 | + descriptor, | |
| 41 | + publishedAt: publishedAt.get(key) ?? 0, | |
| 42 | + })), | |
| 43 | + ), | |
| 30 | 44 | putScopeForConnection: (tenant, connectionName, scope) => |
| 31 | 45 | Effect.sync(() => void scopeConnections.set(keyFor(tenant, connectionName), scope)), |
| 32 | 46 | getScopeForConnection: (tenant, connectionName) => |
| @@ -153,8 +153,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 152 unmodified lines | ||
| 153 | 153 | // Find the plugin edit component based on integration kind |
| 154 | 154 | const editPlugin = useMemo(() => { |
| 155 | 155 | if (!integrationData) return null; |
| 156 | + if (namespace === "apps") return integrationPlugins.find((p) => p.key === "apps") ?? null; | |
| 156 | 157 | return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null; |
| 157 | − }, [integrationData, integrationPlugins]); | |
| 158 | + }, [integrationData, integrationPlugins, namespace]); | |
| 158 | 159 | |
| 159 | 160 | // Policies are pre-sorted by the server in evaluation order (owner rank, then |
| 160 | 161 | // position ASC). The matcher walks the list and stops at the first hit per |