diff --git a/README.md b/README.md index c470298e..66b3e5b4 100644 --- a/README.md +++ b/README.md @@ -202,12 +202,14 @@ rli mcp install # Install Runloop MCP server configurat ```bash rli axon list # List active axons +rli axon events # List events for an axon ``` ### Scenario Commands (alias: `scn`) ```bash rli scenario info # Display scenario definition details +rli scenario list # List scenario runs ``` ### Benchmark-job Commands (alias: `bmj`) diff --git a/package.json b/package.json index 5a6a01a6..81e2e8af 100644 --- a/package.json +++ b/package.json @@ -88,8 +88,8 @@ "ink-link": "5.0.0", "ink-spinner": "5.0.0", "ink-text-input": "6.0.0", - "nanotar": "^0.3.0", "react": "19.2.0", + "tar-stream": "3.1.7", "yaml": "2.8.3", "zustand": "5.0.10" }, @@ -128,6 +128,7 @@ "@types/jest": "29.5.14", "@types/node": "22.19.7", "@types/react": "19.2.10", + "@types/tar-stream": "3.1.4", "@typescript-eslint/eslint-plugin": "8.54.0", "@typescript-eslint/parser": "8.54.0", "esbuild": "0.27.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 465c79fa..16789115 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,12 +86,12 @@ importers: ink-text-input: specifier: 6.0.0 version: 6.0.0(ink@6.6.0(@types/react@19.2.10)(react@19.2.0))(react@19.2.0) - nanotar: - specifier: ^0.3.0 - version: 0.3.0 react: specifier: 19.2.0 version: 19.2.0 + tar-stream: + specifier: 3.1.7 + version: 3.1.7 yaml: specifier: 2.8.3 version: 2.8.3 @@ -114,6 +114,9 @@ importers: '@types/react': specifier: 19.2.10 version: 19.2.10 + '@types/tar-stream': + specifier: 3.1.4 + version: 3.1.4 '@typescript-eslint/eslint-plugin': specifier: 8.54.0 version: 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) @@ -827,6 +830,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/tar-stream@3.1.4': + resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==} + '@types/tinycolor2@1.4.6': resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==} @@ -1029,6 +1035,14 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1057,6 +1071,14 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true @@ -1493,6 +1515,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -1530,6 +1555,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -2361,9 +2389,6 @@ packages: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - nanotar@0.3.0: - resolution: {integrity: sha512-Kv2JYYiCzt16Kt5QwAc9BFG89xfPNBx+oQL4GQXD9nLqPkZBiNaqaCWtwnbk/q7UVsTYevvM1b0UF8zmEI4pCg==} - natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -2760,6 +2785,9 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -2845,6 +2873,9 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar@7.5.13: resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} engines: {node: '>=18'} @@ -2857,6 +2888,9 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} @@ -4006,6 +4040,10 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/tar-stream@3.1.4': + dependencies: + '@types/node': 22.19.7 + '@types/tinycolor2@1.4.6': {} '@types/wrap-ansi@3.0.0': {} @@ -4255,6 +4293,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + b4a@1.8.1: {} + babel-jest@29.7.0(@babel/core@7.28.6): dependencies: '@babel/core': 7.28.6 @@ -4312,6 +4352,8 @@ snapshots: balanced-match@1.0.2: {} + bare-events@2.8.2: {} + baseline-browser-mapping@2.9.19: {} body-parser@2.2.2: @@ -4841,6 +4883,12 @@ snapshots: event-target-shim@5.0.1: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + eventsource-parser@3.0.6: {} eventsource@3.0.7: @@ -4915,6 +4963,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -5920,8 +5970,6 @@ snapshots: mute-stream@1.0.0: {} - nanotar@0.3.0: {} - natural-compare@1.4.0: {} negotiator@1.0.0: {} @@ -6328,6 +6376,15 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-length@4.0.2: dependencies: char-regex: 1.0.2 @@ -6433,6 +6490,15 @@ snapshots: tagged-tag@1.0.0: {} + tar-stream@3.1.7: + dependencies: + b4a: 1.8.1 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + tar@7.5.13: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -6452,6 +6518,12 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + tinycolor2@1.6.0: {} tinyglobby@0.2.15: diff --git a/src/commands/agent/create.ts b/src/commands/agent/create.ts index 6252ffc7..cc34048c 100644 --- a/src/commands/agent/create.ts +++ b/src/commands/agent/create.ts @@ -15,7 +15,6 @@ interface CreateOptions { ref?: string; objectId?: string; setupCommands?: string[]; - public?: boolean; output?: string; } @@ -105,7 +104,6 @@ export async function createAgentCommand( const agent = await createAgent({ name: options.name, ...(options.agentVersion ? { version: options.agentVersion } : {}), - ...(options.public ? { is_public: true } : {}), source: { type: sourceType, [sourceType]: sourceOptions }, }); diff --git a/src/commands/agent/show.ts b/src/commands/agent/show.ts index 62aa28d4..be8a6197 100644 --- a/src/commands/agent/show.ts +++ b/src/commands/agent/show.ts @@ -40,7 +40,7 @@ export async function showAgentCommand( ): Promise { try { const agent = await resolveAgent(idOrName); - output(agent, { format: options.output, defaultFormat: "text" }); + output(agent, { format: options.output, defaultFormat: "json" }); } catch (error) { outputError("Failed to get agent", error); } diff --git a/src/commands/axon/events.ts b/src/commands/axon/events.ts new file mode 100644 index 00000000..c033f85e --- /dev/null +++ b/src/commands/axon/events.ts @@ -0,0 +1,26 @@ +/** + * List axon events command + */ + +import { listAxonEvents as listAxonEventsService } from "../../services/axonService.js"; +import { output, outputError, parseLimit } from "../../utils/output.js"; + +interface EventsOptions { + limit?: string; + output?: string; +} + +export async function listAxonEventsCommand( + axonId: string, + options: EventsOptions, +): Promise { + try { + const parsed = parseLimit(options.limit); + const limit = parsed === Infinity ? 50 : parsed; + const result = await listAxonEventsService(axonId, { limit }); + + output(result.events, { format: options.output, defaultFormat: "json" }); + } catch (error) { + outputError("Failed to get axon events", error); + } +} diff --git a/src/commands/axon/list.tsx b/src/commands/axon/list.tsx index 9e98685b..50280725 100644 --- a/src/commands/axon/list.tsx +++ b/src/commands/axon/list.tsx @@ -5,7 +5,6 @@ import React from "react"; import { Box, Text, useInput, useApp } from "ink"; import figures from "figures"; -import chalk from "chalk"; import { formatTimeAgo } from "../../components/ResourceListView.js"; import { listActiveAxons, type Axon } from "../../services/axonService.js"; import { output, outputError, parseLimit } from "../../utils/output.js"; @@ -34,45 +33,6 @@ interface ListOptions { const CLI_PAGE_SIZE = 100; -function printTable(axons: Axon[]): void { - if (axons.length === 0) { - console.log(chalk.dim("No active axons found")); - return; - } - - const COL_ID = 34; - const COL_NAME = 28; - const COL_CREATED = 12; - - const header = - "ID".padEnd(COL_ID) + - " " + - "NAME".padEnd(COL_NAME) + - " " + - "CREATED".padEnd(COL_CREATED); - console.log(chalk.bold(header)); - console.log(chalk.dim("─".repeat(header.length))); - - for (const axon of axons) { - const id = - axon.id.length > COL_ID ? axon.id.slice(0, COL_ID - 1) + "…" : axon.id; - const nameRaw = axon.name ?? ""; - const name = - nameRaw.length > COL_NAME - ? nameRaw.slice(0, COL_NAME - 1) + "…" - : nameRaw; - const created = formatTimeAgo(axon.created_at_ms); - console.log( - `${id.padEnd(COL_ID)} ${name.padEnd(COL_NAME)} ${created.padEnd(COL_CREATED)}`, - ); - } - - console.log(); - console.log( - chalk.dim(`${axons.length} axon${axons.length !== 1 ? "s" : ""}`), - ); -} - export async function listAxonsCommand(options: ListOptions): Promise { try { const maxResults = parseLimit(options.limit); @@ -87,14 +47,6 @@ export async function listAxonsCommand(options: ListOptions): Promise { startingAfter: options.startingAfter, }); axons = page; - if (format === "text" && hasMore && axons.length > 0) { - console.log( - chalk.dim( - "More results may be available; use --starting-after with the last ID to continue.", - ), - ); - console.log(); - } } else { const all: Axon[] = []; let cursor: string | undefined; diff --git a/src/commands/benchmark-job/list.ts b/src/commands/benchmark-job/list.ts index 86d1dec2..764e2ce7 100644 --- a/src/commands/benchmark-job/list.ts +++ b/src/commands/benchmark-job/list.ts @@ -2,10 +2,8 @@ * List benchmark jobs command */ -import chalk from "chalk"; import { listBenchmarkJobs, - listBenchmarkRunScenarioRuns, type BenchmarkJob, } from "../../services/benchmarkJobService.js"; import { output, outputError } from "../../utils/output.js"; @@ -29,204 +27,6 @@ const VALID_STATES = [ const PAGE_SIZE = 100; -// --- Time formatting --- - -function formatTimeAgo(timestampMs: number): string { - const diffMs = Date.now() - timestampMs; - const diffMinutes = Math.floor(diffMs / 60_000); - const diffHours = Math.floor(diffMs / 3_600_000); - const diffDays = Math.floor(diffMs / 86_400_000); - - if (diffMinutes < 1) return "just now"; - if (diffMinutes < 60) return `${diffMinutes}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays < 7) return `${diffDays}d ago`; - - const date = new Date(timestampMs); - return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); -} - -// --- Job stats aggregation --- - -interface JobStats { - done: number; - total: number; - errors: number; - avgScore: number | null; -} - -// Scenario run states that count as finished -const SCENARIO_DONE_STATES = new Set([ - "completed", - "failed", - "canceled", - "timeout", - "error", -]); - -async function aggregateJobStats(job: BenchmarkJob): Promise { - const outcomes = job.benchmark_outcomes || []; - const scenarioCount = job.job_spec?.scenario_ids?.length || 0; - const agentCount = job.job_spec?.agent_configs?.length || 1; - const total = scenarioCount * agentCount; - - let done = 0; - let errors = 0; - let scoreSum = 0; - let scoreCount = 0; - - // Count from completed benchmark runs - for (const outcome of outcomes) { - done += outcome.n_completed + outcome.n_failed + outcome.n_timeout; - errors += outcome.n_failed + outcome.n_timeout; - if (outcome.average_score !== undefined && outcome.average_score !== null) { - scoreSum += outcome.average_score; - scoreCount++; - } - } - - // Count finished scenarios from in-progress benchmark runs - const inProgressRuns = job.in_progress_runs || []; - if (inProgressRuns.length > 0) { - const runResults = await Promise.all( - inProgressRuns.map((run) => - listBenchmarkRunScenarioRuns(run.benchmark_run_id), - ), - ); - for (const scenarioRuns of runResults) { - let runScoreSum = 0; - let runScoreCount = 0; - for (const sr of scenarioRuns) { - const state = sr.state?.toLowerCase() || ""; - if (SCENARIO_DONE_STATES.has(state)) { - done++; - if (state !== "completed") { - errors++; - } - const score = sr.scoring_contract_result?.score; - if (score !== undefined && score !== null) { - runScoreSum += score; - runScoreCount++; - } - } - } - if (runScoreCount > 0) { - scoreSum += runScoreSum / runScoreCount; - scoreCount++; - } - } - } - - return { - done, - total: total || done, - errors, - avgScore: scoreCount > 0 ? scoreSum / scoreCount : null, - }; -} - -// --- Status coloring --- - -function colorState(state: string): string { - switch (state) { - case "running": - return chalk.yellow(state); - case "completed": - return chalk.green(state); - case "failed": - case "timeout": - return chalk.red(state); - case "cancelled": - return chalk.dim(state); - case "initializing": - case "queued": - return chalk.cyan(state); - default: - return state; - } -} - -// --- Table printing --- - -// Fixed column widths (excluding NAME which is dynamic) -const COL_ID = 30; -const COL_STARTED = 10; -const COL_STATUS = 14; -const COL_DONE = 9; -const COL_ERRORS = 8; -const COL_SCORE = 7; -const FIXED_WIDTH = - COL_ID + COL_STARTED + COL_STATUS + COL_DONE + COL_ERRORS + COL_SCORE + 6; // 6 for spacing - -function truncate(str: string, maxLen: number): string { - if (str.length <= maxLen) return str; - return str.slice(0, maxLen - 1) + "…"; -} - -async function printTable(jobs: BenchmarkJob[]): Promise { - if (jobs.length === 0) { - console.log(chalk.dim("No benchmark jobs found")); - return; - } - - const termWidth = process.stdout.columns || 120; - const nameWidth = Math.max(10, termWidth - FIXED_WIDTH); - - // Header - const header = - "ID".padEnd(COL_ID) + - " " + - "NAME".padEnd(nameWidth) + - " " + - "STARTED".padEnd(COL_STARTED) + - " " + - "STATUS".padEnd(COL_STATUS) + - " " + - "DONE".padStart(COL_DONE) + - " " + - "ERRORS".padStart(COL_ERRORS) + - " " + - "SCORE".padStart(COL_SCORE); - console.log(chalk.bold(header)); - console.log(chalk.dim("─".repeat(Math.min(header.length, termWidth)))); - - // Rows - for (const job of jobs) { - const stats = await aggregateJobStats(job); - - const id = truncate(job.id, COL_ID).padEnd(COL_ID); - const name = truncate(job.name || "", nameWidth).padEnd(nameWidth); - const started = formatTimeAgo(job.create_time_ms).padEnd(COL_STARTED); - const status = colorState(job.state || "unknown"); - // Pad status accounting for chalk invisible chars - const statusRaw = job.state || "unknown"; - const statusPad = " ".repeat(Math.max(0, COL_STATUS - statusRaw.length)); - - const doneStr = `${stats.done}/${stats.total}`.padStart(COL_DONE); - const errorsStr = String(stats.errors).padStart(COL_ERRORS); - const coloredErrors = - stats.errors > 0 ? chalk.red(errorsStr) : chalk.dim(errorsStr); - - let scoreStr: string; - if (stats.avgScore !== null) { - const pct = Math.round(stats.avgScore * 100); - const pctStr = `${pct}%`.padStart(COL_SCORE); - scoreStr = pct >= 50 ? chalk.green(pctStr) : chalk.yellow(pctStr); - } else { - scoreStr = chalk.dim("N/A".padStart(COL_SCORE)); - } - - console.log( - `${id} ${name} ${started} ${status}${statusPad} ${doneStr} ${coloredErrors} ${scoreStr}`, - ); - } - - console.log(); - console.log(chalk.dim(`${jobs.length} job${jobs.length !== 1 ? "s" : ""}`)); -} - -// --- Pagination and filtering --- - async function fetchJobs( cutoffMs: number | null, statusFilter: Set | null, @@ -300,13 +100,7 @@ export async function listBenchmarkJobsCommand( // Sort ascending by create_time_ms (oldest first, most recent at bottom) jobs.sort((a, b) => a.create_time_ms - b.create_time_ms); - // Output - const format = options.output || "text"; - if (format !== "text") { - output(jobs, { format, defaultFormat: "json" }); - } else { - await printTable(jobs); - } + output(jobs, { format: options.output, defaultFormat: "json" }); } catch (error) { outputError("Failed to list benchmark jobs", error); } diff --git a/src/commands/benchmark-job/run.ts b/src/commands/benchmark-job/run.ts index a0e17f34..1d403c72 100644 --- a/src/commands/benchmark-job/run.ts +++ b/src/commands/benchmark-job/run.ts @@ -10,6 +10,7 @@ import { } from "../../services/benchmarkService.js"; import { getClient } from "../../utils/client.js"; import { output, outputError } from "../../utils/output.js"; +import { parseMetadata } from "../../utils/metadata.js"; // Secret name prefix for benchmark job secrets const SECRET_PREFIX = "BMJ_"; @@ -53,6 +54,7 @@ interface RunOptions { nAttempts?: string; nConcurrentTrials?: string; timeoutMultiplier?: string; + metadata?: string[]; output?: string; } @@ -336,6 +338,7 @@ export async function runBenchmarkJob(options: RunOptions) { scenarioIds: options.scenarios, agentConfigs, orchestratorConfig, + metadata: options.metadata ? parseMetadata(options.metadata) : undefined, }); // Output result diff --git a/src/commands/benchmark-job/summary.ts b/src/commands/benchmark-job/summary.ts index 814e6225..07df4797 100644 --- a/src/commands/benchmark-job/summary.ts +++ b/src/commands/benchmark-job/summary.ts @@ -2,298 +2,21 @@ * Summary benchmark job command */ -import chalk from "chalk"; -import { - getBenchmarkJob, - listBenchmarkRunScenarioRuns, - type BenchmarkJob, -} from "../../services/benchmarkJobService.js"; +import { getBenchmarkJob } from "../../services/benchmarkJobService.js"; import { output, outputError } from "../../utils/output.js"; -import { - isJobCompleted, - fetchAllRunsProgress, - type RunProgress, -} from "./progress.js"; interface SummaryOptions { output?: string; extended?: boolean; } -// Format percentage -function formatPercent(count: number, total: number): string { - if (total === 0) return "0.0%"; - return ((count / total) * 100).toFixed(1) + "%"; -} - -// Format a single run's progress line -function formatRunProgressLine(progress: RunProgress): string { - // Format agent/model label - let label = progress.agentName; - if (progress.modelName) { - label += `:${progress.modelName}`; - } - if (label.length > 30) { - label = label.slice(0, 27) + "..."; - } - - // Use expectedTotal if available, otherwise use started count - const total = - progress.expectedTotal > 0 ? progress.expectedTotal : progress.started; - - // Check if this run is complete - const isComplete = progress.finished === total && total > 0; - - if (isComplete) { - // Completed run - show green checkmark and final score - const scoreStr = - progress.avgScore !== null - ? `score: ${(progress.avgScore * 100).toFixed(0)}%` - : "done"; - return `${chalk.green("✓")} ${label.padEnd(30)} ${chalk.green(scoreStr)}`; - } - - // In-progress run - const parts: string[] = []; - parts.push(`${progress.finished}/${total} complete`); - - if (progress.running > 0) { - parts.push(`${progress.running} running`); - } - - if (progress.scoring > 0) { - parts.push(`${progress.scoring} scoring`); - } - - if (progress.avgScore !== null) { - parts.push(`score: ${(progress.avgScore * 100).toFixed(0)}%`); - } - - return ` ${label.padEnd(30)} ${parts.join(", ")}`; -} - -// Print progress for in-progress jobs -function printProgress(progressList: RunProgress[]): void { - if (progressList.length === 0) { - console.log(chalk.dim("No runs in progress")); - return; - } - - console.log(chalk.bold("Progress:")); - - for (const progress of progressList) { - console.log(formatRunProgressLine(progress)); - } -} - -// Print current status (brief) -async function printStatus(job: BenchmarkJob): Promise { - const jobName = job.name || job.id; - const state = job.state || "unknown"; - - console.log(`Job: ${jobName}`); - console.log(`ID: ${job.id}`); - console.log(`State: ${state}`); - - if (!isJobCompleted(state)) { - // Fetch and show progress for in-progress runs - console.log(); - const progressList = await fetchAllRunsProgress( - job, - listBenchmarkRunScenarioRuns, - ); - printProgress(progressList); - } -} - -// Calculate stats for completed scenario outcomes -interface CompletedStats { - total: number; - passed: number; - failedZero: number; - failedError: number; -} - -function calculateCompletedStats( - outcomes: NonNullable< - NonNullable[0]["scenario_outcomes"] - >, -): CompletedStats { - let passed = 0; - let failedZero = 0; - let failedError = 0; - - for (const outcome of outcomes) { - const state = outcome.state?.toUpperCase(); - const score = outcome.score; - - if (state === "COMPLETED") { - if (score === 1.0) { - passed++; - } else { - failedZero++; - } - } else { - failedError++; - } - } - - return { - total: outcomes.length, - passed, - failedZero, - failedError, - }; -} - -// Print results table for completed jobs -function printResultsTable(job: BenchmarkJob, extended: boolean = false): void { - const outcomes = job.benchmark_outcomes || []; - - if (outcomes.length === 0) { - if (job.failure_reason) { - console.log(chalk.red(`Job failed: ${job.failure_reason}`)); - } else { - console.log(chalk.yellow("No benchmark outcomes found")); - } - return; - } - - // Header - console.log(); - console.log(chalk.bold("Benchmark Job Results")); - console.log(chalk.dim(`Job ID: ${job.id}`)); - if (job.name) { - console.log(chalk.dim(`Name: ${job.name}`)); - } - console.log(chalk.dim(`State: ${job.state}`)); - console.log(); - - // Table header - const agentCol = "Agent / Model".padEnd(40); - const passedCol = "Passed".padStart(10); - const failedCol = "Failed (0.0)".padStart(14); - const errorCol = "Failed (error)".padStart(16); - const totalCol = "Total".padStart(8); - - console.log( - chalk.bold(agentCol + passedCol + failedCol + errorCol + totalCol), - ); - console.log(chalk.dim("-".repeat(88))); - - // Print each agent's results - for (const outcome of outcomes) { - const agentName = outcome.agent_name || "unknown"; - const modelName = outcome.model_name || "default"; - const scenarioOutcomes = outcome.scenario_outcomes || []; - - const stats = calculateCompletedStats(scenarioOutcomes); - - // Format agent/model column - let agentModelStr = agentName; - if (modelName && modelName !== "default") { - agentModelStr += ` (${modelName})`; - } - if (agentModelStr.length > 38) { - agentModelStr = agentModelStr.slice(0, 35) + "..."; - } - const agentModelCol = agentModelStr.padEnd(40); - - // Format stats columns with colors - const passedStr = formatPercent(stats.passed, stats.total); - const failedZeroStr = formatPercent(stats.failedZero, stats.total); - const failedErrorStr = formatPercent(stats.failedError, stats.total); - - const passedColored = - stats.passed > 0 - ? chalk.green(passedStr.padStart(10)) - : chalk.dim(passedStr.padStart(10)); - - const failedZeroColored = - stats.failedZero > 0 - ? chalk.yellow(failedZeroStr.padStart(14)) - : chalk.dim(failedZeroStr.padStart(14)); - - const failedErrorColored = - stats.failedError > 0 - ? chalk.red(failedErrorStr.padStart(16)) - : chalk.dim(failedErrorStr.padStart(16)); - - const totalColStr = String(stats.total).padStart(8); - - console.log( - agentModelCol + - passedColored + - failedZeroColored + - failedErrorColored + - chalk.dim(totalColStr), - ); - - // Print individual scenario results underneath (indented) when extended - if (extended) { - for (const scenario of scenarioOutcomes) { - const scenarioName = - scenario.scenario_name || - scenario.scenario_definition_id || - "unknown"; - const state = scenario.state || "unknown"; - const score = scenario.score; - - let statusIcon: string; - let statusColor: typeof chalk.green; - - if (state.toUpperCase() === "COMPLETED") { - if (score === 1.0) { - statusIcon = chalk.green("\u2713"); // checkmark - statusColor = chalk.green; - } else { - statusIcon = chalk.yellow("\u2717"); // X - statusColor = chalk.yellow; - } - } else { - statusIcon = chalk.red("!"); - statusColor = chalk.red; - } - - const scenarioNameTrunc = - scenarioName.length > 50 - ? scenarioName.slice(0, 47) + "..." - : scenarioName; - - const scoreStr = - score !== undefined && score !== null - ? `score=${score.toFixed(1)}` - : state; - - console.log( - chalk.dim(" ") + - statusIcon + - " " + - chalk.dim(scenarioNameTrunc.padEnd(52)) + - statusColor(scoreStr), - ); - } - } - } - - console.log(); -} - export async function summaryBenchmarkJob( id: string, options: SummaryOptions = {}, ) { try { const job = await getBenchmarkJob(id); - const isComplete = isJobCompleted(job.state); - - if (options.output && options.output !== "text") { - output(job, { format: options.output, defaultFormat: "json" }); - } else if (isComplete) { - printResultsTable(job, options.extended); - } else { - await printStatus(job); - } + output(job, { format: options.output, defaultFormat: "json" }); } catch (error) { outputError("Failed to get benchmark job summary", error); } diff --git a/src/commands/blueprint/create.ts b/src/commands/blueprint/create.ts index b2036810..5d552dd0 100644 --- a/src/commands/blueprint/create.ts +++ b/src/commands/blueprint/create.ts @@ -1,13 +1,11 @@ -/** - * Create blueprint command - */ - import { readFile } from "fs/promises"; import { getClient } from "../../utils/client.js"; import { output, outputError } from "../../utils/output.js"; +import { parseMetadata } from "../../utils/metadata.js"; interface CreateBlueprintOptions { - name: string; + name?: string; + base?: string; dockerfile?: string; dockerfilePath?: string; systemSetupCommands?: string[]; @@ -20,46 +18,66 @@ interface CreateBlueprintOptions { output?: string; } -// Parse metadata from key=value format -function parseMetadata(metadata: string[]): Record { - const result: Record = {}; - for (const item of metadata) { - const eqIndex = item.indexOf("="); - if (eqIndex === -1) { - throw new Error(`Invalid metadata format: ${item}. Expected key=value`); - } - const key = item.substring(0, eqIndex); - const value = item.substring(eqIndex + 1); - result[key] = value; - } - return result; -} - export async function createBlueprint(options: CreateBlueprintOptions) { try { const client = getClient(); - // Read dockerfile from file if path is provided + let baseParams: Record = {}; + let baseName: string | undefined; + let baseMetadata: Record | undefined; + + if (options.base) { + let source; + if (options.base.startsWith("bpt_")) { + source = await client.blueprints.retrieve(options.base); + } else { + const result = await client.blueprints.list({ name: options.base }); + const blueprints = result.blueprints || []; + if (blueprints.length === 0) { + return outputError(`Base blueprint not found: ${options.base}`); + } + source = + blueprints.find((b) => b.name === options.base) || blueprints[0]; + } + + const { + name: _n, + dockerfile: _d, + base_blueprint_id: _b, + base_blueprint_name: _bn, + ...inheritedParams + } = (source.parameters ?? {}) as unknown as Record; + baseParams = { + ...inheritedParams, + base_blueprint_id: source.id, + }; + baseName = source.name || "blueprint"; + baseMetadata = source.metadata as Record | undefined; + } + + const name = options.name ?? (baseName ? baseName + "-copy" : undefined); + if (!name) { + return outputError("--name is required (or use --base to derive one)"); + } + let dockerfileContents = options.dockerfile; if (options.dockerfilePath) { dockerfileContents = await readFile(options.dockerfilePath, "utf-8"); } - // Parse user parameters let userParameters = undefined; if (options.user && options.root) { - outputError("Only one of --user or --root can be specified"); + return outputError("Only one of --user or --root can be specified"); } else if (options.user) { const [username, uid] = options.user.split(":"); if (!username || !uid) { - outputError("User must be in format 'username:uid'"); + return outputError("User must be in format 'username:uid'"); } userParameters = { username, uid: parseInt(uid) }; } else if (options.root) { userParameters = { username: "root", uid: 0 }; } - // Build launch parameters const launchParameters: Record = {}; if (options.resources) { launchParameters.resource_size_request = options.resources; @@ -76,22 +94,27 @@ export async function createBlueprint(options: CreateBlueprintOptions) { launchParameters.user_parameters = userParameters; } - // Parse metadata if provided const metadata = options.metadata ? parseMetadata(options.metadata) - : undefined; + : (baseMetadata ?? undefined); + + const overrides: Record = { name }; + if (dockerfileContents !== undefined) { + overrides.dockerfile = dockerfileContents; + delete baseParams.base_blueprint_id; + } + if (options.systemSetupCommands) + overrides.system_setup_commands = options.systemSetupCommands; + if (Object.keys(launchParameters).length > 0) + overrides.launch_parameters = launchParameters; + if (metadata !== undefined) overrides.metadata = metadata; + + const createParams = { ...baseParams, ...overrides }; - const blueprint = await client.blueprints.create({ - name: options.name, - dockerfile: dockerfileContents, - system_setup_commands: options.systemSetupCommands, - launch_parameters: launchParameters as Parameters< - typeof client.blueprints.create - >[0]["launch_parameters"], - metadata, - }); + const blueprint = await client.blueprints.create( + createParams as unknown as Parameters[0], + ); - // Default: output JSON output(blueprint, { format: options.output, defaultFormat: "json" }); } catch (error) { outputError("Failed to create blueprint", error); diff --git a/src/commands/blueprint/from-dockerfile.ts b/src/commands/blueprint/from-dockerfile.ts index 61b17c9d..6f1b5af4 100644 --- a/src/commands/blueprint/from-dockerfile.ts +++ b/src/commands/blueprint/from-dockerfile.ts @@ -13,6 +13,7 @@ import { getClient } from "../../utils/client.js"; import { output, outputError } from "../../utils/output.js"; import { StorageObject } from "@runloop/api-client/sdk"; import type { BlueprintCreateParams } from "@runloop/api-client/resources/blueprints"; +import { parseMetadata } from "../../utils/metadata.js"; interface FromDockerfileOptions { name: string; @@ -30,21 +31,6 @@ interface FromDockerfileOptions { output?: string; } -// Parse metadata from key=value format -function parseMetadata(metadata: string[]): Record { - const result: Record = {}; - for (const item of metadata) { - const eqIndex = item.indexOf("="); - if (eqIndex === -1) { - throw new Error(`Invalid metadata format: ${item}. Expected key=value`); - } - const key = item.substring(0, eqIndex); - const value = item.substring(eqIndex + 1); - result[key] = value; - } - return result; -} - // Helper to check if we should show progress function shouldShowProgress(options: FromDockerfileOptions): boolean { return !options.output || options.output === "text"; diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 746f2a5b..065c04ee 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -31,6 +31,7 @@ import { } from "../../hooks/useInputHandler.js"; import { useNavigation } from "../../store/navigationStore.js"; import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js"; +import { listBlueprints as listBlueprintsService } from "../../services/blueprintService.js"; const DEFAULT_PAGE_SIZE = 10; @@ -42,10 +43,17 @@ type OperationType = | null; // Local interface for blueprint data used in this component +type BlueprintStatus = + | "queued" + | "provisioning" + | "building" + | "failed" + | "build_complete"; + interface BlueprintListItem { id: string; name?: string; - status?: string; + status?: BlueprintStatus; create_time_ms?: number; [key: string]: unknown; } @@ -78,6 +86,7 @@ const ListBlueprintsUI = ({ const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); const [selectedIndex, setSelectedIndex] = React.useState(0); const [showPopup, setShowPopup] = React.useState(false); + const [showPublic, setShowPublic] = React.useState(false); const { navigate } = useNavigation(); // Search state @@ -86,8 +95,8 @@ const ListBlueprintsUI = ({ onSearchClear: () => setSelectedIndex(0), }); - // Calculate overhead for viewport height - const overhead = 13 + search.getSearchOverhead(); + // Calculate overhead for viewport height (14 = breadcrumb + tab bar + search + table header + stats + nav tips + borders) + const overhead = 14 + search.getSearchOverhead(); const { viewportHeight, terminalWidth } = useViewportHeight({ overhead, minHeight: 5, @@ -118,50 +127,26 @@ const ListBlueprintsUI = ({ startingAt?: string; includeTotalCount?: boolean; }) => { - const client = getClient(); - const pageBlueprints: BlueprintListItem[] = []; - - // Build query params - const queryParams: Record = { + const result = await listBlueprintsService({ limit: params.limit, - // Only request total_count on first page (expensive for backend) - include_total_count: params.includeTotalCount === true, - }; - if (params.startingAt) { - queryParams.starting_after = params.startingAt; - } - if (search.submittedSearchQuery) { - queryParams.search = search.submittedSearchQuery; - } - - // Fetch ONE page only - const page = (await client.blueprints.list( - queryParams, - )) as unknown as BlueprintsCursorIDPage & { - total_count?: number; - }; - - // Extract data and create defensive copies - if (page.blueprints && Array.isArray(page.blueprints)) { - page.blueprints.forEach((b: BlueprintListItem) => { - pageBlueprints.push({ - id: b.id, - name: b.name, - status: b.status, - create_time_ms: b.create_time_ms, - }); - }); - } + startingAfter: params.startingAt, + search: search.submittedSearchQuery || undefined, + publicOnly: showPublic, + includeTotalCount: params.includeTotalCount, + }); - const result = { - items: pageBlueprints, - hasMore: page.has_more || false, - totalCount: page.total_count, + return { + items: result.blueprints.map((b) => ({ + id: b.id, + name: b.name, + status: b.status, + create_time_ms: b.create_time_ms, + })), + hasMore: result.hasMore, + totalCount: result.totalCount, }; - - return result; }, - [search.submittedSearchQuery], + [showPublic, search.submittedSearchQuery], ); // Use the shared pagination hook @@ -187,7 +172,7 @@ const ListBlueprintsUI = ({ !executingOperation && !showDeleteConfirm && !search.searchMode, - deps: [search.submittedSearchQuery], + deps: [search.submittedSearchQuery, showPublic], }); // Memoize columns array @@ -325,11 +310,7 @@ const ListBlueprintsUI = ({ icon: figures.info, }); - if ( - blueprint && - (blueprint.status === "build_complete" || - blueprint.status === "building_complete") - ) { + if (blueprint && blueprint.status === "build_complete") { operations.push({ key: "create_devbox", label: "Create Devbox from Blueprint", @@ -602,8 +583,7 @@ const ListBlueprintsUI = ({ c: () => { if ( selectedBlueprintItem && - (selectedBlueprintItem.status === "build_complete" || - selectedBlueprintItem.status === "building_complete") + selectedBlueprintItem.status === "build_complete" ) { setShowPopup(false); setSelectedBlueprint(selectedBlueprintItem); @@ -657,6 +637,10 @@ const ListBlueprintsUI = ({ right: goToNextPage, p: goToPrevPage, left: goToPrevPage, + tab: () => { + setShowPublic((prev) => !prev); + setSelectedIndex(0); + }, enter: () => { if (selectedBlueprintItem) { navigate("blueprint-detail", { @@ -882,6 +866,27 @@ const ListBlueprintsUI = ({ <> + {/* Tab bar */} + + + {!showPublic ? figures.pointer : " "} Custom + + + + {showPublic ? figures.pointer : " "} Public + + + {" "} + (Tab to switch) + + + {/* Search bar */} blueprint.id} selectedIndex={selectedIndex} - title={`blueprints[${totalCount}]`} + title={`blueprints[${totalCount}] ${showPublic ? "(public)" : "(custom)"}`} columns={blueprintColumns} emptyState={ - {figures.info} No blueprints found. Try: rli blueprint create + {figures.info} No {showPublic ? "public " : ""}blueprints found + {!showPublic ? ". Try: rli blueprint create" : ""} } /> @@ -1003,6 +1009,7 @@ const ListBlueprintsUI = ({ }, { key: "Enter", label: "Details" }, { key: "a", label: "Actions" }, + { key: "Tab", label: "Switch tab" }, { key: "o", label: "Browser" }, { key: "/", label: "Search" }, { key: "Esc", label: "Back" }, diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index dc8f2945..3c2f70d1 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -569,6 +569,8 @@ const ListObjectsUI = ({ } else if (input === "a" && selectedObjectItem) { setShowPopup(true); setSelectedOperation(0); + } else if (input === "c") { + navigate("object-create"); } else if (input === "/") { search.enterSearchMode(); } else if (key.escape) { @@ -852,6 +854,7 @@ const ListObjectsUI = ({ }, { key: "Enter", label: "Details" }, { key: "a", label: "Actions" }, + { key: "c", label: "Create" }, { key: "/", label: "Search" }, { key: "Esc", label: "Back" }, ]} diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index 9e8d648d..f87afa57 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -2,12 +2,15 @@ * Upload object command */ -import { lstat, readFile, readdir } from "fs/promises"; +import { lstat, readFile, readdir, readlink, stat } from "fs/promises"; import { dirname, extname, relative, resolve, sep } from "path"; -import { createTar, createTarGzip } from "nanotar"; -import type { TarFileInput } from "nanotar"; +import { createGzip } from "zlib"; +import { pipeline } from "stream/promises"; +import tar from "tar-stream"; +import type { Headers } from "tar-stream"; import { getClient } from "../../utils/client.js"; import { output, outputError } from "../../utils/output.js"; +import { parseMetadata } from "../../utils/metadata.js"; import { processUtils } from "../../utils/processUtils.js"; interface UploadObjectOptions { @@ -15,6 +18,7 @@ interface UploadObjectOptions { name: string; contentType?: string; public?: boolean; + metadata?: string[]; output?: string; } @@ -35,10 +39,16 @@ const CONTENT_TYPE_MAP: Record = { ".tar.gz": "tgz", }; +interface TarEntry { + header: Headers; + data?: Buffer; +} + /** - * Recursively collect all files and directories under the given paths into - * nanotar entries. Normalizes permissions: uid/gid 1000, directories and - * executable files get mode 755, everything else gets 644. Preserves mtime. + * Recursively collect all files, directories, and symlinks under the given + * paths into tar entries. Normalizes permissions: uid/gid 1000, directories + * and executable files get mode 0o755, everything else gets 0o644. Preserves + * mtime. Symlinks are stored as symlink entries with their target path. * * Entry names are always relative to `archiveRoot` and never contain leading * `../` segments, preventing path traversal in the generated archive. @@ -47,14 +57,13 @@ async function collectEntries( paths: string[], archiveRoot: string, precomputedStats?: Map>>, -): Promise { - const entries: TarFileInput[] = []; +): Promise { + const entries: TarEntry[] = []; for (const p of paths) { const absPath = resolve(p); - let relPath = relative(archiveRoot, absPath); + const relPath = relative(archiveRoot, absPath); - // Guard against path traversal: entry names must not escape the archive root if (relPath.startsWith("..")) { throw new Error( `Path "${absPath}" is outside the archive root "${archiveRoot}". All paths must share a common ancestor directory.`, @@ -71,20 +80,27 @@ async function collectEntries( } if (stats.isSymbolicLink()) { - throw new Error( - `Path is a symlink: ${relPath}. Resolve the symlink or pass the target path directly.`, - ); - } - - if (stats.isDirectory()) { + const linkTarget = await readlink(absPath); entries.push({ - name: relPath.endsWith("/") ? relPath : relPath + "/", - attrs: { - mode: "755", + header: { + name: relPath, + type: "symlink", + linkname: linkTarget, + mode: 0o777, uid: 1000, gid: 1000, - // nanotar expects mtime in milliseconds and converts to seconds internally - mtime: Number(stats.mtimeMs), + mtime: stats.mtime, + }, + }); + } else if (stats.isDirectory()) { + entries.push({ + header: { + name: relPath.endsWith("/") ? relPath : relPath + "/", + type: "directory", + mode: 0o755, + uid: 1000, + gid: 1000, + mtime: stats.mtime, }, }); const children = (await readdir(absPath)).sort(); @@ -101,15 +117,16 @@ async function collectEntries( throw new Error(`Cannot read file: ${relPath}`, { cause: err }); } entries.push({ - name: relPath, - data, - attrs: { - mode: isExecutable ? "755" : "644", + header: { + name: relPath, + type: "file", + mode: isExecutable ? 0o755 : 0o644, uid: 1000, gid: 1000, - // nanotar expects mtime in milliseconds and converts to seconds internally - mtime: Number(stats.mtimeMs), + size: data.length, + mtime: stats.mtime, }, + data, }); } } @@ -150,16 +167,40 @@ export async function createTarBuffer( precomputedStats?: Map>>, ): Promise { const absPaths = paths.map((p) => resolve(p)); + const unique = new Set(absPaths); + if (unique.size < absPaths.length) { + const seen = new Set(); + const dupes = absPaths.filter((p) => + seen.has(p) ? true : (seen.add(p), false), + ); + throw new Error(`Duplicate paths: ${[...new Set(dupes)].join(", ")}`); + } const archiveRoot = commonAncestor(absPaths); const entries = await collectEntries(paths, archiveRoot, precomputedStats); + const pack = tar.pack(); + for (const entry of entries) { + if (entry.data) { + pack.entry(entry.header, entry.data); + } else { + pack.entry(entry.header); + } + } + pack.finalize(); + if (gzip) { - const data = await createTarGzip(entries); - return Buffer.from(data); + const gz = createGzip(); + const chunks: Buffer[] = []; + gz.on("data", (chunk: Buffer) => chunks.push(chunk)); + await pipeline(pack, gz); + return Buffer.concat(chunks); } - const data = createTar(entries); - return Buffer.from(data); + const chunks: Buffer[] = []; + for await (const chunk of pack) { + chunks.push(chunk); + } + return Buffer.concat(chunks); } async function readStdinBuffer(): Promise { @@ -240,18 +281,10 @@ export async function uploadObject(options: UploadObjectOptions) { fileSize = fileBuffer.length; detectedContentType = contentType as ContentType; } else { - // Validate all paths exist (use lstat to match collectEntries and detect symlinks) - // Key by resolved absolute path so collectEntries can reuse stats const statsMap = new Map>>(); for (const p of paths) { try { const s = await lstat(p); - if (s.isSymbolicLink()) { - outputError( - `Path is a symlink: ${p}. Resolve the symlink or pass the target path directly.`, - ); - return; - } statsMap.set(resolve(p), s); } catch { outputError(`Path does not exist: ${p}`); @@ -264,7 +297,11 @@ export async function uploadObject(options: UploadObjectOptions) { const firstStats = isSinglePath ? statsMap.get(resolve(paths[0]))! : undefined; - const singleIsDir = isSinglePath && firstStats!.isDirectory(); + const singleIsDir = + isSinglePath && + (firstStats!.isDirectory() || + (firstStats!.isSymbolicLink() && + (await stat(paths[0])).isDirectory())); // Multi-path requires tar/tgz content type if (paths.length > 1 && !isTarType) { @@ -305,11 +342,18 @@ export async function uploadObject(options: UploadObjectOptions) { } // Step 1: Create the object - const createResponse = await client.objects.create({ + const createParams: { + name: string; + content_type: ContentType; + metadata?: Record; + } = { name, content_type: detectedContentType, - ...(options.public ? { is_public: true } : {}), - } as any); + }; + if (options.metadata) { + createParams.metadata = parseMetadata(options.metadata); + } + const createResponse = await client.objects.create(createParams); // Step 2: Upload the file const uploadResponse = await fetch(createResponse.upload_url!, { diff --git a/src/commands/scenario/info.ts b/src/commands/scenario/info.ts index b2f66b8f..66f0a2e4 100644 --- a/src/commands/scenario/info.ts +++ b/src/commands/scenario/info.ts @@ -1,197 +1,19 @@ /** - * Display scenario definition details in a readable format. + * Display scenario definition details. */ -import chalk from "chalk"; import { getClient } from "../../utils/client.js"; import { output, outputError } from "../../utils/output.js"; -import type { ScenarioView } from "@runloop/api-client/resources/scenarios"; interface InfoOptions { output?: string; } -/** Format a scoring function's details for display */ -function formatScorer( - scorer: ScenarioView["scoring_contract"]["scoring_function_parameters"][number], -): string { - const lines: string[] = []; - const s = scorer.scorer; - lines.push(` type: ${s.type}`); - lines.push(` weight: ${scorer.weight}`); - - switch (s.type) { - case "test_based_scorer": - if (s.test_command) lines.push(` test_command: ${s.test_command}`); - if (s.test_files) { - for (const tf of s.test_files) { - lines.push(` file: ${tf.file_path || "(unnamed)"}`); - if (tf.file_contents) { - const indented = tf.file_contents - .split("\n") - .map((l) => ` ${l}`) - .join("\n"); - lines.push(indented); - } - } - } - break; - case "bash_script_scorer": - if (s.bash_script) { - lines.push(" script:"); - lines.push( - s.bash_script - .split("\n") - .map((l) => ` ${l}`) - .join("\n"), - ); - } - break; - case "command_scorer": - if (s.command) lines.push(` command: ${s.command}`); - break; - case "python_script_scorer": - if (s.python_version_constraint) - lines.push(` python: ${s.python_version_constraint}`); - if (s.requirements_contents) - lines.push(` requirements: ${s.requirements_contents}`); - lines.push(" script:"); - lines.push( - s.python_script - .split("\n") - .map((l) => ` ${l}`) - .join("\n"), - ); - break; - case "ast_grep_scorer": - lines.push(` pattern: ${s.pattern}`); - lines.push(` search_directory: ${s.search_directory}`); - if (s.lang) lines.push(` lang: ${s.lang}`); - break; - case "custom_scorer": - lines.push(` custom_type: ${s.custom_scorer_type}`); - if (s.scorer_params) - lines.push(` params: ${JSON.stringify(s.scorer_params)}`); - break; - } - - return lines.join("\n"); -} - -function printScenario(scenario: ScenarioView): void { - console.log(chalk.bold("Scenario: ") + scenario.name); - console.log(chalk.dim("ID: ") + scenario.id); - console.log(chalk.dim("Status: ") + scenario.status); - if (scenario.validation_type && scenario.validation_type !== "UNSPECIFIED") { - console.log(chalk.dim("Validation: ") + scenario.validation_type); - } - - // Environment - const env = scenario.environment; - if (env) { - console.log(); - console.log(chalk.bold("Environment:")); - if (env.blueprint_id) console.log(` blueprint: ${env.blueprint_id}`); - if (env.snapshot_id) console.log(` snapshot: ${env.snapshot_id}`); - if (env.working_directory) - console.log(` working_directory: ${env.working_directory}`); - if (env.launch_parameters) { - const lp = env.launch_parameters; - if (lp.architecture) console.log(` architecture: ${lp.architecture}`); - if (lp.resource_size_request) - console.log(` resources: ${lp.resource_size_request}`); - if (lp.launch_commands?.length) { - console.log(" launch_commands:"); - for (const cmd of lp.launch_commands) { - console.log(` - ${cmd}`); - } - } - } - } - - // Required env vars / secrets - if (scenario.required_environment_variables?.length) { - console.log(); - console.log(chalk.bold("Required Environment Variables:")); - for (const v of scenario.required_environment_variables) { - console.log(` - ${v}`); - } - } - if (scenario.required_secret_names?.length) { - console.log(); - console.log(chalk.bold("Required Secrets:")); - for (const s of scenario.required_secret_names) { - console.log(` - ${s}`); - } - } - - // Metadata - if (scenario.metadata && Object.keys(scenario.metadata).length > 0) { - console.log(); - console.log(chalk.bold("Metadata:")); - for (const [k, v] of Object.entries(scenario.metadata)) { - console.log(` ${k}: ${v}`); - } - } - - // Problem statement - console.log(); - console.log(chalk.bold("Problem Statement:")); - console.log(indent(scenario.input_context.problem_statement, 2)); - - if (scenario.input_context.additional_context) { - console.log(); - console.log(chalk.bold("Additional Context:")); - console.log( - indent( - JSON.stringify(scenario.input_context.additional_context, null, 2), - 2, - ), - ); - } - - // Reference output - if (scenario.reference_output) { - console.log(); - console.log(chalk.bold("Reference Output:")); - console.log(indent(scenario.reference_output, 2)); - } - - // Scoring - const scorers = scenario.scoring_contract.scoring_function_parameters; - if (scorers.length > 0) { - console.log(); - console.log(chalk.bold("Scoring Functions:")); - for (const scorer of scorers) { - console.log(` ${chalk.cyan(scorer.name)}:`); - console.log(formatScorer(scorer)); - } - } - - if (scenario.scorer_timeout_sec) { - console.log(); - console.log(chalk.dim(`Scorer timeout: ${scenario.scorer_timeout_sec}s`)); - } -} - -function indent(text: string, spaces: number): string { - const pad = " ".repeat(spaces); - return text - .split("\n") - .map((l) => pad + l) - .join("\n"); -} - export async function scenarioInfo(id: string, options: InfoOptions = {}) { try { const client = getClient(); const scenario = await client.scenarios.retrieve(id); - - if (options.output && options.output !== "text") { - output(scenario, { format: options.output, defaultFormat: "json" }); - } else { - printScenario(scenario); - } + output(scenario, { format: options.output, defaultFormat: "json" }); } catch (error) { outputError("Failed to get scenario info", error); } diff --git a/src/commands/scenario/list.ts b/src/commands/scenario/list.ts new file mode 100644 index 00000000..1c08d2a8 --- /dev/null +++ b/src/commands/scenario/list.ts @@ -0,0 +1,57 @@ +/** + * List scenario runs command + */ + +import { listScenarioRuns } from "../../services/benchmarkService.js"; +import type { ScenarioRun } from "../../store/benchmarkStore.js"; +import { output, outputError, parseLimit } from "../../utils/output.js"; + +interface ListOptions { + limit?: string; + benchmarkRunId?: string; + output?: string; +} + +const PAGE_SIZE = 100; + +async function fetchRuns( + benchmarkRunId?: string, + maxResults?: number, +): Promise { + const all: ScenarioRun[] = []; + let cursor: string | undefined; + while (true) { + const pageLimit = + maxResults && maxResults > 0 + ? Math.min(PAGE_SIZE, maxResults - all.length) + : PAGE_SIZE; + const result = await listScenarioRuns({ + limit: pageLimit, + startingAfter: cursor, + benchmarkRunId, + }); + all.push(...result.scenarioRuns); + if (!result.hasMore || result.scenarioRuns.length === 0) break; + if (maxResults && maxResults > 0 && all.length >= maxResults) break; + cursor = result.scenarioRuns[result.scenarioRuns.length - 1].id; + } + return all; +} + +export async function listScenarioRunsCommand( + options: ListOptions, +): Promise { + try { + const maxResults = parseLimit(options.limit); + const runs = await fetchRuns( + options.benchmarkRunId, + maxResults === Infinity ? undefined : maxResults, + ); + + runs.sort((a, b) => (a.start_time_ms || 0) - (b.start_time_ms || 0)); + + output(runs, { format: options.output, defaultFormat: "json" }); + } catch (error) { + outputError("Failed to list scenario runs", error); + } +} diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index cd44dedb..7c608eaf 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -3127,7 +3127,7 @@ export const DevboxCreatePage = ({ paddingX={1} > - {`${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] ${selectedGatewayIndex === 0 ? "Attach" : selectedGatewayIndex === maxGatewayIndex ? "Done" : "Select"} • [d] Remove • [esc] Back`} + {`${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] ${selectedGatewayIndex === 0 ? "Attach" : selectedGatewayIndex === maxGatewayIndex ? "Done" : "Select"} • [d] Delete • [esc] Back`} @@ -3408,7 +3408,7 @@ export const DevboxCreatePage = ({ paddingX={1} > - {`${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] ${selectedMcpIndex === 0 ? "Attach" : selectedMcpIndex === maxMcpIndex ? "Done" : "Select"} • [d] Remove • [esc] Back`} + {`${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] ${selectedMcpIndex === 0 ? "Attach" : selectedMcpIndex === maxMcpIndex ? "Done" : "Select"} • [d] Delete • [esc] Back`} @@ -3564,7 +3564,7 @@ export const DevboxCreatePage = ({ {editingAgentMountPath ? "Type to edit path • [Enter/esc] Done" - : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] Select • [e] Edit path • [d] Remove • [esc] Back`} + : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] Select • [e] Edit path • [d] Delete • [esc] Back`} @@ -3693,7 +3693,7 @@ export const DevboxCreatePage = ({ {editingObjectMountPath ? "Type to edit path • [Enter/esc] Done" - : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] Select • [e] Edit path • [d] Remove • [esc] Back`} + : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] Select • [e] Edit path • [d] Delete • [esc] Back`} diff --git a/src/components/ObjectCreatePage.tsx b/src/components/ObjectCreatePage.tsx new file mode 100644 index 00000000..9377809b --- /dev/null +++ b/src/components/ObjectCreatePage.tsx @@ -0,0 +1,868 @@ +/** + * ObjectCreatePage - Form for creating a new storage object + */ +import React from "react"; +import { Box, Text, useInput } from "ink"; +import TextInput from "ink-text-input"; +import figures from "figures"; +import { readFile, lstat } from "fs/promises"; +import { resolve } from "path"; +import { SpinnerComponent } from "./Spinner.js"; +import { ErrorMessage } from "./ErrorMessage.js"; +import { SuccessMessage } from "./SuccessMessage.js"; +import { Breadcrumb } from "./Breadcrumb.js"; +import { NavigationTips } from "./NavigationTips.js"; +import { MetadataDisplay } from "./MetadataDisplay.js"; +import { + FormTextInput, + FormSelect, + FormActionButton, + FormListManager, + useFormSelectNavigation, +} from "./form/index.js"; +import { colors } from "../utils/theme.js"; +import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; +import { copyToClipboard } from "../utils/clipboard.js"; +import { createTarBuffer } from "../commands/object/upload.js"; +import { + createObject, + completeObject, + uploadToPresignedUrl, +} from "../services/objectService.js"; + +interface ObjectCreatePageProps { + onBack: () => void; + onCreate?: (objectId: string) => void; +} + +type FormField = "submit" | "name" | "content_type" | "file_path" | "metadata"; +type ScreenState = + | "form" + | "creating" + | "uploading" + | "show-url" + | "success" + | "error"; + +interface FormData { + name: string; + content_type: "unspecified" | "text" | "binary" | "gzip" | "tar" | "tgz"; + file_path: string; + file_paths: string[]; + metadata: Record; +} + +const CONTENT_TYPE_OPTIONS = [ + "unspecified", + "text", + "binary", + "gzip", + "tar", + "tgz", +] as const; + +export const ObjectCreatePage = ({ + onBack, + onCreate, +}: ObjectCreatePageProps) => { + const [currentField, setCurrentField] = React.useState("submit"); + const [formData, setFormData] = React.useState({ + name: "", + content_type: "unspecified", + file_path: "", + file_paths: [], + metadata: {}, + }); + const [filePathsExpanded, setFilePathsExpanded] = React.useState(false); + const [screenState, setScreenState] = React.useState("form"); + const [uploadUrl, setUploadUrl] = React.useState(""); + const [objectId, setObjectId] = React.useState(""); + const [error, setError] = React.useState(null); + const [validationError, setValidationError] = React.useState( + null, + ); + const [statusMessage, setStatusMessage] = React.useState(""); + const [metadataKey, setMetadataKey] = React.useState(""); + const [metadataValue, setMetadataValue] = React.useState(""); + const [inMetadataSection, setInMetadataSection] = React.useState(false); + const [metadataInputMode, setMetadataInputMode] = React.useState< + "key" | "value" | null + >(null); + const [selectedMetadataIndex, setSelectedMetadataIndex] = React.useState(0); + + const isTarType = + formData.content_type === "tar" || formData.content_type === "tgz"; + + const fields: Array<{ + key: FormField; + label: string; + type: "text" | "select" | "action" | "list" | "metadata"; + }> = [ + { key: "submit", label: "Create Object", type: "action" }, + { key: "name", label: "Name (required)", type: "text" }, + { key: "content_type", label: "Content Type", type: "select" }, + { + key: "file_path", + label: isTarType ? "File Path(s)" : "File Path", + type: isTarType ? "list" : "text", + }, + { key: "metadata", label: "Metadata (optional)", type: "metadata" }, + ]; + + const currentFieldIndex = fields.findIndex((f) => f.key === currentField); + + // Handle Ctrl+C to exit + useExitOnCtrlC(); + + // Hook for content_type select navigation + const handleSelectInput = useFormSelectNavigation( + formData.content_type, + CONTENT_TYPE_OPTIONS, + (newValue) => { + const wasTar = + formData.content_type === "tar" || formData.content_type === "tgz"; + const willBeTar = newValue === "tar" || newValue === "tgz"; + + if (!wasTar && willBeTar) { + setFormData((prev) => { + const paths = [...prev.file_paths]; + if (prev.file_path.trim()) { + paths[0] = prev.file_path.trim(); + } + return { ...prev, content_type: newValue, file_paths: paths }; + }); + } else if (wasTar && !willBeTar) { + setFilePathsExpanded(false); + setFormData((prev) => ({ + ...prev, + content_type: newValue, + file_path: prev.file_paths[0] || prev.file_path, + })); + } else { + setFormData((prev) => ({ ...prev, content_type: newValue })); + } + }, + currentField === "content_type", + ); + + // Main form input handler - active when not in file paths expanded mode + useInput( + (input, key) => { + // Handle show-url screen + if (screenState === "show-url") { + if (input === "c") { + // Copy URL to clipboard + copyToClipboard(uploadUrl).then((message) => { + setStatusMessage(message); + }); + return; + } else if (key.return) { + // Navigate to detail + if (onCreate && objectId) { + onCreate(objectId); + } else { + onBack(); + } + return; + } else if (input === "q" || key.escape) { + onBack(); + return; + } + } + + // Handle success screen + if (screenState === "success") { + if (input === "q" || key.escape || key.return) { + if (onCreate && objectId) { + onCreate(objectId); + } else { + onBack(); + } + } + return; + } + + // Handle error screen + if (screenState === "error") { + if (input === "r" || key.return) { + // Retry - clear error and return to form + setError(null); + setScreenState("form"); + } else if (input === "q" || key.escape) { + // Quit - go back to list + onBack(); + } + return; + } + + // Handle submitting/uploading states + if (screenState === "creating" || screenState === "uploading") { + return; + } + + // Only handle form input when in form state + if (screenState !== "form") { + return; + } + + // Select field navigation with left/right arrows + if (handleSelectInput(input, key)) { + return; + } + + // Back to list + if (input === "q" || key.escape) { + onBack(); + return; + } + + // Submit form with Ctrl+S + if (input === "s" && key.ctrl) { + handleSubmit(); + return; + } + + // Handle Enter on file_path field to expand list manager (tar/tgz only) + if (currentField === "file_path" && isTarType && key.return) { + setFilePathsExpanded(true); + return; + } + + // Handle Enter on metadata field to expand metadata section + if (currentField === "metadata" && key.return) { + setInMetadataSection(true); + setSelectedMetadataIndex(0); + return; + } + + // Handle Enter on any field to submit + if (key.return) { + handleSubmit(); + return; + } + + // Navigation between fields (up/down arrows and tab/shift+tab) + if (key.upArrow || (key.tab && key.shift)) { + const nextIdx = currentFieldIndex - 1; + if (nextIdx >= 0) { + setCurrentField(fields[nextIdx].key); + } + return; + } + + if (key.downArrow || (key.tab && !key.shift)) { + const nextIdx = currentFieldIndex + 1; + if (nextIdx < fields.length) { + setCurrentField(fields[nextIdx].key); + } + return; + } + }, + { isActive: !filePathsExpanded && !inMetadataSection }, + ); + + useInput( + (input, key) => { + const metadataKeys = Object.keys(formData.metadata); + const maxIndex = metadataKeys.length + 1; + + if (metadataInputMode) { + if (metadataInputMode === "key" && key.return && metadataKey.trim()) { + setMetadataInputMode("value"); + return; + } else if (metadataInputMode === "value" && key.return) { + if (metadataKey.trim() && metadataValue.trim()) { + setFormData({ + ...formData, + metadata: { + ...formData.metadata, + [metadataKey.trim()]: metadataValue.trim(), + }, + }); + } + setMetadataKey(""); + setMetadataValue(""); + setMetadataInputMode(null); + setSelectedMetadataIndex(0); + return; + } else if (key.escape) { + setMetadataKey(""); + setMetadataValue(""); + setMetadataInputMode(null); + return; + } else if (key.tab) { + setMetadataInputMode(metadataInputMode === "key" ? "value" : "key"); + return; + } + return; + } + + if (key.upArrow && selectedMetadataIndex > 0) { + setSelectedMetadataIndex(selectedMetadataIndex - 1); + } else if (key.downArrow && selectedMetadataIndex < maxIndex) { + setSelectedMetadataIndex(selectedMetadataIndex + 1); + } else if (key.return) { + if (selectedMetadataIndex === 0) { + setMetadataKey(""); + setMetadataValue(""); + setMetadataInputMode("key"); + } else if (selectedMetadataIndex === maxIndex) { + setInMetadataSection(false); + setSelectedMetadataIndex(0); + setMetadataKey(""); + setMetadataValue(""); + setMetadataInputMode(null); + } else if ( + selectedMetadataIndex >= 1 && + selectedMetadataIndex <= metadataKeys.length + ) { + const keyToEdit = metadataKeys[selectedMetadataIndex - 1]; + setMetadataKey(keyToEdit || ""); + setMetadataValue(formData.metadata[keyToEdit] || ""); + const newMetadata = { ...formData.metadata }; + delete newMetadata[keyToEdit]; + setFormData({ ...formData, metadata: newMetadata }); + setMetadataInputMode("key"); + } + } else if ( + (input === "d" || key.delete) && + selectedMetadataIndex >= 1 && + selectedMetadataIndex <= metadataKeys.length + ) { + const keyToDelete = metadataKeys[selectedMetadataIndex - 1]; + const newMetadata = { ...formData.metadata }; + delete newMetadata[keyToDelete]; + setFormData({ ...formData, metadata: newMetadata }); + const newLength = Object.keys(newMetadata).length; + if (selectedMetadataIndex > newLength) { + setSelectedMetadataIndex(Math.max(0, newLength)); + } + } else if (key.escape || input === "q") { + setInMetadataSection(false); + setSelectedMetadataIndex(0); + setMetadataKey(""); + setMetadataValue(""); + setMetadataInputMode(null); + } + }, + { isActive: inMetadataSection }, + ); + + const handleSubmit = async () => { + // Validate required fields + if (!formData.name.trim()) { + setValidationError("Name is required"); + setCurrentField("name"); + return; + } + + const isTar = + formData.content_type === "tar" || formData.content_type === "tgz"; + const paths = isTar + ? formData.file_paths.filter((p) => p.trim().length > 0) + : formData.file_path.trim() + ? [formData.file_path.trim()] + : []; + + setError(null); + setValidationError(null); + + try { + let currentObjectId = objectId; + let currentUploadUrl = uploadUrl; + + if (!currentObjectId) { + setScreenState("creating"); + const result = await createObject({ + name: formData.name.trim(), + content_type: formData.content_type, + metadata: + Object.keys(formData.metadata).length > 0 + ? formData.metadata + : undefined, + }); + currentObjectId = result.id; + currentUploadUrl = result.upload_url; + setObjectId(result.id); + setUploadUrl(result.upload_url); + } + + if (paths.length > 0) { + setScreenState("uploading"); + + const resolvedPaths = paths.map((p) => resolve(p)); + + let buffer: Buffer; + + const singleIsDir = + paths.length === 1 && (await lstat(resolvedPaths[0])).isDirectory(); + + if (paths.length > 1 || (isTar && singleIsDir)) { + const isGzip = formData.content_type === "tgz"; + buffer = await createTarBuffer(resolvedPaths, isGzip); + } else { + const filePath = resolvedPaths[0]; + const stats = await lstat(filePath); + + if (stats.isDirectory()) { + throw new Error( + "Cannot upload directory directly. Use tar or tgz content type for directories.", + ); + } + + if (stats.isSymbolicLink()) { + throw new Error( + "Cannot upload symbolic links directly. Resolve the symlink first.", + ); + } + + buffer = await readFile(filePath); + } + + await uploadToPresignedUrl(currentUploadUrl, buffer); + await completeObject(currentObjectId); + + setScreenState("success"); + } else { + setScreenState("show-url"); + } + } catch (err) { + setError(err as Error); + setScreenState("error"); + } + }; + + const breadcrumbItems = [ + { label: "Agents & Objects" }, + { label: "Objects" }, + { label: "Create", active: true }, + ]; + + // Show-url screen + if (screenState === "show-url") { + return ( + <> + + + + + + ID:{" "} + + {objectId} + + + + Name: {formData.name} + + + + + + {figures.info} Upload your file using this pre-signed URL: + + + + {uploadUrl} + + + {statusMessage && ( + + {statusMessage} + + )} + + + + ); + } + + // Success screen + if (screenState === "success") { + return ( + <> + + + + + + ID:{" "} + + {objectId} + + + + Name: {formData.name} + + + + + + ); + } + + // Error screen + if (screenState === "error") { + return ( + <> + + + + + ); + } + + // Creating/Uploading screens + if (screenState === "creating") { + return ( + <> + + + + ); + } + + if (screenState === "uploading") { + return ( + <> + + + + ); + } + + // Form screen + return ( + <> + + + + + {figures.info} Note: Create a storage object. + Optionally add file path(s) to upload immediately, or upload later + using the pre-signed URL. + + + + + {fields.map((field) => { + const isActive = currentField === field.key; + + if (field.type === "action") { + return ( + + ); + } + + if (field.type === "text") { + const value = formData[field.key as keyof FormData] as string; + const hasError = + field.key === "name" && validationError === "Name is required"; + + return ( + { + setFormData({ ...formData, [field.key]: newValue }); + if (validationError) { + setValidationError(null); + } + }} + onSubmit={handleSubmit} + isActive={isActive} + placeholder={ + field.key === "file_path" + ? "/path/to/file (optional)" + : "my-object" + } + error={hasError ? validationError : undefined} + /> + ); + } + + if (field.type === "select") { + return ( + { + setFormData((prev) => ({ ...prev, content_type: newValue })); + }} + isActive={isActive} + /> + ); + } + + if (field.type === "list") { + return ( + + setFormData({ ...formData, file_paths: items }) + } + isActive={isActive} + isExpanded={filePathsExpanded} + onExpandedChange={setFilePathsExpanded} + itemPlaceholder="/path/to/file" + addLabel="+ Add file path" + collapsedLabel="file path(s)" + /> + ); + } + + if (field.type === "metadata") { + if (!inMetadataSection) { + return ( + + + + {isActive ? figures.pointer : " "} {field.label}:{" "} + + + {Object.keys(formData.metadata).length > 0 + ? `${Object.keys(formData.metadata).length} item(s)` + : "None"} + + {isActive && ( + + {" "} + [Enter to manage] + + )} + + {Object.keys(formData.metadata).length > 0 && ( + + + + )} + + ); + } + + const metadataKeys = Object.keys(formData.metadata); + const maxIndex = metadataKeys.length + 1; + + return ( + + + {figures.hamburger} Manage Metadata + + + {metadataInputMode && ( + + + {selectedMetadataIndex === 0 ? "Adding New" : "Editing"} + + + {metadataInputMode === "key" ? ( + <> + Key: + + + ) : ( + Key: {metadataKey || ""} + )} + + + {metadataInputMode === "value" ? ( + <> + Value: + + + ) : ( + Value: {metadataValue || ""} + )} + + + )} + + {!metadataInputMode && ( + <> + + + {selectedMetadataIndex === 0 + ? figures.pointer + : " "}{" "} + + + + Add new metadata + + + + {metadataKeys.length > 0 && ( + + {metadataKeys.map((key, index) => { + const itemIndex = index + 1; + const isSelected = + selectedMetadataIndex === itemIndex; + return ( + + + {isSelected ? figures.pointer : " "}{" "} + + + {key}: {formData.metadata[key]} + + + ); + })} + + )} + + + + {selectedMetadataIndex === maxIndex + ? figures.pointer + : " "}{" "} + + + {figures.tick} Done + + + + )} + + + + {metadataInputMode + ? `[Tab] Switch field • [Enter] ${metadataInputMode === "key" ? "Next" : "Save"} • [esc] Cancel` + : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] ${selectedMetadataIndex === 0 ? "Add" : selectedMetadataIndex === maxIndex ? "Done" : "Edit"} • [d] Delete • [esc] Back`} + + + + ); + } + + return null; + })} + + + {!filePathsExpanded && !inMetadataSection && ( + + )} + + ); +}; diff --git a/src/components/ResourceDetailPage.tsx b/src/components/ResourceDetailPage.tsx index 33649c8b..e9d4333c 100644 --- a/src/components/ResourceDetailPage.tsx +++ b/src/components/ResourceDetailPage.tsx @@ -57,7 +57,7 @@ export interface SectionAllocation { /** * Allocate available lines to sections. Section order = priority (first sections get space first). */ -function allocateSectionLines( +export function allocateSectionLines( detailSections: DetailSection[], linesAvailable: number, ): SectionAllocation { @@ -212,7 +212,7 @@ export function ResourceDetailPage({ const actionsOpenListIndex = minimalChromeLayout ? 0 : maxVisibleOperations; // Viewport for full-detail and section-detail overlay views (scroll height) - const detailViewport = useViewportHeight({ overhead: 18, minHeight: 10 }); + const detailViewport = useViewportHeight({ overhead: 19, minHeight: 10 }); // Section allocation: how many fields to show per section, and which sections get "View section" refs const allocation = React.useMemo( @@ -253,7 +253,7 @@ export function ResourceDetailPage({ // Section detail viewport height (same as detailed info) const sectionDetailViewport = useViewportHeight({ - overhead: 18, + overhead: 19, minHeight: 10, }); diff --git a/src/router/Router.tsx b/src/router/Router.tsx index 9d2feb13..5c5248dd 100644 --- a/src/router/Router.tsx +++ b/src/router/Router.tsx @@ -53,6 +53,7 @@ const KNOWN_SCREENS: Set = new Set([ "axon-detail", "object-list", "object-detail", + "object-create", "ssh-session", "benchmark-menu", "benchmark-list", @@ -135,6 +136,7 @@ import { AxonListScreen } from "../screens/AxonListScreen.js"; import { AxonDetailScreen } from "../screens/AxonDetailScreen.js"; import { ObjectListScreen } from "../screens/ObjectListScreen.js"; import { ObjectDetailScreen } from "../screens/ObjectDetailScreen.js"; +import { ObjectCreateScreen } from "../screens/ObjectCreateScreen.js"; import { SSHSessionScreen } from "../screens/SSHSessionScreen.js"; import { BenchmarkMenuScreen } from "../screens/BenchmarkMenuScreen.js"; import { BenchmarkListScreen } from "../screens/BenchmarkListScreen.js"; @@ -218,6 +220,7 @@ export function Router() { case "object-list": case "object-detail": + case "object-create": if (!currentScreen.startsWith("object")) { useObjectStore.getState().clearAll(); } @@ -352,6 +355,9 @@ export function Router() { {currentScreen === "object-detail" && ( )} + {currentScreen === "object-create" && ( + + )} {currentScreen === "ssh-session" && ( )} diff --git a/src/screens/BenchmarkJobCreateScreen.tsx b/src/screens/BenchmarkJobCreateScreen.tsx index 693e3a87..086cff5f 100644 --- a/src/screens/BenchmarkJobCreateScreen.tsx +++ b/src/screens/BenchmarkJobCreateScreen.tsx @@ -13,6 +13,7 @@ import { SuccessMessage } from "../components/SuccessMessage.js"; import { Breadcrumb } from "../components/Breadcrumb.js"; import { NavigationTips } from "../components/NavigationTips.js"; import { ResourcePicker } from "../components/ResourcePicker.js"; +import { MetadataDisplay } from "../components/MetadataDisplay.js"; import { colors } from "../utils/theme.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; import { listBenchmarks, getBenchmark } from "../services/benchmarkService.js"; @@ -48,6 +49,7 @@ type FormField = | "name" | "agent_timeout" | "concurrent_trials" + | "metadata" | "create"; interface FormData { @@ -65,6 +67,7 @@ interface FormData { name: string; agentTimeout: string; concurrentTrials: string; + metadata: Record; } type ScreenState = @@ -414,11 +417,19 @@ export function BenchmarkJobCreateScreen({ name: cloneJobName ? `${cloneJobName} (clone)` : "", agentTimeout: cloneAgentTimeout || "", concurrentTrials: cloneConcurrentTrials || "1", + metadata: {}, }; }); const [createdJob, setCreatedJob] = React.useState(null); const [error, setError] = React.useState(null); + const [metadataKey, setMetadataKey] = React.useState(""); + const [metadataValue, setMetadataValue] = React.useState(""); + const [inMetadataSection, setInMetadataSection] = React.useState(false); + const [metadataInputMode, setMetadataInputMode] = React.useState< + "key" | "value" | null + >(null); + const [selectedMetadataIndex, setSelectedMetadataIndex] = React.useState(0); /** When adding a secret: selected secret awaiting env var name */ const [pendingSecretForEnv, setPendingSecretForEnv] = React.useState<{ id: string; @@ -476,7 +487,7 @@ export function BenchmarkJobCreateScreen({ const fields: Array<{ key: FormField; label: string; - type: "text" | "picker" | "action" | "toggle"; + type: "text" | "picker" | "action" | "toggle" | "metadata"; placeholder?: string; required?: boolean; description?: string; @@ -548,6 +559,12 @@ export function BenchmarkJobCreateScreen({ placeholder: "1", description: "Number of concurrent trials (default: 1)", }, + { + key: "metadata", + label: "Metadata (optional)", + type: "metadata", + description: "Optional key-value metadata for the job", + }, { key: "create", label: "Create Benchmark Job", @@ -879,6 +896,10 @@ export function BenchmarkJobCreateScreen({ : undefined, agentConfigs, orchestratorConfig, + metadata: + Object.keys(formData.metadata).length > 0 + ? formData.metadata + : undefined, }); setCreatedJob(job); @@ -890,62 +911,162 @@ export function BenchmarkJobCreateScreen({ }, [formData, isFormValid, cloneAgentConfigs, cloneOrchestratorConfig]); // Handle input - useInput((input, key) => { - if (screenState !== "form") return; + useInput( + (input, key) => { + if (screenState !== "form") return; - // Handle source type toggle with left/right arrows - if (currentField === "source_type" && (key.leftArrow || key.rightArrow)) { - setFormData((prev) => ({ - ...prev, - sourceType: prev.sourceType === "benchmark" ? "scenarios" : "benchmark", - // Clear the other source when switching - benchmarkId: prev.sourceType === "scenarios" ? "" : prev.benchmarkId, - benchmarkName: - prev.sourceType === "scenarios" ? "" : prev.benchmarkName, - scenarioIds: prev.sourceType === "benchmark" ? [] : prev.scenarioIds, - scenarioNames: - prev.sourceType === "benchmark" ? [] : prev.scenarioNames, - })); - return; - } + // Handle source type toggle with left/right arrows + if (currentField === "source_type" && (key.leftArrow || key.rightArrow)) { + setFormData((prev) => ({ + ...prev, + sourceType: + prev.sourceType === "benchmark" ? "scenarios" : "benchmark", + // Clear the other source when switching + benchmarkId: prev.sourceType === "scenarios" ? "" : prev.benchmarkId, + benchmarkName: + prev.sourceType === "scenarios" ? "" : prev.benchmarkName, + scenarioIds: prev.sourceType === "benchmark" ? [] : prev.scenarioIds, + scenarioNames: + prev.sourceType === "benchmark" ? [] : prev.scenarioNames, + })); + return; + } - // Navigate between fields - if (key.upArrow && currentFieldIndex > 0) { - setCurrentField(fieldKeys[currentFieldIndex - 1]); - } else if (key.downArrow && currentFieldIndex < fieldKeys.length - 1) { - setCurrentField(fieldKeys[currentFieldIndex + 1]); - } else if (key.escape) { - goBack(); - } else if (key.return) { - if (currentFieldDef?.type === "picker" && currentField === "benchmark") { - setScreenState("picking_benchmark"); - } else if ( - currentFieldDef?.type === "picker" && - currentField === "scenarios" - ) { - setScreenState("picking_scenarios"); - } else if ( - currentFieldDef?.type === "picker" && - currentField === "agents" - ) { - setScreenState("picking_agents"); - } else if ( - currentFieldDef?.type === "picker" && - currentField === "secrets" - ) { - setScreenState("secrets_config"); - setSecretsConfigSelectedIndex(0); + // Navigate between fields + if (key.upArrow && currentFieldIndex > 0) { + setCurrentField(fieldKeys[currentFieldIndex - 1]); + } else if (key.downArrow && currentFieldIndex < fieldKeys.length - 1) { + setCurrentField(fieldKeys[currentFieldIndex + 1]); + } else if (key.escape) { + goBack(); + } else if (key.return) { + if ( + currentFieldDef?.type === "picker" && + currentField === "benchmark" + ) { + setScreenState("picking_benchmark"); + } else if ( + currentFieldDef?.type === "picker" && + currentField === "scenarios" + ) { + setScreenState("picking_scenarios"); + } else if ( + currentFieldDef?.type === "picker" && + currentField === "agents" + ) { + setScreenState("picking_agents"); + } else if ( + currentFieldDef?.type === "picker" && + currentField === "secrets" + ) { + setScreenState("secrets_config"); + setSecretsConfigSelectedIndex(0); + } else if ( + currentFieldDef?.type === "metadata" && + currentField === "metadata" + ) { + setInMetadataSection(true); + setSelectedMetadataIndex(0); + } else if ( + currentFieldDef?.type === "action" && + currentField === "create" + ) { + handleCreate(); + } else if (currentFieldIndex < fieldKeys.length - 1) { + // Move to next field on Enter for text inputs + setCurrentField(fieldKeys[currentFieldIndex + 1]); + } + } + }, + { isActive: !inMetadataSection }, + ); + + useInput( + (input, key) => { + const metadataKeys = Object.keys(formData.metadata); + const maxIndex = metadataKeys.length + 1; + + if (metadataInputMode) { + if (metadataInputMode === "key" && key.return && metadataKey.trim()) { + setMetadataInputMode("value"); + return; + } else if (metadataInputMode === "value" && key.return) { + if (metadataKey.trim() && metadataValue.trim()) { + setFormData({ + ...formData, + metadata: { + ...formData.metadata, + [metadataKey.trim()]: metadataValue.trim(), + }, + }); + } + setMetadataKey(""); + setMetadataValue(""); + setMetadataInputMode(null); + setSelectedMetadataIndex(0); + return; + } else if (key.escape) { + setMetadataKey(""); + setMetadataValue(""); + setMetadataInputMode(null); + return; + } else if (key.tab) { + setMetadataInputMode(metadataInputMode === "key" ? "value" : "key"); + return; + } + return; + } + + if (key.upArrow && selectedMetadataIndex > 0) { + setSelectedMetadataIndex(selectedMetadataIndex - 1); + } else if (key.downArrow && selectedMetadataIndex < maxIndex) { + setSelectedMetadataIndex(selectedMetadataIndex + 1); + } else if (key.return) { + if (selectedMetadataIndex === 0) { + setMetadataKey(""); + setMetadataValue(""); + setMetadataInputMode("key"); + } else if (selectedMetadataIndex === maxIndex) { + setInMetadataSection(false); + setSelectedMetadataIndex(0); + setMetadataKey(""); + setMetadataValue(""); + setMetadataInputMode(null); + } else if ( + selectedMetadataIndex >= 1 && + selectedMetadataIndex <= metadataKeys.length + ) { + const keyToEdit = metadataKeys[selectedMetadataIndex - 1]; + setMetadataKey(keyToEdit || ""); + setMetadataValue(formData.metadata[keyToEdit] || ""); + const newMetadata = { ...formData.metadata }; + delete newMetadata[keyToEdit]; + setFormData({ ...formData, metadata: newMetadata }); + setMetadataInputMode("key"); + } } else if ( - currentFieldDef?.type === "action" && - currentField === "create" + (input === "d" || key.delete) && + selectedMetadataIndex >= 1 && + selectedMetadataIndex <= metadataKeys.length ) { - handleCreate(); - } else if (currentFieldIndex < fieldKeys.length - 1) { - // Move to next field on Enter for text inputs - setCurrentField(fieldKeys[currentFieldIndex + 1]); + const keyToDelete = metadataKeys[selectedMetadataIndex - 1]; + const newMetadata = { ...formData.metadata }; + delete newMetadata[keyToDelete]; + setFormData({ ...formData, metadata: newMetadata }); + const newLength = Object.keys(newMetadata).length; + if (selectedMetadataIndex > newLength) { + setSelectedMetadataIndex(Math.max(0, newLength)); + } + } else if (key.escape || input === "q") { + setInMetadataSection(false); + setSelectedMetadataIndex(0); + setMetadataKey(""); + setMetadataValue(""); + setMetadataInputMode(null); } - } - }); + }, + { isActive: inMetadataSection && screenState === "form" }, + ); // ----- Secrets sub-flow ----- const mappingEntries = Object.entries(formData.secretsMapping); @@ -1130,6 +1251,13 @@ export function BenchmarkJobCreateScreen({ return formData.agentTimeout; case "concurrent_trials": return formData.concurrentTrials; + case "metadata": { + const keys = Object.keys(formData.metadata); + if (keys.length === 0) return ""; + if (keys.length === 1) + return `${keys[0]} = ${formData.metadata[keys[0]]}`; + return `${keys.length} item(s)`; + } default: return ""; } @@ -1242,6 +1370,214 @@ export function BenchmarkJobCreateScreen({ )} + ) : field.type === "metadata" ? ( + !inMetadataSection ? ( + + + + {field.label}:{" "} + + + {Object.keys(formData.metadata).length > 0 + ? `${Object.keys(formData.metadata).length} item(s)` + : "None"} + + {isSelected && ( + + {" "} + [Enter to manage] + + )} + + {Object.keys(formData.metadata).length > 0 && ( + + + + )} + + ) : ( + (() => { + const metadataKeys = Object.keys(formData.metadata); + const maxIndex = metadataKeys.length + 1; + + return ( + + + {figures.hamburger} Manage Metadata + + + {metadataInputMode && ( + + + {selectedMetadataIndex === 0 + ? "Adding New" + : "Editing"} + + + {metadataInputMode === "key" ? ( + <> + Key: + + + ) : ( + Key: {metadataKey || ""} + )} + + + {metadataInputMode === "value" ? ( + <> + Value: + + + ) : ( + + Value: {metadataValue || ""} + + )} + + + )} + + {!metadataInputMode && ( + <> + + + {selectedMetadataIndex === 0 + ? figures.pointer + : " "}{" "} + + + + Add new metadata + + + + {metadataKeys.length > 0 && ( + + {metadataKeys.map((key, index) => { + const itemIndex = index + 1; + const isMetadataSelected = + selectedMetadataIndex === itemIndex; + return ( + + + {isMetadataSelected + ? figures.pointer + : " "}{" "} + + + {key}: {formData.metadata[key]} + + + ); + })} + + )} + + + + {selectedMetadataIndex === maxIndex + ? figures.pointer + : " "}{" "} + + + {figures.tick} Done + + + + )} + + + + {metadataInputMode + ? `[Tab] Switch field • [Enter] ${metadataInputMode === "key" ? "Next" : "Save"} • [esc] Cancel` + : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] ${selectedMetadataIndex === 0 ? "Add" : selectedMetadataIndex === maxIndex ? "Done" : "Edit"} • [d] Delete • [esc] Back`} + + + + ); + })() + ) ) : ( diff --git a/src/screens/BenchmarkJobListScreen.tsx b/src/screens/BenchmarkJobListScreen.tsx index 82ad69ff..b95c9926 100644 --- a/src/screens/BenchmarkJobListScreen.tsx +++ b/src/screens/BenchmarkJobListScreen.tsx @@ -109,11 +109,16 @@ export function BenchmarkJobListScreen() { // Fetch function for pagination hook const fetchPage = React.useCallback( - async (params: { limit: number; startingAt?: string }) => { + async (params: { + limit: number; + startingAt?: string; + includeTotalCount?: boolean; + }) => { const result = await listBenchmarkJobs({ limit: params.limit, startingAfter: params.startingAt, name: search.submittedSearchQuery || undefined, + includeTotalCount: params.includeTotalCount, }); return { diff --git a/src/screens/BenchmarkListScreen.tsx b/src/screens/BenchmarkListScreen.tsx index bd25cb64..78cdf0bc 100644 --- a/src/screens/BenchmarkListScreen.tsx +++ b/src/screens/BenchmarkListScreen.tsx @@ -45,7 +45,7 @@ export function BenchmarkListScreen() { }); // Calculate overhead for viewport height - const overhead = 13 + search.getSearchOverhead(); + const overhead = 14 + search.getSearchOverhead(); const { viewportHeight, terminalWidth } = useViewportHeight({ overhead, minHeight: 5, @@ -64,12 +64,17 @@ export function BenchmarkListScreen() { // Fetch function for pagination hook const fetchPage = React.useCallback( - async (params: { limit: number; startingAt?: string }) => { + async (params: { + limit: number; + startingAt?: string; + includeTotalCount?: boolean; + }) => { const listFn = showPublic ? listPublicBenchmarks : listBenchmarks; const result = await listFn({ limit: params.limit, startingAfter: params.startingAt, search: search.submittedSearchQuery || undefined, + includeTotalCount: params.includeTotalCount, }); return { @@ -227,7 +232,7 @@ export function BenchmarkListScreen() { navigate("benchmark-detail", { benchmarkId: selectedBenchmark.id, }); - } else if (input === "c" && selectedBenchmark) { + } else if (input === "s" && selectedBenchmark) { setShowPopup(false); navigate("benchmark-job-create", { initialBenchmarkIds: selectedBenchmark.id, @@ -239,6 +244,13 @@ export function BenchmarkListScreen() { return; } + // Tab switching + if (key.tab) { + setShowPublic((prev) => !prev); + setSelectedIndex(0); + return; + } + const pageBenchmarks = benchmarks.length; // Handle list view navigation @@ -269,16 +281,13 @@ export function BenchmarkListScreen() { } else if (input === "a" && selectedBenchmark) { setShowPopup(true); setSelectedOperation(0); - } else if (input === "c" && selectedBenchmark) { + } else if (input === "s" && selectedBenchmark) { // Quick shortcut to create a job navigate("benchmark-job-create", { initialBenchmarkIds: selectedBenchmark.id, }); } else if (input === "/") { search.enterSearchMode(); - } else if (input === "t") { - setShowPublic((prev) => !prev); - setSelectedIndex(0); } else if (key.escape) { if (search.handleEscape()) { return; @@ -330,6 +339,27 @@ export function BenchmarkListScreen() { ]} /> + {/* Tab bar */} + + + {showPublic ? figures.pointer : " "} Public + + + + {!showPublic ? figures.pointer : " "} Custom + + + {" "} + (Tab to switch) + + + {/* Search bar */} - - {" "} - • {showPublic ? "Public" : "Custom"} - {totalPages > 1 && ( <> @@ -438,9 +461,9 @@ export function BenchmarkListScreen() { condition: hasMore || hasPrev, }, { key: "Enter", label: "Details" }, - { key: "c", label: "Create Job" }, + { key: "s", label: "Create Job" }, { key: "a", label: "Actions" }, - { key: "t", label: showPublic ? "Custom" : "Public" }, + { key: "Tab", label: "Switch tab" }, { key: "/", label: "Search" }, { key: "Esc", label: "Back" }, ]} diff --git a/src/screens/BenchmarkRunListScreen.tsx b/src/screens/BenchmarkRunListScreen.tsx index df81086e..7a093d72 100644 --- a/src/screens/BenchmarkRunListScreen.tsx +++ b/src/screens/BenchmarkRunListScreen.tsx @@ -60,10 +60,15 @@ export function BenchmarkRunListScreen() { // Fetch function for pagination hook const fetchPage = React.useCallback( - async (params: { limit: number; startingAt?: string }) => { + async (params: { + limit: number; + startingAt?: string; + includeTotalCount?: boolean; + }) => { const result = await listBenchmarkRuns({ limit: params.limit, startingAfter: params.startingAt, + includeTotalCount: params.includeTotalCount, }); return { diff --git a/src/screens/ObjectCreateScreen.tsx b/src/screens/ObjectCreateScreen.tsx new file mode 100644 index 00000000..b9fc8a75 --- /dev/null +++ b/src/screens/ObjectCreateScreen.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { useNavigation } from "../store/navigationStore.js"; +import { ObjectCreatePage } from "../components/ObjectCreatePage.js"; + +export function ObjectCreateScreen() { + const { goBack, navigate } = useNavigation(); + return ( + navigate("object-detail", { objectId })} + /> + ); +} diff --git a/src/screens/ScenarioRunListScreen.tsx b/src/screens/ScenarioRunListScreen.tsx index 8935c520..38390aeb 100644 --- a/src/screens/ScenarioRunListScreen.tsx +++ b/src/screens/ScenarioRunListScreen.tsx @@ -67,11 +67,16 @@ export function ScenarioRunListScreen({ // Fetch function for pagination hook const fetchPage = React.useCallback( - async (params: { limit: number; startingAt?: string }) => { + async (params: { + limit: number; + startingAt?: string; + includeTotalCount?: boolean; + }) => { const result = await listScenarioRuns({ limit: params.limit, startingAfter: params.startingAt, benchmarkRunId, + includeTotalCount: params.includeTotalCount, }); return { diff --git a/src/services/agentService.ts b/src/services/agentService.ts index 7870db86..79f8d494 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -258,7 +258,6 @@ export async function listPublicAgents( export interface CreateAgentOptions { name: string; version?: string; - is_public?: boolean; source?: { type: string; npm?: { @@ -281,11 +280,10 @@ export interface CreateAgentOptions { */ export async function createAgent(options: CreateAgentOptions): Promise { const client = getClient(); - const { version, is_public, ...rest } = options; + const { version, ...rest } = options; // eslint-disable-next-line @typescript-eslint/no-explicit-any const params: any = { ...rest }; if (version) params.version = version; - if (is_public !== undefined) params.is_public = is_public; return client.agents.create(params); } diff --git a/src/services/axonService.ts b/src/services/axonService.ts index 71dd4f33..fa9def74 100644 --- a/src/services/axonService.ts +++ b/src/services/axonService.ts @@ -4,9 +4,29 @@ import { getClient } from "../utils/client.js"; import type { AxonView } from "@runloop/api-client/resources/axons/axons"; import type { AxonsCursorIDPage } from "@runloop/api-client/pagination"; +import type { + SqlQueryResultView, + SqlColumnMetaView, + SqlResultMetaView, +} from "@runloop/api-client/resources/axons/sql"; +export type { SqlQueryResultView, SqlColumnMetaView, SqlResultMetaView }; export type Axon = AxonView; +const ORIGIN_MAP: Record = { + 1: "EXTERNAL_EVENT", + 2: "AGENT_EVENT", + 3: "USER_EVENT", + 4: "SYSTEM_EVENT", +}; + +function mapOrigin(value: number | string): string { + if (typeof value === "number") { + return ORIGIN_MAP[value] ?? `UNKNOWN(${value})`; + } + return value; +} + export interface ListActiveAxonsOptions { limit?: number; startingAfter?: string; @@ -76,3 +96,51 @@ export async function getAxon(id: string): Promise { const client = getClient(); return client.axons.retrieve(id); } + +export interface AxonEvent { + sequence: number; + timestamp_ms: number; + origin: string; + source: string; + event_type: string; + payload: string; +} + +export interface AxonEventsResult { + events: AxonEvent[]; + hasMore: boolean; + meta: SqlResultMetaView; +} + +export async function listAxonEvents( + axonId: string, + options: { limit?: number; offset?: number } = {}, +): Promise { + const client = getClient(); + const limit = options.limit ?? 50; + const offset = options.offset ?? 0; + const result = await client.axons.sql.query(axonId, { + sql: `SELECT sequence, timestamp_ms, origin, source, event_type, payload FROM rl_axon_events ORDER BY sequence DESC LIMIT ? OFFSET ?`, + params: [limit + 1, offset], + }); + const allRows = (result.rows as unknown[][]).map((row) => ({ + sequence: row[0] as number, + timestamp_ms: row[1] as number, + origin: mapOrigin(row[2] as number | string), + source: row[3] as string, + event_type: row[4] as string, + payload: row[5] as string, + })); + const hasMore = allRows.length > limit; + const events = hasMore ? allRows.slice(0, limit) : allRows; + return { events, hasMore, meta: result.meta }; +} + +export async function executeAxonSql( + axonId: string, + sql: string, + params?: unknown[], +): Promise { + const client = getClient(); + return client.axons.sql.query(axonId, { sql, params }); +} diff --git a/src/services/benchmarkJobService.ts b/src/services/benchmarkJobService.ts index c766cb1f..698f6836 100644 --- a/src/services/benchmarkJobService.ts +++ b/src/services/benchmarkJobService.ts @@ -101,11 +101,12 @@ export interface ListBenchmarkJobsOptions { limit?: number; startingAfter?: string; name?: string; + includeTotalCount?: boolean; } export interface ListBenchmarkJobsResult { jobs: BenchmarkJob[]; - totalCount: number; + totalCount?: number; hasMore: boolean; } @@ -132,6 +133,7 @@ export interface CreateBenchmarkJobOptions { scenarioIds?: string[]; agentConfigs: AgentConfig[]; orchestratorConfig?: OrchestratorConfig; + metadata?: Record; } /** @@ -146,8 +148,10 @@ export async function listBenchmarkJobs( limit?: number; starting_after?: string; name?: string; + include_total_count?: boolean; } = { limit: options.limit, + include_total_count: options.includeTotalCount === true, }; if (options.startingAfter) { @@ -164,7 +168,7 @@ export async function listBenchmarkJobs( return { jobs, - totalCount: jobs.length, + totalCount: (page as unknown as { total_count?: number }).total_count, hasMore: page.has_more || false, }; } @@ -301,10 +305,15 @@ export async function createBenchmarkJob( }; } - const createParams: BenchmarkJobCreateParams = { + const createParams: BenchmarkJobCreateParams & { + metadata?: Record; + } = { name: options.name, spec, }; + if (options.metadata && Object.keys(options.metadata).length > 0) { + createParams.metadata = options.metadata; + } return client.benchmarkJobs.create(createParams); } diff --git a/src/services/benchmarkService.ts b/src/services/benchmarkService.ts index d1aabb86..be9e807c 100644 --- a/src/services/benchmarkService.ts +++ b/src/services/benchmarkService.ts @@ -14,22 +14,24 @@ export interface ListBenchmarksOptions { limit: number; startingAfter?: string; search?: string; + includeTotalCount?: boolean; } export interface ListBenchmarksResult { benchmarks: Benchmark[]; - totalCount: number; + totalCount?: number; hasMore: boolean; } export interface ListBenchmarkRunsOptions { limit: number; startingAfter?: string; + includeTotalCount?: boolean; } export interface ListBenchmarkRunsResult { benchmarkRuns: BenchmarkRun[]; - totalCount: number; + totalCount?: number; hasMore: boolean; } @@ -37,11 +39,12 @@ export interface ListScenarioRunsOptions { limit: number; startingAfter?: string; benchmarkRunId?: string; + includeTotalCount?: boolean; } export interface ListScenarioRunsResult { scenarioRuns: ScenarioRun[]; - totalCount: number; + totalCount?: number; hasMore: boolean; } @@ -53,8 +56,11 @@ export async function listBenchmarkRuns( ): Promise { const client = getClient(); - const queryParams: BenchmarkRunListParams = { + const queryParams: BenchmarkRunListParams & { + include_total_count?: boolean; + } = { limit: options.limit, + include_total_count: options.includeTotalCount === true, }; if (options.startingAfter) { @@ -66,7 +72,7 @@ export async function listBenchmarkRuns( return { benchmarkRuns, - totalCount: benchmarkRuns.length, + totalCount: (page as unknown as { total_count?: number }).total_count, hasMore: page.has_more || false, }; } @@ -89,8 +95,13 @@ export async function listScenarioRuns( // If we have a benchmark run ID, use the dedicated endpoint if (options.benchmarkRunId) { - const queryParams: { limit?: number; starting_after?: string } = { + const queryParams: { + limit?: number; + starting_after?: string; + include_total_count?: boolean; + } = { limit: options.limit, + include_total_count: options.includeTotalCount === true, }; if (options.startingAfter) { @@ -105,14 +116,15 @@ export async function listScenarioRuns( return { scenarioRuns, - totalCount: scenarioRuns.length, + totalCount: (page as unknown as { total_count?: number }).total_count, hasMore: page.has_more || false, }; } // Otherwise, list all scenario runs - const queryParams: RunListParams = { + const queryParams: RunListParams & { include_total_count?: boolean } = { limit: options.limit, + include_total_count: options.includeTotalCount === true, }; if (options.startingAfter) { @@ -124,7 +136,7 @@ export async function listScenarioRuns( return { scenarioRuns, - totalCount: scenarioRuns.length, + totalCount: (page as unknown as { total_count?: number }).total_count, hasMore: page.has_more || false, }; } @@ -149,8 +161,10 @@ export async function listBenchmarks( limit?: number; starting_after?: string; search?: string; + include_total_count?: boolean; } = { limit: options.limit, + include_total_count: options.includeTotalCount === true, }; if (options.startingAfter) { @@ -166,7 +180,7 @@ export async function listBenchmarks( return { benchmarks, - totalCount: benchmarks.length, + totalCount: (page as unknown as { total_count?: number }).total_count, hasMore: page.has_more || false, }; } @@ -191,8 +205,10 @@ export async function listPublicBenchmarks( limit?: number; starting_after?: string; search?: string; + include_total_count?: boolean; } = { limit: options.limit, + include_total_count: options.includeTotalCount === true, }; if (options.startingAfter) { @@ -208,7 +224,7 @@ export async function listPublicBenchmarks( return { benchmarks, - totalCount: benchmarks.length, + totalCount: (page as unknown as { total_count?: number }).total_count, hasMore: page.has_more || false, }; } diff --git a/src/services/blueprintService.ts b/src/services/blueprintService.ts index d0be6f6e..5f0ffb9c 100644 --- a/src/services/blueprintService.ts +++ b/src/services/blueprintService.ts @@ -13,6 +13,8 @@ export interface ListBlueprintsOptions { limit: number; startingAfter?: string; search?: string; + publicOnly?: boolean; + includeTotalCount?: boolean; } export interface ListBlueprintsResult { @@ -29,8 +31,9 @@ export async function listBlueprints( ): Promise { const client = getClient(); - const queryParams: BlueprintListParams = { + const queryParams: BlueprintListParams & { include_total_count?: boolean } = { limit: options.limit, + include_total_count: options.includeTotalCount === true, }; if (options.startingAfter) { @@ -40,9 +43,14 @@ export async function listBlueprints( queryParams.name = options.search; } - const pagePromise = client.blueprints.list(queryParams); + // Use listPublic or list based on publicOnly option + const pagePromise = options.publicOnly + ? client.blueprints.listPublic(queryParams) + : client.blueprints.list(queryParams); const page = - (await pagePromise) as unknown as BlueprintsCursorIDPage; + (await pagePromise) as unknown as BlueprintsCursorIDPage & { + total_count?: number; + }; const blueprints: Blueprint[] = []; @@ -51,7 +59,6 @@ export async function listBlueprints( // CRITICAL: Truncate all strings to prevent Yoga crashes const MAX_ID_LENGTH = 100; const MAX_NAME_LENGTH = 200; - const MAX_STATUS_LENGTH = 50; const MAX_ARCH_LENGTH = 50; const MAX_RESOURCES_LENGTH = 100; @@ -77,13 +84,11 @@ export async function listBlueprints( }); } - const result = { + return { blueprints, - totalCount: blueprints.length, + totalCount: page.total_count ?? blueprints.length, hasMore: page.has_more || false, }; - - return result; } /** diff --git a/src/services/objectService.ts b/src/services/objectService.ts index 656331c0..d9131935 100644 --- a/src/services/objectService.ts +++ b/src/services/objectService.ts @@ -119,6 +119,58 @@ export async function deleteObject(id: string): Promise { await client.objects.delete(id); } +export interface CreateObjectOptions { + name: string; + content_type: "unspecified" | "text" | "binary" | "gzip" | "tar" | "tgz"; + metadata?: Record; + ttl_ms?: number; +} + +export interface CreateObjectResult { + id: string; + name: string; + upload_url: string; +} + +export async function createObject( + options: CreateObjectOptions, +): Promise { + const client = getClient(); + const response = await client.objects.create({ + name: options.name, + content_type: options.content_type, + metadata: options.metadata ?? undefined, + ttl_ms: options.ttl_ms ?? undefined, + }); + if (!response.upload_url) { + throw new Error("API did not return an upload URL"); + } + return { + id: response.id, + name: response.name, + upload_url: response.upload_url, + }; +} + +export async function completeObject(id: string): Promise { + const client = getClient(); + await client.objects.complete(id); +} + +export async function uploadToPresignedUrl( + uploadUrl: string, + buffer: Buffer, +): Promise { + const response = await fetch(uploadUrl, { + method: "PUT", + body: buffer, + headers: { "Content-Length": buffer.length.toString() }, + }); + if (!response.ok) { + throw new Error(`Upload failed: HTTP ${response.status}`); + } +} + export interface ObjectDetailField { label: string; value: string; diff --git a/src/store/navigationStore.tsx b/src/store/navigationStore.tsx index a6cc54f9..0b465131 100644 --- a/src/store/navigationStore.tsx +++ b/src/store/navigationStore.tsx @@ -44,6 +44,7 @@ export type ScreenName = | "axon-detail" | "object-list" | "object-detail" + | "object-create" | "ssh-session" | "benchmark-list" | "benchmark-detail" diff --git a/src/utils/commands.ts b/src/utils/commands.ts index 685eec9f..19c9732c 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -1,19 +1,13 @@ -import { Command, Option } from "commander"; +import { Command } from "commander"; import { VERSION } from "../version.js"; import { createDevbox } from "../commands/devbox/create.js"; import { listDevboxes } from "../commands/devbox/list.js"; import { deleteDevbox } from "../commands/devbox/delete.js"; import { execCommand } from "../commands/devbox/exec.js"; import { uploadFile } from "../commands/devbox/upload.js"; -import { runloopBaseDomain } from "./config.js"; - -function publicOption(description: string): Option { - const opt = new Option("--public", description); - if (runloopBaseDomain() === "runloop.ai") { - opt.hideHelp(); - } - return opt; -} + +/** Helper for repeatable options (e.g., --metadata a=b --metadata c=d) */ +const collect = (val: string, prev: string[]) => [...prev, val]; /** * Creates and configures the Commander program with all commands. @@ -480,7 +474,11 @@ export function createProgram(): Command { blueprint .command("create") .description("Create a new blueprint") - .requiredOption("--name ", "Blueprint name (required)") + .option("--name ", "Blueprint name (required unless --base is used)") + .option( + "--base ", + "Base blueprint to duplicate (IDs start with bpt_)", + ) .option("--dockerfile ", "Dockerfile contents") .option("--dockerfile-path ", "Dockerfile path") .option("--system-setup-commands ", "System setup commands") @@ -492,7 +490,12 @@ export function createProgram(): Command { .option("--available-ports ", "Available ports") .option("--root", "Run as root") .option("--user ", "Run as this user (format: username:uid)") - .option("--metadata ", "Metadata tags (format: key=value)") + .option( + "--metadata ", + "Metadata tag (format: key=value), repeatable", + collect, + [], + ) .option( "-o, --output [format]", "Output format: text|json|yaml (default: json)", @@ -583,7 +586,12 @@ export function createProgram(): Command { .option("--available-ports ", "Available ports") .option("--root", "Run as root") .option("--user ", "Run as this user (format: username:uid)") - .option("--metadata ", "Metadata tags (format: key=value)") + .option( + "--metadata ", + "Metadata tag (format: key=value), repeatable", + collect, + [], + ) .option( "--ttl ", "TTL in seconds for the build context object (default: 3600)", @@ -668,7 +676,13 @@ export function createProgram(): Command { "--content-type ", "Content type: unspecified|text|binary|gzip|tar|tgz", ) - .addOption(publicOption("Make object publicly accessible")) + .option("--public", "Make object publicly accessible") + .option( + "--metadata ", + "Metadata tag (format: key=value), repeatable", + collect, + [], + ) .option( "-o, --output [format]", "Output format: text|json|yaml (default: text)", @@ -1069,6 +1083,20 @@ export function createProgram(): Command { await listAxonsCommand(options); }); + axon + .command("events ") + .description("List events for an axon") + .option("--limit ", "Number of events to fetch", "50") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (id, options) => { + const { listAxonEventsCommand } = + await import("../commands/axon/events.js"); + await listAxonEventsCommand(id, options); + }); + // Scenario commands const scenario = program .command("scenario") @@ -1087,6 +1115,21 @@ export function createProgram(): Command { await scenarioInfo(id, options); }); + scenario + .command("list") + .description("List scenario runs") + .option("--limit ", "Max scenario runs to return (0 = unlimited)", "0") + .option("--benchmark-run-id ", "Filter by benchmark run ID") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (options) => { + const { listScenarioRunsCommand } = + await import("../commands/scenario/list.js"); + await listScenarioRunsCommand(options); + }); + // Benchmark job commands const benchmarkJob = program .command("benchmark-job") @@ -1118,6 +1161,12 @@ export function createProgram(): Command { .option("--n-attempts ", "Number of attempts per scenario") .option("--n-concurrent-trials ", "Number of concurrent trials") .option("--timeout-multiplier ", "Timeout multiplier") + .option( + "--metadata ", + "Metadata tag (format: key=value), repeatable", + collect, + [], + ) .option( "-o, --output [format]", "Output format: text|json|yaml (default: text)", @@ -1218,13 +1267,12 @@ export function createProgram(): Command { .option("--package ", "Package name (for npm/pip sources)") .option("--registry-url ", "Registry URL (for npm/pip sources)") .option("--repository ", "Git repository URL (for git source)") - .option("--ref ", "Git ref - branch/tag/commit (for git source)") + .option("--ref ", "Git ref - branch or tag (for git source)") .option("--object-id ", "Object ID (for object source)") .option( "--setup-commands ", "Setup commands to run after installation", ) - .addOption(publicOption("Make agent publicly accessible")) .option( "-o, --output [format]", "Output format: text|json|yaml (default: text)", diff --git a/src/utils/metadata.ts b/src/utils/metadata.ts new file mode 100644 index 00000000..e743364f --- /dev/null +++ b/src/utils/metadata.ts @@ -0,0 +1,19 @@ +export function parseMetadata(metadata: string[]): Record { + const result: Record = {}; + for (const item of metadata) { + const eqIndex = item.indexOf("="); + if (eqIndex === -1) { + throw new Error(`Invalid metadata format: ${item}. Expected key=value`); + } + const key = item.substring(0, eqIndex); + if (!key) { + throw new Error(`Invalid metadata: key cannot be empty in "${item}"`); + } + if (key in result) { + throw new Error(`Duplicate metadata key: "${key}"`); + } + const value = item.substring(eqIndex + 1); + result[key] = value; + } + return result; +} diff --git a/tests/__tests__/commands/agent/show.test.ts b/tests/__tests__/commands/agent/show.test.ts index a015bd2a..edb132f6 100644 --- a/tests/__tests__/commands/agent/show.test.ts +++ b/tests/__tests__/commands/agent/show.test.ts @@ -41,7 +41,7 @@ describe("showAgentCommand", () => { expect(mockListAgents).not.toHaveBeenCalled(); expect(mockOutput).toHaveBeenCalledWith(sampleAgent, { format: undefined, - defaultFormat: "text", + defaultFormat: "json", }); }); @@ -59,7 +59,7 @@ describe("showAgentCommand", () => { expect(mockListAgents).toHaveBeenCalledWith({ name: "claude-code" }); expect(mockOutput).toHaveBeenCalledWith(newer, { format: undefined, - defaultFormat: "text", + defaultFormat: "json", }); }); @@ -97,7 +97,7 @@ describe("showAgentCommand", () => { expect(mockOutput).toHaveBeenCalledWith(sampleAgent, { format: "json", - defaultFormat: "text", + defaultFormat: "json", }); }); diff --git a/tests/__tests__/commands/axon/events.test.ts b/tests/__tests__/commands/axon/events.test.ts new file mode 100644 index 00000000..27f51729 --- /dev/null +++ b/tests/__tests__/commands/axon/events.test.ts @@ -0,0 +1,87 @@ +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; + +const mockListAxonEvents = jest.fn(); +jest.unstable_mockModule("@/services/axonService.js", () => ({ + listAxonEvents: mockListAxonEvents, +})); + +const mockOutput = jest.fn(); +const mockOutputError = jest.fn(); +const mockParseLimit = jest.fn(); +jest.unstable_mockModule("@/utils/output.js", () => ({ + output: mockOutput, + outputError: mockOutputError, + parseLimit: mockParseLimit, +})); + +const { listAxonEventsCommand } = await import("@/commands/axon/events.js"); + +describe("listAxonEventsCommand", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockParseLimit.mockReturnValue(Infinity); + }); + + it("outputs events via output() with defaultFormat json", async () => { + const events = [ + { + sequence: 1, + timestamp_ms: 1700000000000, + origin: "api", + source: "user", + event_type: "create", + payload: '{"key":"value"}', + }, + ]; + mockListAxonEvents.mockResolvedValue({ events, hasMore: false }); + + await listAxonEventsCommand("axn_1", {}); + + expect(mockOutput).toHaveBeenCalledWith(events, { + format: undefined, + defaultFormat: "json", + }); + }); + + it("defaults to limit 50 when parseLimit returns Infinity", async () => { + mockParseLimit.mockReturnValue(Infinity); + mockListAxonEvents.mockResolvedValue({ events: [], hasMore: false }); + + await listAxonEventsCommand("axn_1", {}); + + expect(mockListAxonEvents).toHaveBeenCalledWith("axn_1", { limit: 50 }); + }); + + it("uses explicit limit", async () => { + mockParseLimit.mockReturnValue(10); + mockListAxonEvents.mockResolvedValue({ events: [], hasMore: false }); + + await listAxonEventsCommand("axn_1", { limit: "10" }); + + expect(mockListAxonEvents).toHaveBeenCalledWith("axn_1", { limit: 10 }); + }); + + it("passes format option through to output()", async () => { + const events = [{ sequence: 1, timestamp_ms: 1700000000000, origin: "api", source: "user", event_type: "create", payload: "{}" }]; + mockListAxonEvents.mockResolvedValue({ events, hasMore: false }); + + await listAxonEventsCommand("axn_1", { output: "yaml" }); + + expect(mockOutput).toHaveBeenCalledWith(events, { + format: "yaml", + defaultFormat: "json", + }); + }); + + it("handles API error gracefully", async () => { + const error = new Error("Network error"); + mockListAxonEvents.mockRejectedValue(error); + + await listAxonEventsCommand("axn_1", {}); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to get axon events", + error, + ); + }); +}); diff --git a/tests/__tests__/commands/blueprint/create.test.ts b/tests/__tests__/commands/blueprint/create.test.ts new file mode 100644 index 00000000..a0314ac2 --- /dev/null +++ b/tests/__tests__/commands/blueprint/create.test.ts @@ -0,0 +1,387 @@ +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; + +const mockRetrieve = jest.fn(); +const mockList = jest.fn(); +const mockCreate = jest.fn(); + +jest.unstable_mockModule("@/utils/client.js", () => ({ + getClient: () => ({ + blueprints: { + retrieve: mockRetrieve, + list: mockList, + create: mockCreate, + }, + }), +})); + +const mockOutput = jest.fn(); +const mockOutputError = jest.fn(); +jest.unstable_mockModule("@/utils/output.js", () => ({ + output: mockOutput, + outputError: mockOutputError, +})); + +jest.unstable_mockModule("fs/promises", () => ({ + readFile: jest + .fn<() => Promise>() + .mockResolvedValue("FROM ubuntu:22.04\nRUN echo hello"), +})); + +const { createBlueprint } = await import("@/commands/blueprint/create.js"); + +describe("createBlueprint", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("without --base", () => { + it("creates a blueprint with name and options", async () => { + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ name: "my-bp", resources: "LARGE" }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: "my-bp", + launch_parameters: { resource_size_request: "LARGE" }, + }), + ); + }); + + it("errors when --name is missing and no --base", async () => { + await createBlueprint({}); + + expect(mockOutputError).toHaveBeenCalledWith( + "--name is required (or use --base to derive one)", + ); + expect(mockCreate).not.toHaveBeenCalled(); + }); + }); + + describe("with --base", () => { + const sourceBlueprint = { + id: "bpt_abc", + name: "my-blueprint", + parameters: { + name: "my-blueprint", + dockerfile: "FROM ubuntu:22.04", + system_setup_commands: ["apt install curl"], + launch_parameters: { keep_alive_time_seconds: 3600 }, + secrets: { API_KEY: "ref_123" }, + file_mounts: { "/data": "content" }, + code_mounts: [{ repo_name: "test", repo_owner: "owner" }], + }, + metadata: { env: "staging" }, + }; + + it("looks up base by ID when starts with bpt_", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "bpt_abc" }); + + expect(mockRetrieve).toHaveBeenCalledWith("bpt_abc"); + expect(mockList).not.toHaveBeenCalled(); + }); + + it("looks up base by name", async () => { + mockList.mockResolvedValue({ blueprints: [sourceBlueprint] }); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "my-blueprint" }); + + expect(mockList).toHaveBeenCalledWith({ name: "my-blueprint" }); + expect(mockRetrieve).not.toHaveBeenCalled(); + }); + + it("uses exact name match when available", async () => { + const other = { + ...sourceBlueprint, + id: "bpt_other", + name: "my-blueprint-v2", + }; + mockList.mockResolvedValue({ blueprints: [other, sourceBlueprint] }); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "my-blueprint" }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ base_blueprint_id: "bpt_abc" }), + ); + }); + + it("falls back to first result when no exact match", async () => { + const fuzzy = { + ...sourceBlueprint, + id: "bpt_fuzzy", + name: "my-blueprint-v2", + }; + mockList.mockResolvedValue({ blueprints: [fuzzy] }); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "my-blueprint" }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ base_blueprint_id: "bpt_fuzzy" }), + ); + }); + + it("errors when base not found", async () => { + mockList.mockResolvedValue({ blueprints: [] }); + + await createBlueprint({ base: "nonexistent" }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Base blueprint not found: nonexistent", + ); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("defaults name to {base}-copy", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "bpt_abc" }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ name: "my-blueprint-copy" }), + ); + }); + + it("uses fallback name when base has no name", async () => { + mockRetrieve.mockResolvedValue({ ...sourceBlueprint, name: "" }); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "bpt_abc" }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ name: "blueprint-copy" }), + ); + }); + + it("custom name via --name overrides default", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "bpt_abc", name: "custom-name" }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ name: "custom-name" }), + ); + }); + + it("copies all source parameters when no overrides given", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "bpt_abc" }); + + const call = mockCreate.mock.calls[0]![0] as any; + expect(call).toEqual( + expect.objectContaining({ + name: "my-blueprint-copy", + base_blueprint_id: "bpt_abc", + system_setup_commands: ["apt install curl"], + launch_parameters: { keep_alive_time_seconds: 3600 }, + secrets: { API_KEY: "ref_123" }, + file_mounts: { "/data": "content" }, + code_mounts: [{ repo_name: "test", repo_owner: "owner" }], + metadata: { env: "staging" }, + }), + ); + expect(call.dockerfile).toBeUndefined(); + }); + + it("handles base with no parameters", async () => { + mockRetrieve.mockResolvedValue({ + id: "bpt_min", + name: "minimal", + parameters: undefined, + metadata: undefined, + }); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "bpt_min" }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: "minimal-copy", + base_blueprint_id: "bpt_min", + }), + ); + }); + + it("overrides resources via --resources", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "bpt_abc", resources: "LARGE" }); + + const call = mockCreate.mock.calls[0]![0] as any; + expect(call.launch_parameters.resource_size_request).toBe("LARGE"); + expect(call.base_blueprint_id).toBe("bpt_abc"); + }); + + it("overrides architecture", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "bpt_abc", architecture: "x86_64" }); + + const call = mockCreate.mock.calls[0]![0] as any; + expect(call.launch_parameters.architecture).toBe("x86_64"); + }); + + it("overrides system-setup-commands", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ + base: "bpt_abc", + systemSetupCommands: ["apt install git"], + }); + + const call = mockCreate.mock.calls[0]![0] as any; + expect(call.system_setup_commands).toEqual(["apt install git"]); + }); + + it("overrides metadata via --metadata", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ + base: "bpt_abc", + metadata: ["env=production", "team=infra"], + }); + + const call = mockCreate.mock.calls[0]![0] as any; + expect(call.metadata).toEqual({ env: "production", team: "infra" }); + }); + + it("falls back to source metadata when no --metadata override", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "bpt_abc" }); + + const call = mockCreate.mock.calls[0]![0] as any; + expect(call.metadata).toEqual({ env: "staging" }); + }); + + it("--dockerfile replaces base_blueprint_id (mutually exclusive)", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ + base: "bpt_abc", + dockerfile: "FROM node:20\nRUN npm install", + }); + + const call = mockCreate.mock.calls[0]![0] as any; + expect(call.dockerfile).toBe("FROM node:20\nRUN npm install"); + expect(call.base_blueprint_id).toBeUndefined(); + }); + + it("--dockerfile-path replaces base_blueprint_id", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ + base: "bpt_abc", + dockerfilePath: "/path/to/Dockerfile", + }); + + const call = mockCreate.mock.calls[0]![0] as any; + expect(call.dockerfile).toBe("FROM ubuntu:22.04\nRUN echo hello"); + expect(call.base_blueprint_id).toBeUndefined(); + }); + + it("overrides available-ports", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ + base: "bpt_abc", + availablePorts: ["8080", "3000"], + }); + + const call = mockCreate.mock.calls[0]![0] as any; + expect(call.launch_parameters.available_ports).toEqual([8080, 3000]); + }); + + it("overrides user via --root", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "bpt_abc", root: true }); + + const call = mockCreate.mock.calls[0]![0] as any; + expect(call.launch_parameters.user_parameters).toEqual({ + username: "root", + uid: 0, + }); + }); + + it("overrides user via --user", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "bpt_abc", user: "appuser:1001" }); + + const call = mockCreate.mock.calls[0]![0] as any; + expect(call.launch_parameters.user_parameters).toEqual({ + username: "appuser", + uid: 1001, + }); + }); + + it("preserves source params not covered by overrides", async () => { + mockRetrieve.mockResolvedValue(sourceBlueprint); + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ base: "bpt_abc", resources: "SMALL" }); + + const call = mockCreate.mock.calls[0]![0] as any; + expect(call.secrets).toEqual({ API_KEY: "ref_123" }); + expect(call.file_mounts).toEqual({ "/data": "content" }); + expect(call.code_mounts).toEqual([ + { repo_name: "test", repo_owner: "owner" }, + ]); + }); + }); + + it("outputs in default json format", async () => { + const newBlueprint = { id: "bpt_new", name: "my-bp" }; + mockCreate.mockResolvedValue(newBlueprint); + + await createBlueprint({ name: "my-bp" }); + + expect(mockOutput).toHaveBeenCalledWith(newBlueprint, { + format: undefined, + defaultFormat: "json", + }); + }); + + it("outputs in custom format when specified", async () => { + mockCreate.mockResolvedValue({ id: "bpt_new" }); + + await createBlueprint({ name: "my-bp", output: "yaml" }); + + expect(mockOutput).toHaveBeenCalledWith(expect.anything(), { + format: "yaml", + defaultFormat: "json", + }); + }); + + it("handles API error gracefully", async () => { + const error = new Error("API error"); + mockCreate.mockRejectedValue(error); + + await createBlueprint({ name: "my-bp" }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to create blueprint", + error, + ); + }); +}); diff --git a/tests/__tests__/commands/object/upload.test.ts b/tests/__tests__/commands/object/upload.test.ts index 3ee57129..1c884e2a 100644 --- a/tests/__tests__/commands/object/upload.test.ts +++ b/tests/__tests__/commands/object/upload.test.ts @@ -6,7 +6,59 @@ import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals import { mkdtemp, writeFile, mkdir, rm, chmod, utimes, symlink } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; -import { parseTar } from "nanotar"; +import { Readable } from "stream"; +import { createGunzip } from "zlib"; +import { pipeline } from "stream/promises"; +import tar from "tar-stream"; +import type { Headers } from "tar-stream"; + +interface ExtractedEntry { + header: Headers; + data: Buffer; +} + +async function extractTar(buffer: Buffer): Promise { + const extract = tar.extract(); + const entries: ExtractedEntry[] = []; + const done = new Promise((resolve, reject) => { + extract.on("entry", (header, stream, next) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("end", () => { + entries.push({ header, data: Buffer.concat(chunks) }); + next(); + }); + stream.resume(); + }); + extract.on("finish", resolve); + extract.on("error", reject); + }); + Readable.from(buffer).pipe(extract); + await done; + return entries; +} + +async function extractTgz(buffer: Buffer): Promise { + const gunzip = createGunzip(); + const extract = tar.extract(); + const entries: ExtractedEntry[] = []; + const done = new Promise((resolve, reject) => { + extract.on("entry", (header, stream, next) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("end", () => { + entries.push({ header, data: Buffer.concat(chunks) }); + next(); + }); + stream.resume(); + }); + extract.on("finish", resolve); + extract.on("error", reject); + }); + await pipeline(Readable.from(buffer), gunzip, extract); + await done; + return entries; +} // Mock client and output const mockCreate = jest.fn(); @@ -75,8 +127,8 @@ describe("createTarBuffer", () => { expect(buffer).toBeInstanceOf(Buffer); expect(buffer.length).toBeGreaterThan(0); - const entries = parseTar(buffer); - const names = entries.map((e) => e.name); + const entries = await extractTar(buffer); + const names = entries.map((e) => e.header.name); expect(names).toHaveLength(2); expect(names.some((n) => n.endsWith("a.txt"))).toBe(true); expect(names.some((n) => n.endsWith("b.txt"))).toBe(true); @@ -92,9 +144,12 @@ describe("createTarBuffer", () => { ); expect(buffer).toBeInstanceOf(Buffer); - // Gzip magic bytes: 0x1f 0x8b expect(buffer[0]).toBe(0x1f); expect(buffer[1]).toBe(0x8b); + + const entries = await extractTgz(buffer); + expect(entries).toHaveLength(1); + expect(entries[0].data.toString()).toBe("compressed content"); }); it("creates a tar from a directory", async () => { @@ -108,10 +163,11 @@ describe("createTarBuffer", () => { expect(buffer).toBeInstanceOf(Buffer); expect(buffer.length).toBeGreaterThan(0); - const entries = parseTar(buffer); - const dirEntry = entries.find((e) => e.name.endsWith("mydir/")); - const fileEntry = entries.find((e) => e.name.endsWith("nested.txt")); + const entries = await extractTar(buffer); + const dirEntry = entries.find((e) => e.header.name.endsWith("mydir/")); + const fileEntry = entries.find((e) => e.header.name.endsWith("nested.txt")); expect(dirEntry).toBeDefined(); + expect(dirEntry!.header.type).toBe("directory"); expect(fileEntry).toBeDefined(); }); @@ -121,12 +177,9 @@ describe("createTarBuffer", () => { const { createTarBuffer } = await import("@/commands/object/upload.js"); const buffer = await createTarBuffer([join(testDir, "file.txt")], false); - // Verify uid/gid by reading the raw tar header bytes directly. - // nanotar's parseTar has an octal parsing quirk, so read the raw field. - const uid = buffer.toString("ascii", 108, 115).replace(/\0/g, "").trim(); - const gid = buffer.toString("ascii", 116, 123).replace(/\0/g, "").trim(); - expect(parseInt(uid, 8)).toBe(1000); - expect(parseInt(gid, 8)).toBe(1000); + const entries = await extractTar(buffer); + expect(entries[0].header.uid).toBe(1000); + expect(entries[0].header.gid).toBe(1000); }); it("sets mode 644 for non-executable files and 755 for executable files", async () => { @@ -139,11 +192,11 @@ describe("createTarBuffer", () => { const { createTarBuffer } = await import("@/commands/object/upload.js"); const buffer = await createTarBuffer([normalFile, execFile], false); - const entries = parseTar(buffer); - const normal = entries.find((e) => e.name.endsWith("normal.txt")); - const exec = entries.find((e) => e.name.endsWith("script.sh")); - expect(normal?.attrs?.mode).toContain("644"); - expect(exec?.attrs?.mode).toContain("755"); + const entries = await extractTar(buffer); + const normal = entries.find((e) => e.header.name.endsWith("normal.txt")); + const exec = entries.find((e) => e.header.name.endsWith("script.sh")); + expect(normal!.header.mode).toBe(0o644); + expect(exec!.header.mode).toBe(0o755); }); it("sets mode 755 for directories", async () => { @@ -154,39 +207,72 @@ describe("createTarBuffer", () => { const { createTarBuffer } = await import("@/commands/object/upload.js"); const buffer = await createTarBuffer([subDir], false); - const entries = parseTar(buffer); - const dir = entries.find((e) => e.name.endsWith("subdir/")); - expect(dir?.attrs?.mode).toContain("755"); + const entries = await extractTar(buffer); + const dir = entries.find((e) => e.header.name.endsWith("subdir/")); + expect(dir!.header.mode).toBe(0o755); }); - it("errors on symlinks inside a directory tree", async () => { + it("stores symlinks as symlink entries with correct linkname", async () => { + const realFile = join(testDir, "real.txt"); + const linkFile = join(testDir, "link.txt"); + await writeFile(realFile, "real content"); + await symlink(realFile, linkFile); + + const { createTarBuffer } = await import("@/commands/object/upload.js"); + const buffer = await createTarBuffer([realFile, linkFile], false); + + const entries = await extractTar(buffer); + expect(entries).toHaveLength(2); + const realEntry = entries.find((e) => e.header.name.endsWith("real.txt")); + const linkEntry = entries.find((e) => e.header.name.endsWith("link.txt")); + expect(realEntry).toBeDefined(); + expect(realEntry!.header.type).toBe("file"); + expect(realEntry!.data.toString()).toBe("real content"); + expect(linkEntry).toBeDefined(); + expect(linkEntry!.header.type).toBe("symlink"); + expect(linkEntry!.header.linkname).toBe(realFile); + }); + + it("stores symlinks inside a directory tree", async () => { const subDir = join(testDir, "with-symlink"); await mkdir(subDir); await writeFile(join(subDir, "real.txt"), "real content"); await symlink(join(subDir, "real.txt"), join(subDir, "link.txt")); const { createTarBuffer } = await import("@/commands/object/upload.js"); - await expect(createTarBuffer([subDir], false)).rejects.toThrow( - /symlink/i, - ); + const buffer = await createTarBuffer([subDir], false); + + const entries = await extractTar(buffer); + const names = entries.map((e) => e.header.name); + expect(names.some((n) => n.endsWith("real.txt"))).toBe(true); + expect(names.some((n) => n.endsWith("link.txt"))).toBe(true); + const linkEntry = entries.find((e) => e.header.name.endsWith("link.txt")); + expect(linkEntry!.header.type).toBe("symlink"); + }); + + it("rejects duplicate paths", async () => { + const filePath = join(testDir, "dup.txt"); + await writeFile(filePath, "content"); + + const { createTarBuffer } = await import("@/commands/object/upload.js"); + await expect( + createTarBuffer([filePath, filePath], false), + ).rejects.toThrow(/Duplicate paths/); }); it("preserves mtime from the filesystem", async () => { const filePath = join(testDir, "dated.txt"); await writeFile(filePath, "content"); - // Set a known mtime: 2024-01-15T00:00:00Z const knownTime = new Date("2024-01-15T00:00:00Z"); await utimes(filePath, knownTime, knownTime); const { createTarBuffer } = await import("@/commands/object/upload.js"); const buffer = await createTarBuffer([filePath], false); - const entries = parseTar(buffer); - const entry = entries.find((e) => e.name.endsWith("dated.txt")); - expect(entry?.attrs?.mtime).toBeDefined(); - // parseTar returns mtime in seconds (raw tar format) - const expectedSec = Math.floor(knownTime.getTime() / 1000); - expect(entry?.attrs?.mtime).toBe(expectedSec); + const entries = await extractTar(buffer); + const entry = entries.find((e) => e.header.name.endsWith("dated.txt")); + expect(entry!.header.mtime).toBeDefined(); + expect(entry!.header.mtime!.getTime()).toBe(knownTime.getTime()); }); }); @@ -343,7 +429,6 @@ describe("uploadObject", () => { name: "existing-archive", content_type: "tar", }); - // Should upload the raw file content, not create a tar of the tar const fetchCall = mockFetch.mock.calls[0]; const body = fetchCall[1]?.body as Buffer; expect(body.toString()).toBe("fake tar content"); diff --git a/tests/__tests__/commands/scenario/list.test.ts b/tests/__tests__/commands/scenario/list.test.ts new file mode 100644 index 00000000..0a9ed593 --- /dev/null +++ b/tests/__tests__/commands/scenario/list.test.ts @@ -0,0 +1,155 @@ +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; + +const mockListScenarioRuns = jest.fn(); +jest.unstable_mockModule("@/services/benchmarkService.js", () => ({ + listScenarioRuns: mockListScenarioRuns, +})); + +const mockOutput = jest.fn(); +const mockOutputError = jest.fn(); +const mockParseLimit = jest.fn(); +jest.unstable_mockModule("@/utils/output.js", () => ({ + output: mockOutput, + outputError: mockOutputError, + parseLimit: mockParseLimit, +})); + +const { listScenarioRunsCommand } = await import( + "@/commands/scenario/list.js" +); + +describe("listScenarioRunsCommand", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockParseLimit.mockReturnValue(Infinity); + }); + + it("outputs runs via output() with defaultFormat json", async () => { + const runs = [ + { id: "sr_1", name: "run-1", state: "completed", start_time_ms: 1000 }, + ]; + mockListScenarioRuns.mockResolvedValue({ + scenarioRuns: runs, + hasMore: false, + }); + + await listScenarioRunsCommand({}); + + expect(mockOutput).toHaveBeenCalledWith(runs, { + format: undefined, + defaultFormat: "json", + }); + }); + + it("fetches multiple pages when hasMore is true", async () => { + mockListScenarioRuns + .mockResolvedValueOnce({ + scenarioRuns: [ + { id: "sr_1", name: "run-1", state: "completed", start_time_ms: 1000 }, + ], + hasMore: true, + }) + .mockResolvedValueOnce({ + scenarioRuns: [ + { id: "sr_2", name: "run-2", state: "completed", start_time_ms: 2000 }, + ], + hasMore: false, + }); + + await listScenarioRunsCommand({}); + + expect(mockListScenarioRuns).toHaveBeenCalledTimes(2); + expect(mockListScenarioRuns).toHaveBeenLastCalledWith( + expect.objectContaining({ startingAfter: "sr_1" }), + ); + }); + + it("stops fetching when limit is reached", async () => { + mockParseLimit.mockReturnValue(1); + mockListScenarioRuns.mockResolvedValue({ + scenarioRuns: [ + { id: "sr_1", name: "run-1", state: "completed", start_time_ms: 1000 }, + ], + hasMore: true, + }); + + await listScenarioRunsCommand({ limit: "1" }); + + expect(mockListScenarioRuns).toHaveBeenCalledTimes(1); + }); + + it("sorts results by start_time_ms ascending", async () => { + const runs = [ + { id: "sr_2", name: "run-2", state: "completed", start_time_ms: 2000 }, + { id: "sr_1", name: "run-1", state: "completed", start_time_ms: 1000 }, + ]; + mockListScenarioRuns.mockResolvedValue({ + scenarioRuns: runs, + hasMore: false, + }); + + await listScenarioRunsCommand({}); + + const outputData = mockOutput.mock.calls[0][0] as typeof runs; + expect(outputData[0].id).toBe("sr_1"); + expect(outputData[1].id).toBe("sr_2"); + }); + + it("passes format option through to output()", async () => { + const runs = [ + { id: "sr_1", name: "run-1", state: "completed", start_time_ms: 1000 }, + ]; + mockListScenarioRuns.mockResolvedValue({ + scenarioRuns: runs, + hasMore: false, + }); + + await listScenarioRunsCommand({ output: "json" }); + + expect(mockOutput).toHaveBeenCalledWith(runs, { + format: "json", + defaultFormat: "json", + }); + }); + + it("passes benchmarkRunId filter", async () => { + mockListScenarioRuns.mockResolvedValue({ + scenarioRuns: [], + hasMore: false, + }); + + await listScenarioRunsCommand({ benchmarkRunId: "br_1" }); + + expect(mockListScenarioRuns).toHaveBeenCalledWith( + expect.objectContaining({ benchmarkRunId: "br_1" }), + ); + }); + + it("handles API error gracefully", async () => { + const error = new Error("Network error"); + mockListScenarioRuns.mockRejectedValue(error); + + await listScenarioRunsCommand({}); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to list scenario runs", + error, + ); + }); + + it("adjusts page size based on limit", async () => { + mockParseLimit.mockReturnValue(5); + mockListScenarioRuns.mockResolvedValue({ + scenarioRuns: [ + { id: "sr_1", name: "run-1", state: "completed", start_time_ms: 1000 }, + ], + hasMore: false, + }); + + await listScenarioRunsCommand({ limit: "5" }); + + expect(mockListScenarioRuns).toHaveBeenCalledWith( + expect.objectContaining({ limit: 5 }), + ); + }); +}); diff --git a/tests/__tests__/components/allocateSectionLines.test.ts b/tests/__tests__/components/allocateSectionLines.test.ts new file mode 100644 index 00000000..7ae39d15 --- /dev/null +++ b/tests/__tests__/components/allocateSectionLines.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "@jest/globals"; +import { + allocateSectionLines, + type SectionAllocation, +} from "../../../src/components/ResourceDetailPage.js"; +import type { DetailSection } from "../../../src/components/resourceDetailTypes.js"; + +function makeSection(title: string, fieldCount: number): DetailSection { + return { + title, + fields: Array.from({ length: fieldCount }, (_, i) => ({ + label: `${title}-field-${i}`, + value: `value-${i}`, + })), + }; +} + +function makeSectionWithNulls( + title: string, + validCount: number, + nullCount: number, +): DetailSection { + return { + title, + fields: [ + ...Array.from({ length: validCount }, (_, i) => ({ + label: `${title}-field-${i}`, + value: `value-${i}`, + })), + ...Array.from({ length: nullCount }, (_, i) => ({ + label: `${title}-null-${i}`, + value: undefined as unknown as string, + })), + ], + }; +} + +describe("allocateSectionLines", () => { + it("zero available lines puts all sections in sectionViewRefs", () => { + const sections = [makeSection("A", 3), makeSection("B", 2)]; + const result = allocateSectionLines(sections, 0); + + expect(result.visibleFieldCount).toEqual([0, 0]); + expect(result.sectionViewRefs).toHaveLength(2); + expect(result.sectionViewRefs[0].partiallyVisible).toBe(false); + expect(result.sectionViewRefs[1].partiallyVisible).toBe(false); + }); + + it("fewer lines than sections gives headers only, no fields", () => { + const sections = [ + makeSection("A", 3), + makeSection("B", 3), + makeSection("C", 3), + ]; + const result = allocateSectionLines(sections, 2); + + expect(result.visibleFieldCount).toEqual([0, 0, 0]); + // All 3 sections should be in sectionViewRefs + expect(result.sectionViewRefs).toHaveLength(3); + // First 2 are partially visible (have header space), 3rd is not + expect(result.sectionViewRefs[0].partiallyVisible).toBe(true); + expect(result.sectionViewRefs[1].partiallyVisible).toBe(true); + expect(result.sectionViewRefs[2].partiallyVisible).toBe(false); + }); + + it("all fields fit when enough lines available", () => { + const sections = [makeSection("A", 2), makeSection("B", 2)]; + // 2 sections * 2 (header + view row) + 4 fields = 8 lines needed + const result = allocateSectionLines(sections, 100); + + expect(result.visibleFieldCount).toEqual([2, 2]); + expect(result.sectionViewRefs).toHaveLength(0); + }); + + it("single-field sections get priority", () => { + const sections = [makeSection("Big", 10), makeSection("Single", 1)]; + // Enough for headers (4 lines) + 1 field only + const result = allocateSectionLines(sections, 5); + + // Single-field section should get its 1 field guaranteed + expect(result.visibleFieldCount[1]).toBe(1); + // No sectionViewRef for Single since all its fields are visible + const singleRef = result.sectionViewRefs.find( + (r) => r.section.title === "Single", + ); + expect(singleRef).toBeUndefined(); + }); + + it("partial visibility creates sectionViewRef with partiallyVisible=true", () => { + const sections = [makeSection("A", 10)]; + // 1 section * 2 (header + view row) + some field lines + const result = allocateSectionLines(sections, 5); + + expect(result.visibleFieldCount[0]).toBeLessThan(10); + expect(result.visibleFieldCount[0]).toBeGreaterThan(0); + expect(result.sectionViewRefs).toHaveLength(1); + expect(result.sectionViewRefs[0].partiallyVisible).toBe(true); + }); + + it("sections with all undefined/null fields are not in sectionViewRefs", () => { + const sections = [ + makeSection("Valid", 3), + makeSectionWithNulls("AllNull", 0, 5), + ]; + const result = allocateSectionLines(sections, 0); + + // Only the Valid section should appear in sectionViewRefs + expect(result.sectionViewRefs).toHaveLength(1); + expect(result.sectionViewRefs[0].section.title).toBe("Valid"); + }); + + it("empty section (0 valid fields) gets visibleFieldCount=0", () => { + const sections = [makeSectionWithNulls("Empty", 0, 3)]; + const result = allocateSectionLines(sections, 100); + + expect(result.visibleFieldCount).toEqual([0]); + expect(result.sectionViewRefs).toHaveLength(0); + }); + + it("distributes remaining lines to first sections after singles", () => { + const sections = [ + makeSection("A", 5), + makeSection("B", 5), + makeSection("Single", 1), + ]; + // 3 sections * 2 (header + view) = 6 overhead + // If we have 11 lines: 6 overhead + 5 field lines + // Single gets 1, remaining 4 go to A first + const result = allocateSectionLines(sections, 11); + + expect(result.visibleFieldCount[2]).toBe(1); // Single gets its 1 + expect(result.visibleFieldCount[0]).toBe(4); // A gets remaining + expect(result.visibleFieldCount[1]).toBe(0); // B gets none + }); + + it("handles single section with exact fit", () => { + const sections = [makeSection("A", 3)]; + // 1 section * 2 (header + view) + 3 field lines = 5 + const result = allocateSectionLines(sections, 5); + + expect(result.visibleFieldCount).toEqual([3]); + expect(result.sectionViewRefs).toHaveLength(0); + }); + + it("handles many sections with tight budget", () => { + const sections = Array.from({ length: 5 }, (_, i) => + makeSection(`Section${i}`, 3), + ); + // 5 sections * 2 = 10 overhead, only 10 lines available → no field lines + const result = allocateSectionLines(sections, 10); + + // All sections get 0 fields and appear in sectionViewRefs + result.visibleFieldCount.forEach((count) => expect(count).toBe(0)); + expect(result.sectionViewRefs).toHaveLength(5); + }); +}); diff --git a/tests/__tests__/services/axonService.test.ts b/tests/__tests__/services/axonService.test.ts new file mode 100644 index 00000000..1dac75da --- /dev/null +++ b/tests/__tests__/services/axonService.test.ts @@ -0,0 +1,220 @@ +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; + +const mockList = jest.fn(); +const mockRetrieve = jest.fn(); +const mockSqlQuery = jest.fn(); + +jest.unstable_mockModule("@/utils/client.js", () => ({ + getClient: () => ({ + axons: { + list: mockList, + retrieve: mockRetrieve, + sql: { query: mockSqlQuery }, + }, + }), +})); + +const { listActiveAxons, getAxon, listAxonEvents, executeAxonSql } = + await import("@/services/axonService.js"); + +describe("listActiveAxons", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("passes limit, startingAfter, includeTotalCount", async () => { + mockList.mockResolvedValue({ axons: [], has_more: false }); + + await listActiveAxons({ + limit: 10, + startingAfter: "axn_cursor", + includeTotalCount: true, + }); + + expect(mockList).toHaveBeenCalledWith({ + limit: 10, + starting_after: "axn_cursor", + include_total_count: true, + }); + }); + + it("smart search: axn_ prefix sets query.id", async () => { + mockList.mockResolvedValue({ axons: [], has_more: false }); + + await listActiveAxons({ search: "axn_123" }); + + expect(mockList).toHaveBeenCalledWith( + expect.objectContaining({ id: "axn_123" }), + ); + }); + + it("smart search: non-axn_ sets query.name", async () => { + mockList.mockResolvedValue({ axons: [], has_more: false }); + + await listActiveAxons({ search: "my-axon" }); + + expect(mockList).toHaveBeenCalledWith( + expect.objectContaining({ name: "my-axon" }), + ); + }); + + it("explicit name/id override search", async () => { + mockList.mockResolvedValue({ axons: [], has_more: false }); + + await listActiveAxons({ search: "my-axon", name: "override", id: "axn_override" }); + + expect(mockList).toHaveBeenCalledWith( + expect.objectContaining({ name: "override", id: "axn_override" }), + ); + }); + + it("returns axons, hasMore, totalCount", async () => { + mockList.mockResolvedValue({ + axons: [{ id: "axn_1" }], + has_more: true, + total_count: 42, + }); + + const result = await listActiveAxons({ limit: 10 }); + expect(result.axons).toHaveLength(1); + expect(result.hasMore).toBe(true); + expect(result.totalCount).toBe(42); + }); + + it("totalCount defaults to axons.length when absent", async () => { + mockList.mockResolvedValue({ + axons: [{ id: "axn_1" }, { id: "axn_2" }], + has_more: false, + }); + + const result = await listActiveAxons({}); + expect(result.totalCount).toBe(2); + }); + + it("calls list with undefined when no query options", async () => { + mockList.mockResolvedValue({ axons: [], has_more: false }); + + await listActiveAxons({}); + + expect(mockList).toHaveBeenCalledWith(undefined); + }); +}); + +describe("getAxon", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("retrieves by id", async () => { + const mockAxon = { id: "axn_1", name: "test-axon" }; + mockRetrieve.mockResolvedValue(mockAxon); + + const result = await getAxon("axn_1"); + expect(result).toEqual(mockAxon); + expect(mockRetrieve).toHaveBeenCalledWith("axn_1"); + }); +}); + +describe("listAxonEvents", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("calls sql.query with correct SQL and params [limit+1, offset]", async () => { + mockSqlQuery.mockResolvedValue({ + rows: [], + meta: { duration_ms: 10, changes: 0 }, + }); + + await listAxonEvents("axn_1", { limit: 20, offset: 5 }); + + expect(mockSqlQuery).toHaveBeenCalledWith("axn_1", { + sql: expect.stringContaining("rl_axon_events"), + params: [21, 5], + }); + }); + + it("uses default limit=50, offset=0", async () => { + mockSqlQuery.mockResolvedValue({ + rows: [], + meta: { duration_ms: 10, changes: 0 }, + }); + + await listAxonEvents("axn_1"); + + expect(mockSqlQuery).toHaveBeenCalledWith("axn_1", { + sql: expect.any(String), + params: [51, 0], + }); + }); + + it("hasMore=true when rows > limit, slices to limit", async () => { + const rows = Array.from({ length: 6 }, (_, i) => [ + i, 1700000000000, "origin", "source", "event", "payload", + ]); + mockSqlQuery.mockResolvedValue({ + rows, + meta: { duration_ms: 10, changes: 0 }, + }); + + const result = await listAxonEvents("axn_1", { limit: 5 }); + expect(result.hasMore).toBe(true); + expect(result.events).toHaveLength(5); + }); + + it("hasMore=false when rows <= limit", async () => { + const rows = Array.from({ length: 3 }, (_, i) => [ + i, 1700000000000, "origin", "source", "event", "payload", + ]); + mockSqlQuery.mockResolvedValue({ + rows, + meta: { duration_ms: 10, changes: 0 }, + }); + + const result = await listAxonEvents("axn_1", { limit: 5 }); + expect(result.hasMore).toBe(false); + expect(result.events).toHaveLength(3); + }); + + it("maps row arrays to AxonEvent objects", async () => { + mockSqlQuery.mockResolvedValue({ + rows: [[42, 1700000000000, "my-origin", "my-source", "my-type", '{"data":1}']], + meta: { duration_ms: 5, changes: 0 }, + }); + + const result = await listAxonEvents("axn_1", { limit: 10 }); + expect(result.events[0]).toEqual({ + sequence: 42, + timestamp_ms: 1700000000000, + origin: "my-origin", + source: "my-source", + event_type: "my-type", + payload: '{"data":1}', + }); + }); +}); + +describe("executeAxonSql", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("calls client.axons.sql.query with axonId, sql, params", async () => { + const mockResult = { rows: [], columns: [], meta: { duration_ms: 0, changes: 0 } }; + mockSqlQuery.mockResolvedValue(mockResult); + + const result = await executeAxonSql("axn_1", "SELECT 1", []); + expect(mockSqlQuery).toHaveBeenCalledWith("axn_1", { sql: "SELECT 1", params: [] }); + expect(result).toEqual(mockResult); + }); + + it("passes undefined params when not provided", async () => { + mockSqlQuery.mockResolvedValue({ rows: [], columns: [], meta: {} }); + + await executeAxonSql("axn_1", "SELECT 1"); + expect(mockSqlQuery).toHaveBeenCalledWith("axn_1", { + sql: "SELECT 1", + params: undefined, + }); + }); +}); diff --git a/tests/__tests__/services/benchmarkJobService.test.ts b/tests/__tests__/services/benchmarkJobService.test.ts new file mode 100644 index 00000000..309e79c3 --- /dev/null +++ b/tests/__tests__/services/benchmarkJobService.test.ts @@ -0,0 +1,502 @@ +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; + +const mockCreate = jest.fn(); +const mockList = jest.fn(); +const mockRetrieve = jest.fn(); +const mockBenchmarkRunsRetrieve = jest.fn(); +const mockBenchmarkRunsListScenarioRuns = jest.fn(); + +jest.unstable_mockModule("@/utils/client.js", () => ({ + getClient: () => ({ + benchmarkJobs: { + create: mockCreate, + list: mockList, + retrieve: mockRetrieve, + }, + benchmarkRuns: { + retrieve: mockBenchmarkRunsRetrieve, + listScenarioRuns: mockBenchmarkRunsListScenarioRuns, + }, + }), +})); + +const { + buildCloneParams, + createBenchmarkJob, + listBenchmarkJobs, + getBenchmarkJob, + getBenchmarkRun, + listBenchmarkRunScenarioRuns, +} = await import("@/services/benchmarkJobService.js"); + +describe("buildCloneParams", () => { + it("extracts scenario_ids from job_spec", () => { + const job = { + id: "bj_1", + name: "test-job", + job_spec: { scenario_ids: ["s1", "s2"], agent_configs: [] }, + job_source: null, + } as any; + + const params = buildCloneParams(job); + expect(params.cloneSourceType).toBe("scenarios"); + expect(params.initialScenarioIds).toBe("s1,s2"); + }); + + it("extracts benchmark_id from job_spec", () => { + const job = { + id: "bj_2", + name: "test-job", + job_spec: { benchmark_id: "bm_1", agent_configs: [] }, + job_source: null, + } as any; + + const params = buildCloneParams(job); + expect(params.cloneSourceType).toBe("benchmark"); + expect(params.initialBenchmarkIds).toBe("bm_1"); + }); + + it("falls back to job_source when job_spec has neither", () => { + const job = { + id: "bj_3", + name: "test-job", + job_spec: { agent_configs: [] }, + job_source: { scenario_ids: ["s3"] }, + } as any; + + const params = buildCloneParams(job); + expect(params.cloneSourceType).toBe("scenarios"); + expect(params.initialScenarioIds).toBe("s3"); + }); + + it("falls back to job_source benchmark_id", () => { + const job = { + id: "bj_4", + name: "test-job", + job_spec: { agent_configs: [] }, + job_source: { benchmark_id: "bm_2" }, + } as any; + + const params = buildCloneParams(job); + expect(params.cloneSourceType).toBe("benchmark"); + expect(params.initialBenchmarkIds).toBe("bm_2"); + }); + + it("extracts agent configs with secrets", () => { + const job = { + id: "bj_5", + name: "test-job", + job_spec: { + benchmark_id: "bm_1", + agent_configs: [ + { + agent_id: "ag_1", + name: "agent-1", + model_name: "gpt-4", + timeout_seconds: 300, + kwargs: { key: "val" }, + agent_environment: { + secrets: { API_KEY: "secret-ref" }, + environment_variables: { ENV: "prod" }, + }, + }, + ], + }, + } as any; + + const params = buildCloneParams(job); + const configs = JSON.parse(params.cloneAgentConfigs); + expect(configs).toHaveLength(1); + expect(configs[0].agentId).toBe("ag_1"); + expect(configs[0].name).toBe("agent-1"); + expect(configs[0].modelName).toBe("gpt-4"); + expect(configs[0].timeoutSeconds).toBe(300); + expect(configs[0].kwargs).toEqual({ key: "val" }); + expect(configs[0].secrets).toEqual({ API_KEY: "secret-ref" }); + expect(configs[0].environmentVariables).toEqual({ ENV: "prod" }); + }); + + it("extracts agent configs with secret_names (legacy)", () => { + const job = { + id: "bj_6", + name: "test-job", + job_spec: { + benchmark_id: "bm_1", + agent_configs: [ + { + agent_id: "ag_1", + name: "agent-1", + agent_environment: { + secret_names: ["MY_SECRET"], + }, + }, + ], + }, + } as any; + + const params = buildCloneParams(job); + const configs = JSON.parse(params.cloneAgentConfigs); + expect(configs[0].secrets).toEqual(["MY_SECRET"]); + }); + + it("extracts agent configs with secret_refs (object)", () => { + const job = { + id: "bj_7", + name: "test-job", + job_spec: { + benchmark_id: "bm_1", + agent_configs: [ + { + agent_id: "ag_1", + name: "agent-1", + agent_environment: { + secret_refs: { KEY: "ref_123" }, + }, + }, + ], + }, + } as any; + + const params = buildCloneParams(job); + const configs = JSON.parse(params.cloneAgentConfigs); + expect(configs[0].secrets).toEqual({ KEY: "ref_123" }); + }); + + it("extracts orchestrator config", () => { + const job = { + id: "bj_8", + name: "test-job", + job_spec: { + benchmark_id: "bm_1", + agent_configs: [], + orchestrator_config: { + n_attempts: 3, + n_concurrent_trials: 2, + quiet: true, + timeout_multiplier: 1.5, + }, + }, + } as any; + + const params = buildCloneParams(job); + const orch = JSON.parse(params.cloneOrchestratorConfig); + expect(orch.nAttempts).toBe(3); + expect(orch.nConcurrentTrials).toBe(2); + expect(orch.quiet).toBe(true); + expect(orch.timeoutMultiplier).toBe(1.5); + }); + + it("handles minimal job (no configs)", () => { + const job = { + id: "bj_9", + name: "minimal-job", + job_spec: null, + } as any; + + const params = buildCloneParams(job); + expect(params.cloneFromJobId).toBe("bj_9"); + expect(params.cloneJobName).toBe("minimal-job"); + expect(params.cloneAgentConfigs).toBeUndefined(); + expect(params.cloneOrchestratorConfig).toBeUndefined(); + }); + + it("handles null name", () => { + const job = { + id: "bj_10", + name: null, + job_spec: null, + } as any; + + const params = buildCloneParams(job); + expect(params.cloneJobName).toBe(""); + }); + + it("extracts legacy agent IDs and names", () => { + const job = { + id: "bj_11", + name: "test-job", + job_spec: { + benchmark_id: "bm_1", + agent_configs: [ + { agent_id: "ag_1", name: "first" }, + { agent_id: "ag_2", name: "second" }, + ], + }, + } as any; + + const params = buildCloneParams(job); + expect(params.cloneAgentIds).toBe("ag_1,ag_2"); + expect(params.cloneAgentNames).toBe("first,second"); + }); +}); + +describe("createBenchmarkJob", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("creates with benchmarkId", async () => { + const mockJob = { id: "bj_new" }; + mockCreate.mockResolvedValue(mockJob); + + const result = await createBenchmarkJob({ + benchmarkId: "bm_1", + agentConfigs: [{ name: "agent-1" }], + }); + + expect(result).toEqual(mockJob); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + spec: expect.objectContaining({ + type: "benchmark", + benchmark_id: "bm_1", + }), + }), + ); + }); + + it("creates with scenarioIds", async () => { + const mockJob = { id: "bj_new" }; + mockCreate.mockResolvedValue(mockJob); + + await createBenchmarkJob({ + scenarioIds: ["s1", "s2"], + agentConfigs: [{ name: "agent-1" }], + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + spec: expect.objectContaining({ + type: "scenarios", + scenario_ids: ["s1", "s2"], + }), + }), + ); + }); + + it("throws when neither benchmarkId nor scenarioIds provided", async () => { + await expect( + createBenchmarkJob({ agentConfigs: [{ name: "agent-1" }] }), + ).rejects.toThrow("Either benchmarkId or scenarioIds must be provided"); + }); + + it("throws when both benchmarkId and scenarioIds provided", async () => { + await expect( + createBenchmarkJob({ + benchmarkId: "bm_1", + scenarioIds: ["s1"], + agentConfigs: [{ name: "agent-1" }], + }), + ).rejects.toThrow("Cannot specify both"); + }); + + it("maps agent config fields correctly", async () => { + mockCreate.mockResolvedValue({ id: "bj_new" }); + + await createBenchmarkJob({ + benchmarkId: "bm_1", + agentConfigs: [ + { + name: "my-agent", + agentId: "ag_1", + modelName: "gpt-4", + timeoutSeconds: 300, + kwargs: { temp: "0.5" }, + environmentVariables: { ENV: "test" }, + secrets: { KEY: "val" }, + }, + ], + }); + + const spec = mockCreate.mock.calls[0][0].spec; + const agentConfig = spec.agent_configs[0]; + expect(agentConfig.name).toBe("my-agent"); + expect(agentConfig.agent_id).toBe("ag_1"); + expect(agentConfig.model_name).toBe("gpt-4"); + expect(agentConfig.timeout_seconds).toBe(300); + expect(agentConfig.kwargs).toEqual({ temp: "0.5" }); + expect(agentConfig.agent_environment.environment_variables).toEqual({ + ENV: "test", + }); + expect(agentConfig.agent_environment.secrets).toEqual({ KEY: "val" }); + }); + + it("omits empty kwargs", async () => { + mockCreate.mockResolvedValue({ id: "bj_new" }); + + await createBenchmarkJob({ + benchmarkId: "bm_1", + agentConfigs: [{ name: "my-agent", kwargs: {} }], + }); + + const agentConfig = mockCreate.mock.calls[0][0].spec.agent_configs[0]; + expect(agentConfig.kwargs).toBeUndefined(); + }); + + it("includes orchestrator config when provided", async () => { + mockCreate.mockResolvedValue({ id: "bj_new" }); + + await createBenchmarkJob({ + benchmarkId: "bm_1", + agentConfigs: [{ name: "agent-1" }], + orchestratorConfig: { + nAttempts: 3, + nConcurrentTrials: 2, + quiet: true, + timeoutMultiplier: 1.5, + }, + }); + + const spec = mockCreate.mock.calls[0][0].spec; + expect(spec.orchestrator_config).toEqual({ + n_attempts: 3, + n_concurrent_trials: 2, + quiet: true, + timeout_multiplier: 1.5, + }); + }); + + it("includes job name when provided", async () => { + mockCreate.mockResolvedValue({ id: "bj_new" }); + + await createBenchmarkJob({ + name: "my-job", + benchmarkId: "bm_1", + agentConfigs: [{ name: "agent-1" }], + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ name: "my-job" }), + ); + }); +}); + +describe("listBenchmarkJobs", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("passes pagination params", async () => { + mockList.mockResolvedValue({ jobs: [], has_more: false }); + + await listBenchmarkJobs({ + limit: 10, + startingAfter: "bj_cursor", + name: "test", + includeTotalCount: true, + }); + + expect(mockList).toHaveBeenCalledWith({ + limit: 10, + starting_after: "bj_cursor", + name: "test", + include_total_count: true, + }); + }); + + it("defaults include_total_count to false when not specified", async () => { + mockList.mockResolvedValue({ jobs: [], has_more: false }); + + await listBenchmarkJobs({ limit: 10 }); + + expect(mockList).toHaveBeenCalledWith( + expect.objectContaining({ include_total_count: false }), + ); + }); + + it("returns jobs with hasMore and totalCount", async () => { + mockList.mockResolvedValue({ + jobs: [{ id: "bj_1" }], + has_more: true, + total_count: 42, + }); + + const result = await listBenchmarkJobs({ limit: 10 }); + expect(result.jobs).toHaveLength(1); + expect(result.hasMore).toBe(true); + expect(result.totalCount).toBe(42); + }); +}); + +describe("getBenchmarkJob", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("retrieves by id", async () => { + const mockJob = { id: "bj_1", name: "test" }; + mockRetrieve.mockResolvedValue(mockJob); + + const result = await getBenchmarkJob("bj_1"); + expect(result).toEqual(mockJob); + expect(mockRetrieve).toHaveBeenCalledWith("bj_1"); + }); +}); + +describe("getBenchmarkRun", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("retrieves by id", async () => { + const mockRun = { id: "br_1", name: "test-run" }; + mockBenchmarkRunsRetrieve.mockResolvedValue(mockRun); + + const result = await getBenchmarkRun("br_1"); + expect(result).toEqual(mockRun); + expect(mockBenchmarkRunsRetrieve).toHaveBeenCalledWith("br_1"); + }); +}); + +describe("listBenchmarkRunScenarioRuns", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("paginates through all scenario runs", async () => { + const runs = [{ id: "sr_1" }, { id: "sr_2" }, { id: "sr_3" }]; + mockBenchmarkRunsListScenarioRuns.mockReturnValue({ + [Symbol.asyncIterator]: () => { + let i = 0; + return { + next: async () => + i < runs.length + ? { value: runs[i++], done: false } + : { value: undefined, done: true }, + }; + }, + }); + + const result = await listBenchmarkRunScenarioRuns("br_1"); + expect(result).toHaveLength(3); + expect(result[0].id).toBe("sr_1"); + expect(result[2].id).toBe("sr_3"); + expect(mockBenchmarkRunsListScenarioRuns).toHaveBeenCalledWith("br_1", { + limit: 100, + }); + }); + + it("passes additional options", async () => { + mockBenchmarkRunsListScenarioRuns.mockReturnValue({ + [Symbol.asyncIterator]: () => ({ + next: async () => ({ value: undefined, done: true }), + }), + }); + + await listBenchmarkRunScenarioRuns("br_1", { limit: 50 } as any); + expect(mockBenchmarkRunsListScenarioRuns).toHaveBeenCalledWith("br_1", { + limit: 50, + }); + }); + + it("returns empty array when no runs", async () => { + mockBenchmarkRunsListScenarioRuns.mockReturnValue({ + [Symbol.asyncIterator]: () => ({ + next: async () => ({ value: undefined, done: true }), + }), + }); + + const result = await listBenchmarkRunScenarioRuns("br_1"); + expect(result).toEqual([]); + }); +}); diff --git a/tests/__tests__/services/benchmarkService.test.ts b/tests/__tests__/services/benchmarkService.test.ts new file mode 100644 index 00000000..53b7ed67 --- /dev/null +++ b/tests/__tests__/services/benchmarkService.test.ts @@ -0,0 +1,318 @@ +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; + +const mockBenchmarkRunsList = jest.fn(); +const mockBenchmarkRunsRetrieve = jest.fn(); +const mockBenchmarkRunsListScenarioRuns = jest.fn(); +const mockBenchmarkRunsCreate = jest.fn(); +const mockScenarioRunsList = jest.fn(); +const mockScenarioRunsRetrieve = jest.fn(); +const mockBenchmarksList = jest.fn(); +const mockBenchmarksRetrieve = jest.fn(); +const mockBenchmarksListPublic = jest.fn(); + +jest.unstable_mockModule("@/utils/client.js", () => ({ + getClient: () => ({ + benchmarkRuns: { + list: mockBenchmarkRunsList, + retrieve: mockBenchmarkRunsRetrieve, + listScenarioRuns: mockBenchmarkRunsListScenarioRuns, + create: mockBenchmarkRunsCreate, + }, + scenarios: { + runs: { + list: mockScenarioRunsList, + retrieve: mockScenarioRunsRetrieve, + }, + }, + benchmarks: { + list: mockBenchmarksList, + retrieve: mockBenchmarksRetrieve, + listPublic: mockBenchmarksListPublic, + }, + }), +})); + +const { + listBenchmarkRuns, + getBenchmarkRun, + listScenarioRuns, + getScenarioRun, + listBenchmarks, + getBenchmark, + listPublicBenchmarks, + createBenchmarkRun, +} = await import("@/services/benchmarkService.js"); + +describe("listScenarioRuns", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("with benchmarkRunId uses benchmarkRuns.listScenarioRuns endpoint", async () => { + mockBenchmarkRunsListScenarioRuns.mockResolvedValue({ + runs: [{ id: "sr_1" }], + has_more: false, + total_count: 1, + }); + + const result = await listScenarioRuns({ + limit: 10, + benchmarkRunId: "br_1", + }); + + expect(mockBenchmarkRunsListScenarioRuns).toHaveBeenCalledWith("br_1", { + limit: 10, + include_total_count: false, + }); + expect(mockScenarioRunsList).not.toHaveBeenCalled(); + expect(result.scenarioRuns).toHaveLength(1); + expect(result.totalCount).toBe(1); + }); + + it("without benchmarkRunId uses scenarios.runs.list endpoint", async () => { + mockScenarioRunsList.mockResolvedValue({ + runs: [{ id: "sr_2" }], + has_more: true, + total_count: 50, + }); + + const result = await listScenarioRuns({ limit: 10 }); + + expect(mockScenarioRunsList).toHaveBeenCalledWith({ + limit: 10, + include_total_count: false, + }); + expect(mockBenchmarkRunsListScenarioRuns).not.toHaveBeenCalled(); + expect(result.scenarioRuns).toHaveLength(1); + expect(result.hasMore).toBe(true); + }); + + it("passes startingAfter and includeTotalCount", async () => { + mockScenarioRunsList.mockResolvedValue({ + runs: [], + has_more: false, + }); + + await listScenarioRuns({ + limit: 10, + startingAfter: "sr_cursor", + includeTotalCount: true, + }); + + expect(mockScenarioRunsList).toHaveBeenCalledWith({ + limit: 10, + starting_after: "sr_cursor", + include_total_count: true, + }); + }); + + it("passes startingAfter with benchmarkRunId path", async () => { + mockBenchmarkRunsListScenarioRuns.mockResolvedValue({ + runs: [], + has_more: false, + }); + + await listScenarioRuns({ + limit: 10, + benchmarkRunId: "br_1", + startingAfter: "sr_cursor", + includeTotalCount: true, + }); + + expect(mockBenchmarkRunsListScenarioRuns).toHaveBeenCalledWith("br_1", { + limit: 10, + starting_after: "sr_cursor", + include_total_count: true, + }); + }); +}); + +describe("listPublicBenchmarks", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("calls benchmarks.listPublic", async () => { + mockBenchmarksListPublic.mockResolvedValue({ + benchmarks: [{ id: "bm_pub" }], + has_more: false, + total_count: 1, + }); + + const result = await listPublicBenchmarks({ limit: 10 }); + expect(mockBenchmarksListPublic).toHaveBeenCalled(); + expect(result.benchmarks).toHaveLength(1); + }); + + it("passes search param", async () => { + mockBenchmarksListPublic.mockResolvedValue({ + benchmarks: [], + has_more: false, + }); + + await listPublicBenchmarks({ limit: 10, search: "code-review" }); + + expect(mockBenchmarksListPublic).toHaveBeenCalledWith( + expect.objectContaining({ search: "code-review" }), + ); + }); + + it("passes startingAfter for pagination", async () => { + mockBenchmarksListPublic.mockResolvedValue({ + benchmarks: [], + has_more: false, + }); + + await listPublicBenchmarks({ + limit: 10, + startingAfter: "bm_cursor", + includeTotalCount: true, + }); + + expect(mockBenchmarksListPublic).toHaveBeenCalledWith( + expect.objectContaining({ + starting_after: "bm_cursor", + include_total_count: true, + }), + ); + }); +}); + +describe("createBenchmarkRun", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("sends benchmark_ids array", async () => { + const mockRun = { id: "br_new" }; + mockBenchmarkRunsCreate.mockResolvedValue(mockRun); + + const result = await createBenchmarkRun(["bm_1", "bm_2"]); + expect(result).toEqual(mockRun); + expect(mockBenchmarkRunsCreate).toHaveBeenCalledWith({ + benchmark_ids: ["bm_1", "bm_2"], + }); + }); + + it("includes optional name and metadata", async () => { + mockBenchmarkRunsCreate.mockResolvedValue({ id: "br_new" }); + + await createBenchmarkRun(["bm_1"], { + name: "my-run", + metadata: { env: "staging" }, + }); + + expect(mockBenchmarkRunsCreate).toHaveBeenCalledWith({ + benchmark_ids: ["bm_1"], + name: "my-run", + metadata: { env: "staging" }, + }); + }); + + it("omits name and metadata when not provided", async () => { + mockBenchmarkRunsCreate.mockResolvedValue({ id: "br_new" }); + + await createBenchmarkRun(["bm_1"]); + + const callArgs = mockBenchmarkRunsCreate.mock.calls[0][0]; + expect(callArgs).not.toHaveProperty("name"); + expect(callArgs).not.toHaveProperty("metadata"); + }); +}); + +describe("listBenchmarkRuns", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("passes pagination params", async () => { + mockBenchmarkRunsList.mockResolvedValue({ + runs: [], + has_more: false, + }); + + await listBenchmarkRuns({ + limit: 10, + startingAfter: "br_cursor", + includeTotalCount: true, + }); + + expect(mockBenchmarkRunsList).toHaveBeenCalledWith({ + limit: 10, + starting_after: "br_cursor", + include_total_count: true, + }); + }); + + it("returns benchmarkRuns, totalCount, hasMore", async () => { + mockBenchmarkRunsList.mockResolvedValue({ + runs: [{ id: "br_1" }], + has_more: true, + total_count: 30, + }); + + const result = await listBenchmarkRuns({ limit: 10 }); + expect(result.benchmarkRuns).toHaveLength(1); + expect(result.hasMore).toBe(true); + expect(result.totalCount).toBe(30); + }); +}); + +describe("getBenchmarkRun", () => { + it("retrieves by id", async () => { + const mockRun = { id: "br_1" }; + mockBenchmarkRunsRetrieve.mockResolvedValue(mockRun); + + const result = await getBenchmarkRun("br_1"); + expect(result).toEqual(mockRun); + expect(mockBenchmarkRunsRetrieve).toHaveBeenCalledWith("br_1"); + }); +}); + +describe("getScenarioRun", () => { + it("retrieves by id", async () => { + const mockRun = { id: "sr_1" }; + mockScenarioRunsRetrieve.mockResolvedValue(mockRun); + + const result = await getScenarioRun("sr_1"); + expect(result).toEqual(mockRun); + expect(mockScenarioRunsRetrieve).toHaveBeenCalledWith("sr_1"); + }); +}); + +describe("listBenchmarks", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("passes search and pagination params", async () => { + mockBenchmarksList.mockResolvedValue({ + benchmarks: [], + has_more: false, + }); + + await listBenchmarks({ + limit: 10, + search: "test", + startingAfter: "bm_cursor", + includeTotalCount: true, + }); + + expect(mockBenchmarksList).toHaveBeenCalledWith({ + limit: 10, + search: "test", + starting_after: "bm_cursor", + include_total_count: true, + }); + }); +}); + +describe("getBenchmark", () => { + it("retrieves by id", async () => { + const mockBenchmark = { id: "bm_1" }; + mockBenchmarksRetrieve.mockResolvedValue(mockBenchmark); + + const result = await getBenchmark("bm_1"); + expect(result).toEqual(mockBenchmark); + }); +}); diff --git a/tests/__tests__/services/objectService.test.ts b/tests/__tests__/services/objectService.test.ts index e17a8860..1a003479 100644 --- a/tests/__tests__/services/objectService.test.ts +++ b/tests/__tests__/services/objectService.test.ts @@ -102,4 +102,31 @@ describe("buildObjectDetailFields", () => { const expiresField = fields.find((f) => f.label === "Expires"); expect(expiresField?.color).toBe("warning"); }); + + it("shows hours and minutes format when expiry is over 60 minutes", () => { + const future = Date.now() + 90 * 60000; // 90 minutes from now + const fields = buildObjectDetailFields({ + ...baseObject, + delete_after_time_ms: future, + }); + const expiresField = fields.find((f) => f.label === "Expires"); + expect(expiresField?.value).toMatch(/1h \d+m remaining/); + expect(expiresField?.color).toBeUndefined(); + }); + + it("omits size field when size_bytes is undefined", () => { + const obj = { ...baseObject, size_bytes: undefined }; + const fields = buildObjectDetailFields(obj); + expect(fields.find((f) => f.label === "Size")).toBeUndefined(); + }); + + it("shows Public as No when is_public is false", () => { + const fields = buildObjectDetailFields({ ...baseObject, is_public: false }); + expect(fields.find((f) => f.label === "Public")?.value).toBe("No"); + }); + + it("omits Public field when is_public is undefined", () => { + const fields = buildObjectDetailFields(baseObject); + expect(fields.find((f) => f.label === "Public")).toBeUndefined(); + }); }); diff --git a/tests/__tests__/services/objectServiceApi.test.ts b/tests/__tests__/services/objectServiceApi.test.ts new file mode 100644 index 00000000..f34f8662 --- /dev/null +++ b/tests/__tests__/services/objectServiceApi.test.ts @@ -0,0 +1,137 @@ +import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"; + +const mockObjectsCreate = jest.fn(); +const mockObjectsComplete = jest.fn(); + +jest.unstable_mockModule("@/utils/client.js", () => ({ + getClient: () => ({ + objects: { + create: mockObjectsCreate, + complete: mockObjectsComplete, + list: jest.fn(), + retrieve: jest.fn(), + download: jest.fn(), + upload: jest.fn(), + delete: jest.fn(), + }, + }), +})); + +const { createObject, completeObject, uploadToPresignedUrl } = await import( + "@/services/objectService.js" +); + +describe("createObject", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("creates object with required params", async () => { + mockObjectsCreate.mockResolvedValue({ + id: "obj_new", + name: "test.bin", + upload_url: "https://s3.example.com/upload", + }); + + const result = await createObject({ + name: "test.bin", + content_type: "binary", + }); + + expect(result.id).toBe("obj_new"); + expect(result.upload_url).toBe("https://s3.example.com/upload"); + expect(mockObjectsCreate).toHaveBeenCalledWith({ + name: "test.bin", + content_type: "binary", + metadata: undefined, + ttl_ms: undefined, + }); + }); + + it("passes optional metadata and ttl_ms", async () => { + mockObjectsCreate.mockResolvedValue({ + id: "obj_new", + name: "test.bin", + upload_url: "https://s3.example.com/upload", + }); + + await createObject({ + name: "test.bin", + content_type: "text", + metadata: { env: "test" }, + ttl_ms: 3600000, + }); + + expect(mockObjectsCreate).toHaveBeenCalledWith({ + name: "test.bin", + content_type: "text", + metadata: { env: "test" }, + ttl_ms: 3600000, + }); + }); + + it("throws when API does not return upload_url", async () => { + mockObjectsCreate.mockResolvedValue({ + id: "obj_new", + name: "test.bin", + }); + + await expect( + createObject({ name: "test.bin", content_type: "binary" }), + ).rejects.toThrow("API did not return an upload URL"); + }); +}); + +describe("completeObject", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("calls client.objects.complete with correct id", async () => { + mockObjectsComplete.mockResolvedValue(undefined); + + await completeObject("obj_123"); + + expect(mockObjectsComplete).toHaveBeenCalledWith("obj_123"); + }); +}); + +describe("uploadToPresignedUrl", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("makes PUT with correct headers", async () => { + const mockFetch = jest.fn().mockResolvedValue({ ok: true } as Response); + globalThis.fetch = mockFetch as any; + + const buffer = Buffer.from("hello world"); + await uploadToPresignedUrl("https://s3.example.com/upload", buffer); + + expect(mockFetch).toHaveBeenCalledWith("https://s3.example.com/upload", { + method: "PUT", + body: buffer, + headers: { "Content-Length": "11" }, + }); + }); + + it("throws on non-ok response", async () => { + const mockFetch = jest + .fn() + .mockResolvedValue({ ok: false, status: 403 } as Response); + globalThis.fetch = mockFetch as any; + + await expect( + uploadToPresignedUrl( + "https://s3.example.com/upload", + Buffer.from("data"), + ), + ).rejects.toThrow("Upload failed: HTTP 403"); + }); +}); diff --git a/tests/fixtures/mocks.ts b/tests/fixtures/mocks.ts index bfd5ac4d..4e6ccb40 100644 --- a/tests/fixtures/mocks.ts +++ b/tests/fixtures/mocks.ts @@ -119,6 +119,47 @@ export const mockLogEntry = (overrides = {}) => ({ ...overrides }); +export const mockAxon = (overrides = {}) => ({ + id: 'axn_test_id', + name: 'test-axon', + status: 'active', + created_at: '2024-01-01T00:00:00Z', + ...overrides, +}); + +export const mockAxonEvent = (overrides = {}) => ({ + sequence: 1, + timestamp_ms: 1700000000000, + origin: 'test-origin', + source: 'test-source', + event_type: 'test-event', + payload: '{"key":"value"}', + ...overrides, +}); + +export const mockScenarioRun = (overrides = {}) => ({ + id: 'sr_test_id', + name: 'test-scenario-run', + state: 'completed', + start_time_ms: 1700000000000, + scoring_contract_result: { score: 0.85 }, + ...overrides, +}); + +export const mockBenchmarkJob = (overrides = {}) => ({ + id: 'bj_test_id', + name: 'test-benchmark-job', + status: 'completed', + job_spec: { + benchmark_id: 'bm_test', + agent_configs: [], + orchestrator_config: null, + }, + job_source: null, + created_at: '2024-01-01T00:00:00Z', + ...overrides, +}); + export const mockExecution = (overrides = {}) => ({ id: 'exec-test-id', status: 'completed',