import { Effect } from "effect"; import { definePlugin, tool, ToolName, IntegrationSlug, AuthTemplateSlug, connectionIdentifier, type ConnectionRef, type Owner, type PluginCtx, type ResolveToolsInput, type ResolveToolsResult, type InvokeToolInput, type ToolDef, } from "@executor-js/sdk"; import type { AppsRuntime } from "./runtime"; import { makeAppsStore } from "./store"; import type { ClientResolver, ConnectionCandidate } from "./bindings"; import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor"; import { syncGitHubSource, type GitHubSyncResult } from "../source/github-source"; export const APPS_INTEGRATION_SLUG = "apps"; export const APPS_PLUGIN_ID = "apps"; const DEFAULT_CATALOG_SCOPE = "default"; export interface AppsPluginOptions { readonly runtime: AppsRuntime; readonly makeResolver?: (input: { readonly ctx: unknown; readonly scope: string; readonly tool: string; }) => ClientResolver; } interface AppsStoreShape { readonly runtime: AppsRuntime; } const isRecord = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); const unique = (values: readonly string[]): readonly string[] => [...new Set(values)]; const syncFailure = (message: string, path?: string): GitHubSyncResult => ({ status: "failed", tools: [], skipped: [], errors: [ { stage: "source", message, ...(path ? { diagnostics: [{ path, message }] } : {}), }, ], }); const sourceScopeFor = (input: { repo: string; connection: string }): string => String(connectionIdentifier(`github ${input.connection} ${input.repo}`, "githubSource")); const parseConnectionAddress = (address: string): ConnectionRef | null => { const parts = address.split("."); if (parts.length !== 4 || parts[0] !== "tools") return null; const [, integration, owner, name] = parts; if (!integration || !name) return null; if (owner !== "org" && owner !== "user") return null; return { owner: owner as Owner, integration: IntegrationSlug.make(integration), name: connectionIdentifier(name), }; }; const configBaseUrl = (config: unknown): string | undefined => isRecord(config) && typeof config.baseUrl === "string" && config.baseUrl.length > 0 ? config.baseUrl : undefined; const projectInputSchema = ( schema: unknown, integrations: Readonly>, byRole: Readonly>, ): unknown => { const base: Record = isRecord(schema) ? { ...schema } : { type: "object" }; const properties = isRecord(base.properties) ? { ...base.properties } : {}; const required = new Set( Array.isArray(base.required) ? base.required.filter((value): value is string => typeof value === "string") : [], ); for (const [role, decl] of Object.entries(integrations)) { const addresses = unique((byRole[role] ?? []).map((c) => c.address)); const roleSchema: Record = { type: "string", enum: addresses, description: `Connection to use for ${role} (${decl.integration})`, }; if (addresses.length === 1) { roleSchema.default = addresses[0]; required.delete(role); } else { required.add(role); } properties[role] = roleSchema; } const projected: Record = { ...base, type: typeof base.type === "string" ? base.type : "object", properties, }; if (required.size > 0) projected.required = [...required]; else delete projected.required; return projected; }; export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { if (!options?.runtime) { throw new Error("appsPlugin requires a runtime"); } const runtime = options.runtime; const makeResolver = options.makeResolver; const scopeFor = (connectionName: string): Effect.Effect => runtime.deps.store.getScopeForConnection(connectionName).pipe( Effect.orElseSucceed(() => null), Effect.map((mapped) => mapped ?? scopeFromConnection(connectionName)), ); const ensureCatalogConnection = (scope: string, ctx: PluginCtx) => Effect.gen(function* () { const slug = IntegrationSlug.make(APPS_INTEGRATION_SLUG); const existing = yield* ctx.core.integrations .get(slug) .pipe(Effect.orElseSucceed(() => null)); if (!existing) { yield* ctx.core.integrations.register({ slug, name: "Apps", description: "User-authored, published custom tools.", config: {}, canRemove: false, canRefresh: true, }); } const rawName = connectionNameForScope(scope); const connName = connectionIdentifier(rawName); const conns = yield* ctx.connections .list({ integration: slug }) .pipe(Effect.orElseSucceed(() => [])); if (!conns.some((c) => String(c.name) === String(connName))) { yield* ctx.connections.create({ owner: "user", name: connName, integration: slug, template: AuthTemplateSlug.make("none"), value: "", }); } yield* runtime.deps.store .putScopeForConnection(String(rawName), scope) .pipe(Effect.orElseSucceed(() => undefined)); yield* runtime.deps.store .putScopeForConnection(String(connName), scope) .pipe(Effect.orElseSucceed(() => undefined)); return { scope, connection: String(connName), ref: { owner: "user" as const, integration: slug, name: connName }, }; }); return { id: APPS_PLUGIN_ID as "apps", packageName: "@executor-js/plugin-apps", storage: (deps): AppsStoreShape => { void makeAppsStore({ pluginStorage: deps.pluginStorage, }); return { runtime }; }, pluginStorage: { published_descriptor: { name: "published_descriptor", schema: { Type: {} as Record }, indexes: [], }, apps_scope_connection: { name: "apps_scope_connection", schema: { Type: {} as Record }, indexes: [], }, }, extension: () => ({ runtime }), staticSources: () => [ { id: APPS_PLUGIN_ID, kind: "executor", name: "Apps", tools: [ tool({ name: "connect_catalog", description: "Wire this scope's published custom tools into the tool catalog. " + "Idempotent; call once per scope after publishing.", execute: (args, { ctx }) => Effect.gen(function* () { const scope = (args as { scope?: string } | undefined)?.scope ?? DEFAULT_CATALOG_SCOPE; const connected = yield* ensureCatalogConnection(scope, ctx); return { scope, connection: connected.connection }; }), }), tool({ name: "sync_github_source", description: "Sync a GitHub repository containing custom tool source files.", execute: (args, { ctx }) => Effect.gen(function* () { const payload = isRecord(args) ? args : {}; const repo = typeof payload.repo === "string" ? payload.repo : ""; const connection = typeof payload.connection === "string" ? payload.connection : ""; const ref = typeof payload.ref === "string" ? payload.ref : undefined; const explicitScope = typeof payload.scope === "string" ? payload.scope : undefined; if (!repo) return syncFailure('sync_github_source requires "repo"'); if (!connection) return syncFailure('sync_github_source requires "connection"'); const sourceConnection = parseConnectionAddress(connection); if (!sourceConnection) { return syncFailure( `GitHub connection must be a connection address like tools.github.user.main; got "${connection}"`, connection, ); } if (String(sourceConnection.integration) !== "github") { return syncFailure( `GitHub source sync requires a github connection; got "${sourceConnection.integration}"`, connection, ); } const existing = yield* ctx.connections .get(sourceConnection) .pipe(Effect.orElseSucceed(() => null)); if (!existing) return syncFailure(`GitHub connection not found: ${connection}`); const token = yield* ctx.connections .resolveValue(sourceConnection) .pipe(Effect.orElseSucceed(() => null)); const integration = yield* ctx.core.integrations .get(sourceConnection.integration) .pipe(Effect.orElseSucceed(() => null)); const scope = explicitScope ?? sourceScopeFor({ repo, connection }); const result = yield* syncGitHubSource({ runtime, scope, repo, ref, token, baseUrl: configBaseUrl(integration?.config), }); if (result.status === "published") { const catalog = yield* ensureCatalogConnection(scope, ctx); yield* ctx.connections.refresh(catalog.ref).pipe(Effect.orElseSucceed(() => [])); } return result; }), }), ], }, ], resolveTools: ({ ctx, connection }: ResolveToolsInput) => Effect.gen(function* () { const scope = yield* scopeFor(String(connection.name)); const descriptor = yield* runtime.getDescriptor(scope); if (!descriptor) return { tools: [] } satisfies ResolveToolsResult; const resolver = makeResolver && ctx ? makeResolver({ ctx, scope, tool: "*" }) : runtime.deps.resolver; const tools: ToolDef[] = []; for (const t of descriptor.tools) { const byRole: Record = {}; for (const [role, decl] of Object.entries(t.integrations)) { byRole[role] = yield* resolver .listConnections({ integration: decl.integration }) .pipe(Effect.orElseSucceed(() => [])); } tools.push(projectTool(t, byRole)); } return { tools } satisfies ResolveToolsResult; }), invokeTool: ({ ctx, toolRow, args }: InvokeToolInput) => Effect.gen(function* () { const scope = yield* scopeFor(String(toolRow.connection)); const descriptor = yield* runtime.getDescriptor(scope); if (!descriptor) { return yield* Effect.fail( new Error( `apps scope "${scope}" has no published app (connection "${toolRow.connection}")`, ), ); } const toolDesc = descriptor.tools.find((t) => t.name === toolRow.name); if (!toolDesc) { return yield* Effect.fail( new Error(`apps tool "${toolRow.name}" is not published in scope "${scope}"`), ); } const resolver = makeResolver ? makeResolver({ ctx, scope, tool: toolRow.name }) : undefined; return yield* runtime .invokeTool({ scope, tool: toolRow.name, args, resolver }) .pipe( Effect.mapError( (cause) => new Error( "message" in cause && typeof cause.message === "string" ? cause.message : "apps tool invocation failed", ), ), ); }), }; }); const projectTool = ( descriptor: ToolDescriptor, byRole: Readonly>, ): ToolDef => ({ name: ToolName.make(descriptor.name), description: descriptor.description, inputSchema: projectInputSchema(descriptor.inputSchema, descriptor.integrations, byRole), outputSchema: descriptor.outputSchema, annotations: { requiresApproval: descriptor.annotations?.destructive === true, }, }); export const APPS_CONNECTION_PREFIX = APPS_INTEGRATION_SLUG; const pascal = (value: string): string => value.length === 0 ? value : `${value[0]!.toUpperCase()}${value.slice(1)}`; export const connectionNameForScope = (scope: string): string => `${APPS_CONNECTION_PREFIX}${pascal(scope)}`; export const scopeFromConnection = (connectionName: string): string => { if (connectionName.startsWith(`${APPS_INTEGRATION_SLUG}/`)) { return connectionName.slice(APPS_INTEGRATION_SLUG.length + 1); } if ( connectionName.startsWith(APPS_CONNECTION_PREFIX) && connectionName.length > APPS_CONNECTION_PREFIX.length ) { const rest = connectionName.slice(APPS_CONNECTION_PREFIX.length); return `${rest[0]!.toLowerCase()}${rest.slice(1)}`; } const slash = connectionName.indexOf("/"); return slash === -1 ? connectionName : connectionName.slice(slash + 1); };