mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
fix(sandbox): retain submitted build IDs on polling failure
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<void>((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");
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user