diff --git a/rock/ts-sdk/README.md b/rock/ts-sdk/README.md index 8a1302356c..8c13283b4c 100644 --- a/rock/ts-sdk/README.md +++ b/rock/ts-sdk/README.md @@ -52,6 +52,10 @@ const sandbox = new Sandbox({ await sandbox.start(); console.log(`Sandbox ID: ${sandbox.getSandboxId()}`); + +// 停止沙箱后重启(restart 仅适用于已停止的沙箱) +await sandbox.stop(); +await sandbox.restart(); ``` ### 执行命令 @@ -237,6 +241,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..0d89a5ac2b 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/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" + } } 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..5349f3050d 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,88 @@ 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, operation: 'restart' }); + } + + private async waitForAlive(options?: { includeAllStates?: boolean; operation?: string }): 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); + } + + const operation = options?.operation ?? 'start'; + throw new InternalServerRockError( + `Failed to ${operation} 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.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. 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;