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
4747 resolveRuntimeWebAssetsDir,
4848 resolveSelfCommand,
4949 } from "./runtime-paths";
50+import {
51+ buildPausedExecutionOutput,
52+ parseInteractionPayload,
53+} from "./pending-interaction-output";
5054
5155 const toError = (cause: unknown): Error =>
5256 cause instanceof Error ? cause : new Error(String(cause));
@@ -683,41 +687,7 @@ const ensureServer = (baseUrl: string = DEFAULT_SERVER_BASE_URL) => · 630 unmodified lines
683687 yield* waitForReachability(baseUrl, true);
684688 });
685689
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− };
701690
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−};
721691
722692 const isRecord = (value: unknown): value is Record<string, unknown> =>
723693 typeof value === "object" && value !== null && !Array.isArray(value);
@@ -964,6 +934,10 @@ const printExecution = (envelope: ExecutionEnvelope) => · 240 unmodified lines
964934 process.exitCode = 1;
965935 return;
966936 }
937+ if (execution.status === "waiting_for_interaction" && envelope.pendingInteraction !== null) {
938+ return;
939+ }
940+
967941
968942 console.log(JSON.stringify({
969943 id: execution.id,
@@ -1023,6 +997,7 @@ const driveExecution = (input: { · 53 unmodified lines
1023997 client: ControlPlaneClient;
1024998 workspaceId: ExecutionEnvelope["execution"]["workspaceId"];
1025999 envelope: ExecutionEnvelope;
1000+ baseUrl: string;
10261001 shouldOpenUrls: boolean;
10271002 }) =>
10281003 Effect.gen(function* () {
@@ -1036,6 +1011,22 @@ const driveExecution = (input: { · 7 unmodified lines
10361011 }
10371012
10381013 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+
10391030 if (parsed?.mode === "url") {
10401031 yield* printUrlInteraction({
10411032 message: parsed.message,
@@ -1043,10 +1034,6 @@ const driveExecution = (input: { · 1 unmodified line
10431034 shouldOpen: input.shouldOpenUrls,
10441035 });
10451036
1046− if (!process.stdin.isTTY || !process.stdout.isTTY) {
1047− return current;
1048− }
1049−
10501037 current = yield* waitForExecutionProgress({
10511038 client: input.client,
10521039 workspaceId: input.workspaceId,
@@ -1061,14 +1048,15 @@ const driveExecution = (input: { · 8 unmodified lines
10611048 shouldOpenUrls: input.shouldOpenUrls,
10621049 });
10631050 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+ });
10641058 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));
10721060 process.exitCode = 20;
10731061 });
10741062 return current;
@@ -1190,6 +1178,7 @@ const callCommand = Command.make( · 115 unmodified lines
11901178 client,
11911179 workspaceId: installation.workspaceId,
11921180 envelope: created,
1181+ baseUrl,
11931182 shouldOpenUrls: !noOpen,
11941183 });
11951184
@@ -1222,6 +1211,7 @@ const resumeCommand = Command.make( · 26 unmodified lines
12221211 client,
12231212 workspaceId: installation.workspaceId,
12241213 envelope: execution,
1214+ baseUrl,
12251215 shouldOpenUrls: !noOpen,
12261216 });
12271217
apps/executor/src/cli/pending-interaction-output.test.ts+112−0
@@ -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+});
apps/executor/src/cli/pending-interaction-output.ts+129−0
@@ -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+};