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
66 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
77 import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
88 import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
9+import { appsPlugin } from "@executor-js/plugin-apps/api";
910
1011 import { resolveSecretKey } from "./src/config";
11−import { getSelfHostAppsSubsystem } from "./src/apps";
12+import { getSelfHostAppsBackings } from "./src/apps-backings";
1213
1314 // ---------------------------------------------------------------------------
1415 // Single source of truth for the self-hosted app's plugin list.
@@ -37,7 +38,7 @@ export default defineExecutorConfig({ · 22 unmodified lines
3738 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
3839 graphqlHttpPlugin(),
3940 toolkitsPlugin({ activeToolkitSlug }),
40− getSelfHostAppsSubsystem().plugin,
41+ appsPlugin({ backings: getSelfHostAppsBackings() }),
4142 // First writable secret provider -> the default for `secrets.set`.
4243 encryptedSecretsPlugin({ key: resolveSecretKey() }),
4344 ] as const,
apps/host-selfhost/src/app.ts+2−4
@@ -22,8 +22,7 @@ import { makeSelfHostMcpSeams } from "./mcp"; · 21 unmodified lines
2222 import { selfHostPlugins } from "./plugins";
2323 import { ErrorCaptureLive } from "./observability";
2424 import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login";
25−import { closeSelfHostAppsSubsystem } from "./apps";
26−import { makeSelfHostAppsSyncRoute } from "./apps-route";
25+import { closeSelfHostAppsBackings } from "./apps-backings";
2726
2827 // ===========================================================================
2928 // The self-hosted Executor app, as ONE `ExecutorApp.make` call.
@@ -121,7 +120,6 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 91 unmodified lines
121120 makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
122121 // Public system API: /api/health + /api/setup-status (unauthenticated).
123122 makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
124− makeSelfHostAppsSyncRoute({ identity: identityLayer, db: dbHandle }),
125123 // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths).
126124 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),
127125 ],
@@ -142,7 +140,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 14 unmodified lines
142140 toWebHandler,
143141 betterAuth,
144142 closeDb: async () => {
145− await closeSelfHostAppsSubsystem();
143+ await closeSelfHostAppsBackings();
146144 await mcp.close();
147145 await dbHandle.close();
148146 },
apps/host-selfhost/src/apps-backings.ts+52−0
@@ -0,0 +1,52 @@
1+import { join } from "node:path";
2+
3+import { Effect } from "effect";
4+
5+import {
6+ makeGitArtifactStore,
7+ makeLibsqlScopeDb,
8+ makeQuickjsToolSandbox,
9+ makeSqliteAppsStore,
10+ type AppsBackings,
11+} from "@executor-js/plugin-apps/api";
12+
13+import { resolveDataDir } from "./config";
14+
15+interface SelfHostAppsBackings {
16+ readonly backings: AppsBackings;
17+ readonly close: () => Promise<void>;
18+}
19+
20+const 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+
38+let current: { readonly dataDir: string; readonly value: SelfHostAppsBackings } | undefined;
39+
40+export 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+
48+export 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 @@
1−import { Effect } from "effect";
2−
3−import {
4− ConnectionName,
5− IntegrationSlug,
6− ToolAddress,
7− isToolResult,
8− type ConnectionRef,
9− type Owner,
10− type PluginCtx,
11−} from "@executor-js/sdk";
12−import {
13− BindingError,
14− type ClientResolver,
15− type ConnectionCandidate,
16−} from "@executor-js/plugin-apps/api";
17−
18−const 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−
31−const 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−
43−export 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 @@
1−import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
2−import { Data, Effect, Layer, Predicate } from "effect";
3−
4−import {
5− IdentityProvider,
6− RequestOrgSlug,
7− RequestWebOrigin,
8− makeScopedExecutor,
9− type Principal,
10−} from "@executor-js/api/server";
11−import { IntegrationSlug, ToolAddress } from "@executor-js/sdk";
12−
13−import { SelfHostScopedExecutorSeams } from "./execution";
14−import { SelfHostDb, type SelfHostDbHandle } from "./db/self-host-db";
15−import type { SelfHostPlugins } from "./plugins";
16−
17−class AppsSyncRouteError extends Data.TaggedError("AppsSyncRouteError")<{
18− readonly status: number;
19− readonly message: string;
20−}> {}
21−
22−const isAppsSyncRouteError = (failure: unknown): failure is AppsSyncRouteError =>
23− Predicate.isTagged("AppsSyncRouteError")(failure);
24−
25−const 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−
35−const 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−
46−const 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−
61−export interface SelfHostAppsSyncRouteDeps {
62− readonly identity: Layer.Layer<IdentityProvider>;
63− readonly db: SelfHostDbHandle;
64−}
65−
66−const 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−
102−export 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 @@
1−import { join } from "node:path";
2−
3−import { Effect } from "effect";
4−
5−import {
6− appsPlugin,
7− BindingError,
8− makeSelfHostAppsRuntime,
9− makeSqliteAppsStore,
10− type AppsRuntime,
11− type ClientResolver,
12−} from "@executor-js/plugin-apps/api";
13−import type { PluginCtx } from "@executor-js/sdk";
14−
15−import { resolveDataDir } from "./config";
16−import { makeSelfHostAppsResolver } from "./apps-resolver";
17−
18−const 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−
32−const 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−
51−export interface SelfHostAppsSubsystem {
52− readonly runtime: AppsRuntime;
53− readonly plugin: ReturnType<typeof appsPlugin>;
54− readonly close: () => Promise<void>;
55−}
56−
57−let current: { readonly dataDir: string; readonly subsystem: SelfHostAppsSubsystem } | undefined;
58−
59−export 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−
67−export 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 @@
1−import { describe, expect, it } from "@effect/vitest";
2−
3−import 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−
17−const 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−
24−describe("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
3030 } from "../mcp/session-store";
3131 import { selfHostPlugins } from "../plugins";
3232 import { ErrorCaptureLive } from "../observability";
33−import { closeSelfHostAppsSubsystem } from "../apps";
34−import { makeSelfHostAppsSyncRoute } from "../apps-route";
33+import { closeSelfHostAppsBackings } from "../apps-backings";
3534
3635 // ===========================================================================
3736 // Self-host TEST harness — the throwaway composition tests use to exercise the
@@ -204,7 +203,6 @@ export const makeSelfHostTestApp = async ( · 166 unmodified lines
204203 },
205204 extensions: {
206205 routes: [
207− makeSelfHostAppsSyncRoute({ identity: options.identity, db: dbHandle }),
208206 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),
209207 ],
210208 },
@@ -220,7 +218,7 @@ export const makeSelfHostTestApp = async ( · 9 unmodified lines
220218 handler: web.handler,
221219 dispose: async () => {
222220 await web.dispose();
223− await closeSelfHostAppsSubsystem();
221+ await closeSelfHostAppsBackings();
224222 await sessionStore.close();
225223 await dbHandle.close();
226224 },