From 36d92536d4c9b98257314eb285109db49bc20f10 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sun, 1 Feb 2026 13:32:11 -0800 Subject: [PATCH 1/5] Add user submissions management commands - Add `popcorn submissions list --leaderboard ` to list user's submissions - Add `popcorn submissions show ` to view submission with full code - Add `popcorn submissions delete ` to delete with confirmation - Add UserSubmission, SubmissionDetails, SubmissionRun models - Add service functions for new /user/submissions API endpoints - Update README with comprehensive Commands section - Update CLAUDE.md with directory structure and code reuse guidance --- CLAUDE.md | 20 ++++++ README.md | 119 +++++++++++++++++++++++++++++--- src/cmd/mod.rs | 54 +++++++++++++++ src/cmd/submissions.rs | 151 +++++++++++++++++++++++++++++++++++++++++ src/models/mod.rs | 38 +++++++++++ src/service/mod.rs | 145 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 517 insertions(+), 10 deletions(-) create mode 100644 src/cmd/submissions.rs diff --git a/CLAUDE.md b/CLAUDE.md index 42e9985..b939940 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,9 @@ src/ ├── main.rs # Entry point, sets POPCORN_API_URL ├── cmd/ # Command handling │ ├── mod.rs # CLI argument parsing (clap), config loading +│ ├── admin.rs # Admin commands (requires POPCORN_ADMIN_TOKEN) │ ├── auth.rs # OAuth authentication (Discord/GitHub) +│ ├── submissions.rs # User submission management (list, show, delete) │ └── submit.rs # Submission logic, TUI app state machine ├── service/ │ └── mod.rs # HTTP client, API calls, SSE streaming @@ -50,6 +52,24 @@ src/ └── result_page.rs # TUI results display with scrolling ``` +### Before Adding New Features + +**Important:** Before implementing new functionality, check for existing code in both repos: + +1. **Check discord-cluster-manager** for existing Discord commands and database methods: + - `src/kernelbot/cogs/` - Discord bot commands + - `src/libkernelbot/leaderboard_db.py` - Database methods + - `src/kernelbot/api/main.py` - Existing API endpoints + +2. **Check popcorn-cli** for existing service functions and commands: + - `src/service/mod.rs` - API client functions + - `src/cmd/` - CLI command handlers + +3. **Reuse existing functionality** where possible: + - Database methods (e.g., `get_submission_by_id`, `delete_submission`) + - API response handling patterns + - Authentication validation (`validate_user_header`, `validate_cli_header`) + ### Core Flow 1. **Authentication** (`cmd/auth.rs`): User registers via Discord/GitHub OAuth. CLI ID stored in `~/.popcorn.yaml`. diff --git a/README.md b/README.md index f6c440e..b064a5f 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,116 @@ wget https://raw.githubusercontent.com/gpu-mode/reference-kernels/refs/heads/mai popcorn-cli submit --gpu A100 --leaderboard grayscale --mode leaderboard submission.py ``` -We regularly run competitions with clear due dates but for beginners we will always keep open the PMPP_v2 problem set https://github.com/gpu-mode/reference-kernels/tree/main/problems/pmpp_v2 +We regularly run competitions with clear due dates but for beginners we will always keep open the PMPP_v2 problem set https://github.com/gpu-mode/reference-kernels/tree/main/problems/pmpp_v2 + +## Commands + +### Submit + +Submit a solution to a leaderboard. Supports both TUI (interactive) and plain modes. + +```bash +# Interactive TUI mode - select leaderboard, GPU, and mode interactively +popcorn submit solution.py + +# Direct submission with all options +popcorn submit --leaderboard grayscale --gpu A100 --mode leaderboard solution.py + +# Plain output mode (no TUI, good for CI/scripts) +popcorn submit --no-tui --leaderboard grayscale --gpu A100 --mode test solution.py + +# Save results to a file +popcorn submit --output results.json --leaderboard grayscale --gpu A100 --mode benchmark solution.py +``` + +**Submission modes:** +- `test` - Quick test run to check correctness +- `benchmark` - Benchmark your solution (no leaderboard impact) +- `leaderboard` - Official ranked submission +- `profile` - Profile with Nsight Compute (limited availability) + +### Submissions + +Manage your past submissions. + +```bash +# List your submissions for a leaderboard +popcorn submissions list --leaderboard grayscale + +# Limit number of results +popcorn submissions list --leaderboard grayscale --limit 10 + +# View a specific submission with full code +popcorn submissions show + +# Delete a submission (with confirmation prompt) +popcorn submissions delete + +# Delete without confirmation +popcorn submissions delete --force +``` + +### Authentication + +Register or re-register your CLI with Discord or GitHub. + +```bash +# Initial registration (Discord recommended) +popcorn register discord +popcorn register github + +# Re-register if you need to link a new account +popcorn reregister discord +popcorn reregister github +``` + +### Admin Commands + +Admin commands require the `POPCORN_ADMIN_TOKEN` environment variable. + +```bash +# Server control +popcorn admin start # Start accepting jobs +popcorn admin stop # Stop accepting jobs +popcorn admin stats # Get server statistics +popcorn admin stats --last-day # Stats for last 24 hours only + +# Submission management +popcorn admin get-submission # Get any submission by ID +popcorn admin delete-submission # Delete any submission + +# Leaderboard management +popcorn admin create-leaderboard # Create leaderboard from problem directory +popcorn admin delete-leaderboard # Delete a leaderboard +popcorn admin delete-leaderboard --force # Force delete with submissions + +# Update problems from GitHub +popcorn admin update-problems +popcorn admin update-problems --problem-set nvidia --force +``` + +### File Directives + +You can embed default settings directly in your solution files: + +```python +#!POPCORN leaderboard grayscale +#!POPCORN gpu A100 + +def solution(): + ... +``` + +Or C++ style: +```cpp +//!POPCORN leaderboard nvidia-matmul +//!POPCORN gpu H100 +``` + +When these directives are present, you can submit with just: +```bash +popcorn submit solution.py +``` ## Submission Format @@ -87,12 +196,4 @@ Our entire evaluation infrastructure is open source and you can learn more [here Interested in new kernel competitions? Join [discord.gg/gpumode](https://discord.gg/gpumode) and check out the **#announcements** channel to be notified when new challenges drop. -## Discover Problems - -The CLI supports everything Discord does, so you can also discover which leaderboards are available. To make discovery more pleasant we also offer a TUI experience. - -```bash -popcorn-cli submit -``` - glhf! diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index b2322b8..148e74f 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; mod admin; mod auth; +mod submissions; mod submit; pub use admin::AdminAction; @@ -72,6 +73,34 @@ enum AuthProvider { Github, } +#[derive(Subcommand, Debug)] +enum SubmissionsAction { + /// List your submissions for a leaderboard + List { + /// Leaderboard name (required) + #[arg(long)] + leaderboard: String, + + /// Maximum number of submissions to show + #[arg(long, default_value = "50")] + limit: i32, + }, + /// Show a specific submission with full details and code + Show { + /// Submission ID + id: i64, + }, + /// Delete a submission + Delete { + /// Submission ID + id: i64, + + /// Skip confirmation prompt + #[arg(long)] + force: bool, + }, +} + #[derive(Subcommand, Debug)] enum Commands { Reregister { @@ -111,6 +140,11 @@ enum Commands { #[command(subcommand)] action: AdminAction, }, + /// Manage your submissions + Submissions { + #[command(subcommand)] + action: SubmissionsAction, + }, } pub async fn execute(cli: Cli) -> Result<()> { @@ -172,6 +206,26 @@ pub async fn execute(cli: Cli) -> Result<()> { } } Some(Commands::Admin { action }) => admin::handle_admin(action).await, + Some(Commands::Submissions { action }) => { + let config = load_config()?; + let cli_id = config.cli_id.ok_or_else(|| { + anyhow!( + "cli_id not found in config file ({}). Please run `popcorn register` first.", + get_config_path() + .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) + ) + })?; + + match action { + SubmissionsAction::List { leaderboard, limit } => { + submissions::list_submissions(cli_id, leaderboard, Some(limit)).await + } + SubmissionsAction::Show { id } => submissions::show_submission(cli_id, id).await, + SubmissionsAction::Delete { id, force } => { + submissions::delete_submission(cli_id, id, force).await + } + } + } None => { // Check if any of the submission-related flags were used at the top level if cli.gpu.is_some() || cli.leaderboard.is_some() || cli.mode.is_some() { diff --git a/src/cmd/submissions.rs b/src/cmd/submissions.rs new file mode 100644 index 0000000..da972fb --- /dev/null +++ b/src/cmd/submissions.rs @@ -0,0 +1,151 @@ +use anyhow::Result; +use std::io::{self, Write}; + +use crate::service; + +/// List user's submissions for a leaderboard +pub async fn list_submissions( + cli_id: String, + leaderboard: String, + limit: Option, +) -> Result<()> { + let client = service::create_client(Some(cli_id))?; + let submissions = service::get_user_submissions(&client, Some(&leaderboard), limit).await?; + + if submissions.is_empty() { + println!("No submissions found."); + return Ok(()); + } + + // Print header + println!( + "{:<8} {:<20} {:<20} {:<20} {:<12} {:<10} {:>10}", + "ID", "Leaderboard", "File", "Time", "GPU", "Status", "Score" + ); + println!("{}", "-".repeat(105)); + + // Print each submission + for sub in submissions { + let status = if sub.done { "done" } else { "pending" }; + let gpu = sub.gpu_type.as_deref().unwrap_or("-"); + let score = sub + .score + .map(|s| format!("{:.4}", s)) + .unwrap_or_else(|| "-".to_string()); + let time = truncate(&sub.submission_time, 19); + + println!( + "{:<8} {:<20} {:<20} {:<20} {:<12} {:<10} {:>10}", + sub.id, + truncate(&sub.leaderboard_name, 19), + truncate(&sub.file_name, 19), + time, + gpu, + status, + score + ); + } + + Ok(()) +} + +/// Show a specific submission with full details +pub async fn show_submission(cli_id: String, submission_id: i64) -> Result<()> { + let client = service::create_client(Some(cli_id))?; + let sub = service::get_user_submission(&client, submission_id).await?; + + println!("Submission #{}", sub.id); + println!("{}", "=".repeat(60)); + println!( + "Leaderboard: {} (id: {})", + sub.leaderboard_name, sub.leaderboard_id + ); + println!("File: {}", sub.file_name); + println!("User ID: {}", sub.user_id); + println!("Submitted: {}", sub.submission_time); + println!( + "Status: {}", + if sub.done { "done" } else { "pending" } + ); + + if !sub.runs.is_empty() { + println!("\nRuns:"); + for run in &sub.runs { + let score_str = run + .score + .map(|s| format!("{:.4}", s)) + .unwrap_or_else(|| "-".to_string()); + let status = if run.passed { "passed" } else { "failed" }; + let secret_marker = if run.secret { " [secret]" } else { "" }; + let time_info = match (&run.start_time, &run.end_time) { + (Some(start), Some(end)) => format!(" ({} - {})", start, end), + (Some(start), None) => format!(" (started: {})", start), + _ => String::new(), + }; + println!( + " - {} on {}: {} (score: {}){}{}", + run.mode, run.runner, status, score_str, secret_marker, time_info + ); + } + } + + println!("\nCode:"); + println!("{}", "-".repeat(60)); + println!("{}", sub.code); + + Ok(()) +} + +/// Delete a submission with confirmation +pub async fn delete_submission(cli_id: String, submission_id: i64, force: bool) -> Result<()> { + let client = service::create_client(Some(cli_id))?; + + // Fetch submission first to show preview + let sub = service::get_user_submission(&client, submission_id).await?; + + println!("Submission #{}", sub.id); + println!("Leaderboard: {}", sub.leaderboard_name); + println!("File: {}", sub.file_name); + println!("Submitted: {}", sub.submission_time); + + // Show first 20 lines of code + println!("\nCode preview:"); + println!("{}", "-".repeat(60)); + let lines: Vec<&str> = sub.code.lines().take(20).collect(); + for line in &lines { + println!("{}", line); + } + if sub.code.lines().count() > 20 { + println!("... ({} more lines)", sub.code.lines().count() - 20); + } + println!("{}", "-".repeat(60)); + + // Ask for confirmation unless --force + if !force { + print!("\nDelete this submission? [y/N]: "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if !input.trim().eq_ignore_ascii_case("y") { + println!("Cancelled."); + return Ok(()); + } + } + + // Delete the submission + service::delete_user_submission(&client, submission_id).await?; + println!("Submission {} deleted successfully.", submission_id); + + Ok(()) +} + +/// Truncate a string to max length, adding "..." if truncated +fn truncate(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}...", &s[..max_len - 3]) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index dd199e1..fa9970f 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -49,3 +49,41 @@ pub enum AppState { SubmissionModeSelection, WaitingForResult, } + +/// Summary of a user submission for list view +#[derive(Clone, Debug)] +pub struct UserSubmission { + pub id: i64, + pub leaderboard_name: String, + pub file_name: String, + pub submission_time: String, + pub done: bool, + pub gpu_type: Option, + pub score: Option, +} + +/// Full submission details including code and runs +#[derive(Clone, Debug)] +pub struct SubmissionDetails { + pub id: i64, + pub leaderboard_id: i64, + pub leaderboard_name: String, + pub file_name: String, + pub user_id: String, + pub submission_time: String, + pub done: bool, + pub code: String, + pub runs: Vec, +} + +/// A single run within a submission +#[derive(Clone, Debug)] +pub struct SubmissionRun { + pub start_time: Option, + pub end_time: Option, + pub mode: String, + pub secret: bool, + pub runner: String, + pub score: Option, + pub passed: bool, +} diff --git a/src/service/mod.rs b/src/service/mod.rs index 1eb135e..9fcf653 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -10,7 +10,7 @@ use std::path::Path; use std::time::Duration; use tokio::io::AsyncWriteExt; -use crate::models::{GpuItem, LeaderboardItem}; +use crate::models::{GpuItem, LeaderboardItem, SubmissionDetails, SubmissionRun, UserSubmission}; // Helper function to create a reusable reqwest client pub fn create_client(cli_id: Option) -> Result { @@ -291,6 +291,149 @@ pub async fn fetch_gpus(client: &Client, leaderboard: &str) -> Result, + limit: Option, +) -> Result> { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let mut url = format!("{}/user/submissions", base_url); + let mut params = Vec::new(); + if let Some(lb) = leaderboard { + params.push(format!("leaderboard={}", lb)); + } + if let Some(l) = limit { + params.push(format!("limit={}", l)); + } + if !params.is_empty() { + url = format!("{}?{}", url, params.join("&")); + } + + let resp = client + .get(&url) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + + let submissions: Vec = resp.json().await?; + + let mut result = Vec::new(); + for sub in submissions { + result.push(UserSubmission { + id: sub["id"].as_i64().unwrap_or(0), + leaderboard_name: sub["leaderboard_name"].as_str().unwrap_or("").to_string(), + file_name: sub["file_name"].as_str().unwrap_or("").to_string(), + submission_time: sub["submission_time"].as_str().unwrap_or("").to_string(), + done: sub["done"].as_bool().unwrap_or(false), + gpu_type: sub["gpu_type"].as_str().map(str::to_string), + score: sub["score"].as_f64(), + }); + } + + Ok(result) +} + +/// Get a specific submission by ID (with code) +pub async fn get_user_submission(client: &Client, submission_id: i64) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .get(format!("{}/user/submissions/{}", base_url, submission_id)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + + let sub: Value = resp.json().await?; + + let runs = sub["runs"] + .as_array() + .map(|arr| { + arr.iter() + .map(|r| SubmissionRun { + start_time: r["start_time"].as_str().map(str::to_string), + end_time: r["end_time"].as_str().map(str::to_string), + mode: r["mode"].as_str().unwrap_or("").to_string(), + secret: r["secret"].as_bool().unwrap_or(false), + runner: r["runner"].as_str().unwrap_or("").to_string(), + score: r["score"].as_f64(), + passed: r["passed"].as_bool().unwrap_or(false), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(SubmissionDetails { + id: sub["id"].as_i64().unwrap_or(0), + leaderboard_id: sub["leaderboard_id"].as_i64().unwrap_or(0), + leaderboard_name: sub["leaderboard_name"].as_str().unwrap_or("").to_string(), + file_name: sub["file_name"].as_str().unwrap_or("").to_string(), + user_id: sub["user_id"].as_str().unwrap_or("").to_string(), + submission_time: sub["submission_time"].as_str().unwrap_or("").to_string(), + done: sub["done"].as_bool().unwrap_or(false), + code: sub["code"].as_str().unwrap_or("").to_string(), + runs, + }) +} + +/// Delete a user's submission by ID +pub async fn delete_user_submission(client: &Client, submission_id: i64) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .delete(format!("{}/user/submissions/{}", base_url, submission_id)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + + resp.json() + .await + .map_err(|e| anyhow!("Failed to parse response: {}", e)) +} + pub async fn submit_solution>( client: &Client, filepath: P, From c6a3267218d37c6f514777f2c31a5b90eb96a88e Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sun, 1 Feb 2026 13:42:19 -0800 Subject: [PATCH 2/5] Address Copilot feedback and add tests - Fix SQL duplication bug with DISTINCT ON for get_user_submissions - Add input validation for limit (1-100) and offset (>=0) - Use consistent response format {"status": "ok", "submission_id": ...} - Add unit tests for get_user_submissions DB method - Update CLAUDE.md with testing guidance --- CLAUDE.md | 46 ++++++++++++++++++++++++++++++++++++++++++ src/cmd/submissions.rs | 8 ++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b939940..5e12bb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,52 @@ All PRs must pass: - `cargo test` - All tests pass - Builds on Linux, macOS, and Windows +### Testing + +#### Unit Tests + +Tests are in the same file as the code (Rust convention): +- `src/service/mod.rs` - API client tests +- `src/utils/mod.rs` - Utility function tests + +Run all tests: +```bash +cargo test +``` + +Run specific tests: +```bash +cargo test test_name +``` + +#### Test Requirements + +When adding new functionality: + +1. **Service functions** (`src/service/mod.rs`): + - Add tests in the `#[cfg(test)] mod tests` block + - Test error handling, response parsing + +2. **Command handlers** (`src/cmd/`): + - Integration testing via E2E regression tests + +#### E2E Regression Testing + +Use a local instance of kernelbot to test CLI functionality end-to-end: + +```bash +# Start local kernelbot server (see kernelbot repo) +cd ../kernelbot +docker compose up -d +uv run uvicorn src.kernelbot.api.main:app --reload --port 8000 + +# Test CLI against local instance +export POPCORN_API_URL=http://localhost:8000 +cargo run -- submissions list --leaderboard test-leaderboard +cargo run -- submissions show 123 +cargo run -- submissions delete 123 --force +``` + ## Architecture Overview Popcorn CLI is a command-line tool for submitting GPU kernel optimization solutions to [gpumode.com](https://gpumode.com) competitions. diff --git a/src/cmd/submissions.rs b/src/cmd/submissions.rs index da972fb..c8e88d3 100644 --- a/src/cmd/submissions.rs +++ b/src/cmd/submissions.rs @@ -135,8 +135,12 @@ pub async fn delete_submission(cli_id: String, submission_id: i64, force: bool) } // Delete the submission - service::delete_user_submission(&client, submission_id).await?; - println!("Submission {} deleted successfully.", submission_id); + let result = service::delete_user_submission(&client, submission_id).await?; + if result.get("status").and_then(|s| s.as_str()) == Some("ok") { + println!("Submission {} deleted successfully.", submission_id); + } else { + println!("Submission deleted."); + } Ok(()) } From 3281e4a518d2103254e7fec93ba6e8b499fcc682 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sun, 1 Feb 2026 13:46:26 -0800 Subject: [PATCH 3/5] Simplify CLAUDE.md testing instructions --- CLAUDE.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5e12bb8..c6691f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,17 +57,17 @@ When adding new functionality: #### E2E Regression Testing -Use a local instance of kernelbot to test CLI functionality end-to-end: +Test CLI functionality against a local or production instance: ```bash -# Start local kernelbot server (see kernelbot repo) -cd ../kernelbot -docker compose up -d -uv run uvicorn src.kernelbot.api.main:app --reload --port 8000 +# Test against production +export POPCORN_API_URL=https://discord-cluster-manager-1f6c4782e60a.herokuapp.com -# Test CLI against local instance +# Or test against local kernelbot server export POPCORN_API_URL=http://localhost:8000 -cargo run -- submissions list --leaderboard test-leaderboard + +# Run CLI commands +cargo run -- submissions list --leaderboard grayscale cargo run -- submissions show 123 cargo run -- submissions delete 123 --force ``` From e9a9a59b04cec0edcc8e6898d6b77f7bb4d9bd14 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sun, 1 Feb 2026 14:11:09 -0800 Subject: [PATCH 4/5] Add comprehensive E2E testing instructions to CLAUDE.md Document how to test CLI against local kernelbot server: - Two options: production vs local testing - Full local setup workflow with kernelbot server - PostgreSQL, migrations, test user creation - Syncing leaderboards and configuring CLI - Troubleshooting common errors --- CLAUDE.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c6691f5..6372cb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,21 +57,85 @@ When adding new functionality: #### E2E Regression Testing -Test CLI functionality against a local or production instance: +Full end-to-end testing requires a running kernelbot API server. You can test against production or a local instance. + +##### Option A: Test Against Production ```bash -# Test against production export POPCORN_API_URL=https://discord-cluster-manager-1f6c4782e60a.herokuapp.com +cargo run -- submissions list --leaderboard grayscale +``` + +##### Option B: Test Against Local Server (Recommended for Development) + +This tests the complete flow: CLI → API → Database → Modal runner. + +**Step 1: Set up kernelbot server** (in the kernelbot repo): + +```bash +# Start PostgreSQL +brew services start postgresql@14 + +# Create database and run migrations +createdb kernelbot +export DATABASE_URL="postgresql://$(whoami)@localhost:5432/kernelbot" +uv run yoyo apply --database "$DATABASE_URL" src/migrations/ + +# Create test user +psql "$DATABASE_URL" -c "INSERT INTO leaderboard.user_info (id, user_name, cli_id, cli_valid) +VALUES ('999999', 'testuser', 'test-cli-id-123', true) +ON CONFLICT (id) DO UPDATE SET cli_id = 'test-cli-id-123', cli_valid = true;" + +# Start API server +cd src/kernelbot +export ADMIN_TOKEN="your-admin-token" # Check .env for LOCAL_ADMIN_TOKEN +uv run python main.py --api-only +``` + +**Step 2: Sync leaderboards**: + +```bash +curl -X POST "http://localhost:8000/admin/update-problems" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"problem_set": "pmpp_v2"}' +``` + +**Step 3: Configure CLI for local testing**: -# Or test against local kernelbot server +```bash +# Backup and set test config +cp ~/.popcorn.yaml ~/.popcorn.yaml.bak +echo "cli_id: test-cli-id-123" > ~/.popcorn.yaml +``` + +**Step 4: Run CLI commands**: + +```bash export POPCORN_API_URL=http://localhost:8000 -# Run CLI commands -cargo run -- submissions list --leaderboard grayscale -cargo run -- submissions show 123 -cargo run -- submissions delete 123 --force +# Test submissions commands +cargo run --release -- submissions list --leaderboard vectoradd_v2 +cargo run --release -- submissions show +cargo run --release -- submissions delete + +# Test actual submission (requires Modal account for GPU execution) +cargo run --release -- submit solution.py --gpu H100 --leaderboard vectoradd_v2 --mode test ``` +**Step 5: Restore original config**: + +```bash +cp ~/.popcorn.yaml.bak ~/.popcorn.yaml && rm ~/.popcorn.yaml.bak +``` + +##### Troubleshooting + +- **401 Unauthorized**: CLI ID not registered in database - create test user first +- **404 Not Found**: Leaderboards not synced - run update-problems endpoint +- **Connection refused**: API server not running on localhost:8000 +- **"Device not configured"**: TTY issue - ensure POPCORN_API_URL is set + ## Architecture Overview Popcorn CLI is a command-line tool for submitting GPU kernel optimization solutions to [gpumode.com](https://gpumode.com) competitions. From 64b121d9871ccf8e61598e432e3253aa10f0d0aa Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sun, 1 Feb 2026 17:28:24 -0800 Subject: [PATCH 5/5] Update CLI to handle new runs list format in submissions API - Add UserSubmissionRun struct for run summary (gpu_type, score) - Update UserSubmission to have runs: Vec instead of single gpu_type/score fields - Update list_submissions to display all GPUs comma-separated and show best score across all runs - Update service parsing to handle new API response format --- src/cmd/submissions.rs | 26 ++++++++++++++++++++------ src/models/mod.rs | 8 +++++++- src/service/mod.rs | 19 ++++++++++++++++--- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/cmd/submissions.rs b/src/cmd/submissions.rs index c8e88d3..4a9fddc 100644 --- a/src/cmd/submissions.rs +++ b/src/cmd/submissions.rs @@ -20,18 +20,32 @@ pub async fn list_submissions( // Print header println!( "{:<8} {:<20} {:<20} {:<20} {:<12} {:<10} {:>10}", - "ID", "Leaderboard", "File", "Time", "GPU", "Status", "Score" + "ID", "Leaderboard", "File", "Time", "GPU(s)", "Status", "Score" ); println!("{}", "-".repeat(105)); // Print each submission for sub in submissions { let status = if sub.done { "done" } else { "pending" }; - let gpu = sub.gpu_type.as_deref().unwrap_or("-"); - let score = sub - .score + + // Collect all GPU types and best score from runs + let gpus: Vec<&str> = sub.runs.iter().map(|r| r.gpu_type.as_str()).collect(); + let gpu_display = if gpus.is_empty() { + "-".to_string() + } else { + gpus.join(",") + }; + + // Get best score (lowest) + let best_score = sub + .runs + .iter() + .filter_map(|r| r.score) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let score_display = best_score .map(|s| format!("{:.4}", s)) .unwrap_or_else(|| "-".to_string()); + let time = truncate(&sub.submission_time, 19); println!( @@ -40,9 +54,9 @@ pub async fn list_submissions( truncate(&sub.leaderboard_name, 19), truncate(&sub.file_name, 19), time, - gpu, + truncate(&gpu_display, 11), status, - score + score_display ); } diff --git a/src/models/mod.rs b/src/models/mod.rs index fa9970f..4853d23 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -58,7 +58,13 @@ pub struct UserSubmission { pub file_name: String, pub submission_time: String, pub done: bool, - pub gpu_type: Option, + pub runs: Vec, +} + +/// A run summary for list view (gpu_type and score only) +#[derive(Clone, Debug)] +pub struct UserSubmissionRun { + pub gpu_type: String, pub score: Option, } diff --git a/src/service/mod.rs b/src/service/mod.rs index 9fcf653..6293696 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -10,7 +10,9 @@ use std::path::Path; use std::time::Duration; use tokio::io::AsyncWriteExt; -use crate::models::{GpuItem, LeaderboardItem, SubmissionDetails, SubmissionRun, UserSubmission}; +use crate::models::{ + GpuItem, LeaderboardItem, SubmissionDetails, SubmissionRun, UserSubmission, UserSubmissionRun, +}; // Helper function to create a reusable reqwest client pub fn create_client(cli_id: Option) -> Result { @@ -335,14 +337,25 @@ pub async fn get_user_submissions( let mut result = Vec::new(); for sub in submissions { + let runs = sub["runs"] + .as_array() + .map(|arr| { + arr.iter() + .map(|r| UserSubmissionRun { + gpu_type: r["gpu_type"].as_str().unwrap_or("").to_string(), + score: r["score"].as_f64(), + }) + .collect() + }) + .unwrap_or_default(); + result.push(UserSubmission { id: sub["id"].as_i64().unwrap_or(0), leaderboard_name: sub["leaderboard_name"].as_str().unwrap_or("").to_string(), file_name: sub["file_name"].as_str().unwrap_or("").to_string(), submission_time: sub["submission_time"].as_str().unwrap_or("").to_string(), done: sub["done"].as_bool().unwrap_or(false), - gpu_type: sub["gpu_type"].as_str().map(str::to_string), - score: sub["score"].as_f64(), + runs, }); }