- Extract parseInteractionPayload and buildPausedExecutionOutput into a dedicated module with tests - Detect non-interactive terminals early and emit structured paused output with resume instructions instead of hanging - Include baseUrl and web URL in paused output for easier resumption - Pass baseUrl through driveExecution for resume URL construction
Unified · Split
| @@ -47,6 +47,10 @@ import { · 46 unmodified lines | |||
| 47 | resolveRuntimeWebAssetsDir, | 47 | resolveRuntimeWebAssetsDir, |
| 48 | resolveSelfCommand, | 48 | resolveSelfCommand, |
| 49 | } from "./runtime-paths"; | 49 | } from "./runtime-paths"; |
| 50 | import { | ||
| 51 | buildPausedExecutionOutput, | ||
| 52 | parseInteractionPayload, | ||
| 53 | } from "./pending-interaction-output"; | ||
| 50 | 54 | ||
| 51 | const toError = (cause: unknown): Error => | 55 | const toError = (cause: unknown): Error => |
| 52 | cause instanceof Error ? cause : new Error(String(cause)); | 56 | cause instanceof Error ? cause : new Error(String(cause)); |
| @@ -683,41 +687,7 @@ const ensureServer = (baseUrl: string = DEFAULT_SERVER_BASE_URL) => · 630 unmodified lines | |||
| 683 | yield* waitForReachability(baseUrl, true); | 687 | yield* waitForReachability(baseUrl, true); |
| 684 | }); | 688 | }); |
| 685 | 689 | ||
| 686 | const parseInteractionPayload = (interaction: ExecutionInteraction): { | ||
| 687 | message: string; | ||
| 688 | mode: "form" | "url"; | ||
| 689 | url?: string; | ||
| 690 | requestedSchema?: Record<string, unknown>; | ||
| 691 | } | null => { | ||
| 692 | try { | ||
| 693 | const parsed = JSON.parse(interaction.payloadJson) as { | ||
| 694 | elicitation?: { | ||
| 695 | message?: string; | ||
| 696 | mode?: "form" | "url"; | ||
| 697 | url?: string; | ||
| 698 | requestedSchema?: Record<string, unknown>; | ||
| 699 | }; | ||
| 700 | }; | ||
| 701 | 690 | ||
| 702 | if (!parsed.elicitation || typeof parsed.elicitation.message !== "string") { | ||
| 703 | return null; | ||
| 704 | } | ||
| 705 | |||
| 706 | return { | ||
| 707 | message: parsed.elicitation.message, | ||
| 708 | mode: parsed.elicitation.mode === "url" ? "url" : "form", | ||
| 709 | url: parsed.elicitation.url, | ||
| 710 | requestedSchema: | ||
| 711 | typeof parsed.elicitation.requestedSchema === "object" | ||
| 712 | && parsed.elicitation.requestedSchema !== null | ||
| 713 | && !Array.isArray(parsed.elicitation.requestedSchema) | ||
| 714 | ? parsed.elicitation.requestedSchema | ||
| 715 | : undefined, | ||
| 716 | }; | ||
| 717 | } catch { | ||
| 718 | return null; | ||
| 719 | } | ||
| 720 | }; | ||
| 721 | 691 | ||
| 722 | const isRecord = (value: unknown): value is Record<string, unknown> => | 692 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 723 | typeof value === "object" && value !== null && !Array.isArray(value); | 693 | typeof value === "object" && value !== null && !Array.isArray(value); |
| @@ -964,6 +934,10 @@ const printExecution = (envelope: ExecutionEnvelope) => · 240 unmodified lines | |||
| 964 | process.exitCode = 1; | 934 | process.exitCode = 1; |
| 965 | return; | 935 | return; |
| 966 | } | 936 | } |
| 937 | if (execution.status === "waiting_for_interaction" && envelope.pendingInteraction !== null) { | ||
| 938 | return; | ||
| 939 | } | ||
| 940 | |||
| 967 | 941 | ||
| 968 | console.log(JSON.stringify({ | 942 | console.log(JSON.stringify({ |
| 969 | id: execution.id, | 943 | id: execution.id, |
| @@ -1023,6 +997,7 @@ const driveExecution = (input: { · 53 unmodified lines | |||
| 1023 | client: ControlPlaneClient; | 997 | client: ControlPlaneClient; |
| 1024 | workspaceId: ExecutionEnvelope["execution"]["workspaceId"]; | 998 | workspaceId: ExecutionEnvelope["execution"]["workspaceId"]; |
| 1025 | envelope: ExecutionEnvelope; | 999 | envelope: ExecutionEnvelope; |
| 1000 | baseUrl: string; | ||
| 1026 | shouldOpenUrls: boolean; | 1001 | shouldOpenUrls: boolean; |
| 1027 | }) => | 1002 | }) => |
| 1028 | Effect.gen(function* () { | 1003 | Effect.gen(function* () { |
| @@ -1036,6 +1011,22 @@ const driveExecution = (input: { · 7 unmodified lines | |||
| 1036 | } | 1011 | } |
| 1037 | 1012 | ||
| 1038 | const parsed = parseInteractionPayload(pending); | 1013 | const parsed = parseInteractionPayload(pending); |
| 1014 | const isInteractiveTerminal = process.stdin.isTTY && process.stdout.isTTY; | ||
| 1015 | if (!isInteractiveTerminal) { | ||
| 1016 | const paused = buildPausedExecutionOutput({ | ||
| 1017 | executionId: current.execution.id, | ||
| 1018 | interaction: pending, | ||
| 1019 | baseUrl: input.baseUrl, | ||
| 1020 | shouldOpenUrls: input.shouldOpenUrls, | ||
| 1021 | cliName: CLI_NAME, | ||
| 1022 | }); | ||
| 1023 | yield* Effect.sync(() => { | ||
| 1024 | console.log(JSON.stringify(paused)); | ||
| 1025 | process.exitCode = 20; | ||
| 1026 | }); | ||
| 1027 | return current; | ||
| 1028 | } | ||
| 1029 | |||
| 1039 | if (parsed?.mode === "url") { | 1030 | if (parsed?.mode === "url") { |
| 1040 | yield* printUrlInteraction({ | 1031 | yield* printUrlInteraction({ |
| 1041 | message: parsed.message, | 1032 | message: parsed.message, |
| @@ -1043,10 +1034,6 @@ const driveExecution = (input: { · 1 unmodified line | |||
| 1043 | shouldOpen: input.shouldOpenUrls, | 1034 | shouldOpen: input.shouldOpenUrls, |
| 1044 | }); | 1035 | }); |
| 1045 | 1036 | ||
| 1046 | if (!process.stdin.isTTY || !process.stdout.isTTY) { | ||
| 1047 | return current; | ||
| 1048 | } | ||
| 1049 | |||
| 1050 | current = yield* waitForExecutionProgress({ | 1037 | current = yield* waitForExecutionProgress({ |
| 1051 | client: input.client, | 1038 | client: input.client, |
| 1052 | workspaceId: input.workspaceId, | 1039 | workspaceId: input.workspaceId, |
| @@ -1061,14 +1048,15 @@ const driveExecution = (input: { · 8 unmodified lines | |||
| 1061 | shouldOpenUrls: input.shouldOpenUrls, | 1048 | shouldOpenUrls: input.shouldOpenUrls, |
| 1062 | }); | 1049 | }); |
| 1063 | if (responseJson === null) { | 1050 | if (responseJson === null) { |
| 1051 | const paused = buildPausedExecutionOutput({ | ||
| 1052 | executionId: current.execution.id, | ||
| 1053 | interaction: pending, | ||
| 1054 | baseUrl: input.baseUrl, | ||
| 1055 | shouldOpenUrls: input.shouldOpenUrls, | ||
| 1056 | cliName: CLI_NAME, | ||
| 1057 | }); | ||
| 1064 | yield* Effect.sync(() => { | 1058 | yield* Effect.sync(() => { |
| 1065 | console.log(JSON.stringify({ | 1059 | console.log(JSON.stringify(paused)); |
| 1066 | id: current.execution.id, | ||
| 1067 | status: current.execution.status, | ||
| 1068 | interactionId: pending.id, | ||
| 1069 | message: parseInteractionPayload(pending)?.message ?? "Interaction required", | ||
| 1070 | resumeCommand: `executor resume --execution-id ${current.execution.id}`, | ||
| 1071 | })); | ||
| 1072 | process.exitCode = 20; | 1060 | process.exitCode = 20; |
| 1073 | }); | 1061 | }); |
| 1074 | return current; | 1062 | return current; |
| @@ -1190,6 +1178,7 @@ const callCommand = Command.make( · 115 unmodified lines | |||
| 1190 | client, | 1178 | client, |
| 1191 | workspaceId: installation.workspaceId, | 1179 | workspaceId: installation.workspaceId, |
| 1192 | envelope: created, | 1180 | envelope: created, |
| 1181 | baseUrl, | ||
| 1193 | shouldOpenUrls: !noOpen, | 1182 | shouldOpenUrls: !noOpen, |
| 1194 | }); | 1183 | }); |
| 1195 | 1184 | ||
| @@ -1222,6 +1211,7 @@ const resumeCommand = Command.make( · 26 unmodified lines | |||
| 1222 | client, | 1211 | client, |
| 1223 | workspaceId: installation.workspaceId, | 1212 | workspaceId: installation.workspaceId, |
| 1224 | envelope: execution, | 1213 | envelope: execution, |
| 1214 | baseUrl, | ||
| 1225 | shouldOpenUrls: !noOpen, | 1215 | shouldOpenUrls: !noOpen, |
| 1226 | }); | 1216 | }); |
| 1227 | 1217 | ||
| @@ -0,0 +1,112 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import type { ExecutionInteraction } from "@executor-v3/control-plane"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | buildPausedExecutionOutput, | ||
| 6 | parseInteractionPayload, | ||
| 7 | } from "./pending-interaction-output"; | ||
| 8 | |||
| 9 | const makeInteraction = (patch: Partial<ExecutionInteraction> = {}): ExecutionInteraction => ({ | ||
| 10 | id: "exec_123:tool_execution_gate:call_1" as never, | ||
| 11 | executionId: "exec_123" as never, | ||
| 12 | status: "pending", | ||
| 13 | kind: "form", | ||
| 14 | purpose: "tool_execution_gate", | ||
| 15 | payloadJson: JSON.stringify({ | ||
| 16 | elicitation: { | ||
| 17 | mode: "form", | ||
| 18 | message: "Allow DELETE /v2/domains/{domain}/records/{recordId}?", | ||
| 19 | requestedSchema: { | ||
| 20 | type: "object", | ||
| 21 | properties: { | ||
| 22 | approve: { | ||
| 23 | type: "boolean", | ||
| 24 | description: "Whether to approve the tool call", | ||
| 25 | }, | ||
| 26 | }, | ||
| 27 | required: ["approve"], | ||
| 28 | additionalProperties: false, | ||
| 29 | }, | ||
| 30 | }, | ||
| 31 | }), | ||
| 32 | responseJson: null, | ||
| 33 | createdAt: 1, | ||
| 34 | updatedAt: 1, | ||
| 35 | ...patch, | ||
| 36 | }); | ||
| 37 | |||
| 38 | describe("pending-interaction-output", () => { | ||
| 39 | it("parses requested schema from interaction payload", () => { | ||
| 40 | const parsed = parseInteractionPayload(makeInteraction()); | ||
| 41 | |||
| 42 | expect(parsed).not.toBeNull(); | ||
| 43 | expect(parsed?.mode).toBe("form"); | ||
| 44 | expect(parsed?.requestedSchema).toEqual({ | ||
| 45 | type: "object", | ||
| 46 | properties: { | ||
| 47 | approve: { | ||
| 48 | type: "boolean", | ||
| 49 | description: "Whether to approve the tool call", | ||
| 50 | }, | ||
| 51 | }, | ||
| 52 | required: ["approve"], | ||
| 53 | additionalProperties: false, | ||
| 54 | }); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("builds a structured paused execution instruction with full resume command", () => { | ||
| 58 | const output = buildPausedExecutionOutput({ | ||
| 59 | executionId: "exec_123", | ||
| 60 | interaction: makeInteraction(), | ||
| 61 | baseUrl: "http://127.0.0.1:8788", | ||
| 62 | shouldOpenUrls: false, | ||
| 63 | }); | ||
| 64 | |||
| 65 | expect(output.resumeCommand).toBe( | ||
| 66 | "executor resume --execution-id exec_123 --base-url http://127.0.0.1:8788 --no-open", | ||
| 67 | ); | ||
| 68 | expect(output.interaction.requestedSchema).toEqual({ | ||
| 69 | type: "object", | ||
| 70 | properties: { | ||
| 71 | approve: { | ||
| 72 | type: "boolean", | ||
| 73 | description: "Whether to approve the tool call", | ||
| 74 | }, | ||
| 75 | }, | ||
| 76 | required: ["approve"], | ||
| 77 | additionalProperties: false, | ||
| 78 | }); | ||
| 79 | expect(output.instruction).toContain("requires approval"); | ||
| 80 | expect(output.instruction).toContain("Allow DELETE /v2/domains/{domain}/records/{recordId}?"); | ||
| 81 | expect(output.instruction).toContain(output.resumeCommand); | ||
| 82 | expect(output.instruction).toContain("interaction.requestedSchema"); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("describes URL interactions with the next command to run", () => { | ||
| 86 | const output = buildPausedExecutionOutput({ | ||
| 87 | executionId: "exec_456", | ||
| 88 | interaction: makeInteraction({ | ||
| 89 | id: "exec_456:source_connect_oauth2:call_1" as never, | ||
| 90 | executionId: "exec_456" as never, | ||
| 91 | kind: "url", | ||
| 92 | purpose: "source_connect_oauth2", | ||
| 93 | payloadJson: JSON.stringify({ | ||
| 94 | elicitation: { | ||
| 95 | mode: "url", | ||
| 96 | message: "Finish connecting Vercel", | ||
| 97 | url: "https://vercel.com/oauth/start", | ||
| 98 | }, | ||
| 99 | }), | ||
| 100 | }), | ||
| 101 | baseUrl: "http://127.0.0.1:8788", | ||
| 102 | shouldOpenUrls: true, | ||
| 103 | }); | ||
| 104 | |||
| 105 | expect(output.interaction.mode).toBe("url"); | ||
| 106 | expect(output.interaction.url).toBe("https://vercel.com/oauth/start"); | ||
| 107 | expect(output.instruction).toContain("https://vercel.com/oauth/start"); | ||
| 108 | expect(output.instruction).toContain( | ||
| 109 | "executor resume --execution-id exec_456 --base-url http://127.0.0.1:8788", | ||
| 110 | ); | ||
| 111 | }); | ||
| 112 | }); | ||
| @@ -0,0 +1,129 @@ | |||
| 1 | import type { ExecutionInteraction } from "@executor-v3/control-plane"; | ||
| 2 | |||
| 3 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 4 | typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 5 | |||
| 6 | export type ParsedInteractionPayload = { | ||
| 7 | message: string; | ||
| 8 | mode: "form" | "url"; | ||
| 9 | url?: string; | ||
| 10 | requestedSchema?: Record<string, unknown>; | ||
| 11 | }; | ||
| 12 | |||
| 13 | export const parseInteractionPayload = ( | ||
| 14 | interaction: ExecutionInteraction, | ||
| 15 | ): ParsedInteractionPayload | null => { | ||
| 16 | try { | ||
| 17 | const parsed = JSON.parse(interaction.payloadJson) as { | ||
| 18 | elicitation?: { | ||
| 19 | message?: string; | ||
| 20 | mode?: "form" | "url"; | ||
| 21 | url?: string; | ||
| 22 | requestedSchema?: Record<string, unknown>; | ||
| 23 | }; | ||
| 24 | }; | ||
| 25 | |||
| 26 | if (!parsed.elicitation || typeof parsed.elicitation.message !== "string") { | ||
| 27 | return null; | ||
| 28 | } | ||
| 29 | |||
| 30 | return { | ||
| 31 | message: parsed.elicitation.message, | ||
| 32 | mode: parsed.elicitation.mode === "url" ? "url" : "form", | ||
| 33 | url: parsed.elicitation.url, | ||
| 34 | requestedSchema: | ||
| 35 | isRecord(parsed.elicitation.requestedSchema) | ||
| 36 | ? parsed.elicitation.requestedSchema | ||
| 37 | : undefined, | ||
| 38 | }; | ||
| 39 | } catch { | ||
| 40 | return null; | ||
| 41 | } | ||
| 42 | }; | ||
| 43 | |||
| 44 | const shellEscape = (value: string): string => { | ||
| 45 | if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) { | ||
| 46 | return value; | ||
| 47 | } | ||
| 48 | |||
| 49 | return `'${value.replace(/'/g, `'"'"'`)}'`; | ||
| 50 | }; | ||
| 51 | |||
| 52 | const renderCommand = (argv: readonly string[]): string => argv.map(shellEscape).join(" "); | ||
| 53 | |||
| 54 | const describePauseReason = (interaction: ExecutionInteraction): string => { | ||
| 55 | switch (interaction.purpose) { | ||
| 56 | case "tool_execution_gate": | ||
| 57 | return "this tool call requires approval before executor can continue"; | ||
| 58 | case "source_connect_oauth2": | ||
| 59 | return "executor needs browser authentication to finish connecting the source"; | ||
| 60 | case "source_connect_secret": | ||
| 61 | return "executor needs credentials to finish connecting the source"; | ||
| 62 | default: | ||
| 63 | return "executor needs additional input before it can continue"; | ||
| 64 | } | ||
| 65 | }; | ||
| 66 | |||
| 67 | const buildInstruction = (input: { | ||
| 68 | interaction: ExecutionInteraction; | ||
| 69 | parsed: ParsedInteractionPayload | null; | ||
| 70 | resumeCommand: string; | ||
| 71 | }): string => { | ||
| 72 | const reason = describePauseReason(input.interaction); | ||
| 73 | const prompt = input.parsed?.message ?? "Interaction required"; | ||
| 74 | |||
| 75 | if (input.parsed?.mode === "url") { | ||
| 76 | const url = input.parsed.url?.trim(); | ||
| 77 | return url && url.length > 0 | ||
| 78 | ? `Execution paused because ${reason}. Open ${url} and complete the requested flow for \"${prompt}\", then run ${input.resumeCommand} to continue if it does not resume automatically.` | ||
| 79 | : `Execution paused because ${reason}. Run ${input.resumeCommand} to continue and complete the requested flow for \"${prompt}\".`; | ||
| 80 | } | ||
| 81 | |||
| 82 | if (input.parsed?.requestedSchema) { | ||
| 83 | return `Execution paused because ${reason}. The interaction prompt is \"${prompt}\". Run ${input.resumeCommand} in an interactive terminal and respond with input matching interaction.requestedSchema.`; | ||
| 84 | } | ||
| 85 | |||
| 86 | return `Execution paused because ${reason}. The interaction prompt is \"${prompt}\". Run ${input.resumeCommand} in an interactive terminal to continue.`; | ||
| 87 | }; | ||
| 88 | |||
| 89 | export const buildPausedExecutionOutput = (input: { | ||
| 90 | executionId: string; | ||
| 91 | interaction: ExecutionInteraction; | ||
| 92 | baseUrl: string; | ||
| 93 | shouldOpenUrls: boolean; | ||
| 94 | cliName?: string; | ||
| 95 | }) => { | ||
| 96 | const cliName = input.cliName ?? "executor"; | ||
| 97 | const argv = [cliName, "resume", "--execution-id", input.executionId, "--base-url", input.baseUrl]; | ||
| 98 | if (!input.shouldOpenUrls) { | ||
| 99 | argv.push("--no-open"); | ||
| 100 | } | ||
| 101 | |||
| 102 | const command = renderCommand(argv); | ||
| 103 | const parsed = parseInteractionPayload(input.interaction); | ||
| 104 | |||
| 105 | return { | ||
| 106 | id: input.executionId, | ||
| 107 | status: "waiting_for_interaction" as const, | ||
| 108 | interactionId: input.interaction.id, | ||
| 109 | message: parsed?.message ?? "Interaction required", | ||
| 110 | resumeCommand: command, | ||
| 111 | interaction: { | ||
| 112 | id: input.interaction.id, | ||
| 113 | purpose: input.interaction.purpose, | ||
| 114 | mode: parsed?.mode ?? (input.interaction.kind === "url" ? "url" : "form"), | ||
| 115 | message: parsed?.message ?? "Interaction required", | ||
| 116 | url: parsed?.url ?? null, | ||
| 117 | requestedSchema: parsed?.requestedSchema ?? null, | ||
| 118 | }, | ||
| 119 | resume: { | ||
| 120 | command, | ||
| 121 | argv, | ||
| 122 | }, | ||
| 123 | instruction: buildInstruction({ | ||
| 124 | interaction: input.interaction, | ||
| 125 | parsed, | ||
| 126 | resumeCommand: command, | ||
| 127 | }), | ||
| 128 | }; | ||
| 129 | }; | ||