Skip to content

Commit 7f3561d

Browse files
dines-rltode-rlclaude
authored
feat(execute): adding sendStdIn to devbox exec (#647)
Co-authored-by: --replace-all <tony@runloop.ai> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 1731eb2 commit 7f3561d

3 files changed

Lines changed: 132 additions & 32 deletions

File tree

‎src/sdk/execution.ts‎

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Runloop } from '../index';
22
import type * as Core from '../core';
3-
import type { DevboxAsyncExecutionDetailView } from '../resources/devboxes/devboxes';
3+
import { RunloopError } from '../error';
4+
import type { DevboxAsyncExecutionDetailView, DevboxSendStdInResult } from '../resources/devboxes/devboxes';
5+
import type { ExecutionSendStdInParams } from '../resources/devboxes/executions';
46
import { longPollUntil, resolveLongPollTimeoutMs, type LongPollRequestOptions } from '../lib/polling';
57
import { ExecutionResult } from './execution-result';
68

@@ -64,21 +66,51 @@ export class Execution {
6466
}
6567
}
6668

67-
// Doesn't work as expected, the execution is killed when the stdin is sent.
68-
// /**
69-
// * Send input to the execution's stdin.
70-
// *
71-
// * @param input - The input to send
72-
// * @param options - Request options
73-
// */
74-
// async sendStdIn(input: string, options?: Core.RequestOptions): Promise<void> {
75-
// await this.client.devboxes.executions.sendStdIn(
76-
// this._devboxId,
77-
// this._executionId,
78-
// { text: input },
79-
// options,
80-
// );
81-
// }
69+
/**
70+
* Send input to the execution's stdin. The execution must have been started with `attach_stdin: true`.
71+
*
72+
* @example
73+
* ```typescript
74+
* const execution = await devbox.cmd.execAsync('cat', { attach_stdin: true });
75+
* await execution.sendStdIn('Hello from stdin!\n');
76+
* await execution.closeStdIn();
77+
* const result = await execution.result();
78+
* ```
79+
*
80+
* @param {string} input - The text to write to stdin
81+
* @param {Core.RequestOptions} [options] - Request options
82+
* @returns {Promise<void>} Promise that resolves once the input has been delivered
83+
* @throws {RunloopError} If the API reports that the input was not delivered
84+
*/
85+
async sendStdIn(input: string, options?: Core.RequestOptions): Promise<void> {
86+
await this.sendStdInRequest({ text: input }, options);
87+
}
88+
89+
/**
90+
* Close the execution's stdin by sending EOF, so commands that read until end of input can finish.
91+
*
92+
* @param {Core.RequestOptions} [options] - Request options
93+
* @returns {Promise<void>} Promise that resolves once EOF has been delivered
94+
* @throws {RunloopError} If the API reports that EOF was not delivered
95+
*/
96+
async closeStdIn(options?: Core.RequestOptions): Promise<void> {
97+
await this.sendStdInRequest({ signal: 'EOF' }, options);
98+
}
99+
100+
private async sendStdInRequest(
101+
body: ExecutionSendStdInParams,
102+
options?: Core.RequestOptions,
103+
): Promise<void> {
104+
// executions.sendStdIn() would treat `{ signal: 'EOF' }` as RequestOptions (its `signal` key collides
105+
// with the AbortSignal option) and drop the body, so post to the endpoint directly.
106+
const response = await this.client.post<unknown, DevboxSendStdInResult>(
107+
`/v1/devboxes/${this._devboxId}/executions/${this._executionId}/send_std_in`,
108+
{ ...options, body },
109+
);
110+
if (!response.success) {
111+
throw new RunloopError(`Failed to send stdin to execution ${this._executionId}`);
112+
}
113+
}
82114

83115
/**
84116
* Wait for the execution to complete and return the result.

‎tests/objects/execution.test.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Execution } from '../../src/sdk/execution';
2+
import { RunloopError } from '../../src/error';
3+
import type { DevboxAsyncExecutionDetailView } from '../../src/resources/devboxes/devboxes';
4+
5+
jest.mock('../../src/index');
6+
7+
describe('Execution', () => {
8+
let mockClient: any;
9+
let execution: Execution;
10+
11+
beforeEach(() => {
12+
mockClient = {
13+
post: jest.fn(),
14+
} as any;
15+
16+
const initialResult: DevboxAsyncExecutionDetailView = {
17+
devbox_id: 'devbox-123',
18+
execution_id: 'exec-456',
19+
status: 'running',
20+
};
21+
execution = new Execution(mockClient, 'devbox-123', 'exec-456', initialResult);
22+
});
23+
24+
describe('sendStdIn', () => {
25+
it('sends text to the execution stdin', async () => {
26+
mockClient.post.mockResolvedValue({
27+
devbox_id: 'devbox-123',
28+
execution_id: 'exec-456',
29+
success: true,
30+
});
31+
32+
await execution.sendStdIn('hello\n', { timeout: 1000 });
33+
34+
expect(mockClient.post).toHaveBeenCalledWith(
35+
'/v1/devboxes/devbox-123/executions/exec-456/send_std_in',
36+
{ timeout: 1000, body: { text: 'hello\n' } },
37+
);
38+
});
39+
40+
it('rejects when the API reports the input was not sent', async () => {
41+
mockClient.post.mockResolvedValue({
42+
devbox_id: 'devbox-123',
43+
execution_id: 'exec-456',
44+
success: false,
45+
});
46+
47+
await expect(execution.sendStdIn('hello\n')).rejects.toThrow(RunloopError);
48+
});
49+
});
50+
51+
describe('closeStdIn', () => {
52+
it('sends an EOF signal to the execution stdin', async () => {
53+
mockClient.post.mockResolvedValue({
54+
devbox_id: 'devbox-123',
55+
execution_id: 'exec-456',
56+
success: true,
57+
});
58+
59+
await execution.closeStdIn();
60+
61+
expect(mockClient.post).toHaveBeenCalledWith(
62+
'/v1/devboxes/devbox-123/executions/exec-456/send_std_in',
63+
{ body: { signal: 'EOF' } },
64+
);
65+
});
66+
67+
it('rejects when the API reports EOF was not sent', async () => {
68+
mockClient.post.mockResolvedValue({
69+
devbox_id: 'devbox-123',
70+
execution_id: 'exec-456',
71+
success: false,
72+
});
73+
74+
await expect(execution.closeStdIn()).rejects.toThrow(RunloopError);
75+
});
76+
});
77+
});

‎tests/smoketests/object-oriented/execution.test.ts‎

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -89,33 +89,24 @@ describe('smoketest: object-oriented execution', () => {
8989
}
9090
});
9191

92-
test.skip('start execution with stdin enabled', async () => {
92+
test('start execution with stdin enabled', async () => {
9393
expect(devbox).toBeDefined();
9494
execution = await devbox.cmd.execAsync('cat', {
9595
attach_stdin: true,
9696
});
9797
expect(execution).toBeDefined();
9898
expect(execution.executionId).toBeTruthy();
99-
expect((await execution.getState()).status).toBe('running');
99+
expect((await execution.getState()).status).not.toBe('completed');
100100
});
101101

102-
test.skip('send input to execution', async () => {
102+
test('send input to execution', async () => {
103103
expect(execution).toBeDefined();
104-
expect((await execution.getState()).status).toBe('running');
105-
try {
106-
//await execution.sendStdIn('Hello from stdin!\n');
107-
} catch (error) {
108-
console.error('Error sending input to execution:', error);
109-
}
110-
111-
// Wait a bit for the input to be processed
112-
await new Promise((resolve) => setTimeout(resolve, 1000));
113-
114-
// Kill the execution to get the result
115-
await execution.kill();
104+
expect((await execution.getState()).status).not.toBe('completed');
105+
await execution.sendStdIn('Hello from stdin!\n');
106+
await execution.closeStdIn();
116107

117108
const result = await execution.result();
118-
expect(result).toBeDefined();
109+
expect(result.exitCode).toBe(0);
119110

120111
const output = await result.stdout();
121112
expect(output).toContain('Hello from stdin!');

0 commit comments

Comments
 (0)