Unified · Split
| @@ -45,6 +45,27 @@ const toError = (cause: unknown): Error => · 44 unmodified lines | |||
| 45 | const sleep = (ms: number) => | 45 | const sleep = (ms: number) => |
| 46 | Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms))); | 46 | Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms))); |
| 47 | 47 | ||
| 48 | const openUrlInBrowser = (url: string): Effect.Effect<void, never, never> => | ||
| 49 | Effect.sync(() => { | ||
| 50 | const cmd = | ||
| 51 | process.platform === "darwin" | ||
| 52 | ? ["open", url] | ||
| 53 | : process.platform === "win32" | ||
| 54 | ? ["cmd", "/c", "start", "", url] | ||
| 55 | : ["xdg-open", url]; | ||
| 56 | |||
| 57 | try { | ||
| 58 | const child = spawn(cmd[0]!, cmd.slice(1), { | ||
| 59 | detached: true, | ||
| 60 | stdio: "ignore", | ||
| 61 | }); | ||
| 62 | child.on("error", () => undefined); | ||
| 63 | child.unref(); | ||
| 64 | } catch { | ||
| 65 | // Best-effort browser launch only; always leave the URL in stdout. | ||
| 66 | } | ||
| 67 | }).pipe(Effect.catchAll(() => Effect.void)); | ||
| 68 | |||
| 48 | const promptLine = (prompt: string): Effect.Effect<string, Error, never> => | 69 | const promptLine = (prompt: string): Effect.Effect<string, Error, never> => |
| 49 | Effect.tryPromise({ | 70 | Effect.tryPromise({ |
| 50 | try: async () => { | 71 | try: async () => { |
| @@ -254,8 +275,8 @@ const printRootHelp = (workflow: string) => · 203 unmodified lines | |||
| 254 | "", | 275 | "", |
| 255 | "USAGE", | 276 | "USAGE", |
| 256 | "", | 277 | "", |
| 257 | " executor call [code] [--file text] [--stdin] [--base-url text]", | 278 | " executor call [code] [--file text] [--stdin] [--base-url text] [--no-open]", |
| 258 | " executor resume --execution-id text [--base-url text]", | 279 | " executor resume --execution-id text [--base-url text] [--no-open]", |
| 259 | "", | 280 | "", |
| 260 | "CALL WORKFLOW", | 281 | "CALL WORKFLOW", |
| 261 | "", | 282 | "", |
| @@ -281,7 +302,7 @@ const printCallHelp = (workflow: string) => · 19 unmodified lines | |||
| 281 | "", | 302 | "", |
| 282 | "USAGE", | 303 | "USAGE", |
| 283 | "", | 304 | "", |
| 284 | " executor call [code] [--file text] [--stdin] [--base-url text]", | 305 | " executor call [code] [--file text] [--stdin] [--base-url text] [--no-open]", |
| 285 | "", | 306 | "", |
| 286 | "DESCRIPTION", | 307 | "DESCRIPTION", |
| 287 | "", | 308 | "", |
| @@ -301,15 +322,18 @@ const printCallHelp = (workflow: string) => · 13 unmodified lines | |||
| 301 | " Read code from stdin.", | 322 | " Read code from stdin.", |
| 302 | " --base-url text", | 323 | " --base-url text", |
| 303 | " Override the executor server base URL.", | 324 | " Override the executor server base URL.", |
| 325 | " --no-open", | ||
| 326 | " Print interaction URLs without opening a browser.", | ||
| 304 | "", | 327 | "", |
| 305 | "EXAMPLES", | 328 | "EXAMPLES", |
| 306 | "", | 329 | "", |
| 307 | ' executor call \'const matches = await tools.discover({ query: "github issues", limit: 5 }); return matches;\'', | 330 | ' executor call \'const matches = await tools.discover({ query: "github issues", limit: 5 }); return matches;\'', |
| 308 | ' executor call \'const matches = await tools.discover({ query: "repo details", limit: 1 }); const path = matches.bestPath; return await tools.describe.tool({ path, includeSchemas: true });\'', | 331 | ' executor call \'const matches = await tools.discover({ query: "repo details", limit: 1 }); const path = matches.bestPath; return await tools.describe.tool({ path, includeSchemas: true });\'', |
| 309 | ' executor call \'return await tools.executor.sources.add({ endpoint: "https://example.com/mcp", name: "Example", namespace: "example" });\'', | 332 | ' executor call \'return await tools.executor.sources.add({ endpoint: "https://example.com/mcp", name: "Example", namespace: "example" });\'', |
| 310 | ' executor call \'return await tools.executor.sources.add({ kind: "openapi", endpoint: "https://api.github.com", specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", name: "GitHub", namespace: "github", auth: { kind: "bearer" } });\'', | 333 | ' executor call \'return await tools.executor.sources.add({ kind: "openapi", endpoint: "https://api.github.com", specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", name: "GitHub", namespace: "github" });\'', |
| 311 | " cat script.ts | executor call --stdin", | 334 | " cat script.ts | executor call --stdin", |
| 312 | " executor call --file script.ts", | 335 | " executor call --file script.ts", |
| 336 | " executor call --no-open --file script.ts", | ||
| 313 | " executor resume --execution-id exec_123", | 337 | " executor resume --execution-id exec_123", |
| 314 | ].join("\n")); | 338 | ].join("\n")); |
| 315 | }); | 339 | }); |
| @@ -564,17 +588,37 @@ const promptStructuredInteraction = (parsed: { · 248 unmodified lines | |||
| 564 | return content; | 588 | return content; |
| 565 | }); | 589 | }); |
| 566 | 590 | ||
| 567 | const promptInteraction = (interaction: ExecutionInteraction) => | 591 | const printUrlInteraction = (input: { |
| 592 | message: string; | ||
| 593 | url: string | null; | ||
| 594 | shouldOpen: boolean; | ||
| 595 | }) => | ||
| 596 | Effect.gen(function* () { | ||
| 597 | yield* Effect.sync(() => { | ||
| 598 | process.stdout.write(`${input.message}\n${input.url ?? ""}\n`); | ||
| 599 | }); | ||
| 600 | |||
| 601 | if (input.shouldOpen && input.url) { | ||
| 602 | yield* openUrlInBrowser(input.url); | ||
| 603 | } | ||
| 604 | }); | ||
| 605 | |||
| 606 | const promptInteraction = (input: { | ||
| 607 | interaction: ExecutionInteraction; | ||
| 608 | shouldOpenUrls: boolean; | ||
| 609 | }) => | ||
| 568 | Effect.gen(function* () { | 610 | Effect.gen(function* () { |
| 569 | const parsed = parseInteractionPayload(interaction); | 611 | const parsed = parseInteractionPayload(input.interaction); |
| 570 | 612 | ||
| 571 | if (!process.stdin.isTTY || !process.stdout.isTTY || parsed === null) { | 613 | if (!process.stdin.isTTY || !process.stdout.isTTY || parsed === null) { |
| 572 | return null; | 614 | return null; |
| 573 | } | 615 | } |
| 574 | 616 | ||
| 575 | if (parsed.mode === "url") { | 617 | if (parsed.mode === "url") { |
| 576 | yield* Effect.sync(() => { | 618 | yield* printUrlInteraction({ |
| 577 | process.stdout.write(`${parsed.message}\n${parsed.url ?? ""}\n`); | 619 | message: parsed.message, |
| 620 | url: parsed.url ?? null, | ||
| 621 | shouldOpen: input.shouldOpenUrls, | ||
| 578 | }); | 622 | }); |
| 579 | return null; | 623 | return null; |
| 580 | } | 624 | } |
| @@ -708,6 +752,7 @@ const driveExecution = (input: { · 127 unmodified lines | |||
| 708 | client: ControlPlaneClient; | 752 | client: ControlPlaneClient; |
| 709 | workspaceId: ExecutionEnvelope["execution"]["workspaceId"]; | 753 | workspaceId: ExecutionEnvelope["execution"]["workspaceId"]; |
| 710 | envelope: ExecutionEnvelope; | 754 | envelope: ExecutionEnvelope; |
| 755 | shouldOpenUrls: boolean; | ||
| 711 | }) => | 756 | }) => |
| 712 | Effect.gen(function* () { | 757 | Effect.gen(function* () { |
| 713 | let current = input.envelope; | 758 | let current = input.envelope; |
| @@ -721,8 +766,10 @@ const driveExecution = (input: { · 7 unmodified lines | |||
| 721 | 766 | ||
| 722 | const parsed = parseInteractionPayload(pending); | 767 | const parsed = parseInteractionPayload(pending); |
| 723 | if (parsed?.mode === "url") { | 768 | if (parsed?.mode === "url") { |
| 724 | yield* Effect.sync(() => { | 769 | yield* printUrlInteraction({ |
| 725 | process.stdout.write(`${parsed.message}\n${parsed.url ?? ""}\n`); | 770 | message: parsed.message, |
| 771 | url: parsed.url ?? null, | ||
| 772 | shouldOpen: input.shouldOpenUrls, | ||
| 726 | }); | 773 | }); |
| 727 | 774 | ||
| 728 | if (!process.stdin.isTTY || !process.stdout.isTTY) { | 775 | if (!process.stdin.isTTY || !process.stdout.isTTY) { |
| @@ -738,7 +785,10 @@ const driveExecution = (input: { · 9 unmodified lines | |||
| 738 | continue; | 785 | continue; |
| 739 | } | 786 | } |
| 740 | 787 | ||
| 741 | const responseJson = yield* promptInteraction(pending); | 788 | const responseJson = yield* promptInteraction({ |
| 789 | interaction: pending, | ||
| 790 | shouldOpenUrls: input.shouldOpenUrls, | ||
| 791 | }); | ||
| 742 | if (responseJson === null) { | 792 | if (responseJson === null) { |
| 743 | yield* Effect.sync(() => { | 793 | yield* Effect.sync(() => { |
| 744 | console.log(JSON.stringify({ | 794 | console.log(JSON.stringify({ |
| @@ -790,8 +840,9 @@ const callCommand = Command.make( · 45 unmodified lines | |||
| 790 | file: Options.text("file").pipe(Options.optional), | 840 | file: Options.text("file").pipe(Options.optional), |
| 791 | stdin: Options.boolean("stdin").pipe(Options.withDefault(false)), | 841 | stdin: Options.boolean("stdin").pipe(Options.withDefault(false)), |
| 792 | baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_SERVER_BASE_URL)), | 842 | baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_SERVER_BASE_URL)), |
| 843 | noOpen: Options.boolean("no-open").pipe(Options.withDefault(false)), | ||
| 793 | }, | 844 | }, |
| 794 | ({ code, file, stdin, baseUrl }) => | 845 | ({ code, file, stdin, baseUrl, noOpen }) => |
| 795 | Effect.gen(function* () { | 846 | Effect.gen(function* () { |
| 796 | const resolvedCode = yield* readCode({ | 847 | const resolvedCode = yield* readCode({ |
| 797 | code: Option.getOrUndefined(code), | 848 | code: Option.getOrUndefined(code), |
| @@ -814,6 +865,7 @@ const callCommand = Command.make( · 16 unmodified lines | |||
| 814 | client, | 865 | client, |
| 815 | workspaceId: installation.workspaceId, | 866 | workspaceId: installation.workspaceId, |
| 816 | envelope: created, | 867 | envelope: created, |
| 868 | shouldOpenUrls: !noOpen, | ||
| 817 | }); | 869 | }); |
| 818 | 870 | ||
| 819 | yield* printExecution(settled); | 871 | yield* printExecution(settled); |
| @@ -825,8 +877,9 @@ const resumeCommand = Command.make( · 5 unmodified lines | |||
| 825 | { | 877 | { |
| 826 | executionId: Options.text("execution-id"), | 878 | executionId: Options.text("execution-id"), |
| 827 | baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_SERVER_BASE_URL)), | 879 | baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_SERVER_BASE_URL)), |
| 880 | noOpen: Options.boolean("no-open").pipe(Options.withDefault(false)), | ||
| 828 | }, | 881 | }, |
| 829 | ({ executionId, baseUrl }) => | 882 | ({ executionId, baseUrl, noOpen }) => |
| 830 | Effect.gen(function* () { | 883 | Effect.gen(function* () { |
| 831 | yield* ensureServer(baseUrl); | 884 | yield* ensureServer(baseUrl); |
| 832 | const { installation, client } = yield* getLocalAuthedClient(baseUrl); | 885 | const { installation, client } = yield* getLocalAuthedClient(baseUrl); |
| @@ -844,6 +897,7 @@ const resumeCommand = Command.make( · 11 unmodified lines | |||
| 844 | client, | 897 | client, |
| 845 | workspaceId: installation.workspaceId, | 898 | workspaceId: installation.workspaceId, |
| 846 | envelope: execution, | 899 | envelope: execution, |
| 900 | shouldOpenUrls: !noOpen, | ||
| 847 | }); | 901 | }); |
| 848 | 902 | ||
| 849 | yield* printExecution(settled); | 903 | yield* printExecution(settled); |
| @@ -1,5 +1,4 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; |
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | import { LocalInstallationSchema } from "#schema"; | 2 | import { LocalInstallationSchema } from "#schema"; |
| 4 | 3 | ||
| 5 | import { | 4 | import { |
| @@ -16,11 +15,4 @@ export class LocalApi extends HttpApiGroup.make("local") · 10 unmodified lines | |||
| 16 | .addError(ControlPlaneNotFoundError) | 15 | .addError(ControlPlaneNotFoundError) |
| 17 | .addError(ControlPlaneStorageError), | 16 | .addError(ControlPlaneStorageError), |
| 18 | ) | 17 | ) |
| 19 | .add( | ||
| 20 | HttpApiEndpoint.get("oauthCallback")`/local/oauth/callback` | ||
| 21 | .addSuccess(Schema.String) | ||
| 22 | .addError(ControlPlaneBadRequestError) | ||
| 23 | .addError(ControlPlaneNotFoundError) | ||
| 24 | .addError(ControlPlaneStorageError), | ||
| 25 | ) | ||
| 26 | .prefix("/v1") {} | 18 | .prefix("/v1") {} |
| @@ -1,7 +1,8 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { | 1 | import { |
| 4 | completeSourceAuthCallback, | 2 | HttpApiBuilder, |
| 3 | } from "@effect/platform"; | ||
| 4 | |||
| 5 | import { | ||
| 5 | getLocalInstallation, | 6 | getLocalInstallation, |
| 6 | } from "../../runtime/local-operations"; | 7 | } from "../../runtime/local-operations"; |
| 7 | 8 | ||
| @@ -14,20 +15,5 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 6 unmodified lines | |||
| 14 | handlers | 15 | handlers |
| 15 | .handle("installation", () => | 16 | .handle("installation", () => |
| 16 | getLocalInstallation(), | 17 | getLocalInstallation(), |
| 17 | ) | ||
| 18 | .handle("oauthCallback", () => | ||
| 19 | Effect.gen(function* () { | ||
| 20 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 21 | const requestUrl = new URL(request.url, "http://127.0.0.1"); | ||
| 22 | |||
| 23 | const source = yield* completeSourceAuthCallback({ | ||
| 24 | state: requestUrl.searchParams.get("state") ?? "", | ||
| 25 | code: requestUrl.searchParams.get("code"), | ||
| 26 | error: requestUrl.searchParams.get("error"), | ||
| 27 | errorDescription: requestUrl.searchParams.get("error_description"), | ||
| 28 | }); | ||
| 29 | |||
| 30 | return `Source connected: ${source.id}. You can close this window.`; | ||
| 31 | }), | ||
| 32 | ), | 18 | ), |
| 33 | ); | 19 | ); |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | import { | 2 | import { |
| 3 | ExecutionInteractionIdSchema, | ||
| 3 | SourceAuthSchema, | 4 | SourceAuthSchema, |
| 4 | SourceIdSchema, | 5 | SourceIdSchema, |
| 5 | SourceKindSchema, | 6 | SourceKindSchema, |
| @@ -72,6 +73,26 @@ export type UpdateSourcePayload = typeof UpdateSourcePayloadSchema.Type; · 66 unmodified lines | |||
| 72 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); | 73 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); |
| 73 | const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema); | 74 | const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema); |
| 74 | 75 | ||
| 76 | const CredentialPageUrlParamsSchema = Schema.Struct({ | ||
| 77 | interactionId: ExecutionInteractionIdSchema, | ||
| 78 | }); | ||
| 79 | |||
| 80 | const CredentialSubmitPayloadSchema = Schema.Struct({ | ||
| 81 | action: Schema.optional(Schema.Literal("submit", "continue", "cancel")), | ||
| 82 | token: Schema.optional(Schema.String), | ||
| 83 | }); | ||
| 84 | |||
| 85 | const CredentialOauthCompleteUrlParamsSchema = Schema.Struct({ | ||
| 86 | state: Schema.String, | ||
| 87 | code: Schema.optional(Schema.String), | ||
| 88 | error: Schema.optional(Schema.String), | ||
| 89 | error_description: Schema.optional(Schema.String), | ||
| 90 | }); | ||
| 91 | |||
| 92 | const HtmlSchema = HttpApiSchema.Text({ | ||
| 93 | contentType: "text/html", | ||
| 94 | }); | ||
| 95 | |||
| 75 | export class SourcesApi extends HttpApiGroup.make("sources") | 96 | export class SourcesApi extends HttpApiGroup.make("sources") |
| 76 | .add( | 97 | .add( |
| 77 | HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/sources` | 98 | HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/sources` |
| @@ -117,4 +138,29 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 39 unmodified lines | |||
| 117 | .addError(ControlPlaneForbiddenError) | 138 | .addError(ControlPlaneForbiddenError) |
| 118 | .addError(ControlPlaneStorageError), | 139 | .addError(ControlPlaneStorageError), |
| 119 | ) | 140 | ) |
| 141 | .add( | ||
| 142 | HttpApiEndpoint.get("credentialPage")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/credentials` | ||
| 143 | .setUrlParams(CredentialPageUrlParamsSchema) | ||
| 144 | .addSuccess(HtmlSchema) | ||
| 145 | .addError(ControlPlaneBadRequestError) | ||
| 146 | .addError(ControlPlaneNotFoundError) | ||
| 147 | .addError(ControlPlaneStorageError), | ||
| 148 | ) | ||
| 149 | .add( | ||
| 150 | HttpApiEndpoint.post("credentialSubmit")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/credentials` | ||
| 151 | .setUrlParams(CredentialPageUrlParamsSchema) | ||
| 152 | .setPayload(CredentialSubmitPayloadSchema) | ||
| 153 | .addSuccess(HtmlSchema) | ||
| 154 | .addError(ControlPlaneBadRequestError) | ||
| 155 | .addError(ControlPlaneNotFoundError) | ||
| 156 | .addError(ControlPlaneStorageError), | ||
| 157 | ) | ||
| 158 | .add( | ||
| 159 | HttpApiEndpoint.get("credentialComplete")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/credentials/oauth/complete` | ||
| 160 | .setUrlParams(CredentialOauthCompleteUrlParamsSchema) | ||
| 161 | .addSuccess(Schema.String) | ||
| 162 | .addError(ControlPlaneBadRequestError) | ||
| 163 | .addError(ControlPlaneNotFoundError) | ||
| 164 | .addError(ControlPlaneStorageError), | ||
| 165 | ) | ||
| 120 | .prefix("/v1") {} | 166 | .prefix("/v1") {} |
| @@ -1,5 +1,13 @@ | |||
| 1 | import { HttpApiBuilder } from "@effect/platform"; | 1 | import { |
| 2 | import type { WorkspaceId } from "#schema"; | 2 | HttpApiBuilder, |
| 3 | HttpServerResponse, | ||
| 4 | } from "@effect/platform"; | ||
| 5 | import type { | ||
| 6 | ExecutionInteraction, | ||
| 7 | Source, | ||
| 8 | WorkspaceId, | ||
| 9 | } from "#schema"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 3 | 11 | ||
| 4 | import { requirePermission, withPolicy } from "#domain"; | 12 | import { requirePermission, withPolicy } from "#domain"; |
| 5 | import { | 13 | import { |
| @@ -9,7 +17,17 @@ import { · 3 unmodified lines | |||
| 9 | removeSource, | 17 | removeSource, |
| 10 | updateSource, | 18 | updateSource, |
| 11 | } from "../../runtime/sources-operations"; | 19 | } from "../../runtime/sources-operations"; |
| 20 | import { | ||
| 21 | completeSourceCredentialSetup, | ||
| 22 | getSourceCredentialInteraction, | ||
| 23 | submitSourceCredentialInteraction, | ||
| 24 | } from "../../runtime/local-operations"; | ||
| 12 | 25 | ||
| 26 | import { | ||
| 27 | ControlPlaneBadRequestError, | ||
| 28 | ControlPlaneNotFoundError, | ||
| 29 | ControlPlaneStorageError, | ||
| 30 | } from "../errors"; | ||
| 13 | import { ControlPlaneApi } from "../api"; | 31 | import { ControlPlaneApi } from "../api"; |
| 14 | import { withWorkspaceRequestActor } from "../http-auth"; | 32 | import { withWorkspaceRequestActor } from "../http-auth"; |
| 15 | 33 | ||
| @@ -25,6 +43,446 @@ const requireWriteSources = (workspaceId: WorkspaceId) => · 9 unmodified lines | |||
| 25 | workspaceId, | 43 | workspaceId, |
| 26 | }); | 44 | }); |
| 27 | 45 | ||
| 46 | const escapeHtml = (value: string): string => | ||
| 47 | value | ||
| 48 | .replaceAll("&", "&") | ||
| 49 | .replaceAll("<", "<") | ||
| 50 | .replaceAll(">", ">") | ||
| 51 | .replaceAll('"', """) | ||
| 52 | .replaceAll("'", "'"); | ||
| 53 | |||
| 54 | const credentialPageDocument = (input: { | ||
| 55 | title: string; | ||
| 56 | eyebrow: string; | ||
| 57 | message: string; | ||
| 58 | sourceLabel: string; | ||
| 59 | endpoint: string; | ||
| 60 | interactionId: string; | ||
| 61 | error?: string; | ||
| 62 | state?: "pending" | "stored" | "continued" | "cancelled" | "inactive"; | ||
| 63 | }) => `<!doctype html> | ||
| 64 | <html lang="en"> | ||
| 65 | <head> | ||
| 66 | <meta charset="utf-8" /> | ||
| 67 | <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| 68 | <title>${escapeHtml(input.title)}</title> | ||
| 69 | <link rel="preconnect" href="https://fonts.googleapis.com" /> | ||
| 70 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | ||
| 71 | <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" /> | ||
| 72 | <style> | ||
| 73 | :root { | ||
| 74 | --bg: #fafaf9; | ||
| 75 | --surface: #ffffff; | ||
| 76 | --text: #1c1917; | ||
| 77 | --text-secondary: #78716c; | ||
| 78 | --text-tertiary: #a8a29e; | ||
| 79 | --border: #e7e5e4; | ||
| 80 | --accent: #1d4ed8; | ||
| 81 | --accent-light: #eff6ff; | ||
| 82 | --green: #16a34a; | ||
| 83 | --green-light: #f0fdf4; | ||
| 84 | --green-border: #bbf7d0; | ||
| 85 | --red: #dc2626; | ||
| 86 | --red-light: #fef2f2; | ||
| 87 | --red-border: #fecaca; | ||
| 88 | --amber: #d97706; | ||
| 89 | --amber-light: #fffbeb; | ||
| 90 | --amber-border: #fde68a; | ||
| 91 | } | ||
| 92 | |||
| 93 | * { box-sizing: border-box; margin: 0; padding: 0; } | ||
| 94 | |||
| 95 | body { | ||
| 96 | min-height: 100vh; | ||
| 97 | background: var(--bg); | ||
| 98 | color: var(--text); | ||
| 99 | font-family: 'DM Sans', system-ui, -apple-system, sans-serif; | ||
| 100 | display: flex; | ||
| 101 | justify-content: center; | ||
| 102 | padding: 80px 24px 48px; | ||
| 103 | -webkit-font-smoothing: antialiased; | ||
| 104 | } | ||
| 105 | |||
| 106 | .page { | ||
| 107 | width: 100%; | ||
| 108 | max-width: 540px; | ||
| 109 | } | ||
| 110 | |||
| 111 | /* Status pill at the very top */ | ||
| 112 | .status-pill { | ||
| 113 | display: inline-flex; | ||
| 114 | align-items: center; | ||
| 115 | gap: 7px; | ||
| 116 | padding: 5px 14px 5px 10px; | ||
| 117 | border-radius: 999px; | ||
| 118 | font-family: 'DM Mono', monospace; | ||
| 119 | font-size: 12px; | ||
| 120 | font-weight: 500; | ||
| 121 | letter-spacing: 0.01em; | ||
| 122 | margin-bottom: 28px; | ||
| 123 | } | ||
| 124 | |||
| 125 | .status-pill::before { | ||
| 126 | content: ''; | ||
| 127 | width: 7px; | ||
| 128 | height: 7px; | ||
| 129 | border-radius: 50%; | ||
| 130 | flex-shrink: 0; | ||
| 131 | } | ||
| 132 | |||
| 133 | .status-pill[data-state='pending'] { | ||
| 134 | background: var(--amber-light); | ||
| 135 | color: var(--amber); | ||
| 136 | border: 1px solid var(--amber-border); | ||
| 137 | } | ||
| 138 | .status-pill[data-state='pending']::before { background: var(--amber); } | ||
| 139 | |||
| 140 | .status-pill[data-state='stored'] { | ||
| 141 | background: var(--green-light); | ||
| 142 | color: var(--green); | ||
| 143 | border: 1px solid var(--green-border); | ||
| 144 | } | ||
| 145 | .status-pill[data-state='stored']::before { background: var(--green); } | ||
| 146 | |||
| 147 | .status-pill[data-state='continued'] { | ||
| 148 | background: var(--green-light); | ||
| 149 | color: var(--green); | ||
| 150 | border: 1px solid var(--green-border); | ||
| 151 | } | ||
| 152 | .status-pill[data-state='continued']::before { background: var(--green); } | ||
| 153 | |||
| 154 | .status-pill[data-state='cancelled'], | ||
| 155 | .status-pill[data-state='inactive'] { | ||
| 156 | background: #f5f5f4; | ||
| 157 | color: var(--text-secondary); | ||
| 158 | border: 1px solid var(--border); | ||
| 159 | } | ||
| 160 | .status-pill[data-state='cancelled']::before, | ||
| 161 | .status-pill[data-state='inactive']::before { background: var(--text-tertiary); } | ||
| 162 | |||
| 163 | /* Title area */ | ||
| 164 | h1 { | ||
| 165 | font-size: 28px; | ||
| 166 | font-weight: 600; | ||
| 167 | letter-spacing: -0.025em; | ||
| 168 | line-height: 1.2; | ||
| 169 | margin-bottom: 10px; | ||
| 170 | } | ||
| 171 | |||
| 172 | .subtitle { | ||
| 173 | font-size: 15px; | ||
| 174 | line-height: 1.6; | ||
| 175 | color: var(--text-secondary); | ||
| 176 | } | ||
| 177 | |||
| 178 | /* Divider */ | ||
| 179 | hr { | ||
| 180 | border: none; | ||
| 181 | height: 1px; | ||
| 182 | background: var(--border); | ||
| 183 | margin: 36px 0; | ||
| 184 | } | ||
| 185 | |||
| 186 | /* Source detail rows */ | ||
| 187 | .detail { | ||
| 188 | display: flex; | ||
| 189 | flex-direction: column; | ||
| 190 | gap: 6px; | ||
| 191 | margin-bottom: 24px; | ||
| 192 | } | ||
| 193 | |||
| 194 | .detail-label { | ||
| 195 | font-size: 13px; | ||
| 196 | font-weight: 500; | ||
| 197 | color: var(--text-secondary); | ||
| 198 | } | ||
| 199 | |||
| 200 | .detail-value { | ||
| 201 | font-size: 15px; | ||
| 202 | line-height: 1.5; | ||
| 203 | word-break: break-word; | ||
| 204 | } | ||
| 205 | |||
| 206 | .detail-mono { | ||
| 207 | font-family: 'DM Mono', monospace; | ||
| 208 | font-size: 13px; | ||
| 209 | line-height: 1.5; | ||
| 210 | padding: 10px 14px; | ||
| 211 | background: #f5f5f4; | ||
| 212 | border: 1px solid var(--border); | ||
| 213 | border-radius: 8px; | ||
| 214 | word-break: break-all; | ||
| 215 | color: var(--text); | ||
| 216 | } | ||
| 217 | |||
| 218 | .detail-id { | ||
| 219 | font-family: 'DM Mono', monospace; | ||
| 220 | font-size: 12px; | ||
| 221 | color: var(--text-tertiary); | ||
| 222 | } | ||
| 223 | |||
| 224 | /* Result card for stored/cancelled/inactive */ | ||
| 225 | .result-card { | ||
| 226 | padding: 32px; | ||
| 227 | border-radius: 12px; | ||
| 228 | text-align: center; | ||
| 229 | } | ||
| 230 | |||
| 231 | .result-card.success { | ||
| 232 | background: var(--green-light); | ||
| 233 | border: 1px solid var(--green-border); | ||
| 234 | } | ||
| 235 | |||
| 236 | .result-card.neutral { | ||
| 237 | background: #f5f5f4; | ||
| 238 | border: 1px solid var(--border); | ||
| 239 | } | ||
| 240 | |||
| 241 | .result-card p { | ||
| 242 | font-size: 15px; | ||
| 243 | line-height: 1.6; | ||
| 244 | color: var(--text-secondary); | ||
| 245 | } | ||
| 246 | |||
| 247 | .result-card .result-heading { | ||
| 248 | font-weight: 600; | ||
| 249 | font-size: 15px; | ||
| 250 | color: var(--text); | ||
| 251 | margin-bottom: 6px; | ||
| 252 | } | ||
| 253 | |||
| 254 | /* Form */ | ||
| 255 | .alert { | ||
| 256 | padding: 12px 14px; | ||
| 257 | border-radius: 8px; | ||
| 258 | background: var(--red-light); | ||
| 259 | border: 1px solid var(--red-border); | ||
| 260 | color: var(--red); | ||
| 261 | font-size: 14px; | ||
| 262 | line-height: 1.5; | ||
| 263 | margin-bottom: 20px; | ||
| 264 | } | ||
| 265 | |||
| 266 | .field { margin-bottom: 20px; } | ||
| 267 | |||
| 268 | label { | ||
| 269 | display: block; | ||
| 270 | font-size: 14px; | ||
| 271 | font-weight: 500; | ||
| 272 | margin-bottom: 6px; | ||
| 273 | } | ||
| 274 | |||
| 275 | input[type='password'] { | ||
| 276 | width: 100%; | ||
| 277 | padding: 10px 14px; | ||
| 278 | background: var(--surface); | ||
| 279 | border: 1px solid var(--border); | ||
| 280 | border-radius: 8px; | ||
| 281 | font-family: 'DM Mono', monospace; | ||
| 282 | font-size: 14px; | ||
| 283 | color: var(--text); | ||
| 284 | transition: border-color 0.15s, box-shadow 0.15s; | ||
| 285 | } | ||
| 286 | |||
| 287 | input[type='password']::placeholder { color: var(--text-tertiary); } | ||
| 288 | |||
| 289 | input[type='password']:focus { | ||
| 290 | outline: none; | ||
| 291 | border-color: var(--accent); | ||
| 292 | box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.1); | ||
| 293 | } | ||
| 294 | |||
| 295 | .hint { | ||
| 296 | margin-top: 8px; | ||
| 297 | font-size: 13px; | ||
| 298 | line-height: 1.5; | ||
| 299 | color: var(--text-tertiary); | ||
| 300 | } | ||
| 301 | |||
| 302 | .actions { | ||
| 303 | display: flex; | ||
| 304 | gap: 10px; | ||
| 305 | margin-top: 24px; | ||
| 306 | } | ||
| 307 | |||
| 308 | button { | ||
| 309 | appearance: none; | ||
| 310 | border: none; | ||
| 311 | border-radius: 8px; | ||
| 312 | padding: 10px 20px; | ||
| 313 | font-family: inherit; | ||
| 314 | font-size: 14px; | ||
| 315 | font-weight: 500; | ||
| 316 | cursor: pointer; | ||
| 317 | transition: background 0.15s; | ||
| 318 | } | ||
| 319 | |||
| 320 | .primary { | ||
| 321 | background: var(--text); | ||
| 322 | color: var(--surface); | ||
| 323 | } | ||
| 324 | .primary:hover { background: #292524; } | ||
| 325 | |||
| 326 | .secondary { | ||
| 327 | background: transparent; | ||
| 328 | color: var(--text-secondary); | ||
| 329 | border: 1px solid var(--border); | ||
| 330 | } | ||
| 331 | .secondary:hover { background: #f5f5f4; } | ||
| 332 | |||
| 333 | @media (max-width: 540px) { | ||
| 334 | body { padding: 48px 20px 32px; } | ||
| 335 | h1 { font-size: 24px; } | ||
| 336 | } | ||
| 337 | </style> | ||
| 338 | </head> | ||
| 339 | <body> | ||
| 340 | <main class="page"> | ||
| 341 | <div class="status-pill" data-state="${escapeHtml(input.state ?? "pending")}">${escapeHtml(input.state ?? "pending")}</div> | ||
| 342 | |||
| 343 | <h1>${escapeHtml(input.title)}</h1> | ||
| 344 | <p class="subtitle">${escapeHtml(input.message)}</p> | ||
| 345 | |||
| 346 | <hr /> | ||
| 347 | |||
| 348 | <div class="detail"> | ||
| 349 | <span class="detail-label">Source</span> | ||
| 350 | <span class="detail-value">${escapeHtml(input.sourceLabel)}</span> | ||
| 351 | </div> | ||
| 352 | |||
| 353 | <div class="detail"> | ||
| 354 | <span class="detail-label">API endpoint</span> | ||
| 355 | <div class="detail-mono">${escapeHtml(input.endpoint)}</div> | ||
| 356 | </div> | ||
| 357 | |||
| 358 | <div class="detail"> | ||
| 359 | <span class="detail-label">Interaction</span> | ||
| 360 | <span class="detail-id">${escapeHtml(input.interactionId)}</span> | ||
| 361 | </div> | ||
| 362 | |||
| 363 | <hr /> | ||
| 364 | |||
| 365 | ${ | ||
| 366 | input.error | ||
| 367 | ? `<p class="alert">${escapeHtml(input.error)}</p>` | ||
| 368 | : "" | ||
| 369 | } | ||
| 370 | ${ | ||
| 371 | input.state === "stored" | ||
| 372 | ? `<div class="result-card success"> | ||
| 373 | <p class="result-heading">Credential stored</p> | ||
| 374 | <p>Executor resumed the source connection request. You can close this window.</p> | ||
| 375 | </div>` | ||
| 376 | : input.state === "continued" | ||
| 377 | ? `<div class="result-card success"> | ||
| 378 | <p class="result-heading">Continuing without auth</p> | ||
| 379 | <p>Executor resumed the source connection without stored credentials. You can close this window.</p> | ||
| 380 | </div>` | ||
| 381 | : input.state === "cancelled" | ||
| 382 | ? `<div class="result-card neutral"> | ||
| 383 | <p class="result-heading">Request cancelled</p> | ||
| 384 | <p>Executor was notified. You can close this window.</p> | ||
| 385 | </div>` | ||
| 386 | : input.state === "inactive" | ||
| 387 | ? `<div class="result-card neutral"> | ||
| 388 | <p class="result-heading">No longer active</p> | ||
| 389 | <p>This credential request has expired. Start a new source connection flow if needed.</p> | ||
| 390 | </div>` | ||
| 391 | : `<form method="post"> | ||
| 392 | <div class="field"> | ||
| 393 | <label for="token">Personal access token</label> | ||
| 394 | <input | ||
| 395 | id="token" | ||
| 396 | name="token" | ||
| 397 | type="password" | ||
| 398 | autocomplete="off" | ||
| 399 | spellcheck="false" | ||
| 400 | placeholder="ghp_..." | ||
| 401 | /> | ||
| 402 | <p class="hint"> | ||
| 403 | Add a token to enable authenticated requests, or continue without auth if this API supports public access. | ||
| 404 | </p> | ||
| 405 | </div> | ||
| 406 | <div class="actions"> | ||
| 407 | <button class="primary" type="submit" name="action" value="submit">Connect token</button> | ||
| 408 | <button class="secondary" type="submit" name="action" value="continue" formnovalidate>Continue without auth</button> | ||
| 409 | <button class="secondary" type="submit" name="action" value="cancel" formnovalidate>Cancel</button> | ||
| 410 | </div> | ||
| 411 | </form>` | ||
| 412 | } | ||
| 413 | </main> | ||
| 414 | </body> | ||
| 415 | </html>`; | ||
| 416 | |||
| 417 | const htmlResponse = (html: string, status = 200) => | ||
| 418 | HttpServerResponse.html(html).pipe( | ||
| 419 | HttpServerResponse.setStatus(status), | ||
| 420 | ); | ||
| 421 | |||
| 422 | const credentialErrorResponse = (input: { | ||
| 423 | title: string; | ||
| 424 | message: string; | ||
| 425 | status: number; | ||
| 426 | }) => | ||
| 427 | htmlResponse( | ||
| 428 | credentialPageDocument({ | ||
| 429 | title: input.title, | ||
| 430 | eyebrow: "Executor", | ||
| 431 | message: input.message, | ||
| 432 | sourceLabel: "Unavailable", | ||
| 433 | endpoint: "Unavailable", | ||
| 434 | interactionId: "unavailable", | ||
| 435 | state: "inactive", | ||
| 436 | error: input.message, | ||
| 437 | }), | ||
| 438 | input.status, | ||
| 439 | ); | ||
| 440 | |||
| 441 | const credentialErrorStatus = ( | ||
| 442 | error: ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError, | ||
| 443 | ) => | ||
| 444 | error instanceof ControlPlaneNotFoundError | ||
| 445 | ? 404 | ||
| 446 | : error instanceof ControlPlaneBadRequestError | ||
| 447 | ? 400 | ||
| 448 | : 500; | ||
| 449 | |||
| 450 | const credentialSubmitErrorResponse = (input: { | ||
| 451 | workspaceId: WorkspaceId; | ||
| 452 | sourceId: Source["id"]; | ||
| 453 | interactionId: ExecutionInteraction["id"]; | ||
| 454 | message: string; | ||
| 455 | status: number; | ||
| 456 | }) => | ||
| 457 | getSourceCredentialInteraction({ | ||
| 458 | workspaceId: input.workspaceId, | ||
| 459 | sourceId: input.sourceId, | ||
| 460 | interactionId: input.interactionId, | ||
| 461 | }).pipe( | ||
| 462 | Effect.match({ | ||
| 463 | onFailure: () => | ||
| 464 | credentialErrorResponse({ | ||
| 465 | title: "Source Credential Request Failed", | ||
| 466 | message: input.message, | ||
| 467 | status: input.status, | ||
| 468 | }), | ||
| 469 | onSuccess: (interaction) => | ||
| 470 | htmlResponse( | ||
| 471 | credentialPageDocument({ | ||
| 472 | title: "Configure Source Access", | ||
| 473 | eyebrow: "Executor", | ||
| 474 | message: interaction.message, | ||
| 475 | sourceLabel: interaction.sourceLabel, | ||
| 476 | endpoint: interaction.endpoint, | ||
| 477 | interactionId: interaction.interactionId, | ||
| 478 | error: input.message, | ||
| 479 | state: "pending", | ||
| 480 | }), | ||
| 481 | input.status, | ||
| 482 | ), | ||
| 483 | }), | ||
| 484 | ); | ||
| 485 | |||
| 28 | export const ControlPlaneSourcesLive = HttpApiBuilder.group( | 486 | export const ControlPlaneSourcesLive = HttpApiBuilder.group( |
| 29 | ControlPlaneApi, | 487 | ControlPlaneApi, |
| 30 | "sources", | 488 | "sources", |
| @@ -74,5 +532,125 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 43 unmodified lines | |||
| 74 | }), | 532 | }), |
| 75 | ), | 533 | ), |
| 76 | ), | 534 | ), |
| 535 | ) | ||
| 536 | .handle("credentialPage", ({ path, urlParams }) => | ||
| 537 | getSourceCredentialInteraction({ | ||
| 538 | workspaceId: path.workspaceId, | ||
| 539 | sourceId: path.sourceId, | ||
| 540 | interactionId: urlParams.interactionId, | ||
| 541 | }).pipe( | ||
| 542 | Effect.map((interaction) => | ||
| 543 | htmlResponse( | ||
| 544 | credentialPageDocument({ | ||
| 545 | title: "Configure Source Access", | ||
| 546 | eyebrow: "Executor", | ||
| 547 | message: interaction.message, | ||
| 548 | sourceLabel: interaction.sourceLabel, | ||
| 549 | endpoint: interaction.endpoint, | ||
| 550 | interactionId: interaction.interactionId, | ||
| 551 | state: interaction.status === "pending" ? "pending" : "inactive", | ||
| 552 | }), | ||
| 553 | ) | ||
| 554 | ), | ||
| 555 | Effect.catchTags({ | ||
| 556 | ControlPlaneNotFoundError: (error) => | ||
| 557 | Effect.succeed( | ||
| 558 | credentialErrorResponse({ | ||
| 559 | title: "Source Credential Request Unavailable", | ||
| 560 | message: error.message, | ||
| 561 | status: credentialErrorStatus(error), | ||
| 562 | }), | ||
| 563 | ), | ||
| 564 | ControlPlaneStorageError: (error) => | ||
| 565 | Effect.succeed( | ||
| 566 | credentialErrorResponse({ | ||
| 567 | title: "Source Credential Request Failed", | ||
| 568 | message: error.message, | ||
| 569 | status: credentialErrorStatus(error), | ||
| 570 | }), | ||
| 571 | ), | ||
| 572 | }), | ||
| 573 | ), | ||
| 574 | ) | ||
| 575 | .handle("credentialSubmit", ({ path, urlParams, payload }) => | ||
| 576 | submitSourceCredentialInteraction({ | ||
| 577 | workspaceId: path.workspaceId, | ||
| 578 | sourceId: path.sourceId, | ||
| 579 | interactionId: urlParams.interactionId, | ||
| 580 | action: | ||
| 581 | payload.action === "cancel" | ||
| 582 | ? "cancel" | ||
| 583 | : payload.action === "continue" | ||
| 584 | ? "continue" | ||
| 585 | : "submit", | ||
| 586 | token: payload.token, | ||
| 587 | }).pipe( | ||
| 588 | Effect.map((result) => | ||
| 589 | htmlResponse( | ||
| 590 | credentialPageDocument({ | ||
| 591 | title: | ||
| 592 | result.kind === "stored" | ||
| 593 | ? "Source Credential Stored" | ||
| 594 | : result.kind === "continued" | ||
| 595 | ? "Source Access Updated" | ||
| 596 | : "Source Credential Request Cancelled", | ||
| 597 | eyebrow: "Executor", | ||
| 598 | message: | ||
| 599 | result.kind === "stored" | ||
| 600 | ? "Executor stored the credential and resumed the source connection." | ||
| 601 | : result.kind === "continued" | ||
| 602 | ? "Executor resumed the source connection without stored credentials." | ||
| 603 | : "Executor cancelled the source credential request.", | ||
| 604 | sourceLabel: result.sourceLabel, | ||
| 605 | endpoint: result.endpoint, | ||
| 606 | interactionId: urlParams.interactionId, | ||
| 607 | state: | ||
| 608 | result.kind === "stored" | ||
| 609 | ? "stored" | ||
| 610 | : result.kind === "continued" | ||
| 611 | ? "continued" | ||
| 612 | : "cancelled", | ||
| 613 | }), | ||
| 614 | ) | ||
| 615 | ), | ||
| 616 | Effect.catchTags({ | ||
| 617 | ControlPlaneBadRequestError: (error) => | ||
| 618 | credentialSubmitErrorResponse({ | ||
| 619 | workspaceId: path.workspaceId, | ||
| 620 | sourceId: path.sourceId, | ||
| 621 | interactionId: urlParams.interactionId, | ||
| 622 | message: error.message, | ||
| 623 | status: credentialErrorStatus(error), | ||
| 624 | }), | ||
| 625 | ControlPlaneNotFoundError: (error) => | ||
| 626 | Effect.succeed( | ||
| 627 | credentialErrorResponse({ | ||
| 628 | title: "Source Credential Request Unavailable", | ||
| 629 | message: error.message, | ||
| 630 | status: credentialErrorStatus(error), | ||
| 631 | }), | ||
| 632 | ), | ||
| 633 | ControlPlaneStorageError: (error) => | ||
| 634 | Effect.succeed( | ||
| 635 | credentialErrorResponse({ | ||
| 636 | title: "Source Credential Request Failed", | ||
| 637 | message: error.message, | ||
| 638 | status: credentialErrorStatus(error), | ||
| 639 | }), | ||
| 640 | ), | ||
| 641 | }), | ||
| 642 | ), | ||
| 643 | ) | ||
| 644 | .handle("credentialComplete", ({ path, urlParams }) => | ||
| 645 | completeSourceCredentialSetup({ | ||
| 646 | workspaceId: path.workspaceId, | ||
| 647 | sourceId: path.sourceId, | ||
| 648 | state: urlParams.state, | ||
| 649 | code: urlParams.code, | ||
| 650 | error: urlParams.error, | ||
| 651 | errorDescription: urlParams.error_description, | ||
| 652 | }).pipe( | ||
| 653 | Effect.map((source) => `Source connected: ${source.id}. You can close this window.`), | ||
| 654 | ), | ||
| 77 | ), | 655 | ), |
| 78 | ); | 656 | ); |
| @@ -18,6 +18,20 @@ export const createExecutionInteractionsRepo = ( · 17 unmodified lines | |||
| 18 | client: DrizzleClient, | 18 | client: DrizzleClient, |
| 19 | tables: DrizzleTables, | 19 | tables: DrizzleTables, |
| 20 | ) => ({ | 20 | ) => ({ |
| 21 | getById: (interactionId: ExecutionInteraction["id"]) => | ||
| 22 | client.use("rows.execution_interactions.get_by_id", async (db) => { | ||
| 23 | const rows = await db | ||
| 24 | .select() | ||
| 25 | .from(tables.executionInteractionsTable) | ||
| 26 | .where(eq(tables.executionInteractionsTable.id, interactionId)) | ||
| 27 | .limit(1); | ||
| 28 | |||
| 29 | const row = firstOption(rows); | ||
| 30 | return Option.isSome(row) | ||
| 31 | ? Option.some(decodeExecutionInteraction(row.value)) | ||
| 32 | : Option.none<ExecutionInteraction>(); | ||
| 33 | }), | ||
| 34 | |||
| 21 | listByExecutionId: (executionId: ExecutionInteraction["executionId"]) => | 35 | listByExecutionId: (executionId: ExecutionInteraction["executionId"]) => |
| 22 | client.use("rows.execution_interactions.list_by_execution_id", async (db) => { | 36 | client.use("rows.execution_interactions.list_by_execution_id", async (db) => { |
| 23 | const rows = await db | 37 | const rows = await db |
| @@ -178,7 +178,7 @@ export const createToolArtifactsRepo = ( · 177 unmodified lines | |||
| 178 | }, | 178 | }, |
| 179 | ) => | 179 | ) => |
| 180 | client.use("rows.tool_artifacts.search_by_workspace", async (db) => { | 180 | client.use("rows.tool_artifacts.search_by_workspace", async (db) => { |
| 181 | const rows = await db | 181 | const baseQuery = db |
| 182 | .select() | 182 | .select() |
| 183 | .from(tables.toolArtifactsTable) | 183 | .from(tables.toolArtifactsTable) |
| 184 | .where( | 184 | .where( |
| @@ -191,8 +191,10 @@ export const createToolArtifactsRepo = ( · 6 unmodified lines | |||
| 191 | .orderBy( | 191 | .orderBy( |
| 192 | asc(tables.toolArtifactsTable.searchNamespace), | 192 | asc(tables.toolArtifactsTable.searchNamespace), |
| 193 | asc(tables.toolArtifactsTable.path), | 193 | asc(tables.toolArtifactsTable.path), |
| 194 | ) | 194 | ); |
| 195 | .limit(input.limit ?? 500); | 195 | const rows = input.limit !== undefined |
| 196 | ? await baseQuery.limit(input.limit) | ||
| 197 | : await baseQuery; | ||
| 196 | 198 | ||
| 197 | return rows.map((row) => decodeStoredToolArtifactRecord(row)); | 199 | return rows.map((row) => decodeStoredToolArtifactRecord(row)); |
| 198 | }), | 200 | }), |
| @@ -1,10 +1,22 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { assertTrue } from "@effect/vitest/utils"; | 2 | import { assertTrue } from "@effect/vitest/utils"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 4 | 6 | ||
| 5 | import type { AccountId } from "#schema"; | 7 | import type { AccountId } from "#schema"; |
| 6 | 8 | import { | |
| 7 | import { createSqlControlPlaneRuntime } from "./index"; | 9 | ExecutionIdSchema, |
| 10 | ExecutionInteractionIdSchema, | ||
| 11 | SecretMaterialIdSchema, | ||
| 12 | SourceIdSchema, | ||
| 13 | } from "#schema"; | ||
| 14 | import type { ToolPath } from "@executor-v3/codemode-core"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | createSqlControlPlaneRuntime, | ||
| 18 | LiveExecutionManagerService, | ||
| 19 | } from "./index"; | ||
| 8 | import { withControlPlaneClient } from "./test-http-client"; | 20 | import { withControlPlaneClient } from "./test-http-client"; |
| 9 | 21 | ||
| 10 | const makeRuntime = Effect.acquireRelease( | 22 | const makeRuntime = Effect.acquireRelease( |
| @@ -102,6 +114,275 @@ describe("control-plane-runtime", () => { · 91 unmodified lines | |||
| 102 | }), | 114 | }), |
| 103 | ); | 115 | ); |
| 104 | 116 | ||
| 117 | it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () => | ||
| 118 | Effect.gen(function* () { | ||
| 119 | const runtime = yield* makeRuntime; | ||
| 120 | const installation = runtime.localInstallation; | ||
| 121 | const executionId = ExecutionIdSchema.make("exec_local_credential"); | ||
| 122 | const sourceId = SourceIdSchema.make("src_local_credential"); | ||
| 123 | const interactionSuffix = "executor.sources.add:test"; | ||
| 124 | const interactionId = ExecutionInteractionIdSchema.make( | ||
| 125 | `${executionId}:${interactionSuffix}`, | ||
| 126 | ); | ||
| 127 | const now = Date.now(); | ||
| 128 | |||
| 129 | yield* runtime.persistence.rows.executions.insert({ | ||
| 130 | id: executionId, | ||
| 131 | workspaceId: installation.workspaceId, | ||
| 132 | createdByAccountId: installation.accountId, | ||
| 133 | status: "running", | ||
| 134 | code: "return await tools.executor.sources.add(...)", | ||
| 135 | resultJson: null, | ||
| 136 | errorText: null, | ||
| 137 | logsJson: null, | ||
| 138 | startedAt: now, | ||
| 139 | completedAt: null, | ||
| 140 | createdAt: now, | ||
| 141 | updatedAt: now, | ||
| 142 | }); | ||
| 143 | |||
| 144 | yield* runtime.persistence.rows.sources.insert({ | ||
| 145 | id: sourceId, | ||
| 146 | workspaceId: installation.workspaceId, | ||
| 147 | name: "GitHub", | ||
| 148 | kind: "openapi", | ||
| 149 | endpoint: "https://api.github.com", | ||
| 150 | status: "auth_required", | ||
| 151 | enabled: true, | ||
| 152 | namespace: "github", | ||
| 153 | transport: null, | ||
| 154 | queryParamsJson: null, | ||
| 155 | headersJson: null, | ||
| 156 | specUrl: "https://example.com/github-openapi.yaml", | ||
| 157 | defaultHeadersJson: null, | ||
| 158 | authKind: "none", | ||
| 159 | authHeaderName: null, | ||
| 160 | authPrefix: null, | ||
| 161 | sourceHash: null, | ||
| 162 | lastError: null, | ||
| 163 | createdAt: now, | ||
| 164 | updatedAt: now, | ||
| 165 | }); | ||
| 166 | |||
| 167 | const interactionFiber = yield* Effect.gen(function* () { | ||
| 168 | const liveExecutionManager = yield* LiveExecutionManagerService; | ||
| 169 | const onElicitation = liveExecutionManager.createOnElicitation({ | ||
| 170 | rows: runtime.persistence.rows, | ||
| 171 | executionId, | ||
| 172 | }); | ||
| 173 | |||
| 174 | return yield* onElicitation({ | ||
| 175 | interactionId: interactionSuffix, | ||
| 176 | path: "executor.sources.add" as ToolPath, | ||
| 177 | sourceKey: "executor", | ||
| 178 | args: { | ||
| 179 | kind: "openapi", | ||
| 180 | endpoint: "https://api.github.com", | ||
| 181 | specUrl: "https://example.com/github-openapi.yaml", | ||
| 182 | name: "GitHub", | ||
| 183 | workspaceId: installation.workspaceId, | ||
| 184 | sourceId, | ||
| 185 | }, | ||
| 186 | elicitation: { | ||
| 187 | mode: "url", | ||
| 188 | message: "Open the secure credential page to connect GitHub", | ||
| 189 | url: `http://127.0.0.1/v1/workspaces/${encodeURIComponent(installation.workspaceId)}/sources/${encodeURIComponent(sourceId)}/credentials?interactionId=${encodeURIComponent(interactionId)}`, | ||
| 190 | elicitationId: interactionSuffix, | ||
| 191 | }, | ||
| 192 | }); | ||
| 193 | }).pipe( | ||
| 194 | Effect.provide(runtime.runtimeLayer), | ||
| 195 | Effect.fork, | ||
| 196 | ); | ||
| 197 | |||
| 198 | yield* Effect.yieldNow(); | ||
| 199 | |||
| 200 | const pendingInteraction = yield* runtime.persistence.rows.executionInteractions | ||
| 201 | .getPendingByExecutionId(executionId); | ||
| 202 | assertTrue(Option.isSome(pendingInteraction)); | ||
| 203 | expect(pendingInteraction.value.id).toBe(interactionId); | ||
| 204 | |||
| 205 | const page = yield* withControlPlaneClient( | ||
| 206 | { runtime }, | ||
| 207 | (client) => | ||
| 208 | client.sources.credentialPage({ | ||
| 209 | path: { | ||
| 210 | workspaceId: installation.workspaceId, | ||
| 211 | sourceId, | ||
| 212 | }, | ||
| 213 | urlParams: { | ||
| 214 | interactionId: pendingInteraction.value.id, | ||
| 215 | }, | ||
| 216 | }), | ||
| 217 | ); | ||
| 218 | expect(page).toContain("Configure Source Access"); | ||
| 219 | expect(page).toContain("GitHub"); | ||
| 220 | expect(page).toContain("Continue without auth"); | ||
| 221 | |||
| 222 | const submittedPage = yield* withControlPlaneClient( | ||
| 223 | { runtime }, | ||
| 224 | (client) => | ||
| 225 | client.sources.credentialSubmit({ | ||
| 226 | path: { | ||
| 227 | workspaceId: installation.workspaceId, | ||
| 228 | sourceId, | ||
| 229 | }, | ||
| 230 | urlParams: { | ||
| 231 | interactionId: pendingInteraction.value.id, | ||
| 232 | }, | ||
| 233 | payload: { | ||
| 234 | action: "submit", | ||
| 235 | token: "ghp_local_test_token", | ||
| 236 | }, | ||
| 237 | }), | ||
| 238 | ); | ||
| 239 | expect(submittedPage).toContain("Credential Stored"); | ||
| 240 | |||
| 241 | const response = yield* Fiber.join(interactionFiber); | ||
| 242 | expect(response.action).toBe("accept"); | ||
| 243 | expect(response.content?.authKind).toBe("bearer"); | ||
| 244 | expect(typeof response.content?.tokenSecretMaterialId).toBe("string"); | ||
| 245 | |||
| 246 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( | ||
| 247 | String(response.content?.tokenSecretMaterialId), | ||
| 248 | ); | ||
| 249 | const storedSecret = yield* runtime.persistence.rows.secretMaterials.getById( | ||
| 250 | tokenSecretMaterialId, | ||
| 251 | ); | ||
| 252 | assertTrue(Option.isSome(storedSecret)); | ||
| 253 | expect(storedSecret.value.value).toBe("ghp_local_test_token"); | ||
| 254 | |||
| 255 | const storedInteraction = yield* runtime.persistence.rows.executionInteractions.getById( | ||
| 256 | pendingInteraction.value.id, | ||
| 257 | ); | ||
| 258 | assertTrue(Option.isSome(storedInteraction)); | ||
| 259 | expect(storedInteraction.value.responseJson).toContain("\"authKind\":\"bearer\""); | ||
| 260 | expect(storedInteraction.value.responseJson).toContain("tokenSecretMaterialId"); | ||
| 261 | expect(storedInteraction.value.responseJson).not.toContain("ghp_local_test_token"); | ||
| 262 | }), | ||
| 263 | ); | ||
| 264 | |||
| 265 | it.scoped("allows continuing an OpenAPI source credential request without auth", () => | ||
| 266 | Effect.gen(function* () { | ||
| 267 | const runtime = yield* makeRuntime; | ||
| 268 | const installation = runtime.localInstallation; | ||
| 269 | const executionId = ExecutionIdSchema.make("exec_local_credential_continue"); | ||
| 270 | const sourceId = SourceIdSchema.make("src_local_credential_continue"); | ||
| 271 | const interactionSuffix = "executor.sources.add:continue"; | ||
| 272 | const interactionId = ExecutionInteractionIdSchema.make( | ||
| 273 | `${executionId}:${interactionSuffix}`, | ||
| 274 | ); | ||
| 275 | const now = Date.now(); | ||
| 276 | |||
| 277 | yield* runtime.persistence.rows.executions.insert({ | ||
| 278 | id: executionId, | ||
| 279 | workspaceId: installation.workspaceId, | ||
| 280 | createdByAccountId: installation.accountId, | ||
| 281 | status: "running", | ||
| 282 | code: "return await tools.executor.sources.add(...)", | ||
| 283 | resultJson: null, | ||
| 284 | errorText: null, | ||
| 285 | logsJson: null, | ||
| 286 | startedAt: now, | ||
| 287 | completedAt: null, | ||
| 288 | createdAt: now, | ||
| 289 | updatedAt: now, | ||
| 290 | }); | ||
| 291 | |||
| 292 | yield* runtime.persistence.rows.sources.insert({ | ||
| 293 | id: sourceId, | ||
| 294 | workspaceId: installation.workspaceId, | ||
| 295 | name: "GitHub", | ||
| 296 | kind: "openapi", | ||
| 297 | endpoint: "https://api.github.com", | ||
| 298 | status: "auth_required", | ||
| 299 | enabled: true, | ||
| 300 | namespace: "github", | ||
| 301 | transport: null, | ||
| 302 | queryParamsJson: null, | ||
| 303 | headersJson: null, | ||
| 304 | specUrl: "https://example.com/github-openapi.yaml", | ||
| 305 | defaultHeadersJson: null, | ||
| 306 | authKind: "none", | ||
| 307 | authHeaderName: null, | ||
| 308 | authPrefix: null, | ||
| 309 | sourceHash: null, | ||
| 310 | lastError: null, | ||
| 311 | createdAt: now, | ||
| 312 | updatedAt: now, | ||
| 313 | }); | ||
| 314 | |||
| 315 | const interactionFiber = yield* Effect.gen(function* () { | ||
| 316 | const liveExecutionManager = yield* LiveExecutionManagerService; | ||
| 317 | const onElicitation = liveExecutionManager.createOnElicitation({ | ||
| 318 | rows: runtime.persistence.rows, | ||
| 319 | executionId, | ||
| 320 | }); | ||
| 321 | |||
| 322 | return yield* onElicitation({ | ||
| 323 | interactionId: interactionSuffix, | ||
| 324 | path: "executor.sources.add" as ToolPath, | ||
| 325 | sourceKey: "executor", | ||
| 326 | args: { | ||
| 327 | kind: "openapi", | ||
| 328 | endpoint: "https://api.github.com", | ||
| 329 | specUrl: "https://example.com/github-openapi.yaml", | ||
| 330 | name: "GitHub", | ||
| 331 | workspaceId: installation.workspaceId, | ||
| 332 | sourceId, | ||
| 333 | }, | ||
| 334 | elicitation: { | ||
| 335 | mode: "url", | ||
| 336 | message: "Open the secure credential page to connect GitHub", | ||
| 337 | url: `http://127.0.0.1/v1/workspaces/${encodeURIComponent(installation.workspaceId)}/sources/${encodeURIComponent(sourceId)}/credentials?interactionId=${encodeURIComponent(interactionId)}`, | ||
| 338 | elicitationId: interactionSuffix, | ||
| 339 | }, | ||
| 340 | }); | ||
| 341 | }).pipe( | ||
| 342 | Effect.provide(runtime.runtimeLayer), | ||
| 343 | Effect.fork, | ||
| 344 | ); | ||
| 345 | |||
| 346 | yield* Effect.yieldNow(); | ||
| 347 | |||
| 348 | const pendingInteraction = yield* runtime.persistence.rows.executionInteractions | ||
| 349 | .getPendingByExecutionId(executionId); | ||
| 350 | assertTrue(Option.isSome(pendingInteraction)); | ||
| 351 | |||
| 352 | const submittedPage = yield* withControlPlaneClient( | ||
| 353 | { runtime }, | ||
| 354 | (client) => | ||
| 355 | client.sources.credentialSubmit({ | ||
| 356 | path: { | ||
| 357 | workspaceId: installation.workspaceId, | ||
| 358 | sourceId, | ||
| 359 | }, | ||
| 360 | urlParams: { | ||
| 361 | interactionId: pendingInteraction.value.id, | ||
| 362 | }, | ||
| 363 | payload: { | ||
| 364 | action: "continue", | ||
| 365 | }, | ||
| 366 | }), | ||
| 367 | ); | ||
| 368 | |||
| 369 | expect(submittedPage).toContain("Continuing without auth"); | ||
| 370 | |||
| 371 | const response = yield* Fiber.join(interactionFiber); | ||
| 372 | expect(response.action).toBe("accept"); | ||
| 373 | expect(response.content).toEqual({ | ||
| 374 | authKind: "none", | ||
| 375 | }); | ||
| 376 | |||
| 377 | const storedInteraction = yield* runtime.persistence.rows.executionInteractions.getById( | ||
| 378 | pendingInteraction.value.id, | ||
| 379 | ); | ||
| 380 | assertTrue(Option.isSome(storedInteraction)); | ||
| 381 | expect(storedInteraction.value.responseJson).toContain("\"authKind\":\"none\""); | ||
| 382 | expect(storedInteraction.value.responseJson).not.toContain("tokenSecretMaterialId"); | ||
| 383 | }), | ||
| 384 | ); | ||
| 385 | |||
| 105 | it.scoped("scopes organization list/get to memberships", () => | 386 | it.scoped("scopes organization list/get to memberships", () => |
| 106 | Effect.gen(function* () { | 387 | Effect.gen(function* () { |
| 107 | const runtime = yield* makeRuntime; | 388 | const runtime = yield* makeRuntime; |
| @@ -19,6 +19,7 @@ import * as Schema from "effect/Schema"; · 18 unmodified lines | |||
| 19 | 19 | ||
| 20 | import { | 20 | import { |
| 21 | type ExecutorAddSourceInput, | 21 | type ExecutorAddSourceInput, |
| 22 | type ExecutorOpenApiSourceAuthInput, | ||
| 22 | type RuntimeSourceAuthService, | 23 | type RuntimeSourceAuthService, |
| 23 | } from "./source-auth-service"; | 24 | } from "./source-auth-service"; |
| 24 | import { | 25 | import { |
| @@ -26,19 +27,6 @@ import { · 1 unmodified line | |||
| 26 | deriveSchemaTypeSignature, | 27 | deriveSchemaTypeSignature, |
| 27 | } from "./schema-type-signature"; | 28 | } from "./schema-type-signature"; |
| 28 | 29 | ||
| 29 | const ExecutorOpenApiSourceAuthInputSchema = Schema.Union( | ||
| 30 | Schema.Struct({ | ||
| 31 | kind: Schema.Literal("none"), | ||
| 32 | }), | ||
| 33 | Schema.Struct({ | ||
| 34 | kind: Schema.Literal("bearer"), | ||
| 35 | headerName: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 36 | prefix: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 37 | token: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 38 | tokenEnvVar: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 39 | }), | ||
| 40 | ); | ||
| 41 | |||
| 42 | const ExecutorMcpSourceAddInputSchema = Schema.Struct({ | 30 | const ExecutorMcpSourceAddInputSchema = Schema.Struct({ |
| 43 | kind: Schema.optional(Schema.Literal("mcp")), | 31 | kind: Schema.optional(Schema.Literal("mcp")), |
| 44 | endpoint: Schema.String, | 32 | endpoint: Schema.String, |
| @@ -52,7 +40,6 @@ const ExecutorOpenApiSourceAddInputSchema = Schema.Struct({ · 7 unmodified lines | |||
| 52 | specUrl: Schema.String, | 40 | specUrl: Schema.String, |
| 53 | name: Schema.optional(Schema.NullOr(Schema.String)), | 41 | name: Schema.optional(Schema.NullOr(Schema.String)), |
| 54 | namespace: Schema.optional(Schema.NullOr(Schema.String)), | 42 | namespace: Schema.optional(Schema.NullOr(Schema.String)), |
| 55 | auth: Schema.optional(Schema.NullOr(ExecutorOpenApiSourceAuthInputSchema)), | ||
| 56 | }); | 43 | }); |
| 57 | 44 | ||
| 58 | const ExecutorSourcesAddSchema = Schema.Union( | 45 | const ExecutorSourcesAddSchema = Schema.Union( |
| @@ -100,7 +87,7 @@ export const EXECUTOR_SOURCES_ADD_HELP_LINES = [ · 41 unmodified lines | |||
| 100 | ' Omit kind or set kind: "mcp". endpoint is the MCP server URL.', | 87 | ' Omit kind or set kind: "mcp". endpoint is the MCP server URL.', |
| 101 | `- OpenAPI: ${EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE}`, | 88 | `- OpenAPI: ${EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE}`, |
| 102 | " endpoint is the base API URL. specUrl is the OpenAPI document URL.", | 89 | " endpoint is the base API URL. specUrl is the OpenAPI document URL.", |
| 103 | " If credentials are needed, executor prompts with form or URL interaction.", | 90 | " executor handles the credential setup for you.", |
| 104 | ] as const; | 91 | ] as const; |
| 105 | 92 | ||
| 106 | export const buildExecutorSourcesAddDescription = (): string => | 93 | export const buildExecutorSourcesAddDescription = (): string => |
| @@ -133,11 +120,34 @@ const resolveOpenApiSourceLabel = (input: { · 26 unmodified lines | |||
| 133 | endpoint: string; | 120 | endpoint: string; |
| 134 | }): string => trimOrNull(input.name) ?? input.endpoint; | 121 | }): string => trimOrNull(input.name) ?? input.endpoint; |
| 135 | 122 | ||
| 136 | const promptForBearerToken = (input: { | 123 | const resolveLocalCredentialUrl = (input: { |
| 137 | args: Extract<ExecutorAddSourceInput, { kind: "openapi" }>; | 124 | baseUrl: string; |
| 125 | workspaceId: WorkspaceId; | ||
| 126 | sourceId: Source["id"]; | ||
| 127 | executionId: string; | ||
| 128 | interactionId: string; | ||
| 129 | }): string => | ||
| 130 | new URL( | ||
| 131 | `/v1/workspaces/${encodeURIComponent(input.workspaceId)}/sources/${encodeURIComponent(input.sourceId)}/credentials?interactionId=${encodeURIComponent(`${input.executionId}:${input.interactionId}`)}`, | ||
| 132 | input.baseUrl, | ||
| 133 | ).toString(); | ||
| 134 | |||
| 135 | const promptForSourceCredentialSelection = (input: { | ||
| 136 | args: { | ||
| 137 | workspaceId: WorkspaceId; | ||
| 138 | sourceId: Source["id"]; | ||
| 139 | kind: "openapi"; | ||
| 140 | endpoint: string; | ||
| 141 | specUrl: string; | ||
| 142 | name?: string | null; | ||
| 143 | namespace?: string | null; | ||
| 144 | }; | ||
| 145 | source: Source; | ||
| 146 | executionId: string; | ||
| 138 | interactionId: string; | 147 | interactionId: string; |
| 139 | path: ToolPath; | 148 | path: ToolPath; |
| 140 | sourceKey: string; | 149 | sourceKey: string; |
| 150 | localServerBaseUrl: string | null; | ||
| 141 | metadata?: ToolMetadata; | 151 | metadata?: ToolMetadata; |
| 142 | invocation?: ToolInvocationContext; | 152 | invocation?: ToolInvocationContext; |
| 143 | onElicitation?: OnElicitation; | 153 | onElicitation?: OnElicitation; |
| @@ -149,6 +159,12 @@ const promptForBearerToken = (input: { · 5 unmodified lines | |||
| 149 | ); | 159 | ); |
| 150 | } | 160 | } |
| 151 | 161 | ||
| 162 | if (input.localServerBaseUrl === null) { | ||
| 163 | return yield* Effect.fail( | ||
| 164 | new Error("executor.sources.add requires a local server base URL for credential capture"), | ||
| 165 | ); | ||
| 166 | } | ||
| 167 | |||
| 152 | const response: ElicitationResponse = yield* input.onElicitation({ | 168 | const response: ElicitationResponse = yield* input.onElicitation({ |
| 153 | interactionId: input.interactionId, | 169 | interactionId: input.interactionId, |
| 154 | path: input.path, | 170 | path: input.path, |
| @@ -157,40 +173,49 @@ const promptForBearerToken = (input: { · 2 unmodified lines | |||
| 157 | metadata: input.metadata, | 173 | metadata: input.metadata, |
| 158 | context: input.invocation, | 174 | context: input.invocation, |
| 159 | elicitation: { | 175 | elicitation: { |
| 160 | mode: "form", | 176 | mode: "url", |
| 161 | message: `Enter the API token to connect ${resolveOpenApiSourceLabel(input.args)}`, | 177 | message: `Open the secure credential page to connect ${input.source.name}`, |
| 162 | requestedSchema: { | 178 | url: resolveLocalCredentialUrl({ |
| 163 | type: "object", | 179 | baseUrl: input.localServerBaseUrl, |
| 164 | properties: { | 180 | workspaceId: input.args.workspaceId, |
| 165 | token: { | 181 | sourceId: input.args.sourceId, |
| 166 | type: "string", | 182 | executionId: input.executionId, |
| 167 | title: "API token", | 183 | interactionId: input.interactionId, |
| 168 | }, | 184 | }), |
| 169 | }, | 185 | elicitationId: input.interactionId, |
| 170 | required: ["token"], | ||
| 171 | additionalProperties: false, | ||
| 172 | }, | ||
| 173 | }, | 186 | }, |
| 174 | }).pipe(Effect.mapError((cause) => cause instanceof Error ? cause : new Error(String(cause)))); | 187 | }).pipe(Effect.mapError((cause) => cause instanceof Error ? cause : new Error(String(cause)))); |
| 175 | 188 | ||
| 176 | if (response.action !== "accept") { | 189 | if (response.action !== "accept") { |
| 177 | return yield* Effect.fail( | 190 | return yield* Effect.fail( |
| 178 | new Error(`Source add was not completed for ${resolveOpenApiSourceLabel(input.args)}`), | 191 | new Error(`Source credential setup was not completed for ${input.source.name}`), |
| 179 | ); | 192 | ); |
| 180 | } | 193 | } |
| 181 | 194 | ||
| 182 | const token = | 195 | const authKind = |
| 183 | response.content && typeof response.content.token === "string" | 196 | response.content && typeof response.content.authKind === "string" |
| 184 | ? response.content.token.trim() | 197 | ? response.content.authKind.trim() |
| 198 | : ""; | ||
| 199 | |||
| 200 | if (authKind === "none") { | ||
| 201 | return { kind: "none" } satisfies ExecutorOpenApiSourceAuthInput; | ||
| 202 | } | ||
| 203 | |||
| 204 | const tokenSecretMaterialId = | ||
| 205 | response.content && typeof response.content.tokenSecretMaterialId === "string" | ||
| 206 | ? response.content.tokenSecretMaterialId.trim() | ||
| 185 | : ""; | 207 | : ""; |
| 186 | 208 | ||
| 187 | if (token.length === 0) { | 209 | if (authKind !== "bearer" || tokenSecretMaterialId.length === 0) { |
| 188 | return yield* Effect.fail( | 210 | return yield* Effect.fail( |
| 189 | new Error("API token was not provided for executor.sources.add"), | 211 | new Error("Credential capture did not return a valid source auth choice for executor.sources.add"), |
| 190 | ); | 212 | ); |
| 191 | } | 213 | } |
| 192 | 214 | ||
| 193 | return token; | 215 | return { |
| 216 | kind: "bearer", | ||
| 217 | tokenSecretMaterialId, | ||
| 218 | } satisfies ExecutorOpenApiSourceAuthInput; | ||
| 194 | }); | 219 | }); |
| 195 | 220 | ||
| 196 | export const createExecutorToolMap = (input: { | 221 | export const createExecutorToolMap = (input: { |
| @@ -205,30 +230,18 @@ export const createExecutorToolMap = (input: { · 8 unmodified lines | |||
| 205 | execute: async ( | 230 | execute: async ( |
| 206 | args: | 231 | args: |
| 207 | | { | 232 | | { |
| 208 | kind?: "mcp"; | 233 | kind?: "mcp"; |
| 209 | endpoint: string; | 234 | endpoint: string; |
| 210 | name?: string | null; | 235 | name?: string | null; |
| 211 | namespace?: string | null; | 236 | namespace?: string | null; |
| 212 | } | 237 | } |
| 213 | | { | 238 | | { |
| 214 | kind: "openapi"; | 239 | kind: "openapi"; |
| 215 | endpoint: string; | 240 | endpoint: string; |
| 216 | specUrl: string; | 241 | specUrl: string; |
| 217 | name?: string | null; | 242 | name?: string | null; |
| 218 | namespace?: string | null; | 243 | namespace?: string | null; |
| 219 | auth?: | 244 | }, |
| 220 | | { | ||
| 221 | kind: "none"; | ||
| 222 | } | ||
| 223 | | { | ||
| 224 | kind: "bearer"; | ||
| 225 | headerName?: string | null; | ||
| 226 | prefix?: string | null; | ||
| 227 | token?: string | null; | ||
| 228 | tokenEnvVar?: string | null; | ||
| 229 | } | ||
| 230 | | null; | ||
| 231 | }, | ||
| 232 | context, | 245 | context, |
| 233 | ): Promise<Source> => { | 246 | ): Promise<Source> => { |
| 234 | const executionId = toExecutionId(context?.invocation?.runId); | 247 | const executionId = toExecutionId(context?.invocation?.runId); |
| @@ -238,50 +251,74 @@ export const createExecutorToolMap = (input: { · 3 unmodified lines | |||
| 238 | const preparedArgs: ExecutorAddSourceInput = | 251 | const preparedArgs: ExecutorAddSourceInput = |
| 239 | args.kind === "openapi" | 252 | args.kind === "openapi" |
| 240 | ? { | 253 | ? { |
| 241 | ...args, | 254 | ...args, |
| 242 | workspaceId: input.workspaceId, | 255 | workspaceId: input.workspaceId, |
| 243 | executionId, | 256 | executionId, |
| 244 | interactionId, | 257 | interactionId, |
| 245 | auth: | 258 | } |
| 246 | args.auth?.kind === "bearer" | ||
| 247 | && !trimOrNull(args.auth.token) | ||
| 248 | && !trimOrNull(args.auth.tokenEnvVar) | ||
| 249 | ? { | ||
| 250 | ...args.auth, | ||
| 251 | token: await Effect.runPromise( | ||
| 252 | promptForBearerToken({ | ||
| 253 | args: { | ||
| 254 | ...args, | ||
| 255 | auth: args.auth, | ||
| 256 | workspaceId: input.workspaceId, | ||
| 257 | executionId, | ||
| 258 | interactionId, | ||
| 259 | }, | ||
| 260 | interactionId, | ||
| 261 | path: context.path ?? asToolPath("executor.sources.add"), | ||
| 262 | sourceKey: context.sourceKey, | ||
| 263 | metadata: context.metadata, | ||
| 264 | invocation: context.invocation, | ||
| 265 | onElicitation: context.onElicitation, | ||
| 266 | }), | ||
| 267 | ), | ||
| 268 | } | ||
| 269 | : args.auth ?? null, | ||
| 270 | } | ||
| 271 | : { | 259 | : { |
| 272 | kind: args.kind, | 260 | kind: args.kind, |
| 273 | endpoint: args.endpoint, | 261 | endpoint: args.endpoint, |
| 274 | name: args.name ?? null, | 262 | name: args.name ?? null, |
| 275 | namespace: args.namespace ?? null, | 263 | namespace: args.namespace ?? null, |
| 276 | workspaceId: input.workspaceId, | 264 | workspaceId: input.workspaceId, |
| 277 | executionId, | 265 | executionId, |
| 278 | interactionId, | 266 | interactionId, |
| 279 | }; | 267 | }; |
| 280 | const result = await Effect.runPromise( | 268 | const result = await Effect.runPromise( |
| 281 | input.sourceAuthService.addExecutorSource( | 269 | input.sourceAuthService.addExecutorSource( |
| 282 | preparedArgs, | 270 | preparedArgs, |
| 283 | context?.onElicitation | 271 | context?.onElicitation |
| 284 | ? { | 272 | ? { |
| 273 | mcpDiscoveryElicitation: { | ||
| 274 | onElicitation: context.onElicitation, | ||
| 275 | path: context.path ?? asToolPath("executor.sources.add"), | ||
| 276 | sourceKey: context.sourceKey, | ||
| 277 | args, | ||
| 278 | metadata: context.metadata, | ||
| 279 | invocation: context.invocation, | ||
| 280 | }, | ||
| 281 | } | ||
| 282 | : undefined, | ||
| 283 | ), | ||
| 284 | ); | ||
| 285 | |||
| 286 | if (result.kind === "connected") { | ||
| 287 | return result.source; | ||
| 288 | } | ||
| 289 | |||
| 290 | if (result.kind === "credential_required") { | ||
| 291 | const preparedOpenApiArgs = preparedArgs as Extract< | ||
| 292 | ExecutorAddSourceInput, | ||
| 293 | { kind: "openapi" } | ||
| 294 | >; | ||
| 295 | const selectedAuth = await Effect.runPromise( | ||
| 296 | promptForSourceCredentialSelection({ | ||
| 297 | args: { | ||
| 298 | ...preparedOpenApiArgs, | ||
| 299 | workspaceId: input.workspaceId, | ||
| 300 | sourceId: result.source.id, | ||
| 301 | }, | ||
| 302 | source: result.source, | ||
| 303 | executionId, | ||
| 304 | interactionId, | ||
| 305 | path: context?.path ?? asToolPath("executor.sources.add"), | ||
| 306 | sourceKey: context?.sourceKey ?? "executor", | ||
| 307 | localServerBaseUrl: input.sourceAuthService.getLocalServerBaseUrl(), | ||
| 308 | metadata: context?.metadata, | ||
| 309 | invocation: context?.invocation, | ||
| 310 | onElicitation: context?.onElicitation, | ||
| 311 | }), | ||
| 312 | ); | ||
| 313 | |||
| 314 | const completed = await Effect.runPromise( | ||
| 315 | input.sourceAuthService.addExecutorSource( | ||
| 316 | { | ||
| 317 | ...preparedOpenApiArgs, | ||
| 318 | auth: selectedAuth, | ||
| 319 | }, | ||
| 320 | context?.onElicitation | ||
| 321 | ? { | ||
| 285 | mcpDiscoveryElicitation: { | 322 | mcpDiscoveryElicitation: { |
| 286 | onElicitation: context.onElicitation, | 323 | onElicitation: context.onElicitation, |
| 287 | path: context.path ?? asToolPath("executor.sources.add"), | 324 | path: context.path ?? asToolPath("executor.sources.add"), |
| @@ -291,12 +328,15 @@ export const createExecutorToolMap = (input: { · 3 unmodified lines | |||
| 291 | invocation: context.invocation, | 328 | invocation: context.invocation, |
| 292 | }, | 329 | }, |
| 293 | } | 330 | } |
| 294 | : undefined, | 331 | : undefined, |
| 295 | ), | 332 | ), |
| 296 | ); | 333 | ); |
| 297 | 334 | ||
| 298 | if (result.kind === "connected") { | 335 | if (completed.kind === "connected") { |
| 299 | return result.source; | 336 | return completed.source; |
| 337 | } | ||
| 338 | |||
| 339 | throw new Error(`Source add was not completed for ${result.source.id}`); | ||
| 300 | } | 340 | } |
| 301 | 341 | ||
| 302 | if (!context?.onElicitation) { | 342 | if (!context?.onElicitation) { |
| @@ -332,13 +372,13 @@ export const createExecutorToolMap = (input: { · 29 unmodified lines | |||
| 332 | ); | 372 | ); |
| 333 | }, | 373 | }, |
| 334 | }, | 374 | }, |
| 335 | metadata: { | 375 | metadata: { |
| 336 | inputHint: EXECUTOR_SOURCES_ADD_INPUT_HINT, | 376 | inputHint: EXECUTOR_SOURCES_ADD_INPUT_HINT, |
| 337 | outputHint: EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE, | 377 | outputHint: EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE, |
| 338 | inputSchemaJson: EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON, | 378 | inputSchemaJson: EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON, |
| 339 | outputSchemaJson: EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON, | 379 | outputSchemaJson: EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON, |
| 340 | sourceKey: "executor", | 380 | sourceKey: "executor", |
| 341 | interaction: "auto", | 381 | interaction: "auto", |
| 342 | }, | 382 | }, |
| 343 | }), | 383 | }), |
| 344 | }); | 384 | }); |
| @@ -103,10 +103,19 @@ const createRuntimeLayerFromContext = ( · 102 unmodified lines | |||
| 103 | | RuntimeSourceAuthServiceTag | 103 | | RuntimeSourceAuthServiceTag |
| 104 | | RuntimeExecutionResolverService | 104 | | RuntimeExecutionResolverService |
| 105 | >, | 105 | >, |
| 106 | ): RuntimeControlPlaneLayer => | 106 | ): RuntimeControlPlaneLayer => { |
| 107 | Layer.succeedContext( | 107 | const runtimeContext = context.pipe( |
| 108 | context.pipe(Context.pick(...runtimeContextTags)), | 108 | Context.pick(...runtimeContextTags), |
| 109 | ); | 109 | ) as Context.Context< |
| 110 | ControlPlaneActorResolver | ||
| 111 | | ControlPlaneStore | ||
| 112 | | LiveExecutionManagerService | ||
| 113 | | RuntimeSourceAuthServiceTag | ||
| 114 | | RuntimeExecutionResolverService | ||
| 115 | >; | ||
| 116 | |||
| 117 | return Layer.succeedContext(runtimeContext); | ||
| 118 | }; | ||
| 110 | 119 | ||
| 111 | export const createRuntimeControlPlaneLayer = ( | 120 | export const createRuntimeControlPlaneLayer = ( |
| 112 | options: RuntimeControlPlaneOptions = {}, | 121 | options: RuntimeControlPlaneOptions = {}, |
| @@ -1,7 +1,19 @@ | |||
| 1 | import { ControlPlanePersistenceError } from "#persistence"; | 1 | import { ControlPlanePersistenceError } from "#persistence"; |
| 2 | import type { | ||
| 3 | Execution, | ||
| 4 | ExecutionInteraction, | ||
| 5 | Source, | ||
| 6 | WorkspaceId, | ||
| 7 | } from "#schema"; | ||
| 8 | import { | ||
| 9 | SourceIdSchema, | ||
| 10 | WorkspaceIdSchema, | ||
| 11 | } from "#schema"; | ||
| 2 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Option from "effect/Option"; | ||
| 3 | 14 | ||
| 4 | import { loadLocalInstallation } from "./local-installation"; | 15 | import { loadLocalInstallation } from "./local-installation"; |
| 16 | import { LiveExecutionManagerService } from "./live-execution"; | ||
| 5 | import { | 17 | import { |
| 6 | operationErrors, | 18 | operationErrors, |
| 7 | } from "./operation-errors"; | 19 | } from "./operation-errors"; |
| @@ -10,9 +22,145 @@ import { ControlPlaneStore } from "./store"; · 2 unmodified lines | |||
| 10 | 22 | ||
| 11 | const localOps = { | 23 | const localOps = { |
| 12 | installation: operationErrors("local.installation.get"), | 24 | installation: operationErrors("local.installation.get"), |
| 13 | oauthCallback: operationErrors("local.oauth.callback"), | 25 | sourceCredentialComplete: operationErrors("sources.credentials.complete"), |
| 26 | sourceCredentialPage: operationErrors("sources.credentials.page"), | ||
| 27 | sourceCredentialSubmit: operationErrors("sources.credentials.submit"), | ||
| 14 | } as const; | 28 | } as const; |
| 15 | 29 | ||
| 30 | type SourceCredentialInteraction = { | ||
| 31 | interactionId: ExecutionInteraction["id"]; | ||
| 32 | executionId: Execution["id"]; | ||
| 33 | status: ExecutionInteraction["status"]; | ||
| 34 | message: string; | ||
| 35 | workspaceId: WorkspaceId; | ||
| 36 | sourceId: Source["id"]; | ||
| 37 | sourceLabel: string; | ||
| 38 | endpoint: string; | ||
| 39 | }; | ||
| 40 | |||
| 41 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 42 | typeof value === "object" && value !== null; | ||
| 43 | |||
| 44 | const asString = (value: unknown): string | null => | ||
| 45 | typeof value === "string" ? value : null; | ||
| 46 | |||
| 47 | const trimOrNull = (value: string | null | undefined): string | null => { | ||
| 48 | if (value === null || value === undefined) { | ||
| 49 | return null; | ||
| 50 | } | ||
| 51 | |||
| 52 | const trimmed = value.trim(); | ||
| 53 | return trimmed.length > 0 ? trimmed : null; | ||
| 54 | }; | ||
| 55 | |||
| 56 | const decodeSourceCredentialInteraction = ( | ||
| 57 | interaction: ExecutionInteraction, | ||
| 58 | ): Omit<SourceCredentialInteraction, "sourceLabel" | "endpoint"> | null => { | ||
| 59 | try { | ||
| 60 | const payload = JSON.parse(interaction.payloadJson) as unknown; | ||
| 61 | if (!isRecord(payload)) { | ||
| 62 | return null; | ||
| 63 | } | ||
| 64 | |||
| 65 | const args = payload.args; | ||
| 66 | const elicitation = payload.elicitation; | ||
| 67 | if (!isRecord(args) || !isRecord(elicitation)) { | ||
| 68 | return null; | ||
| 69 | } | ||
| 70 | |||
| 71 | if ( | ||
| 72 | payload.path !== "executor.sources.add" | ||
| 73 | || args.kind !== "openapi" | ||
| 74 | || elicitation.mode !== "url" | ||
| 75 | ) { | ||
| 76 | return null; | ||
| 77 | } | ||
| 78 | |||
| 79 | const workspaceId = trimOrNull(asString(args.workspaceId)); | ||
| 80 | const sourceId = trimOrNull(asString(args.sourceId)); | ||
| 81 | const message = trimOrNull(asString(elicitation.message)); | ||
| 82 | if (workspaceId === null || sourceId === null || message === null) { | ||
| 83 | return null; | ||
| 84 | } | ||
| 85 | |||
| 86 | return { | ||
| 87 | interactionId: interaction.id, | ||
| 88 | executionId: interaction.executionId, | ||
| 89 | status: interaction.status, | ||
| 90 | message, | ||
| 91 | workspaceId: WorkspaceIdSchema.make(workspaceId), | ||
| 92 | sourceId: SourceIdSchema.make(sourceId), | ||
| 93 | }; | ||
| 94 | } catch { | ||
| 95 | return null; | ||
| 96 | } | ||
| 97 | }; | ||
| 98 | |||
| 99 | const loadSourceCredentialInteraction = (input: { | ||
| 100 | workspaceId: WorkspaceId; | ||
| 101 | sourceId: Source["id"]; | ||
| 102 | interactionId: ExecutionInteraction["id"]; | ||
| 103 | operation: | ||
| 104 | | typeof localOps.sourceCredentialPage | ||
| 105 | | typeof localOps.sourceCredentialSubmit; | ||
| 106 | }) => | ||
| 107 | Effect.gen(function* () { | ||
| 108 | const store = yield* ControlPlaneStore; | ||
| 109 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | ||
| 110 | |||
| 111 | const stored = yield* store.executionInteractions.getById(input.interactionId).pipe( | ||
| 112 | Effect.mapError((error) => | ||
| 113 | error instanceof ControlPlanePersistenceError | ||
| 114 | ? input.operation.storage(error) | ||
| 115 | : input.operation.unknownStorage( | ||
| 116 | error, | ||
| 117 | `Failed loading execution interaction ${input.interactionId}`, | ||
| 118 | ), | ||
| 119 | ), | ||
| 120 | ); | ||
| 121 | |||
| 122 | if (Option.isNone(stored)) { | ||
| 123 | return yield* Effect.fail( | ||
| 124 | input.operation.notFound( | ||
| 125 | "Source credential request not found", | ||
| 126 | `interactionId=${input.interactionId}`, | ||
| 127 | ), | ||
| 128 | ); | ||
| 129 | } | ||
| 130 | |||
| 131 | const decoded = decodeSourceCredentialInteraction(stored.value); | ||
| 132 | if ( | ||
| 133 | decoded === null | ||
| 134 | || decoded.workspaceId !== input.workspaceId | ||
| 135 | || decoded.sourceId !== input.sourceId | ||
| 136 | ) { | ||
| 137 | return yield* Effect.fail( | ||
| 138 | input.operation.notFound( | ||
| 139 | "Source credential request not found", | ||
| 140 | `workspaceId=${input.workspaceId} sourceId=${input.sourceId} interactionId=${input.interactionId}`, | ||
| 141 | ), | ||
| 142 | ); | ||
| 143 | } | ||
| 144 | |||
| 145 | const source = yield* sourceAuthService.getSourceById({ | ||
| 146 | workspaceId: input.workspaceId, | ||
| 147 | sourceId: input.sourceId, | ||
| 148 | }).pipe( | ||
| 149 | Effect.mapError((error) => | ||
| 150 | input.operation.unknownStorage( | ||
| 151 | error, | ||
| 152 | `Failed loading source ${input.sourceId}`, | ||
| 153 | ), | ||
| 154 | ), | ||
| 155 | ); | ||
| 156 | |||
| 157 | return { | ||
| 158 | ...decoded, | ||
| 159 | sourceLabel: source.name, | ||
| 160 | endpoint: source.endpoint, | ||
| 161 | } satisfies SourceCredentialInteraction; | ||
| 162 | }); | ||
| 163 | |||
| 16 | export const getLocalInstallation = () => | 164 | export const getLocalInstallation = () => |
| 17 | Effect.gen(function* () { | 165 | Effect.gen(function* () { |
| 18 | const store = yield* ControlPlaneStore; | 166 | const store = yield* ControlPlaneStore; |
| @@ -40,7 +188,147 @@ export const getLocalInstallation = () => · 21 unmodified lines | |||
| 40 | return installation; | 188 | return installation; |
| 41 | }); | 189 | }); |
| 42 | 190 | ||
| 43 | export const completeSourceAuthCallback = (input: { | 191 | export const getSourceCredentialInteraction = (input: { |
| 192 | workspaceId: WorkspaceId; | ||
| 193 | sourceId: Source["id"]; | ||
| 194 | interactionId: ExecutionInteraction["id"]; | ||
| 195 | }) => | ||
| 196 | loadSourceCredentialInteraction({ | ||
| 197 | ...input, | ||
| 198 | operation: localOps.sourceCredentialPage, | ||
| 199 | }); | ||
| 200 | |||
| 201 | export const submitSourceCredentialInteraction = (input: { | ||
| 202 | workspaceId: WorkspaceId; | ||
| 203 | sourceId: Source["id"]; | ||
| 204 | interactionId: ExecutionInteraction["id"]; | ||
| 205 | action: "submit" | "continue" | "cancel"; | ||
| 206 | token?: string | null; | ||
| 207 | }) => | ||
| 208 | Effect.gen(function* () { | ||
| 209 | const interaction = yield* loadSourceCredentialInteraction({ | ||
| 210 | workspaceId: input.workspaceId, | ||
| 211 | sourceId: input.sourceId, | ||
| 212 | interactionId: input.interactionId, | ||
| 213 | operation: localOps.sourceCredentialSubmit, | ||
| 214 | }); | ||
| 215 | |||
| 216 | if (interaction.status !== "pending") { | ||
| 217 | return yield* Effect.fail( | ||
| 218 | localOps.sourceCredentialSubmit.badRequest( | ||
| 219 | "Source credential request is no longer active", | ||
| 220 | `interactionId=${interaction.interactionId} status=${interaction.status}`, | ||
| 221 | ), | ||
| 222 | ); | ||
| 223 | } | ||
| 224 | |||
| 225 | const liveExecutionManager = yield* LiveExecutionManagerService; | ||
| 226 | |||
| 227 | if (input.action === "cancel") { | ||
| 228 | const resumed = yield* liveExecutionManager.resolveInteraction({ | ||
| 229 | executionId: interaction.executionId, | ||
| 230 | response: { | ||
| 231 | action: "cancel", | ||
| 232 | }, | ||
| 233 | }); | ||
| 234 | |||
| 235 | if (!resumed) { | ||
| 236 | return yield* Effect.fail( | ||
| 237 | localOps.sourceCredentialSubmit.badRequest( | ||
| 238 | "Source credential request is no longer resumable", | ||
| 239 | `interactionId=${interaction.interactionId}`, | ||
| 240 | ), | ||
| 241 | ); | ||
| 242 | } | ||
| 243 | |||
| 244 | return { | ||
| 245 | kind: "cancelled" as const, | ||
| 246 | sourceLabel: interaction.sourceLabel, | ||
| 247 | endpoint: interaction.endpoint, | ||
| 248 | }; | ||
| 249 | } | ||
| 250 | |||
| 251 | if (input.action === "continue") { | ||
| 252 | const resumed = yield* liveExecutionManager.resolveInteraction({ | ||
| 253 | executionId: interaction.executionId, | ||
| 254 | response: { | ||
| 255 | action: "accept", | ||
| 256 | content: { | ||
| 257 | authKind: "none", | ||
| 258 | }, | ||
| 259 | }, | ||
| 260 | }); | ||
| 261 | |||
| 262 | if (!resumed) { | ||
| 263 | return yield* Effect.fail( | ||
| 264 | localOps.sourceCredentialSubmit.badRequest( | ||
| 265 | "Source credential request is no longer resumable", | ||
| 266 | `interactionId=${interaction.interactionId}`, | ||
| 267 | ), | ||
| 268 | ); | ||
| 269 | } | ||
| 270 | |||
| 271 | return { | ||
| 272 | kind: "continued" as const, | ||
| 273 | sourceLabel: interaction.sourceLabel, | ||
| 274 | endpoint: interaction.endpoint, | ||
| 275 | }; | ||
| 276 | } | ||
| 277 | |||
| 278 | const token = trimOrNull(input.token); | ||
| 279 | if (token === null) { | ||
| 280 | return yield* Effect.fail( | ||
| 281 | localOps.sourceCredentialSubmit.badRequest( | ||
| 282 | "Missing token", | ||
| 283 | `interactionId=${interaction.interactionId}`, | ||
| 284 | ), | ||
| 285 | ); | ||
| 286 | } | ||
| 287 | |||
| 288 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | ||
| 289 | const tokenSecretMaterialId = yield* sourceAuthService.storeSecretMaterial({ | ||
| 290 | purpose: "auth_material", | ||
| 291 | value: token, | ||
| 292 | }).pipe( | ||
| 293 | Effect.mapError((error) => | ||
| 294 | error instanceof ControlPlanePersistenceError | ||
| 295 | ? localOps.sourceCredentialSubmit.storage(error) | ||
| 296 | : localOps.sourceCredentialSubmit.unknownStorage( | ||
| 297 | error, | ||
| 298 | `Failed storing credential material for interaction ${interaction.interactionId}`, | ||
| 299 | ), | ||
| 300 | ), | ||
| 301 | ); | ||
| 302 | const resumed = yield* liveExecutionManager.resolveInteraction({ | ||
| 303 | executionId: interaction.executionId, | ||
| 304 | response: { | ||
| 305 | action: "accept", | ||
| 306 | content: { | ||
| 307 | authKind: "bearer", | ||
| 308 | tokenSecretMaterialId, | ||
| 309 | }, | ||
| 310 | }, | ||
| 311 | }); | ||
| 312 | |||
| 313 | if (!resumed) { | ||
| 314 | return yield* Effect.fail( | ||
| 315 | localOps.sourceCredentialSubmit.badRequest( | ||
| 316 | "Source credential request is no longer resumable", | ||
| 317 | `interactionId=${interaction.interactionId}`, | ||
| 318 | ), | ||
| 319 | ); | ||
| 320 | } | ||
| 321 | |||
| 322 | return { | ||
| 323 | kind: "stored" as const, | ||
| 324 | sourceLabel: interaction.sourceLabel, | ||
| 325 | endpoint: interaction.endpoint, | ||
| 326 | }; | ||
| 327 | }); | ||
| 328 | |||
| 329 | export const completeSourceCredentialSetup = (input: { | ||
| 330 | workspaceId: WorkspaceId; | ||
| 331 | sourceId: Source["id"]; | ||
| 44 | state: string; | 332 | state: string; |
| 45 | code?: string | null; | 333 | code?: string | null; |
| 46 | error?: string | null; | 334 | error?: string | null; |
| @@ -49,11 +337,11 @@ export const completeSourceAuthCallback = (input: { · 2 unmodified lines | |||
| 49 | Effect.gen(function* () { | 337 | Effect.gen(function* () { |
| 50 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | 338 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; |
| 51 | 339 | ||
| 52 | return yield* sourceAuthService.completeSourceAuthCallback(input).pipe( | 340 | return yield* sourceAuthService.completeSourceCredentialSetup(input).pipe( |
| 53 | Effect.mapError((error) => | 341 | Effect.mapError((error) => |
| 54 | localOps.oauthCallback.unknownStorage( | 342 | localOps.sourceCredentialComplete.unknownStorage( |
| 55 | error, | 343 | error, |
| 56 | "Failed completing source auth callback", | 344 | "Failed completing source credential setup", |
| 57 | ), | 345 | ), |
| 58 | ), | 346 | ), |
| 59 | ); | 347 | ); |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | |||
| 18 | } from "#persistence"; | 18 | } from "#persistence"; |
| 19 | import { | 19 | import { |
| 20 | ExecutionIdSchema, | 20 | ExecutionIdSchema, |
| 21 | type SecretMaterialId, | ||
| 21 | SecretMaterialIdSchema, | 22 | SecretMaterialIdSchema, |
| 22 | Source, | 23 | Source, |
| 23 | SourceAuthSession, | 24 | SourceAuthSession, |
| @@ -52,7 +53,6 @@ import { · 28 unmodified lines | |||
| 52 | } from "./tool-artifacts"; | 53 | } from "./tool-artifacts"; |
| 53 | 54 | ||
| 54 | export const CONTROL_PLANE_SECRET_PROVIDER_ID = "control-plane"; | 55 | export const CONTROL_PLANE_SECRET_PROVIDER_ID = "control-plane"; |
| 55 | const OAUTH_CALLBACK_PATH = "/v1/local/oauth/callback"; | ||
| 56 | 56 | ||
| 57 | const trimOrNull = (value: string | null | undefined): string | null => { | 57 | const trimOrNull = (value: string | null | undefined): string | null => { |
| 58 | if (value === null || value === undefined) { | 58 | if (value === null || value === undefined) { |
| @@ -101,8 +101,15 @@ const decodeJson = <A>(input: { · 42 unmodified lines | |||
| 101 | } | 101 | } |
| 102 | }; | 102 | }; |
| 103 | 103 | ||
| 104 | const resolveRedirectUrl = (baseUrl: string): string => | 104 | const resolveSourceCredentialOauthCompleteUrl = (input: { |
| 105 | new URL(OAUTH_CALLBACK_PATH, baseUrl).toString(); | 105 | baseUrl: string; |
| 106 | workspaceId: WorkspaceId; | ||
| 107 | sourceId: Source["id"]; | ||
| 108 | }): string => | ||
| 109 | new URL( | ||
| 110 | `/v1/workspaces/${encodeURIComponent(input.workspaceId)}/sources/${encodeURIComponent(input.sourceId)}/credentials/oauth/complete`, | ||
| 111 | input.baseUrl, | ||
| 112 | ).toString(); | ||
| 106 | 113 | ||
| 107 | const normalizeEndpoint = (endpoint: string): string => { | 114 | const normalizeEndpoint = (endpoint: string): string => { |
| 108 | const url = new URL(endpoint.trim()); | 115 | const url = new URL(endpoint.trim()); |
| @@ -347,7 +354,7 @@ const exchangeOAuthAuthorizationCode = (input: { · 238 unmodified lines | |||
| 347 | captured.tokens = tokens; | 354 | captured.tokens = tokens; |
| 348 | }, | 355 | }, |
| 349 | redirectToAuthorization: () => { | 356 | redirectToAuthorization: () => { |
| 350 | throw new Error("Unexpected redirect during OAuth callback completion"); | 357 | throw new Error("Unexpected redirect while completing source credential setup"); |
| 351 | }, | 358 | }, |
| 352 | saveCodeVerifier: () => undefined, | 359 | saveCodeVerifier: () => undefined, |
| 353 | codeVerifier: () => { | 360 | codeVerifier: () => { |
| @@ -373,7 +380,7 @@ const exchangeOAuthAuthorizationCode = (input: { · 19 unmodified lines | |||
| 373 | }); | 380 | }); |
| 374 | 381 | ||
| 375 | if (result !== "AUTHORIZED" || !captured.tokens) { | 382 | if (result !== "AUTHORIZED" || !captured.tokens) { |
| 376 | return yield* Effect.fail(new Error("OAuth callback did not complete authorization")); | 383 | return yield* Effect.fail(new Error("OAuth redirect did not complete source credential setup")); |
| 377 | } | 384 | } |
| 378 | 385 | ||
| 379 | return { | 386 | return { |
| @@ -455,6 +462,10 @@ export type ExecutorSourceAddResult = · 75 unmodified lines | |||
| 455 | kind: "connected"; | 462 | kind: "connected"; |
| 456 | source: Source; | 463 | source: Source; |
| 457 | } | 464 | } |
| 465 | | { | ||
| 466 | kind: "credential_required"; | ||
| 467 | source: Source; | ||
| 468 | } | ||
| 458 | | { | 469 | | { |
| 459 | kind: "oauth_required"; | 470 | kind: "oauth_required"; |
| 460 | source: Source; | 471 | source: Source; |
| @@ -472,6 +483,7 @@ export type ExecutorOpenApiSourceAuthInput = · 11 unmodified lines | |||
| 472 | prefix?: string | null; | 483 | prefix?: string | null; |
| 473 | token?: string | null; | 484 | token?: string | null; |
| 474 | tokenEnvVar?: string | null; | 485 | tokenEnvVar?: string | null; |
| 486 | tokenSecretMaterialId?: string | null; | ||
| 475 | }; | 487 | }; |
| 476 | 488 | ||
| 477 | export type ExecutorAddSourceInput = | 489 | export type ExecutorAddSourceInput = |
| @@ -496,6 +508,17 @@ export type ExecutorAddSourceInput = · 18 unmodified lines | |||
| 496 | auth?: ExecutorOpenApiSourceAuthInput | null; | 508 | auth?: ExecutorOpenApiSourceAuthInput | null; |
| 497 | }; | 509 | }; |
| 498 | 510 | ||
| 511 | const shouldPromptForOpenApiCredentialSetup = (input: { | ||
| 512 | existing?: Source; | ||
| 513 | auth?: ExecutorOpenApiSourceAuthInput | null; | ||
| 514 | }): boolean => { | ||
| 515 | if (input.auth !== undefined) { | ||
| 516 | return false; | ||
| 517 | } | ||
| 518 | |||
| 519 | return !(input.existing?.kind === "openapi" && input.existing.auth.kind === "bearer"); | ||
| 520 | }; | ||
| 521 | |||
| 499 | const materializeExecutorOpenApiAuth = (input: { | 522 | const materializeExecutorOpenApiAuth = (input: { |
| 500 | rows: SqlControlPlaneRows; | 523 | rows: SqlControlPlaneRows; |
| 501 | existing?: Source; | 524 | existing?: Source; |
| @@ -515,10 +538,21 @@ const materializeExecutorOpenApiAuth = (input: { · 13 unmodified lines | |||
| 515 | const prefix = auth.prefix ?? "Bearer "; | 538 | const prefix = auth.prefix ?? "Bearer "; |
| 516 | const token = trimOrNull(auth.token); | 539 | const token = trimOrNull(auth.token); |
| 517 | const tokenEnvVar = trimOrNull(auth.tokenEnvVar); | 540 | const tokenEnvVar = trimOrNull(auth.tokenEnvVar); |
| 541 | const tokenSecretMaterialId = trimOrNull(auth.tokenSecretMaterialId); | ||
| 542 | |||
| 543 | if ( | ||
| 544 | token === null | ||
| 545 | && tokenEnvVar === null | ||
| 546 | && tokenSecretMaterialId === null | ||
| 547 | && input.existing?.kind === "openapi" | ||
| 548 | && input.existing.auth.kind === "bearer" | ||
| 549 | ) { | ||
| 550 | return input.existing.auth; | ||
| 551 | } | ||
| 518 | 552 | ||
| 519 | if (token === null && tokenEnvVar === null) { | 553 | if (token === null && tokenEnvVar === null && tokenSecretMaterialId === null) { |
| 520 | return yield* Effect.fail( | 554 | return yield* Effect.fail( |
| 521 | new Error("Bearer auth requires either token or tokenEnvVar"), | 555 | new Error("Bearer auth requires token, tokenEnvVar, or tokenSecretMaterialId"), |
| 522 | ); | 556 | ); |
| 523 | } | 557 | } |
| 524 | 558 | ||
| @@ -530,6 +564,11 @@ const materializeExecutorOpenApiAuth = (input: { · 5 unmodified lines | |||
| 530 | value: token, | 564 | value: token, |
| 531 | }), | 565 | }), |
| 532 | } | 566 | } |
| 567 | : tokenSecretMaterialId !== null | ||
| 568 | ? { | ||
| 569 | providerId: CONTROL_PLANE_SECRET_PROVIDER_ID, | ||
| 570 | handle: tokenSecretMaterialId, | ||
| 571 | } | ||
| 533 | : { | 572 | : { |
| 534 | providerId: "env", | 573 | providerId: "env", |
| 535 | handle: tokenEnvVar!, | 574 | handle: tokenEnvVar!, |
| @@ -548,13 +587,20 @@ type RuntimeSourceAuthServiceShape = { · 12 unmodified lines | |||
| 548 | workspaceId: WorkspaceId; | 587 | workspaceId: WorkspaceId; |
| 549 | sourceId: Source["id"]; | 588 | sourceId: Source["id"]; |
| 550 | }) => Effect.Effect<Source, Error, never>; | 589 | }) => Effect.Effect<Source, Error, never>; |
| 590 | getLocalServerBaseUrl: () => string | null; | ||
| 591 | storeSecretMaterial: (input: { | ||
| 592 | purpose: "auth_material" | "oauth_access_token" | "oauth_refresh_token"; | ||
| 593 | value: string; | ||
| 594 | }) => Effect.Effect<SecretMaterialId, Error, never>; | ||
| 551 | addExecutorSource: ( | 595 | addExecutorSource: ( |
| 552 | input: ExecutorAddSourceInput, | 596 | input: ExecutorAddSourceInput, |
| 553 | options?: { | 597 | options?: { |
| 554 | mcpDiscoveryElicitation?: McpDiscoveryElicitationContext; | 598 | mcpDiscoveryElicitation?: McpDiscoveryElicitationContext; |
| 555 | }, | 599 | }, |
| 556 | ) => Effect.Effect<ExecutorSourceAddResult, Error, never>; | 600 | ) => Effect.Effect<ExecutorSourceAddResult, Error, never>; |
| 557 | completeSourceAuthCallback: (input: { | 601 | completeSourceCredentialSetup: (input: { |
| 602 | workspaceId: WorkspaceId; | ||
| 603 | sourceId: Source["id"]; | ||
| 558 | state: string; | 604 | state: string; |
| 559 | code?: string | null; | 605 | code?: string | null; |
| 560 | error?: string | null; | 606 | error?: string | null; |
| @@ -602,6 +648,14 @@ export const createRuntimeSourceAuthService = (input: { · 41 unmodified lines | |||
| 602 | }); | 648 | }); |
| 603 | 649 | ||
| 604 | return { | 650 | return { |
| 651 | getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null, | ||
| 652 | |||
| 653 | storeSecretMaterial: ({ purpose, value }) => | ||
| 654 | upsertSecretMaterial(input.rows, { | ||
| 655 | purpose, | ||
| 656 | value, | ||
| 657 | }), | ||
| 658 | |||
| 605 | getSourceById: ({ workspaceId, sourceId }) => | 659 | getSourceById: ({ workspaceId, sourceId }) => |
| 606 | loadSourceById(input.rows, { | 660 | loadSourceById(input.rows, { |
| 607 | workspaceId, | 661 | workspaceId, |
| @@ -632,12 +686,56 @@ export const createRuntimeSourceAuthService = (input: { · 24 unmodified lines | |||
| 632 | trimOrNull(sourceInput.namespace) | 686 | trimOrNull(sourceInput.namespace) |
| 633 | ?? existing?.namespace | 687 | ?? existing?.namespace |
| 634 | ?? defaultNamespaceFromName(chosenName); | 688 | ?? defaultNamespaceFromName(chosenName); |
| 689 | const now = Date.now(); | ||
| 690 | |||
| 691 | if (shouldPromptForOpenApiCredentialSetup({ | ||
| 692 | existing, | ||
| 693 | auth: sourceInput.auth, | ||
| 694 | })) { | ||
| 695 | const draftSource = existing | ||
| 696 | ? yield* updateSourceFromPayload({ | ||
| 697 | source: existing, | ||
| 698 | payload: { | ||
| 699 | name: chosenName, | ||
| 700 | endpoint: normalizedEndpoint, | ||
| 701 | namespace: chosenNamespace, | ||
| 702 | kind: "openapi", | ||
| 703 | status: "auth_required", | ||
| 704 | enabled: true, | ||
| 705 | specUrl: normalizedSpecUrl, | ||
| 706 | auth: { kind: "none" }, | ||
| 707 | lastError: null, | ||
| 708 | }, | ||
| 709 | now, | ||
| 710 | }) | ||
| 711 | : yield* createSourceFromPayload({ | ||
| 712 | workspaceId: sourceInput.workspaceId, | ||
| 713 | sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`), | ||
| 714 | payload: { | ||
| 715 | name: chosenName, | ||
| 716 | kind: "openapi", | ||
| 717 | endpoint: normalizedEndpoint, | ||
| 718 | namespace: chosenNamespace, | ||
| 719 | status: "auth_required", | ||
| 720 | enabled: true, | ||
| 721 | specUrl: normalizedSpecUrl, | ||
| 722 | auth: { kind: "none" }, | ||
| 723 | }, | ||
| 724 | now, | ||
| 725 | }); | ||
| 726 | |||
| 727 | const persistedDraft = yield* persistSource(input.rows, draftSource); | ||
| 728 | return { | ||
| 729 | kind: "credential_required", | ||
| 730 | source: persistedDraft, | ||
| 731 | } satisfies ExecutorSourceAddResult; | ||
| 732 | } | ||
| 733 | |||
| 635 | const auth = yield* materializeExecutorOpenApiAuth({ | 734 | const auth = yield* materializeExecutorOpenApiAuth({ |
| 636 | rows: input.rows, | 735 | rows: input.rows, |
| 637 | existing, | 736 | existing, |
| 638 | auth: sourceInput.auth, | 737 | auth: sourceInput.auth, |
| 639 | }); | 738 | }); |
| 640 | const now = Date.now(); | ||
| 641 | 739 | ||
| 642 | const draftSource = existing | 740 | const draftSource = existing |
| 643 | ? yield* updateSourceFromPayload({ | 741 | ? yield* updateSourceFromPayload({ |
| @@ -812,13 +910,17 @@ export const createRuntimeSourceAuthService = (input: { · 168 unmodified lines | |||
| 812 | const localServerBaseUrl = input.getLocalServerBaseUrl?.(); | 910 | const localServerBaseUrl = input.getLocalServerBaseUrl?.(); |
| 813 | if (!localServerBaseUrl) { | 911 | if (!localServerBaseUrl) { |
| 814 | return yield* Effect.fail( | 912 | return yield* Effect.fail( |
| 815 | new Error("Local executor server base URL is unavailable for OAuth callback handling"), | 913 | new Error("Local executor server base URL is unavailable for source credential setup"), |
| 816 | ); | 914 | ); |
| 817 | } | 915 | } |
| 818 | 916 | ||
| 819 | const sessionId = SourceAuthSessionIdSchema.make(`src_auth_${crypto.randomUUID()}`); | 917 | const sessionId = SourceAuthSessionIdSchema.make(`src_auth_${crypto.randomUUID()}`); |
| 820 | const state = crypto.randomUUID(); | 918 | const state = crypto.randomUUID(); |
| 821 | const redirectUrl = resolveRedirectUrl(localServerBaseUrl); | 919 | const redirectUrl = resolveSourceCredentialOauthCompleteUrl({ |
| 920 | baseUrl: localServerBaseUrl, | ||
| 921 | workspaceId: sourceInput.workspaceId, | ||
| 922 | sourceId: persistedDraft.id, | ||
| 923 | }); | ||
| 822 | const oauthStart = yield* startOAuthAuthorization({ | 924 | const oauthStart = yield* startOAuthAuthorization({ |
| 823 | endpoint: normalizedEndpoint, | 925 | endpoint: normalizedEndpoint, |
| 824 | redirectUrl, | 926 | redirectUrl, |
| @@ -864,7 +966,14 @@ export const createRuntimeSourceAuthService = (input: { · 39 unmodified lines | |||
| 864 | } satisfies ExecutorSourceAddResult; | 966 | } satisfies ExecutorSourceAddResult; |
| 865 | }), | 967 | }), |
| 866 | 968 | ||
| 867 | completeSourceAuthCallback: ({ state, code, error, errorDescription }) => | 969 | completeSourceCredentialSetup: ({ |
| 970 | workspaceId, | ||
| 971 | sourceId, | ||
| 972 | state, | ||
| 973 | code, | ||
| 974 | error, | ||
| 975 | errorDescription, | ||
| 976 | }) => | ||
| 868 | Effect.gen(function* () { | 977 | Effect.gen(function* () { |
| 869 | const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state); | 978 | const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state); |
| 870 | if (Option.isNone(sessionOption)) { | 979 | if (Option.isNone(sessionOption)) { |
| @@ -872,6 +981,14 @@ export const createRuntimeSourceAuthService = (input: { · 1 unmodified line | |||
| 872 | } | 981 | } |
| 873 | 982 | ||
| 874 | const session = sessionOption.value; | 983 | const session = sessionOption.value; |
| 984 | if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) { | ||
| 985 | return yield* Effect.fail( | ||
| 986 | new Error( | ||
| 987 | `Source auth session ${session.id} does not match workspaceId=${workspaceId} sourceId=${sourceId}`, | ||
| 988 | ), | ||
| 989 | ); | ||
| 990 | } | ||
| 991 | |||
| 875 | const source = yield* loadSourceById(input.rows, { | 992 | const source = yield* loadSourceById(input.rows, { |
| 876 | workspaceId: session.workspaceId, | 993 | workspaceId: session.workspaceId, |
| 877 | sourceId: session.sourceId, | 994 | sourceId: session.sourceId, |
| @@ -1015,9 +1132,7 @@ export const createRuntimeSourceAuthService = (input: { · 137 unmodified lines | |||
| 1015 | } satisfies RuntimeSourceAuthServiceShape; | 1132 | } satisfies RuntimeSourceAuthServiceShape; |
| 1016 | }; | 1133 | }; |
| 1017 | 1134 | ||
| 1018 | export type RuntimeSourceAuthService = ReturnType< | 1135 | export type RuntimeSourceAuthService = RuntimeSourceAuthServiceShape; |
| 1019 | typeof createRuntimeSourceAuthService | ||
| 1020 | >; | ||
| 1021 | 1136 | ||
| 1022 | export class RuntimeSourceAuthServiceTag extends Context.Tag( | 1137 | export class RuntimeSourceAuthServiceTag extends Context.Tag( |
| 1023 | "#runtime/RuntimeSourceAuthServiceTag", | 1138 | "#runtime/RuntimeSourceAuthServiceTag", |
| @@ -1045,6 +1160,10 @@ export const ExecutorAddSourceResultSchema = Schema.Union( · 21 unmodified lines | |||
| 1045 | kind: Schema.Literal("connected"), | 1160 | kind: Schema.Literal("connected"), |
| 1046 | source: SourceSchema, | 1161 | source: SourceSchema, |
| 1047 | }), | 1162 | }), |
| 1163 | Schema.Struct({ | ||
| 1164 | kind: Schema.Literal("credential_required"), | ||
| 1165 | source: SourceSchema, | ||
| 1166 | }), | ||
| 1048 | Schema.Struct({ | 1167 | Schema.Struct({ |
| 1049 | kind: Schema.Literal("oauth_required"), | 1168 | kind: Schema.Literal("oauth_required"), |
| 1050 | source: SourceSchema, | 1169 | source: SourceSchema, |
| @@ -16,6 +16,7 @@ import { describe, expect, it } from "@effect/vitest"; · 15 unmodified lines | |||
| 16 | import { | 16 | import { |
| 17 | AccountIdSchema, | 17 | AccountIdSchema, |
| 18 | ExecutionIdSchema, | 18 | ExecutionIdSchema, |
| 19 | SecretMaterialIdSchema, | ||
| 19 | SourceIdSchema, | 20 | SourceIdSchema, |
| 20 | WorkspaceIdSchema, | 21 | WorkspaceIdSchema, |
| 21 | } from "#schema"; | 22 | } from "#schema"; |
| @@ -413,13 +414,126 @@ const persistConnectedEchoTool = (input: { · 391 unmodified lines | |||
| 413 | }); | 414 | }); |
| 414 | }); | 415 | }); |
| 415 | 416 | ||
| 417 | const persistConnectedGithubSearchArtifacts = (input: { | ||
| 418 | persistence: SqlControlPlanePersistence; | ||
| 419 | workspaceId: WorkspaceId; | ||
| 420 | sourceId: SourceId; | ||
| 421 | }) => | ||
| 422 | Effect.gen(function* () { | ||
| 423 | const now = Date.now(); | ||
| 424 | |||
| 425 | yield* input.persistence.rows.sources.insert({ | ||
| 426 | id: input.sourceId, | ||
| 427 | workspaceId: input.workspaceId, | ||
| 428 | name: "GitHub", | ||
| 429 | kind: "openapi", | ||
| 430 | endpoint: "https://api.github.com", | ||
| 431 | status: "connected", | ||
| 432 | enabled: true, | ||
| 433 | namespace: "github", | ||
| 434 | transport: null, | ||
| 435 | queryParamsJson: null, | ||
| 436 | headersJson: null, | ||
| 437 | specUrl: "https://example.com/github-openapi.json", | ||
| 438 | defaultHeadersJson: null, | ||
| 439 | authKind: "none", | ||
| 440 | authHeaderName: null, | ||
| 441 | authPrefix: null, | ||
| 442 | sourceHash: null, | ||
| 443 | lastError: null, | ||
| 444 | createdAt: now, | ||
| 445 | updatedAt: now, | ||
| 446 | }); | ||
| 447 | |||
| 448 | yield* input.persistence.rows.toolArtifacts.replaceForSource({ | ||
| 449 | workspaceId: input.workspaceId, | ||
| 450 | sourceId: input.sourceId, | ||
| 451 | artifacts: [ | ||
| 452 | { | ||
| 453 | artifact: { | ||
| 454 | workspaceId: input.workspaceId, | ||
| 455 | path: "github.actions/get-environment-secret", | ||
| 456 | toolId: "actions/get-environment-secret", | ||
| 457 | sourceId: input.sourceId, | ||
| 458 | title: "Get an environment secret", | ||
| 459 | description: | ||
| 460 | "Gets a single environment secret without revealing its encrypted value. Authenticated users must have collaborator access to a repository to create, update, or read secrets.", | ||
| 461 | searchNamespace: "github.actions", | ||
| 462 | searchText: | ||
| 463 | "github.actions/get-environment-secret github.actions get an environment secret gets a single environment secret without revealing its encrypted value authenticated users must have collaborator access to a repository to create update or read secrets GET /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", | ||
| 464 | inputSchemaJson: null, | ||
| 465 | outputSchemaJson: null, | ||
| 466 | providerKind: "openapi", | ||
| 467 | mcpToolName: null, | ||
| 468 | openApiMethod: "get", | ||
| 469 | openApiPathTemplate: "/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", | ||
| 470 | openApiOperationHash: "op_actions_get_environment_secret", | ||
| 471 | openApiRequestBodyRequired: null, | ||
| 472 | createdAt: now, | ||
| 473 | updatedAt: now, | ||
| 474 | }, | ||
| 475 | }, | ||
| 476 | { | ||
| 477 | artifact: { | ||
| 478 | workspaceId: input.workspaceId, | ||
| 479 | path: "github.actions/get-self-hosted-runner-for-org", | ||
| 480 | toolId: "actions/get-self-hosted-runner-for-org", | ||
| 481 | sourceId: input.sourceId, | ||
| 482 | title: "Get a self-hosted runner for an organization", | ||
| 483 | description: | ||
| 484 | "Gets a specific self-hosted runner configured in an organization. Authenticated users must have admin access to the organization to use this endpoint.", | ||
| 485 | searchNamespace: "github.actions", | ||
| 486 | searchText: | ||
| 487 | "github.actions/get-self-hosted-runner-for-org github.actions get a self hosted runner for an organization gets a specific self hosted runner configured in an organization authenticated users must have admin access to the organization to use this endpoint GET /orgs/{org}/actions/runners/{runner_id}", | ||
| 488 | inputSchemaJson: null, | ||
| 489 | outputSchemaJson: null, | ||
| 490 | providerKind: "openapi", | ||
| 491 | mcpToolName: null, | ||
| 492 | openApiMethod: "get", | ||
| 493 | openApiPathTemplate: "/orgs/{org}/actions/runners/{runner_id}", | ||
| 494 | openApiOperationHash: "op_actions_get_self_hosted_runner_for_org", | ||
| 495 | openApiRequestBodyRequired: null, | ||
| 496 | createdAt: now, | ||
| 497 | updatedAt: now, | ||
| 498 | }, | ||
| 499 | }, | ||
| 500 | { | ||
| 501 | artifact: { | ||
| 502 | workspaceId: input.workspaceId, | ||
| 503 | path: "github.users/get-authenticated", | ||
| 504 | toolId: "users/get-authenticated", | ||
| 505 | sourceId: input.sourceId, | ||
| 506 | title: "Get the authenticated user", | ||
| 507 | description: | ||
| 508 | "Gets the authenticated user. OAuth app tokens and personal access tokens need the user scope to include private profile information.", | ||
| 509 | searchNamespace: "github.users", | ||
| 510 | searchText: | ||
| 511 | "github.users/get-authenticated github.users get the authenticated user gets the authenticated user oauth app tokens and personal access tokens need the user scope to include private profile information GET /user", | ||
| 512 | inputSchemaJson: null, | ||
| 513 | outputSchemaJson: null, | ||
| 514 | providerKind: "openapi", | ||
| 515 | mcpToolName: null, | ||
| 516 | openApiMethod: "get", | ||
| 517 | openApiPathTemplate: "/user", | ||
| 518 | openApiOperationHash: "op_users_get_authenticated", | ||
| 519 | openApiRequestBodyRequired: null, | ||
| 520 | createdAt: now, | ||
| 521 | updatedAt: now, | ||
| 522 | }, | ||
| 523 | }, | ||
| 524 | ], | ||
| 525 | }); | ||
| 526 | }); | ||
| 527 | |||
| 416 | const makeResolver = (persistence: SqlControlPlanePersistence) => | 528 | const makeResolver = (persistence: SqlControlPlanePersistence) => |
| 417 | createWorkspaceExecutionEnvironmentResolver({ | 529 | createWorkspaceExecutionEnvironmentResolver({ |
| 418 | rows: persistence.rows, | 530 | rows: persistence.rows, |
| 419 | sourceAuthService: { | 531 | sourceAuthService: { |
| 532 | getLocalServerBaseUrl: () => null, | ||
| 533 | storeSecretMaterial: () => Effect.fail(new Error("not implemented in test")), | ||
| 420 | getSourceById: () => Effect.fail(new Error("not implemented in test")), | 534 | getSourceById: () => Effect.fail(new Error("not implemented in test")), |
| 421 | addExecutorSource: () => Effect.fail(new Error("not implemented in test")), | 535 | addExecutorSource: () => Effect.fail(new Error("not implemented in test")), |
| 422 | completeSourceAuthCallback: () => Effect.fail(new Error("not implemented in test")), | 536 | completeSourceCredentialSetup: () => Effect.fail(new Error("not implemented in test")), |
| 423 | } as RuntimeSourceAuthService, | 537 | } as RuntimeSourceAuthService, |
| 424 | }); | 538 | }); |
| 425 | 539 | ||
| @@ -429,6 +543,7 @@ const makeResolverWithLiveSourceAuth = (persistence: SqlControlPlanePersistence) · 3 unmodified lines | |||
| 429 | sourceAuthService: createRuntimeSourceAuthService({ | 543 | sourceAuthService: createRuntimeSourceAuthService({ |
| 430 | rows: persistence.rows, | 544 | rows: persistence.rows, |
| 431 | liveExecutionManager: createLiveExecutionManager(), | 545 | liveExecutionManager: createLiveExecutionManager(), |
| 546 | getLocalServerBaseUrl: () => "http://127.0.0.1:8788", | ||
| 432 | }), | 547 | }), |
| 433 | }); | 548 | }); |
| 434 | 549 | ||
| @@ -477,6 +592,54 @@ describe("workspace-execution-environment", () => { · 42 unmodified lines | |||
| 477 | }), | 592 | }), |
| 478 | ); | 593 | ); |
| 479 | 594 | ||
| 595 | it.scoped("prefers namespace and path matches over noisy GitHub boilerplate", () => | ||
| 596 | Effect.gen(function* () { | ||
| 597 | const persistence = yield* makePersistence; | ||
| 598 | const workspaceId = WorkspaceIdSchema.make("ws_github_discovery"); | ||
| 599 | const sourceId = SourceIdSchema.make("src_github_discovery"); | ||
| 600 | yield* persistConnectedGithubSearchArtifacts({ | ||
| 601 | persistence, | ||
| 602 | workspaceId, | ||
| 603 | sourceId, | ||
| 604 | }); | ||
| 605 | |||
| 606 | const resolveEnvironment = makeResolver(persistence); | ||
| 607 | const environment = yield* resolveEnvironment({ | ||
| 608 | workspaceId, | ||
| 609 | accountId: AccountIdSchema.make("acc_github_discovery"), | ||
| 610 | executionId: ExecutionIdSchema.make("exec_github_discovery"), | ||
| 611 | }); | ||
| 612 | |||
| 613 | const firstQuery = (yield* environment.toolInvoker.invoke({ | ||
| 614 | path: "discover", | ||
| 615 | args: { | ||
| 616 | query: "get authenticated user", | ||
| 617 | limit: 5, | ||
| 618 | }, | ||
| 619 | })) as { | ||
| 620 | bestPath: string | null; | ||
| 621 | results: Array<{ path: string }>; | ||
| 622 | }; | ||
| 623 | |||
| 624 | expect(firstQuery.bestPath).toBe("github.users/get-authenticated"); | ||
| 625 | expect(firstQuery.results[0]?.path).toBe("github.users/get-authenticated"); | ||
| 626 | |||
| 627 | const secondQuery = (yield* environment.toolInvoker.invoke({ | ||
| 628 | path: "discover", | ||
| 629 | args: { | ||
| 630 | query: "users get myself current user profile", | ||
| 631 | limit: 5, | ||
| 632 | }, | ||
| 633 | })) as { | ||
| 634 | bestPath: string | null; | ||
| 635 | results: Array<{ path: string }>; | ||
| 636 | }; | ||
| 637 | |||
| 638 | expect(secondQuery.bestPath).toBe("github.users/get-authenticated"); | ||
| 639 | expect(secondQuery.results[0]?.path).toBe("github.users/get-authenticated"); | ||
| 640 | }), | ||
| 641 | ); | ||
| 642 | |||
| 480 | it.scoped("invokes persisted MCP tools on demand without re-listing", () => | 643 | it.scoped("invokes persisted MCP tools on demand without re-listing", () => |
| 481 | Effect.gen(function* () { | 644 | Effect.gen(function* () { |
| 482 | const server = yield* makeCountedMcpServer; | 645 | const server = yield* makeCountedMcpServer; |
| @@ -518,10 +681,22 @@ describe("workspace-execution-environment", () => { · 35 unmodified lines | |||
| 518 | const persistence = yield* makePersistence; | 681 | const persistence = yield* makePersistence; |
| 519 | const workspaceId = WorkspaceIdSchema.make("ws_add_openapi"); | 682 | const workspaceId = WorkspaceIdSchema.make("ws_add_openapi"); |
| 520 | const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | 683 | const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); |
| 684 | const now = Date.now(); | ||
| 685 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( | ||
| 686 | "sec_test_openapi_bearer", | ||
| 687 | ); | ||
| 688 | yield* persistence.rows.secretMaterials.upsert({ | ||
| 689 | id: tokenSecretMaterialId, | ||
| 690 | purpose: "auth_material", | ||
| 691 | value: "ghp_test_token", | ||
| 692 | createdAt: now, | ||
| 693 | updatedAt: now, | ||
| 694 | }); | ||
| 521 | let capturedElicitation: | 695 | let capturedElicitation: |
| 522 | | { | 696 | | { |
| 523 | mode?: string; | 697 | mode?: string; |
| 524 | requestedSchema?: Record<string, unknown>; | 698 | url?: string; |
| 699 | elicitationId?: string; | ||
| 525 | } | 700 | } |
| 526 | | null = null; | 701 | | null = null; |
| 527 | 702 | ||
| @@ -533,13 +708,15 @@ describe("workspace-execution-environment", () => { · 5 unmodified lines | |||
| 533 | Effect.sync(() => { | 708 | Effect.sync(() => { |
| 534 | capturedElicitation = request.elicitation as { | 709 | capturedElicitation = request.elicitation as { |
| 535 | mode?: string; | 710 | mode?: string; |
| 536 | requestedSchema?: Record<string, unknown>; | 711 | url?: string; |
| 712 | elicitationId?: string; | ||
| 537 | }; | 713 | }; |
| 538 | 714 | ||
| 539 | return { | 715 | return { |
| 540 | action: "accept" as const, | 716 | action: "accept" as const, |
| 541 | content: { | 717 | content: { |
| 542 | token: "ghp_test_token", | 718 | authKind: "bearer", |
| 719 | tokenSecretMaterialId, | ||
| 543 | }, | 720 | }, |
| 544 | }; | 721 | }; |
| 545 | }), | 722 | }), |
| @@ -553,9 +730,6 @@ describe("workspace-execution-environment", () => { · 7 unmodified lines | |||
| 553 | specUrl: specServer.specUrl, | 730 | specUrl: specServer.specUrl, |
| 554 | name: "GitHub", | 731 | name: "GitHub", |
| 555 | namespace: "github", | 732 | namespace: "github", |
| 556 | auth: { | ||
| 557 | kind: "bearer", | ||
| 558 | }, | ||
| 559 | }, | 733 | }, |
| 560 | context: { | 734 | context: { |
| 561 | runId: "exec_add_openapi", | 735 | runId: "exec_add_openapi", |
| @@ -576,11 +750,13 @@ describe("workspace-execution-environment", () => { · 14 unmodified lines | |||
| 576 | expect(added.auth.kind).toBe("bearer"); | 750 | expect(added.auth.kind).toBe("bearer"); |
| 577 | expect(added.auth.token?.providerId).toBe("control-plane"); | 751 | expect(added.auth.token?.providerId).toBe("control-plane"); |
| 578 | expect(capturedElicitation).toMatchObject({ | 752 | expect(capturedElicitation).toMatchObject({ |
| 579 | mode: "form", | 753 | mode: "url", |
| 580 | requestedSchema: { | 754 | url: expect.stringContaining("/v1/workspaces/"), |
| 581 | type: "object", | 755 | elicitationId: expect.stringContaining("executor.sources.add:"), |
| 582 | }, | ||
| 583 | }); | 756 | }); |
| 757 | expect((capturedElicitation as { url?: string } | null)?.url).toContain( | ||
| 758 | encodeURIComponent("exec_add_openapi:executor.sources.add:"), | ||
| 759 | ); | ||
| 584 | 760 | ||
| 585 | const storedArtifacts = yield* persistence.rows.toolArtifacts.listByWorkspaceId( | 761 | const storedArtifacts = yield* persistence.rows.toolArtifacts.listByWorkspaceId( |
| 586 | workspaceId, | 762 | workspaceId, |
| @@ -656,7 +832,9 @@ describe("workspace-execution-environment", () => { · 69 unmodified lines | |||
| 656 | expect(described?.path).toBe("executor.sources.add"); | 832 | expect(described?.path).toBe("executor.sources.add"); |
| 657 | expect(described?.description).toContain("Source add input shapes:"); | 833 | expect(described?.description).toContain("Source add input shapes:"); |
| 658 | expect(described?.description).toContain("specUrl"); | 834 | expect(described?.description).toContain("specUrl"); |
| 835 | expect(described?.description).toContain("credential setup"); | ||
| 659 | expect(described?.inputHint).toContain("endpoint"); | 836 | expect(described?.inputHint).toContain("endpoint"); |
| 837 | expect(described?.inputHint).not.toContain("auth"); | ||
| 660 | 838 | ||
| 661 | const inputSchema = described?.inputSchemaJson | 839 | const inputSchema = described?.inputSchemaJson |
| 662 | ? JSON.parse(described.inputSchemaJson) as Record<string, unknown> | 840 | ? JSON.parse(described.inputSchemaJson) as Record<string, unknown> |
| @@ -664,6 +842,7 @@ describe("workspace-execution-environment", () => { · 1 unmodified line | |||
| 664 | expect(inputSchema).not.toBeNull(); | 842 | expect(inputSchema).not.toBeNull(); |
| 665 | expect(JSON.stringify(inputSchema)).toContain("specUrl"); | 843 | expect(JSON.stringify(inputSchema)).toContain("specUrl"); |
| 666 | expect(JSON.stringify(inputSchema)).toContain("endpoint"); | 844 | expect(JSON.stringify(inputSchema)).toContain("endpoint"); |
| 845 | expect(JSON.stringify(inputSchema)).not.toContain("\"auth\""); | ||
| 667 | }), | 846 | }), |
| 668 | ); | 847 | ); |
| 669 | }); | 848 | }); |
| @@ -55,17 +55,157 @@ const tokenize = (value: string): string[] => · 54 unmodified lines | |||
| 55 | value | 55 | value |
| 56 | .trim() | 56 | .trim() |
| 57 | .toLowerCase() | 57 | .toLowerCase() |
| 58 | .split(/\s+/) | 58 | .split(/[^a-z0-9]+/) |
| 59 | .filter(Boolean); | 59 | .filter(Boolean); |
| 60 | 60 | ||
| 61 | const LOW_SIGNAL_QUERY_TOKENS = new Set([ | ||
| 62 | "a", | ||
| 63 | "an", | ||
| 64 | "the", | ||
| 65 | "am", | ||
| 66 | "as", | ||
| 67 | "for", | ||
| 68 | "from", | ||
| 69 | "get", | ||
| 70 | "i", | ||
| 71 | "in", | ||
| 72 | "is", | ||
| 73 | "list", | ||
| 74 | "me", | ||
| 75 | "my", | ||
| 76 | "of", | ||
| 77 | "on", | ||
| 78 | "or", | ||
| 79 | "signed", | ||
| 80 | "to", | ||
| 81 | "who", | ||
| 82 | ]); | ||
| 83 | |||
| 84 | const singularizeToken = (value: string): string => | ||
| 85 | value.length > 3 && value.endsWith("s") | ||
| 86 | ? value.slice(0, -1) | ||
| 87 | : value; | ||
| 88 | |||
| 89 | const tokenEquals = (left: string, right: string): boolean => | ||
| 90 | left === right || singularizeToken(left) === singularizeToken(right); | ||
| 91 | |||
| 92 | const hasTokenMatch = (tokens: readonly string[], queryToken: string): boolean => | ||
| 93 | tokens.some((token) => tokenEquals(token, queryToken)); | ||
| 94 | |||
| 95 | const hasSubstringMatch = (value: string, queryToken: string): boolean => { | ||
| 96 | if (value.includes(queryToken)) { | ||
| 97 | return true; | ||
| 98 | } | ||
| 99 | |||
| 100 | const singular = singularizeToken(queryToken); | ||
| 101 | return singular !== queryToken && value.includes(singular); | ||
| 102 | }; | ||
| 103 | |||
| 104 | const queryTokenWeight = (token: string): number => | ||
| 105 | LOW_SIGNAL_QUERY_TOKENS.has(token) ? 0.25 : 1; | ||
| 106 | |||
| 61 | const scoreArtifact = ( | 107 | const scoreArtifact = ( |
| 62 | queryTokens: readonly string[], | 108 | queryTokens: readonly string[], |
| 63 | searchText: string, | 109 | artifact: StoredToolArtifactRecord, |
| 64 | ): number => | 110 | ): number => { |
| 65 | queryTokens.reduce( | 111 | const pathText = artifact.path.toLowerCase(); |
| 66 | (total, token) => total + (searchText.includes(token) ? 1 : 0), | 112 | const namespaceText = artifact.searchNamespace.toLowerCase(); |
| 67 | 0, | 113 | const toolIdText = artifact.toolId.toLowerCase(); |
| 68 | ); | 114 | const titleText = artifact.title?.toLowerCase() ?? ""; |
| 115 | const descriptionText = artifact.description?.toLowerCase() ?? ""; | ||
| 116 | const templateText = artifact.openApiPathTemplate?.toLowerCase() ?? ""; | ||
| 117 | |||
| 118 | const pathTokens = tokenize(`${artifact.path} ${artifact.toolId}`); | ||
| 119 | const namespaceTokens = tokenize(artifact.searchNamespace); | ||
| 120 | const titleTokens = tokenize(artifact.title ?? ""); | ||
| 121 | const templateTokens = tokenize(artifact.openApiPathTemplate ?? ""); | ||
| 122 | |||
| 123 | let score = 0; | ||
| 124 | let structuralHits = 0; | ||
| 125 | let namespaceHits = 0; | ||
| 126 | let pathHits = 0; | ||
| 127 | |||
| 128 | for (const token of queryTokens) { | ||
| 129 | const weight = queryTokenWeight(token); | ||
| 130 | |||
| 131 | if (hasTokenMatch(pathTokens, token)) { | ||
| 132 | score += 12 * weight; | ||
| 133 | structuralHits += 1; | ||
| 134 | pathHits += 1; | ||
| 135 | continue; | ||
| 136 | } | ||
| 137 | |||
| 138 | if (hasTokenMatch(namespaceTokens, token)) { | ||
| 139 | score += 11 * weight; | ||
| 140 | structuralHits += 1; | ||
| 141 | namespaceHits += 1; | ||
| 142 | continue; | ||
| 143 | } | ||
| 144 | |||
| 145 | if (hasTokenMatch(titleTokens, token)) { | ||
| 146 | score += 9 * weight; | ||
| 147 | structuralHits += 1; | ||
| 148 | continue; | ||
| 149 | } | ||
| 150 | |||
| 151 | if (hasTokenMatch(templateTokens, token)) { | ||
| 152 | score += 8 * weight; | ||
| 153 | structuralHits += 1; | ||
| 154 | continue; | ||
| 155 | } | ||
| 156 | |||
| 157 | if (hasSubstringMatch(pathText, token) || hasSubstringMatch(toolIdText, token)) { | ||
| 158 | score += 6 * weight; | ||
| 159 | structuralHits += 1; | ||
| 160 | pathHits += 1; | ||
| 161 | continue; | ||
| 162 | } | ||
| 163 | |||
| 164 | if (hasSubstringMatch(namespaceText, token)) { | ||
| 165 | score += 5 * weight; | ||
| 166 | structuralHits += 1; | ||
| 167 | namespaceHits += 1; | ||
| 168 | continue; | ||
| 169 | } | ||
| 170 | |||
| 171 | if (hasSubstringMatch(titleText, token) || hasSubstringMatch(templateText, token)) { | ||
| 172 | score += 4 * weight; | ||
| 173 | structuralHits += 1; | ||
| 174 | continue; | ||
| 175 | } | ||
| 176 | |||
| 177 | if (hasSubstringMatch(descriptionText, token)) { | ||
| 178 | score += 0.5 * weight; | ||
| 179 | } | ||
| 180 | } | ||
| 181 | |||
| 182 | const strongTokens = queryTokens.filter((token) => queryTokenWeight(token) >= 1); | ||
| 183 | if (strongTokens.length >= 2) { | ||
| 184 | for (let index = 0; index < strongTokens.length - 1; index += 1) { | ||
| 185 | const current = strongTokens[index]!; | ||
| 186 | const next = strongTokens[index + 1]!; | ||
| 187 | const phrases = [ | ||
| 188 | `${current}-${next}`, | ||
| 189 | `${current}.${next}`, | ||
| 190 | `${current}/${next}`, | ||
| 191 | ]; | ||
| 192 | |||
| 193 | if (phrases.some((phrase) => pathText.includes(phrase) || templateText.includes(phrase))) { | ||
| 194 | score += 10; | ||
| 195 | } | ||
| 196 | } | ||
| 197 | } | ||
| 198 | |||
| 199 | if (namespaceHits > 0 && pathHits > 0) { | ||
| 200 | score += 8; | ||
| 201 | } | ||
| 202 | |||
| 203 | if (structuralHits === 0 && score > 0) { | ||
| 204 | score *= 0.25; | ||
| 205 | } | ||
| 206 | |||
| 207 | return score; | ||
| 208 | }; | ||
| 69 | 209 | ||
| 70 | const toDescriptor = (input: { | 210 | const toDescriptor = (input: { |
| 71 | artifact: StoredToolArtifactRecord; | 211 | artifact: StoredToolArtifactRecord; |
| @@ -263,7 +403,6 @@ const createWorkspaceToolCatalog = (input: { · 191 unmodified lines | |||
| 263 | : input.rows.toolArtifacts.searchByWorkspaceId(input.workspaceId, { | 403 | : input.rows.toolArtifacts.searchByWorkspaceId(input.workspaceId, { |
| 264 | namespace, | 404 | namespace, |
| 265 | query, | 405 | query, |
| 266 | limit: Math.max(limit * 8, 50), | ||
| 267 | }).pipe( | 406 | }).pipe( |
| 268 | Effect.mapError((cause) => | 407 | Effect.mapError((cause) => |
| 269 | cause instanceof Error ? cause : new Error(String(cause)), | 408 | cause instanceof Error ? cause : new Error(String(cause)), |
| @@ -279,7 +418,7 @@ const createWorkspaceToolCatalog = (input: { · 9 unmodified lines | |||
| 279 | const persistedHits: SearchHit[] = persisted | 418 | const persistedHits: SearchHit[] = persisted |
| 280 | .map((artifact) => ({ | 419 | .map((artifact) => ({ |
| 281 | path: asToolPath(artifact.path), | 420 | path: asToolPath(artifact.path), |
| 282 | score: scoreArtifact(queryTokens, artifact.searchText), | 421 | score: scoreArtifact(queryTokens, artifact), |
| 283 | })) | 422 | })) |
| 284 | .filter((hit) => hit.score > 0); | 423 | .filter((hit) => hit.score > 0); |
| 285 | 424 | ||
| @@ -10,7 +10,7 @@ export type InProcessExecutorOptions = { · 9 unmodified lines | |||
| 10 | allowFetch?: boolean; | 10 | allowFetch?: boolean; |
| 11 | }; | 11 | }; |
| 12 | 12 | ||
| 13 | const DEFAULT_TIMEOUT_MS = 30_000; | 13 | const DEFAULT_TIMEOUT_MS = 5 * 60_000; |
| 14 | 14 | ||
| 15 | const formatLogArg = (value: unknown): string => { | 15 | const formatLogArg = (value: unknown): string => { |
| 16 | if (typeof value === "string") { | 16 | if (typeof value === "string") { |
| @@ -179,4 +179,3 @@ export const makeInProcessExecutor = ( · 162 unmodified lines | |||
| 179 | execute: (code: string, toolInvoker: ToolInvoker) => | 179 | execute: (code: string, toolInvoker: ToolInvoker) => |
| 180 | runInProcess(options, code, toolInvoker), | 180 | runInProcess(options, code, toolInvoker), |
| 181 | }); | 181 | }); |
| 182 | |||