Skip to content

Commit 1cd1956

Browse files
Merge remote-tracking branch 'origin/main' into next
2 parents 33624ce + 52a9275 commit 1cd1956

14 files changed

Lines changed: 1070 additions & 55 deletions

src/sdk/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export { Execution } from './execution';
77
export { ExecutionResult } from './execution-result';
88
export { Scorer } from './scorer';
99
export { NetworkPolicy } from './network-policy';
10+
export { ScenarioRun } from './scenario-run';

src/sdk/scenario-run.ts

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import { Runloop } from '../index';
2+
import type * as Core from '../core';
3+
import type { ScenarioRunView, ScoringContractResultView } from '../resources/scenarios/scenarios';
4+
import type { DevboxView } from '../resources/devboxes/devboxes';
5+
import { PollingOptions } from '../lib/polling';
6+
import { Devbox } from './devbox';
7+
import * as fs from 'fs';
8+
import * as path from 'path';
9+
10+
/**
11+
* Object-oriented interface for working with Scenario Runs.
12+
*
13+
* @category Scenario
14+
*
15+
* @remarks
16+
* ## Overview
17+
*
18+
* The `ScenarioRun` class provides a high-level API for managing scenario runs.
19+
* A scenario run represents a single execution of a scenario on a devbox, including
20+
* the ability to interact with the devbox, score the run, and retrieve results.
21+
*
22+
* ## Quickstart
23+
*
24+
* ScenarioRuns are typically obtained from a Scenario's `run()` or `runAsync()` methods:
25+
*
26+
* ```typescript
27+
* import { RunloopSDK } from '@runloop/api-client';
28+
*
29+
* const runloop = new RunloopSDK();
30+
* const scenario = runloop.scenario.fromId('scenario-123');
31+
* const run = await scenario.run({ run_name: 'my-run' });
32+
*
33+
* // Access the devbox and execute your agent to solve the scenario
34+
* const devbox = run.devbox;
35+
* await devbox.cmd.exec('python /home/user/agent/main.py');
36+
*
37+
* // Score and complete the run
38+
* await run.scoreAndComplete();
39+
* const score = await run.getScore();
40+
* ```
41+
*/
42+
export class ScenarioRun {
43+
private client: Runloop;
44+
private _id: string;
45+
private _devboxId: string;
46+
private _devbox: Devbox | null = null;
47+
48+
/**
49+
* @private
50+
*/
51+
constructor(client: Runloop, id: string, devboxId: string) {
52+
this.client = client;
53+
this._id = id;
54+
this._devboxId = devboxId;
55+
}
56+
57+
/**
58+
* Get the scenario run ID.
59+
* @returns {string} The scenario run ID
60+
*/
61+
get id(): string {
62+
return this._id;
63+
}
64+
65+
/**
66+
* Get the associated devbox ID.
67+
* @returns {string} The devbox ID
68+
*/
69+
get devboxId(): string {
70+
return this._devboxId;
71+
}
72+
73+
/**
74+
* Get the devbox instance for this scenario run.
75+
*
76+
* This property provides lazy-loaded access to the devbox associated with
77+
* this scenario run. Use this to interact with the devbox environment
78+
* during the scenario execution.
79+
*
80+
* @example
81+
* ```typescript
82+
* const run = await scenario.run();
83+
* const devbox = run.devbox;
84+
* await devbox.cmd.exec('npm test');
85+
* ```
86+
*
87+
* @returns {Devbox} The devbox instance
88+
*/
89+
get devbox(): Devbox {
90+
if (!this._devbox) {
91+
this._devbox = Devbox.fromId(this.client, this._devboxId);
92+
}
93+
return this._devbox;
94+
}
95+
96+
/**
97+
* Get the complete scenario run data from the API.
98+
*
99+
* @example
100+
* ```typescript
101+
* const info = await run.getInfo();
102+
* console.log(`Run state: ${info.state}`);
103+
* console.log(`Score: ${info.scoring_contract_result?.score}`);
104+
* ```
105+
*
106+
* @param {Core.RequestOptions} [options] - Request options
107+
* @returns {Promise<ScenarioRunView>} The scenario run data
108+
*/
109+
async getInfo(options?: Core.RequestOptions): Promise<ScenarioRunView> {
110+
return this.client.scenarios.runs.retrieve(this._id, options);
111+
}
112+
113+
/**
114+
* Wait for the scenario environment (devbox) to be ready.
115+
*
116+
* Blocks until the devbox reaches running state. Call this after using
117+
* `scenario.runAsync()` to ensure the devbox is ready for interaction.
118+
*
119+
* @example
120+
* ```typescript
121+
* const run = await scenario.runAsync();
122+
* await run.awaitEnvReady();
123+
* // Devbox is now ready
124+
* await run.devbox.cmd.exec('ls -la');
125+
* ```
126+
*
127+
* @param {Core.RequestOptions & { polling?: Partial<PollingOptions<DevboxView>> }} [options] - Request options with optional polling configuration
128+
* @returns {Promise<ScenarioRunView>} The scenario run data after environment is ready
129+
*/
130+
async awaitEnvReady(
131+
options?: Core.RequestOptions & { polling?: Partial<PollingOptions<DevboxView>> },
132+
): Promise<ScenarioRunView> {
133+
await this.client.devboxes.awaitRunning(this._devboxId, options);
134+
return this.getInfo(options);
135+
}
136+
137+
/**
138+
* Submit the scenario run for scoring.
139+
*
140+
* This triggers the scoring process using the scenario's scoring contract.
141+
* The scoring runs asynchronously; use `awaitScored()` or `scoreAndAwait()`
142+
* to wait for scoring to complete.
143+
*
144+
* @example
145+
* ```typescript
146+
* await run.score();
147+
* // Scoring is now in progress
148+
* const result = await run.awaitScored();
149+
* ```
150+
*
151+
* @param {Core.RequestOptions} [options] - Request options
152+
* @returns {Promise<ScenarioRunView>} The updated scenario run data
153+
*/
154+
async score(options?: Core.RequestOptions): Promise<ScenarioRunView> {
155+
return this.client.scenarios.runs.score(this._id, options);
156+
}
157+
158+
/**
159+
* Wait for the scenario run to be scored.
160+
*
161+
* Blocks until scoring is complete. Call this after `score()` to wait
162+
* for the scoring process to finish.
163+
*
164+
* @example
165+
* ```typescript
166+
* await run.score();
167+
* const result = await run.awaitScored();
168+
* console.log(`Final score: ${result.scoring_contract_result?.score}`);
169+
* ```
170+
*
171+
* @param {Core.RequestOptions & { polling?: Partial<PollingOptions<ScenarioRunView>> }} [options] - Request options with optional polling configuration
172+
* @returns {Promise<ScenarioRunView>} The scored scenario run data
173+
*/
174+
async awaitScored(
175+
options?: Core.RequestOptions & { polling?: Partial<PollingOptions<ScenarioRunView>> },
176+
): Promise<ScenarioRunView> {
177+
return this.client.scenarios.runs.awaitScored(this._id, options);
178+
}
179+
180+
/**
181+
* Submit for scoring and wait for completion.
182+
*
183+
* This is a convenience method that combines `score()` and `awaitScored()`.
184+
*
185+
* @example
186+
* ```typescript
187+
* // Agent has finished working...
188+
* const result = await run.scoreAndAwait();
189+
* console.log(`Final score: ${result.scoring_contract_result?.score}`);
190+
* ```
191+
*
192+
* @param {Core.RequestOptions & { polling?: Partial<PollingOptions<ScenarioRunView>> }} [options] - Request options with optional polling configuration
193+
* @returns {Promise<ScenarioRunView>} The scored scenario run data
194+
*/
195+
async scoreAndAwait(
196+
options?: Core.RequestOptions & { polling?: Partial<PollingOptions<ScenarioRunView>> },
197+
): Promise<ScenarioRunView> {
198+
return this.client.scenarios.runs.scoreAndAwait(this._id, options);
199+
}
200+
201+
/**
202+
* Score the run, wait for scoring, then complete and shutdown.
203+
*
204+
* This is a convenience method that scores the scenario run, waits for
205+
* scoring to finish, then completes the run and shuts down the devbox.
206+
* This is the recommended way to finish a scenario run.
207+
*
208+
* @example
209+
* ```typescript
210+
* // Agent has finished working...
211+
* const result = await run.scoreAndComplete();
212+
* console.log(`Final score: ${result.scoring_contract_result?.score}`);
213+
* // Devbox has been shut down
214+
* ```
215+
*
216+
* @param {Core.RequestOptions & { polling?: Partial<PollingOptions<ScenarioRunView>> }} [options] - Request options with optional polling configuration
217+
* @returns {Promise<ScenarioRunView>} The completed scenario run data with scoring results
218+
*/
219+
async scoreAndComplete(
220+
options?: Core.RequestOptions & { polling?: Partial<PollingOptions<ScenarioRunView>> },
221+
): Promise<ScenarioRunView> {
222+
return this.client.scenarios.runs.scoreAndComplete(this._id, options);
223+
}
224+
225+
/**
226+
* Complete the scenario run and shutdown the devbox.
227+
*
228+
* Call this after scoring to finalize the run. The devbox will be
229+
* shut down and resources released. Note: The run must be in a
230+
* scored state before calling complete. Use `cancel()` to end a
231+
* run without scoring, or `scoreAndComplete()` to score and complete
232+
* in one operation.
233+
*
234+
* @example
235+
* ```typescript
236+
* // Score first, then complete
237+
* await run.scoreAndAwait();
238+
* await run.complete();
239+
* ```
240+
*
241+
* @param {Core.RequestOptions} [options] - Request options
242+
* @returns {Promise<ScenarioRunView>} The final scenario run data
243+
*/
244+
async complete(options?: Core.RequestOptions): Promise<ScenarioRunView> {
245+
return this.client.scenarios.runs.complete(this._id, options);
246+
}
247+
248+
/**
249+
* Cancel the scenario run and shutdown the devbox.
250+
*
251+
* Use this to abort a running scenario. The devbox will be shut down
252+
* and the run marked as canceled.
253+
*
254+
* @example
255+
* ```typescript
256+
* // Abort the scenario
257+
* await run.cancel();
258+
* ```
259+
*
260+
* @param {Core.RequestOptions} [options] - Request options
261+
* @returns {Promise<ScenarioRunView>} The canceled scenario run data
262+
*/
263+
async cancel(options?: Core.RequestOptions): Promise<ScenarioRunView> {
264+
return this.client.scenarios.runs.cancel(this._id, options);
265+
}
266+
267+
/**
268+
* Download all logs for this scenario run to a file.
269+
*
270+
* Downloads a zip archive containing all logs from the scenario run's
271+
* associated devbox. This is useful for debugging and analysis.
272+
*
273+
* @example
274+
* ```typescript
275+
* await run.scoreAndComplete();
276+
* await run.downloadLogs('./scenario-logs.zip');
277+
* ```
278+
*
279+
* @param {string} filePath - Path where the zip file will be written
280+
* @param {Core.RequestOptions} [options] - Request options
281+
* @returns {Promise<void>}
282+
*/
283+
async downloadLogs(filePath: string, options?: Core.RequestOptions): Promise<void> {
284+
// Validate the parent directory exists and is writable
285+
const parentDir = path.dirname(filePath);
286+
try {
287+
await fs.promises.access(parentDir, fs.constants.W_OK);
288+
} catch {
289+
throw new Error(
290+
`Cannot write to ${filePath}: parent directory '${parentDir}' does not exist or is not writable`,
291+
);
292+
}
293+
294+
const response = await this.client.scenarios.runs.downloadLogs(this._id, options);
295+
296+
// Get the response as an ArrayBuffer and write to file
297+
const arrayBuffer = await response.arrayBuffer();
298+
const buffer = Buffer.from(arrayBuffer);
299+
300+
await fs.promises.writeFile(filePath, buffer);
301+
}
302+
303+
/**
304+
* Get the scoring result for this run.
305+
*
306+
* Returns null if the run has not been scored yet. Always makes an API
307+
* call to retrieve the current scoring result.
308+
*
309+
* @example
310+
* ```typescript
311+
* await run.scoreAndAwait();
312+
* const score = await run.getScore();
313+
* if (score) {
314+
* console.log(`Total score: ${score.score}`);
315+
* for (const fn of score.scoring_function_results) {
316+
* console.log(` ${fn.scoring_function_name}: ${fn.score}`);
317+
* }
318+
* }
319+
* ```
320+
*
321+
* @param {Core.RequestOptions} [options] - Request options
322+
* @returns {Promise<ScoringContractResultView | null>} The scoring result or null if not yet scored
323+
*/
324+
async getScore(options?: Core.RequestOptions): Promise<ScoringContractResultView | null> {
325+
const info = await this.getInfo(options);
326+
return info.scoring_contract_result ?? null;
327+
}
328+
}

0 commit comments

Comments
 (0)