Unified · Split
| @@ -0,0 +1,20 @@ | ||
| 1 | +import { createServerHandlersWithExecutor, type ServerHandlers } from "./main"; | |
| 2 | +import { createServerExecutorHandle } from "./services/executor"; | |
| 3 | + | |
| 4 | +export type HotBackend = ServerHandlers & { | |
| 5 | + readonly dispose: () => Promise<void>; | |
| 6 | +}; | |
| 7 | + | |
| 8 | +export const createHotBackend = async (): Promise<HotBackend> => { | |
| 9 | + const handle = await createServerExecutorHandle(); | |
| 10 | + const handlers = await createServerHandlersWithExecutor(handle.executor); | |
| 11 | + | |
| 12 | + return { | |
| 13 | + ...handlers, | |
| 14 | + dispose: async () => { | |
| 15 | + await handlers.api.dispose().catch(() => undefined); | |
| 16 | + await handlers.mcp.close().catch(() => undefined); | |
| 17 | + await handle.dispose(); | |
| 18 | + }, | |
| 19 | + }; | |
| 20 | +}; | |
| @@ -17,6 +17,9 @@ export const SourcesHandlers = HttpApiBuilder.group( · 16 unmodified lines | ||
| 17 | 17 | id: s.id, |
| 18 | 18 | name: s.name, |
| 19 | 19 | kind: s.kind, |
| 20 | + runtime: s.runtime, | |
| 21 | + canRemove: s.canRemove, | |
| 22 | + canRefresh: s.canRefresh, | |
| 20 | 23 | })); |
| 21 | 24 | }), |
| 22 | 25 | ) |
| @@ -1,3 +1,4 @@ | ||
| 1 | 1 | export { createApiHandler, createServerHandlers, type ApiHandler, type ServerHandlers } from "./main"; |
| 2 | −export { ExecutorServiceLayer, getExecutor } from "./services/executor"; | |
| 2 | +export { createServerHandlersWithExecutor } from "./main"; | |
| 3 | +export { ExecutorServiceLayer, createServerExecutorHandle, disposeExecutor, getExecutor, reloadExecutor } from "./services/executor"; | |
| 3 | 4 | export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./mcp"; |
| @@ -16,7 +16,7 @@ import { SecretsHandlers } from "./handlers/secrets"; · 15 unmodified lines | ||
| 16 | 16 | import { OpenApiHandlersLive } from "./handlers/openapi"; |
| 17 | 17 | import { McpSourceHandlersLive } from "./handlers/mcp-source"; |
| 18 | 18 | import { OnePasswordHandlersLive } from "./handlers/onepassword"; |
| 19 | −import { ExecutorServiceLayer, getExecutor } from "./services/executor"; | |
| 19 | +import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor"; | |
| 20 | 20 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; |
| 21 | 21 | |
| 22 | 22 | // --------------------------------------------------------------------------- |
| @@ -29,7 +29,7 @@ const ExecutorApiWithPlugins = addGroup(OpenApiGroup).add(McpGroup).add(OnePassw · 6 unmodified lines | ||
| 29 | 29 | // API Layer |
| 30 | 30 | // --------------------------------------------------------------------------- |
| 31 | 31 | |
| 32 | −const ApiLive = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( | |
| 32 | +const ApiBase = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( | |
| 33 | 33 | Layer.provide([ |
| 34 | 34 | ToolsHandlers, |
| 35 | 35 | SourcesHandlers, |
| @@ -38,7 +38,6 @@ const ApiLive = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( · 2 unmodified lines | ||
| 38 | 38 | McpSourceHandlersLive, |
| 39 | 39 | OnePasswordHandlersLive, |
| 40 | 40 | ]), |
| 41 | − Layer.provide(ExecutorServiceLayer), | |
| 42 | 41 | ); |
| 43 | 42 | |
| 44 | 43 | // --------------------------------------------------------------------------- |
| @@ -53,24 +52,30 @@ export type ServerHandlers = { · 8 unmodified lines | ||
| 53 | 52 | readonly mcp: McpRequestHandler; |
| 54 | 53 | }; |
| 55 | 54 | |
| 56 | −export const createServerHandlers = async (): Promise<ServerHandlers> => { | |
| 57 | − const executor = await getExecutor(); | |
| 58 | − | |
| 59 | − const api = HttpApiBuilder.toWebHandler( | |
| 55 | +const createApiHandlerWithExecutor = (executor: ServerExecutor) => | |
| 56 | + HttpApiBuilder.toWebHandler( | |
| 60 | 57 | HttpApiSwagger.layer().pipe( |
| 61 | 58 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), |
| 62 | 59 | Layer.provideMerge(HttpApiBuilder.middlewareCors()), |
| 63 | − Layer.provideMerge(ApiLive), | |
| 60 | + Layer.provideMerge(ApiBase), | |
| 61 | + Layer.provideMerge(Layer.succeed(ExecutorService, executor)), | |
| 64 | 62 | Layer.provideMerge(HttpServer.layerContext), |
| 65 | 63 | ), |
| 66 | 64 | { middleware: HttpMiddleware.logger }, |
| 67 | 65 | ); |
| 68 | 66 | |
| 67 | +export const createServerHandlersWithExecutor = async ( | |
| 68 | + executor: ServerExecutor, | |
| 69 | +): Promise<ServerHandlers> => { | |
| 70 | + const api = createApiHandlerWithExecutor(executor); | |
| 69 | 71 | const mcp = createMcpRequestHandler({ executor }); |
| 70 | 72 | |
| 71 | 73 | return { api, mcp }; |
| 72 | 74 | }; |
| 73 | 75 | |
| 76 | +export const createServerHandlers = async (): Promise<ServerHandlers> => | |
| 77 | + createServerHandlersWithExecutor(await getExecutor()); | |
| 78 | + | |
| 74 | 79 | // --------------------------------------------------------------------------- |
| 75 | 80 | // Backwards compat — standalone API handler (no MCP) |
| 76 | 81 | // --------------------------------------------------------------------------- |
| @@ -80,7 +85,8 @@ export const createApiHandler = () => · 3 unmodified lines | ||
| 80 | 85 | HttpApiSwagger.layer().pipe( |
| 81 | 86 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), |
| 82 | 87 | Layer.provideMerge(HttpApiBuilder.middlewareCors()), |
| 83 | − Layer.provideMerge(ApiLive), | |
| 88 | + Layer.provideMerge(ApiBase), | |
| 89 | + Layer.provideMerge(ExecutorServiceLayer), | |
| 84 | 90 | Layer.provideMerge(HttpServer.layerContext), |
| 85 | 91 | ), |
| 86 | 92 | { middleware: HttpMiddleware.logger }, |
| @@ -21,6 +21,10 @@ type ServerPlugins = readonly [ · 20 unmodified lines | ||
| 21 | 21 | ExecutorPlugin<"onepassword", OnePasswordExtension>, |
| 22 | 22 | ]; |
| 23 | 23 | export type ServerExecutor = Executor<ServerPlugins>; |
| 24 | +export type ServerExecutorHandle = { | |
| 25 | + readonly executor: ServerExecutor; | |
| 26 | + readonly dispose: () => Promise<void>; | |
| 27 | +}; | |
| 24 | 28 | |
| 25 | 29 | // --------------------------------------------------------------------------- |
| 26 | 30 | // Service tag |
| @@ -78,17 +82,57 @@ const ExecutorLayer = Layer.effect( · 51 unmodified lines | ||
| 78 | 82 | ); |
| 79 | 83 | |
| 80 | 84 | // --------------------------------------------------------------------------- |
| 81 | −// ManagedRuntime — keeps the SQLite scope alive for the process lifetime | |
| 85 | +// ManagedRuntime — shared singleton for production, scoped handles for dev HMR | |
| 82 | 86 | // --------------------------------------------------------------------------- |
| 83 | 87 | |
| 84 | −const runtime = ManagedRuntime.make(ExecutorLayer); | |
| 88 | +export const createServerExecutorHandle = async (): Promise<ServerExecutorHandle> => { | |
| 89 | + const runtime = ManagedRuntime.make(ExecutorLayer); | |
| 90 | + const executor = await runtime.runPromise(ExecutorService); | |
| 91 | + | |
| 92 | + return { | |
| 93 | + executor, | |
| 94 | + dispose: async () => { | |
| 95 | + await Effect.runPromise(executor.close()).catch(() => undefined); | |
| 96 | + await runtime.dispose().catch(() => undefined); | |
| 97 | + }, | |
| 98 | + }; | |
| 99 | +}; | |
| 100 | + | |
| 101 | +let sharedHandlePromise: Promise<ServerExecutorHandle> | null = null; | |
| 102 | + | |
| 103 | +const loadSharedHandle = (): Promise<ServerExecutorHandle> => { | |
| 104 | + if (!sharedHandlePromise) { | |
| 105 | + sharedHandlePromise = createServerExecutorHandle(); | |
| 106 | + } | |
| 107 | + return sharedHandlePromise; | |
| 108 | +}; | |
| 85 | 109 | |
| 86 | 110 | /** |
| 87 | 111 | * Get the shared executor instance. The ManagedRuntime keeps the SQLite |
| 88 | 112 | * connection (and everything else) alive until the process exits. |
| 89 | 113 | */ |
| 90 | 114 | export const getExecutor = (): Promise<ServerExecutor> => |
| 91 | − runtime.runPromise(ExecutorService); | |
| 115 | + loadSharedHandle().then((handle) => handle.executor); | |
| 116 | + | |
| 117 | +/** | |
| 118 | + * Dispose the shared executor/runtime. Mainly useful in development when the | |
| 119 | + * backend module graph is hot-reloaded and we need fresh plugin init. | |
| 120 | + */ | |
| 121 | +export const disposeExecutor = async (): Promise<void> => { | |
| 122 | + const currentHandlePromise = sharedHandlePromise; | |
| 123 | + sharedHandlePromise = null; | |
| 124 | + | |
| 125 | + const handle = await currentHandlePromise?.catch(() => null); | |
| 126 | + await handle?.dispose().catch(() => undefined); | |
| 127 | +}; | |
| 128 | + | |
| 129 | +/** | |
| 130 | + * Dispose and eagerly recreate the shared executor. | |
| 131 | + */ | |
| 132 | +export const reloadExecutor = async (): Promise<ServerExecutor> => { | |
| 133 | + await disposeExecutor(); | |
| 134 | + return getExecutor(); | |
| 135 | +}; | |
| 92 | 136 | |
| 93 | 137 | /** |
| 94 | 138 | * Provide `ExecutorService` to an Effect layer using the shared runtime. |
| @@ -34,6 +34,8 @@ export function SourceDetailPage(props: { namespace: string }) { · 33 unmodified lines | ||
| 34 | 34 | const [refreshing, setRefreshing] = useState(false); |
| 35 | 35 | |
| 36 | 36 | const sourceData = Result.isSuccess(source) ? source.value : null; |
| 37 | + const canRefresh = sourceData ? (sourceData.canRefresh ?? true) : false; | |
| 38 | + const canRemove = sourceData ? (sourceData.canRemove ?? true) : false; | |
| 37 | 39 | |
| 38 | 40 | const sourceTools: ToolSummary[] = useMemo(() => { |
| 39 | 41 | if (!Result.isSuccess(tools)) return []; |
| @@ -85,6 +87,11 @@ export function SourceDetailPage(props: { namespace: string }) { · 45 unmodified lines | ||
| 85 | 87 | <h2 className="truncate text-sm font-semibold text-foreground"> |
| 86 | 88 | {sourceData?.name ?? namespace} |
| 87 | 89 | </h2> |
| 90 | + {sourceData?.runtime && ( | |
| 91 | + <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"> | |
| 92 | + built-in | |
| 93 | + </span> | |
| 94 | + )} | |
| 88 | 95 | <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground"> |
| 89 | 96 | {sourceData?.kind ?? "source"} |
| 90 | 97 | </span> |
| @@ -96,16 +103,18 @@ export function SourceDetailPage(props: { namespace: string }) { · 5 unmodified lines | ||
| 96 | 103 | </div> |
| 97 | 104 | |
| 98 | 105 | <div className="flex shrink-0 items-center gap-2"> |
| 99 | − <button | |
| 100 | − type="button" | |
| 101 | − onClick={() => void handleRefresh()} | |
| 102 | − disabled={refreshing} | |
| 103 | − className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-[12px] font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50" | |
| 104 | − > | |
| 105 | − {refreshing ? "Refreshing…" : "Refresh"} | |
| 106 | − </button> | |
| 106 | + {canRefresh && ( | |
| 107 | + <button | |
| 108 | + type="button" | |
| 109 | + onClick={() => void handleRefresh()} | |
| 110 | + disabled={refreshing} | |
| 111 | + className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-[12px] font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50" | |
| 112 | + > | |
| 113 | + {refreshing ? "Refreshing…" : "Refresh"} | |
| 114 | + </button> | |
| 115 | + )} | |
| 107 | 116 | |
| 108 | − {confirmDelete ? ( | |
| 117 | + {canRemove && (confirmDelete ? ( | |
| 109 | 118 | <div className="flex items-center gap-2"> |
| 110 | 119 | <span className="text-[11px] font-medium text-destructive"> |
| 111 | 120 | Confirm? |
| @@ -135,7 +144,7 @@ export function SourceDetailPage(props: { namespace: string }) { · 23 unmodified lines | ||
| 135 | 144 | > |
| 136 | 145 | Delete |
| 137 | 146 | </button> |
| 138 | − )} | |
| 147 | + ))} | |
| 139 | 148 | </div> |
| 140 | 149 | </div> |
| 141 | 150 | |
| @@ -25,6 +25,53 @@ export function SourcesPage() { · 24 unmodified lines | ||
| 25 | 25 | ? sourcePlugins.find((p) => p.key === adding) |
| 26 | 26 | : undefined; |
| 27 | 27 | |
| 28 | + const renderSourceGrid = (sources: readonly { | |
| 29 | + id: string; | |
| 30 | + name: string; | |
| 31 | + kind: string; | |
| 32 | + runtime?: boolean; | |
| 33 | + }[]) => ( | |
| 34 | + <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 35 | + {sources.map((s) => ( | |
| 36 | + <Link | |
| 37 | + key={s.id} | |
| 38 | + to="/sources/$namespace" | |
| 39 | + params={{ namespace: s.id }} | |
| 40 | + className="flex h-full flex-col rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90" | |
| 41 | + > | |
| 42 | + <div className="flex items-start gap-3"> | |
| 43 | + <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> | |
| 44 | + <svg viewBox="0 0 16 16" className="size-4"> | |
| 45 | + <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.2" /> | |
| 46 | + <path d="M8 5v6M5 8h6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> | |
| 47 | + </svg> | |
| 48 | + </div> | |
| 49 | + <div className="min-w-0 flex-1"> | |
| 50 | + <div className="flex items-start justify-between gap-2"> | |
| 51 | + <div className="truncate text-sm font-semibold text-foreground"> | |
| 52 | + {s.name} | |
| 53 | + </div> | |
| 54 | + <div className="flex shrink-0 items-center gap-1.5"> | |
| 55 | + {s.runtime && ( | |
| 56 | + <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"> | |
| 57 | + built-in | |
| 58 | + </span> | |
| 59 | + )} | |
| 60 | + <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground"> | |
| 61 | + {s.kind} | |
| 62 | + </span> | |
| 63 | + </div> | |
| 64 | + </div> | |
| 65 | + <div className="mt-0.5 text-xs text-muted-foreground"> | |
| 66 | + {s.id} | |
| 67 | + </div> | |
| 68 | + </div> | |
| 69 | + </div> | |
| 70 | + </Link> | |
| 71 | + ))} | |
| 72 | + </div> | |
| 73 | + ); | |
| 74 | + | |
| 28 | 75 | if (plugin) { |
| 29 | 76 | const AddComponent = plugin.add; |
| 30 | 77 | return ( |
| @@ -54,7 +101,7 @@ export function SourcesPage() { · 23 unmodified lines | ||
| 54 | 101 | Sources |
| 55 | 102 | </h1> |
| 56 | 103 | <p className="mt-1.5 text-[14px] text-muted-foreground"> |
| 57 | − Connected tool providers in this workspace. | |
| 104 | + Tool providers available in this workspace. | |
| 58 | 105 | </p> |
| 59 | 106 | </div> |
| 60 | 107 | <div className="flex gap-2"> |
| @@ -82,8 +129,11 @@ export function SourcesPage() { · 21 unmodified lines | ||
| 82 | 129 | onFailure: () => ( |
| 83 | 130 | <p className="text-sm text-destructive">Failed to load sources</p> |
| 84 | 131 | ), |
| 85 | − onSuccess: ({ value }) => | |
| 86 | − value.length === 0 ? ( | |
| 132 | + onSuccess: ({ value }) => { | |
| 133 | + const builtInSources = value.filter((source) => source.runtime); | |
| 134 | + const connectedSources = value.filter((source) => !source.runtime); | |
| 135 | + | |
| 136 | + return value.length === 0 ? ( | |
| 87 | 137 | <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20"> |
| 88 | 138 | <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4"> |
| 89 | 139 | <svg viewBox="0 0 24 24" fill="none" className="size-5"> |
| @@ -98,39 +148,37 @@ export function SourcesPage() { · 8 unmodified lines | ||
| 98 | 148 | </p> |
| 99 | 149 | </div> |
| 100 | 150 | ) : ( |
| 101 | − <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 102 | − {value.map((s) => ( | |
| 103 | − <Link | |
| 104 | − key={s.id} | |
| 105 | − to="/sources/$namespace" | |
| 106 | − params={{ namespace: s.id }} | |
| 107 | − className="flex h-full flex-col rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90" | |
| 108 | − > | |
| 109 | − <div className="flex items-start gap-3"> | |
| 110 | − <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> | |
| 111 | − <svg viewBox="0 0 16 16" className="size-4"> | |
| 112 | − <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.2" /> | |
| 113 | − <path d="M8 5v6M5 8h6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> | |
| 114 | − </svg> | |
| 115 | − </div> | |
| 116 | − <div className="min-w-0 flex-1"> | |
| 117 | − <div className="flex items-start justify-between gap-2"> | |
| 118 | − <div className="truncate text-sm font-semibold text-foreground"> | |
| 119 | − {s.name} | |
| 120 | − </div> | |
| 121 | − <span className="shrink-0 rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground"> | |
| 122 | − {s.kind} | |
| 123 | − </span> | |
| 124 | − </div> | |
| 125 | − <div className="mt-0.5 text-xs text-muted-foreground"> | |
| 126 | − {s.id} | |
| 127 | − </div> | |
| 128 | − </div> | |
| 151 | + <div className="space-y-8"> | |
| 152 | + {builtInSources.length > 0 && ( | |
| 153 | + <section className="space-y-3"> | |
| 154 | + <div> | |
| 155 | + <h2 className="text-sm font-semibold text-foreground"> | |
| 156 | + Built-in | |
| 157 | + </h2> | |
| 158 | + <p className="mt-1 text-[13px] text-muted-foreground"> | |
| 159 | + Runtime sources exposed by the loaded executor plugins. | |
| 160 | + </p> | |
| 161 | + </div> | |
| 162 | + {renderSourceGrid(builtInSources)} | |
| 163 | + </section> | |
| 164 | + )} | |
| 165 | + | |
| 166 | + {connectedSources.length > 0 && ( | |
| 167 | + <section className="space-y-3"> | |
| 168 | + <div> | |
| 169 | + <h2 className="text-sm font-semibold text-foreground"> | |
| 170 | + Connected | |
| 171 | + </h2> | |
| 172 | + <p className="mt-1 text-[13px] text-muted-foreground"> | |
| 173 | + User-configured sources available in this workspace. | |
| 174 | + </p> | |
| 129 | 175 | </div> |
| 130 | − </Link> | |
| 131 | − ))} | |
| 176 | + {renderSourceGrid(connectedSources)} | |
| 177 | + </section> | |
| 178 | + )} | |
| 132 | 179 | </div> |
| 133 | − ), | |
| 180 | + ); | |
| 181 | + }, | |
| 134 | 182 | })} |
| 135 | 183 | </div> |
| 136 | 184 | </div> |
| @@ -1,6 +1,13 @@ | ||
| 1 | 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; |
| 2 | 2 | import { useEffect, useRef, useState } from "react"; |
| 3 | −import { useAtomValue, sourcesAtom, Result } from "@executor/react"; | |
| 3 | +import { | |
| 4 | + useAtomRefresh, | |
| 5 | + useAtomValue, | |
| 6 | + Result, | |
| 7 | + sourceToolsAtom, | |
| 8 | + sourcesAtom, | |
| 9 | + toolsAtom, | |
| 10 | +} from "@executor/react"; | |
| 4 | 11 | |
| 5 | 12 | // ── NavItem ────────────────────────────────────────────────────────────── |
| 6 | 13 | |
| @@ -157,6 +164,14 @@ function SidebarContent(props: { · 150 unmodified lines | ||
| 157 | 164 | export function Shell() { |
| 158 | 165 | const location = useLocation(); |
| 159 | 166 | const pathname = location.pathname; |
| 167 | + const currentSourceId = pathname.startsWith("/sources/") | |
| 168 | + ? decodeURIComponent(pathname.slice("/sources/".length).split("/")[0] ?? "") | |
| 169 | + : null; | |
| 170 | + const refreshSources = useAtomRefresh(sourcesAtom()); | |
| 171 | + const refreshTools = useAtomRefresh(toolsAtom()); | |
| 172 | + const refreshSourceTools = useAtomRefresh( | |
| 173 | + sourceToolsAtom(currentSourceId ?? "__runtime__"), | |
| 174 | + ); | |
| 160 | 175 | const lastPathname = useRef(pathname); |
| 161 | 176 | const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); |
| 162 | 177 | if (lastPathname.current !== pathname) { |
| @@ -174,6 +189,26 @@ export function Shell() { · 11 unmodified lines | ||
| 174 | 189 | }; |
| 175 | 190 | }, [mobileSidebarOpen]); |
| 176 | 191 | |
| 192 | + useEffect(() => { | |
| 193 | + if (!import.meta.hot) { | |
| 194 | + return; | |
| 195 | + } | |
| 196 | + | |
| 197 | + const refreshBackendData = () => { | |
| 198 | + refreshSources(); | |
| 199 | + refreshTools(); | |
| 200 | + if (currentSourceId) { | |
| 201 | + refreshSourceTools(); | |
| 202 | + } | |
| 203 | + }; | |
| 204 | + | |
| 205 | + import.meta.hot.on("executor:backend-updated", refreshBackendData); | |
| 206 | + | |
| 207 | + return () => { | |
| 208 | + import.meta.hot?.off("executor:backend-updated", refreshBackendData); | |
| 209 | + }; | |
| 210 | + }, [currentSourceId, refreshSourceTools, refreshSources, refreshTools]); | |
| 211 | + | |
| 177 | 212 | return ( |
| 178 | 213 | <div className="flex h-screen overflow-hidden"> |
| 179 | 214 | {/* Desktop sidebar */} |
| @@ -1,9 +1,24 @@ | ||
| 1 | +import { resolve } from "node:path"; | |
| 1 | 2 | import { defineConfig } from "vite"; |
| 2 | 3 | import react from "@vitejs/plugin-react"; |
| 3 | 4 | import tailwindcss from "@tailwindcss/vite"; |
| 4 | −import { createServerHandlers } from "@executor/server"; | |
| 5 | 5 | |
| 6 | −const handlersPromise = createServerHandlers(); | |
| 6 | +type HotBackend = { | |
| 7 | + readonly api: { | |
| 8 | + readonly handler: (request: Request) => Promise<Response>; | |
| 9 | + readonly dispose: () => Promise<void>; | |
| 10 | + }; | |
| 11 | + readonly mcp: { | |
| 12 | + readonly handleRequest: (request: Request) => Promise<Response>; | |
| 13 | + readonly close: () => Promise<void>; | |
| 14 | + }; | |
| 15 | + readonly dispose: () => Promise<void>; | |
| 16 | +}; | |
| 17 | + | |
| 18 | +const DEV_BACKEND_MODULE_ID = `/@fs/${resolve( | |
| 19 | + process.cwd(), | |
| 20 | + "../server/src/dev-backend.ts", | |
| 21 | +).replace(/\\/g, "/")}`; | |
| 7 | 22 | |
| 8 | 23 | // Build a Web Request from a Node IncomingMessage |
| 9 | 24 | const toWebRequest = async ( |
| @@ -69,6 +84,69 @@ export default defineConfig({ · 59 unmodified lines | ||
| 69 | 84 | { |
| 70 | 85 | name: "executor-api", |
| 71 | 86 | configureServer(server) { |
| 87 | + let backendPromise: Promise<HotBackend> | null = null; | |
| 88 | + let reloadPromise: Promise<void> | null = null; | |
| 89 | + | |
| 90 | + const loadBackend = () => | |
| 91 | + server | |
| 92 | + .ssrLoadModule(DEV_BACKEND_MODULE_ID) | |
| 93 | + .then((mod) => mod.createHotBackend() as Promise<HotBackend>); | |
| 94 | + | |
| 95 | + const getBackend = () => { | |
| 96 | + if (!backendPromise) { | |
| 97 | + backendPromise = loadBackend(); | |
| 98 | + } | |
| 99 | + return backendPromise; | |
| 100 | + }; | |
| 101 | + | |
| 102 | + const shouldReloadBackend = (file: string) => | |
| 103 | + /\.(ts|tsx)$/.test(file) && | |
| 104 | + !file.includes("/apps/web/") && | |
| 105 | + ( | |
| 106 | + file.includes("/apps/server/") || | |
| 107 | + file.includes("/packages/core/") || | |
| 108 | + file.includes("/packages/plugins/") || | |
| 109 | + file.includes("/packages/hosts/") | |
| 110 | + ); | |
| 111 | + | |
| 112 | + server.httpServer?.once("close", () => { | |
| 113 | + const pending = backendPromise; | |
| 114 | + backendPromise = null; | |
| 115 | + void pending?.then((backend) => backend.dispose()).catch(() => undefined); | |
| 116 | + }); | |
| 117 | + | |
| 118 | + server.watcher.on("change", (file) => { | |
| 119 | + if (!shouldReloadBackend(file) || reloadPromise) { | |
| 120 | + return; | |
| 121 | + } | |
| 122 | + | |
| 123 | + reloadPromise = (async () => { | |
| 124 | + server.config.logger.info(`[executor-api] hot reloading backend: ${file}`); | |
| 125 | + | |
| 126 | + const previous = await getBackend().catch(() => null); | |
| 127 | + | |
| 128 | + server.moduleGraph.invalidateAll(); | |
| 129 | + backendPromise = loadBackend(); | |
| 130 | + | |
| 131 | + await backendPromise; | |
| 132 | + await previous?.dispose().catch(() => undefined); | |
| 133 | + | |
| 134 | + server.ws.send({ | |
| 135 | + type: "custom", | |
| 136 | + event: "executor:backend-updated", | |
| 137 | + data: { file }, | |
| 138 | + }); | |
| 139 | + })() | |
| 140 | + .catch((error) => { | |
| 141 | + server.config.logger.error( | |
| 142 | + `[executor-api] backend reload failed: ${error instanceof Error ? error.message : String(error)}`, | |
| 143 | + ); | |
| 144 | + }) | |
| 145 | + .finally(() => { | |
| 146 | + reloadPromise = null; | |
| 147 | + }); | |
| 148 | + }); | |
| 149 | + | |
| 72 | 150 | server.middlewares.use(async (req, res, next) => { |
| 73 | 151 | const url = req.url ?? "/"; |
| 74 | 152 | |
| @@ -80,12 +158,12 @@ export default defineConfig({ · 5 unmodified lines | ||
| 80 | 158 | |
| 81 | 159 | if (!isApi && !isMcp) return next(); |
| 82 | 160 | |
| 83 | − const handlers = await handlersPromise; | |
| 161 | + const backend = await getBackend(); | |
| 84 | 162 | const request = await toWebRequest(req); |
| 85 | 163 | |
| 86 | 164 | const response = isMcp |
| 87 | − ? await handlers.mcp.handleRequest(request) | |
| 88 | − : await handlers.api.handler(request); | |
| 165 | + ? await backend.mcp.handleRequest(request) | |
| 166 | + : await backend.api.handler(request); | |
| 89 | 167 | |
| 90 | 168 | await sendWebResponse(response, res); |
| 91 | 169 | }); |
| @@ -17,6 +17,9 @@ const SourceResponse = Schema.Struct({ · 16 unmodified lines | ||
| 17 | 17 | id: Schema.String, |
| 18 | 18 | name: Schema.String, |
| 19 | 19 | kind: Schema.String, |
| 20 | + runtime: Schema.optional(Schema.Boolean), | |
| 21 | + canRemove: Schema.optional(Schema.Boolean), | |
| 22 | + canRefresh: Schema.optional(Schema.Boolean), | |
| 20 | 23 | }); |
| 21 | 24 | |
| 22 | 25 | const SourceRemoveResponse = Schema.Struct({ |
| @@ -2,18 +2,41 @@ import { Effect } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { ToolId } from "../ids"; |
| 4 | 4 | import { ToolNotFoundError, ToolInvocationError } from "../errors"; |
| 5 | −import type { ToolRegistration, ToolInvoker, ToolListFilter, InvokeOptions } from "../tools"; | |
| 5 | +import type { | |
| 6 | + ToolRegistration, | |
| 7 | + ToolInvoker, | |
| 8 | + ToolListFilter, | |
| 9 | + InvokeOptions, | |
| 10 | + RuntimeToolHandler, | |
| 11 | +} from "../tools"; | |
| 6 | 12 | import { reattachDefs } from "../schema-refs"; |
| 7 | 13 | |
| 8 | 14 | export const makeInMemoryToolRegistry = () => { |
| 9 | 15 | const tools = new Map<string, ToolRegistration>(); |
| 16 | + const runtimeTools = new Map<string, ToolRegistration>(); | |
| 17 | + const runtimeHandlers = new Map<string, RuntimeToolHandler>(); | |
| 10 | 18 | const invokers = new Map<string, ToolInvoker>(); |
| 11 | 19 | const sharedDefs = new Map<string, unknown>(); |
| 20 | + const runtimeDefs = new Map<string, unknown>(); | |
| 21 | + | |
| 22 | + const getTool = (toolId: ToolId): ToolRegistration | undefined => | |
| 23 | + runtimeTools.get(toolId) ?? tools.get(toolId); | |
| 24 | + | |
| 25 | + const getDefs = (): Map<string, unknown> => { | |
| 26 | + const defs = new Map<string, unknown>(); | |
| 27 | + for (const [k, v] of sharedDefs) defs.set(k, v); | |
| 28 | + for (const [k, v] of runtimeDefs) defs.set(k, v); | |
| 29 | + return defs; | |
| 30 | + }; | |
| 12 | 31 | |
| 13 | 32 | return { |
| 14 | 33 | list: (filter?: ToolListFilter) => |
| 15 | 34 | Effect.sync(() => { |
| 16 | − let result = [...tools.values()]; | |
| 35 | + const byId = new Map<string, ToolRegistration>(); | |
| 36 | + for (const tool of tools.values()) byId.set(tool.id, tool); | |
| 37 | + for (const tool of runtimeTools.values()) byId.set(tool.id, tool); | |
| 38 | + | |
| 39 | + let result = [...byId.values()]; | |
| 17 | 40 | if (filter?.sourceId) { |
| 18 | 41 | const sid = filter.sourceId; |
| 19 | 42 | result = result.filter((t) => t.sourceId === sid); |
| @@ -36,12 +59,12 @@ export const makeInMemoryToolRegistry = () => { · 16 unmodified lines | ||
| 36 | 59 | }), |
| 37 | 60 | |
| 38 | 61 | schema: (toolId: ToolId) => |
| 39 | − Effect.fromNullable(tools.get(toolId)).pipe( | |
| 62 | + Effect.fromNullable(getTool(toolId)).pipe( | |
| 40 | 63 | Effect.mapError(() => new ToolNotFoundError({ toolId })), |
| 41 | 64 | Effect.map((t) => ({ |
| 42 | 65 | id: t.id, |
| 43 | − inputSchema: reattachDefs(t.inputSchema, sharedDefs), | |
| 44 | − outputSchema: reattachDefs(t.outputSchema, sharedDefs), | |
| 66 | + inputSchema: reattachDefs(t.inputSchema, getDefs()), | |
| 67 | + outputSchema: reattachDefs(t.outputSchema, getDefs()), | |
| 45 | 68 | })), |
| 46 | 69 | ), |
| 47 | 70 | |
| @@ -51,6 +74,9 @@ export const makeInMemoryToolRegistry = () => { · 3 unmodified lines | ||
| 51 | 74 | for (const [k, v] of sharedDefs) { |
| 52 | 75 | result[k] = v; |
| 53 | 76 | } |
| 77 | + for (const [k, v] of runtimeDefs) { | |
| 78 | + result[k] = v; | |
| 79 | + } | |
| 54 | 80 | return result; |
| 55 | 81 | }), |
| 56 | 82 | |
| @@ -61,6 +87,20 @@ export const makeInMemoryToolRegistry = () => { · 4 unmodified lines | ||
| 61 | 87 | } |
| 62 | 88 | }), |
| 63 | 89 | |
| 90 | + registerRuntimeDefinitions: (defs: Record<string, unknown>) => | |
| 91 | + Effect.sync(() => { | |
| 92 | + for (const [k, v] of Object.entries(defs)) { | |
| 93 | + runtimeDefs.set(k, v); | |
| 94 | + } | |
| 95 | + }), | |
| 96 | + | |
| 97 | + unregisterRuntimeDefinitions: (names: readonly string[]) => | |
| 98 | + Effect.sync(() => { | |
| 99 | + for (const name of names) { | |
| 100 | + runtimeDefs.delete(name); | |
| 101 | + } | |
| 102 | + }), | |
| 103 | + | |
| 64 | 104 | registerInvoker: (pluginKey: string, invoker: ToolInvoker) => |
| 65 | 105 | Effect.sync(() => { |
| 66 | 106 | invokers.set(pluginKey, invoker); |
| @@ -68,8 +108,12 @@ export const makeInMemoryToolRegistry = () => { · 1 unmodified line | ||
| 68 | 108 | |
| 69 | 109 | resolveAnnotations: (toolId: ToolId) => |
| 70 | 110 | Effect.gen(function* () { |
| 71 | − const tool = tools.get(toolId); | |
| 111 | + const tool = getTool(toolId); | |
| 72 | 112 | if (!tool) return undefined; |
| 113 | + const runtimeHandler = runtimeHandlers.get(toolId); | |
| 114 | + if (runtimeHandler?.resolveAnnotations) { | |
| 115 | + return yield* runtimeHandler.resolveAnnotations(); | |
| 116 | + } | |
| 73 | 117 | const invoker = invokers.get(tool.pluginKey); |
| 74 | 118 | if (!invoker?.resolveAnnotations) return undefined; |
| 75 | 119 | return yield* invoker.resolveAnnotations(toolId); |
| @@ -77,9 +121,13 @@ export const makeInMemoryToolRegistry = () => { · 1 unmodified line | ||
| 77 | 121 | |
| 78 | 122 | invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) => |
| 79 | 123 | Effect.gen(function* () { |
| 80 | − const tool = yield* Effect.fromNullable(tools.get(toolId)).pipe( | |
| 124 | + const tool = yield* Effect.fromNullable(getTool(toolId)).pipe( | |
| 81 | 125 | Effect.mapError(() => new ToolNotFoundError({ toolId })), |
| 82 | 126 | ); |
| 127 | + const runtimeHandler = runtimeHandlers.get(toolId); | |
| 128 | + if (runtimeHandler) { | |
| 129 | + return yield* runtimeHandler.invoke(args, options); | |
| 130 | + } | |
| 83 | 131 | const invoker = invokers.get(tool.pluginKey); |
| 84 | 132 | if (!invoker) { |
| 85 | 133 | return yield* new ToolInvocationError({ |
| @@ -98,10 +146,32 @@ export const makeInMemoryToolRegistry = () => { · 12 unmodified lines | ||
| 98 | 146 | } |
| 99 | 147 | }), |
| 100 | 148 | |
| 149 | + registerRuntime: (newTools: readonly ToolRegistration[]) => | |
| 150 | + Effect.sync(() => { | |
| 151 | + for (const t of newTools) { | |
| 152 | + runtimeTools.set(t.id, t); | |
| 153 | + } | |
| 154 | + }), | |
| 155 | + | |
| 156 | + registerRuntimeHandler: (toolId: ToolId, handler: RuntimeToolHandler) => | |
| 157 | + Effect.sync(() => { | |
| 158 | + runtimeHandlers.set(toolId, handler); | |
| 159 | + }), | |
| 160 | + | |
| 161 | + unregisterRuntime: (toolIds: readonly ToolId[]) => | |
| 162 | + Effect.sync(() => { | |
| 163 | + for (const id of toolIds) { | |
| 164 | + runtimeTools.delete(id); | |
| 165 | + runtimeHandlers.delete(id); | |
| 166 | + } | |
| 167 | + }), | |
| 168 | + | |
| 101 | 169 | unregister: (toolIds: readonly ToolId[]) => |
| 102 | 170 | Effect.sync(() => { |
| 103 | 171 | for (const id of toolIds) { |
| 104 | 172 | tools.delete(id); |
| 173 | + runtimeTools.delete(id); | |
| 174 | + runtimeHandlers.delete(id); | |
| 105 | 175 | } |
| 106 | 176 | }), |
| 107 | 177 | |
| @@ -112,6 +182,12 @@ export const makeInMemoryToolRegistry = () => { · 4 unmodified lines | ||
| 112 | 182 | tools.delete(id); |
| 113 | 183 | } |
| 114 | 184 | } |
| 185 | + for (const [id, t] of runtimeTools) { | |
| 186 | + if (t.sourceId === sourceId) { | |
| 187 | + runtimeTools.delete(id); | |
| 188 | + runtimeHandlers.delete(id); | |
| 189 | + } | |
| 190 | + } | |
| 115 | 191 | }), |
| 116 | 192 | }; |
| 117 | 193 | }; |
| @@ -4,11 +4,13 @@ import { Effect, Exit, Schema } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | import { |
| 5 | 5 | createExecutor, |
| 6 | 6 | makeTestConfig, |
| 7 | + makeInMemorySourceRegistry, | |
| 7 | 8 | inMemoryToolsPlugin, |
| 8 | 9 | tool, |
| 9 | 10 | FormElicitation, |
| 10 | 11 | UrlElicitation, |
| 11 | 12 | ElicitationResponse, |
| 13 | + Source, | |
| 12 | 14 | type MemoryToolContext, |
| 13 | 15 | type ToolId, |
| 14 | 16 | type InvokeOptions, |
| @@ -40,6 +42,42 @@ describe("SDK Executor", () => { · 25 unmodified lines | ||
| 40 | 42 | }), |
| 41 | 43 | ); |
| 42 | 44 | |
| 45 | + it.effect("runtime sources are listed separately from source managers", () => | |
| 46 | + Effect.gen(function* () { | |
| 47 | + const sources = makeInMemorySourceRegistry(); | |
| 48 | + | |
| 49 | + yield* sources.registerRuntime(new Source({ | |
| 50 | + id: "executor.openapi", | |
| 51 | + name: "OpenAPI", | |
| 52 | + kind: "openapi", | |
| 53 | + runtime: true, | |
| 54 | + canRemove: false, | |
| 55 | + canRefresh: false, | |
| 56 | + })); | |
| 57 | + | |
| 58 | + yield* sources.addManager({ | |
| 59 | + kind: "openapi", | |
| 60 | + list: () => | |
| 61 | + Effect.succeed([ | |
| 62 | + new Source({ | |
| 63 | + id: "vercel", | |
| 64 | + name: "Vercel API", | |
| 65 | + kind: "openapi", | |
| 66 | + runtime: false, | |
| 67 | + canRemove: true, | |
| 68 | + canRefresh: false, | |
| 69 | + }), | |
| 70 | + ]), | |
| 71 | + remove: () => Effect.void, | |
| 72 | + }); | |
| 73 | + | |
| 74 | + expect((yield* sources.list()).map((source) => source.id)).toEqual([ | |
| 75 | + "executor.openapi", | |
| 76 | + "vercel", | |
| 77 | + ]); | |
| 78 | + }), | |
| 79 | + ); | |
| 80 | + | |
| 43 | 81 | it.effect("memory plugin registers tools and they are discoverable", () => |
| 44 | 82 | Effect.gen(function* () { |
| 45 | 83 | const executor = yield* createExecutor( |
| @@ -355,7 +393,9 @@ describe("SDK Executor", () => { · 309 unmodified lines | ||
| 355 | 393 | ); |
| 356 | 394 | |
| 357 | 395 | const error = yield* Effect.flip( |
| 358 | − executor.tools.invoke("auth.login", {}, autoApprove), | |
| 396 | + executor.tools.invoke("auth.login", {}, { | |
| 397 | + onElicitation: undefined as never, | |
| 398 | + }), | |
| 359 | 399 | ); |
| 360 | 400 | expect(error._tag).toBe("ElicitationDeclinedError"); |
| 361 | 401 | }), |
| @@ -20,6 +20,7 @@ export { · 19 unmodified lines | ||
| 20 | 20 | ToolAnnotations, |
| 21 | 21 | ToolListFilter, |
| 22 | 22 | type ToolInvoker, |
| 23 | + type RuntimeToolHandler, | |
| 23 | 24 | type InvokeOptions, |
| 24 | 25 | } from "./tools"; |
| 25 | 26 | |
| @@ -81,6 +82,14 @@ export { · 55 unmodified lines | ||
| 81 | 82 | // Schema ref utilities |
| 82 | 83 | export { hoistDefinitions, collectRefs, reattachDefs } from "./schema-refs"; |
| 83 | 84 | |
| 85 | +// Runtime tools | |
| 86 | +export { | |
| 87 | + registerRuntimeTools, | |
| 88 | + runtimeTool, | |
| 89 | + type RuntimeSourceDefinition, | |
| 90 | + type RuntimeToolDefinition, | |
| 91 | +} from "./runtime-tools"; | |
| 92 | + | |
| 84 | 93 | // In-memory implementations |
| 85 | 94 | export { makeInMemoryToolRegistry } from "./in-memory/tool-registry"; |
| 86 | 95 | export { makeInMemorySecretStore, makeInMemorySecretProvider } from "./in-memory/secret-store"; |
| @@ -0,0 +1,183 @@ | ||
| 1 | +import { Effect, JSONSchema, Schema } from "effect"; | |
| 2 | + | |
| 3 | +import { ToolId } from "./ids"; | |
| 4 | +import { ToolInvocationError } from "./errors"; | |
| 5 | +import { Source } from "./sources"; | |
| 6 | +import { | |
| 7 | + ToolInvocationResult, | |
| 8 | + type ToolRegistration, | |
| 9 | + type RuntimeToolHandler, | |
| 10 | +} from "./tools"; | |
| 11 | +import { hoistDefinitions } from "./schema-refs"; | |
| 12 | + | |
| 13 | +export interface RuntimeToolDefinition<TInput = unknown, TOutput = unknown> { | |
| 14 | + readonly id: string; | |
| 15 | + readonly sourceId?: string; | |
| 16 | + readonly name: string; | |
| 17 | + readonly description?: string; | |
| 18 | + readonly inputSchema: Schema.Schema<TInput, any, never>; | |
| 19 | + readonly outputSchema?: Schema.Schema<TOutput, any, never>; | |
| 20 | + readonly handler: (args: TInput) => Effect.Effect<unknown, unknown>; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export interface RuntimeSourceDefinition { | |
| 24 | + readonly id: string; | |
| 25 | + readonly name: string; | |
| 26 | + readonly kind?: string; | |
| 27 | + readonly canRemove?: boolean; | |
| 28 | + readonly canRefresh?: boolean; | |
| 29 | +} | |
| 30 | + | |
| 31 | +interface RuntimeHandlerEntry { | |
| 32 | + readonly decode: (args: unknown) => unknown; | |
| 33 | + readonly handler: (args: unknown) => Effect.Effect<unknown, unknown>; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export const runtimeTool = <TInput, TOutput>( | |
| 37 | + def: RuntimeToolDefinition<TInput, TOutput>, | |
| 38 | +): RuntimeToolDefinition<TInput, TOutput> => def; | |
| 39 | + | |
| 40 | +const buildRuntimeTool = ( | |
| 41 | + pluginKey: string, | |
| 42 | + sourceId: string, | |
| 43 | + def: RuntimeToolDefinition, | |
| 44 | +): { | |
| 45 | + readonly registration: ToolRegistration; | |
| 46 | + readonly definitions: Record<string, unknown>; | |
| 47 | + readonly entry: RuntimeHandlerEntry; | |
| 48 | +} => { | |
| 49 | + const inputJson = JSONSchema.make(def.inputSchema); | |
| 50 | + const outputJson = def.outputSchema ? JSONSchema.make(def.outputSchema) : undefined; | |
| 51 | + | |
| 52 | + const inputHoist = hoistDefinitions(inputJson); | |
| 53 | + const outputHoist = hoistDefinitions(outputJson); | |
| 54 | + | |
| 55 | + return { | |
| 56 | + registration: { | |
| 57 | + id: ToolId.make(def.id), | |
| 58 | + pluginKey, | |
| 59 | + sourceId, | |
| 60 | + name: def.name, | |
| 61 | + description: def.description, | |
| 62 | + inputSchema: inputHoist.stripped, | |
| 63 | + outputSchema: outputHoist.stripped, | |
| 64 | + }, | |
| 65 | + definitions: { | |
| 66 | + ...inputHoist.defs, | |
| 67 | + ...outputHoist.defs, | |
| 68 | + }, | |
| 69 | + entry: { | |
| 70 | + decode: Schema.decodeUnknownSync(def.inputSchema), | |
| 71 | + handler: def.handler as (args: unknown) => Effect.Effect<unknown, unknown>, | |
| 72 | + }, | |
| 73 | + }; | |
| 74 | +}; | |
| 75 | + | |
| 76 | +const toRuntimeHandler = ( | |
| 77 | + toolId: ToolId, | |
| 78 | + entry: RuntimeHandlerEntry, | |
| 79 | +): RuntimeToolHandler => ({ | |
| 80 | + invoke: (args) => | |
| 81 | + Effect.try({ | |
| 82 | + try: () => entry.decode(args), | |
| 83 | + catch: (err) => | |
| 84 | + new ToolInvocationError({ | |
| 85 | + toolId, | |
| 86 | + message: `Invalid input: ${err instanceof Error ? err.message : String(err)}`, | |
| 87 | + cause: err, | |
| 88 | + }), | |
| 89 | + }).pipe( | |
| 90 | + Effect.flatMap((input) => entry.handler(input)), | |
| 91 | + Effect.map((data) => new ToolInvocationResult({ data, error: null })), | |
| 92 | + Effect.mapError((err) => | |
| 93 | + err instanceof ToolInvocationError | |
| 94 | + ? err | |
| 95 | + : new ToolInvocationError({ | |
| 96 | + toolId, | |
| 97 | + message: err instanceof Error ? err.message : String(err), | |
| 98 | + cause: err, | |
| 99 | + }), | |
| 100 | + ), | |
| 101 | + ), | |
| 102 | +}); | |
| 103 | + | |
| 104 | +export const registerRuntimeTools = < | |
| 105 | + const TTools extends readonly RuntimeToolDefinition<any, any>[], | |
| 106 | +>(input: { | |
| 107 | + readonly registry: { | |
| 108 | + readonly registerRuntimeDefinitions: ( | |
| 109 | + defs: Record<string, unknown>, | |
| 110 | + ) => Effect.Effect<void>; | |
| 111 | + readonly unregisterRuntimeDefinitions: ( | |
| 112 | + names: readonly string[], | |
| 113 | + ) => Effect.Effect<void>; | |
| 114 | + readonly registerRuntime: ( | |
| 115 | + tools: readonly ToolRegistration[], | |
| 116 | + ) => Effect.Effect<void>; | |
| 117 | + readonly registerRuntimeHandler: ( | |
| 118 | + toolId: ToolId, | |
| 119 | + handler: RuntimeToolHandler, | |
| 120 | + ) => Effect.Effect<void>; | |
| 121 | + readonly unregisterRuntime: ( | |
| 122 | + toolIds: readonly ToolId[], | |
| 123 | + ) => Effect.Effect<void>; | |
| 124 | + }; | |
| 125 | + readonly sources?: { | |
| 126 | + readonly registerRuntime: (source: Source) => Effect.Effect<void>; | |
| 127 | + readonly unregisterRuntime: (sourceId: string) => Effect.Effect<void>; | |
| 128 | + }; | |
| 129 | + readonly pluginKey: string; | |
| 130 | + readonly source?: RuntimeSourceDefinition; | |
| 131 | + readonly tools: TTools; | |
| 132 | +}) => | |
| 133 | + Effect.gen(function* () { | |
| 134 | + const built = yield* Effect.forEach(input.tools, (tool) => | |
| 135 | + Effect.sync(() => { | |
| 136 | + const sourceId = tool.sourceId ?? input.source?.id; | |
| 137 | + if (!sourceId) { | |
| 138 | + throw new Error( | |
| 139 | + `Runtime tool "${tool.id}" is missing a sourceId and no shared runtime source was provided`, | |
| 140 | + ); | |
| 141 | + } | |
| 142 | + return buildRuntimeTool(input.pluginKey, sourceId, tool); | |
| 143 | + }), | |
| 144 | + ); | |
| 145 | + | |
| 146 | + if (input.source && input.sources) { | |
| 147 | + yield* input.sources.registerRuntime(new Source({ | |
| 148 | + id: input.source.id, | |
| 149 | + name: input.source.name, | |
| 150 | + kind: input.source.kind ?? input.pluginKey, | |
| 151 | + runtime: true, | |
| 152 | + canRemove: input.source.canRemove ?? false, | |
| 153 | + canRefresh: input.source.canRefresh ?? false, | |
| 154 | + })); | |
| 155 | + } | |
| 156 | + | |
| 157 | + const defs: Record<string, unknown> = {}; | |
| 158 | + for (const tool of built) { | |
| 159 | + Object.assign(defs, tool.definitions); | |
| 160 | + yield* input.registry.registerRuntimeHandler( | |
| 161 | + tool.registration.id, | |
| 162 | + toRuntimeHandler(tool.registration.id, tool.entry), | |
| 163 | + ); | |
| 164 | + } | |
| 165 | + | |
| 166 | + yield* input.registry.registerRuntimeDefinitions(defs); | |
| 167 | + yield* input.registry.registerRuntime(built.map((tool) => tool.registration)); | |
| 168 | + | |
| 169 | + const toolIds = built.map((tool) => tool.registration.id); | |
| 170 | + const defNames = Object.keys(defs); | |
| 171 | + | |
| 172 | + return { | |
| 173 | + toolIds, | |
| 174 | + close: () => | |
| 175 | + Effect.gen(function* () { | |
| 176 | + yield* input.registry.unregisterRuntime(toolIds); | |
| 177 | + yield* input.registry.unregisterRuntimeDefinitions(defNames); | |
| 178 | + if (input.source && input.sources) { | |
| 179 | + yield* input.sources.unregisterRuntime(input.source.id); | |
| 180 | + } | |
| 181 | + }), | |
| 182 | + }; | |
| 183 | + }); | |
| @@ -11,6 +11,12 @@ export class Source extends Schema.Class<Source>("Source")({ · 10 unmodified lines | ||
| 11 | 11 | name: Schema.String, |
| 12 | 12 | /** Plugin kind that manages this source (e.g. "openapi", "mcp") */ |
| 13 | 13 | kind: Schema.String, |
| 14 | + /** True when the source is provided by the running executor */ | |
| 15 | + runtime: Schema.optional(Schema.Boolean), | |
| 16 | + /** Whether the source supports removal */ | |
| 17 | + canRemove: Schema.optional(Schema.Boolean), | |
| 18 | + /** Whether the source supports refresh */ | |
| 19 | + canRefresh: Schema.optional(Schema.Boolean), | |
| 14 | 20 | }) {} |
| 15 | 21 | |
| 16 | 22 | // --------------------------------------------------------------------------- |
| @@ -46,6 +52,12 @@ export class SourceRegistry extends Context.Tag( · 29 unmodified lines | ||
| 46 | 52 | /** Register a source manager (called by plugins during init) */ |
| 47 | 53 | readonly addManager: (manager: SourceManager) => Effect.Effect<void>; |
| 48 | 54 | |
| 55 | + /** Register a runtime-only source entry. */ | |
| 56 | + readonly registerRuntime: (source: Source) => Effect.Effect<void>; | |
| 57 | + | |
| 58 | + /** Unregister a runtime-only source entry by id. */ | |
| 59 | + readonly unregisterRuntime: (sourceId: string) => Effect.Effect<void>; | |
| 60 | + | |
| 49 | 61 | /** List all sources across all plugins */ |
| 50 | 62 | readonly list: () => Effect.Effect<readonly Source[]>; |
| 51 | 63 | |
| @@ -63,6 +75,7 @@ export class SourceRegistry extends Context.Tag( · 11 unmodified lines | ||
| 63 | 75 | |
| 64 | 76 | export const makeInMemorySourceRegistry = () => { |
| 65 | 77 | const managers = new Map<string, SourceManager>(); |
| 78 | + const runtimeSources = new Map<string, Source>(); | |
| 66 | 79 | |
| 67 | 80 | return { |
| 68 | 81 | addManager: (manager: SourceManager) => |
| @@ -70,9 +83,19 @@ export const makeInMemorySourceRegistry = () => { · 1 unmodified line | ||
| 70 | 83 | managers.set(manager.kind, manager); |
| 71 | 84 | }), |
| 72 | 85 | |
| 86 | + registerRuntime: (source: Source) => | |
| 87 | + Effect.sync(() => { | |
| 88 | + runtimeSources.set(source.id, source); | |
| 89 | + }), | |
| 90 | + | |
| 91 | + unregisterRuntime: (sourceId: string) => | |
| 92 | + Effect.sync(() => { | |
| 93 | + runtimeSources.delete(sourceId); | |
| 94 | + }), | |
| 95 | + | |
| 73 | 96 | list: () => |
| 74 | 97 | Effect.gen(function* () { |
| 75 | − const all: Source[] = []; | |
| 98 | + const all: Source[] = [...runtimeSources.values()]; | |
| 76 | 99 | for (const manager of managers.values()) { |
| 77 | 100 | const sources = yield* manager.list(); |
| 78 | 101 | all.push(...sources); |
| @@ -82,6 +105,14 @@ export const makeInMemorySourceRegistry = () => { · 3 unmodified lines | ||
| 82 | 105 | |
| 83 | 106 | remove: (sourceId: string) => |
| 84 | 107 | Effect.gen(function* () { |
| 108 | + const runtimeSource = runtimeSources.get(sourceId); | |
| 109 | + if (runtimeSource) { | |
| 110 | + if (runtimeSource.canRemove) { | |
| 111 | + runtimeSources.delete(sourceId); | |
| 112 | + } | |
| 113 | + return; | |
| 114 | + } | |
| 115 | + | |
| 85 | 116 | for (const manager of managers.values()) { |
| 86 | 117 | const sources = yield* manager.list(); |
| 87 | 118 | if (sources.some((s) => s.id === sourceId)) { |
| @@ -93,6 +124,11 @@ export const makeInMemorySourceRegistry = () => { · 5 unmodified lines | ||
| 93 | 124 | |
| 94 | 125 | refresh: (sourceId: string) => |
| 95 | 126 | Effect.gen(function* () { |
| 127 | + const runtimeSource = runtimeSources.get(sourceId); | |
| 128 | + if (runtimeSource) { | |
| 129 | + return; | |
| 130 | + } | |
| 131 | + | |
| 96 | 132 | for (const manager of managers.values()) { |
| 97 | 133 | const sources = yield* manager.list(); |
| 98 | 134 | if (sources.some((s) => s.id === sourceId)) { |
| @@ -98,6 +98,19 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 97 unmodified lines | ||
| 98 | 98 | defs: Record<string, unknown>, |
| 99 | 99 | ) => Effect.Effect<void>; |
| 100 | 100 | |
| 101 | + /** | |
| 102 | + * Register named schema definitions for runtime tools. These remain | |
| 103 | + * runtime-only and are not persisted by storage-backed registries. | |
| 104 | + */ | |
| 105 | + readonly registerRuntimeDefinitions: ( | |
| 106 | + defs: Record<string, unknown>, | |
| 107 | + ) => Effect.Effect<void>; | |
| 108 | + | |
| 109 | + /** Remove named schema definitions that were registered for runtime tools. */ | |
| 110 | + readonly unregisterRuntimeDefinitions: ( | |
| 111 | + names: readonly string[], | |
| 112 | + ) => Effect.Effect<void>; | |
| 113 | + | |
| 101 | 114 | /** |
| 102 | 115 | * Register a plugin invoker. Must be called before registering tools |
| 103 | 116 | * with the corresponding pluginKey. |
| @@ -119,6 +132,25 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 15 unmodified lines | ||
| 119 | 132 | tools: readonly ToolRegistration[], |
| 120 | 133 | ) => Effect.Effect<void>; |
| 121 | 134 | |
| 135 | + /** | |
| 136 | + * Register runtime-only tools. These should behave like normal tools for | |
| 137 | + * listing, schema lookup, discovery, and invocation, but are not persisted. | |
| 138 | + */ | |
| 139 | + readonly registerRuntime: ( | |
| 140 | + tools: readonly ToolRegistration[], | |
| 141 | + ) => Effect.Effect<void>; | |
| 142 | + | |
| 143 | + /** Register a runtime-only handler for a specific tool id. */ | |
| 144 | + readonly registerRuntimeHandler: ( | |
| 145 | + toolId: ToolId, | |
| 146 | + handler: RuntimeToolHandler, | |
| 147 | + ) => Effect.Effect<void>; | |
| 148 | + | |
| 149 | + /** Unregister runtime-only tools by id without touching persisted storage. */ | |
| 150 | + readonly unregisterRuntime: ( | |
| 151 | + toolIds: readonly ToolId[], | |
| 152 | + ) => Effect.Effect<void>; | |
| 153 | + | |
| 122 | 154 | /** Unregister tools by id (used by plugins on cleanup) */ |
| 123 | 155 | readonly unregister: ( |
| 124 | 156 | toolIds: readonly ToolId[], |
| @@ -151,6 +183,18 @@ export interface ToolInvoker { · 26 unmodified lines | ||
| 151 | 183 | ) => Effect.Effect<ToolAnnotations | undefined>; |
| 152 | 184 | } |
| 153 | 185 | |
| 186 | +export interface RuntimeToolHandler { | |
| 187 | + readonly invoke: ( | |
| 188 | + args: unknown, | |
| 189 | + options: InvokeOptions, | |
| 190 | + ) => Effect.Effect< | |
| 191 | + ToolInvocationResult, | |
| 192 | + ToolInvocationError | ElicitationDeclinedError | |
| 193 | + >; | |
| 194 | + | |
| 195 | + readonly resolveAnnotations?: () => Effect.Effect<ToolAnnotations | undefined>; | |
| 196 | +} | |
| 197 | + | |
| 154 | 198 | // --------------------------------------------------------------------------- |
| 155 | 199 | // ToolRegistration — pure data, no closures |
| 156 | 200 | // --------------------------------------------------------------------------- |
| @@ -122,6 +122,30 @@ describe("KvToolRegistry", () => { · 121 unmodified lines | ||
| 122 | 122 | }), |
| 123 | 123 | ), |
| 124 | 124 | ); |
| 125 | + | |
| 126 | + it.effect("runtime tools are listed but not persisted", () => | |
| 127 | + withKv((kv) => | |
| 128 | + Effect.gen(function* () { | |
| 129 | + const reg1 = makeKvToolRegistry(scopeKv(kv, "tools"), scopeKv(kv, "defs")); | |
| 130 | + yield* reg1.registerRuntime([ | |
| 131 | + { | |
| 132 | + id: ToolId.make("executor.test.runtime"), | |
| 133 | + pluginKey: "test", | |
| 134 | + sourceId: "executor.test", | |
| 135 | + name: "runtime", | |
| 136 | + description: "Runtime-only tool", | |
| 137 | + }, | |
| 138 | + ]); | |
| 139 | + | |
| 140 | + const listed = yield* reg1.list(); | |
| 141 | + expect(listed.map((tool) => tool.id)).toContain("executor.test.runtime"); | |
| 142 | + | |
| 143 | + const reg2 = makeKvToolRegistry(scopeKv(kv, "tools"), scopeKv(kv, "defs")); | |
| 144 | + const relisted = yield* reg2.list(); | |
| 145 | + expect(relisted.map((tool) => tool.id)).not.toContain("executor.test.runtime"); | |
| 146 | + }), | |
| 147 | + ), | |
| 148 | + ); | |
| 125 | 149 | }); |
| 126 | 150 | |
| 127 | 151 | // --------------------------------------------------------------------------- |
| @@ -6,7 +6,12 @@ import { Effect, Schema } from "effect"; · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | import type { ToolId, ScopedKv } from "@executor/sdk"; |
| 8 | 8 | import { ToolNotFoundError, ToolInvocationError, ToolRegistration } from "@executor/sdk"; |
| 9 | −import type { ToolInvoker, ToolListFilter, InvokeOptions } from "@executor/sdk"; | |
| 9 | +import type { | |
| 10 | + ToolInvoker, | |
| 11 | + ToolListFilter, | |
| 12 | + InvokeOptions, | |
| 13 | + RuntimeToolHandler, | |
| 14 | +} from "@executor/sdk"; | |
| 10 | 15 | import { reattachDefs } from "@executor/sdk"; |
| 11 | 16 | |
| 12 | 17 | // --------------------------------------------------------------------------- |
| @@ -25,9 +30,12 @@ export const makeKvToolRegistry = ( · 12 unmodified lines | ||
| 25 | 30 | toolsKv: ScopedKv, |
| 26 | 31 | defsKv: ScopedKv, |
| 27 | 32 | ) => { |
| 33 | + const runtimeTools = new Map<string, ToolRegistration>(); | |
| 34 | + const runtimeHandlers = new Map<string, RuntimeToolHandler>(); | |
| 35 | + const runtimeDefs = new Map<string, unknown>(); | |
| 28 | 36 | const invokers = new Map<string, ToolInvoker>(); |
| 29 | 37 | |
| 30 | − const getTool = (id: string): Effect.Effect<ToolRegistration | null> => | |
| 38 | + const getPersistedTool = (id: string): Effect.Effect<ToolRegistration | null> => | |
| 31 | 39 | Effect.gen(function* () { |
| 32 | 40 | const raw = yield* toolsKv.get(id); |
| 33 | 41 | if (!raw) return null; |
| @@ -43,13 +51,19 @@ export const makeKvToolRegistry = ( · 9 unmodified lines | ||
| 43 | 51 | const getDefsMap = (): Effect.Effect<Map<string, unknown>> => |
| 44 | 52 | Effect.gen(function* () { |
| 45 | 53 | const entries = yield* defsKv.list(); |
| 46 | − return new Map(entries.map((e) => [e.key, JSON.parse(e.value)])); | |
| 54 | + const defs = new Map(entries.map((e) => [e.key, JSON.parse(e.value)])); | |
| 55 | + for (const [k, v] of runtimeDefs) defs.set(k, v); | |
| 56 | + return defs; | |
| 47 | 57 | }); |
| 48 | 58 | |
| 49 | 59 | return { |
| 50 | 60 | list: (filter?: ToolListFilter) => |
| 51 | 61 | Effect.gen(function* () { |
| 52 | − let tools = yield* getAllTools(); | |
| 62 | + const byId = new Map<string, ToolRegistration>(); | |
| 63 | + for (const tool of yield* getAllTools()) byId.set(tool.id, tool); | |
| 64 | + for (const tool of runtimeTools.values()) byId.set(tool.id, tool); | |
| 65 | + | |
| 66 | + let tools = [...byId.values()]; | |
| 53 | 67 | if (filter?.sourceId) { |
| 54 | 68 | const sid = filter.sourceId; |
| 55 | 69 | tools = tools.filter((t) => t.sourceId === sid); |
| @@ -73,7 +87,7 @@ export const makeKvToolRegistry = ( · 17 unmodified lines | ||
| 73 | 87 | |
| 74 | 88 | schema: (toolId: ToolId) => |
| 75 | 89 | Effect.gen(function* () { |
| 76 | − const t = yield* getTool(toolId); | |
| 90 | + const t = runtimeTools.get(toolId) ?? (yield* getPersistedTool(toolId)); | |
| 77 | 91 | if (!t) return yield* new ToolNotFoundError({ toolId }); |
| 78 | 92 | const defs = yield* getDefsMap(); |
| 79 | 93 | return { |
| @@ -96,13 +110,31 @@ export const makeKvToolRegistry = ( · 16 unmodified lines | ||
| 96 | 110 | } |
| 97 | 111 | }), |
| 98 | 112 | |
| 113 | + registerRuntimeDefinitions: (newDefs: Record<string, unknown>) => | |
| 114 | + Effect.sync(() => { | |
| 115 | + for (const [name, schema] of Object.entries(newDefs)) { | |
| 116 | + runtimeDefs.set(name, schema); | |
| 117 | + } | |
| 118 | + }), | |
| 119 | + | |
| 120 | + unregisterRuntimeDefinitions: (names: readonly string[]) => | |
| 121 | + Effect.sync(() => { | |
| 122 | + for (const name of names) { | |
| 123 | + runtimeDefs.delete(name); | |
| 124 | + } | |
| 125 | + }), | |
| 126 | + | |
| 99 | 127 | registerInvoker: (pluginKey: string, invoker: ToolInvoker) => |
| 100 | 128 | Effect.sync(() => { invokers.set(pluginKey, invoker); }), |
| 101 | 129 | |
| 102 | 130 | resolveAnnotations: (toolId: ToolId) => |
| 103 | 131 | Effect.gen(function* () { |
| 104 | − const tool = yield* getTool(toolId); | |
| 132 | + const tool = runtimeTools.get(toolId) ?? (yield* getPersistedTool(toolId)); | |
| 105 | 133 | if (!tool) return undefined; |
| 134 | + const runtimeHandler = runtimeHandlers.get(toolId); | |
| 135 | + if (runtimeHandler?.resolveAnnotations) { | |
| 136 | + return yield* runtimeHandler.resolveAnnotations(); | |
| 137 | + } | |
| 106 | 138 | const invoker = invokers.get(tool.pluginKey); |
| 107 | 139 | if (!invoker?.resolveAnnotations) return undefined; |
| 108 | 140 | return yield* invoker.resolveAnnotations(toolId); |
| @@ -110,8 +142,12 @@ export const makeKvToolRegistry = ( · 1 unmodified line | ||
| 110 | 142 | |
| 111 | 143 | invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) => |
| 112 | 144 | Effect.gen(function* () { |
| 113 | − const tool = yield* getTool(toolId); | |
| 145 | + const tool = runtimeTools.get(toolId) ?? (yield* getPersistedTool(toolId)); | |
| 114 | 146 | if (!tool) return yield* new ToolNotFoundError({ toolId }); |
| 147 | + const runtimeHandler = runtimeHandlers.get(toolId); | |
| 148 | + if (runtimeHandler) { | |
| 149 | + return yield* runtimeHandler.invoke(args, options); | |
| 150 | + } | |
| 115 | 151 | const invoker = invokers.get(tool.pluginKey); |
| 116 | 152 | if (!invoker) { |
| 117 | 153 | return yield* new ToolInvocationError({ |
| @@ -130,9 +166,31 @@ export const makeKvToolRegistry = ( · 12 unmodified lines | ||
| 130 | 166 | } |
| 131 | 167 | }), |
| 132 | 168 | |
| 169 | + registerRuntime: (newTools: readonly ToolRegistration[]) => | |
| 170 | + Effect.sync(() => { | |
| 171 | + for (const t of newTools) { | |
| 172 | + runtimeTools.set(t.id, t); | |
| 173 | + } | |
| 174 | + }), | |
| 175 | + | |
| 176 | + registerRuntimeHandler: (toolId: ToolId, handler: RuntimeToolHandler) => | |
| 177 | + Effect.sync(() => { | |
| 178 | + runtimeHandlers.set(toolId, handler); | |
| 179 | + }), | |
| 180 | + | |
| 181 | + unregisterRuntime: (toolIds: readonly ToolId[]) => | |
| 182 | + Effect.sync(() => { | |
| 183 | + for (const id of toolIds) { | |
| 184 | + runtimeTools.delete(id); | |
| 185 | + runtimeHandlers.delete(id); | |
| 186 | + } | |
| 187 | + }), | |
| 188 | + | |
| 133 | 189 | unregister: (toolIds: readonly ToolId[]) => |
| 134 | 190 | Effect.gen(function* () { |
| 135 | 191 | for (const id of toolIds) { |
| 192 | + runtimeTools.delete(id); | |
| 193 | + runtimeHandlers.delete(id); | |
| 136 | 194 | yield* toolsKv.delete(id); |
| 137 | 195 | } |
| 138 | 196 | }), |
| @@ -145,6 +203,12 @@ export const makeKvToolRegistry = ( · 6 unmodified lines | ||
| 145 | 203 | yield* toolsKv.delete(t.id); |
| 146 | 204 | } |
| 147 | 205 | } |
| 206 | + for (const [id, t] of runtimeTools) { | |
| 207 | + if (t.sourceId === sourceId) { | |
| 208 | + runtimeTools.delete(id); | |
| 209 | + runtimeHandlers.delete(id); | |
| 210 | + } | |
| 211 | + } | |
| 148 | 212 | }), |
| 149 | 213 | }; |
| 150 | 214 | }; |
| @@ -11,7 +11,17 @@ import { · 10 unmodified lines | ||
| 11 | 11 | } from "@effect/platform"; |
| 12 | 12 | import { NodeHttpServer } from "@effect/platform-node"; |
| 13 | 13 | |
| 14 | −import { createExecutor, makeTestConfig, SecretId, type InvokeOptions } from "@executor/sdk"; | |
| 14 | +import { | |
| 15 | + createExecutor, | |
| 16 | + makeInMemoryPolicyEngine, | |
| 17 | + makeInMemorySecretStore, | |
| 18 | + makeInMemorySourceRegistry, | |
| 19 | + makeInMemoryToolRegistry, | |
| 20 | + makeTestConfig, | |
| 21 | + ScopeId, | |
| 22 | + SecretId, | |
| 23 | + type InvokeOptions, | |
| 24 | +} from "@executor/sdk"; | |
| 15 | 25 | import { openApiPlugin } from "./plugin"; |
| 16 | 26 | |
| 17 | 27 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; |
| @@ -118,6 +128,154 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 100 unmodified lines | ||
| 118 | 128 | }), |
| 119 | 129 | ); |
| 120 | 130 | |
| 131 | + it.effect("registers runtime executor.openapi tools", () => | |
| 132 | + Effect.gen(function* () { | |
| 133 | + const httpClient = yield* HttpClient.HttpClient; | |
| 134 | + const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | |
| 135 | + | |
| 136 | + const executor = yield* createExecutor( | |
| 137 | + makeTestConfig({ | |
| 138 | + plugins: [ | |
| 139 | + openApiPlugin({ httpClientLayer: clientLayer }), | |
| 140 | + ] as const, | |
| 141 | + }), | |
| 142 | + ); | |
| 143 | + | |
| 144 | + const tools = yield* executor.tools.list(); | |
| 145 | + const ids = tools.map((t) => t.id); | |
| 146 | + expect(ids).toContain("executor.openapi.previewSpec"); | |
| 147 | + expect(ids).toContain("executor.openapi.addSource"); | |
| 148 | + }), | |
| 149 | + ); | |
| 150 | + | |
| 151 | + it.effect("lists executor.openapi as a runtime source", () => | |
| 152 | + Effect.gen(function* () { | |
| 153 | + const httpClient = yield* HttpClient.HttpClient; | |
| 154 | + const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | |
| 155 | + | |
| 156 | + const executor = yield* createExecutor( | |
| 157 | + makeTestConfig({ | |
| 158 | + plugins: [ | |
| 159 | + openApiPlugin({ httpClientLayer: clientLayer }), | |
| 160 | + ] as const, | |
| 161 | + }), | |
| 162 | + ); | |
| 163 | + | |
| 164 | + const sources = yield* executor.sources.list(); | |
| 165 | + | |
| 166 | + expect(sources).toContainEqual(expect.objectContaining({ | |
| 167 | + id: "executor.openapi", | |
| 168 | + name: "OpenAPI", | |
| 169 | + kind: "openapi", | |
| 170 | + runtime: true, | |
| 171 | + canRemove: false, | |
| 172 | + canRefresh: false, | |
| 173 | + })); | |
| 174 | + }), | |
| 175 | + ); | |
| 176 | + | |
| 177 | + it.effect("closing an executor does not remove added source tools from the shared registry", () => | |
| 178 | + Effect.gen(function* () { | |
| 179 | + const httpClient = yield* HttpClient.HttpClient; | |
| 180 | + const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | |
| 181 | + | |
| 182 | + const scope = { | |
| 183 | + id: ScopeId.make("test-scope"), | |
| 184 | + parentId: null, | |
| 185 | + name: "test", | |
| 186 | + createdAt: new Date(), | |
| 187 | + } as const; | |
| 188 | + | |
| 189 | + const sharedConfig = { | |
| 190 | + scope, | |
| 191 | + tools: makeInMemoryToolRegistry(), | |
| 192 | + sources: makeInMemorySourceRegistry(), | |
| 193 | + secrets: makeInMemorySecretStore(), | |
| 194 | + policies: makeInMemoryPolicyEngine(), | |
| 195 | + }; | |
| 196 | + | |
| 197 | + const executor1 = yield* createExecutor({ | |
| 198 | + ...sharedConfig, | |
| 199 | + plugins: [ | |
| 200 | + openApiPlugin({ httpClientLayer: clientLayer }), | |
| 201 | + ] as const, | |
| 202 | + }); | |
| 203 | + | |
| 204 | + yield* executor1.openapi.addSpec({ | |
| 205 | + spec: specJson, | |
| 206 | + namespace: "persisted", | |
| 207 | + }); | |
| 208 | + | |
| 209 | + expect((yield* executor1.tools.list()).map((tool) => tool.id)).toContain( | |
| 210 | + "persisted.items.listItems", | |
| 211 | + ); | |
| 212 | + | |
| 213 | + yield* executor1.close(); | |
| 214 | + | |
| 215 | + const executor2 = yield* createExecutor({ | |
| 216 | + ...sharedConfig, | |
| 217 | + plugins: [ | |
| 218 | + openApiPlugin({ httpClientLayer: clientLayer }), | |
| 219 | + ] as const, | |
| 220 | + }); | |
| 221 | + | |
| 222 | + expect((yield* executor2.tools.list()).map((tool) => tool.id)).toContain( | |
| 223 | + "persisted.items.listItems", | |
| 224 | + ); | |
| 225 | + }), | |
| 226 | + ); | |
| 227 | + | |
| 228 | + it.effect("invokes runtime previewSpec through executor.tools.invoke", () => | |
| 229 | + Effect.gen(function* () { | |
| 230 | + const httpClient = yield* HttpClient.HttpClient; | |
| 231 | + const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | |
| 232 | + | |
| 233 | + const executor = yield* createExecutor( | |
| 234 | + makeTestConfig({ | |
| 235 | + plugins: [ | |
| 236 | + openApiPlugin({ httpClientLayer: clientLayer }), | |
| 237 | + ] as const, | |
| 238 | + }), | |
| 239 | + ); | |
| 240 | + | |
| 241 | + const result = yield* executor.tools.invoke( | |
| 242 | + "executor.openapi.previewSpec", | |
| 243 | + { spec: specJson }, | |
| 244 | + autoApprove, | |
| 245 | + ); | |
| 246 | + | |
| 247 | + expect(result.error).toBeNull(); | |
| 248 | + expect((result.data as { operationCount: number }).operationCount).toBeGreaterThanOrEqual(2); | |
| 249 | + }), | |
| 250 | + ); | |
| 251 | + | |
| 252 | + it.effect("invokes runtime addSource through executor.tools.invoke", () => | |
| 253 | + Effect.gen(function* () { | |
| 254 | + const httpClient = yield* HttpClient.HttpClient; | |
| 255 | + const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | |
| 256 | + | |
| 257 | + const executor = yield* createExecutor( | |
| 258 | + makeTestConfig({ | |
| 259 | + plugins: [ | |
| 260 | + openApiPlugin({ httpClientLayer: clientLayer }), | |
| 261 | + ] as const, | |
| 262 | + }), | |
| 263 | + ); | |
| 264 | + | |
| 265 | + const result = yield* executor.tools.invoke( | |
| 266 | + "executor.openapi.addSource", | |
| 267 | + { spec: specJson, namespace: "runtime" }, | |
| 268 | + autoApprove, | |
| 269 | + ); | |
| 270 | + | |
| 271 | + expect(result.error).toBeNull(); | |
| 272 | + expect(result.data).toEqual({ sourceId: "runtime", toolCount: 3 }); | |
| 273 | + expect((yield* executor.tools.list()).map((t) => t.id)).toContain( | |
| 274 | + "runtime.items.listItems", | |
| 275 | + ); | |
| 276 | + }), | |
| 277 | + ); | |
| 278 | + | |
| 121 | 279 | it.effect("resolves secret-backed headers at invocation time", () => |
| 122 | 280 | Effect.gen(function* () { |
| 123 | 281 | const httpClient = yield* HttpClient.HttpClient; |
| @@ -299,7 +457,11 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 175 unmodified lines | ||
| 299 | 457 | |
| 300 | 458 | yield* executor.openapi.removeSpec("removable"); |
| 301 | 459 | |
| 302 | − expect(yield* executor.tools.list()).toHaveLength(0); | |
| 460 | + const remaining = yield* executor.tools.list(); | |
| 461 | + expect(remaining.map((tool) => tool.id)).toEqual([ | |
| 462 | + "executor.openapi.previewSpec", | |
| 463 | + "executor.openapi.addSource", | |
| 464 | + ]); | |
| 303 | 465 | }), |
| 304 | 466 | ); |
| 305 | 467 | }); |
| @@ -1,10 +1,12 @@ | ||
| 1 | −import { Effect, Option } from "effect"; | |
| 1 | +import { Effect, Option, Schema } from "effect"; | |
| 2 | 2 | import { FetchHttpClient, HttpClient } from "@effect/platform"; |
| 3 | 3 | import type { Layer } from "effect"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | 6 | Source, |
| 7 | 7 | definePlugin, |
| 8 | + registerRuntimeTools, | |
| 9 | + runtimeTool, | |
| 8 | 10 | type ExecutorPlugin, |
| 9 | 11 | type PluginContext, |
| 10 | 12 | ToolId, |
| @@ -18,19 +20,20 @@ import { makeOpenApiInvoker } from "./invoke"; · 7 unmodified lines | ||
| 18 | 20 | import { resolveBaseUrl } from "./openapi-utils"; |
| 19 | 21 | import type { OpenApiOperationStore } from "./operation-store"; |
| 20 | 22 | import { makeInMemoryOperationStore } from "./kv-operation-store"; |
| 21 | −import { previewSpec, type SpecPreview } from "./preview"; | |
| 23 | +import { previewSpec, SpecPreview } from "./preview"; | |
| 22 | 24 | import { |
| 25 | + HeaderValue as HeaderValueSchema, | |
| 23 | 26 | InvocationConfig, |
| 24 | 27 | OperationBinding, |
| 28 | + type HeaderValue as HeaderValueValue, | |
| 25 | 29 | } from "./types"; |
| 26 | 30 | |
| 27 | − | |
| 28 | 31 | // --------------------------------------------------------------------------- |
| 29 | 32 | // Plugin config |
| 30 | 33 | // --------------------------------------------------------------------------- |
| 31 | 34 | |
| 32 | 35 | /** A header value — either a static string or a reference to a secret */ |
| 33 | −export type HeaderValue = string | { readonly secretId: string; readonly prefix?: string }; | |
| 36 | +export type HeaderValue = HeaderValueValue; | |
| 34 | 37 | |
| 35 | 38 | export interface OpenApiSpecConfig { |
| 36 | 39 | readonly spec: string; |
| @@ -46,9 +49,7 @@ export interface OpenApiSpecConfig { · 9 unmodified lines | ||
| 46 | 49 | |
| 47 | 50 | export interface OpenApiPluginExtension { |
| 48 | 51 | /** Preview a spec without registering — returns metadata, auth strategies, header presets */ |
| 49 | − readonly previewSpec: ( | |
| 50 | − specText: string, | |
| 51 | − ) => Effect.Effect<SpecPreview, Error>; | |
| 52 | + readonly previewSpec: (specText: string) => Effect.Effect<SpecPreview, Error>; | |
| 52 | 53 | |
| 53 | 54 | /** Add an OpenAPI spec and register its operations as tools */ |
| 54 | 55 | readonly addSpec: ( |
| @@ -63,14 +64,35 @@ export interface OpenApiPluginExtension { · 8 unmodified lines | ||
| 63 | 64 | // Helpers |
| 64 | 65 | // --------------------------------------------------------------------------- |
| 65 | 66 | |
| 67 | +const PreviewSpecInputSchema = Schema.Struct({ | |
| 68 | + spec: Schema.String, | |
| 69 | +}); | |
| 70 | +type PreviewSpecInput = typeof PreviewSpecInputSchema.Type; | |
| 71 | + | |
| 72 | +const AddSourceInputSchema = Schema.Struct({ | |
| 73 | + spec: Schema.String, | |
| 74 | + baseUrl: Schema.optional(Schema.String), | |
| 75 | + namespace: Schema.optional(Schema.String), | |
| 76 | + headers: Schema.optional( | |
| 77 | + Schema.Record({ key: Schema.String, value: HeaderValueSchema }), | |
| 78 | + ), | |
| 79 | +}); | |
| 80 | +type AddSourceInput = typeof AddSourceInputSchema.Type; | |
| 81 | + | |
| 82 | +const AddSourceOutputSchema = Schema.Struct({ | |
| 83 | + sourceId: Schema.String, | |
| 84 | + toolCount: Schema.Number, | |
| 85 | +}); | |
| 86 | + | |
| 66 | 87 | const toRegistration = ( |
| 67 | 88 | def: ToolDefinition, |
| 68 | 89 | namespace: string, |
| 69 | 90 | ): ToolRegistration => { |
| 70 | 91 | const op = def.operation; |
| 71 | 92 | const description = Option.getOrElse(op.description, () => |
| 72 | − Option.getOrElse(op.summary, () => | |
| 73 | − `${op.method.toUpperCase()} ${op.pathTemplate}`, | |
| 93 | + Option.getOrElse( | |
| 94 | + op.summary, | |
| 95 | + () => `${op.method.toUpperCase()} ${op.pathTemplate}`, | |
| 74 | 96 | ), |
| 75 | 97 | ); |
| 76 | 98 | return { |
| @@ -101,10 +123,8 @@ export const openApiPlugin = (options?: { · 24 unmodified lines | ||
| 101 | 123 | readonly operationStore?: OpenApiOperationStore; |
| 102 | 124 | }): ExecutorPlugin<"openapi", OpenApiPluginExtension> => { |
| 103 | 125 | const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer; |
| 104 | − const operationStore = options?.operationStore ?? makeInMemoryOperationStore(); | |
| 105 | − | |
| 106 | − // Track added sources so we can list them | |
| 107 | − const addedSources = new Map<string, Source>(); | |
| 126 | + const operationStore = | |
| 127 | + options?.operationStore ?? makeInMemoryOperationStore(); | |
| 108 | 128 | |
| 109 | 129 | return definePlugin({ |
| 110 | 130 | key: "openapi", |
| @@ -120,111 +140,142 @@ export const openApiPlugin = (options?: { · 9 unmodified lines | ||
| 120 | 140 | }), |
| 121 | 141 | ); |
| 122 | 142 | |
| 123 | − // Restore source metadata from persistent store | |
| 124 | − const savedMetas = yield* operationStore.listSourceMeta(); | |
| 125 | − for (const meta of savedMetas) { | |
| 126 | − addedSources.set(meta.namespace, new Source({ | |
| 127 | − id: meta.namespace, | |
| 128 | − name: meta.name, | |
| 129 | − kind: "openapi", | |
| 130 | − })); | |
| 131 | − } | |
| 132 | − | |
| 133 | 143 | // Tools are already persisted in the KV tool registry — no need to |
| 134 | 144 | // re-register them. We only need the source list and the invoker. |
| 135 | − | |
| 136 | 145 | // Register source manager so the core can list/remove/refresh our sources |
| 137 | 146 | yield* ctx.sources.addManager({ |
| 138 | 147 | kind: "openapi", |
| 139 | 148 | |
| 140 | 149 | list: () => |
| 141 | − Effect.sync(() => [...addedSources.values()]), | |
| 150 | + operationStore.listSourceMeta().pipe( | |
| 151 | + Effect.map((metas) => | |
| 152 | + metas.map( | |
| 153 | + (meta) => | |
| 154 | + new Source({ | |
| 155 | + id: meta.namespace, | |
| 156 | + name: meta.name, | |
| 157 | + kind: "openapi", | |
| 158 | + runtime: false, | |
| 159 | + canRemove: true, | |
| 160 | + canRefresh: false, | |
| 161 | + }), | |
| 162 | + ), | |
| 163 | + ), | |
| 164 | + ), | |
| 142 | 165 | |
| 143 | 166 | remove: (sourceId: string) => |
| 144 | 167 | Effect.gen(function* () { |
| 145 | 168 | yield* operationStore.removeByNamespace(sourceId); |
| 146 | 169 | yield* operationStore.removeSourceMeta(sourceId); |
| 147 | 170 | yield* ctx.tools.unregisterBySource(sourceId); |
| 148 | − addedSources.delete(sourceId); | |
| 149 | 171 | }), |
| 150 | 172 | |
| 151 | 173 | // TODO: refresh requires storing original config per namespace |
| 152 | 174 | }); |
| 153 | 175 | |
| 176 | + const addSpecInternal = (config: OpenApiSpecConfig) => | |
| 177 | + Effect.gen(function* () { | |
| 178 | + const doc = yield* parse(config.spec); | |
| 179 | + const result = yield* extract(doc); | |
| 180 | + | |
| 181 | + const namespace = | |
| 182 | + config.namespace ?? | |
| 183 | + Option.getOrElse(result.title, () => "api") | |
| 184 | + .toLowerCase() | |
| 185 | + .replace(/[^a-z0-9]+/g, "_"); | |
| 186 | + | |
| 187 | + if (doc.components?.schemas) { | |
| 188 | + yield* ctx.tools.registerDefinitions(doc.components.schemas); | |
| 189 | + } | |
| 190 | + | |
| 191 | + const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers); | |
| 192 | + const invocationConfig = new InvocationConfig({ | |
| 193 | + baseUrl, | |
| 194 | + headers: config.headers ?? {}, | |
| 195 | + }); | |
| 196 | + | |
| 197 | + const definitions = compileToolDefinitions(result.operations); | |
| 198 | + | |
| 199 | + const registrations = definitions.map((def) => | |
| 200 | + toRegistration(def, namespace), | |
| 201 | + ); | |
| 202 | + | |
| 203 | + yield* Effect.forEach( | |
| 204 | + definitions, | |
| 205 | + (def) => | |
| 206 | + operationStore.put( | |
| 207 | + ToolId.make(`${namespace}.${def.toolPath}`), | |
| 208 | + namespace, | |
| 209 | + toBinding(def), | |
| 210 | + invocationConfig, | |
| 211 | + ), | |
| 212 | + { discard: true }, | |
| 213 | + ); | |
| 214 | + | |
| 215 | + yield* ctx.tools.register(registrations); | |
| 216 | + | |
| 217 | + const sourceName = Option.getOrElse(result.title, () => namespace); | |
| 218 | + yield* operationStore.putSourceMeta({ | |
| 219 | + namespace, | |
| 220 | + name: sourceName, | |
| 221 | + }); | |
| 222 | + | |
| 223 | + return { sourceId: namespace, toolCount: registrations.length }; | |
| 224 | + }); | |
| 225 | + | |
| 226 | + const runtimeTools = yield* registerRuntimeTools({ | |
| 227 | + registry: ctx.tools, | |
| 228 | + sources: ctx.sources, | |
| 229 | + pluginKey: "openapi", | |
| 230 | + source: { | |
| 231 | + id: "executor.openapi", | |
| 232 | + name: "OpenAPI", | |
| 233 | + }, | |
| 234 | + tools: [ | |
| 235 | + runtimeTool({ | |
| 236 | + id: "executor.openapi.previewSpec", | |
| 237 | + name: "previewSpec", | |
| 238 | + description: | |
| 239 | + "Preview an OpenAPI document before adding it as a source", | |
| 240 | + inputSchema: PreviewSpecInputSchema, | |
| 241 | + outputSchema: SpecPreview, | |
| 242 | + handler: ({ spec }: PreviewSpecInput) => previewSpec(spec), | |
| 243 | + }), | |
| 244 | + runtimeTool({ | |
| 245 | + id: "executor.openapi.addSource", | |
| 246 | + name: "addSource", | |
| 247 | + description: | |
| 248 | + "Add an OpenAPI source and register its operations as tools", | |
| 249 | + inputSchema: AddSourceInputSchema, | |
| 250 | + outputSchema: AddSourceOutputSchema, | |
| 251 | + handler: (input: AddSourceInput) => addSpecInternal(input), | |
| 252 | + }), | |
| 253 | + ], | |
| 254 | + }); | |
| 255 | + | |
| 154 | 256 | return { |
| 155 | 257 | extension: { |
| 156 | 258 | previewSpec: (specText: string) => previewSpec(specText), |
| 157 | 259 | |
| 158 | 260 | addSpec: (config: OpenApiSpecConfig) => |
| 159 | − Effect.gen(function* () { | |
| 160 | − const doc = yield* parse(config.spec); | |
| 161 | − const result = yield* extract(doc); | |
| 162 | − | |
| 163 | − const namespace = | |
| 164 | − config.namespace ?? | |
| 165 | − Option.getOrElse(result.title, () => "api") | |
| 166 | − .toLowerCase() | |
| 167 | − .replace(/[^a-z0-9]+/g, "_"); | |
| 168 | − | |
| 169 | − if (doc.components?.schemas) { | |
| 170 | − yield* ctx.tools.registerDefinitions(doc.components.schemas); | |
| 171 | − } | |
| 172 | − | |
| 173 | − const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers); | |
| 174 | − const invocationConfig = new InvocationConfig({ | |
| 175 | − baseUrl, | |
| 176 | − headers: config.headers ?? {}, | |
| 177 | − }); | |
| 178 | − | |
| 179 | − const definitions = compileToolDefinitions(result.operations); | |
| 180 | − | |
| 181 | − const registrations = definitions.map((def) => | |
| 182 | − toRegistration(def, namespace), | |
| 183 | − ); | |
| 184 | − | |
| 185 | − yield* Effect.forEach( | |
| 186 | − definitions, | |
| 187 | − (def) => | |
| 188 | − operationStore.put( | |
| 189 | − ToolId.make(`${namespace}.${def.toolPath}`), | |
| 190 | − namespace, | |
| 191 | − toBinding(def), | |
| 192 | − invocationConfig, | |
| 193 | − ), | |
| 194 | − { discard: true }, | |
| 195 | − ); | |
| 196 | − | |
| 197 | − yield* ctx.tools.register(registrations); | |
| 198 | − | |
| 199 | − // Track the source — persist and cache | |
| 200 | − const sourceName = Option.getOrElse(result.title, () => namespace); | |
| 201 | − yield* operationStore.putSourceMeta({ namespace, name: sourceName }); | |
| 202 | − addedSources.set(namespace, new Source({ | |
| 203 | − id: namespace, | |
| 204 | − name: sourceName, | |
| 205 | − kind: "openapi", | |
| 206 | − })); | |
| 207 | − | |
| 208 | − return { toolCount: registrations.length }; | |
| 209 | − }), | |
| 261 | + addSpecInternal(config).pipe( | |
| 262 | + Effect.map(({ toolCount }) => ({ toolCount })), | |
| 263 | + ), | |
| 210 | 264 | |
| 211 | 265 | removeSpec: (namespace: string) => |
| 212 | 266 | Effect.gen(function* () { |
| 213 | − const toolIds = yield* operationStore.removeByNamespace(namespace); | |
| 267 | + const toolIds = | |
| 268 | + yield* operationStore.removeByNamespace(namespace); | |
| 214 | 269 | if (toolIds.length > 0) { |
| 215 | 270 | yield* ctx.tools.unregister(toolIds); |
| 216 | 271 | } |
| 217 | 272 | yield* operationStore.removeSourceMeta(namespace); |
| 218 | − addedSources.delete(namespace); | |
| 219 | 273 | }), |
| 220 | 274 | }, |
| 221 | 275 | |
| 222 | 276 | close: () => |
| 223 | 277 | Effect.gen(function* () { |
| 224 | − for (const sourceId of addedSources.keys()) { | |
| 225 | − yield* ctx.tools.unregisterBySource(sourceId); | |
| 226 | − } | |
| 227 | − addedSources.clear(); | |
| 278 | + yield* runtimeTools.close(); | |
| 228 | 279 | }), |
| 229 | 280 | }; |
| 230 | 281 | }), |
| @@ -114,9 +114,12 @@ describe("Real specs: Cloudflare API", () => { · 113 unmodified lines | ||
| 114 | 114 | expect(result.toolCount).toBeGreaterThan(1000); |
| 115 | 115 | |
| 116 | 116 | const tools = yield* executor.tools.list(); |
| 117 | − expect(tools.length).toBe(result.toolCount); | |
| 117 | + expect(tools.length).toBe(result.toolCount + 2); | |
| 118 | 118 | |
| 119 | − for (const tool of tools) { | |
| 119 | + const cloudflareTools = tools.filter((tool) => tool.sourceId === "cloudflare"); | |
| 120 | + expect(cloudflareTools.length).toBe(result.toolCount); | |
| 121 | + | |
| 122 | + for (const tool of cloudflareTools) { | |
| 120 | 123 | expect(tool.pluginKey).toBe("openapi"); |
| 121 | 124 | expect(tool.sourceId).toBe("cloudflare"); |
| 122 | 125 | } |
| @@ -212,7 +215,11 @@ describe("Real specs: Cloudflare API", () => { · 89 unmodified lines | ||
| 212 | 215 | |
| 213 | 216 | yield* executor.openapi.removeSpec("cloudflare"); |
| 214 | 217 | |
| 215 | − expect(yield* executor.tools.list()).toHaveLength(0); | |
| 218 | + const remaining = yield* executor.tools.list(); | |
| 219 | + expect(remaining.map((tool) => tool.id)).toEqual([ | |
| 220 | + "executor.openapi.previewSpec", | |
| 221 | + "executor.openapi.addSource", | |
| 222 | + ]); | |
| 216 | 223 | }), |
| 217 | 224 | ); |
| 218 | 225 | }); |