From 3e8df956fe8cabc251334bea2eb70af0c28ec963 Mon Sep 17 00:00:00 2001 From: chenanran555 Date: Thu, 10 Sep 2026 11:21:05 +0800 Subject: [PATCH] fix(sandbox): retain submitted build IDs on polling failure --- .../commands/src/commands/sandbox/template.ts | 41 ++++++- .../tests/e2e/sandbox-base-url.e2e.test.ts | 85 ++++++++++++++ packages/commands/tests/sandbox.test.ts | 107 ++++++++++++++++++ 3 files changed, 227 insertions(+), 6 deletions(-) diff --git a/packages/commands/src/commands/sandbox/template.ts b/packages/commands/src/commands/sandbox/template.ts index 7bfcaf6..cee17fb 100644 --- a/packages/commands/src/commands/sandbox/template.ts +++ b/packages/commands/src/commands/sandbox/template.ts @@ -355,12 +355,41 @@ async function emitTemplateMutationResult(options: { ), options.endpoint, ).toString(); - const build = await waitForTemplateBuild( - options.client, - options.settings, - buildEndpoint, - options.pollInterval ?? 5, - ); + let build: TemplateBuildStatus; + try { + build = await waitForTemplateBuild( + options.client, + options.settings, + buildEndpoint, + options.pollInterval ?? 5, + ); + } catch (error) { + const hint = + `Submitted build / 已提交的构建: templateID=${options.response.templateID}, buildID=${options.response.buildID}.\n` + + "Check this build with sandbox template build-status before submitting another build. / 请先通过 sandbox template build-status 查询本次构建,再决定是否重新提交。"; + if (error instanceof BailianError) { + throw new BailianError( + error.message, + error.exitCode, + [error.hint, hint].filter(Boolean).join("\n"), + { + api: error.api, + rawResponse: error.rawResponse, + cause: error.cause, + }, + ); + } + // Leave transport errors intact so runtime retains timeout/network classification. + const recovery = { + templateID: options.response.templateID, + buildID: options.response.buildID, + hint, + }; + process.stderr.write( + format === "json" ? `${JSON.stringify(recovery, null, 2)}\n\n` : `${hint}\n`, + ); + throw error; + } if (options.settings.quiet) emitBare(displayValue(options.response.templateID)); else emitResult({ template: options.response, build }, format); } diff --git a/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts b/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts index ab0f592..5e1b58d 100644 --- a/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts +++ b/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts @@ -231,3 +231,88 @@ describe("e2e: Sandbox custom gateway transport", () => { }, ); }); + +describe("e2e: Sandbox submitted build recovery", () => { + test.each([ + { action: "create", output: "json", failure: "timeout", exitCode: 5 }, + { action: "update", output: "text", failure: "service", exitCode: 1 }, + { action: "create", output: "json", failure: "network", exitCode: 6 }, + ])( + "$action retains IDs after a $failure in $output output", + async ({ action, output, failure, exitCode }) => { + let submissionCount = 0; + const server = createServer((request, response) => { + request.resume(); + response.setHeader("content-type", "application/json"); + if (request.method !== "GET") { + submissionCount += 1; + response.end( + JSON.stringify({ + templateID: "template-recovery", + buildID: "build-recovery", + buildStatus: "building", + }), + ); + } else if (failure === "network") { + request.socket.destroy(); + } else if (failure === "service") { + response.writeHead(503); + response.end( + JSON.stringify({ + code: 100005, + message: "original service failure", + requestID: "request-recovery", + }), + ); + } else { + response.end(JSON.stringify({ status: "building" })); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a local TCP server."); + const args = + action === "create" + ? ["--name", "recovery", "--cpu-count", "1", "--memory-mb", "2048"] + : ["--template-id", "template-recovery", "--description", "updated"]; + const result = await runCommandE2e( + ROUTES, + ["sandbox", "template", action, ...args, "--timeout", "1", "--quiet", "--output", output], + makeConfigEnv({ + api_key: "sk-recovery-test", + base_url: `http://127.0.0.1:${address.port}`, + }), + ); + expect(result.exitCode, result.stderr).toBe(exitCode); + expect(result.stdout).toBe(""); + expect(submissionCount).toBe(1); + expect(result.stderr).toContain("templateID=template-recovery, buildID=build-recovery"); + expect(result.stderr).toContain("sandbox template build-status"); + if (output === "json") { + const diagnostics = result.stderr + .trim() + .split(/\n\s*\n/) + .map((diagnostic) => JSON.parse(diagnostic)); + expect(diagnostics.at(-1)).toMatchObject({ error: { code: exitCode } }); + if (failure === "timeout") { + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].error.message).toBe("Template build polling timed out."); + } else { + expect(diagnostics[0]).toMatchObject({ + templateID: "template-recovery", + buildID: "build-recovery", + }); + expect(diagnostics.at(-1).error.message).toContain("Network request failed"); + } + } else { + expect(result.stderr).toContain("original service failure"); + expect(result.stderr).toContain("HTTP 503 (100005)"); + expect(result.stderr).toContain("request-recovery"); + } + }, + ); +}); diff --git a/packages/commands/tests/sandbox.test.ts b/packages/commands/tests/sandbox.test.ts index 2c7f813..9356301 100644 --- a/packages/commands/tests/sandbox.test.ts +++ b/packages/commands/tests/sandbox.test.ts @@ -445,6 +445,113 @@ describe("Sandbox template build polling", () => { expect(requestJson).not.toHaveBeenCalled(); }); + test.each([ + { name: "create", command: sandboxTemplateCreate }, + { name: "update", command: sandboxTemplateUpdate }, + ])("$name preserves submitted IDs and the original polling failure", async ({ command }) => { + const submission = { templateID: "template-test", buildID: "build-test" }; + const serviceCause = new Error("original cause"); + const serviceError = new BailianError("service message", ExitCode.GENERAL, "original hint", { + api: { httpStatus: 503, apiCode: "Unavailable", requestId: "request-test" }, + rawResponse: "original response", + cause: serviceCause, + }); + const scenarios = [ + { + timeout: 0, + response: { status: "building" }, + exitCode: ExitCode.TIMEOUT, + message: "Template build polling timed out.", + }, + { + timeout: 30, + response: { status: "error", reason: { message: "image download failed" } }, + exitCode: ExitCode.GENERAL, + message: "image download failed", + }, + { + timeout: 30, + error: serviceError, + exitCode: ExitCode.GENERAL, + message: serviceError.message, + }, + ]; + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + for (const scenario of scenarios) { + const requestJson = vi.fn().mockResolvedValueOnce(submission); + if (scenario.error) requestJson.mockRejectedValue(scenario.error); + else requestJson.mockResolvedValue(scenario.response); + const operation = command.run({ + identity: { binName: "bl" }, + settings: { ...SETTINGS, timeout: scenario.timeout }, + flags: { + workspaceId: "ws-test", + templateId: "template-test", + name: "python", + cpuCount: 1, + memoryMb: 2048, + async: false, + }, + client: { requestJson, url: createUrlResolver() }, + } as never); + await expect(operation).rejects.toMatchObject({ + message: scenario.message, + exitCode: scenario.exitCode, + hint: expect.stringContaining("templateID=template-test, buildID=build-test"), + }); + if (scenario.error) { + await expect(operation).rejects.toMatchObject({ + api: serviceError.api, + rawResponse: serviceError.rawResponse, + cause: serviceCause, + hint: expect.stringContaining("original hint"), + }); + } + expect(requestJson.mock.calls.filter(([request]) => request.method !== "GET")).toHaveLength( + 1, + ); + } + expect(stdout).not.toHaveBeenCalled(); + }); + + test.each(["json", "text"] as const)( + "transport failures retain their identity and emit build recovery in %s diagnostics", + async (output) => { + const failure = new TypeError("fetch failed", { cause: { code: "ECONNRESET" } }); + const requestJson = vi + .fn() + .mockResolvedValueOnce({ templateID: "template-test", buildID: "build-test" }) + .mockRejectedValue(failure); + let stderr = ""; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + await expect( + sandboxTemplateCreate.run({ + identity: { binName: "bl" }, + settings: { ...SETTINGS, output }, + flags: { + workspaceId: "ws-test", + name: "python", + cpuCount: 1, + memoryMb: 2048, + async: false, + }, + client: { requestJson, url: createUrlResolver() }, + } as never), + ).rejects.toBe(failure); + if (output === "json") { + expect(JSON.parse(stderr)).toMatchObject({ + templateID: "template-test", + buildID: "build-test", + }); + } else { + expect(stderr).toContain("templateID=template-test, buildID=build-test"); + } + }, + ); + test("async template creation returns after the submit request", async () => { let stdout = ""; vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {