History

Add GitHub sync and self-host custom tools wiring

Rhys Sullivan committed 4 months ago

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

Showing the changes to packages/plugins/apps/src/plugin (2 of 19 files). Show all

Unified · Split

2 files
  1. packages/plugins/apps/src/plugin/apps-plugin.ts +136 −37
  2. packages/plugins/apps/src/plugin/runtime.ts +5 −1
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);