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 @@
1−import { HttpEffect, HttpRouter } from "effect/unstable/http";
1+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
22 import { Data, Effect, Layer } from "effect";
33
44 import {
@@ -19,12 +19,6 @@ class AppsSyncRouteError extends Data.TaggedError("AppsSyncRouteError")<{ · 14 unmodified lines
1919 readonly message: string;
2020 }> {}
2121
22−const jsonResponse = (body: unknown, status = 200): Response =>
23− new Response(JSON.stringify(body), {
24− status,
25− headers: { "content-type": "application/json" },
26− });
27−
2822 const parseJson = (request: Request): Effect.Effect<unknown, AppsSyncRouteError> =>
2923 Effect.tryPromise({
3024 try: () => request.json() as Promise<unknown>,
@@ -51,41 +45,76 @@ export interface SelfHostAppsSyncRouteDeps { · 20 unmodified lines
5145 readonly db: SelfHostDbHandle;
5246 }
5347
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+
5485 export const makeSelfHostAppsSyncRoute = ({ identity, db }: SelfHostAppsSyncRouteDeps) => {
5586 const services = Layer.merge(
5687 SelfHostScopedExecutorSeams.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
5788 identity,
5889 );
59− return HttpRouter.add(
60− "POST",
61− "/api/apps/sources/github/sync",
62− HttpEffect.fromWebHandler(
63− (request): Promise<Response> =>
64− Effect.runPromise(
65− Effect.gen(function* () {
66− const identityProvider = yield* IdentityProvider;
67− const principal = yield* identityProvider.authenticate(request);
68− const payload = yield* parseJson(request);
69− const executor = yield* buildExecutor(principal, request);
70− const result = yield* executor.execute(
71− ToolAddress.make("executor.apps.sync_github_source"),
72− payload,
73− );
74− return jsonResponse(result);
75− }).pipe(
76− Effect.catchTags({
77− Unauthorized: () => Effect.succeed(new Response("Unauthorized", { status: 401 })),
78− NoOrganization: () => Effect.succeed(new Response("Forbidden", { status: 403 })),
79− Unavailable: () => Effect.succeed(new Response("Unavailable", { status: 503 })),
80− AppsSyncRouteError: (error) =>
81− Effect.succeed(jsonResponse({ error: error.message }, error.status)),
82− }),
83− Effect.catchCause(() =>
84− Effect.succeed(new Response("Internal Server Error", { status: 500 })),
85− ),
86− Effect.provide(services),
87− ),
88− ),
90+ return Layer.mergeAll(
91+ HttpRouter.add(
92+ "GET",
93+ "/api/apps/sources/github",
94+ routeHandler((request) =>
95+ Effect.gen(function* () {
96+ const identityProvider = yield* IdentityProvider;
97+ const principal = yield* identityProvider.authenticate(request);
98+ const executor = yield* buildExecutor(principal, request);
99+ return yield* executor.execute(ToolAddress.make("executor.apps.list_github_sources"), {});
100+ }),
101+ ),
89102 ),
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));
91120 };
apps/host-selfhost/src/custom-tools-console.web.test.ts+137−0
@@ -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+});
apps/host-selfhost/tsconfig.json+1−0
@@ -6,6 +6,7 @@ · 5 unmodified lines
66 "strict": true,
77 "esModuleInterop": true,
88 "skipLibCheck": true,
9+ "jsx": "react-jsx",
910 "types": ["bun-types"],
1011 "noUnusedLocals": true,
1112 "noImplicitOverride": true,
bun.lock+13−1
@@ -795,16 +795,28 @@ · 794 unmodified lines
795795 "zod": "4.3.6",
796796 },
797797 "devDependencies": {
798+ "@effect/atom-react": "catalog:",
798799 "@effect/vitest": "catalog:",
800+ "@executor-js/react": "workspace:*",
799801 "@types/node": "catalog:",
802+ "@types/react": "catalog:",
800803 "bun-types": "catalog:",
801804 "effect": "catalog:",
805+ "react": "catalog:",
802806 "tsup": "catalog:",
803807 "vitest": "catalog:",
804808 },
805809 "peerDependencies": {
810+ "@effect/atom-react": "catalog:",
811+ "@executor-js/react": "workspace:*",
806812 "effect": "catalog:",
813+ "react": "catalog:",
807814 },
815+ "optionalPeers": [
816+ "@effect/atom-react",
817+ "@executor-js/react",
818+ "react",
819+ ],
808820 },
809821 "packages/plugins/desktop-settings": {
810822 "name": "@executor-js/plugin-desktop-settings",
@@ -1240,9 +1252,9 @@ · 429 unmodified lines
12401252 },
12411253 },
12421254 "patchedDependencies": {
1243− "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch",
12441255 "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch",
12451256 "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",
12461258 "agents@0.17.3": "patches/agents@0.17.3.patch",
12471259 "postgres@3.4.9": "patches/postgres@3.4.9.patch",
12481260 },
packages/plugins/apps/package.json+20−1
@@ -18,6 +18,7 @@ · 17 unmodified lines
1818 "exports": {
1919 ".": "./src/index.ts",
2020 "./api": "./src/api.ts",
21+ "./client": "./src/react/plugin-client.tsx",
2122 "./seams": "./src/seams/index.ts",
2223 "./testing": "./src/testing/index.ts"
2324 },
@@ -37,14 +38,32 @@ · 13 unmodified lines
3738 "zod": "4.3.6"
3839 },
3940 "devDependencies": {
41+ "@effect/atom-react": "catalog:",
4042 "@effect/vitest": "catalog:",
43+ "@executor-js/react": "workspace:*",
4144 "@types/node": "catalog:",
45+ "@types/react": "catalog:",
4246 "bun-types": "catalog:",
4347 "effect": "catalog:",
48+ "react": "catalog:",
4449 "tsup": "catalog:",
4550 "vitest": "catalog:"
4651 },
4752 "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+ }
4968 }
5069 }
packages/plugins/apps/src/api.ts+1−1
@@ -13,7 +13,7 @@ export { · 12 unmodified lines
1313 type SelfHostAppsRuntime,
1414 type SelfHostAppsRuntimeOptions,
1515 } from "./plugin/self-host-runtime";
16−export { type AppsRuntime } from "./plugin/runtime";
16+export { type AppsRuntime, type GitHubCustomToolsSourceSummary } from "./plugin/runtime";
1717 export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";
1818 export {
1919 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
7777 },
7878 catch: (cause) => storageFail("getDescriptor failed", cause),
7979 }),
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+ }),
8095 putScopeForConnection: (tenant, connectionName, scope) =>
8196 Effect.tryPromise({
8297 try: async () => {
packages/plugins/apps/src/pipeline/descriptor.ts+7−0
@@ -31,11 +31,18 @@ export interface IntegrationDecl { · 30 unmodified lines
3131 readonly integration: string;
3232 }
3333
34+export interface SourceSkippedArtifact {
35+ readonly path: string;
36+ readonly reason: "not supported yet" | "unsupported file type" | "ignored";
37+}
38+
3439 export interface GitHubSourceRef {
3540 readonly kind: "github";
3641 readonly repo: string;
3742 readonly ref: string;
3843 readonly upstreamSha: string;
44+ readonly connection?: string;
45+ readonly skipped?: readonly SourceSkippedArtifact[];
3946 }
4047
4148 export 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
269269 scope,
270270 repo,
271271 ref,
272+ connection,
272273 token,
273274 baseUrl: configBaseUrl(integration?.config),
274275 });
@@ -279,6 +280,16 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 4 unmodified lines
279280 return result;
280281 }),
281282 }),
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+ }),
282293 ],
283294 },
284295 ],
packages/plugins/apps/src/plugin/runtime.ts+48−0
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
2525 } from "./bindings";
2626 import type { AppsStore } from "./store";
2727 import { scopeAddress } from "../seams/scope-address";
28+import type { GitHubSkippedArtifact } from "../source/github-source";
2829
2930 // ---------------------------------------------------------------------------
3031 // AppsRuntime: the substrate-neutral core for published custom tools.
@@ -40,6 +41,19 @@ export interface AppsRuntimeDeps { · 9 unmodified lines
4041 readonly defaultTenant?: string;
4142 }
4243
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+
4357 export interface AppsRuntime {
4458 readonly publish: (input: {
4559 readonly tenant?: string;
@@ -53,6 +67,9 @@ export interface AppsRuntime { · 7 unmodified lines
5367 tenantOrScope: string,
5468 scope?: string,
5569 ) => Effect.Effect<AppDescriptor | null>;
70+ readonly listGitHubSources: (
71+ tenant?: string,
72+ ) => Effect.Effect<readonly GitHubCustomToolsSourceSummary[]>;
5673 /** Re-derive the published-descriptor pointer from the latest committed
5774 * snapshot. */
5875 readonly repair: (
@@ -345,6 +362,37 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 286 unmodified lines
345362 );
346363 },
347364
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+
348396 repair: (tenantOrScope, maybeScope) => {
349397 const { tenant, scope } = resolveTenantScope(tenantOrScope, maybeScope);
350398 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
3030 },
3131 });
3232
33+export interface AppDescriptorRecord {
34+ readonly descriptor: AppDescriptor;
35+ readonly publishedAt: number;
36+}
37+
3338 export interface AppsStore {
3439 readonly putDescriptor: (
3540 tenant: string,
@@ -40,6 +45,9 @@ export interface AppsStore { · 4 unmodified lines
4045 tenant: string,
4146 scope: string,
4247 ) => Effect.Effect<AppDescriptor | null, StorageFailure>;
48+ readonly listDescriptors: (
49+ tenant: string,
50+ ) => Effect.Effect<readonly AppDescriptorRecord[], StorageFailure>;
4351 readonly putScopeForConnection: (
4452 tenant: string,
4553 connectionName: string,
@@ -92,5 +100,14 @@ export const makeAppsStore = (deps: AppsStoreDeps): AppsStore => { · 46 unmodified lines
92100 descriptors
93101 .get({ key: keyFor(tenant, scope) })
94102 .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+ ),
95112 };
96113 };
packages/plugins/apps/src/react/AddCustomToolsSource.tsx+163−0
@@ -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+}
packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx+344−0
@@ -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+}
packages/plugins/apps/src/react/custom-tools-client.ts+113−0
@@ -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+};
packages/plugins/apps/src/react/plugin-client.tsx+11−0
@@ -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+});
packages/plugins/apps/src/react/source-plugin.ts+18−0
@@ -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+};
packages/plugins/apps/src/source/github-source.test.ts+4−0
@@ -159,6 +159,10 @@ describe("GitHub custom-tools source", () => { · 158 unmodified lines
159159 repo: "acme/tools",
160160 ref: "main",
161161 upstreamSha: "commit-a",
162+ skipped: [
163+ { path: "workflows/deferred.ts", reason: "not supported yet" },
164+ { path: "docs/readme.md", reason: "ignored" },
165+ ],
162166 });
163167 await host.close();
164168 });
packages/plugins/apps/src/source/github-source.ts+7−7
@@ -2,7 +2,7 @@ import { Data, Effect, Result } from "effect"; · 1 unmodified line
22
33 import { PublishError, type FileDiagnostic } from "../pipeline/discover";
44 import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish";
5−import type { AppSourceRef } from "../pipeline/descriptor";
5+import type { AppSourceRef, SourceSkippedArtifact } from "../pipeline/descriptor";
66 import type { AppsRuntime } from "../plugin/runtime";
77 import type { FileSet, SnapshotId } from "../seams/artifact-store";
88
@@ -36,10 +36,7 @@ export interface SyncErrorData { · 27 unmodified lines
3636 readonly diagnostics?: readonly FileDiagnostic[];
3737 }
3838
39−export interface GitHubSkippedArtifact {
40− readonly path: string;
41− readonly reason: "not supported yet" | "unsupported file type" | "ignored";
42−}
39+export type GitHubSkippedArtifact = SourceSkippedArtifact;
4340
4441 export type GitHubSyncResult =
4542 | {
@@ -69,6 +66,7 @@ export interface SyncGitHubSourceInput extends GitHubSourceInput { · 23 unmodified lines
6966 readonly runtime: AppsRuntime;
7067 readonly tenant?: string;
7168 readonly scope: string;
69+ readonly connection?: string;
7270 }
7371
7472 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
374372 diagnostics: error.path ? [{ path: error.path, message: error.message }] : [],
375373 });
376374
377−const sourceRef = (snapshot: GitHubSourceSnapshot): AppSourceRef => ({
375+const sourceRef = (snapshot: GitHubSourceSnapshot, connection?: string): AppSourceRef => ({
378376 kind: "github",
379377 repo: snapshot.repo,
380378 ref: snapshot.ref,
381379 upstreamSha: snapshot.upstreamSha,
380+ ...(connection ? { connection } : {}),
381+ skipped: snapshot.skipped,
382382 });
383383
384384 export const syncGitHubSource = (input: SyncGitHubSourceInput): Effect.Effect<GitHubSyncResult> =>
@@ -420,7 +420,7 @@ export const syncGitHubSource = (input: SyncGitHubSourceInput): Effect.Effect<Gi · 35 unmodified lines
420420 scope: input.scope,
421421 files: snapshot.files,
422422 description: snapshot.description,
423− source: sourceRef(snapshot),
423+ source: sourceRef(snapshot, input.connection),
424424 message: `sync ${snapshot.repo}@${snapshot.upstreamSha}`,
425425 })
426426 .pipe(Effect.result);
packages/plugins/apps/src/testing/index.ts+15−1
@@ -19,14 +19,28 @@ export const makeInMemoryAppsStore = (): AppsStore & { · 18 unmodified lines
1919 readonly descriptors: Map<string, AppDescriptor>;
2020 } => {
2121 const descriptors = new Map<string, AppDescriptor>();
22+ const publishedAt = new Map<string, number>();
2223 const scopeConnections = new Map<string, string>();
2324 const keyFor = (tenant: string, key: string): string => `${tenant}:${key}`;
2425 return {
2526 descriptors,
2627 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+ }),
2833 getDescriptor: (tenant, scope) =>
2934 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+ ),
3044 putScopeForConnection: (tenant, connectionName, scope) =>
3145 Effect.sync(() => void scopeConnections.set(keyFor(tenant, connectionName), scope)),
3246 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
153153 // Find the plugin edit component based on integration kind
154154 const editPlugin = useMemo(() => {
155155 if (!integrationData) return null;
156+ if (namespace === "apps") return integrationPlugins.find((p) => p.key === "apps") ?? null;
156157 return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null;
157− }, [integrationData, integrationPlugins]);
158+ }, [integrationData, integrationPlugins, namespace]);
158159
159160 // Policies are pre-sorted by the server in evaluation order (owner rank, then
160161 // position ASC). The matcher walks the list and stops at the first hit per