| @@ -36,7 +36,7 @@ · 35 unmodified lines |
| 36 | 36 | import { Result } from "better-result"; |
| 37 | 37 | import { WorkerEntrypoint } from "cloudflare:workers"; |
| 38 | 38 | import { api } from "@executor/convex/_generated/api"; |
| 39 | | −import { ConvexHttpClient } from "convex/browser"; |
| 39 | +import { ConvexClient, ConvexHttpClient } from "convex/browser"; |
| 40 | 40 | |
| 41 | 41 | // Import isolate modules as raw text — these are loaded as JS modules inside |
| 42 | 42 | // the dynamic isolate, NOT executed in the host worker. The *.isolate.js |
| @@ -113,6 +113,8 @@ interface BridgeProps { · 70 unmodified lines |
| 113 | 113 | taskId: string; |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | +const APPROVAL_SUBSCRIPTION_TIMEOUT_MS = 10 * 60 * 1000; |
| 117 | + |
| 116 | 118 | // ── Auth ────────────────────────────────────────────────────────────────────── |
| 117 | 119 | |
| 118 | 120 | /** Constant-time string comparison to prevent timing side-channels. */ |
| @@ -168,6 +170,58 @@ export class ToolBridge extends WorkerEntrypoint<Env> { · 49 unmodified lines |
| 168 | 170 | }); |
| 169 | 171 | } |
| 170 | 172 | |
| 173 | + private createRealtimeClient(): ConvexClient { |
| 174 | + return new ConvexClient(this.props.callbackConvexUrl, { |
| 175 | + skipConvexDeploymentUrlCheck: true, |
| 176 | + }); |
| 177 | + } |
| 178 | + |
| 179 | + private async waitForApprovalUpdate(approvalId: string): Promise<void> { |
| 180 | + const client = this.createRealtimeClient(); |
| 181 | + |
| 182 | + await new Promise<void>((resolve, reject) => { |
| 183 | + let settled = false; |
| 184 | + |
| 185 | + const timer = setTimeout(() => { |
| 186 | + if (settled) return; |
| 187 | + settled = true; |
| 188 | + unsubscribe(); |
| 189 | + client.close(); |
| 190 | + reject(new Error(`Timed out waiting for approval update: ${approvalId}`)); |
| 191 | + }, APPROVAL_SUBSCRIPTION_TIMEOUT_MS); |
| 192 | + |
| 193 | + const unsubscribe = client.onUpdate( |
| 194 | + (api as any).runtimeCallbacks.getApprovalStatus, |
| 195 | + { |
| 196 | + internalSecret: this.props.callbackInternalSecret, |
| 197 | + runId: this.props.taskId, |
| 198 | + approvalId, |
| 199 | + }, |
| 200 | + (value: { status?: "pending" | "approved" | "denied" | "missing" } | null | undefined) => { |
| 201 | + const status = value?.status; |
| 202 | + if (!status || status === "pending") { |
| 203 | + return; |
| 204 | + } |
| 205 | + if (status === "missing") { |
| 206 | + if (settled) return; |
| 207 | + settled = true; |
| 208 | + clearTimeout(timer); |
| 209 | + unsubscribe(); |
| 210 | + client.close(); |
| 211 | + reject(new Error(`Approval not found: ${approvalId}`)); |
| 212 | + return; |
| 213 | + } |
| 214 | + if (settled) return; |
| 215 | + settled = true; |
| 216 | + clearTimeout(timer); |
| 217 | + unsubscribe(); |
| 218 | + client.close(); |
| 219 | + resolve(); |
| 220 | + }, |
| 221 | + ); |
| 222 | + }); |
| 223 | + } |
| 224 | + |
| 171 | 225 | /** Forward a tool call to the Convex callback RPC action. */ |
| 172 | 226 | async callTool(toolPath: string, input: unknown, callId?: string): Promise<ToolCallResult> { |
| 173 | 227 | const { callbackInternalSecret, taskId } = this.props; |
| @@ -175,24 +229,42 @@ export class ToolBridge extends WorkerEntrypoint<Env> { · 1 unmodified line |
| 175 | 229 | ? callId |
| 176 | 230 | : `call_${crypto.randomUUID()}`; |
| 177 | 231 | |
| 178 | | − const response = await Result.tryPromise(async () => { |
| 179 | | − const convex = this.createConvexClient(); |
| 180 | | − return await convex.action(api.runtimeCallbacks.handleToolCall, { |
| 181 | | − internalSecret: callbackInternalSecret, |
| 182 | | − runId: taskId, |
| 183 | | − callId: effectiveCallId, |
| 184 | | − toolPath, |
| 185 | | − input, |
| 232 | + while (true) { |
| 233 | + const response = await Result.tryPromise(async () => { |
| 234 | + const convex = this.createConvexClient(); |
| 235 | + return await convex.action(api.runtimeCallbacks.handleToolCall, { |
| 236 | + internalSecret: callbackInternalSecret, |
| 237 | + runId: taskId, |
| 238 | + callId: effectiveCallId, |
| 239 | + toolPath, |
| 240 | + input, |
| 241 | + }); |
| 186 | 242 | }); |
| 187 | | − }); |
| 188 | 243 | |
| 189 | | − if (response.isErr()) { |
| 190 | | − const cause = response.error.cause; |
| 191 | | − const message = cause instanceof Error ? cause.message : String(cause); |
| 192 | | − return { ok: false, kind: "failed", error: `Tool callback failed: ${message}` }; |
| 244 | + if (response.isErr()) { |
| 245 | + const cause = response.error.cause; |
| 246 | + const message = cause instanceof Error ? cause.message : String(cause); |
| 247 | + return { ok: false, kind: "failed", error: `Tool callback failed: ${message}` }; |
| 248 | + } |
| 249 | + |
| 250 | + const result = response.value as ToolCallResult; |
| 251 | + if (!result.ok && result.kind === "pending") { |
| 252 | + if (!result.approvalId) { |
| 253 | + return { ok: false, kind: "failed", error: "Approval pending without approvalId" }; |
| 254 | + } |
| 255 | + |
| 256 | + const approvalId = result.approvalId; |
| 257 | + const wait = await Result.tryPromise(() => this.waitForApprovalUpdate(approvalId)); |
| 258 | + if (wait.isErr()) { |
| 259 | + const cause = wait.error.cause; |
| 260 | + const message = cause instanceof Error ? cause.message : String(cause); |
| 261 | + return { ok: false, kind: "failed", error: `Approval subscription failed: ${message}` }; |
| 262 | + } |
| 263 | + continue; |
| 264 | + } |
| 265 | + |
| 266 | + return result; |
| 193 | 267 | } |
| 194 | | − |
| 195 | | − return response.value as ToolCallResult; |
| 196 | 268 | } |
| 197 | 269 | } |
| 198 | 270 | |