History

Consolidate custom tools plugin wiring

Rhys Sullivan committed 4 months ago

Commit 3dba8101 parent aeb483318 files · +497 −310Browse files

Showing the changes to apps (8 of 18 files). Show all

Unified · Split

8 files
  1. apps/host-selfhost/executor.config.ts +3 −2
  2. apps/host-selfhost/src/app.ts +2 −4
  3. apps/host-selfhost/src/apps-backings.ts +52 −0
  4. apps/host-selfhost/src/apps-resolver.ts +0 −120
  5. apps/host-selfhost/src/apps-route.ts +0 −166
  6. apps/host-selfhost/src/apps.ts +0 −71
  7. apps/host-selfhost/src/custom-tools-console.web.test.ts +0 −177
  8. apps/host-selfhost/src/testing/test-app.ts +2 −4
apps/host-selfhost/executor.config.ts+3−2
@@ -6,9 +6,10 @@ import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; · 5 unmodified lines
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
8import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";8import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
9import { appsPlugin } from "@executor-js/plugin-apps/api";
9 10
10import { resolveSecretKey } from "./src/config";11import { resolveSecretKey } from "./src/config";
11import { getSelfHostAppsSubsystem } from "./src/apps";12import { getSelfHostAppsBackings } from "./src/apps-backings";
12 13
13// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
14// Single source of truth for the self-hosted app's plugin list.15// Single source of truth for the self-hosted app's plugin list.
@@ -37,7 +38,7 @@ export default defineExecutorConfig({ · 22 unmodified lines
37 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),38 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
38 graphqlHttpPlugin(),39 graphqlHttpPlugin(),
39 toolkitsPlugin({ activeToolkitSlug }),40 toolkitsPlugin({ activeToolkitSlug }),
40 getSelfHostAppsSubsystem().plugin,41 appsPlugin({ backings: getSelfHostAppsBackings() }),
41 // First writable secret provider -> the default for `secrets.set`.42 // First writable secret provider -> the default for `secrets.set`.
42 encryptedSecretsPlugin({ key: resolveSecretKey() }),43 encryptedSecretsPlugin({ key: resolveSecretKey() }),
43 ] as const,44 ] as const,
apps/host-selfhost/src/app.ts+2−4
@@ -22,8 +22,7 @@ import { makeSelfHostMcpSeams } from "./mcp"; · 21 unmodified lines
22import { selfHostPlugins } from "./plugins";22import { selfHostPlugins } from "./plugins";
23import { ErrorCaptureLive } from "./observability";23import { ErrorCaptureLive } from "./observability";
24import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login";24import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login";
25import { closeSelfHostAppsSubsystem } from "./apps";25import { closeSelfHostAppsBackings } from "./apps-backings";
26import { makeSelfHostAppsSyncRoute } from "./apps-route";
27 26
28// ===========================================================================27// ===========================================================================
29// The self-hosted Executor app, as ONE `ExecutorApp.make` call.28// The self-hosted Executor app, as ONE `ExecutorApp.make` call.
@@ -121,7 +120,6 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 91 unmodified lines
121 makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),120 makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
122 // Public system API: /api/health + /api/setup-status (unauthenticated).121 // Public system API: /api/health + /api/setup-status (unauthenticated).
123 makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),122 makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
124 makeSelfHostAppsSyncRoute({ identity: identityLayer, db: dbHandle }),
125 // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths).123 // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths).
126 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),124 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),
127 ],125 ],
@@ -142,7 +140,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 14 unmodified lines
142 toWebHandler,140 toWebHandler,
143 betterAuth,141 betterAuth,
144 closeDb: async () => {142 closeDb: async () => {
145 await closeSelfHostAppsSubsystem();143 await closeSelfHostAppsBackings();
146 await mcp.close();144 await mcp.close();
147 await dbHandle.close();145 await dbHandle.close();
148 },146 },
apps/host-selfhost/src/apps-backings.ts+52−0
@@ -0,0 +1,52 @@
1import { join } from "node:path";
2
3import { Effect } from "effect";
4
5import {
6 makeGitArtifactStore,
7 makeLibsqlScopeDb,
8 makeQuickjsToolSandbox,
9 makeSqliteAppsStore,
10 type AppsBackings,
11} from "@executor-js/plugin-apps/api";
12
13import { resolveDataDir } from "./config";
14
15interface SelfHostAppsBackings {
16 readonly backings: AppsBackings;
17 readonly close: () => Promise<void>;
18}
19
20const createBackings = (dataDir: string): SelfHostAppsBackings => {
21 const appsDir = join(dataDir, "apps");
22 const scopeDb = makeLibsqlScopeDb({
23 root: join(appsDir, "scope-db"),
24 });
25 return {
26 backings: {
27 artifactStore: makeGitArtifactStore({ root: join(appsDir, "artifacts") }),
28 scopeDb,
29 sandbox: makeQuickjsToolSandbox(),
30 store: makeSqliteAppsStore({ path: join(appsDir, "store.sqlite") }),
31 },
32 close: async () => {
33 await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined)));
34 },
35 };
36};
37
38let current: { readonly dataDir: string; readonly value: SelfHostAppsBackings } | undefined;
39
40export const getSelfHostAppsBackings = (): AppsBackings => {
41 const dataDir = resolveDataDir();
42 if (current && current.dataDir === dataDir) return current.value.backings;
43 const value = createBackings(dataDir);
44 current = { dataDir, value };
45 return value.backings;
46};
47
48export const closeSelfHostAppsBackings = async (): Promise<void> => {
49 const value = current?.value;
50 current = undefined;
51 await value?.close();
52};
apps/host-selfhost/src/apps-resolver.ts+0−120
@@ -1,120 +0,0 @@
1import { Effect } from "effect";
2
3import {
4 ConnectionName,
5 IntegrationSlug,
6 ToolAddress,
7 isToolResult,
8 type ConnectionRef,
9 type Owner,
10 type PluginCtx,
11} from "@executor-js/sdk";
12import {
13 BindingError,
14 type ClientResolver,
15 type ConnectionCandidate,
16} from "@executor-js/plugin-apps/api";
17
18const parseConnectionAddress = (address: string): ConnectionRef | null => {
19 const parts = address.split(".");
20 if (parts.length !== 4 || parts[0] !== "tools") return null;
21 const [, integration, owner, name] = parts;
22 if (!integration || !name) return null;
23 if (owner !== "org" && owner !== "user") return null;
24 return {
25 owner: owner as Owner,
26 integration: IntegrationSlug.make(integration),
27 name: ConnectionName.make(name),
28 };
29};
30
31const toCandidate = (connection: {
32 readonly address: unknown;
33 readonly integration: unknown;
34 readonly name?: unknown;
35 readonly owner?: unknown;
36}): ConnectionCandidate => ({
37 address: String(connection.address),
38 integration: String(connection.integration),
39 ...(connection.name !== undefined ? { name: String(connection.name) } : {}),
40 ...(connection.owner !== undefined ? { owner: String(connection.owner) } : {}),
41});
42
43export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): ClientResolver => ({
44 listConnections: ({ integration }) =>
45 input.ctx.connections.list({ integration: IntegrationSlug.make(integration) }).pipe(
46 Effect.map((connections) => connections.map(toCandidate)),
47 Effect.mapError(
48 () =>
49 new BindingError({
50 role: integration,
51 integration,
52 message: `failed to list ${integration} connections`,
53 }),
54 ),
55 ),
56
57 resolveConnection: ({ connection }) =>
58 Effect.gen(function* () {
59 const ref = parseConnectionAddress(connection);
60 if (!ref) return null;
61 const row = yield* input.ctx.connections.get(ref).pipe(
62 Effect.mapError(
63 () =>
64 new BindingError({
65 role: String(ref.integration),
66 integration: String(ref.integration),
67 requestedConnection: connection,
68 message: `failed to resolve connection ${connection}`,
69 }),
70 ),
71 );
72 return row ? toCandidate(row) : null;
73 }),
74
75 call: ({ integration, connection, path, args, invokeOptions }) =>
76 Effect.gen(function* () {
77 const ref = parseConnectionAddress(connection);
78 if (!ref) {
79 return yield* new BindingError({
80 role: integration,
81 integration,
82 requestedConnection: connection,
83 message: `invalid connection address ${connection}`,
84 });
85 }
86 if (String(ref.integration) !== integration) {
87 return yield* new BindingError({
88 role: integration,
89 integration,
90 requestedConnection: connection,
91 message: `connection "${connection}" belongs to integration "${ref.integration}", not "${integration}"`,
92 });
93 }
94 const tool = path.join(".");
95 const address = ToolAddress.make(`${connection}.${tool}`);
96 const payload = args[0] ?? {};
97 const result = yield* input.ctx.execute(address, payload, invokeOptions).pipe(
98 Effect.mapError(
99 () =>
100 new BindingError({
101 role: integration,
102 integration,
103 requestedConnection: connection,
104 message: `failed to execute ${address}`,
105 }),
106 ),
107 );
108 if (isToolResult(result)) {
109 if (result.ok) return result.data;
110 const { message } = result.error;
111 return yield* new BindingError({
112 role: integration,
113 integration,
114 requestedConnection: connection,
115 message,
116 });
117 }
118 return result;
119 }),
120});
apps/host-selfhost/src/apps-route.ts+0−166
@@ -1,166 +0,0 @@
1import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
2import { Data, Effect, Layer, Predicate } from "effect";
3
4import {
5 IdentityProvider,
6 RequestOrgSlug,
7 RequestWebOrigin,
8 makeScopedExecutor,
9 type Principal,
10} from "@executor-js/api/server";
11import { IntegrationSlug, ToolAddress } from "@executor-js/sdk";
12
13import { SelfHostScopedExecutorSeams } from "./execution";
14import { SelfHostDb, type SelfHostDbHandle } from "./db/self-host-db";
15import type { SelfHostPlugins } from "./plugins";
16
17class AppsSyncRouteError extends Data.TaggedError("AppsSyncRouteError")<{
18 readonly status: number;
19 readonly message: string;
20}> {}
21
22const isAppsSyncRouteError = (failure: unknown): failure is AppsSyncRouteError =>
23 Predicate.isTagged("AppsSyncRouteError")(failure);
24
25const parseJson = (request: Request): Effect.Effect<unknown, AppsSyncRouteError> =>
26 Effect.tryPromise({
27 try: () => request.json() as Promise<unknown>,
28 catch: () =>
29 new AppsSyncRouteError({
30 status: 400,
31 message: "Invalid JSON body",
32 }),
33 });
34
35const buildExecutor = (principal: Principal, request: Request) => {
36 const withOrigin = makeScopedExecutor<SelfHostPlugins>(
37 principal.accountId,
38 principal.organizationId,
39 principal.organizationName,
40 ).pipe(Effect.provideService(RequestWebOrigin, { origin: new URL(request.url).origin }));
41 return principal.organizationSlug
42 ? withOrigin.pipe(Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug }))
43 : withOrigin;
44};
45
46const sourceSlugFromRoute = () =>
47 HttpRouter.params.pipe(
48 Effect.map((params) => params.slug ?? ""),
49 Effect.flatMap((slug) =>
50 slug.length > 0
51 ? Effect.succeed(slug)
52 : Effect.fail(
53 new AppsSyncRouteError({
54 status: 400,
55 message: "Missing custom tools source slug",
56 }),
57 ),
58 ),
59 );
60
61export interface SelfHostAppsSyncRouteDeps {
62 readonly identity: Layer.Layer<IdentityProvider>;
63 readonly db: SelfHostDbHandle;
64}
65
66const routeHandler = (
67 run: (request: Request) => Effect.Effect<unknown, unknown, unknown>,
68): Effect.Effect<HttpServerResponse.HttpServerResponse, never, unknown> =>
69 Effect.gen(function* () {
70 const httpRequest = yield* HttpServerRequest.HttpServerRequest;
71 const request = yield* HttpServerRequest.toWeb(httpRequest).pipe(
72 Effect.catch(() => Effect.succeed(null)),
73 );
74 if (request === null) {
75 return HttpServerResponse.text("Internal Server Error", { status: 500 });
76 }
77 return yield* run(request).pipe(
78 Effect.map((body) => HttpServerResponse.jsonUnsafe(body)),
79 Effect.catch((failure: unknown) => {
80 if (Predicate.isTagged("Unauthorized")(failure)) {
81 return Effect.succeed(HttpServerResponse.text("Unauthorized", { status: 401 }));
82 }
83 if (Predicate.isTagged("NoOrganization")(failure)) {
84 return Effect.succeed(HttpServerResponse.text("Forbidden", { status: 403 }));
85 }
86 if (Predicate.isTagged("Unavailable")(failure)) {
87 return Effect.succeed(HttpServerResponse.text("Unavailable", { status: 503 }));
88 }
89 if (isAppsSyncRouteError(failure)) {
90 const message = failure.message;
91 const status = failure.status;
92 return Effect.succeed(HttpServerResponse.jsonUnsafe({ error: message }, { status }));
93 }
94 return Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 }));
95 }),
96 Effect.catchCause(() =>
97 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
98 ),
99 );
100 });
101
102export const makeSelfHostAppsSyncRoute = ({ identity, db }: SelfHostAppsSyncRouteDeps) => {
103 const services = Layer.merge(
104 SelfHostScopedExecutorSeams.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
105 identity,
106 );
107 return Layer.mergeAll(
108 HttpRouter.add(
109 "GET",
110 "/api/apps/sources/github",
111 routeHandler((request) =>
112 Effect.gen(function* () {
113 const identityProvider = yield* IdentityProvider;
114 const principal = yield* identityProvider.authenticate(request);
115 const executor = yield* buildExecutor(principal, request);
116 return yield* executor.execute(ToolAddress.make("executor.apps.list_github_sources"), {});
117 }),
118 ),
119 ),
120 HttpRouter.add(
121 "POST",
122 "/api/apps/sources/github/sync",
123 routeHandler((request) =>
124 Effect.gen(function* () {
125 const identityProvider = yield* IdentityProvider;
126 const principal = yield* identityProvider.authenticate(request);
127 const payload = yield* parseJson(request);
128 const executor = yield* buildExecutor(principal, request);
129 return yield* executor.execute(
130 ToolAddress.make("executor.apps.sync_github_source"),
131 payload,
132 );
133 }),
134 ),
135 ),
136 HttpRouter.add(
137 "GET",
138 "/api/apps/sources/github/:slug",
139 routeHandler((request) =>
140 Effect.gen(function* () {
141 const identityProvider = yield* IdentityProvider;
142 const principal = yield* identityProvider.authenticate(request);
143 const slug = yield* sourceSlugFromRoute();
144 const executor = yield* buildExecutor(principal, request);
145 return yield* executor.execute(ToolAddress.make("executor.apps.get_github_source"), {
146 slug,
147 });
148 }),
149 ),
150 ),
151 HttpRouter.add(
152 "DELETE",
153 "/api/apps/sources/github/:slug",
154 routeHandler((request) =>
155 Effect.gen(function* () {
156 const identityProvider = yield* IdentityProvider;
157 const principal = yield* identityProvider.authenticate(request);
158 const slug = yield* sourceSlugFromRoute();
159 const executor = yield* buildExecutor(principal, request);
160 yield* executor.integrations.remove(IntegrationSlug.make(slug));
161 return { removed: true };
162 }),
163 ),
164 ),
165 ).pipe(HttpRouter.provideRequest(services));
166};
apps/host-selfhost/src/apps.ts+0−71
@@ -1,71 +0,0 @@
1import { join } from "node:path";
2
3import { Effect } from "effect";
4
5import {
6 appsPlugin,
7 BindingError,
8 makeSelfHostAppsRuntime,
9 makeSqliteAppsStore,
10 type AppsRuntime,
11 type ClientResolver,
12} from "@executor-js/plugin-apps/api";
13import type { PluginCtx } from "@executor-js/sdk";
14
15import { resolveDataDir } from "./config";
16import { makeSelfHostAppsResolver } from "./apps-resolver";
17
18const missingResolver = (): ClientResolver => ({
19 listConnections: () => Effect.succeed([]),
20 resolveConnection: () => Effect.succeed(null),
21 call: ({ integration, connection }) =>
22 Effect.fail(
23 new BindingError({
24 role: integration,
25 integration,
26 requestedConnection: connection,
27 message: "apps resolver is unavailable outside a request-scoped executor",
28 }),
29 ),
30});
31
32const createSubsystem = (dataDir: string) => {
33 const appsDir = join(dataDir, "apps");
34 const store = makeSqliteAppsStore({ path: join(appsDir, "store.sqlite") });
35 const host = makeSelfHostAppsRuntime({
36 dataDir: appsDir,
37 store,
38 resolver: missingResolver(),
39 });
40 const plugin = appsPlugin({
41 runtime: host.runtime,
42 makeResolver: ({ ctx }) => makeSelfHostAppsResolver({ ctx: ctx as PluginCtx }),
43 });
44 return {
45 runtime: host.runtime,
46 plugin,
47 close: host.close,
48 };
49};
50
51export interface SelfHostAppsSubsystem {
52 readonly runtime: AppsRuntime;
53 readonly plugin: ReturnType<typeof appsPlugin>;
54 readonly close: () => Promise<void>;
55}
56
57let current: { readonly dataDir: string; readonly subsystem: SelfHostAppsSubsystem } | undefined;
58
59export const getSelfHostAppsSubsystem = (): SelfHostAppsSubsystem => {
60 const dataDir = resolveDataDir();
61 if (current && current.dataDir === dataDir) return current.subsystem;
62 const subsystem = createSubsystem(dataDir);
63 current = { dataDir, subsystem };
64 return subsystem;
65};
66
67export const closeSelfHostAppsSubsystem = async (): Promise<void> => {
68 const subsystem = current?.subsystem;
69 current = undefined;
70 await subsystem?.close();
71};
apps/host-selfhost/src/custom-tools-console.web.test.ts+0−177
@@ -1,177 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import appsClientPlugin, {
4 CUSTOM_TOOLS_LABEL,
5 CUSTOM_TOOLS_PLUGIN_KEY,
6 type CustomToolsFetch,
7 appsIntegrationPlugin,
8 formatSyncErrors,
9 getCustomToolSource,
10 listCustomToolSources,
11 removeCustomToolSource,
12 syncCustomToolSource,
13 syncStatusLabel,
14 validateGitHubSourceUrl,
15} from "@executor-js/plugin-apps/client";
16
17const jsonResponse = (body: unknown, init?: ResponseInit): Response =>
18 new Response(JSON.stringify(body), {
19 status: 200,
20 headers: { "content-type": "application/json" },
21 ...init,
22 });
23
24describe("custom tools console client", () => {
25 it("registers the manual Custom tools tile", () => {
26 const manualTiles = [appsIntegrationPlugin].map((plugin) => ({
27 href: `/integrations/add/${plugin.key}`,
28 label: plugin.label,
29 }));
30
31 expect(appsClientPlugin.id).toBe(CUSTOM_TOOLS_PLUGIN_KEY);
32 expect(appsClientPlugin.integrationPlugin?.label).toBe(CUSTOM_TOOLS_LABEL);
33 expect(manualTiles).toContainEqual({
34 href: "/integrations/add/apps",
35 label: "Custom tools",
36 });
37 });
38
39 it("validates the GitHub URL shape", () => {
40 expect(validateGitHubSourceUrl("https://github.com/UsefulSoftwareCo/executor")).toBeNull();
41 expect(validateGitHubSourceUrl("UsefulSoftwareCo/executor")).toBeNull();
42 expect(validateGitHubSourceUrl("https://gitlab.com/UsefulSoftwareCo/executor")).toBe(
43 "GitHub source URLs must use github.com.",
44 );
45 expect(validateGitHubSourceUrl("UsefulSoftwareCo")).toBe(
46 "Use a GitHub repo URL like https://github.com/owner/repo, optionally with /tree/<ref> or /commit/<sha>.",
47 );
48 expect(validateGitHubSourceUrl("")).toBe("Enter a GitHub URL.");
49 });
50
51 it("surfaces successful sync and source detail data", async () => {
52 let syncBody = "";
53 let removed = false;
54 const fetchImpl: CustomToolsFetch = async (input, init) => {
55 const url = String(input);
56 if (url.endsWith("/sync")) {
57 syncBody = String(init?.body ?? "");
58 return jsonResponse({
59 status: "published",
60 snapshotId: "snap1",
61 upstreamSha: "abc123",
62 tools: ["repo-summary", "stale-issues"],
63 skipped: [],
64 });
65 }
66 if (url.endsWith("/demo-tools")) {
67 if (init?.method === "DELETE") {
68 removed = true;
69 return jsonResponse({ removed: true });
70 }
71 return jsonResponse({
72 source: {
73 slug: "demo-tools",
74 name: "demo-tools",
75 scope: "demo-tools",
76 url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
77 repo: "RhysSullivan/executor-custom-tools-demo",
78 ref: "main",
79 hasToken: true,
80 upstreamSha: "abc123",
81 snapshotId: "snap1",
82 publishedAt: "2026-07-06T12:00:00.000Z",
83 tools: ["repo-summary", "stale-issues"],
84 skipped: [],
85 },
86 });
87 }
88 return jsonResponse({
89 sources: [
90 {
91 slug: "demo-tools",
92 name: "demo-tools",
93 scope: "demo-tools",
94 url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
95 repo: "RhysSullivan/executor-custom-tools-demo",
96 ref: "main",
97 hasToken: true,
98 upstreamSha: "abc123",
99 snapshotId: "snap1",
100 publishedAt: "2026-07-06T12:00:00.000Z",
101 tools: ["repo-summary", "stale-issues"],
102 skipped: [],
103 },
104 ],
105 });
106 };
107
108 const syncResult = await syncCustomToolSource(
109 {
110 name: "demo-tools",
111 url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
112 token: "ghp_demo",
113 },
114 fetchImpl,
115 );
116 const listed = await listCustomToolSources(fetchImpl);
117 const detail = await getCustomToolSource("demo-tools", fetchImpl);
118 const remove = await removeCustomToolSource("demo-tools", fetchImpl);
119
120 expect(syncStatusLabel(syncResult)).toBe("Published 2 tools.");
121 expect(syncBody).toContain("ghp_demo");
122 expect(syncBody).toContain("demo-tools");
123 expect(syncResult.tools).toEqual(["repo-summary", "stale-issues"]);
124 expect(detail.source?.slug).toBe("demo-tools");
125 expect(listed.sources[0]?.hasToken).toBe(true);
126 expect(listed.sources[0]?.tools).toEqual(["repo-summary", "stale-issues"]);
127 expect(remove).toEqual({ removed: true });
128 expect(removed).toBe(true);
129 });
130
131 it("renders failed sync errors readably", async () => {
132 const fetchImpl: CustomToolsFetch = async () =>
133 jsonResponse({
134 status: "failed",
135 tools: [],
136 skipped: [],
137 errors: [
138 {
139 stage: "collect",
140 message: "schema library not supported for schema export",
141 diagnostics: [{ path: "tools/x.ts", message: "vendor: nope" }],
142 },
143 ],
144 });
145
146 const result = await syncCustomToolSource(
147 {
148 url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
149 },
150 fetchImpl,
151 );
152
153 expect(syncStatusLabel(result)).toBe("Sync failed.");
154 expect(formatSyncErrors(result)).toEqual([
155 "collect: schema library not supported for schema export (tools/x.ts: vendor: nope)",
156 ]);
157 });
158
159 it("shows the up-to-date sync state", async () => {
160 const fetchImpl: CustomToolsFetch = async () =>
161 jsonResponse({
162 status: "up-to-date",
163 upstreamSha: "abc123",
164 tools: ["repo-summary"],
165 skipped: [],
166 });
167
168 const result = await syncCustomToolSource(
169 {
170 url: "https://github.com/RhysSullivan/executor-custom-tools-demo",
171 },
172 fetchImpl,
173 );
174
175 expect(syncStatusLabel(result)).toBe("Already up to date.");
176 });
177});
apps/host-selfhost/src/testing/test-app.ts+2−4
@@ -30,8 +30,7 @@ import { · 29 unmodified lines
30} from "../mcp/session-store";30} from "../mcp/session-store";
31import { selfHostPlugins } from "../plugins";31import { selfHostPlugins } from "../plugins";
32import { ErrorCaptureLive } from "../observability";32import { ErrorCaptureLive } from "../observability";
33import { closeSelfHostAppsSubsystem } from "../apps";33import { closeSelfHostAppsBackings } from "../apps-backings";
34import { makeSelfHostAppsSyncRoute } from "../apps-route";
35 34
36// ===========================================================================35// ===========================================================================
37// Self-host TEST harness — the throwaway composition tests use to exercise the36// Self-host TEST harness — the throwaway composition tests use to exercise the
@@ -204,7 +203,6 @@ export const makeSelfHostTestApp = async ( · 166 unmodified lines
204 },203 },
205 extensions: {204 extensions: {
206 routes: [205 routes: [
207 makeSelfHostAppsSyncRoute({ identity: options.identity, db: dbHandle }),
208 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),206 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),
209 ],207 ],
210 },208 },
@@ -220,7 +218,7 @@ export const makeSelfHostTestApp = async ( · 9 unmodified lines
220 handler: web.handler,218 handler: web.handler,
221 dispose: async () => {219 dispose: async () => {
222 await web.dispose();220 await web.dispose();
223 await closeSelfHostAppsSubsystem();221 await closeSelfHostAppsBackings();
224 await sessionStore.close();222 await sessionStore.close();
225 await dbHandle.close();223 await dbHandle.close();
226 },224 },