Unified · Split
| @@ -1,4 +1,4 @@ | |||
| 1 | import { HttpEffect, HttpRouter } from "effect/unstable/http"; | 1 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 2 | import { Data, Effect, Layer } from "effect"; | 2 | import { Data, Effect, Layer } from "effect"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| @@ -19,12 +19,6 @@ class AppsSyncRouteError extends Data.TaggedError("AppsSyncRouteError")<{ · 14 unmodified lines | |||
| 19 | readonly message: string; | 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 | const parseJson = (request: Request): Effect.Effect<unknown, AppsSyncRouteError> => | 22 | const parseJson = (request: Request): Effect.Effect<unknown, AppsSyncRouteError> => |
| 29 | Effect.tryPromise({ | 23 | Effect.tryPromise({ |
| 30 | try: () => request.json() as Promise<unknown>, | 24 | try: () => request.json() as Promise<unknown>, |
| @@ -51,41 +45,76 @@ export interface SelfHostAppsSyncRouteDeps { · 20 unmodified lines | |||
| 51 | readonly db: SelfHostDbHandle; | 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 | export const makeSelfHostAppsSyncRoute = ({ identity, db }: SelfHostAppsSyncRouteDeps) => { | 85 | export const makeSelfHostAppsSyncRoute = ({ identity, db }: SelfHostAppsSyncRouteDeps) => { |
| 55 | const services = Layer.merge( | 86 | const services = Layer.merge( |
| 56 | SelfHostScopedExecutorSeams.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), | 87 | SelfHostScopedExecutorSeams.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), |
| 57 | identity, | 88 | identity, |
| 58 | ); | 89 | ); |
| 59 | return HttpRouter.add( | 90 | return Layer.mergeAll( |
| 60 | "POST", | 91 | HttpRouter.add( |
| 61 | "/api/apps/sources/github/sync", | 92 | "GET", |
| 62 | HttpEffect.fromWebHandler( | 93 | "/api/apps/sources/github", |
| 63 | (request): Promise<Response> => | 94 | routeHandler((request) => |
| 64 | Effect.runPromise( | 95 | Effect.gen(function* () { |
| 65 | Effect.gen(function* () { | 96 | const identityProvider = yield* IdentityProvider; |
| 66 | const identityProvider = yield* IdentityProvider; | 97 | const principal = yield* identityProvider.authenticate(request); |
| 67 | const principal = yield* identityProvider.authenticate(request); | 98 | const executor = yield* buildExecutor(principal, request); |
| 68 | const payload = yield* parseJson(request); | 99 | return yield* executor.execute(ToolAddress.make("executor.apps.list_github_sources"), {}); |
| 69 | const executor = yield* buildExecutor(principal, request); | 100 | }), |
| 70 | const result = yield* executor.execute( | 101 | ), |
| 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 | ), | ||
| 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 | "strict": true, | 6 | "strict": true, |
| 7 | "esModuleInterop": true, | 7 | "esModuleInterop": true, |
| 8 | "skipLibCheck": true, | 8 | "skipLibCheck": true, |
| 9 | "jsx": "react-jsx", | ||
| 9 | "types": ["bun-types"], | 10 | "types": ["bun-types"], |
| 10 | "noUnusedLocals": true, | 11 | "noUnusedLocals": true, |
| 11 | "noImplicitOverride": true, | 12 | "noImplicitOverride": true, |
| @@ -795,16 +795,28 @@ · 794 unmodified lines | |||
| 795 | "zod": "4.3.6", | 795 | "zod": "4.3.6", |
| 796 | }, | 796 | }, |
| 797 | "devDependencies": { | 797 | "devDependencies": { |
| 798 | "@effect/atom-react": "catalog:", | ||
| 798 | "@effect/vitest": "catalog:", | 799 | "@effect/vitest": "catalog:", |
| 800 | "@executor-js/react": "workspace:*", | ||
| 799 | "@types/node": "catalog:", | 801 | "@types/node": "catalog:", |
| 802 | "@types/react": "catalog:", | ||
| 800 | "bun-types": "catalog:", | 803 | "bun-types": "catalog:", |
| 801 | "effect": "catalog:", | 804 | "effect": "catalog:", |
| 805 | "react": "catalog:", | ||
| 802 | "tsup": "catalog:", | 806 | "tsup": "catalog:", |
| 803 | "vitest": "catalog:", | 807 | "vitest": "catalog:", |
| 804 | }, | 808 | }, |
| 805 | "peerDependencies": { | 809 | "peerDependencies": { |
| 810 | "@effect/atom-react": "catalog:", | ||
| 811 | "@executor-js/react": "workspace:*", | ||
| 806 | "effect": "catalog:", | 812 | "effect": "catalog:", |
| 813 | "react": "catalog:", | ||
| 807 | }, | 814 | }, |
| 815 | "optionalPeers": [ | ||
| 816 | "@effect/atom-react", | ||
| 817 | "@executor-js/react", | ||
| 818 | "react", | ||
| 819 | ], | ||
| 808 | }, | 820 | }, |
| 809 | "packages/plugins/desktop-settings": { | 821 | "packages/plugins/desktop-settings": { |
| 810 | "name": "@executor-js/plugin-desktop-settings", | 822 | "name": "@executor-js/plugin-desktop-settings", |
| @@ -1240,9 +1252,9 @@ · 429 unmodified lines | |||
| 1240 | }, | 1252 | }, |
| 1241 | }, | 1253 | }, |
| 1242 | "patchedDependencies": { | 1254 | "patchedDependencies": { |
| 1243 | "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", | ||
| 1244 | "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch", | 1255 | "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch", |
| 1245 | "libsql@0.5.29": "patches/libsql@0.5.29.patch", | 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 | "agents@0.17.3": "patches/agents@0.17.3.patch", | 1258 | "agents@0.17.3": "patches/agents@0.17.3.patch", |
| 1247 | "postgres@3.4.9": "patches/postgres@3.4.9.patch", | 1259 | "postgres@3.4.9": "patches/postgres@3.4.9.patch", |
| 1248 | }, | 1260 | }, |
| @@ -18,6 +18,7 @@ · 17 unmodified lines | |||
| 18 | "exports": { | 18 | "exports": { |
| 19 | ".": "./src/index.ts", | 19 | ".": "./src/index.ts", |
| 20 | "./api": "./src/api.ts", | 20 | "./api": "./src/api.ts", |
| 21 | "./client": "./src/react/plugin-client.tsx", | ||
| 21 | "./seams": "./src/seams/index.ts", | 22 | "./seams": "./src/seams/index.ts", |
| 22 | "./testing": "./src/testing/index.ts" | 23 | "./testing": "./src/testing/index.ts" |
| 23 | }, | 24 | }, |
| @@ -37,14 +38,32 @@ · 13 unmodified lines | |||
| 37 | "zod": "4.3.6" | 38 | "zod": "4.3.6" |
| 38 | }, | 39 | }, |
| 39 | "devDependencies": { | 40 | "devDependencies": { |
| 41 | "@effect/atom-react": "catalog:", | ||
| 40 | "@effect/vitest": "catalog:", | 42 | "@effect/vitest": "catalog:", |
| 43 | "@executor-js/react": "workspace:*", | ||
| 41 | "@types/node": "catalog:", | 44 | "@types/node": "catalog:", |
| 45 | "@types/react": "catalog:", | ||
| 42 | "bun-types": "catalog:", | 46 | "bun-types": "catalog:", |
| 43 | "effect": "catalog:", | 47 | "effect": "catalog:", |
| 48 | "react": "catalog:", | ||
| 44 | "tsup": "catalog:", | 49 | "tsup": "catalog:", |
| 45 | "vitest": "catalog:" | 50 | "vitest": "catalog:" |
| 46 | }, | 51 | }, |
| 47 | "peerDependencies": { | 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 | type SelfHostAppsRuntime, | 13 | type SelfHostAppsRuntime, |
| 14 | type SelfHostAppsRuntimeOptions, | 14 | type SelfHostAppsRuntimeOptions, |
| 15 | } from "./plugin/self-host-runtime"; | 15 | } from "./plugin/self-host-runtime"; |
| 16 | export { type AppsRuntime } from "./plugin/runtime"; | 16 | export { type AppsRuntime, type GitHubCustomToolsSourceSummary } from "./plugin/runtime"; |
| 17 | export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; | 17 | export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; |
| 18 | export { | 18 | export { |
| 19 | BindingError, | 19 | BindingError, |
| @@ -77,6 +77,21 @@ export const makeSqliteAppsStore = (options: SqliteAppsStoreOptions): AppsStore · 76 unmodified lines | |||
| 77 | }, | 77 | }, |
| 78 | catch: (cause) => storageFail("getDescriptor failed", cause), | 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 | putScopeForConnection: (tenant, connectionName, scope) => | 95 | putScopeForConnection: (tenant, connectionName, scope) => |
| 81 | Effect.tryPromise({ | 96 | Effect.tryPromise({ |
| 82 | try: async () => { | 97 | try: async () => { |
| @@ -31,11 +31,18 @@ export interface IntegrationDecl { · 30 unmodified lines | |||
| 31 | readonly integration: string; | 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 | export interface GitHubSourceRef { | 39 | export interface GitHubSourceRef { |
| 35 | readonly kind: "github"; | 40 | readonly kind: "github"; |
| 36 | readonly repo: string; | 41 | readonly repo: string; |
| 37 | readonly ref: string; | 42 | readonly ref: string; |
| 38 | readonly upstreamSha: string; | 43 | readonly upstreamSha: string; |
| 44 | readonly connection?: string; | ||
| 45 | readonly skipped?: readonly SourceSkippedArtifact[]; | ||
| 39 | } | 46 | } |
| 40 | 47 | ||
| 41 | export type AppSourceRef = GitHubSourceRef; | 48 | export type AppSourceRef = GitHubSourceRef; |
| @@ -269,6 +269,7 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 268 unmodified lines | |||
| 269 | scope, | 269 | scope, |
| 270 | repo, | 270 | repo, |
| 271 | ref, | 271 | ref, |
| 272 | connection, | ||
| 272 | token, | 273 | token, |
| 273 | baseUrl: configBaseUrl(integration?.config), | 274 | baseUrl: configBaseUrl(integration?.config), |
| 274 | }); | 275 | }); |
| @@ -279,6 +280,16 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 4 unmodified lines | |||
| 279 | return result; | 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 | } from "./bindings"; | 25 | } from "./bindings"; |
| 26 | import type { AppsStore } from "./store"; | 26 | import type { AppsStore } from "./store"; |
| 27 | import { scopeAddress } from "../seams/scope-address"; | 27 | import { scopeAddress } from "../seams/scope-address"; |
| 28 | import type { GitHubSkippedArtifact } from "../source/github-source"; | ||
| 28 | 29 | ||
| 29 | // --------------------------------------------------------------------------- | 30 | // --------------------------------------------------------------------------- |
| 30 | // AppsRuntime: the substrate-neutral core for published custom tools. | 31 | // AppsRuntime: the substrate-neutral core for published custom tools. |
| @@ -40,6 +41,19 @@ export interface AppsRuntimeDeps { · 9 unmodified lines | |||
| 40 | readonly defaultTenant?: string; | 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 | export interface AppsRuntime { | 57 | export interface AppsRuntime { |
| 44 | readonly publish: (input: { | 58 | readonly publish: (input: { |
| 45 | readonly tenant?: string; | 59 | readonly tenant?: string; |
| @@ -53,6 +67,9 @@ export interface AppsRuntime { · 7 unmodified lines | |||
| 53 | tenantOrScope: string, | 67 | tenantOrScope: string, |
| 54 | scope?: string, | 68 | scope?: string, |
| 55 | ) => Effect.Effect<AppDescriptor | null>; | 69 | ) => Effect.Effect<AppDescriptor | null>; |
| 70 | readonly listGitHubSources: ( | ||
| 71 | tenant?: string, | ||
| 72 | ) => Effect.Effect<readonly GitHubCustomToolsSourceSummary[]>; | ||
| 56 | /** Re-derive the published-descriptor pointer from the latest committed | 73 | /** Re-derive the published-descriptor pointer from the latest committed |
| 57 | * snapshot. */ | 74 | * snapshot. */ |
| 58 | readonly repair: ( | 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 | repair: (tenantOrScope, maybeScope) => { | 396 | repair: (tenantOrScope, maybeScope) => { |
| 349 | const { tenant, scope } = resolveTenantScope(tenantOrScope, maybeScope); | 397 | const { tenant, scope } = resolveTenantScope(tenantOrScope, maybeScope); |
| 350 | return repairScope(tenant, scope); | 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 | export interface AppsStore { | 38 | export interface AppsStore { |
| 34 | readonly putDescriptor: ( | 39 | readonly putDescriptor: ( |
| 35 | tenant: string, | 40 | tenant: string, |
| @@ -40,6 +45,9 @@ export interface AppsStore { · 4 unmodified lines | |||
| 40 | tenant: string, | 45 | tenant: string, |
| 41 | scope: string, | 46 | scope: string, |
| 42 | ) => Effect.Effect<AppDescriptor | null, StorageFailure>; | 47 | ) => Effect.Effect<AppDescriptor | null, StorageFailure>; |
| 48 | readonly listDescriptors: ( | ||
| 49 | tenant: string, | ||
| 50 | ) => Effect.Effect<readonly AppDescriptorRecord[], StorageFailure>; | ||
| 43 | readonly putScopeForConnection: ( | 51 | readonly putScopeForConnection: ( |
| 44 | tenant: string, | 52 | tenant: string, |
| 45 | connectionName: string, | 53 | connectionName: string, |
| @@ -92,5 +100,14 @@ export const makeAppsStore = (deps: AppsStoreDeps): AppsStore => { · 46 unmodified lines | |||
| 92 | descriptors | 100 | descriptors |
| 93 | .get({ key: keyFor(tenant, scope) }) | 101 | .get({ key: keyFor(tenant, scope) }) |
| 94 | .pipe(Effect.map((entry) => entry?.data.descriptor ?? null)), | 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 | repo: "acme/tools", | 159 | repo: "acme/tools", |
| 160 | ref: "main", | 160 | ref: "main", |
| 161 | upstreamSha: "commit-a", | 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 | await host.close(); | 167 | await host.close(); |
| 164 | }); | 168 | }); |
| @@ -2,7 +2,7 @@ import { Data, Effect, Result } from "effect"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { PublishError, type FileDiagnostic } from "../pipeline/discover"; | 3 | import { PublishError, type FileDiagnostic } from "../pipeline/discover"; |
| 4 | import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish"; | 4 | import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish"; |
| 5 | import type { AppSourceRef } from "../pipeline/descriptor"; | 5 | import type { AppSourceRef, SourceSkippedArtifact } from "../pipeline/descriptor"; |
| 6 | import type { AppsRuntime } from "../plugin/runtime"; | 6 | import type { AppsRuntime } from "../plugin/runtime"; |
| 7 | import type { FileSet, SnapshotId } from "../seams/artifact-store"; | 7 | import type { FileSet, SnapshotId } from "../seams/artifact-store"; |
| 8 | 8 | ||
| @@ -36,10 +36,7 @@ export interface SyncErrorData { · 27 unmodified lines | |||
| 36 | readonly diagnostics?: readonly FileDiagnostic[]; | 36 | readonly diagnostics?: readonly FileDiagnostic[]; |
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | export interface GitHubSkippedArtifact { | 39 | export type GitHubSkippedArtifact = SourceSkippedArtifact; |
| 40 | readonly path: string; | ||
| 41 | readonly reason: "not supported yet" | "unsupported file type" | "ignored"; | ||
| 42 | } | ||
| 43 | 40 | ||
| 44 | export type GitHubSyncResult = | 41 | export type GitHubSyncResult = |
| 45 | | { | 42 | | { |
| @@ -69,6 +66,7 @@ export interface SyncGitHubSourceInput extends GitHubSourceInput { · 23 unmodified lines | |||
| 69 | readonly runtime: AppsRuntime; | 66 | readonly runtime: AppsRuntime; |
| 70 | readonly tenant?: string; | 67 | readonly tenant?: string; |
| 71 | readonly scope: string; | 68 | readonly scope: string; |
| 69 | readonly connection?: string; | ||
| 72 | } | 70 | } |
| 73 | 71 | ||
| 74 | const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/; | 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 | diagnostics: error.path ? [{ path: error.path, message: error.message }] : [], | 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 | kind: "github", | 376 | kind: "github", |
| 379 | repo: snapshot.repo, | 377 | repo: snapshot.repo, |
| 380 | ref: snapshot.ref, | 378 | ref: snapshot.ref, |
| 381 | upstreamSha: snapshot.upstreamSha, | 379 | upstreamSha: snapshot.upstreamSha, |
| 380 | ...(connection ? { connection } : {}), | ||
| 381 | skipped: snapshot.skipped, | ||
| 382 | }); | 382 | }); |
| 383 | 383 | ||
| 384 | export const syncGitHubSource = (input: SyncGitHubSourceInput): Effect.Effect<GitHubSyncResult> => | 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 | scope: input.scope, | 420 | scope: input.scope, |
| 421 | files: snapshot.files, | 421 | files: snapshot.files, |
| 422 | description: snapshot.description, | 422 | description: snapshot.description, |
| 423 | source: sourceRef(snapshot), | 423 | source: sourceRef(snapshot, input.connection), |
| 424 | message: `sync ${snapshot.repo}@${snapshot.upstreamSha}`, | 424 | message: `sync ${snapshot.repo}@${snapshot.upstreamSha}`, |
| 425 | }) | 425 | }) |
| 426 | .pipe(Effect.result); | 426 | .pipe(Effect.result); |
| @@ -19,14 +19,28 @@ export const makeInMemoryAppsStore = (): AppsStore & { · 18 unmodified lines | |||
| 19 | readonly descriptors: Map<string, AppDescriptor>; | 19 | readonly descriptors: Map<string, AppDescriptor>; |
| 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 scopeConnections = new Map<string, string>(); | 23 | const scopeConnections = new Map<string, string>(); |
| 23 | const keyFor = (tenant: string, key: string): string => `${tenant}:${key}`; | 24 | const keyFor = (tenant: string, key: string): string => `${tenant}:${key}`; |
| 24 | return { | 25 | return { |
| 25 | descriptors, | 26 | descriptors, |
| 26 | putDescriptor: (tenant, _owner, descriptor) => | 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 | getDescriptor: (tenant, scope) => | 33 | getDescriptor: (tenant, scope) => |
| 29 | Effect.sync(() => descriptors.get(keyFor(tenant, scope)) ?? null), | 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 | putScopeForConnection: (tenant, connectionName, scope) => | 44 | putScopeForConnection: (tenant, connectionName, scope) => |
| 31 | Effect.sync(() => void scopeConnections.set(keyFor(tenant, connectionName), scope)), | 45 | Effect.sync(() => void scopeConnections.set(keyFor(tenant, connectionName), scope)), |
| 32 | getScopeForConnection: (tenant, connectionName) => | 46 | getScopeForConnection: (tenant, connectionName) => |
| @@ -153,8 +153,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 152 unmodified lines | |||
| 153 | // Find the plugin edit component based on integration kind | 153 | // Find the plugin edit component based on integration kind |
| 154 | const editPlugin = useMemo(() => { | 154 | const editPlugin = useMemo(() => { |
| 155 | if (!integrationData) return null; | 155 | if (!integrationData) return null; |
| 156 | if (namespace === "apps") return integrationPlugins.find((p) => p.key === "apps") ?? null; | ||
| 156 | return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null; | 157 | return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null; |
| 157 | }, [integrationData, integrationPlugins]); | 158 | }, [integrationData, integrationPlugins, namespace]); |
| 158 | 159 | ||
| 159 | // Policies are pre-sorted by the server in evaluation order (owner rank, then | 160 | // Policies are pre-sorted by the server in evaluation order (owner rank, then |
| 160 | // position ASC). The matcher walks the list and stops at the first hit per | 161 | // position ASC). The matcher walks the list and stops at the first hit per |