From 277df1e6be31a1dfa637bb434fbcef8fbed8a5a4 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 10 Nov 2025 14:03:35 -0800 Subject: [PATCH 1/3] cp dines --- src/objects/execution.ts | 29 +++++++++---------- .../object-oriented/execution.test.ts | 10 ++----- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/src/objects/execution.ts b/src/objects/execution.ts index 0649842a5..457e0c995 100644 --- a/src/objects/execution.ts +++ b/src/objects/execution.ts @@ -30,21 +30,20 @@ export class Execution { } } - // Doesn't work as expected, the execution is killed when the stdin is sent. - // /** - // * Send input to the execution's stdin. - // * - // * @param input - The input to send - // * @param options - Request options - // */ - // async sendStdIn(input: string, options?: Core.RequestOptions): Promise { - // await this.client.devboxes.executions.sendStdIn( - // this._devboxId, - // this._executionId, - // { text: input }, - // options, - // ); - // } + /** + * Send input to the execution's stdin. + * + * @param input - The input to send + * @param options - Request options + */ + async sendStdIn(input: string, options?: Core.RequestOptions): Promise { + await this.client.devboxes.executions.sendStdIn( + this._devboxId, + this._executionId, + { text: input }, + options, + ); + } /** * Wait for the execution to complete and return the result. diff --git a/tests/smoketests/object-oriented/execution.test.ts b/tests/smoketests/object-oriented/execution.test.ts index 441566f90..df70b8cbb 100644 --- a/tests/smoketests/object-oriented/execution.test.ts +++ b/tests/smoketests/object-oriented/execution.test.ts @@ -90,7 +90,7 @@ describe('smoketest: object-oriented execution', () => { } }); - test.skip('start execution with stdin enabled', async () => { + test('start execution with stdin enabled', async () => { expect(devbox).toBeDefined(); execution = await devbox.cmd.execAsync({ command: 'cat', @@ -101,14 +101,10 @@ describe('smoketest: object-oriented execution', () => { expect((await execution.getState()).status).toBe('running'); }); - test.skip('send input to execution', async () => { + test('send input to execution', async () => { expect(execution).toBeDefined(); expect((await execution.getState()).status).toBe('running'); - try { - //await execution.sendStdIn('Hello from stdin!\n'); - } catch (error) { - console.error('Error sending input to execution:', error); - } + await execution.sendStdIn('Hello from stdin!\n'); // Wait a bit for the input to be processed await new Promise((resolve) => setTimeout(resolve, 1000)); From f7792b982df217d388fe5f779c302b31ca444d22 Mon Sep 17 00:00:00 2001 From: --replace-all Date: Thu, 24 Sep 2026 11:26:29 -0700 Subject: [PATCH 2/3] fix(execution): add closeStdIn and reject unsuccessful stdin sends - closeStdIn() sends the EOF signal so stdin-consuming commands can exit normally; the smoke test now completes `cat` via EOF instead of kill. - sendStdIn()/closeStdIn() throw RunloopError when the API reports success: false. - Post to send_std_in directly: the generated executions.sendStdIn() treats a `{ signal: 'EOF' }` body as RequestOptions and drops it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/sdk/execution.ts | 51 +++++++++--- tests/objects/execution.test.ts | 77 +++++++++++++++++++ .../object-oriented/execution.test.ts | 9 +-- 3 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 tests/objects/execution.test.ts diff --git a/src/sdk/execution.ts b/src/sdk/execution.ts index 58e7fbd6d..a72304192 100644 --- a/src/sdk/execution.ts +++ b/src/sdk/execution.ts @@ -1,6 +1,8 @@ import { Runloop } from '../index'; import type * as Core from '../core'; -import type { DevboxAsyncExecutionDetailView } from '../resources/devboxes/devboxes'; +import { RunloopError } from '../error'; +import type { DevboxAsyncExecutionDetailView, DevboxSendStdInResult } from '../resources/devboxes/devboxes'; +import type { ExecutionSendStdInParams } from '../resources/devboxes/executions'; import { longPollUntil, resolveLongPollTimeoutMs, type LongPollRequestOptions } from '../lib/polling'; import { ExecutionResult } from './execution-result'; @@ -65,18 +67,49 @@ export class Execution { } /** - * Send input to the execution's stdin. + * Send input to the execution's stdin. The execution must have been started with `attach_stdin: true`. * - * @param input - The input to send - * @param options - Request options + * @example + * ```typescript + * const execution = await devbox.cmd.execAsync('cat', { attach_stdin: true }); + * await execution.sendStdIn('Hello from stdin!\n'); + * await execution.closeStdIn(); + * const result = await execution.result(); + * ``` + * + * @param {string} input - The text to write to stdin + * @param {Core.RequestOptions} [options] - Request options + * @returns {Promise} Promise that resolves once the input has been delivered + * @throws {RunloopError} If the API reports that the input was not delivered */ async sendStdIn(input: string, options?: Core.RequestOptions): Promise { - await this.client.devboxes.executions.sendStdIn( - this._devboxId, - this._executionId, - { text: input }, - options, + await this.sendStdInRequest({ text: input }, options); + } + + /** + * Close the execution's stdin by sending EOF, so commands that read until end of input can finish. + * + * @param {Core.RequestOptions} [options] - Request options + * @returns {Promise} Promise that resolves once EOF has been delivered + * @throws {RunloopError} If the API reports that EOF was not delivered + */ + async closeStdIn(options?: Core.RequestOptions): Promise { + await this.sendStdInRequest({ signal: 'EOF' }, options); + } + + private async sendStdInRequest( + body: ExecutionSendStdInParams, + options?: Core.RequestOptions, + ): Promise { + // executions.sendStdIn() would treat `{ signal: 'EOF' }` as RequestOptions (its `signal` key collides + // with the AbortSignal option) and drop the body, so post to the endpoint directly. + const response = await this.client.post( + `/v1/devboxes/${this._devboxId}/executions/${this._executionId}/send_std_in`, + { ...options, body }, ); + if (!response.success) { + throw new RunloopError(`Failed to send stdin to execution ${this._executionId}`); + } } /** diff --git a/tests/objects/execution.test.ts b/tests/objects/execution.test.ts new file mode 100644 index 000000000..f5b9e16d5 --- /dev/null +++ b/tests/objects/execution.test.ts @@ -0,0 +1,77 @@ +import { Execution } from '../../src/sdk/execution'; +import { RunloopError } from '../../src/error'; +import type { DevboxAsyncExecutionDetailView } from '../../src/resources/devboxes/devboxes'; + +jest.mock('../../src/index'); + +describe('Execution', () => { + let mockClient: any; + let execution: Execution; + + beforeEach(() => { + mockClient = { + post: jest.fn(), + } as any; + + const initialResult: DevboxAsyncExecutionDetailView = { + devbox_id: 'devbox-123', + execution_id: 'exec-456', + status: 'running', + }; + execution = new Execution(mockClient, 'devbox-123', 'exec-456', initialResult); + }); + + describe('sendStdIn', () => { + it('sends text to the execution stdin', async () => { + mockClient.post.mockResolvedValue({ + devbox_id: 'devbox-123', + execution_id: 'exec-456', + success: true, + }); + + await execution.sendStdIn('hello\n', { timeout: 1000 }); + + expect(mockClient.post).toHaveBeenCalledWith( + '/v1/devboxes/devbox-123/executions/exec-456/send_std_in', + { timeout: 1000, body: { text: 'hello\n' } }, + ); + }); + + it('rejects when the API reports the input was not sent', async () => { + mockClient.post.mockResolvedValue({ + devbox_id: 'devbox-123', + execution_id: 'exec-456', + success: false, + }); + + await expect(execution.sendStdIn('hello\n')).rejects.toThrow(RunloopError); + }); + }); + + describe('closeStdIn', () => { + it('sends an EOF signal to the execution stdin', async () => { + mockClient.post.mockResolvedValue({ + devbox_id: 'devbox-123', + execution_id: 'exec-456', + success: true, + }); + + await execution.closeStdIn(); + + expect(mockClient.post).toHaveBeenCalledWith( + '/v1/devboxes/devbox-123/executions/exec-456/send_std_in', + { body: { signal: 'EOF' } }, + ); + }); + + it('rejects when the API reports EOF was not sent', async () => { + mockClient.post.mockResolvedValue({ + devbox_id: 'devbox-123', + execution_id: 'exec-456', + success: false, + }); + + await expect(execution.closeStdIn()).rejects.toThrow(RunloopError); + }); + }); +}); diff --git a/tests/smoketests/object-oriented/execution.test.ts b/tests/smoketests/object-oriented/execution.test.ts index 862921ecf..763bb6d96 100644 --- a/tests/smoketests/object-oriented/execution.test.ts +++ b/tests/smoketests/object-oriented/execution.test.ts @@ -103,15 +103,10 @@ describe('smoketest: object-oriented execution', () => { expect(execution).toBeDefined(); expect((await execution.getState()).status).toBe('running'); await execution.sendStdIn('Hello from stdin!\n'); - - // Wait a bit for the input to be processed - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Kill the execution to get the result - await execution.kill(); + await execution.closeStdIn(); const result = await execution.result(); - expect(result).toBeDefined(); + expect(result.exitCode).toBe(0); const output = await result.stdout(); expect(output).toContain('Hello from stdin!'); From df4aa2c71690c0c996c7d679a6be7fb438d44e89 Mon Sep 17 00:00:00 2001 From: --replace-all Date: Thu, 24 Sep 2026 17:37:03 -0700 Subject: [PATCH 3/3] test(execution): allow queued status in stdin smoke tests execAsync returns before the execution starts, so status can still be queued. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/smoketests/object-oriented/execution.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/smoketests/object-oriented/execution.test.ts b/tests/smoketests/object-oriented/execution.test.ts index 763bb6d96..4f87ea057 100644 --- a/tests/smoketests/object-oriented/execution.test.ts +++ b/tests/smoketests/object-oriented/execution.test.ts @@ -96,12 +96,12 @@ describe('smoketest: object-oriented execution', () => { }); expect(execution).toBeDefined(); expect(execution.executionId).toBeTruthy(); - expect((await execution.getState()).status).toBe('running'); + expect((await execution.getState()).status).not.toBe('completed'); }); test('send input to execution', async () => { expect(execution).toBeDefined(); - expect((await execution.getState()).status).toBe('running'); + expect((await execution.getState()).status).not.toBe('completed'); await execution.sendStdIn('Hello from stdin!\n'); await execution.closeStdIn();