- 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 | 47 | resolveRuntimeWebAssetsDir, |
| 48 | 48 | resolveSelfCommand, |
| 49 | 49 | } from "./runtime-paths"; |
| 50 | +import { | |
| 51 | + buildPausedExecutionOutput, | |
| 52 | + parseInteractionPayload, | |
| 53 | +} from "./pending-interaction-output"; | |
| 50 | 54 | |
| 51 | 55 | const toError = (cause: unknown): Error => |
| 52 | 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 | 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 | 692 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 723 | 693 | typeof value === "object" && value !== null && !Array.isArray(value); |
| @@ -964,6 +934,10 @@ const printExecution = (envelope: ExecutionEnvelope) => · 240 unmodified lines | ||
| 964 | 934 | process.exitCode = 1; |
| 965 | 935 | return; |
| 966 | 936 | } |
| 937 | + if (execution.status === "waiting_for_interaction" && envelope.pendingInteraction !== null) { | |
| 938 | + return; | |
| 939 | + } | |
| 940 | + | |
| 967 | 941 | |
| 968 | 942 | console.log(JSON.stringify({ |
| 969 | 943 | id: execution.id, |
| @@ -1023,6 +997,7 @@ const driveExecution = (input: { · 53 unmodified lines | ||
| 1023 | 997 | client: ControlPlaneClient; |
| 1024 | 998 | workspaceId: ExecutionEnvelope["execution"]["workspaceId"]; |
| 1025 | 999 | envelope: ExecutionEnvelope; |
| 1000 | + baseUrl: string; | |
| 1026 | 1001 | shouldOpenUrls: boolean; |
| 1027 | 1002 | }) => |
| 1028 | 1003 | Effect.gen(function* () { |
| @@ -1036,6 +1011,22 @@ const driveExecution = (input: { · 7 unmodified lines | ||
| 1036 | 1011 | } |
| 1037 | 1012 | |
| 1038 | 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 | 1030 | if (parsed?.mode === "url") { |
| 1040 | 1031 | yield* printUrlInteraction({ |
| 1041 | 1032 | message: parsed.message, |
| @@ -1043,10 +1034,6 @@ const driveExecution = (input: { · 1 unmodified line | ||
| 1043 | 1034 | shouldOpen: input.shouldOpenUrls, |
| 1044 | 1035 | }); |
| 1045 | 1036 | |
| 1046 | − if (!process.stdin.isTTY || !process.stdout.isTTY) { | |
| 1047 | − return current; | |
| 1048 | − } | |
| 1049 | − | |
| 1050 | 1037 | current = yield* waitForExecutionProgress({ |
| 1051 | 1038 | client: input.client, |
| 1052 | 1039 | workspaceId: input.workspaceId, |
| @@ -1061,14 +1048,15 @@ const driveExecution = (input: { · 8 unmodified lines | ||
| 1061 | 1048 | shouldOpenUrls: input.shouldOpenUrls, |
| 1062 | 1049 | }); |
| 1063 | 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 | 1058 | yield* Effect.sync(() => { |
| 1065 | − console.log(JSON.stringify({ | |
| 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 | − })); | |
| 1059 | + console.log(JSON.stringify(paused)); | |
| 1072 | 1060 | process.exitCode = 20; |
| 1073 | 1061 | }); |
| 1074 | 1062 | return current; |
| @@ -1190,6 +1178,7 @@ const callCommand = Command.make( · 115 unmodified lines | ||
| 1190 | 1178 | client, |
| 1191 | 1179 | workspaceId: installation.workspaceId, |
| 1192 | 1180 | envelope: created, |
| 1181 | + baseUrl, | |
| 1193 | 1182 | shouldOpenUrls: !noOpen, |
| 1194 | 1183 | }); |
| 1195 | 1184 | |
| @@ -1222,6 +1211,7 @@ const resumeCommand = Command.make( · 26 unmodified lines | ||
| 1222 | 1211 | client, |
| 1223 | 1212 | workspaceId: installation.workspaceId, |
| 1224 | 1213 | envelope: execution, |
| 1214 | + baseUrl, | |
| 1225 | 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 | +}; | |