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
8import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";8import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
9 9
10import { resolveSecretKey } from "./src/config";10import { resolveSecretKey } from "./src/config";
11import { getSelfHostAppsSubsystem } from "./src/apps";
11 12
12// ---------------------------------------------------------------------------13// ---------------------------------------------------------------------------
13// Single source of truth for the self-hosted app's plugin list.14// Single source of truth for the self-hosted app's plugin list.
@@ -36,6 +37,7 @@ export default defineExecutorConfig({ · 22 unmodified lines
36 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),37 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
37 graphqlHttpPlugin(),38 graphqlHttpPlugin(),
38 toolkitsPlugin({ activeToolkitSlug }),39 toolkitsPlugin({ activeToolkitSlug }),
40 getSelfHostAppsSubsystem().plugin,
39 // First writable secret provider -> the default for `secrets.set`.41 // First writable secret provider -> the default for `secrets.set`.
40 encryptedSecretsPlugin({ key: resolveSecretKey() }),42 encryptedSecretsPlugin({ key: resolveSecretKey() }),
41 ] as const,43 ] as const,
apps/host-selfhost/package.json+2−0
@@ -26,6 +26,7 @@ · 25 unmodified lines
26 "@executor-js/execution": "workspace:*",26 "@executor-js/execution": "workspace:*",
27 "@executor-js/fumadb": "workspace:*",27 "@executor-js/fumadb": "workspace:*",
28 "@executor-js/host-mcp": "workspace:*",28 "@executor-js/host-mcp": "workspace:*",
29 "@executor-js/plugin-apps": "workspace:*",
29 "@executor-js/plugin-encrypted-secrets": "workspace:*",30 "@executor-js/plugin-encrypted-secrets": "workspace:*",
30 "@executor-js/plugin-google": "workspace:*",31 "@executor-js/plugin-google": "workspace:*",
31 "@executor-js/plugin-graphql": "workspace:*",32 "@executor-js/plugin-graphql": "workspace:*",
@@ -48,6 +49,7 @@ · 16 unmodified lines
48 },49 },
49 "devDependencies": {50 "devDependencies": {
50 "@effect/vitest": "catalog:",51 "@effect/vitest": "catalog:",
52 "@executor-js/emulate": "^0.13.2",
51 "@executor-js/vite-plugin": "workspace:*",53 "@executor-js/vite-plugin": "workspace:*",
52 "@tailwindcss/vite": "catalog:",54 "@tailwindcss/vite": "catalog:",
53 "@tanstack/router-plugin": "^1.167.12",55 "@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
22import { selfHostPlugins } from "./plugins";22import { selfHostPlugins } from "./plugins";
23import { ErrorCaptureLive } from "./observability";23import { ErrorCaptureLive } from "./observability";
24import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login";24import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login";
25import { closeSelfHostAppsSubsystem } from "./apps";
26import { makeSelfHostAppsSyncRoute } from "./apps-route";
25 27
26// ===========================================================================28// ===========================================================================
27// The self-hosted Executor app, as ONE `ExecutorApp.make` call.29// The self-hosted Executor app, as ONE `ExecutorApp.make` call.
@@ -119,6 +121,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 91 unmodified lines
119 makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),121 makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
120 // Public system API: /api/health + /api/setup-status (unauthenticated).122 // Public system API: /api/health + /api/setup-status (unauthenticated).
121 makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),123 makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
124 makeSelfHostAppsSyncRoute({ identity: identityLayer, db: dbHandle }),
122 // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths).125 // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths).
123 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),126 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),
124 ],127 ],
@@ -139,6 +142,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 14 unmodified lines
139 toWebHandler,142 toWebHandler,
140 betterAuth,143 betterAuth,
141 closeDb: async () => {144 closeDb: async () => {
145 await closeSelfHostAppsSubsystem();
142 await mcp.close();146 await mcp.close();
143 await dbHandle.close();147 await dbHandle.close();
144 },148 },
apps/host-selfhost/src/apps-resolver.ts+130−0
@@ -0,0 +1,130 @@
1import { Effect } from "effect";
2
3import {
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";
13import {
14 BindingError,
15 type ClientResolver,
16 type ConnectionCandidate,
17} from "@executor-js/plugin-apps/api";
18
19const 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
32const 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
45const executeErrorMessage = (cause: ExecuteError): string =>
46 typeof cause.message === "string" && cause.message.length > 0 ? cause.message : cause._tag;
47
48const 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
60export 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 @@
1import { HttpEffect, HttpRouter } from "effect/unstable/http";
2import { Data, Effect, Layer } from "effect";
3
4import {
5 IdentityProvider,
6 RequestOrgSlug,
7 RequestWebOrigin,
8 makeScopedExecutor,
9 type Principal,
10} from "@executor-js/api/server";
11import { ToolAddress } from "@executor-js/sdk";
12
13import { SelfHostScopedExecutorSeams } from "./execution";
14import { SelfHostDb, type SelfHostDbHandle } from "./db/self-host-db";
15import type { SelfHostPlugins } from "./plugins";
16
17class AppsSyncRouteError extends Data.TaggedError("AppsSyncRouteError")<{
18 readonly status: number;
19 readonly message: string;
20}> {}
21
22const jsonResponse = (body: unknown, status = 200): Response =>
23 new Response(JSON.stringify(body), {
24 status,
25 headers: { "content-type": "application/json" },
26 });
27
28const parseJson = (request: Request): Effect.Effect<unknown, AppsSyncRouteError> =>
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
38const 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
49export interface SelfHostAppsSyncRouteDeps {
50 readonly identity: Layer.Layer<IdentityProvider>;
51 readonly db: SelfHostDbHandle;
52}
53
54export 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 @@
1import { randomBytes } from "node:crypto";
2import { mkdtempSync } from "node:fs";
3import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4import { createServer as createNetServer } from "node:net";
5import { tmpdir } from "node:os";
6import { join } from "node:path";
7
8import { afterAll, beforeAll, expect, test } from "@effect/vitest";
9import { createEmulator, type Emulator } from "@executor-js/emulate";
10
11const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-"));
12const dbPath = join(dataDir, "data.db");
13process.env.EXECUTOR_DATA_DIR = dataDir;
14process.env.EXECUTOR_SECRET_KEY = "apps-wire-secret-key";
15process.env.EXECUTOR_ALLOW_LOCAL_NETWORK = "true";
16
17const TEST_USER = "apps-user";
18const TEST_ORG = "apps-org";
19const OWNER = "syncer";
20const REPO = "custom-tools";
21const REPO_FULL_NAME = `${OWNER}/${REPO}`;
22const GITHUB_CONNECTION = "tools.github.user.main";
23
24interface TestHttpServer {
25 readonly baseUrl: string;
26 readonly close: () => Promise<void>;
27}
28
29interface 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
38interface ToolRow {
39 readonly address: string;
40 readonly name: string;
41 readonly integration: string;
42}
43
44interface 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
55const bearerTemplate = {
56 slug: "bearer",
57 type: "apiKey",
58 label: "Bearer token",
59 headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
60};
61
62const jsonHeaders = {
63 "content-type": "application/json",
64 "x-test-user": TEST_USER,
65 "x-test-org": TEST_ORG,
66};
67
68const 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
79const 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
89const 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
106const 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
112const 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
143const 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
154const 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
165const 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
181const 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
187const 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
234const 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
243const dealPipelineSyncSource = `import { z } from "zod";
244import { 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 */
250export 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
294const findDealDocsSource = `import { z } from "zod";
295import { 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 */
301export 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
345const extraToolSource = `import { z } from "zod";
346import { defineTool } from "executor:app";
347
348export 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
359const initialFiles = (): Record<string, string> => ({
360 "executor.json": executorJson,
361 "tools/deal-pipeline-sync.ts": dealPipelineSyncSource,
362 "tools/find-deal-docs.ts": findDealDocsSource,
363});
364
365const 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
382const syncSource = (repo = REPO_FULL_NAME): Promise<SyncResult> =>
383 postJson<SyncResult>("/api/apps/sources/github/sync", {
384 repo,
385 connection: GITHUB_CONNECTION,
386 });
387
388const listAppTools = (): Promise<readonly ToolRow[]> =>
389 requestJson<readonly ToolRow[]>("/api/tools?integration=apps", {
390 headers: jsonHeaders,
391 });
392
393const execute = (code: string): Promise<ExecuteResponse> =>
394 postJson<ExecuteResponse>("/api/executions", { code, autoApprove: true });
395
396const 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
403const callAppToolCode = (toolName: string, args: unknown): string => `
404const found = await tools.search({ namespace: "apps", query: ${JSON.stringify(toolName)}, limit: 20 });
405const item = found.items.find((candidate) => candidate.path.endsWith(${JSON.stringify(toolName)}));
406if (!item) return { ok: false, missing: ${JSON.stringify(toolName)}, found };
407let fn = tools;
408for (const segment of item.path.split(".")) fn = fn[segment];
409const result = await fn(${JSON.stringify(args)});
410return { path: item.path, result };
411`;
412
413let github!: Emulator;
414let server!: TestHttpServer;
415let disposeApp: () => Promise<void> = async () => {};
416
417beforeAll(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
435afterAll(async () => {
436 await server?.close();
437 await disposeApp();
438 await github?.close();
439});
440
441test("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 @@
1import { join } from "node:path";
2
3import { Effect } from "effect";
4
5import {
6 appsPlugin,
7 BindingError,
8 makeSelfHostAppsRuntime,
9 makeSqliteAppsStore,
10 type AppsRuntime,
11 type ClientResolver,
12} from "@executor-js/plugin-apps/api";
13import type { PluginCtx } from "@executor-js/sdk";
14
15import { resolveDataDir } from "./config";
16import { makeSelfHostAppsResolver } from "./apps-resolver";
17
18const missingResolver = (): ClientResolver => ({
19 listConnections: () => Effect.succeed([]),
20 resolveConnection: () => Effect.succeed(null),
21 call: ({ integration, connection }) =>
22 Effect.fail(
23 new BindingError({
24 role: integration,
25 integration,
26 requestedConnection: connection,
27 message: "apps resolver is unavailable outside a request-scoped executor",
28 }),
29 ),
30});
31
32const createSubsystem = (dataDir: string) => {
33 const appsDir = join(dataDir, "apps");
34 const store = makeSqliteAppsStore({ path: join(appsDir, "store.sqlite") });
35 const host = makeSelfHostAppsRuntime({
36 dataDir: appsDir,
37 store,
38 resolver: missingResolver(),
39 });
40 const plugin = appsPlugin({
41 runtime: host.runtime,
42 makeResolver: ({ ctx }) => makeSelfHostAppsResolver({ ctx: ctx as PluginCtx }),
43 });
44 return {
45 runtime: host.runtime,
46 plugin,
47 close: host.close,
48 };
49};
50
51export interface SelfHostAppsSubsystem {
52 readonly runtime: AppsRuntime;
53 readonly plugin: ReturnType<typeof appsPlugin>;
54 readonly close: () => Promise<void>;
55}
56
57let current: { readonly dataDir: string; readonly subsystem: SelfHostAppsSubsystem } | undefined;
58
59export const getSelfHostAppsSubsystem = (): SelfHostAppsSubsystem => {
60 const dataDir = resolveDataDir();
61 if (current && current.dataDir === dataDir) return current.subsystem;
62 const subsystem = createSubsystem(dataDir);
63 current = { dataDir, subsystem };
64 return subsystem;
65};
66
67export const closeSelfHostAppsSubsystem = async (): Promise<void> => {
68 const subsystem = current?.subsystem;
69 current = undefined;
70 await subsystem?.close();
71};
apps/host-selfhost/src/testing/test-app.ts+4−0
@@ -30,6 +30,8 @@ import { · 29 unmodified lines
30} from "../mcp/session-store";30} from "../mcp/session-store";
31import { selfHostPlugins } from "../plugins";31import { selfHostPlugins } from "../plugins";
32import { ErrorCaptureLive } from "../observability";32import { ErrorCaptureLive } from "../observability";
33import { closeSelfHostAppsSubsystem } from "../apps";
34import { makeSelfHostAppsSyncRoute } from "../apps-route";
33 35
34// ===========================================================================36// ===========================================================================
35// Self-host TEST harness — the throwaway composition tests use to exercise the37// Self-host TEST harness — the throwaway composition tests use to exercise the
@@ -202,6 +204,7 @@ export const makeSelfHostTestApp = async ( · 166 unmodified lines
202 },204 },
203 extensions: {205 extensions: {
204 routes: [206 routes: [
207 makeSelfHostAppsSyncRoute({ identity: options.identity, db: dbHandle }),
205 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),208 HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),
206 ],209 ],
207 },210 },
@@ -217,6 +220,7 @@ export const makeSelfHostTestApp = async ( · 9 unmodified lines
217 handler: web.handler,220 handler: web.handler,
218 dispose: async () => {221 dispose: async () => {
219 await web.dispose();222 await web.dispose();
223 await closeSelfHostAppsSubsystem();
220 await sessionStore.close();224 await sessionStore.close();
221 await dbHandle.close();225 await dbHandle.close();
222 },226 },
bun.lock+2−0
@@ -224,6 +224,7 @@ · 223 unmodified lines
224 "@executor-js/execution": "workspace:*",224 "@executor-js/execution": "workspace:*",
225 "@executor-js/fumadb": "workspace:*",225 "@executor-js/fumadb": "workspace:*",
226 "@executor-js/host-mcp": "workspace:*",226 "@executor-js/host-mcp": "workspace:*",
227 "@executor-js/plugin-apps": "workspace:*",
227 "@executor-js/plugin-encrypted-secrets": "workspace:*",228 "@executor-js/plugin-encrypted-secrets": "workspace:*",
228 "@executor-js/plugin-google": "workspace:*",229 "@executor-js/plugin-google": "workspace:*",
229 "@executor-js/plugin-graphql": "workspace:*",230 "@executor-js/plugin-graphql": "workspace:*",
@@ -246,6 +247,7 @@ · 16 unmodified lines
246 },247 },
247 "devDependencies": {248 "devDependencies": {
248 "@effect/vitest": "catalog:",249 "@effect/vitest": "catalog:",
250 "@executor-js/emulate": "^0.13.2",
249 "@executor-js/vite-plugin": "workspace:*",251 "@executor-js/vite-plugin": "workspace:*",
250 "@tailwindcss/vite": "catalog:",252 "@tailwindcss/vite": "catalog:",
251 "@tanstack/router-plugin": "^1.167.12",253 "@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
2071 2071
2072 const result: ResolveToolsResult = yield* runtime.plugin2072 const result: ResolveToolsResult = yield* runtime.plugin
2073 .resolveTools({2073 .resolveTools({
2074 ctx: runtime.ctx,
2074 integration: rowToIntegration(integrationRow),2075 integration: rowToIntegration(integrationRow),
2075 config: decodeJsonColumn(integrationRow.config),2076 config: decodeJsonColumn(integrationRow.config),
2076 httpClientLayer: runtime.ctx.httpClientLayer,2077 httpClientLayer: runtime.ctx.httpClientLayer,
@@ -3732,6 +3733,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1655 unmodified lines
3732 items: (key) => providersItems(key),3733 items: (key) => providersItems(key),
3733 },3734 },
3734 oauth,3735 oauth,
3736 execute: (address, args) => execute(address, args),
3735 transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect),3737 transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect),
3736 };3738 };
3737 3739
packages/core/sdk/src/plugin.ts+9−0
@@ -29,6 +29,7 @@ import type { · 28 unmodified lines
29 ProviderKey,29 ProviderKey,
30 Subject,30 Subject,
31 Tenant,31 Tenant,
32 ToolAddress,
32} from "./ids";33} from "./ids";
33import type { IntegrationDetectionResult } from "./types";34import type { IntegrationDetectionResult } from "./types";
34import type {35import type {
@@ -38,6 +39,7 @@ import type { · 3 unmodified lines
38 ElicitationResponse,39 ElicitationResponse,
39} from "./elicitation";40} from "./elicitation";
40import type {41import type {
42 ExecuteError,
41 ConnectionNotFoundError,43 ConnectionNotFoundError,
42 CredentialProviderNotRegisteredError,44 CredentialProviderNotRegisteredError,
43 IntegrationNotFoundError,45 IntegrationNotFoundError,
@@ -249,6 +251,12 @@ export interface PluginCtx<TStore = unknown> { · 205 unmodified lines
249 /** Shared OAuth service. */251 /** Shared OAuth service. */
250 readonly oauth: OAuthService;252 readonly oauth: OAuthService;
251 253
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
252 /** Run `effect` inside a FumaDB transaction (atomic across plugin storage +260 /** Run `effect` inside a FumaDB transaction (atomic across plugin storage +
253 * core integration/tool writes). */261 * core integration/tool writes). */
254 readonly transaction: <A, E>(effect: Effect.Effect<A, E>) => Effect.Effect<A, E | StorageFailure>;262 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
261// ---------------------------------------------------------------------------269// ---------------------------------------------------------------------------
262 270
263export interface ResolveToolsInput<TStore = unknown> {271export interface ResolveToolsInput<TStore = unknown> {
272 readonly ctx?: PluginCtx<TStore>;
264 /** The catalog record (public projection) whose connection is being resolved. */273 /** The catalog record (public projection) whose connection is being resolved. */
265 readonly integration: Integration;274 readonly integration: Integration;
266 /** The plugin's stored opaque config for that integration. */275 /** 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
13 type SelfHostAppsRuntime,13 type SelfHostAppsRuntime,
14 type SelfHostAppsRuntimeOptions,14 type SelfHostAppsRuntimeOptions,
15} from "./plugin/self-host-runtime";15} from "./plugin/self-host-runtime";
16export { type AppsRuntime } from "./plugin/runtime";
16export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";17export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";
17export {18export {
18 BindingError,19 BindingError,
@@ -20,3 +21,13 @@ export { · 1 unmodified line
20 type ConnectionCandidate,21 type ConnectionCandidate,
21 type RoleBindings,22 type RoleBindings,
22} from "./plugin/bindings";23} from "./plugin/bindings";
24export {
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
16 type PublishOutput,16 type PublishOutput,
17 type PublishDeps,17 type PublishDeps,
18} from "./pipeline/publish";18} from "./pipeline/publish";
19export {
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";
19 29
20export { makeAppsRuntime, type AppsRuntime, type AppsRuntimeDeps } from "./plugin/runtime";30export { makeAppsRuntime, type AppsRuntime, type AppsRuntimeDeps } from "./plugin/runtime";
21export {31export {
packages/plugins/apps/src/pipeline/descriptor.ts+16−1
@@ -7,7 +7,7 @@ · 6 unmodified lines
7 7
8/** Descriptor schema version. Bumped on any breaking shape change. A reader8/** Descriptor schema version. Bumped on any breaking shape change. A reader
9 * refuses a descriptor from a version it does not understand. */9 * refuses a descriptor from a version it does not understand. */
10export const DESCRIPTOR_VERSION = 2 as const;10export const DESCRIPTOR_VERSION = 3 as const;
11 11
12/** Where an entry came from: path + content hash. Lets a projection point back12/** Where an entry came from: path + content hash. Lets a projection point back
13 * at the exact source bytes without re-reading the snapshot, and makes the13 * at the exact source bytes without re-reading the snapshot, and makes the
@@ -31,6 +31,15 @@ export interface IntegrationDecl { · 17 unmodified lines
31 readonly integration: string;31 readonly integration: string;
32}32}
33 33
34export interface GitHubSourceRef {
35 readonly kind: "github";
36 readonly repo: string;
37 readonly ref: string;
38 readonly upstreamSha: string;
39}
40
41export type AppSourceRef = GitHubSourceRef;
42
34export interface ToolDescriptor {43export interface ToolDescriptor {
35 /** Path identity, e.g. `issues-sync` (from `tools/issues-sync.ts`). */44 /** Path identity, e.g. `issues-sync` (from `tools/issues-sync.ts`). */
36 readonly name: string;45 readonly name: string;
@@ -62,6 +71,8 @@ export const GUIDE_ENTRIES_KEY = "skills"; · 25 unmodified lines
62export interface AppDescriptor {71export interface AppDescriptor {
63 readonly version: typeof DESCRIPTOR_VERSION;72 readonly version: typeof DESCRIPTOR_VERSION;
64 readonly scope: string;73 readonly scope: string;
74 readonly description?: string;
75 readonly source?: AppSourceRef;
65 /** The snapshot (commit hash) this descriptor was extracted from. */76 /** The snapshot (commit hash) this descriptor was extracted from. */
66 readonly snapshotId: string;77 readonly snapshotId: string;
67 /** The toolchain that produced the compiled bundles. */78 /** The toolchain that produced the compiled bundles. */
@@ -70,6 +81,10 @@ export interface AppDescriptor { · 2 unmodified lines
70 readonly [FLOW_ENTRIES_KEY]: readonly DeferredDescriptor[];81 readonly [FLOW_ENTRIES_KEY]: readonly DeferredDescriptor[];
71 readonly ui: readonly DeferredDescriptor[];82 readonly ui: readonly DeferredDescriptor[];
72 readonly [GUIDE_ENTRIES_KEY]: readonly DeferredDescriptor[];83 readonly [GUIDE_ENTRIES_KEY]: readonly DeferredDescriptor[];
84 readonly skipped: readonly {
85 readonly path: string;
86 readonly reason: "not supported yet";
87 }[];
73 /** Shared JSON-schema `$defs` reachable from tool schemas. */88 /** Shared JSON-schema `$defs` reachable from tool schemas. */
74 readonly definitions?: Record<string, unknown>;89 readonly definitions?: Record<string, unknown>;
75}90}
packages/plugins/apps/src/pipeline/publish.ts+8−1
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
10 GUIDE_ENTRIES_KEY,10 GUIDE_ENTRIES_KEY,
11 stableStringify,11 stableStringify,
12 type AppDescriptor,12 type AppDescriptor,
13 type AppSourceRef,
13 type IntegrationDecl,14 type IntegrationDecl,
14 type ModuleSourceRef,15 type ModuleSourceRef,
15 type ToolDescriptor,16 type ToolDescriptor,
@@ -73,6 +74,8 @@ export interface PublishInput { · 57 unmodified lines
73 readonly scope: string;74 readonly scope: string;
74 readonly files: FileSet;75 readonly files: FileSet;
75 readonly commitMessage?: string;76 readonly commitMessage?: string;
77 readonly description?: string;
78 readonly source?: AppSourceRef;
76}79}
77 80
78export interface PublishOutput {81export interface PublishOutput {
@@ -219,14 +222,18 @@ const assemble = (deps: PublishDeps, files: FileSet): Effect.Effect<AssembledApp · 140 unmodified lines
219const descriptorBody = (222const descriptorBody = (
220 scope: string,223 scope: string,
221 assembled: AssembledApp,224 assembled: AssembledApp,
225 input: Pick<PublishInput, "description" | "source">,
222): Omit<AppDescriptor, "snapshotId"> => ({226): Omit<AppDescriptor, "snapshotId"> => ({
223 version: DESCRIPTOR_VERSION,227 version: DESCRIPTOR_VERSION,
224 scope,228 scope,
229 ...(input.description !== undefined ? { description: input.description } : {}),
230 ...(input.source !== undefined ? { source: input.source } : {}),
225 toolchain: toolchainRef(),231 toolchain: toolchainRef(),
226 tools: assembled.tools,232 tools: assembled.tools,
227 [FLOW_ENTRIES_KEY]: [],233 [FLOW_ENTRIES_KEY]: [],
228 ui: [],234 ui: [],
229 [GUIDE_ENTRIES_KEY]: [],235 [GUIDE_ENTRIES_KEY]: [],
236 skipped: assembled.skipped,
230});237});
231 238
232export const publish = (239export const publish = (
@@ -238,7 +245,7 @@ export const publish = ( · 5 unmodified lines
238 if (overLimit) return yield* Effect.fail(overLimit);245 if (overLimit) return yield* Effect.fail(overLimit);
239 246
240 const assembled = yield* assemble(deps, input.files);247 const assembled = yield* assemble(deps, input.files);
241 const body = descriptorBody(input.scope, assembled);248 const body = descriptorBody(input.scope, assembled, input);
242 249
243 const scopeStore = yield* deps.artifactStore250 const scopeStore = yield* deps.artifactStore
244 .forScope(input.scope)251 .forScope(input.scope)
packages/plugins/apps/src/plugin/apps-plugin.ts+136−37
@@ -4,10 +4,12 @@ import { · 3 unmodified lines
4 definePlugin,4 definePlugin,
5 tool,5 tool,
6 ToolName,6 ToolName,
7 ConnectionName,
8 IntegrationSlug,7 IntegrationSlug,
9 AuthTemplateSlug,8 AuthTemplateSlug,
10 connectionIdentifier,9 connectionIdentifier,
10 type ConnectionRef,
11 type Owner,
12 type PluginCtx,
11 type ResolveToolsInput,13 type ResolveToolsInput,
12 type ResolveToolsResult,14 type ResolveToolsResult,
13 type InvokeToolInput,15 type InvokeToolInput,
@@ -18,6 +20,7 @@ import type { AppsRuntime } from "./runtime"; · 4 unmodified lines
18import { makeAppsStore } from "./store";20import { makeAppsStore } from "./store";
19import type { ClientResolver, ConnectionCandidate } from "./bindings";21import type { ClientResolver, ConnectionCandidate } from "./bindings";
20import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor";22import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor";
23import { syncGitHubSource, type GitHubSyncResult } from "../source/github-source";
21 24
22export const APPS_INTEGRATION_SLUG = "apps";25export const APPS_INTEGRATION_SLUG = "apps";
23export const APPS_PLUGIN_ID = "apps";26export const APPS_PLUGIN_ID = "apps";
@@ -42,6 +45,40 @@ const isRecord = (value: unknown): value is Record<string, unknown> => · 18 unmodified lines
42 45
43const unique = (values: readonly string[]): readonly string[] => [...new Set(values)];46const unique = (values: readonly string[]): readonly string[] => [...new Set(values)];
44 47
48const 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
61const sourceScopeFor = (input: { repo: string; connection: string }): string =>
62 String(connectionIdentifier(`github ${input.connection} ${input.repo}`, "githubSource"));
63
64const 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
77const configBaseUrl = (config: unknown): string | undefined =>
78 isRecord(config) && typeof config.baseUrl === "string" && config.baseUrl.length > 0
79 ? config.baseUrl
80 : undefined;
81
45const projectInputSchema = (82const projectInputSchema = (
46 schema: unknown,83 schema: unknown,
47 integrations: Readonly<Record<string, IntegrationDecl>>,84 integrations: Readonly<Record<string, IntegrationDecl>>,
@@ -94,6 +131,49 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 46 unmodified lines
94 Effect.map((mapped) => mapped ?? scopeFromConnection(connectionName)),131 Effect.map((mapped) => mapped ?? scopeFromConnection(connectionName)),
95 );132 );
96 133
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
97 return {177 return {
98 id: APPS_PLUGIN_ID as "apps",178 id: APPS_PLUGIN_ID as "apps",
99 packageName: "@executor-js/plugin-apps",179 packageName: "@executor-js/plugin-apps",
@@ -135,59 +215,78 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 35 unmodified lines
135 Effect.gen(function* () {215 Effect.gen(function* () {
136 const scope =216 const scope =
137 (args as { scope?: string } | undefined)?.scope ?? DEFAULT_CATALOG_SCOPE;217 (args as { scope?: string } | undefined)?.scope ?? DEFAULT_CATALOG_SCOPE;
138 const slug = IntegrationSlug.make(APPS_INTEGRATION_SLUG);218 const connected = yield* ensureCatalogConnection(scope, ctx);
139 const existing = yield* ctx.core.integrations219 return { scope, connection: connected.connection };
140 .get(slug)220 }),
141 .pipe(Effect.orElseSucceed(() => null));221 }),
142 if (!existing) {222 tool<AppsStoreShape>({
143 yield* ctx.core.integrations.register({223 name: "sync_github_source",
144 slug,224 description: "Sync a GitHub repository containing custom tool source files.",
145 name: "Apps",225 execute: (args, { ctx }) =>
146 description: "User-authored, published custom tools.",226 Effect.gen(function* () {
147 config: {},227 const payload = isRecord(args) ? args : {};
148 canRemove: false,228 const repo = typeof payload.repo === "string" ? payload.repo : "";
149 canRefresh: true,229 const connection = typeof payload.connection === "string" ? payload.connection : "";
150 });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 );
151 }241 }
152 const connName = ConnectionName.make(connectionNameForScope(scope));242 if (String(sourceConnection.integration) !== "github") {
153 const conns = yield* ctx.connections243 return syncFailure(
154 .list({ integration: slug })244 `GitHub source sync requires a github connection; got "${sourceConnection.integration}"`,
155 .pipe(Effect.orElseSucceed(() => []));245 connection,
156 if (!conns.some((c) => String(c.name) === String(connName))) {246 );
157 yield* ctx.connections.create({
158 owner: "user",
159 name: connName,
160 integration: slug,
161 template: AuthTemplateSlug.make("none"),
162 value: "",
163 });
164 }247 }
165 yield* runtime.deps.store248 const existing = yield* ctx.connections
166 .putScopeForConnection(String(connName), scope)249 .get(sourceConnection)
167 .pipe(Effect.orElseSucceed(() => undefined));250 .pipe(Effect.orElseSucceed(() => null));
168 const normalized = connectionIdentifier(String(connName));251 if (!existing) return syncFailure(`GitHub connection not found: ${connection}`);
169 if (normalized !== String(connName)) {252 const token = yield* ctx.connections
170 yield* runtime.deps.store253 .resolveValue(sourceConnection)
171 .putScopeForConnection(normalized, scope)254 .pipe(Effect.orElseSucceed(() => null));
172 .pipe(Effect.orElseSucceed(() => undefined));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(() => []));
173 }270 }
174 return { scope, connection: String(connName) };271 return result;
175 }),272 }),
176 }),273 }),
177 ],274 ],
178 },275 },
179 ],276 ],
180 277
181 resolveTools: ({ connection }: ResolveToolsInput<AppsStoreShape>) =>278 resolveTools: ({ ctx, connection }: ResolveToolsInput<AppsStoreShape>) =>
182 Effect.gen(function* () {279 Effect.gen(function* () {
183 const scope = yield* scopeFor(String(connection.name));280 const scope = yield* scopeFor(String(connection.name));
184 const descriptor = yield* runtime.getDescriptor(scope);281 const descriptor = yield* runtime.getDescriptor(scope);
185 if (!descriptor) return { tools: [] } satisfies ResolveToolsResult;282 if (!descriptor) return { tools: [] } satisfies ResolveToolsResult;
283 const resolver =
284 makeResolver && ctx ? makeResolver({ ctx, scope, tool: "*" }) : runtime.deps.resolver;
186 const tools: ToolDef[] = [];285 const tools: ToolDef[] = [];
187 for (const t of descriptor.tools) {286 for (const t of descriptor.tools) {
188 const byRole: Record<string, readonly ConnectionCandidate[]> = {};287 const byRole: Record<string, readonly ConnectionCandidate[]> = {};
189 for (const [role, decl] of Object.entries(t.integrations)) {288 for (const [role, decl] of Object.entries(t.integrations)) {
190 byRole[role] = yield* runtime.deps.resolver289 byRole[role] = yield* resolver
191 .listConnections({ integration: decl.integration })290 .listConnections({ integration: decl.integration })
192 .pipe(Effect.orElseSucceed(() => []));291 .pipe(Effect.orElseSucceed(() => []));
193 }292 }
packages/plugins/apps/src/plugin/runtime.ts+5−1
@@ -13,7 +13,7 @@ import { · 12 unmodified lines
13 type PublishOutput,13 type PublishOutput,
14} from "../pipeline/publish";14} from "../pipeline/publish";
15import { PublishError } from "../pipeline/discover";15import { PublishError } from "../pipeline/discover";
16import type { AppDescriptor, ToolDescriptor } from "../pipeline/descriptor";16import type { AppDescriptor, AppSourceRef, ToolDescriptor } from "../pipeline/descriptor";
17import { bundleEntry } from "../pipeline/bundle";17import { bundleEntry } from "../pipeline/bundle";
18import {18import {
19 buildBridge,19 buildBridge,
@@ -42,6 +42,8 @@ export interface AppsRuntime { · 22 unmodified lines
42 readonly scope: string;42 readonly scope: string;
43 readonly files: ReadonlyMap<string, string>;43 readonly files: ReadonlyMap<string, string>;
44 readonly message?: string;44 readonly message?: string;
45 readonly description?: string;
46 readonly source?: AppSourceRef;
45 }) => Effect.Effect<PublishOutput, PublishError>;47 }) => Effect.Effect<PublishOutput, PublishError>;
46 readonly getDescriptor: (scope: string) => Effect.Effect<AppDescriptor | null>;48 readonly getDescriptor: (scope: string) => Effect.Effect<AppDescriptor | null>;
47 /** Re-derive the published-descriptor pointer from the latest committed49 /** Re-derive the published-descriptor pointer from the latest committed
@@ -264,6 +266,8 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 216 unmodified lines
264 scope: input.scope,266 scope: input.scope,
265 files: input.files,267 files: input.files,
266 commitMessage: input.message,268 commitMessage: input.message,
269 description: input.description,
270 source: input.source,
267 },271 },
268 );272 );
269 yield* putDescriptorPointer(out.descriptor);273 yield* putDescriptorPointer(out.descriptor);
packages/plugins/apps/src/source/github-source.test.ts+211−0
@@ -0,0 +1,211 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
5import { describe, expect, it } from "vitest";
6import { Effect } from "effect";
7
8import { makeSelfHostAppsRuntime } from "../plugin/self-host-runtime";
9import { makeInMemoryAppsStore, makeTestResolver } from "../testing";
10import { PUBLISH_LIMITS } from "../pipeline/publish";
11import { fetchGitHubSource, syncGitHubSource } from "./github-source";
12
13const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
14
15const toolSource = (name = "ok"): string => `import { defineTool } from "executor:app";
16import { z } from "zod";
17export default defineTool({
18 description: "${name}",
19 input: z.object({ value: z.string().default("${name}") }),
20 async handler(input) { return { value: input.value }; },
21});`;
22
23const makeRuntime = () =>
24 makeSelfHostAppsRuntime({
25 dataDir: mkdtempSync(join(tmpdir(), "apps-gh-src-")),
26 store: makeInMemoryAppsStore(),
27 resolver: makeTestResolver({}),
28 inMemory: true,
29 });
30
31const json = (value: unknown, status = 200): Response =>
32 new Response(JSON.stringify(value), {
33 status,
34 headers: { "content-type": "application/json" },
35 });
36
37const 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
89describe("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";
190import { chunk } from "lodash";
191export 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 @@
1import { Data, Effect, Result } from "effect";
2
3import { PublishError, type FileDiagnostic, type SkippedArtifact } from "../pipeline/discover";
4import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish";
5import type { AppSourceRef } from "../pipeline/descriptor";
6import type { AppsRuntime } from "../plugin/runtime";
7import type { FileSet, SnapshotId } from "../seams/artifact-store";
8
9export 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
17export interface GitHubSourceSnapshot {
18 readonly files: FileSet;
19 readonly repo: string;
20 readonly ref: string;
21 readonly upstreamSha: string;
22 readonly description?: string;
23}
24
25export class GitHubSourceError extends Data.TaggedError("GitHubSourceError")<{
26 readonly message: string;
27 readonly status?: number;
28 readonly path?: string;
29 readonly cause?: unknown;
30}> {}
31
32export interface SyncErrorData {
33 readonly stage: "source" | "discover" | "bundle" | "collect" | "project";
34 readonly message: string;
35 readonly diagnostics?: readonly FileDiagnostic[];
36}
37
38export 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
62export interface SyncGitHubSourceInput extends GitHubSourceInput {
63 readonly runtime: AppsRuntime;
64 readonly scope: string;
65}
66
67const MATERIALIZED_RE = /^(tools|workflows|ui|skills)\//;
68
69const trimBaseUrl = (baseUrl: string): string => baseUrl.replace(/\/+$/, "");
70
71const 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
77const 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
90const materializes = (path: string): boolean =>
91 path === "executor.json" || MATERIALIZED_RE.test(path);
92
93const isRecord = (value: unknown): value is Record<string, unknown> =>
94 value !== null && typeof value === "object" && !Array.isArray(value);
95
96const asString = (value: unknown): string | undefined =>
97 typeof value === "string" && value.length > 0 ? value : undefined;
98
99const 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
136interface RepoResponse {
137 readonly default_branch?: unknown;
138}
139
140interface CommitResponse {
141 readonly sha?: unknown;
142 readonly commit?: {
143 readonly tree?: {
144 readonly sha?: unknown;
145 };
146 };
147}
148
149interface GitCommitResponse {
150 readonly sha?: unknown;
151 readonly tree?: {
152 readonly sha?: unknown;
153 };
154}
155
156interface RefResponse {
157 readonly object?: {
158 readonly sha?: unknown;
159 };
160}
161
162interface TreeEntry {
163 readonly path?: unknown;
164 readonly type?: unknown;
165 readonly sha?: unknown;
166 readonly size?: unknown;
167}
168
169interface TreeResponse {
170 readonly tree?: readonly TreeEntry[];
171 readonly truncated?: unknown;
172}
173
174interface BlobResponse {
175 readonly content?: unknown;
176 readonly encoding?: unknown;
177}
178
179const 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
185const 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
192const 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
221const 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
240const 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
260export 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
326const publishErrorToSyncError = (error: PublishError): SyncErrorData => ({
327 stage: error.stage,
328 message: error.message,
329 diagnostics: error.diagnostics,
330});
331
332const sourceErrorToSyncError = (error: GitHubSourceError): SyncErrorData => ({
333 stage: "source",
334 message: error.message,
335 diagnostics: error.path ? [{ path: error.path, message: error.message }] : [],
336});
337
338const sourceRef = (snapshot: GitHubSourceSnapshot): AppSourceRef => ({
339 kind: "github",
340 repo: snapshot.repo,
341 ref: snapshot.ref,
342 upstreamSha: snapshot.upstreamSha,
343});
344
345export 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 });