From 4b9399bdc2aa96ab9693e6a3053d7cc66e4da077 Mon Sep 17 00:00:00 2001 From: berstpander Date: Tue, 16 Jun 2026 17:50:34 +0800 Subject: [PATCH 1/4] feat(ts-sdk): add restart() and autoDeleteSeconds to Sandbox Co-Authored-By: Claude Opus 4.6 --- rock/ts-sdk/src/sandbox/client.test.ts | 167 +++++++++++++++++++++++++ rock/ts-sdk/src/sandbox/client.ts | 126 +++++++++++++------ rock/ts-sdk/src/sandbox/config.ts | 1 + 3 files changed, 257 insertions(+), 37 deletions(-) diff --git a/rock/ts-sdk/src/sandbox/client.test.ts b/rock/ts-sdk/src/sandbox/client.test.ts index c47b07ca4b..797897b5f8 100644 --- a/rock/ts-sdk/src/sandbox/client.test.ts +++ b/rock/ts-sdk/src/sandbox/client.test.ts @@ -1246,4 +1246,171 @@ describe('uploadByPath() with uploadMode', () => { expect(result).toBeDefined(); }); +}); + +/** + * restart() tests + * + * These tests verify the restart lifecycle method: + * - Precondition: sandboxId must be set + * - Error code handling (same pattern as start/execute) + * - Polling until alive or timeout + * - Error status detection during polling + */ +describe('restart()', () => { + let sandbox: Sandbox; + let mockPost: jest.Mock; + let mockGet: jest.Mock; + + // Helper: start a sandbox so sandboxId is set + async function startSandbox() { + mockPost.mockResolvedValueOnce({ + data: { + status: 'Success', + result: { + sandbox_id: 'test-id', + host_name: 'test-host', + host_ip: '127.0.0.1', + }, + }, + headers: {}, + }); + mockGet.mockResolvedValueOnce({ + data: { + status: 'Success', + result: { is_alive: true }, + }, + headers: {}, + }); + await sandbox.start(); + } + + beforeEach(() => { + jest.clearAllMocks(); + mockPost = jest.fn(); + mockGet = jest.fn(); + mockedAxios.create = jest.fn().mockReturnValue({ + post: mockPost, + get: mockGet, + }); + + sandbox = new Sandbox({ + image: 'test:latest', + startupTimeout: 2, + }); + }); + + test('should throw when sandboxId is not set', async () => { + await expect(sandbox.restart()).rejects.toThrow('sandbox_id is not set, cannot restart'); + }); + + test('should throw BadRequestRockError when API returns 4xxx code', async () => { + await startSandbox(); + + mockPost.mockResolvedValueOnce({ + data: { + status: 'Failed', + result: { code: Codes.BAD_REQUEST }, + }, + headers: {}, + }); + + await expect(sandbox.restart()).rejects.toThrow(BadRequestRockError); + }); + + test('should throw InternalServerRockError when API returns 5xxx code', async () => { + await startSandbox(); + + mockPost.mockResolvedValueOnce({ + data: { + status: 'Failed', + result: { code: Codes.INTERNAL_SERVER_ERROR }, + }, + headers: {}, + }); + + await expect(sandbox.restart()).rejects.toThrow(InternalServerRockError); + }); + + test('should throw generic Error on non-Success response without error code', async () => { + await startSandbox(); + + mockPost.mockResolvedValueOnce({ + data: { + status: 'Failed', + result: {}, + }, + headers: {}, + }); + + await expect(sandbox.restart()).rejects.toThrow('Failed to restart sandbox'); + }); + + test('should throw InternalServerRockError on startup timeout', async () => { + await startSandbox(); + + // Restart API succeeds + mockPost.mockResolvedValueOnce({ + data: { status: 'Success', result: {} }, + headers: {}, + }); + + // getStatus always returns not alive + mockGet.mockResolvedValue({ + data: { + status: 'Success', + result: { is_alive: false }, + }, + headers: {}, + }); + + await expect(sandbox.restart()).rejects.toThrow(InternalServerRockError); + }, 10000); + + test('should succeed when sandbox becomes alive after restart', async () => { + await startSandbox(); + + // Restart API succeeds + mockPost.mockResolvedValueOnce({ + data: { status: 'Success', result: {} }, + headers: {}, + }); + + // getStatus returns alive on first check + mockGet.mockResolvedValueOnce({ + data: { + status: 'Success', + result: { is_alive: true }, + }, + headers: {}, + }); + + await expect(sandbox.restart()).resolves.toBeUndefined(); + }, 10000); + + test('should throw InternalServerRockError when status has a failed stage', async () => { + await startSandbox(); + + // Restart API succeeds + mockPost.mockResolvedValueOnce({ + data: { status: 'Success', result: {} }, + headers: {}, + }); + + // getStatus returns a status with a failed stage + mockGet.mockResolvedValue({ + data: { + status: 'Success', + result: { + is_alive: false, + status: { + docker_start: { status: 'failed', message: 'container not found' }, + }, + }, + }, + headers: {}, + }); + + await expect(sandbox.restart()).rejects.toThrow(InternalServerRockError); + }, 10000); }); \ No newline at end of file diff --git a/rock/ts-sdk/src/sandbox/client.ts b/rock/ts-sdk/src/sandbox/client.ts index 2bdcc26afc..7155648d86 100644 --- a/rock/ts-sdk/src/sandbox/client.ts +++ b/rock/ts-sdk/src/sandbox/client.ts @@ -215,13 +215,14 @@ export class Sandbox extends AbstractSandbox { const url = `${this.url}/start_async`; const headers = this.buildHeaders(); // Use camelCase - HTTP layer will convert to snake_case - const data = { + const data: Record = { image: this.config.image, autoClearTime: this.config.autoClearSeconds / 60, autoClearTimeMinutes: this.config.autoClearSeconds / 60, startupTimeout: this.config.startupTimeout, memory: this.config.memory, cpus: this.config.cpus, + autoDeleteSeconds: this.config.autoDeleteSeconds, }; logger.debug(`Calling start_async API: ${url}`); @@ -251,40 +252,7 @@ export class Sandbox extends AbstractSandbox { logger.info(`Sandbox ID: ${this.sandboxId}`); // Wait for sandbox to be alive - // First, wait a bit for the backend to process the start request - await sleep(2000); - - const startTime = Date.now(); - const checkTimeout = 10000; // 10s timeout for each status check - const checkInterval = 3000; // 3s between checks - - while (Date.now() - startTime < this.config.startupTimeout * 1000) { - let timeoutId: ReturnType | undefined; - try { - logger.info(`Checking status... (elapsed: ${Math.round((Date.now() - startTime) / 1000)}s)`); - // Use Promise.race to implement timeout for status check - const statusPromise = this.getStatus(); - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error('Status check timeout')), checkTimeout); - }); - - const status = await Promise.race([statusPromise, timeoutPromise]); - if (status && status.isAlive) { - logger.info('Sandbox is alive'); - return; - } - } catch (e) { - // Status check may fail temporarily during startup, continue waiting - logger.debug(`Status check failed (will retry): ${e}`); - } finally { - clearTimeout(timeoutId); - } - await sleep(checkInterval); - } - - throw new InternalServerRockError( - `Failed to start sandbox within ${this.config.startupTimeout}s, sandbox: ${this.toString()}` - ); + await this.waitForAlive(); } async stop(): Promise { @@ -312,8 +280,11 @@ export class Sandbox extends AbstractSandbox { } } - async getStatus(): Promise { - const url = `${this.url}/get_status?sandbox_id=${this.sandboxId}`; + async getStatus(options?: { includeAllStates?: boolean }): Promise { + let url = `${this.url}/get_status?sandbox_id=${this.sandboxId}`; + if (options?.includeAllStates) { + url += '&include_all_states=true'; + } const headers = this.buildHeaders(); const response = await HttpUtils.get(url, headers); @@ -1005,6 +976,87 @@ export class Sandbox extends AbstractSandbox { }); } + async restart(): Promise { + if (!this.sandboxId) { + throw new Error('sandbox_id is not set, cannot restart'); + } + + const url = `${this.url}/restart`; + const headers = this.buildHeaders(); + const data = { sandboxId: this.sandboxId }; + + const response = await HttpUtils.post<{ code?: number }>(url, headers, data); + + logger.debug(`Restart sandbox response: ${JSON.stringify(response)}`); + + if (response.status !== 'Success') { + const code = response.result?.code; + raiseForCode(code, `Failed to restart sandbox: ${JSON.stringify(response)}`); + throw new Error(`Failed to restart sandbox: ${JSON.stringify(response)}`); + } + + await this.waitForAlive({ includeAllStates: true }); + } + + private async waitForAlive(options?: { includeAllStates?: boolean }): Promise { + await sleep(2000); + + const startTime = Date.now(); + const checkTimeout = 10000; + const checkInterval = 3000; + + while (Date.now() - startTime < this.config.startupTimeout * 1000) { + let timeoutId: ReturnType | undefined; + try { + logger.info(`Checking status... (elapsed: ${Math.round((Date.now() - startTime) / 1000)}s)`); + const statusPromise = this.getStatus(options); + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('Status check timeout')), checkTimeout); + }); + + const status = await Promise.race([statusPromise, timeoutPromise]); + if (status && status.isAlive) { + logger.info('Sandbox is alive'); + return; + } + + if (options?.includeAllStates && status) { + const errorMsg = this.parseErrorMessageFromStatus(status.status); + if (errorMsg) { + throw new InternalServerRockError( + `Failed to restart sandbox because ${errorMsg}, sandbox: ${this.toString()}` + ); + } + } + } catch (e) { + if (e instanceof InternalServerRockError) { + throw e; + } + logger.debug(`Status check failed (will retry): ${e}`); + } finally { + clearTimeout(timeoutId); + } + await sleep(checkInterval); + } + + throw new InternalServerRockError( + `Failed to start sandbox within ${this.config.startupTimeout}s, sandbox: ${this.toString()}` + ); + } + + private parseErrorMessageFromStatus(status?: Record): string | null { + if (!status) return null; + for (const [stage, details] of Object.entries(status)) { + if (details && typeof details === 'object') { + const d = details as Record; + if (d.status === 'failed' || d.status === 'timeout') { + return `${stage}: ${(d.message as string) ?? 'No message provided'}`; + } + } + } + return null; + } + // Close override async close(): Promise { await this.stop(); diff --git a/rock/ts-sdk/src/sandbox/config.ts b/rock/ts-sdk/src/sandbox/config.ts index 486af15fec..7b003823d8 100644 --- a/rock/ts-sdk/src/sandbox/config.ts +++ b/rock/ts-sdk/src/sandbox/config.ts @@ -30,6 +30,7 @@ export const SandboxConfigSchema = BaseConfigSchema.extend({ experimentId: z.string().optional(), cluster: z.string().default(() => envVars.ROCK_DEFAULT_CLUSTER), namespace: z.string().optional(), + autoDeleteSeconds: z.number().min(0, 'autoDeleteSeconds must be >= 0').nullish(), }); export type SandboxConfig = z.infer; From 2eac9ff55110118250905d56e92074bede40ce8e Mon Sep 17 00:00:00 2001 From: berstpander Date: Wed, 17 Jun 2026 10:28:42 +0800 Subject: [PATCH 2/4] feat: update ts-sdk version --- rock/ts-sdk/package.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/rock/ts-sdk/package.json b/rock/ts-sdk/package.json index f4af892296..f8e39c4465 100644 --- a/rock/ts-sdk/package.json +++ b/rock/ts-sdk/package.json @@ -1,6 +1,6 @@ { "name": "rl-rock", - "version": "1.3.9", + "version": "1.10.0", "description": "ROCK TypeScript SDK - Sandbox management and agent framework", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -65,5 +65,9 @@ "bugs": { "url": "https://github.com/alibaba/ROCK/issues" }, - "homepage": "https://github.com/alibaba/ROCK#readme" + "homepage": "https://github.com/alibaba/ROCK#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public" + } } From 59db6514391b4664bd3cbebf9a2e6e0b28874bc0 Mon Sep 17 00:00:00 2001 From: berstpander Date: Thu, 18 Jun 2026 11:07:12 +0800 Subject: [PATCH 3/4] fix(ts-sdk): use operation-specific error message in waitForAlive and add autoDeleteSeconds tests Co-Authored-By: Claude Opus 4.6 --- rock/ts-sdk/README.md | 4 ++++ rock/ts-sdk/docs/DEVELOPER_GUIDE.md | 1 + rock/ts-sdk/src/sandbox/client.ts | 7 +++--- rock/ts-sdk/src/sandbox/config.test.ts | 30 ++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/rock/ts-sdk/README.md b/rock/ts-sdk/README.md index 8a1302356c..46474fd823 100644 --- a/rock/ts-sdk/README.md +++ b/rock/ts-sdk/README.md @@ -52,6 +52,9 @@ const sandbox = new Sandbox({ await sandbox.start(); console.log(`Sandbox ID: ${sandbox.getSandboxId()}`); + +// 重启沙箱 +await sandbox.restart(); ``` ### 执行命令 @@ -237,6 +240,7 @@ interface SandboxConfig { cpus: number; // CPU 核心数 autoClearSeconds: number; // 自动清理时间 startupTimeout: number; // 启动超时 + autoDeleteSeconds?: number | null; // 沙箱自动删除时间(秒),0 表示立即清理,null/undefined 表示不自动删除 routeKey?: string; // 路由键 extraHeaders?: Record; // 额外请求头 } diff --git a/rock/ts-sdk/docs/DEVELOPER_GUIDE.md b/rock/ts-sdk/docs/DEVELOPER_GUIDE.md index 5a05cf0c3e..b7d61e3486 100644 --- a/rock/ts-sdk/docs/DEVELOPER_GUIDE.md +++ b/rock/ts-sdk/docs/DEVELOPER_GUIDE.md @@ -76,6 +76,7 @@ const sandbox = new Sandbox({ | 方法 | 说明 | |------|------| | `start()` | 启动沙箱 | +| `restart()` | 重启沙箱(需已启动) | | `stop()` | 停止沙箱 | | `close()` | 关闭并清理资源 | | `isAlive()` | 检查沙箱是否存活 | diff --git a/rock/ts-sdk/src/sandbox/client.ts b/rock/ts-sdk/src/sandbox/client.ts index 7155648d86..5349f3050d 100644 --- a/rock/ts-sdk/src/sandbox/client.ts +++ b/rock/ts-sdk/src/sandbox/client.ts @@ -995,10 +995,10 @@ export class Sandbox extends AbstractSandbox { throw new Error(`Failed to restart sandbox: ${JSON.stringify(response)}`); } - await this.waitForAlive({ includeAllStates: true }); + await this.waitForAlive({ includeAllStates: true, operation: 'restart' }); } - private async waitForAlive(options?: { includeAllStates?: boolean }): Promise { + private async waitForAlive(options?: { includeAllStates?: boolean; operation?: string }): Promise { await sleep(2000); const startTime = Date.now(); @@ -1039,8 +1039,9 @@ export class Sandbox extends AbstractSandbox { await sleep(checkInterval); } + const operation = options?.operation ?? 'start'; throw new InternalServerRockError( - `Failed to start sandbox within ${this.config.startupTimeout}s, sandbox: ${this.toString()}` + `Failed to ${operation} sandbox within ${this.config.startupTimeout}s, sandbox: ${this.toString()}` ); } diff --git a/rock/ts-sdk/src/sandbox/config.test.ts b/rock/ts-sdk/src/sandbox/config.test.ts index 6c531f095a..8bbbdd41ae 100644 --- a/rock/ts-sdk/src/sandbox/config.test.ts +++ b/rock/ts-sdk/src/sandbox/config.test.ts @@ -87,6 +87,36 @@ describe('createSandboxGroupConfig', () => { }); }); +describe('autoDeleteSeconds validation', () => { + test('should default to undefined when not provided', () => { + const config = SandboxConfigSchema.parse({}); + expect(config.autoDeleteSeconds).toBeUndefined(); + }); + + test('should accept null', () => { + const config = SandboxConfigSchema.parse({ autoDeleteSeconds: null }); + expect(config.autoDeleteSeconds).toBeNull(); + }); + + test('should accept 0', () => { + const config = SandboxConfigSchema.parse({ autoDeleteSeconds: 0 }); + expect(config.autoDeleteSeconds).toBe(0); + }); + + test('should accept positive number', () => { + const config = SandboxConfigSchema.parse({ autoDeleteSeconds: 300 }); + expect(config.autoDeleteSeconds).toBe(300); + }); + + test('should reject negative number', () => { + expect(() => SandboxConfigSchema.parse({ autoDeleteSeconds: -1 })).toThrow(); + }); + + test('should reject non-number value', () => { + expect(() => SandboxConfigSchema.parse({ autoDeleteSeconds: 'abc' })).toThrow(); + }); +}); + describe('Config uses envVars (not hardcoded)', () => { // These tests verify that config schemas read defaults from envVars // rather than using hardcoded values. From f4b74bc99838ebfcd898e3774332ba5d447d6530 Mon Sep 17 00:00:00 2001 From: berstpander Date: Thu, 18 Jun 2026 14:10:46 +0800 Subject: [PATCH 4/4] docs(ts-sdk): fix restart() usage to reflect STOPPED state requirement Co-Authored-By: Claude Opus 4.6 --- rock/ts-sdk/README.md | 3 ++- rock/ts-sdk/docs/DEVELOPER_GUIDE.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/rock/ts-sdk/README.md b/rock/ts-sdk/README.md index 46474fd823..8c13283b4c 100644 --- a/rock/ts-sdk/README.md +++ b/rock/ts-sdk/README.md @@ -53,7 +53,8 @@ await sandbox.start(); console.log(`Sandbox ID: ${sandbox.getSandboxId()}`); -// 重启沙箱 +// 停止沙箱后重启(restart 仅适用于已停止的沙箱) +await sandbox.stop(); await sandbox.restart(); ``` diff --git a/rock/ts-sdk/docs/DEVELOPER_GUIDE.md b/rock/ts-sdk/docs/DEVELOPER_GUIDE.md index b7d61e3486..0d89a5ac2b 100644 --- a/rock/ts-sdk/docs/DEVELOPER_GUIDE.md +++ b/rock/ts-sdk/docs/DEVELOPER_GUIDE.md @@ -76,7 +76,7 @@ const sandbox = new Sandbox({ | 方法 | 说明 | |------|------| | `start()` | 启动沙箱 | -| `restart()` | 重启沙箱(需已启动) | +| `restart()` | 重启已停止的沙箱 | | `stop()` | 停止沙箱 | | `close()` | 关闭并清理资源 | | `isAlive()` | 检查沙箱是否存活 |