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 | id: s.id, | 17 | id: s.id, |
| 18 | name: s.name, | 18 | name: s.name, |
| 19 | kind: s.kind, | 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 | export { createApiHandler, createServerHandlers, type ApiHandler, type ServerHandlers } from "./main"; | 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 | export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./mcp"; | 4 | export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./mcp"; |
| @@ -16,7 +16,7 @@ import { SecretsHandlers } from "./handlers/secrets"; · 15 unmodified lines | |||
| 16 | import { OpenApiHandlersLive } from "./handlers/openapi"; | 16 | import { OpenApiHandlersLive } from "./handlers/openapi"; |
| 17 | import { McpSourceHandlersLive } from "./handlers/mcp-source"; | 17 | import { McpSourceHandlersLive } from "./handlers/mcp-source"; |
| 18 | import { OnePasswordHandlersLive } from "./handlers/onepassword"; | 18 | import { OnePasswordHandlersLive } from "./handlers/onepassword"; |
| 19 | import { ExecutorServiceLayer, getExecutor } from "./services/executor"; | 19 | import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor"; |
| 20 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; | 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 | // API Layer | 29 | // API Layer |
| 30 | // --------------------------------------------------------------------------- | 30 | // --------------------------------------------------------------------------- |
| 31 | 31 | ||
| 32 | const ApiLive = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( | 32 | const ApiBase = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( |
| 33 | Layer.provide([ | 33 | Layer.provide([ |
| 34 | ToolsHandlers, | 34 | ToolsHandlers, |
| 35 | SourcesHandlers, | 35 | SourcesHandlers, |
| @@ -38,7 +38,6 @@ const ApiLive = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( · 2 unmodified lines | |||
| 38 | McpSourceHandlersLive, | 38 | McpSourceHandlersLive, |
| 39 | OnePasswordHandlersLive, | 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 | readonly mcp: McpRequestHandler; | 52 | readonly mcp: McpRequestHandler; |
| 54 | }; | 53 | }; |
| 55 | 54 | ||
| 56 | export const createServerHandlers = async (): Promise<ServerHandlers> => { | 55 | const createApiHandlerWithExecutor = (executor: ServerExecutor) => |
| 57 | const executor = await getExecutor(); | 56 | HttpApiBuilder.toWebHandler( |
| 58 | |||
| 59 | const api = HttpApiBuilder.toWebHandler( | ||
| 60 | HttpApiSwagger.layer().pipe( | 57 | HttpApiSwagger.layer().pipe( |
| 61 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), | 58 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), |
| 62 | Layer.provideMerge(HttpApiBuilder.middlewareCors()), | 59 | Layer.provideMerge(HttpApiBuilder.middlewareCors()), |
| 63 | Layer.provideMerge(ApiLive), | 60 | Layer.provideMerge(ApiBase), |
| 61 | Layer.provideMerge(Layer.succeed(ExecutorService, executor)), | ||
| 64 | Layer.provideMerge(HttpServer.layerContext), | 62 | Layer.provideMerge(HttpServer.layerContext), |
| 65 | ), | 63 | ), |
| 66 | { middleware: HttpMiddleware.logger }, | 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 | const mcp = createMcpRequestHandler({ executor }); | 71 | const mcp = createMcpRequestHandler({ executor }); |
| 70 | 72 | ||
| 71 | return { api, mcp }; | 73 | return { api, mcp }; |
| 72 | }; | 74 | }; |
| 73 | 75 | ||
| 76 | export const createServerHandlers = async (): Promise<ServerHandlers> => | ||
| 77 | createServerHandlersWithExecutor(await getExecutor()); | ||
| 78 | |||
| 74 | // --------------------------------------------------------------------------- | 79 | // --------------------------------------------------------------------------- |
| 75 | // Backwards compat — standalone API handler (no MCP) | 80 | // Backwards compat — standalone API handler (no MCP) |
| 76 | // --------------------------------------------------------------------------- | 81 | // --------------------------------------------------------------------------- |
| @@ -80,7 +85,8 @@ export const createApiHandler = () => · 3 unmodified lines | |||
| 80 | HttpApiSwagger.layer().pipe( | 85 | HttpApiSwagger.layer().pipe( |
| 81 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), | 86 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), |
| 82 | Layer.provideMerge(HttpApiBuilder.middlewareCors()), | 87 | Layer.provideMerge(HttpApiBuilder.middlewareCors()), |
| 83 | Layer.provideMerge(ApiLive), | 88 | Layer.provideMerge(ApiBase), |
| 89 | Layer.provideMerge(ExecutorServiceLayer), | ||
| 84 | Layer.provideMerge(HttpServer.layerContext), | 90 | Layer.provideMerge(HttpServer.layerContext), |
| 85 | ), | 91 | ), |
| 86 | { middleware: HttpMiddleware.logger }, | 92 | { middleware: HttpMiddleware.logger }, |
| @@ -21,6 +21,10 @@ type ServerPlugins = readonly [ · 20 unmodified lines | |||
| 21 | ExecutorPlugin<"onepassword", OnePasswordExtension>, | 21 | ExecutorPlugin<"onepassword", OnePasswordExtension>, |
| 22 | ]; | 22 | ]; |
| 23 | export type ServerExecutor = Executor<ServerPlugins>; | 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 | // Service tag | 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 | * Get the shared executor instance. The ManagedRuntime keeps the SQLite | 111 | * Get the shared executor instance. The ManagedRuntime keeps the SQLite |
| 88 | * connection (and everything else) alive until the process exits. | 112 | * connection (and everything else) alive until the process exits. |
| 89 | */ | 113 | */ |
| 90 | export const getExecutor = (): Promise<ServerExecutor> => | 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 | * Provide `ExecutorService` to an Effect layer using the shared runtime. | 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 | const [refreshing, setRefreshing] = useState(false); | 34 | const [refreshing, setRefreshing] = useState(false); |
| 35 | 35 | ||
| 36 | const sourceData = Result.isSuccess(source) ? source.value : null; | 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 | const sourceTools: ToolSummary[] = useMemo(() => { | 40 | const sourceTools: ToolSummary[] = useMemo(() => { |
| 39 | if (!Result.isSuccess(tools)) return []; | 41 | if (!Result.isSuccess(tools)) return []; |
| @@ -85,6 +87,11 @@ export function SourceDetailPage(props: { namespace: string }) { · 45 unmodified lines | |||
| 85 | <h2 className="truncate text-sm font-semibold text-foreground"> | 87 | <h2 className="truncate text-sm font-semibold text-foreground"> |
| 86 | {sourceData?.name ?? namespace} | 88 | {sourceData?.name ?? namespace} |
| 87 | </h2> | 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 | <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground"> | 95 | <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground"> |
| 89 | {sourceData?.kind ?? "source"} | 96 | {sourceData?.kind ?? "source"} |
| 90 | </span> | 97 | </span> |
| @@ -96,16 +103,18 @@ export function SourceDetailPage(props: { namespace: string }) { · 5 unmodified lines | |||
| 96 | </div> | 103 | </div> |
| 97 | 104 | ||
| 98 | <div className="flex shrink-0 items-center gap-2"> | 105 | <div className="flex shrink-0 items-center gap-2"> |
| 99 | <button | 106 | {canRefresh && ( |
| 100 | type="button" | 107 | <button |
| 101 | onClick={() => void handleRefresh()} | 108 | type="button" |
| 102 | disabled={refreshing} | 109 | onClick={() => void handleRefresh()} |
| 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" | 110 | disabled={refreshing} |
| 104 | > | 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" |
| 105 | {refreshing ? "Refreshing…" : "Refresh"} | 112 | > |
| 106 | </button> | 113 | {refreshing ? "Refreshing…" : "Refresh"} |
| 114 | </button> | ||
| 115 | )} | ||
| 107 | 116 | ||
| 108 | {confirmDelete ? ( | 117 | {canRemove && (confirmDelete ? ( |
| 109 | <div className="flex items-center gap-2"> | 118 | <div className="flex items-center gap-2"> |
| 110 | <span className="text-[11px] font-medium text-destructive"> | 119 | <span className="text-[11px] font-medium text-destructive"> |
| 111 | Confirm? | 120 | Confirm? |
| @@ -135,7 +144,7 @@ export function SourceDetailPage(props: { namespace: string }) { · 23 unmodified lines | |||
| 135 | > | 144 | > |
| 136 | Delete | 145 | Delete |
| 137 | </button> | 146 | </button> |
| 138 | )} | 147 | ))} |
| 139 | </div> | 148 | </div> |
| 140 | </div> | 149 | </div> |
| 141 | 150 | ||
| @@ -25,6 +25,53 @@ export function SourcesPage() { · 24 unmodified lines | |||
| 25 | ? sourcePlugins.find((p) => p.key === adding) | 25 | ? sourcePlugins.find((p) => p.key === adding) |
| 26 | : undefined; | 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 | if (plugin) { | 75 | if (plugin) { |
| 29 | const AddComponent = plugin.add; | 76 | const AddComponent = plugin.add; |
| 30 | return ( | 77 | return ( |
| @@ -54,7 +101,7 @@ export function SourcesPage() { · 23 unmodified lines | |||
| 54 | Sources | 101 | Sources |
| 55 | </h1> | 102 | </h1> |
| 56 | <p className="mt-1.5 text-[14px] text-muted-foreground"> | 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 | </p> | 105 | </p> |
| 59 | </div> | 106 | </div> |
| 60 | <div className="flex gap-2"> | 107 | <div className="flex gap-2"> |
| @@ -82,8 +129,11 @@ export function SourcesPage() { · 21 unmodified lines | |||
| 82 | onFailure: () => ( | 129 | onFailure: () => ( |
| 83 | <p className="text-sm text-destructive">Failed to load sources</p> | 130 | <p className="text-sm text-destructive">Failed to load sources</p> |
| 84 | ), | 131 | ), |
| 85 | onSuccess: ({ value }) => | 132 | onSuccess: ({ value }) => { |
| 86 | value.length === 0 ? ( | 133 | const builtInSources = value.filter((source) => source.runtime); |
| 134 | const connectedSources = value.filter((source) => !source.runtime); | ||
| 135 | |||
| 136 | return value.length === 0 ? ( | ||
| 87 | <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20"> | 137 | <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20"> |
| 88 | <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4"> | 138 | <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4"> |
| 89 | <svg viewBox="0 0 24 24" fill="none" className="size-5"> | 139 | <svg viewBox="0 0 24 24" fill="none" className="size-5"> |
| @@ -98,39 +148,37 @@ export function SourcesPage() { · 8 unmodified lines | |||
| 98 | </p> | 148 | </p> |
| 99 | </div> | 149 | </div> |
| 100 | ) : ( | 150 | ) : ( |
| 101 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | 151 | <div className="space-y-8"> |
| 102 | {value.map((s) => ( | 152 | {builtInSources.length > 0 && ( |
| 103 | <Link | 153 | <section className="space-y-3"> |
| 104 | key={s.id} | 154 | <div> |
| 105 | to="/sources/$namespace" | 155 | <h2 className="text-sm font-semibold text-foreground"> |
| 106 | params={{ namespace: s.id }} | 156 | Built-in |
| 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" | 157 | </h2> |
| 108 | > | 158 | <p className="mt-1 text-[13px] text-muted-foreground"> |
| 109 | <div className="flex items-start gap-3"> | 159 | Runtime sources exposed by the loaded executor plugins. |
| 110 | <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> | 160 | </p> |
| 111 | <svg viewBox="0 0 16 16" className="size-4"> | 161 | </div> |
| 112 | <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.2" /> | 162 | {renderSourceGrid(builtInSources)} |
| 113 | <path d="M8 5v6M5 8h6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> | 163 | </section> |
| 114 | </svg> | 164 | )} |
| 115 | </div> | 165 | |
| 116 | <div className="min-w-0 flex-1"> | 166 | {connectedSources.length > 0 && ( |
| 117 | <div className="flex items-start justify-between gap-2"> | 167 | <section className="space-y-3"> |
| 118 | <div className="truncate text-sm font-semibold text-foreground"> | 168 | <div> |
| 119 | {s.name} | 169 | <h2 className="text-sm font-semibold text-foreground"> |
| 120 | </div> | 170 | Connected |
| 121 | <span className="shrink-0 rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground"> | 171 | </h2> |
| 122 | {s.kind} | 172 | <p className="mt-1 text-[13px] text-muted-foreground"> |
| 123 | </span> | 173 | User-configured sources available in this workspace. |
| 124 | </div> | 174 | </p> |
| 125 | <div className="mt-0.5 text-xs text-muted-foreground"> | ||
| 126 | {s.id} | ||
| 127 | </div> | ||
| 128 | </div> | ||
| 129 | </div> | 175 | </div> |
| 130 | </Link> | 176 | {renderSourceGrid(connectedSources)} |
| 131 | ))} | 177 | </section> |
| 178 | )} | ||
| 132 | </div> | 179 | </div> |
| 133 | ), | 180 | ); |
| 181 | }, | ||
| 134 | })} | 182 | })} |
| 135 | </div> | 183 | </div> |
| 136 | </div> | 184 | </div> |
| @@ -1,6 +1,13 @@ | |||
| 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; | 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; |
| 2 | import { useEffect, useRef, useState } from "react"; | 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 | // ── NavItem ────────────────────────────────────────────────────────────── | 12 | // ── NavItem ────────────────────────────────────────────────────────────── |
| 6 | 13 | ||
| @@ -157,6 +164,14 @@ function SidebarContent(props: { · 150 unmodified lines | |||
| 157 | export function Shell() { | 164 | export function Shell() { |
| 158 | const location = useLocation(); | 165 | const location = useLocation(); |
| 159 | const pathname = location.pathname; | 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 | const lastPathname = useRef(pathname); | 175 | const lastPathname = useRef(pathname); |
| 161 | const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); | 176 | const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); |
| 162 | if (lastPathname.current !== pathname) { | 177 | if (lastPathname.current !== pathname) { |
| @@ -174,6 +189,26 @@ export function Shell() { · 11 unmodified lines | |||
| 174 | }; | 189 | }; |
| 175 | }, [mobileSidebarOpen]); | 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 | return ( | 212 | return ( |
| 178 | <div className="flex h-screen overflow-hidden"> | 213 | <div className="flex h-screen overflow-hidden"> |
| 179 | {/* Desktop sidebar */} | 214 | {/* Desktop sidebar */} |
| @@ -1,9 +1,24 @@ | |||
| 1 | import { resolve } from "node:path"; | ||
| 1 | import { defineConfig } from "vite"; | 2 | import { defineConfig } from "vite"; |
| 2 | import react from "@vitejs/plugin-react"; | 3 | import react from "@vitejs/plugin-react"; |
| 3 | import tailwindcss from "@tailwindcss/vite"; | 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 | // Build a Web Request from a Node IncomingMessage | 23 | // Build a Web Request from a Node IncomingMessage |
| 9 | const toWebRequest = async ( | 24 | const toWebRequest = async ( |
| @@ -69,6 +84,69 @@ export default defineConfig({ · 59 unmodified lines | |||
| 69 | { | 84 | { |
| 70 | name: "executor-api", | 85 | name: "executor-api", |
| 71 | configureServer(server) { | 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 | server.middlewares.use(async (req, res, next) => { | 150 | server.middlewares.use(async (req, res, next) => { |
| 73 | const url = req.url ?? "/"; | 151 | const url = req.url ?? "/"; |
| 74 | 152 | ||
| @@ -80,12 +158,12 @@ export default defineConfig({ · 5 unmodified lines | |||
| 80 | 158 | ||
| 81 | if (!isApi && !isMcp) return next(); | 159 | if (!isApi && !isMcp) return next(); |
| 82 | 160 | ||
| 83 | const handlers = await handlersPromise; | 161 | const backend = await getBackend(); |
| 84 | const request = await toWebRequest(req); | 162 | const request = await toWebRequest(req); |
| 85 | 163 | ||
| 86 | const response = isMcp | 164 | const response = isMcp |
| 87 | ? await handlers.mcp.handleRequest(request) | 165 | ? await backend.mcp.handleRequest(request) |
| 88 | : await handlers.api.handler(request); | 166 | : await backend.api.handler(request); |
| 89 | 167 | ||
| 90 | await sendWebResponse(response, res); | 168 | await sendWebResponse(response, res); |
| 91 | }); | 169 | }); |
| @@ -17,6 +17,9 @@ const SourceResponse = Schema.Struct({ · 16 unmodified lines | |||
| 17 | id: Schema.String, | 17 | id: Schema.String, |
| 18 | name: Schema.String, | 18 | name: Schema.String, |
| 19 | kind: Schema.String, | 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 | const SourceRemoveResponse = Schema.Struct({ | 25 | const SourceRemoveResponse = Schema.Struct({ |
| @@ -2,18 +2,41 @@ import { Effect } from "effect"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { ToolId } from "../ids"; | 3 | import { ToolId } from "../ids"; |
| 4 | import { ToolNotFoundError, ToolInvocationError } from "../errors"; | 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 | import { reattachDefs } from "../schema-refs"; | 12 | import { reattachDefs } from "../schema-refs"; |
| 7 | 13 | ||
| 8 | export const makeInMemoryToolRegistry = () => { | 14 | export const makeInMemoryToolRegistry = () => { |
| 9 | const tools = new Map<string, ToolRegistration>(); | 15 | const tools = new Map<string, ToolRegistration>(); |
| 16 | const runtimeTools = new Map<string, ToolRegistration>(); | ||
| 17 | const runtimeHandlers = new Map<string, RuntimeToolHandler>(); | ||
| 10 | const invokers = new Map<string, ToolInvoker>(); | 18 | const invokers = new Map<string, ToolInvoker>(); |
| 11 | const sharedDefs = new Map<string, unknown>(); | 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 | return { | 32 | return { |
| 14 | list: (filter?: ToolListFilter) => | 33 | list: (filter?: ToolListFilter) => |
| 15 | Effect.sync(() => { | 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 | if (filter?.sourceId) { | 40 | if (filter?.sourceId) { |
| 18 | const sid = filter.sourceId; | 41 | const sid = filter.sourceId; |
| 19 | result = result.filter((t) => t.sourceId === sid); | 42 | result = result.filter((t) => t.sourceId === sid); |
| @@ -36,12 +59,12 @@ export const makeInMemoryToolRegistry = () => { · 16 unmodified lines | |||
| 36 | }), | 59 | }), |
| 37 | 60 | ||
| 38 | schema: (toolId: ToolId) => | 61 | schema: (toolId: ToolId) => |
| 39 | Effect.fromNullable(tools.get(toolId)).pipe( | 62 | Effect.fromNullable(getTool(toolId)).pipe( |
| 40 | Effect.mapError(() => new ToolNotFoundError({ toolId })), | 63 | Effect.mapError(() => new ToolNotFoundError({ toolId })), |
| 41 | Effect.map((t) => ({ | 64 | Effect.map((t) => ({ |
| 42 | id: t.id, | 65 | id: t.id, |
| 43 | inputSchema: reattachDefs(t.inputSchema, sharedDefs), | 66 | inputSchema: reattachDefs(t.inputSchema, getDefs()), |
| 44 | outputSchema: reattachDefs(t.outputSchema, sharedDefs), | 67 | outputSchema: reattachDefs(t.outputSchema, getDefs()), |
| 45 | })), | 68 | })), |
| 46 | ), | 69 | ), |
| 47 | 70 | ||
| @@ -51,6 +74,9 @@ export const makeInMemoryToolRegistry = () => { · 3 unmodified lines | |||
| 51 | for (const [k, v] of sharedDefs) { | 74 | for (const [k, v] of sharedDefs) { |
| 52 | result[k] = v; | 75 | result[k] = v; |
| 53 | } | 76 | } |
| 77 | for (const [k, v] of runtimeDefs) { | ||
| 78 | result[k] = v; | ||
| 79 | } | ||
| 54 | return result; | 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 | registerInvoker: (pluginKey: string, invoker: ToolInvoker) => | 104 | registerInvoker: (pluginKey: string, invoker: ToolInvoker) => |
| 65 | Effect.sync(() => { | 105 | Effect.sync(() => { |
| 66 | invokers.set(pluginKey, invoker); | 106 | invokers.set(pluginKey, invoker); |
| @@ -68,8 +108,12 @@ export const makeInMemoryToolRegistry = () => { · 1 unmodified line | |||
| 68 | 108 | ||
| 69 | resolveAnnotations: (toolId: ToolId) => | 109 | resolveAnnotations: (toolId: ToolId) => |
| 70 | Effect.gen(function* () { | 110 | Effect.gen(function* () { |
| 71 | const tool = tools.get(toolId); | 111 | const tool = getTool(toolId); |
| 72 | if (!tool) return undefined; | 112 | if (!tool) return undefined; |
| 113 | const runtimeHandler = runtimeHandlers.get(toolId); | ||
| 114 | if (runtimeHandler?.resolveAnnotations) { | ||
| 115 | return yield* runtimeHandler.resolveAnnotations(); | ||
| 116 | } | ||
| 73 | const invoker = invokers.get(tool.pluginKey); | 117 | const invoker = invokers.get(tool.pluginKey); |
| 74 | if (!invoker?.resolveAnnotations) return undefined; | 118 | if (!invoker?.resolveAnnotations) return undefined; |
| 75 | return yield* invoker.resolveAnnotations(toolId); | 119 | return yield* invoker.resolveAnnotations(toolId); |
| @@ -77,9 +121,13 @@ export const makeInMemoryToolRegistry = () => { · 1 unmodified line | |||
| 77 | 121 | ||
| 78 | invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) => | 122 | invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) => |
| 79 | Effect.gen(function* () { | 123 | Effect.gen(function* () { |
| 80 | const tool = yield* Effect.fromNullable(tools.get(toolId)).pipe( | 124 | const tool = yield* Effect.fromNullable(getTool(toolId)).pipe( |
| 81 | Effect.mapError(() => new ToolNotFoundError({ toolId })), | 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 | const invoker = invokers.get(tool.pluginKey); | 131 | const invoker = invokers.get(tool.pluginKey); |
| 84 | if (!invoker) { | 132 | if (!invoker) { |
| 85 | return yield* new ToolInvocationError({ | 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 | unregister: (toolIds: readonly ToolId[]) => | 169 | unregister: (toolIds: readonly ToolId[]) => |
| 102 | Effect.sync(() => { | 170 | Effect.sync(() => { |
| 103 | for (const id of toolIds) { | 171 | for (const id of toolIds) { |
| 104 | tools.delete(id); | 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 | tools.delete(id); | 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 | import { | 4 | import { |
| 5 | createExecutor, | 5 | createExecutor, |
| 6 | makeTestConfig, | 6 | makeTestConfig, |
| 7 | makeInMemorySourceRegistry, | ||
| 7 | inMemoryToolsPlugin, | 8 | inMemoryToolsPlugin, |
| 8 | tool, | 9 | tool, |
| 9 | FormElicitation, | 10 | FormElicitation, |
| 10 | UrlElicitation, | 11 | UrlElicitation, |
| 11 | ElicitationResponse, | 12 | ElicitationResponse, |
| 13 | Source, | ||
| 12 | type MemoryToolContext, | 14 | type MemoryToolContext, |
| 13 | type ToolId, | 15 | type ToolId, |
| 14 | type InvokeOptions, | 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 | it.effect("memory plugin registers tools and they are discoverable", () => | 81 | it.effect("memory plugin registers tools and they are discoverable", () => |
| 44 | Effect.gen(function* () { | 82 | Effect.gen(function* () { |
| 45 | const executor = yield* createExecutor( | 83 | const executor = yield* createExecutor( |
| @@ -355,7 +393,9 @@ describe("SDK Executor", () => { · 309 unmodified lines | |||
| 355 | ); | 393 | ); |
| 356 | 394 | ||
| 357 | const error = yield* Effect.flip( | 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 | expect(error._tag).toBe("ElicitationDeclinedError"); | 400 | expect(error._tag).toBe("ElicitationDeclinedError"); |
| 361 | }), | 401 | }), |
| @@ -20,6 +20,7 @@ export { · 19 unmodified lines | |||
| 20 | ToolAnnotations, | 20 | ToolAnnotations, |
| 21 | ToolListFilter, | 21 | ToolListFilter, |
| 22 | type ToolInvoker, | 22 | type ToolInvoker, |
| 23 | type RuntimeToolHandler, | ||
| 23 | type InvokeOptions, | 24 | type InvokeOptions, |
| 24 | } from "./tools"; | 25 | } from "./tools"; |
| 25 | 26 | ||
| @@ -81,6 +82,14 @@ export { · 55 unmodified lines | |||
| 81 | // Schema ref utilities | 82 | // Schema ref utilities |
| 82 | export { hoistDefinitions, collectRefs, reattachDefs } from "./schema-refs"; | 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 | // In-memory implementations | 93 | // In-memory implementations |
| 85 | export { makeInMemoryToolRegistry } from "./in-memory/tool-registry"; | 94 | export { makeInMemoryToolRegistry } from "./in-memory/tool-registry"; |
| 86 | export { makeInMemorySecretStore, makeInMemorySecretProvider } from "./in-memory/secret-store"; | 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 | name: Schema.String, | 11 | name: Schema.String, |
| 12 | /** Plugin kind that manages this source (e.g. "openapi", "mcp") */ | 12 | /** Plugin kind that manages this source (e.g. "openapi", "mcp") */ |
| 13 | kind: Schema.String, | 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 | /** Register a source manager (called by plugins during init) */ | 52 | /** Register a source manager (called by plugins during init) */ |
| 47 | readonly addManager: (manager: SourceManager) => Effect.Effect<void>; | 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 | /** List all sources across all plugins */ | 61 | /** List all sources across all plugins */ |
| 50 | readonly list: () => Effect.Effect<readonly Source[]>; | 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 | export const makeInMemorySourceRegistry = () => { | 76 | export const makeInMemorySourceRegistry = () => { |
| 65 | const managers = new Map<string, SourceManager>(); | 77 | const managers = new Map<string, SourceManager>(); |
| 78 | const runtimeSources = new Map<string, Source>(); | ||
| 66 | 79 | ||
| 67 | return { | 80 | return { |
| 68 | addManager: (manager: SourceManager) => | 81 | addManager: (manager: SourceManager) => |
| @@ -70,9 +83,19 @@ export const makeInMemorySourceRegistry = () => { · 1 unmodified line | |||
| 70 | managers.set(manager.kind, manager); | 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 | list: () => | 96 | list: () => |
| 74 | Effect.gen(function* () { | 97 | Effect.gen(function* () { |
| 75 | const all: Source[] = []; | 98 | const all: Source[] = [...runtimeSources.values()]; |
| 76 | for (const manager of managers.values()) { | 99 | for (const manager of managers.values()) { |
| 77 | const sources = yield* manager.list(); | 100 | const sources = yield* manager.list(); |
| 78 | all.push(...sources); | 101 | all.push(...sources); |
| @@ -82,6 +105,14 @@ export const makeInMemorySourceRegistry = () => { · 3 unmodified lines | |||
| 82 | 105 | ||
| 83 | remove: (sourceId: string) => | 106 | remove: (sourceId: string) => |
| 84 | Effect.gen(function* () { | 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 | for (const manager of managers.values()) { | 116 | for (const manager of managers.values()) { |
| 86 | const sources = yield* manager.list(); | 117 | const sources = yield* manager.list(); |
| 87 | if (sources.some((s) => s.id === sourceId)) { | 118 | if (sources.some((s) => s.id === sourceId)) { |
| @@ -93,6 +124,11 @@ export const makeInMemorySourceRegistry = () => { · 5 unmodified lines | |||
| 93 | 124 | ||
| 94 | refresh: (sourceId: string) => | 125 | refresh: (sourceId: string) => |
| 95 | Effect.gen(function* () { | 126 | Effect.gen(function* () { |
| 127 | const runtimeSource = runtimeSources.get(sourceId); | ||
| 128 | if (runtimeSource) { | ||
| 129 | return; | ||
| 130 | } | ||
| 131 | |||
| 96 | for (const manager of managers.values()) { | 132 | for (const manager of managers.values()) { |
| 97 | const sources = yield* manager.list(); | 133 | const sources = yield* manager.list(); |
| 98 | if (sources.some((s) => s.id === sourceId)) { | 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 | defs: Record<string, unknown>, | 98 | defs: Record<string, unknown>, |
| 99 | ) => Effect.Effect<void>; | 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 | * Register a plugin invoker. Must be called before registering tools | 115 | * Register a plugin invoker. Must be called before registering tools |
| 103 | * with the corresponding pluginKey. | 116 | * with the corresponding pluginKey. |
| @@ -119,6 +132,25 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 15 unmodified lines | |||
| 119 | tools: readonly ToolRegistration[], | 132 | tools: readonly ToolRegistration[], |
| 120 | ) => Effect.Effect<void>; | 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 | /** Unregister tools by id (used by plugins on cleanup) */ | 154 | /** Unregister tools by id (used by plugins on cleanup) */ |
| 123 | readonly unregister: ( | 155 | readonly unregister: ( |
| 124 | toolIds: readonly ToolId[], | 156 | toolIds: readonly ToolId[], |
| @@ -151,6 +183,18 @@ export interface ToolInvoker { · 26 unmodified lines | |||
| 151 | ) => Effect.Effect<ToolAnnotations | undefined>; | 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 | // ToolRegistration — pure data, no closures | 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 | import type { ToolId, ScopedKv } from "@executor/sdk"; | 7 | import type { ToolId, ScopedKv } from "@executor/sdk"; |
| 8 | import { ToolNotFoundError, ToolInvocationError, ToolRegistration } from "@executor/sdk"; | 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 | import { reattachDefs } from "@executor/sdk"; | 15 | import { reattachDefs } from "@executor/sdk"; |
| 11 | 16 | ||
| 12 | // --------------------------------------------------------------------------- | 17 | // --------------------------------------------------------------------------- |
| @@ -25,9 +30,12 @@ export const makeKvToolRegistry = ( · 12 unmodified lines | |||
| 25 | toolsKv: ScopedKv, | 30 | toolsKv: ScopedKv, |
| 26 | defsKv: ScopedKv, | 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 | const invokers = new Map<string, ToolInvoker>(); | 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 | Effect.gen(function* () { | 39 | Effect.gen(function* () { |
| 32 | const raw = yield* toolsKv.get(id); | 40 | const raw = yield* toolsKv.get(id); |
| 33 | if (!raw) return null; | 41 | if (!raw) return null; |
| @@ -43,13 +51,19 @@ export const makeKvToolRegistry = ( · 9 unmodified lines | |||
| 43 | const getDefsMap = (): Effect.Effect<Map<string, unknown>> => | 51 | const getDefsMap = (): Effect.Effect<Map<string, unknown>> => |
| 44 | Effect.gen(function* () { | 52 | Effect.gen(function* () { |
| 45 | const entries = yield* defsKv.list(); | 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 | return { | 59 | return { |
| 50 | list: (filter?: ToolListFilter) => | 60 | list: (filter?: ToolListFilter) => |
| 51 | Effect.gen(function* () { | 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 | if (filter?.sourceId) { | 67 | if (filter?.sourceId) { |
| 54 | const sid = filter.sourceId; | 68 | const sid = filter.sourceId; |
| 55 | tools = tools.filter((t) => t.sourceId === sid); | 69 | tools = tools.filter((t) => t.sourceId === sid); |
| @@ -73,7 +87,7 @@ export const makeKvToolRegistry = ( · 17 unmodified lines | |||
| 73 | 87 | ||
| 74 | schema: (toolId: ToolId) => | 88 | schema: (toolId: ToolId) => |
| 75 | Effect.gen(function* () { | 89 | Effect.gen(function* () { |
| 76 | const t = yield* getTool(toolId); | 90 | const t = runtimeTools.get(toolId) ?? (yield* getPersistedTool(toolId)); |
| 77 | if (!t) return yield* new ToolNotFoundError({ toolId }); | 91 | if (!t) return yield* new ToolNotFoundError({ toolId }); |
| 78 | const defs = yield* getDefsMap(); | 92 | const defs = yield* getDefsMap(); |
| 79 | return { | 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 | registerInvoker: (pluginKey: string, invoker: ToolInvoker) => | 127 | registerInvoker: (pluginKey: string, invoker: ToolInvoker) => |
| 100 | Effect.sync(() => { invokers.set(pluginKey, invoker); }), | 128 | Effect.sync(() => { invokers.set(pluginKey, invoker); }), |
| 101 | 129 | ||
| 102 | resolveAnnotations: (toolId: ToolId) => | 130 | resolveAnnotations: (toolId: ToolId) => |
| 103 | Effect.gen(function* () { | 131 | Effect.gen(function* () { |
| 104 | const tool = yield* getTool(toolId); | 132 | const tool = runtimeTools.get(toolId) ?? (yield* getPersistedTool(toolId)); |
| 105 | if (!tool) return undefined; | 133 | if (!tool) return undefined; |
| 134 | const runtimeHandler = runtimeHandlers.get(toolId); | ||
| 135 | if (runtimeHandler?.resolveAnnotations) { | ||
| 136 | return yield* runtimeHandler.resolveAnnotations(); | ||
| 137 | } | ||
| 106 | const invoker = invokers.get(tool.pluginKey); | 138 | const invoker = invokers.get(tool.pluginKey); |
| 107 | if (!invoker?.resolveAnnotations) return undefined; | 139 | if (!invoker?.resolveAnnotations) return undefined; |
| 108 | return yield* invoker.resolveAnnotations(toolId); | 140 | return yield* invoker.resolveAnnotations(toolId); |
| @@ -110,8 +142,12 @@ export const makeKvToolRegistry = ( · 1 unmodified line | |||
| 110 | 142 | ||
| 111 | invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) => | 143 | invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) => |
| 112 | Effect.gen(function* () { | 144 | Effect.gen(function* () { |
| 113 | const tool = yield* getTool(toolId); | 145 | const tool = runtimeTools.get(toolId) ?? (yield* getPersistedTool(toolId)); |
| 114 | if (!tool) return yield* new ToolNotFoundError({ toolId }); | 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 | const invoker = invokers.get(tool.pluginKey); | 151 | const invoker = invokers.get(tool.pluginKey); |
| 116 | if (!invoker) { | 152 | if (!invoker) { |
| 117 | return yield* new ToolInvocationError({ | 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 | unregister: (toolIds: readonly ToolId[]) => | 189 | unregister: (toolIds: readonly ToolId[]) => |
| 134 | Effect.gen(function* () { | 190 | Effect.gen(function* () { |
| 135 | for (const id of toolIds) { | 191 | for (const id of toolIds) { |
| 192 | runtimeTools.delete(id); | ||
| 193 | runtimeHandlers.delete(id); | ||
| 136 | yield* toolsKv.delete(id); | 194 | yield* toolsKv.delete(id); |
| 137 | } | 195 | } |
| 138 | }), | 196 | }), |
| @@ -145,6 +203,12 @@ export const makeKvToolRegistry = ( · 6 unmodified lines | |||
| 145 | yield* toolsKv.delete(t.id); | 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 | } from "@effect/platform"; | 11 | } from "@effect/platform"; |
| 12 | import { NodeHttpServer } from "@effect/platform-node"; | 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 | import { openApiPlugin } from "./plugin"; | 25 | import { openApiPlugin } from "./plugin"; |
| 16 | 26 | ||
| 17 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; | 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 | it.effect("resolves secret-backed headers at invocation time", () => | 279 | it.effect("resolves secret-backed headers at invocation time", () => |
| 122 | Effect.gen(function* () { | 280 | Effect.gen(function* () { |
| 123 | const httpClient = yield* HttpClient.HttpClient; | 281 | const httpClient = yield* HttpClient.HttpClient; |
| @@ -299,7 +457,11 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 175 unmodified lines | |||
| 299 | 457 | ||
| 300 | yield* executor.openapi.removeSpec("removable"); | 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 | import { FetchHttpClient, HttpClient } from "@effect/platform"; | 2 | import { FetchHttpClient, HttpClient } from "@effect/platform"; |
| 3 | import type { Layer } from "effect"; | 3 | import type { Layer } from "effect"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | Source, | 6 | Source, |
| 7 | definePlugin, | 7 | definePlugin, |
| 8 | registerRuntimeTools, | ||
| 9 | runtimeTool, | ||
| 8 | type ExecutorPlugin, | 10 | type ExecutorPlugin, |
| 9 | type PluginContext, | 11 | type PluginContext, |
| 10 | ToolId, | 12 | ToolId, |
| @@ -18,19 +20,20 @@ import { makeOpenApiInvoker } from "./invoke"; · 7 unmodified lines | |||
| 18 | import { resolveBaseUrl } from "./openapi-utils"; | 20 | import { resolveBaseUrl } from "./openapi-utils"; |
| 19 | import type { OpenApiOperationStore } from "./operation-store"; | 21 | import type { OpenApiOperationStore } from "./operation-store"; |
| 20 | import { makeInMemoryOperationStore } from "./kv-operation-store"; | 22 | import { makeInMemoryOperationStore } from "./kv-operation-store"; |
| 21 | import { previewSpec, type SpecPreview } from "./preview"; | 23 | import { previewSpec, SpecPreview } from "./preview"; |
| 22 | import { | 24 | import { |
| 25 | HeaderValue as HeaderValueSchema, | ||
| 23 | InvocationConfig, | 26 | InvocationConfig, |
| 24 | OperationBinding, | 27 | OperationBinding, |
| 28 | type HeaderValue as HeaderValueValue, | ||
| 25 | } from "./types"; | 29 | } from "./types"; |
| 26 | 30 | ||
| 27 | |||
| 28 | // --------------------------------------------------------------------------- | 31 | // --------------------------------------------------------------------------- |
| 29 | // Plugin config | 32 | // Plugin config |
| 30 | // --------------------------------------------------------------------------- | 33 | // --------------------------------------------------------------------------- |
| 31 | 34 | ||
| 32 | /** A header value — either a static string or a reference to a secret */ | 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 | export interface OpenApiSpecConfig { | 38 | export interface OpenApiSpecConfig { |
| 36 | readonly spec: string; | 39 | readonly spec: string; |
| @@ -46,9 +49,7 @@ export interface OpenApiSpecConfig { · 9 unmodified lines | |||
| 46 | 49 | ||
| 47 | export interface OpenApiPluginExtension { | 50 | export interface OpenApiPluginExtension { |
| 48 | /** Preview a spec without registering — returns metadata, auth strategies, header presets */ | 51 | /** Preview a spec without registering — returns metadata, auth strategies, header presets */ |
| 49 | readonly previewSpec: ( | 52 | readonly previewSpec: (specText: string) => Effect.Effect<SpecPreview, Error>; |
| 50 | specText: string, | ||
| 51 | ) => Effect.Effect<SpecPreview, Error>; | ||
| 52 | 53 | ||
| 53 | /** Add an OpenAPI spec and register its operations as tools */ | 54 | /** Add an OpenAPI spec and register its operations as tools */ |
| 54 | readonly addSpec: ( | 55 | readonly addSpec: ( |
| @@ -63,14 +64,35 @@ export interface OpenApiPluginExtension { · 8 unmodified lines | |||
| 63 | // Helpers | 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 | const toRegistration = ( | 87 | const toRegistration = ( |
| 67 | def: ToolDefinition, | 88 | def: ToolDefinition, |
| 68 | namespace: string, | 89 | namespace: string, |
| 69 | ): ToolRegistration => { | 90 | ): ToolRegistration => { |
| 70 | const op = def.operation; | 91 | const op = def.operation; |
| 71 | const description = Option.getOrElse(op.description, () => | 92 | const description = Option.getOrElse(op.description, () => |
| 72 | Option.getOrElse(op.summary, () => | 93 | Option.getOrElse( |
| 73 | `${op.method.toUpperCase()} ${op.pathTemplate}`, | 94 | op.summary, |
| 95 | () => `${op.method.toUpperCase()} ${op.pathTemplate}`, | ||
| 74 | ), | 96 | ), |
| 75 | ); | 97 | ); |
| 76 | return { | 98 | return { |
| @@ -101,10 +123,8 @@ export const openApiPlugin = (options?: { · 24 unmodified lines | |||
| 101 | readonly operationStore?: OpenApiOperationStore; | 123 | readonly operationStore?: OpenApiOperationStore; |
| 102 | }): ExecutorPlugin<"openapi", OpenApiPluginExtension> => { | 124 | }): ExecutorPlugin<"openapi", OpenApiPluginExtension> => { |
| 103 | const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer; | 125 | const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer; |
| 104 | const operationStore = options?.operationStore ?? makeInMemoryOperationStore(); | 126 | const operationStore = |
| 105 | 127 | options?.operationStore ?? makeInMemoryOperationStore(); | |
| 106 | // Track added sources so we can list them | ||
| 107 | const addedSources = new Map<string, Source>(); | ||
| 108 | 128 | ||
| 109 | return definePlugin({ | 129 | return definePlugin({ |
| 110 | key: "openapi", | 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 | // Tools are already persisted in the KV tool registry — no need to | 143 | // Tools are already persisted in the KV tool registry — no need to |
| 134 | // re-register them. We only need the source list and the invoker. | 144 | // re-register them. We only need the source list and the invoker. |
| 135 | |||
| 136 | // Register source manager so the core can list/remove/refresh our sources | 145 | // Register source manager so the core can list/remove/refresh our sources |
| 137 | yield* ctx.sources.addManager({ | 146 | yield* ctx.sources.addManager({ |
| 138 | kind: "openapi", | 147 | kind: "openapi", |
| 139 | 148 | ||
| 140 | list: () => | 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 | remove: (sourceId: string) => | 166 | remove: (sourceId: string) => |
| 144 | Effect.gen(function* () { | 167 | Effect.gen(function* () { |
| 145 | yield* operationStore.removeByNamespace(sourceId); | 168 | yield* operationStore.removeByNamespace(sourceId); |
| 146 | yield* operationStore.removeSourceMeta(sourceId); | 169 | yield* operationStore.removeSourceMeta(sourceId); |
| 147 | yield* ctx.tools.unregisterBySource(sourceId); | 170 | yield* ctx.tools.unregisterBySource(sourceId); |
| 148 | addedSources.delete(sourceId); | ||
| 149 | }), | 171 | }), |
| 150 | 172 | ||
| 151 | // TODO: refresh requires storing original config per namespace | 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 | return { | 256 | return { |
| 155 | extension: { | 257 | extension: { |
| 156 | previewSpec: (specText: string) => previewSpec(specText), | 258 | previewSpec: (specText: string) => previewSpec(specText), |
| 157 | 259 | ||
| 158 | addSpec: (config: OpenApiSpecConfig) => | 260 | addSpec: (config: OpenApiSpecConfig) => |
| 159 | Effect.gen(function* () { | 261 | addSpecInternal(config).pipe( |
| 160 | const doc = yield* parse(config.spec); | 262 | Effect.map(({ toolCount }) => ({ toolCount })), |
| 161 | const result = yield* extract(doc); | 263 | ), |
| 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 | }), | ||
| 210 | 264 | ||
| 211 | removeSpec: (namespace: string) => | 265 | removeSpec: (namespace: string) => |
| 212 | Effect.gen(function* () { | 266 | Effect.gen(function* () { |
| 213 | const toolIds = yield* operationStore.removeByNamespace(namespace); | 267 | const toolIds = |
| 268 | yield* operationStore.removeByNamespace(namespace); | ||
| 214 | if (toolIds.length > 0) { | 269 | if (toolIds.length > 0) { |
| 215 | yield* ctx.tools.unregister(toolIds); | 270 | yield* ctx.tools.unregister(toolIds); |
| 216 | } | 271 | } |
| 217 | yield* operationStore.removeSourceMeta(namespace); | 272 | yield* operationStore.removeSourceMeta(namespace); |
| 218 | addedSources.delete(namespace); | ||
| 219 | }), | 273 | }), |
| 220 | }, | 274 | }, |
| 221 | 275 | ||
| 222 | close: () => | 276 | close: () => |
| 223 | Effect.gen(function* () { | 277 | Effect.gen(function* () { |
| 224 | for (const sourceId of addedSources.keys()) { | 278 | yield* runtimeTools.close(); |
| 225 | yield* ctx.tools.unregisterBySource(sourceId); | ||
| 226 | } | ||
| 227 | addedSources.clear(); | ||
| 228 | }), | 279 | }), |
| 229 | }; | 280 | }; |
| 230 | }), | 281 | }), |
| @@ -114,9 +114,12 @@ describe("Real specs: Cloudflare API", () => { · 113 unmodified lines | |||
| 114 | expect(result.toolCount).toBeGreaterThan(1000); | 114 | expect(result.toolCount).toBeGreaterThan(1000); |
| 115 | 115 | ||
| 116 | const tools = yield* executor.tools.list(); | 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 | expect(tool.pluginKey).toBe("openapi"); | 123 | expect(tool.pluginKey).toBe("openapi"); |
| 121 | expect(tool.sourceId).toBe("cloudflare"); | 124 | expect(tool.sourceId).toBe("cloudflare"); |
| 122 | } | 125 | } |
| @@ -212,7 +215,11 @@ describe("Real specs: Cloudflare API", () => { · 89 unmodified lines | |||
| 212 | 215 | ||
| 213 | yield* executor.openapi.removeSpec("cloudflare"); | 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 | }); |