Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions rock/ts-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ const sandbox = new Sandbox({
await sandbox.start();

console.log(`Sandbox ID: ${sandbox.getSandboxId()}`);

// 停止沙箱后重启(restart 仅适用于已停止的沙箱)
await sandbox.stop();
await sandbox.restart();
```

### 执行命令
Expand Down Expand Up @@ -237,6 +241,7 @@ interface SandboxConfig {
cpus: number; // CPU 核心数
autoClearSeconds: number; // 自动清理时间
startupTimeout: number; // 启动超时
autoDeleteSeconds?: number | null; // 沙箱自动删除时间(秒),0 表示立即清理,null/undefined 表示不自动删除
routeKey?: string; // 路由键
extraHeaders?: Record<string, string>; // 额外请求头
}
Expand Down
1 change: 1 addition & 0 deletions rock/ts-sdk/docs/DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const sandbox = new Sandbox({
| 方法 | 说明 |
|------|------|
| `start()` | 启动沙箱 |
| `restart()` | 重启已停止的沙箱 |
| `stop()` | 停止沙箱 |
| `close()` | 关闭并清理资源 |
| `isAlive()` | 检查沙箱是否存活 |
Expand Down
8 changes: 6 additions & 2 deletions rock/ts-sdk/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
}
}
167 changes: 167 additions & 0 deletions rock/ts-sdk/src/sandbox/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
127 changes: 90 additions & 37 deletions rock/ts-sdk/src/sandbox/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {
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}`);
Expand Down Expand Up @@ -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<typeof setTimeout> | 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<null>((_, 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<void> {
Expand Down Expand Up @@ -312,8 +280,11 @@ export class Sandbox extends AbstractSandbox {
}
}

async getStatus(): Promise<SandboxStatusResponse> {
const url = `${this.url}/get_status?sandbox_id=${this.sandboxId}`;
async getStatus(options?: { includeAllStates?: boolean }): Promise<SandboxStatusResponse> {
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<SandboxStatusResponse & { code?: number }>(url, headers);

Expand Down Expand Up @@ -1005,6 +976,88 @@ export class Sandbox extends AbstractSandbox {
});
}

async restart(): Promise<void> {
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<void> {
await sleep(2000);

const startTime = Date.now();
const checkTimeout = 10000;
const checkInterval = 3000;

while (Date.now() - startTime < this.config.startupTimeout * 1000) {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
logger.info(`Checking status... (elapsed: ${Math.round((Date.now() - startTime) / 1000)}s)`);
const statusPromise = this.getStatus(options);
const timeoutPromise = new Promise<null>((_, 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, unknown>): string | null {
if (!status) return null;
for (const [stage, details] of Object.entries(status)) {
if (details && typeof details === 'object') {
const d = details as Record<string, unknown>;
if (d.status === 'failed' || d.status === 'timeout') {
return `${stage}: ${(d.message as string) ?? 'No message provided'}`;
}
}
}
return null;
}

// Close
override async close(): Promise<void> {
await this.stop();
Expand Down
Loading