History

extract pending interaction output and improve non-interactive CLI handling

- 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

Rhys Sullivan committed 8 months ago

Commit 8e45e141 parent f0156d53 files · +276 −45Browse files

Unified · Split

3 files
  1. apps/executor/src/cli/main.ts +35 −45
  2. apps/executor/src/cli/pending-interaction-output.test.ts +112 −0
  3. apps/executor/src/cli/pending-interaction-output.ts +129 −0
apps/executor/src/cli/main.ts+35−45
@@ -47,6 +47,10 @@ import { · 46 unmodified lines
47 resolveRuntimeWebAssetsDir,47 resolveRuntimeWebAssetsDir,
48 resolveSelfCommand,48 resolveSelfCommand,
49} from "./runtime-paths";49} from "./runtime-paths";
50import {
51 buildPausedExecutionOutput,
52 parseInteractionPayload,
53} from "./pending-interaction-output";
50 54
51const toError = (cause: unknown): Error =>55const 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
686const 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
722const isRecord = (value: unknown): value is Record<string, unknown> =>692const 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
apps/executor/src/cli/pending-interaction-output.test.ts+112−0
@@ -0,0 +1,112 @@
1import { describe, expect, it } from "@effect/vitest";
2import type { ExecutionInteraction } from "@executor-v3/control-plane";
3
4import {
5 buildPausedExecutionOutput,
6 parseInteractionPayload,
7} from "./pending-interaction-output";
8
9const 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
38describe("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});
apps/executor/src/cli/pending-interaction-output.ts+129−0
@@ -0,0 +1,129 @@
1import type { ExecutionInteraction } from "@executor-v3/control-plane";
2
3const isRecord = (value: unknown): value is Record<string, unknown> =>
4 typeof value === "object" && value !== null && !Array.isArray(value);
5
6export type ParsedInteractionPayload = {
7 message: string;
8 mode: "form" | "url";
9 url?: string;
10 requestedSchema?: Record<string, unknown>;
11};
12
13export 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
44const shellEscape = (value: string): string => {
45 if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) {
46 return value;
47 }
48
49 return `'${value.replace(/'/g, `'"'"'`)}'`;
50};
51
52const renderCommand = (argv: readonly string[]): string => argv.map(shellEscape).join(" ");
53
54const 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
67const 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
89export 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};