History

Add GitHub sync and self-host custom tools wiring

Rhys Sullivan committed 4 months ago

Commit 275446e1 parent 41eb3ae19 files · +1665 −40Browse files

Unified · Split

19 files
  1. apps/host-selfhost/executor.config.ts +2 −0
  2. apps/host-selfhost/package.json +2 −0
  3. apps/host-selfhost/src/app.ts +4 −0
  4. apps/host-selfhost/src/apps-resolver.ts +130 −0
  5. apps/host-selfhost/src/apps-route.ts +91 −0
  6. apps/host-selfhost/src/apps-wire.node.test.ts +549 −0
  7. apps/host-selfhost/src/apps.ts +71 −0
  8. apps/host-selfhost/src/testing/test-app.ts +4 −0
  9. bun.lock +2 −0
  10. packages/core/sdk/src/executor.ts +2 −0
  11. packages/core/sdk/src/plugin.ts +9 −0
  12. packages/plugins/apps/src/api.ts +11 −0
  13. packages/plugins/apps/src/index.ts +10 −0
  14. packages/plugins/apps/src/pipeline/descriptor.ts +16 −1
  15. packages/plugins/apps/src/pipeline/publish.ts +8 −1
  16. packages/plugins/apps/src/plugin/apps-plugin.ts +136 −37
  17. packages/plugins/apps/src/plugin/runtime.ts +5 −1
  18. packages/plugins/apps/src/source/github-source.test.ts +211 −0
  19. packages/plugins/apps/src/source/github-source.ts +402 −0
apps/host-selfhost/executor.config.ts+2−0
@@ -8,6 +8,7 @@ import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; · 7 unmodified lines
88 import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
99
1010 import { resolveSecretKey } from "./src/config";
11+import { getSelfHostAppsSubsystem } from "./src/apps";
1112
1213 // ---------------------------------------------------------------------------
1314 // Single source of truth for the self-hosted app's plugin list.
@@ -36,6 +37,7 @@ export default defineExecutorConfig({ · 22 unmodified lines
3637 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
3738 graphqlHttpPlugin(),
3839 toolkitsPlugin({ activeToolkitSlug }),
40+ getSelfHostAppsSubsystem().plugin,
3941 // First writable secret provider -> the default for `secrets.set`.
4042 encryptedSecretsPlugin({ key: resolveSecretKey() }),
4143 ] as const,
apps/host-selfhost/package.json+2−0
@@ -26,6 +26,7 @@ · 25 unmodified lines
2626 "@executor-js/execution": "workspace:*",
2727 "@executor-js/fumadb": "workspace:*",
2828 "@executor-js/host-mcp": "workspace:*",
29+ "@executor-js/plugin-apps": "workspace:*",
2930 "@executor-js/plugin-encrypted-secrets": "workspace:*",
3031 "@executor-js/plugin-google": "workspace:*",
3132 "@executor-js/plugin-graphql": "workspace:*",
@@ -48,6 +49,7 @@ · 16 unmodified lines
4849 },
4950 "devDependencies": {
5051 "@effect/vitest": "catalog:",
52+ "@executor-js/emulate": "^0.13.2",
5153 "@executor-js/vite-plugin": "workspace:*",
5254 "@tailwindcss/vite": "catalog:",
5355 "@tanstack/router-plugin": "^1.167.12",
apps/host-selfhost/src/app.ts+4−0
@@ -22,6 +22,8 @@ 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";
2527
2628 // ===========================================================================
2729 // The self-hosted Executor app, as ONE `ExecutorApp.make` call.
@@ -119,6 +121,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 91 unmodified lines
119121 makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
120122 // Public system API: /api/health + /api/setup-status (unauthenticated).
121123 makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
124+ makeSelfHostAppsSyncRoute({ identity: identityLayer, db: dbHandle }),
122125 // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths).
123126 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),
124127 ],
@@ -139,6 +142,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 14 unmodified lines
139142 toWebHandler,
140143 betterAuth,
141144 closeDb: async () => {
145+ await closeSelfHostAppsSubsystem();
142146 await mcp.close();
143147 await dbHandle.close();
144148 },
apps/host-selfhost/src/apps-resolver.ts+130−0
@@ -0,0 +1,130 @@
1+import { Effect } from "effect";
2+
3+import {
4+ ConnectionName,
5+ IntegrationSlug,
6+ ToolAddress,
7+ isToolResult,
8+ type ConnectionRef,
9+ type ExecuteError,
10+ type Owner,
11+ type PluginCtx,
12+} from "@executor-js/sdk";
13+import {
14+ BindingError,
15+ type ClientResolver,
16+ type ConnectionCandidate,
17+} from "@executor-js/plugin-apps/api";
18+
19+const parseConnectionAddress = (address: string): ConnectionRef | null => {
20+ const parts = address.split(".");
21+ if (parts.length !== 4 || parts[0] !== "tools") return null;
22+ const [, integration, owner, name] = parts;
23+ if (!integration || !name) return null;
24+ if (owner !== "org" && owner !== "user") return null;
25+ return {
26+ owner: owner as Owner,
27+ integration: IntegrationSlug.make(integration),
28+ name: ConnectionName.make(name),
29+ };
30+};
31+
32+const bindingError = (input: {
33+ readonly integration: string;
34+ readonly connection?: string;
35+ readonly message: string;
36+ readonly cause?: unknown;
37+}): BindingError =>
38+ new BindingError({
39+ role: input.integration,
40+ integration: input.integration,
41+ requestedConnection: input.connection,
42+ message: input.message,
43+ });
44+
45+const executeErrorMessage = (cause: ExecuteError): string =>
46+ typeof cause.message === "string" && cause.message.length > 0 ? cause.message : cause._tag;
47+
48+const toCandidate = (connection: {
49+ readonly address: unknown;
50+ readonly integration: unknown;
51+ readonly name?: unknown;
52+ readonly owner?: unknown;
53+}): ConnectionCandidate => ({
54+ address: String(connection.address),
55+ integration: String(connection.integration),
56+ ...(connection.name !== undefined ? { name: String(connection.name) } : {}),
57+ ...(connection.owner !== undefined ? { owner: String(connection.owner) } : {}),
58+});
59+
60+export const makeSelfHostAppsResolver = (input: { readonly ctx: PluginCtx }): ClientResolver => ({
61+ listConnections: ({ integration }) =>
62+ input.ctx.connections.list({ integration: IntegrationSlug.make(integration) }).pipe(
63+ Effect.map((connections) => connections.map(toCandidate)),
64+ Effect.mapError((cause) =>
65+ bindingError({
66+ integration,
67+ message: `failed to list ${integration} connections`,
68+ cause,
69+ }),
70+ ),
71+ ),
72+
73+ resolveConnection: ({ connection }) =>
74+ Effect.gen(function* () {
75+ const ref = parseConnectionAddress(connection);
76+ if (!ref) return null;
77+ const row = yield* input.ctx.connections.get(ref).pipe(
78+ Effect.mapError((cause) =>
79+ bindingError({
80+ integration: String(ref.integration),
81+ connection,
82+ message: `failed to resolve connection ${connection}`,
83+ cause,
84+ }),
85+ ),
86+ );
87+ return row ? toCandidate(row) : null;
88+ }),
89+
90+ call: ({ integration, connection, path, args }) =>
91+ Effect.gen(function* () {
92+ const ref = parseConnectionAddress(connection);
93+ if (!ref) {
94+ return yield* bindingError({
95+ integration,
96+ connection,
97+ message: `invalid connection address ${connection}`,
98+ });
99+ }
100+ if (String(ref.integration) !== integration) {
101+ return yield* bindingError({
102+ integration,
103+ connection,
104+ message: `connection "${connection}" belongs to integration "${ref.integration}", not "${integration}"`,
105+ });
106+ }
107+ const tool = path.join(".");
108+ const address = ToolAddress.make(`${connection}.${tool}`);
109+ const payload = args[0] ?? {};
110+ const result = yield* input.ctx.execute(address, payload).pipe(
111+ Effect.mapError((cause) =>
112+ bindingError({
113+ integration,
114+ connection,
115+ message: executeErrorMessage(cause),
116+ cause,
117+ }),
118+ ),
119+ );
120+ if (isToolResult(result)) {
121+ if (result.ok) return result.data;
122+ return yield* bindingError({
123+ integration,
124+ connection,
125+ message: result.error.message,
126+ });
127+ }
128+ return result;
129+ }),
130+});
apps/host-selfhost/src/apps-route.ts+91−0
@@ -0,0 +1,91 @@
1+import { HttpEffect, HttpRouter } from "effect/unstable/http";
2+import { Data, Effect, Layer } from "effect";
3+
4+import {
5+ IdentityProvider,
6+ RequestOrgSlug,
7+ RequestWebOrigin,
8+ makeScopedExecutor,
9+ type Principal,
10+} from "@executor-js/api/server";
11+import { 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 jsonResponse = (body: unknown, status = 200): Response =>
23+ new Response(JSON.stringify(body), {
24+ status,
25+ headers: { "content-type": "application/json" },
26+ });
27+
28+const parseJson = (request: Request): Effect.Effect<unknown, AppsSyncRouteError> =>
29+ Effect.tryPromise({
30+ try: () => request.json() as Promise<unknown>,
31+ catch: () =>
32+ new AppsSyncRouteError({
33+ status: 400,
34+ message: "Invalid JSON body",
35+ }),
36+ });
37+
38+const buildExecutor = (principal: Principal, request: Request) => {
39+ const withOrigin = makeScopedExecutor<SelfHostPlugins>(
40+ principal.accountId,
41+ principal.organizationId,
42+ principal.organizationName,
43+ ).pipe(Effect.provideService(RequestWebOrigin, { origin: new URL(request.url).origin }));
44+ return principal.organizationSlug
45+ ? withOrigin.pipe(Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug }))
46+ : withOrigin;
47+};
48+
49+export interface SelfHostAppsSyncRouteDeps {
50+ readonly identity: Layer.Layer<IdentityProvider>;
51+ readonly db: SelfHostDbHandle;
52+}
53+
54+export const makeSelfHostAppsSyncRoute = ({ identity, db }: SelfHostAppsSyncRouteDeps) => {
55+ const services = Layer.merge(
56+ SelfHostScopedExecutorSeams.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
57+ identity,
58+ );
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+ ),
89+ ),
90+ );
91+};
apps/host-selfhost/src/apps-wire.node.test.ts+549−0
@@ -0,0 +1,549 @@
1+import { randomBytes } from "node:crypto";
2+import { mkdtempSync } from "node:fs";
3+import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4+import { createServer as createNetServer } from "node:net";
5+import { tmpdir } from "node:os";
6+import { join } from "node:path";
7+
8+import { afterAll, beforeAll, expect, test } from "@effect/vitest";
9+import { createEmulator, type Emulator } from "@executor-js/emulate";
10+
11+const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-"));
12+const dbPath = join(dataDir, "data.db");
13+process.env.EXECUTOR_DATA_DIR = dataDir;
14+process.env.EXECUTOR_SECRET_KEY = "apps-wire-secret-key";
15+process.env.EXECUTOR_ALLOW_LOCAL_NETWORK = "true";
16+
17+const TEST_USER = "apps-user";
18+const TEST_ORG = "apps-org";
19+const OWNER = "syncer";
20+const REPO = "custom-tools";
21+const REPO_FULL_NAME = `${OWNER}/${REPO}`;
22+const GITHUB_CONNECTION = "tools.github.user.main";
23+
24+interface TestHttpServer {
25+ readonly baseUrl: string;
26+ readonly close: () => Promise<void>;
27+}
28+
29+interface SyncResult {
30+ readonly status: "published" | "up-to-date" | "failed";
31+ readonly snapshotId?: string;
32+ readonly upstreamSha?: string;
33+ readonly tools: readonly string[];
34+ readonly skipped: readonly { readonly path: string; readonly reason: string }[];
35+ readonly errors?: readonly unknown[];
36+}
37+
38+interface ToolRow {
39+ readonly address: string;
40+ readonly name: string;
41+ readonly integration: string;
42+}
43+
44+interface ExecuteResponse {
45+ readonly status: "completed" | "paused";
46+ readonly text: string;
47+ readonly structured: {
48+ readonly status?: string;
49+ readonly result?: unknown;
50+ readonly error?: unknown;
51+ };
52+ readonly isError?: boolean;
53+}
54+
55+const bearerTemplate = {
56+ slug: "bearer",
57+ type: "apiKey",
58+ label: "Bearer token",
59+ headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
60+};
61+
62+const jsonHeaders = {
63+ "content-type": "application/json",
64+ "x-test-user": TEST_USER,
65+ "x-test-org": TEST_ORG,
66+};
67+
68+const findFreePort = (): Promise<number> =>
69+ new Promise((resolve, reject) => {
70+ const probe = createNetServer();
71+ probe.once("error", reject);
72+ probe.listen(0, "127.0.0.1", () => {
73+ const address = probe.address();
74+ const port = typeof address === "object" && address ? address.port : 0;
75+ probe.close((error) => (error ? reject(error) : resolve(port)));
76+ });
77+ });
78+
79+const readRequestBody = async (request: IncomingMessage): Promise<Buffer | undefined> => {
80+ if (request.method === "GET" || request.method === "HEAD") return undefined;
81+ const chunks: Buffer[] = [];
82+ for await (const chunk of request) {
83+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
84+ }
85+ const body = Buffer.concat(chunks);
86+ return body.length > 0 ? body : undefined;
87+};
88+
89+const webRequestFromNode = async (request: IncomingMessage, port: number): Promise<Request> => {
90+ const headers = new Headers();
91+ for (const [name, raw] of Object.entries(request.headers)) {
92+ if (Array.isArray(raw)) {
93+ for (const value of raw) headers.append(name, value);
94+ } else if (raw !== undefined) {
95+ headers.set(name, raw);
96+ }
97+ }
98+ const body = await readRequestBody(request);
99+ return new Request(`http://127.0.0.1:${port}${request.url ?? "/"}`, {
100+ method: request.method,
101+ headers,
102+ body: body ? new Uint8Array(body) : undefined,
103+ });
104+};
105+
106+const sendWebResponse = async (webResponse: Response, response: ServerResponse): Promise<void> => {
107+ response.statusCode = webResponse.status;
108+ webResponse.headers.forEach((value, name) => response.setHeader(name, value));
109+ response.end(Buffer.from(await webResponse.arrayBuffer()));
110+};
111+
112+const startHttpServer = async (
113+ handler: (request: Request) => Promise<Response>,
114+): Promise<TestHttpServer> => {
115+ let port = 0;
116+ const server = createServer((request, response) => {
117+ void (async () => {
118+ try {
119+ await sendWebResponse(await handler(await webRequestFromNode(request, port)), response);
120+ } catch (error) {
121+ response.statusCode = 500;
122+ response.end(error instanceof Error ? error.message : String(error));
123+ }
124+ })();
125+ });
126+ await new Promise<void>((resolve, reject) => {
127+ server.once("error", reject);
128+ server.listen(0, "127.0.0.1", () => {
129+ const address = server.address();
130+ port = typeof address === "object" && address ? address.port : 0;
131+ resolve();
132+ });
133+ });
134+ return {
135+ baseUrl: `http://127.0.0.1:${port}`,
136+ close: () =>
137+ new Promise((resolve, reject) => {
138+ server.close((error) => (error ? reject(error) : resolve()));
139+ }),
140+ };
141+};
142+
143+const requestJson = async <T>(
144+ path: string,
145+ init: RequestInit = {},
146+ expectedStatus = 200,
147+): Promise<T> => {
148+ const response = await fetch(`${server.baseUrl}${path}`, init);
149+ const text = await response.text();
150+ expect(response.status).toBe(expectedStatus);
151+ return (text.length > 0 ? JSON.parse(text) : null) as T;
152+};
153+
154+const postJson = <T>(path: string, body: unknown, expectedStatus = 200): Promise<T> =>
155+ requestJson<T>(
156+ path,
157+ {
158+ method: "POST",
159+ headers: jsonHeaders,
160+ body: JSON.stringify(body),
161+ },
162+ expectedStatus,
163+ );
164+
165+const githubFetch = async <T>(
166+ emulator: Emulator,
167+ token: string,
168+ path: string,
169+ init: RequestInit = {},
170+): Promise<T> => {
171+ const headers = new Headers(init.headers);
172+ headers.set("authorization", `Bearer ${token}`);
173+ headers.set("accept", "application/vnd.github+json");
174+ if (init.body !== undefined) headers.set("content-type", "application/json");
175+ const response = await fetch(`${emulator.url}${path}`, { ...init, headers });
176+ const text = await response.text();
177+ expect(response.ok).toBe(true);
178+ return (text.length > 0 ? JSON.parse(text) : null) as T;
179+};
180+
181+const createIssue = (emulator: Emulator, token: string, title: string): Promise<unknown> =>
182+ githubFetch(emulator, token, `/repos/${OWNER}/${REPO}/issues`, {
183+ method: "POST",
184+ body: JSON.stringify({ title }),
185+ });
186+
187+const putRepoFiles = async (
188+ emulator: Emulator,
189+ token: string,
190+ files: Readonly<Record<string, string>>,
191+): Promise<string> => {
192+ const ref = await githubFetch<{ object: { sha: string } }>(
193+ emulator,
194+ token,
195+ `/repos/${OWNER}/${REPO}/git/ref/heads/main`,
196+ );
197+ const parentSha = ref.object.sha;
198+ const tree = await githubFetch<{ sha: string }>(
199+ emulator,
200+ token,
201+ `/repos/${OWNER}/${REPO}/git/trees`,
202+ {
203+ method: "POST",
204+ body: JSON.stringify({
205+ tree: Object.entries(files).map(([path, content]) => ({
206+ path,
207+ mode: "100644",
208+ type: "blob",
209+ content,
210+ })),
211+ }),
212+ },
213+ );
214+ const commit = await githubFetch<{ sha: string }>(
215+ emulator,
216+ token,
217+ `/repos/${OWNER}/${REPO}/git/commits`,
218+ {
219+ method: "POST",
220+ body: JSON.stringify({
221+ message: `Update custom tools ${randomBytes(3).toString("hex")}`,
222+ tree: tree.sha,
223+ parents: [parentSha],
224+ }),
225+ },
226+ );
227+ await githubFetch(emulator, token, `/repos/${OWNER}/${REPO}/git/refs/heads/main`, {
228+ method: "PATCH",
229+ body: JSON.stringify({ sha: commit.sha }),
230+ });
231+ return commit.sha;
232+};
233+
234+const executorJson = JSON.stringify(
235+ {
236+ $schema: "https://executor.sh/schemas/executor.json",
237+ description: "Curated tools wrapping our raw integration surface.",
238+ },
239+ null,
240+ 2,
241+);
242+
243+const dealPipelineSyncSource = `import { z } from "zod";
244+import { defineTool, integration } from "executor:app";
245+
246+/**
247+ * The deterministic-program case: pull GitHub issues into the scope database
248+ * so agents query a table instead of paging the raw API.
249+ */
250+export default defineTool({
251+ description:
252+ "Refresh the deals table from GitHub issues. Syncs issue numbers and titles " +
253+ "for pipeline questions.",
254+
255+ integrations: {
256+ github: integration("github"),
257+ },
258+
259+ input: z.object({
260+ owner: z.string(),
261+ repo: z.string(),
262+ }),
263+
264+ output: z.object({ synced: z.number() }),
265+
266+ annotations: { readOnly: false, destructive: false },
267+
268+ async handler({ owner, repo }, { github, db }) {
269+ await db.sql\`
270+ CREATE TABLE IF NOT EXISTS deals (
271+ id TEXT PRIMARY KEY,
272+ name TEXT NOT NULL,
273+ stage TEXT NOT NULL
274+ )\`;
275+
276+ const issues = await github.repos.listIssues({ owner, repo });
277+
278+ let synced = 0;
279+ for (const issue of issues) {
280+ await db.sql\`
281+ INSERT INTO deals (id, name, stage)
282+ VALUES (\${String(issue.number)}, \${issue.title}, 'open')
283+ ON CONFLICT (id) DO UPDATE SET
284+ name = excluded.name,
285+ stage = excluded.stage\`;
286+ synced++;
287+ }
288+
289+ return { synced };
290+ },
291+});
292+`;
293+
294+const findDealDocsSource = `import { z } from "zod";
295+import { defineTool, integration } from "executor:app";
296+
297+/**
298+ * The curation case: a read-only query over the scope database populated by
299+ * the sync tool.
300+ */
301+export default defineTool({
302+ description:
303+ "Find documents for a deal across synced pipeline records. Searches " +
304+ "stored issue titles.",
305+
306+ integrations: {
307+ github: integration("github"),
308+ },
309+
310+ input: z.object({
311+ limit: z.number().int().max(50).default(20),
312+ }),
313+
314+ output: z.object({
315+ documents: z.array(
316+ z.object({
317+ name: z.string(),
318+ stage: z.string(),
319+ }),
320+ ),
321+ }),
322+
323+ annotations: { readOnly: true, destructive: false },
324+
325+ async handler({ limit }, { db }) {
326+ await db.sql\`
327+ CREATE TABLE IF NOT EXISTS deals (
328+ id TEXT PRIMARY KEY,
329+ name TEXT NOT NULL,
330+ stage TEXT NOT NULL
331+ )\`;
332+ const rows = await db.sql\`
333+ SELECT name, stage FROM deals ORDER BY id LIMIT \${limit}\`;
334+
335+ return {
336+ documents: rows.map((row) => ({
337+ name: String(row.name),
338+ stage: String(row.stage),
339+ })),
340+ };
341+ },
342+});
343+`;
344+
345+const extraToolSource = `import { z } from "zod";
346+import { defineTool } from "executor:app";
347+
348+export default defineTool({
349+ description: "Return a static custom-tools health marker.",
350+ input: z.object({}),
351+ output: z.object({ ok: z.boolean() }),
352+ annotations: { readOnly: true, destructive: false },
353+ async handler() {
354+ return { ok: true };
355+ },
356+});
357+`;
358+
359+const initialFiles = (): Record<string, string> => ({
360+ "executor.json": executorJson,
361+ "tools/deal-pipeline-sync.ts": dealPipelineSyncSource,
362+ "tools/find-deal-docs.ts": findDealDocsSource,
363+});
364+
365+const registerGitHubIntegration = async (emulator: Emulator, token: string): Promise<void> => {
366+ await postJson("/api/openapi/specs", {
367+ spec: { kind: "url", url: emulator.openapiUrl },
368+ slug: "github",
369+ baseUrl: emulator.url,
370+ authenticationTemplate: [bearerTemplate],
371+ });
372+ const created = await postJson<{ address: string }>("/api/connections", {
373+ owner: "user",
374+ name: "main",
375+ integration: "github",
376+ template: "bearer",
377+ value: token,
378+ });
379+ expect(created.address).toBe(GITHUB_CONNECTION);
380+};
381+
382+const syncSource = (repo = REPO_FULL_NAME): Promise<SyncResult> =>
383+ postJson<SyncResult>("/api/apps/sources/github/sync", {
384+ repo,
385+ connection: GITHUB_CONNECTION,
386+ });
387+
388+const listAppTools = (): Promise<readonly ToolRow[]> =>
389+ requestJson<readonly ToolRow[]>("/api/tools?integration=apps", {
390+ headers: jsonHeaders,
391+ });
392+
393+const execute = (code: string): Promise<ExecuteResponse> =>
394+ postJson<ExecuteResponse>("/api/executions", { code, autoApprove: true });
395+
396+const executeResult = async (code: string): Promise<unknown> => {
397+ const response = await execute(code);
398+ expect(response.status).toBe("completed");
399+ expect(response.isError).toBe(false);
400+ return response.structured.result;
401+};
402+
403+const callAppToolCode = (toolName: string, args: unknown): string => `
404+const found = await tools.search({ namespace: "apps", query: ${JSON.stringify(toolName)}, limit: 20 });
405+const item = found.items.find((candidate) => candidate.path.endsWith(${JSON.stringify(toolName)}));
406+if (!item) return { ok: false, missing: ${JSON.stringify(toolName)}, found };
407+let fn = tools;
408+for (const segment of item.path.split(".")) fn = fn[segment];
409+const result = await fn(${JSON.stringify(args)});
410+return { path: item.path, result };
411+`;
412+
413+let github!: Emulator;
414+let server!: TestHttpServer;
415+let disposeApp: () => Promise<void> = async () => {};
416+
417+beforeAll(async () => {
418+ const emulatorPort = await findFreePort();
419+ github = await createEmulator({
420+ service: "github",
421+ port: emulatorPort,
422+ seed: {
423+ github: {
424+ users: [{ login: OWNER, name: "Syncer" }],
425+ repos: [{ owner: OWNER, name: REPO, auto_init: true }],
426+ },
427+ },
428+ });
429+ const { makeSelfHostTestApp, headerIdentityLayer } = await import("./testing/test-app");
430+ const app = await makeSelfHostTestApp({ identity: headerIdentityLayer, dbPath });
431+ disposeApp = app.dispose;
432+ server = await startHttpServer(app.handler);
433+});
434+
435+afterAll(async () => {
436+ await server?.close();
437+ await disposeApp();
438+ await github?.close();
439+});
440+
441+test("GitHub source sync publishes and invokes custom tools through self-host HTTP", async () => {
442+ const credential = await github.credentials.mint({
443+ type: "api-key",
444+ login: OWNER,
445+ scopes: ["repo", "user"],
446+ });
447+ const token = credential.token;
448+ expect(token).toBeTruthy();
449+ await registerGitHubIntegration(github, token!);
450+ await createIssue(github, token!, "Acme renewal diligence");
451+ await createIssue(github, token!, "Beta pipeline memo");
452+ await putRepoFiles(github, token!, initialFiles());
453+
454+ const unauthorized = await fetch(`${server.baseUrl}/api/apps/sources/github/sync`, {
455+ method: "POST",
456+ headers: { "content-type": "application/json" },
457+ body: JSON.stringify({ repo: REPO_FULL_NAME, connection: GITHUB_CONNECTION }),
458+ });
459+ expect(unauthorized.status).toBe(401);
460+
461+ const published = await syncSource();
462+ expect(published.status).toBe("published");
463+ expect(published.tools).toEqual(["deal-pipeline-sync", "find-deal-docs"]);
464+ expect(published.skipped).toEqual([]);
465+ const firstSnapshot = published.snapshotId;
466+ expect(firstSnapshot).toBeTruthy();
467+
468+ const listed = await listAppTools();
469+ expect(listed.map((tool) => tool.name).sort()).toEqual(["deal-pipeline-sync", "find-deal-docs"]);
470+
471+ const searchResult = await executeResult(
472+ 'const found = await tools.search({ namespace: "apps", query: "pipeline", limit: 10 }); return found.items.map((item) => item.path);',
473+ );
474+ expect(JSON.stringify(searchResult)).toContain("deal-pipeline-sync");
475+
476+ const schema = await requestJson<{ inputSchema: { properties?: Record<string, unknown> } }>(
477+ `/api/tools/schema?address=${encodeURIComponent(listed.find((tool) => tool.name === "deal-pipeline-sync")!.address)}`,
478+ { headers: jsonHeaders },
479+ );
480+ expect(schema.inputSchema.properties?.github).toMatchObject({
481+ type: "string",
482+ enum: [GITHUB_CONNECTION],
483+ });
484+
485+ const invoke = (await executeResult(
486+ callAppToolCode("deal-pipeline-sync", {
487+ github: GITHUB_CONNECTION,
488+ owner: OWNER,
489+ repo: REPO,
490+ }),
491+ )) as { result?: { ok?: boolean; data?: { synced?: number }; error?: unknown } };
492+ expect(invoke.result?.ok).toBe(true);
493+ expect(invoke.result?.data?.synced).toBe(2);
494+
495+ const ledger = await github.ledger.list();
496+ const issueList = ledger.find(
497+ (entry) =>
498+ entry.operationId === "issues/listForRepo" && entry.path === `/repos/${OWNER}/${REPO}/issues`,
499+ );
500+ expect(issueList?.identity.user?.login).toBe(OWNER);
501+
502+ const readDb = (await executeResult(callAppToolCode("find-deal-docs", { limit: 10 }))) as {
503+ result?: { ok?: boolean; data?: { documents?: readonly { name: string }[] } };
504+ };
505+ expect(readDb.result?.ok).toBe(true);
506+ expect(readDb.result?.data?.documents?.map((row) => row.name).sort()).toEqual([
507+ "Acme renewal diligence",
508+ "Beta pipeline memo",
509+ ]);
510+
511+ const upToDate = await syncSource();
512+ expect(upToDate.status).toBe("up-to-date");
513+ expect("snapshotId" in upToDate).toBe(false);
514+
515+ await putRepoFiles(github, token!, {
516+ ...initialFiles(),
517+ "tools/extra-tool.ts": extraToolSource,
518+ });
519+ const withExtra = await syncSource();
520+ expect(withExtra.status).toBe("published");
521+ expect(withExtra.snapshotId).not.toBe(firstSnapshot);
522+ expect((await listAppTools()).map((tool) => tool.name).sort()).toEqual([
523+ "deal-pipeline-sync",
524+ "extra-tool",
525+ "find-deal-docs",
526+ ]);
527+
528+ await putRepoFiles(github, token!, initialFiles());
529+ const removed = await syncSource();
530+ expect(removed.status).toBe("published");
531+ expect((await listAppTools()).map((tool) => tool.name).sort()).toEqual([
532+ "deal-pipeline-sync",
533+ "find-deal-docs",
534+ ]);
535+
536+ await putRepoFiles(github, token!, {
537+ ...initialFiles(),
538+ "workflows/x.ts": "export default {};",
539+ });
540+ const skipped = await syncSource();
541+ expect(skipped.status).toBe("published");
542+ expect(skipped.skipped).toEqual([{ path: "workflows/x.ts", reason: "not supported yet" }]);
543+ const afterSkipped = await listAppTools();
544+ expect(afterSkipped.map((tool) => tool.name).sort()).toEqual([
545+ "deal-pipeline-sync",
546+ "find-deal-docs",
547+ ]);
548+ expect(afterSkipped.some((tool) => tool.address.includes("workflow"))).toBe(false);
549+});
apps/host-selfhost/src/apps.ts+71−0
@@ -0,0 +1,71 @@
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/testing/test-app.ts+4−0
@@ -30,6 +30,8 @@ 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";
3335
3436 // ===========================================================================
3537 // Self-host TEST harness — the throwaway composition tests use to exercise the
@@ -202,6 +204,7 @@ export const makeSelfHostTestApp = async ( · 166 unmodified lines
202204 },
203205 extensions: {
204206 routes: [
207+ makeSelfHostAppsSyncRoute({ identity: options.identity, db: dbHandle }),
205208 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),
206209 ],
207210 },
@@ -217,6 +220,7 @@ export const makeSelfHostTestApp = async ( · 9 unmodified lines
217220 handler: web.handler,
218221 dispose: async () => {
219222 await web.dispose();
223+ await closeSelfHostAppsSubsystem();
220224 await sessionStore.close();
221225 await dbHandle.close();
222226 },
bun.lock+2−0
@@ -224,6 +224,7 @@ · 223 unmodified lines
224224 "@executor-js/execution": "workspace:*",
225225 "@executor-js/fumadb": "workspace:*",
226226 "@executor-js/host-mcp": "workspace:*",
227+ "@executor-js/plugin-apps": "workspace:*",
227228 "@executor-js/plugin-encrypted-secrets": "workspace:*",
228229 "@executor-js/plugin-google": "workspace:*",
229230 "@executor-js/plugin-graphql": "workspace:*",
@@ -246,6 +247,7 @@ · 16 unmodified lines
246247 },
247248 "devDependencies": {
248249 "@effect/vitest": "catalog:",
250+ "@executor-js/emulate": "^0.13.2",
249251 "@executor-js/vite-plugin": "workspace:*",
250252 "@tailwindcss/vite": "catalog:",
251253 "@tanstack/router-plugin": "^1.167.12",
packages/core/sdk/src/executor.ts+2−0
@@ -2071,6 +2071,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2070 unmodified lines
20712071
20722072 const result: ResolveToolsResult = yield* runtime.plugin
20732073 .resolveTools({
2074+ ctx: runtime.ctx,
20742075 integration: rowToIntegration(integrationRow),
20752076 config: decodeJsonColumn(integrationRow.config),
20762077 httpClientLayer: runtime.ctx.httpClientLayer,
@@ -3732,6 +3733,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1655 unmodified lines
37323733 items: (key) => providersItems(key),
37333734 },
37343735 oauth,
3736+ execute: (address, args) => execute(address, args),
37353737 transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect),
37363738 };
37373739
packages/core/sdk/src/plugin.ts+9−0
@@ -29,6 +29,7 @@ import type { · 28 unmodified lines
2929 ProviderKey,
3030 Subject,
3131 Tenant,
32+ ToolAddress,
3233 } from "./ids";
3334 import type { IntegrationDetectionResult } from "./types";
3435 import type {
@@ -38,6 +39,7 @@ import type { · 3 unmodified lines
3839 ElicitationResponse,
3940 } from "./elicitation";
4041 import type {
42+ ExecuteError,
4143 ConnectionNotFoundError,
4244 CredentialProviderNotRegisteredError,
4345 IntegrationNotFoundError,
@@ -249,6 +251,12 @@ export interface PluginCtx<TStore = unknown> { · 205 unmodified lines
249251 /** Shared OAuth service. */
250252 readonly oauth: OAuthService;
251253
254+ /** Invoke another catalog tool through the same executor request context:
255+ * policy, approval, credential resolution, and plugin dispatch all stay in
256+ * the core path. Intended for plugin sandboxes that expose higher-level
257+ * virtual tools over existing integration tools. */
258+ readonly execute: (address: ToolAddress, args: unknown) => Effect.Effect<unknown, ExecuteError>;
259+
252260 /** Run `effect` inside a FumaDB transaction (atomic across plugin storage +
253261 * core integration/tool writes). */
254262 readonly transaction: <A, E>(effect: Effect.Effect<A, E>) => Effect.Effect<A, E | StorageFailure>;
@@ -261,6 +269,7 @@ export interface PluginCtx<TStore = unknown> { · 6 unmodified lines
261269 // ---------------------------------------------------------------------------
262270
263271 export interface ResolveToolsInput<TStore = unknown> {
272+ readonly ctx?: PluginCtx<TStore>;
264273 /** The catalog record (public projection) whose connection is being resolved. */
265274 readonly integration: Integration;
266275 /** The plugin's stored opaque config for that integration. */
packages/plugins/apps/src/api.ts+11−0
@@ -13,6 +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";
1617 export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";
1718 export {
1819 BindingError,
@@ -20,3 +21,13 @@ export { · 1 unmodified line
2021 type ConnectionCandidate,
2122 type RoleBindings,
2223 } from "./plugin/bindings";
24+export {
25+ fetchGitHubSource,
26+ syncGitHubSource,
27+ GitHubSourceError,
28+ type GitHubSourceInput,
29+ type GitHubSourceSnapshot,
30+ type GitHubSyncResult,
31+ type SyncErrorData,
32+ type SyncGitHubSourceInput,
33+} from "./source/github-source";
packages/plugins/apps/src/index.ts+10−0
@@ -16,6 +16,16 @@ export { · 15 unmodified lines
1616 type PublishOutput,
1717 type PublishDeps,
1818 } from "./pipeline/publish";
19+export {
20+ fetchGitHubSource,
21+ syncGitHubSource,
22+ GitHubSourceError,
23+ type GitHubSourceInput,
24+ type GitHubSourceSnapshot,
25+ type GitHubSyncResult,
26+ type SyncErrorData,
27+ type SyncGitHubSourceInput,
28+} from "./source/github-source";
1929
2030 export { makeAppsRuntime, type AppsRuntime, type AppsRuntimeDeps } from "./plugin/runtime";
2131 export {
packages/plugins/apps/src/pipeline/descriptor.ts+16−1
@@ -7,7 +7,7 @@ · 6 unmodified lines
77
88 /** Descriptor schema version. Bumped on any breaking shape change. A reader
99 * refuses a descriptor from a version it does not understand. */
10−export const DESCRIPTOR_VERSION = 2 as const;
10+export const DESCRIPTOR_VERSION = 3 as const;
1111
1212 /** Where an entry came from: path + content hash. Lets a projection point back
1313 * at the exact source bytes without re-reading the snapshot, and makes the
@@ -31,6 +31,15 @@ export interface IntegrationDecl { · 17 unmodified lines
3131 readonly integration: string;
3232 }
3333
34+export interface GitHubSourceRef {
35+ readonly kind: "github";
36+ readonly repo: string;
37+ readonly ref: string;
38+ readonly upstreamSha: string;
39+}
40+
41+export type AppSourceRef = GitHubSourceRef;
42+
3443 export interface ToolDescriptor {
3544 /** Path identity, e.g. `issues-sync` (from `tools/issues-sync.ts`). */
3645 readonly name: string;
@@ -62,6 +71,8 @@ export const GUIDE_ENTRIES_KEY = "skills"; · 25 unmodified lines
6271 export interface AppDescriptor {
6372 readonly version: typeof DESCRIPTOR_VERSION;
6473 readonly scope: string;
74+ readonly description?: string;
75+ readonly source?: AppSourceRef;
6576 /** The snapshot (commit hash) this descriptor was extracted from. */
6677 readonly snapshotId: string;
6778 /** The toolchain that produced the compiled bundles. */
@@ -70,6 +81,10 @@ export interface AppDescriptor { · 2 unmodified lines
7081 readonly [FLOW_ENTRIES_KEY]: readonly DeferredDescriptor[];
7182 readonly ui: readonly DeferredDescriptor[];
7283 readonly [GUIDE_ENTRIES_KEY]: readonly DeferredDescriptor[];
84+ readonly skipped: readonly {
85+ readonly path: string;
86+ readonly reason: "not supported yet";
87+ }[];
7388 /** Shared JSON-schema `$defs` reachable from tool schemas. */
7489 readonly definitions?: Record<string, unknown>;
7590 }
packages/plugins/apps/src/pipeline/publish.ts+8−1
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 GUIDE_ENTRIES_KEY,
1111 stableStringify,
1212 type AppDescriptor,
13+ type AppSourceRef,
1314 type IntegrationDecl,
1415 type ModuleSourceRef,
1516 type ToolDescriptor,
@@ -73,6 +74,8 @@ export interface PublishInput { · 57 unmodified lines
7374 readonly scope: string;
7475 readonly files: FileSet;
7576 readonly commitMessage?: string;
77+ readonly description?: string;
78+ readonly source?: AppSourceRef;
7679 }
7780
7881 export interface PublishOutput {
@@ -219,14 +222,18 @@ const assemble = (deps: PublishDeps, files: FileSet): Effect.Effect<AssembledApp · 140 unmodified lines
219222 const descriptorBody = (
220223 scope: string,
221224 assembled: AssembledApp,
225+ input: Pick<PublishInput, "description" | "source">,
222226 ): Omit<AppDescriptor, "snapshotId"> => ({
223227 version: DESCRIPTOR_VERSION,
224228 scope,
229+ ...(input.description !== undefined ? { description: input.description } : {}),
230+ ...(input.source !== undefined ? { source: input.source } : {}),
225231 toolchain: toolchainRef(),
226232 tools: assembled.tools,
227233 [FLOW_ENTRIES_KEY]: [],
228234 ui: [],
229235 [GUIDE_ENTRIES_KEY]: [],
236+ skipped: assembled.skipped,
230237 });
231238
232239 export const publish = (
@@ -238,7 +245,7 @@ export const publish = ( · 5 unmodified lines
238245 if (overLimit) return yield* Effect.fail(overLimit);
239246
240247 const assembled = yield* assemble(deps, input.files);
241− const body = descriptorBody(input.scope, assembled);
248+ const body = descriptorBody(input.scope, assembled, input);
242249
243250 const scopeStore = yield* deps.artifactStore
244251 .forScope(input.scope)
packages/plugins/apps/src/plugin/apps-plugin.ts+136−37
@@ -4,10 +4,12 @@ import { · 3 unmodified lines
44 definePlugin,
55 tool,
66 ToolName,
7− ConnectionName,
87 IntegrationSlug,
98 AuthTemplateSlug,
109 connectionIdentifier,
10+ type ConnectionRef,
11+ type Owner,
12+ type PluginCtx,
1113 type ResolveToolsInput,
1214 type ResolveToolsResult,
1315 type InvokeToolInput,
@@ -18,6 +20,7 @@ import type { AppsRuntime } from "./runtime"; · 4 unmodified lines
1820 import { makeAppsStore } from "./store";
1921 import type { ClientResolver, ConnectionCandidate } from "./bindings";
2022 import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor";
23+import { syncGitHubSource, type GitHubSyncResult } from "../source/github-source";
2124
2225 export const APPS_INTEGRATION_SLUG = "apps";
2326 export const APPS_PLUGIN_ID = "apps";
@@ -42,6 +45,40 @@ const isRecord = (value: unknown): value is Record<string, unknown> => · 18 unmodified lines
4245
4346 const unique = (values: readonly string[]): readonly string[] => [...new Set(values)];
4447
48+const syncFailure = (message: string, path?: string): GitHubSyncResult => ({
49+ status: "failed",
50+ tools: [],
51+ skipped: [],
52+ errors: [
53+ {
54+ stage: "source",
55+ message,
56+ ...(path ? { diagnostics: [{ path, message }] } : {}),
57+ },
58+ ],
59+});
60+
61+const sourceScopeFor = (input: { repo: string; connection: string }): string =>
62+ String(connectionIdentifier(`github ${input.connection} ${input.repo}`, "githubSource"));
63+
64+const parseConnectionAddress = (address: string): ConnectionRef | null => {
65+ const parts = address.split(".");
66+ if (parts.length !== 4 || parts[0] !== "tools") return null;
67+ const [, integration, owner, name] = parts;
68+ if (!integration || !name) return null;
69+ if (owner !== "org" && owner !== "user") return null;
70+ return {
71+ owner: owner as Owner,
72+ integration: IntegrationSlug.make(integration),
73+ name: connectionIdentifier(name),
74+ };
75+};
76+
77+const configBaseUrl = (config: unknown): string | undefined =>
78+ isRecord(config) && typeof config.baseUrl === "string" && config.baseUrl.length > 0
79+ ? config.baseUrl
80+ : undefined;
81+
4582 const projectInputSchema = (
4683 schema: unknown,
4784 integrations: Readonly<Record<string, IntegrationDecl>>,
@@ -94,6 +131,49 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 46 unmodified lines
94131 Effect.map((mapped) => mapped ?? scopeFromConnection(connectionName)),
95132 );
96133
134+ const ensureCatalogConnection = (scope: string, ctx: PluginCtx<AppsStoreShape>) =>
135+ Effect.gen(function* () {
136+ const slug = IntegrationSlug.make(APPS_INTEGRATION_SLUG);
137+ const existing = yield* ctx.core.integrations
138+ .get(slug)
139+ .pipe(Effect.orElseSucceed(() => null));
140+ if (!existing) {
141+ yield* ctx.core.integrations.register({
142+ slug,
143+ name: "Apps",
144+ description: "User-authored, published custom tools.",
145+ config: {},
146+ canRemove: false,
147+ canRefresh: true,
148+ });
149+ }
150+ const rawName = connectionNameForScope(scope);
151+ const connName = connectionIdentifier(rawName);
152+ const conns = yield* ctx.connections
153+ .list({ integration: slug })
154+ .pipe(Effect.orElseSucceed(() => []));
155+ if (!conns.some((c) => String(c.name) === String(connName))) {
156+ yield* ctx.connections.create({
157+ owner: "user",
158+ name: connName,
159+ integration: slug,
160+ template: AuthTemplateSlug.make("none"),
161+ value: "",
162+ });
163+ }
164+ yield* runtime.deps.store
165+ .putScopeForConnection(String(rawName), scope)
166+ .pipe(Effect.orElseSucceed(() => undefined));
167+ yield* runtime.deps.store
168+ .putScopeForConnection(String(connName), scope)
169+ .pipe(Effect.orElseSucceed(() => undefined));
170+ return {
171+ scope,
172+ connection: String(connName),
173+ ref: { owner: "user" as const, integration: slug, name: connName },
174+ };
175+ });
176+
97177 return {
98178 id: APPS_PLUGIN_ID as "apps",
99179 packageName: "@executor-js/plugin-apps",
@@ -135,59 +215,78 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 35 unmodified lines
135215 Effect.gen(function* () {
136216 const scope =
137217 (args as { scope?: string } | undefined)?.scope ?? DEFAULT_CATALOG_SCOPE;
138− const slug = IntegrationSlug.make(APPS_INTEGRATION_SLUG);
139− const existing = yield* ctx.core.integrations
140− .get(slug)
141− .pipe(Effect.orElseSucceed(() => null));
142− if (!existing) {
143− yield* ctx.core.integrations.register({
144− slug,
145− name: "Apps",
146− description: "User-authored, published custom tools.",
147− config: {},
148− canRemove: false,
149− canRefresh: true,
150− });
218+ const connected = yield* ensureCatalogConnection(scope, ctx);
219+ return { scope, connection: connected.connection };
220+ }),
221+ }),
222+ tool<AppsStoreShape>({
223+ name: "sync_github_source",
224+ description: "Sync a GitHub repository containing custom tool source files.",
225+ execute: (args, { ctx }) =>
226+ Effect.gen(function* () {
227+ const payload = isRecord(args) ? args : {};
228+ const repo = typeof payload.repo === "string" ? payload.repo : "";
229+ const connection = typeof payload.connection === "string" ? payload.connection : "";
230+ const ref = typeof payload.ref === "string" ? payload.ref : undefined;
231+ const explicitScope = typeof payload.scope === "string" ? payload.scope : undefined;
232+ if (!repo) return syncFailure('sync_github_source requires "repo"');
233+ if (!connection) return syncFailure('sync_github_source requires "connection"');
234+
235+ const sourceConnection = parseConnectionAddress(connection);
236+ if (!sourceConnection) {
237+ return syncFailure(
238+ `GitHub connection must be a connection address like tools.github.user.main; got "${connection}"`,
239+ connection,
240+ );
151241 }
152− const connName = ConnectionName.make(connectionNameForScope(scope));
153− const conns = yield* ctx.connections
154− .list({ integration: slug })
155− .pipe(Effect.orElseSucceed(() => []));
156− if (!conns.some((c) => String(c.name) === String(connName))) {
157− yield* ctx.connections.create({
158− owner: "user",
159− name: connName,
160− integration: slug,
161− template: AuthTemplateSlug.make("none"),
162− value: "",
163− });
242+ if (String(sourceConnection.integration) !== "github") {
243+ return syncFailure(
244+ `GitHub source sync requires a github connection; got "${sourceConnection.integration}"`,
245+ connection,
246+ );
164247 }
165− yield* runtime.deps.store
166− .putScopeForConnection(String(connName), scope)
167− .pipe(Effect.orElseSucceed(() => undefined));
168− const normalized = connectionIdentifier(String(connName));
169− if (normalized !== String(connName)) {
170− yield* runtime.deps.store
171− .putScopeForConnection(normalized, scope)
172− .pipe(Effect.orElseSucceed(() => undefined));
248+ const existing = yield* ctx.connections
249+ .get(sourceConnection)
250+ .pipe(Effect.orElseSucceed(() => null));
251+ if (!existing) return syncFailure(`GitHub connection not found: ${connection}`);
252+ const token = yield* ctx.connections
253+ .resolveValue(sourceConnection)
254+ .pipe(Effect.orElseSucceed(() => null));
255+ const integration = yield* ctx.core.integrations
256+ .get(sourceConnection.integration)
257+ .pipe(Effect.orElseSucceed(() => null));
258+ const scope = explicitScope ?? sourceScopeFor({ repo, connection });
259+ const result = yield* syncGitHubSource({
260+ runtime,
261+ scope,
262+ repo,
263+ ref,
264+ token,
265+ baseUrl: configBaseUrl(integration?.config),
266+ });
267+ if (result.status === "published") {
268+ const catalog = yield* ensureCatalogConnection(scope, ctx);
269+ yield* ctx.connections.refresh(catalog.ref).pipe(Effect.orElseSucceed(() => []));
173270 }
174− return { scope, connection: String(connName) };
271+ return result;
175272 }),
176273 }),
177274 ],
178275 },
179276 ],
180277
181− resolveTools: ({ connection }: ResolveToolsInput<AppsStoreShape>) =>
278+ resolveTools: ({ ctx, connection }: ResolveToolsInput<AppsStoreShape>) =>
182279 Effect.gen(function* () {
183280 const scope = yield* scopeFor(String(connection.name));
184281 const descriptor = yield* runtime.getDescriptor(scope);
185282 if (!descriptor) return { tools: [] } satisfies ResolveToolsResult;
283+ const resolver =
284+ makeResolver && ctx ? makeResolver({ ctx, scope, tool: "*" }) : runtime.deps.resolver;
186285 const tools: ToolDef[] = [];
187286 for (const t of descriptor.tools) {
188287 const byRole: Record<string, readonly ConnectionCandidate[]> = {};
189288 for (const [role, decl] of Object.entries(t.integrations)) {
190− byRole[role] = yield* runtime.deps.resolver
289+ byRole[role] = yield* resolver
191290 .listConnections({ integration: decl.integration })
192291 .pipe(Effect.orElseSucceed(() => []));
193292 }
packages/plugins/apps/src/plugin/runtime.ts+5−1
@@ -13,7 +13,7 @@ import { · 12 unmodified lines
1313 type PublishOutput,
1414 } from "../pipeline/publish";
1515 import { PublishError } from "../pipeline/discover";
16−import type { AppDescriptor, ToolDescriptor } from "../pipeline/descriptor";
16+import type { AppDescriptor, AppSourceRef, ToolDescriptor } from "../pipeline/descriptor";
1717 import { bundleEntry } from "../pipeline/bundle";
1818 import {
1919 buildBridge,
@@ -42,6 +42,8 @@ export interface AppsRuntime { · 22 unmodified lines
4242 readonly scope: string;
4343 readonly files: ReadonlyMap<string, string>;
4444 readonly message?: string;
45+ readonly description?: string;
46+ readonly source?: AppSourceRef;
4547 }) => Effect.Effect<PublishOutput, PublishError>;
4648 readonly getDescriptor: (scope: string) => Effect.Effect<AppDescriptor | null>;
4749 /** Re-derive the published-descriptor pointer from the latest committed
@@ -264,6 +266,8 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 216 unmodified lines
264266 scope: input.scope,
265267 files: input.files,
266268 commitMessage: input.message,
269+ description: input.description,
270+ source: input.source,
267271 },
268272 );
269273 yield* putDescriptorPointer(out.descriptor);
packages/plugins/apps/src/source/github-source.test.ts+211−0
@@ -0,0 +1,211 @@
1+import { mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+
5+import { describe, expect, it } from "vitest";
6+import { Effect } from "effect";
7+
8+import { makeSelfHostAppsRuntime } from "../plugin/self-host-runtime";
9+import { makeInMemoryAppsStore, makeTestResolver } from "../testing";
10+import { PUBLISH_LIMITS } from "../pipeline/publish";
11+import { fetchGitHubSource, syncGitHubSource } from "./github-source";
12+
13+const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
14+
15+const toolSource = (name = "ok"): string => `import { defineTool } from "executor:app";
16+import { z } from "zod";
17+export default defineTool({
18+ description: "${name}",
19+ input: z.object({ value: z.string().default("${name}") }),
20+ async handler(input) { return { value: input.value }; },
21+});`;
22+
23+const makeRuntime = () =>
24+ makeSelfHostAppsRuntime({
25+ dataDir: mkdtempSync(join(tmpdir(), "apps-gh-src-")),
26+ store: makeInMemoryAppsStore(),
27+ resolver: makeTestResolver({}),
28+ inMemory: true,
29+ });
30+
31+const json = (value: unknown, status = 200): Response =>
32+ new Response(JSON.stringify(value), {
33+ status,
34+ headers: { "content-type": "application/json" },
35+ });
36+
37+const makeGitHubFetch = (input: {
38+ readonly files: ReadonlyMap<string, string>;
39+ readonly upstreamSha?: string;
40+ readonly treeSha?: string;
41+}) => {
42+ const repoPath = "/repos/acme/tools";
43+ const upstreamSha = input.upstreamSha ?? "commit-1";
44+ const treeSha = input.treeSha ?? "tree-1";
45+ const blobBySha = new Map<string, { path: string; content: string }>();
46+ let index = 0;
47+ for (const [path, content] of input.files) {
48+ blobBySha.set(`blob-${++index}`, { path, content });
49+ }
50+ let blobCalls = 0;
51+ const fetch = (async (rawUrl: string) => {
52+ const url = new URL(rawUrl);
53+ if (url.pathname === repoPath) return json({ default_branch: "main" });
54+ if (url.pathname === `${repoPath}/git/ref/heads%2Fmain`) {
55+ return json({ object: { sha: upstreamSha } });
56+ }
57+ if (url.pathname === `${repoPath}/git/commits/${upstreamSha}`) {
58+ return json({ sha: upstreamSha, tree: { sha: treeSha } });
59+ }
60+ if (url.pathname === `${repoPath}/git/trees/${treeSha}`) {
61+ return json({
62+ tree: [...blobBySha.entries()].map(([sha, blob]) => ({
63+ path: blob.path,
64+ type: "blob",
65+ sha,
66+ size: Buffer.byteLength(blob.content, "utf8"),
67+ })),
68+ });
69+ }
70+ const blobPrefix = `${repoPath}/git/blobs/`;
71+ if (url.pathname.startsWith(blobPrefix)) {
72+ blobCalls++;
73+ const sha = decodeURIComponent(url.pathname.slice(blobPrefix.length));
74+ const blob = blobBySha.get(sha);
75+ if (!blob) return json({ message: "not found" }, 404);
76+ return json({
77+ encoding: "base64",
78+ content: Buffer.from(blob.content, "utf8").toString("base64"),
79+ });
80+ }
81+ return json({ message: "not found" }, 404);
82+ }) as typeof globalThis.fetch;
83+ return {
84+ fetch,
85+ blobCalls: () => blobCalls,
86+ };
87+};
88+
89+describe("GitHub custom-tools source", () => {
90+ it("fetches a repo fileset and publishes provenance, description, and skipped entries", async () => {
91+ const files = new Map<string, string>([
92+ [
93+ "executor.json",
94+ JSON.stringify({
95+ $schema: "https://example.test/schema",
96+ description: "Acme tools",
97+ ignored: true,
98+ }),
99+ ],
100+ ["tools/hello.ts", toolSource("hello")],
101+ ["workflows/deferred.ts", "export default {};"],
102+ ["docs/readme.md", "ignored"],
103+ ]);
104+ const github = makeGitHubFetch({ files, upstreamSha: "commit-a" });
105+ const snapshot = await run(fetchGitHubSource({ repo: "acme/tools", fetch: github.fetch }));
106+ expect([...snapshot.files.keys()].sort()).toEqual([
107+ "executor.json",
108+ "tools/hello.ts",
109+ "workflows/deferred.ts",
110+ ]);
111+ expect(snapshot.description).toBe("Acme tools");
112+
113+ const host = makeRuntime();
114+ const result = await run(
115+ syncGitHubSource({
116+ runtime: host.runtime,
117+ scope: "githubTools",
118+ repo: "acme/tools",
119+ fetch: github.fetch,
120+ }),
121+ );
122+ expect(result.status).toBe("published");
123+ expect(result.tools).toEqual(["hello"]);
124+ expect(result.skipped).toEqual([
125+ { path: "workflows/deferred.ts", reason: "not supported yet" },
126+ ]);
127+ const descriptor = await run(host.runtime.getDescriptor("githubTools"));
128+ expect(descriptor?.description).toBe("Acme tools");
129+ expect(descriptor?.source).toEqual({
130+ kind: "github",
131+ repo: "acme/tools",
132+ ref: "main",
133+ upstreamSha: "commit-a",
134+ });
135+ await host.close();
136+ });
137+
138+ it("reports up-to-date when the upstream commit SHA is unchanged", async () => {
139+ const github = makeGitHubFetch({
140+ files: new Map([["tools/hello.ts", toolSource("hello")]]),
141+ upstreamSha: "same-sha",
142+ });
143+ const host = makeRuntime();
144+ const first = await run(
145+ syncGitHubSource({
146+ runtime: host.runtime,
147+ scope: "githubTools",
148+ repo: "acme/tools",
149+ fetch: github.fetch,
150+ }),
151+ );
152+ const second = await run(
153+ syncGitHubSource({
154+ runtime: host.runtime,
155+ scope: "githubTools",
156+ repo: "acme/tools",
157+ fetch: github.fetch,
158+ }),
159+ );
160+ expect(first.status).toBe("published");
161+ expect(second).toEqual({
162+ status: "up-to-date",
163+ upstreamSha: "same-sha",
164+ tools: ["hello"],
165+ skipped: [],
166+ });
167+ await host.close();
168+ });
169+
170+ it("rejects oversized trees before fetching blobs", async () => {
171+ const files = new Map<string, string>();
172+ for (let i = 0; i < PUBLISH_LIMITS.maxFiles + 1; i++) {
173+ files.set(`tools/t${i}.ts`, toolSource(`t${i}`));
174+ }
175+ const github = makeGitHubFetch({ files });
176+ const exit = await Effect.runPromiseExit(
177+ fetchGitHubSource({ repo: "acme/tools", fetch: github.fetch }),
178+ );
179+ expect(exit._tag).toBe("Failure");
180+ expect(JSON.stringify(exit)).toContain("exceeding the limit");
181+ expect(github.blobCalls()).toBe(0);
182+ });
183+
184+ it("returns typed failure data for publish errors", async () => {
185+ const github = makeGitHubFetch({
186+ files: new Map([
187+ [
188+ "tools/bad.ts",
189+ `import { defineTool } from "executor:app";
190+import { chunk } from "lodash";
191+export default defineTool({ description: "bad", input: { type: "object" }, async handler(){ return chunk([1], 1); } });`,
192+ ],
193+ ]),
194+ upstreamSha: "bad-sha",
195+ });
196+ const host = makeRuntime();
197+ const result = await run(
198+ syncGitHubSource({
199+ runtime: host.runtime,
200+ scope: "githubTools",
201+ repo: "acme/tools",
202+ fetch: github.fetch,
203+ }),
204+ );
205+ expect(result.status).toBe("failed");
206+ expect(result.upstreamSha).toBe("bad-sha");
207+ expect(result.errors?.[0]?.stage).toBe("bundle");
208+ expect(result.errors?.[0]?.message).toContain('bare import "lodash" is not allowed');
209+ await host.close();
210+ });
211+});
packages/plugins/apps/src/source/github-source.ts+402−0
@@ -0,0 +1,402 @@
1+import { Data, Effect, Result } from "effect";
2+
3+import { PublishError, type FileDiagnostic, type SkippedArtifact } from "../pipeline/discover";
4+import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish";
5+import type { AppSourceRef } from "../pipeline/descriptor";
6+import type { AppsRuntime } from "../plugin/runtime";
7+import type { FileSet, SnapshotId } from "../seams/artifact-store";
8+
9+export interface GitHubSourceInput {
10+ readonly repo: string;
11+ readonly ref?: string;
12+ readonly token?: string | null;
13+ readonly baseUrl?: string;
14+ readonly fetch?: typeof globalThis.fetch;
15+}
16+
17+export interface GitHubSourceSnapshot {
18+ readonly files: FileSet;
19+ readonly repo: string;
20+ readonly ref: string;
21+ readonly upstreamSha: string;
22+ readonly description?: string;
23+}
24+
25+export class GitHubSourceError extends Data.TaggedError("GitHubSourceError")<{
26+ readonly message: string;
27+ readonly status?: number;
28+ readonly path?: string;
29+ readonly cause?: unknown;
30+}> {}
31+
32+export interface SyncErrorData {
33+ readonly stage: "source" | "discover" | "bundle" | "collect" | "project";
34+ readonly message: string;
35+ readonly diagnostics?: readonly FileDiagnostic[];
36+}
37+
38+export type GitHubSyncResult =
39+ | {
40+ readonly status: "published";
41+ readonly snapshotId: SnapshotId;
42+ readonly upstreamSha: string;
43+ readonly tools: readonly string[];
44+ readonly skipped: readonly SkippedArtifact[];
45+ readonly errors?: undefined;
46+ }
47+ | {
48+ readonly status: "up-to-date";
49+ readonly upstreamSha: string;
50+ readonly tools: readonly string[];
51+ readonly skipped: readonly SkippedArtifact[];
52+ readonly errors?: undefined;
53+ }
54+ | {
55+ readonly status: "failed";
56+ readonly upstreamSha?: string;
57+ readonly tools: readonly string[];
58+ readonly skipped: readonly SkippedArtifact[];
59+ readonly errors: readonly SyncErrorData[];
60+ };
61+
62+export interface SyncGitHubSourceInput extends GitHubSourceInput {
63+ readonly runtime: AppsRuntime;
64+ readonly scope: string;
65+}
66+
67+const MATERIALIZED_RE = /^(tools|workflows|ui|skills)\//;
68+
69+const trimBaseUrl = (baseUrl: string): string => baseUrl.replace(/\/+$/, "");
70+
71+const repoParts = (repo: string): { owner: string; name: string } | null => {
72+ const [owner, name, ...rest] = repo.split("/");
73+ if (!owner || !name || rest.length > 0) return null;
74+ return { owner, name };
75+};
76+
77+const encodedRepoPath = (repo: string): Effect.Effect<string, GitHubSourceError> => {
78+ const parsed = repoParts(repo);
79+ if (!parsed) {
80+ return Effect.fail(
81+ new GitHubSourceError({
82+ message: `GitHub repo must be "owner/name"; got "${repo}"`,
83+ path: repo,
84+ }),
85+ );
86+ }
87+ return Effect.succeed(`${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}`);
88+};
89+
90+const materializes = (path: string): boolean =>
91+ path === "executor.json" || MATERIALIZED_RE.test(path);
92+
93+const isRecord = (value: unknown): value is Record<string, unknown> =>
94+ value !== null && typeof value === "object" && !Array.isArray(value);
95+
96+const asString = (value: unknown): string | undefined =>
97+ typeof value === "string" && value.length > 0 ? value : undefined;
98+
99+const requestJson = <A>(
100+ input: GitHubSourceInput,
101+ path: string,
102+): Effect.Effect<A, GitHubSourceError> =>
103+ Effect.tryPromise({
104+ try: async () => {
105+ const fetchImpl = input.fetch ?? globalThis.fetch;
106+ const headers: Record<string, string> = {
107+ accept: "application/vnd.github+json",
108+ "user-agent": "executor-apps-github-source",
109+ };
110+ if (input.token) headers.authorization = `Bearer ${input.token}`;
111+ const response = await fetchImpl(
112+ `${trimBaseUrl(input.baseUrl ?? "https://api.github.com")}${path}`,
113+ {
114+ headers,
115+ },
116+ );
117+ if (!response.ok) {
118+ throw new GitHubSourceError({
119+ message: `GitHub request failed: GET ${path} -> ${response.status}`,
120+ status: response.status,
121+ path,
122+ });
123+ }
124+ return (await response.json()) as A;
125+ },
126+ catch: (cause) =>
127+ cause instanceof GitHubSourceError
128+ ? cause
129+ : new GitHubSourceError({
130+ message: `GitHub request failed: GET ${path}`,
131+ path,
132+ cause,
133+ }),
134+ });
135+
136+interface RepoResponse {
137+ readonly default_branch?: unknown;
138+}
139+
140+interface CommitResponse {
141+ readonly sha?: unknown;
142+ readonly commit?: {
143+ readonly tree?: {
144+ readonly sha?: unknown;
145+ };
146+ };
147+}
148+
149+interface GitCommitResponse {
150+ readonly sha?: unknown;
151+ readonly tree?: {
152+ readonly sha?: unknown;
153+ };
154+}
155+
156+interface RefResponse {
157+ readonly object?: {
158+ readonly sha?: unknown;
159+ };
160+}
161+
162+interface TreeEntry {
163+ readonly path?: unknown;
164+ readonly type?: unknown;
165+ readonly sha?: unknown;
166+ readonly size?: unknown;
167+}
168+
169+interface TreeResponse {
170+ readonly tree?: readonly TreeEntry[];
171+ readonly truncated?: unknown;
172+}
173+
174+interface BlobResponse {
175+ readonly content?: unknown;
176+ readonly encoding?: unknown;
177+}
178+
179+const commitTreeSha = (commit: CommitResponse | GitCommitResponse): string | undefined => {
180+ if ("commit" in commit) return asString(commit.commit?.tree?.sha);
181+ if ("tree" in commit) return asString(commit.tree?.sha);
182+ return undefined;
183+};
184+
185+const limitError = (diagnostics: readonly FileDiagnostic[]): PublishError =>
186+ new PublishError({
187+ message: `publish payload exceeds limits (${diagnostics.length} problem(s))`,
188+ stage: "discover",
189+ diagnostics,
190+ });
191+
192+const checkTreeLimits = (entries: readonly TreeEntry[]): PublishError | null => {
193+ const diagnostics: FileDiagnostic[] = [];
194+ if (entries.length > PUBLISH_LIMITS.maxFiles) {
195+ diagnostics.push({
196+ path: "",
197+ message: `publish has ${entries.length} files, exceeding the limit of ${PUBLISH_LIMITS.maxFiles}`,
198+ });
199+ }
200+ let total = 0;
201+ for (const entry of entries) {
202+ const path = String(entry.path ?? "");
203+ const size = typeof entry.size === "number" ? entry.size : 0;
204+ total += size;
205+ if (size > PUBLISH_LIMITS.maxFileBytes) {
206+ diagnostics.push({
207+ path,
208+ message: `file is ${size} bytes, exceeding the per-file limit of ${PUBLISH_LIMITS.maxFileBytes} bytes`,
209+ });
210+ }
211+ }
212+ if (total > PUBLISH_LIMITS.maxTotalBytes) {
213+ diagnostics.push({
214+ path: "",
215+ message: `publish total is ${total} bytes, exceeding the total limit of ${PUBLISH_LIMITS.maxTotalBytes} bytes`,
216+ });
217+ }
218+ return diagnostics.length === 0 ? null : limitError(diagnostics);
219+};
220+
221+const decodeBlob = (path: string, blob: BlobResponse): Effect.Effect<string, GitHubSourceError> =>
222+ Effect.try({
223+ try: () => {
224+ const encoding = asString(blob.encoding);
225+ const content = asString(blob.content);
226+ if (!content || encoding !== "base64") {
227+ throw new GitHubSourceError({
228+ message: `GitHub blob ${path} did not return base64 content`,
229+ path,
230+ });
231+ }
232+ return Buffer.from(content.replace(/\s/g, ""), "base64").toString("utf8");
233+ },
234+ catch: (cause) =>
235+ cause instanceof GitHubSourceError
236+ ? cause
237+ : new GitHubSourceError({ message: `Failed to decode GitHub blob ${path}`, path, cause }),
238+ });
239+
240+const executorDescription = (
241+ files: FileSet,
242+): Effect.Effect<string | undefined, GitHubSourceError> =>
243+ Effect.try({
244+ try: () => {
245+ const raw = files.get("executor.json");
246+ if (!raw) return undefined;
247+ const parsed = JSON.parse(raw) as unknown;
248+ if (!isRecord(parsed)) return undefined;
249+ const description = parsed.description;
250+ return typeof description === "string" ? description : undefined;
251+ },
252+ catch: (cause) =>
253+ new GitHubSourceError({
254+ message: "executor.json is not valid JSON",
255+ path: "executor.json",
256+ cause,
257+ }),
258+ });
259+
260+export const fetchGitHubSource = (
261+ input: GitHubSourceInput,
262+): Effect.Effect<GitHubSourceSnapshot, GitHubSourceError | PublishError> =>
263+ Effect.gen(function* () {
264+ const repoPath = yield* encodedRepoPath(input.repo);
265+ const repo = yield* requestJson<RepoResponse>(input, `/repos/${repoPath}`);
266+ const ref = input.ref ?? asString(repo.default_branch) ?? "main";
267+ const branchRef = yield* requestJson<RefResponse>(
268+ input,
269+ `/repos/${repoPath}/git/ref/${encodeURIComponent(`heads/${ref}`)}`,
270+ ).pipe(Effect.result);
271+ const commitPath =
272+ Result.isSuccess(branchRef) && asString(branchRef.success.object?.sha)
273+ ? `/repos/${repoPath}/git/commits/${encodeURIComponent(String(branchRef.success.object?.sha))}`
274+ : `/repos/${repoPath}/commits/${encodeURIComponent(ref)}`;
275+ const commit = yield* requestJson<CommitResponse | GitCommitResponse>(input, commitPath);
276+ const upstreamSha = asString(commit.sha);
277+ const treeSha = commitTreeSha(commit);
278+ if (!upstreamSha || !treeSha) {
279+ return yield* new GitHubSourceError({
280+ message: `GitHub commit ${ref} did not include a commit SHA and tree SHA`,
281+ path: commitPath,
282+ });
283+ }
284+
285+ const tree = yield* requestJson<TreeResponse>(
286+ input,
287+ `/repos/${repoPath}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`,
288+ );
289+ if (tree.truncated === true) {
290+ return yield* new GitHubSourceError({
291+ message: "GitHub returned a truncated repository tree; custom tools source is too large",
292+ path: `/repos/${repoPath}/git/trees/${treeSha}`,
293+ });
294+ }
295+ const entries = (tree.tree ?? []).filter(
296+ (entry) =>
297+ entry.type === "blob" &&
298+ typeof entry.path === "string" &&
299+ typeof entry.sha === "string" &&
300+ materializes(entry.path),
301+ );
302+ const treeLimitError = checkTreeLimits(entries);
303+ if (treeLimitError) return yield* Effect.fail(treeLimitError);
304+
305+ const files = new Map<string, string>();
306+ for (const entry of entries) {
307+ const path = String(entry.path);
308+ const blob = yield* requestJson<BlobResponse>(
309+ input,
310+ `/repos/${repoPath}/git/blobs/${encodeURIComponent(String(entry.sha))}`,
311+ );
312+ files.set(path, yield* decodeBlob(path, blob));
313+ }
314+ const payloadLimitError = enforcePublishLimits(files);
315+ if (payloadLimitError) return yield* Effect.fail(payloadLimitError);
316+
317+ return {
318+ files,
319+ repo: input.repo,
320+ ref,
321+ upstreamSha,
322+ description: yield* executorDescription(files),
323+ };
324+ });
325+
326+const publishErrorToSyncError = (error: PublishError): SyncErrorData => ({
327+ stage: error.stage,
328+ message: error.message,
329+ diagnostics: error.diagnostics,
330+});
331+
332+const sourceErrorToSyncError = (error: GitHubSourceError): SyncErrorData => ({
333+ stage: "source",
334+ message: error.message,
335+ diagnostics: error.path ? [{ path: error.path, message: error.message }] : [],
336+});
337+
338+const sourceRef = (snapshot: GitHubSourceSnapshot): AppSourceRef => ({
339+ kind: "github",
340+ repo: snapshot.repo,
341+ ref: snapshot.ref,
342+ upstreamSha: snapshot.upstreamSha,
343+});
344+
345+export const syncGitHubSource = (input: SyncGitHubSourceInput): Effect.Effect<GitHubSyncResult> =>
346+ Effect.gen(function* () {
347+ const fetched = yield* fetchGitHubSource(input).pipe(Effect.result);
348+ if (Result.isFailure(fetched)) {
349+ const error = fetched.failure;
350+ return {
351+ status: "failed",
352+ tools: [],
353+ skipped: [],
354+ errors: [
355+ error instanceof PublishError
356+ ? publishErrorToSyncError(error)
357+ : sourceErrorToSyncError(error),
358+ ],
359+ } satisfies GitHubSyncResult;
360+ }
361+ const snapshot = fetched.success;
362+ const current = yield* input.runtime.getDescriptor(input.scope);
363+ if (
364+ current?.source?.kind === "github" &&
365+ current.source.repo === snapshot.repo &&
366+ current.source.upstreamSha === snapshot.upstreamSha
367+ ) {
368+ return {
369+ status: "up-to-date",
370+ upstreamSha: snapshot.upstreamSha,
371+ tools: current.tools.map((tool) => tool.name),
372+ skipped: current.skipped ?? [],
373+ } satisfies GitHubSyncResult;
374+ }
375+
376+ const published = yield* input.runtime
377+ .publish({
378+ scope: input.scope,
379+ files: snapshot.files,
380+ description: snapshot.description,
381+ source: sourceRef(snapshot),
382+ message: `sync ${snapshot.repo}@${snapshot.upstreamSha}`,
383+ })
384+ .pipe(Effect.result);
385+ if (Result.isFailure(published)) {
386+ return {
387+ status: "failed",
388+ upstreamSha: snapshot.upstreamSha,
389+ tools: [],
390+ skipped: [],
391+ errors: [publishErrorToSyncError(published.failure)],
392+ } satisfies GitHubSyncResult;
393+ }
394+
395+ return {
396+ status: "published",
397+ snapshotId: published.success.snapshotId,
398+ upstreamSha: snapshot.upstreamSha,
399+ tools: published.success.descriptor.tools.map((tool) => tool.name),
400+ skipped: published.success.skipped,
401+ } satisfies GitHubSyncResult;
402+ });