History

Add self-host custom tools source UI

Rhys Sullivan committed 4 months ago

Commit 2a35fef1 parent 9e77e3420 files · +1014 −50Browse files

Unified · Split

20 files
  1. apps/host-selfhost/src/apps-route.ts +67 −38
  2. apps/host-selfhost/src/custom-tools-console.web.test.ts +137 −0
  3. apps/host-selfhost/tsconfig.json +1 −0
  4. bun.lock +13 −1
  5. packages/plugins/apps/package.json +20 −1
  6. packages/plugins/apps/src/api.ts +1 −1
  7. packages/plugins/apps/src/backing/sqlite-apps-store.ts +15 −0
  8. packages/plugins/apps/src/pipeline/descriptor.ts +7 −0
  9. packages/plugins/apps/src/plugin/apps-plugin.ts +11 −0
  10. packages/plugins/apps/src/plugin/runtime.ts +48 −0
  11. packages/plugins/apps/src/plugin/store.ts +17 −0
  12. packages/plugins/apps/src/react/AddCustomToolsSource.tsx +163 −0
  13. packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx +344 −0
  14. packages/plugins/apps/src/react/custom-tools-client.ts +113 −0
  15. packages/plugins/apps/src/react/plugin-client.tsx +11 −0
  16. packages/plugins/apps/src/react/source-plugin.ts +18 −0
  17. packages/plugins/apps/src/source/github-source.test.ts +4 −0
  18. packages/plugins/apps/src/source/github-source.ts +7 −7
  19. packages/plugins/apps/src/testing/index.ts +15 −1
  20. packages/react/src/pages/integration-detail.tsx +2 −1
apps/host-selfhost/src/apps-route.ts+67−38
@@ -1,4 +1,4 @@
1import { HttpEffect, HttpRouter } from "effect/unstable/http";1import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
2import { Data, Effect, Layer } from "effect";2import { Data, Effect, Layer } from "effect";
3 3
4import {4import {
@@ -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
22const jsonResponse = (body: unknown, status = 200): Response =>
23 new Response(JSON.stringify(body), {
24 status,
25 headers: { "content-type": "application/json" },
26 });
27
28const parseJson = (request: Request): Effect.Effect<unknown, AppsSyncRouteError> =>22const 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
48const 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
54export const makeSelfHostAppsSyncRoute = ({ identity, db }: SelfHostAppsSyncRouteDeps) => {85export 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};
apps/host-selfhost/src/custom-tools-console.web.test.ts+137−0
@@ -0,0 +1,137 @@
1import { describe, expect, it, vi } from "@effect/vitest";
2
3import 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
14const 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
21describe("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});
apps/host-selfhost/tsconfig.json+1−0
@@ -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,
bun.lock+13−1
@@ -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 },
packages/plugins/apps/package.json+20−1
@@ -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}
packages/plugins/apps/src/api.ts+1−1
@@ -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";
16export { type AppsRuntime } from "./plugin/runtime";16export { type AppsRuntime, type GitHubCustomToolsSourceSummary } from "./plugin/runtime";
17export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";17export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";
18export {18export {
19 BindingError,19 BindingError,
packages/plugins/apps/src/backing/sqlite-apps-store.ts+15−0
@@ -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 () => {
packages/plugins/apps/src/pipeline/descriptor.ts+7−0
@@ -31,11 +31,18 @@ export interface IntegrationDecl { · 30 unmodified lines
31 readonly integration: string;31 readonly integration: string;
32}32}
33 33
34export interface SourceSkippedArtifact {
35 readonly path: string;
36 readonly reason: "not supported yet" | "unsupported file type" | "ignored";
37}
38
34export interface GitHubSourceRef {39export 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
41export type AppSourceRef = GitHubSourceRef;48export type AppSourceRef = GitHubSourceRef;
packages/plugins/apps/src/plugin/apps-plugin.ts+11−0
@@ -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 ],
packages/plugins/apps/src/plugin/runtime.ts+48−0
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
25} from "./bindings";25} from "./bindings";
26import type { AppsStore } from "./store";26import type { AppsStore } from "./store";
27import { scopeAddress } from "../seams/scope-address";27import { scopeAddress } from "../seams/scope-address";
28import 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
44export 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
43export interface AppsRuntime {57export 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 committed73 /** 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);
packages/plugins/apps/src/plugin/store.ts+17−0
@@ -30,6 +30,11 @@ export const scopeConnectionCollection = definePluginStorageCollection("apps_sco · 29 unmodified lines
30 },30 },
31});31});
32 32
33export interface AppDescriptorRecord {
34 readonly descriptor: AppDescriptor;
35 readonly publishedAt: number;
36}
37
33export interface AppsStore {38export 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 descriptors100 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};
packages/plugins/apps/src/react/AddCustomToolsSource.tsx+163−0
@@ -0,0 +1,163 @@
1import { useMemo, useState } from "react";
2import { useAtomValue } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4
5import { connectionsAllAtom } from "@executor-js/react/api/atoms";
6import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
7import { Button } from "@executor-js/react/components/button";
8import {
9 CardStack,
10 CardStackContent,
11 CardStackEntryField,
12} from "@executor-js/react/components/card-stack";
13import { FloatActions } from "@executor-js/react/components/float-actions";
14import { Input } from "@executor-js/react/components/input";
15import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";
16import { toast } from "@executor-js/react/components/sonner";
17import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
18
19import {
20 consoleIntegrationHref,
21 formatSyncErrors,
22 githubConnections,
23 syncCustomToolSource,
24 syncStatusLabel,
25 validateGitHubRepo,
26} from "./custom-tools-client";
27
28export 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}
packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx+344−0
@@ -0,0 +1,344 @@
1import { useEffect, useMemo, useState, type ReactNode } from "react";
2
3import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
4import { Badge } from "@executor-js/react/components/badge";
5import { Button } from "@executor-js/react/components/button";
6import {
7 CardStack,
8 CardStackContent,
9 CardStackEntry,
10 CardStackEntryActions,
11 CardStackEntryContent,
12 CardStackEntryDescription,
13 CardStackEntryTitle,
14} from "@executor-js/react/components/card-stack";
15
16import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api";
17import {
18 consoleIntegrationHref,
19 formatSyncErrors,
20 listCustomToolSources,
21 syncCustomToolSource,
22 syncStatusLabel,
23 toolDiff,
24} from "./custom-tools-client";
25
26type LoadState =
27 | { readonly status: "loading" }
28 | { readonly status: "error"; readonly message: string }
29 | { readonly status: "ready"; readonly sources: readonly GitHubCustomToolsSourceSummary[] };
30
31interface 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
39const formatDate = (value: string): string => {
40 const date = new Date(value);
41 return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
42};
43
44export 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
223function 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
239function 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
324function 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
337function 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}
packages/plugins/apps/src/react/custom-tools-client.ts+113−0
@@ -0,0 +1,113 @@
1import type { Connection } from "@executor-js/sdk/shared";
2
3import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api";
4
5export const CUSTOM_TOOLS_PLUGIN_KEY = "apps";
6export const CUSTOM_TOOLS_LABEL = "Custom tools";
7
8export interface GitHubSourcesListResponse {
9 readonly sources: readonly GitHubCustomToolsSourceSummary[];
10}
11
12export interface SyncGitHubSourceRequest {
13 readonly repo: string;
14 readonly ref?: string;
15 readonly connection: string;
16}
17
18export class CustomToolsClientError extends Error {
19 constructor(message: string) {
20 super(message);
21 this.name = "CustomToolsClientError";
22 }
23}
24
25const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
26
27export 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
34export const githubConnections = (connections: readonly Connection[]): readonly Connection[] =>
35 connections.filter((connection) => String(connection.integration) === "github");
36
37const 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
53export 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
62export 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
79export 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
85export 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
95export 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
107export 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};
packages/plugins/apps/src/react/plugin-client.tsx+11−0
@@ -0,0 +1,11 @@
1import { defineClientPlugin } from "@executor-js/sdk/client";
2
3import { appsIntegrationPlugin } from "./source-plugin";
4
5export { appsIntegrationPlugin } from "./source-plugin";
6export * from "./custom-tools-client";
7
8export default defineClientPlugin({
9 id: "apps",
10 integrationPlugin: appsIntegrationPlugin,
11});
packages/plugins/apps/src/react/source-plugin.ts+18−0
@@ -0,0 +1,18 @@
1import { lazy } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";
3
4import { CUSTOM_TOOLS_LABEL, CUSTOM_TOOLS_PLUGIN_KEY } from "./custom-tools-client";
5
6const importAdd = () => import("./AddCustomToolsSource");
7const importAccounts = () => import("./CustomToolsAccountsPanel");
8
9export 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};
packages/plugins/apps/src/source/github-source.test.ts+4−0
@@ -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 });
packages/plugins/apps/src/source/github-source.ts+7−7
@@ -2,7 +2,7 @@ import { Data, Effect, Result } from "effect"; · 1 unmodified line
2 2
3import { PublishError, type FileDiagnostic } from "../pipeline/discover";3import { PublishError, type FileDiagnostic } from "../pipeline/discover";
4import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish";4import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish";
5import type { AppSourceRef } from "../pipeline/descriptor";5import type { AppSourceRef, SourceSkippedArtifact } from "../pipeline/descriptor";
6import type { AppsRuntime } from "../plugin/runtime";6import type { AppsRuntime } from "../plugin/runtime";
7import type { FileSet, SnapshotId } from "../seams/artifact-store";7import 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
39export interface GitHubSkippedArtifact {39export type GitHubSkippedArtifact = SourceSkippedArtifact;
40 readonly path: string;
41 readonly reason: "not supported yet" | "unsupported file type" | "ignored";
42}
43 40
44export type GitHubSyncResult =41export 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
74const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/;72const 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
377const sourceRef = (snapshot: GitHubSourceSnapshot): AppSourceRef => ({375const 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
384export const syncGitHubSource = (input: SyncGitHubSourceInput): Effect.Effect<GitHubSyncResult> =>384export 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);
packages/plugins/apps/src/testing/index.ts+15−1
@@ -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) =>
packages/react/src/pages/integration-detail.tsx+2−1
@@ -153,8 +153,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 152 unmodified lines
153 // Find the plugin edit component based on integration kind153 // 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, then160 // 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 per161 // position ASC). The matcher walks the list and stops at the first hit per