Repository: METR/vivaria
Pull request #961: Add option to filter by report
Author: login: sjawhar
Created: 2025-03-07T03:11:03Z | Merged: 2025-03-14T01:42:08Z

<img width="814" alt="image" src="https://github.com/user-attachments/assets/4e5ec6ae-fd07-4b88-80f6-53c5e9e03701" />
<img width="814" alt="image" src="https://github.com/user-attachments/assets/b9190840-f6d8-4ebb-be68-413c09e5b721" />


Details:
* To be used with transcripts server, for filtering to a specific group of runs.
* After first trying `metadata->'report_name' = $report_name`, I decided to switch to a separate `report_runs_t` table that I think would be both more performant and easier to maintain
     * easier to maintain e.g. can "reset" a report by deleting all rows with that report name (instead of running update on `runs_t`). Messing with JSON fields in SQL is annoying.
* The report name drop-down is pre-populated by the `/getReportNames` route.
    * This functionality is only enabled if the app is running in read-only mode

Testing:
- Set `VITE_IS_READ_ONLY: true` and `VITE_USE_AUTH0: false` in the UI
- SET `VIVARIA_IS_READ_ONLY: true` and `USE_AUTH0: false` in the server
- `docker compose up -d`
- Can also provide `?report_name=foo` in the URL


Diff:
diff --git a/.mentat/precommit.sh b/.mentat/precommit.sh
index 705110b3e..c720b0541 100755
--- a/.mentat/precommit.sh
+++ b/.mentat/precommit.sh
@@ -1,7 +1,7 @@
 #!/bin/bash
 set -eux
 
-. $(/opt/poetry/bin/poetry env info --path)/bin/activate
+. ./.venv/bin/activate
 ruff format .
 ruff check --fix .
 pyright ./pyhooks ./cli
diff --git a/.mentat/setup.sh b/.mentat/setup.sh
index 592123189..2e0fa912f 100755
--- a/.mentat/setup.sh
+++ b/.mentat/setup.sh
@@ -8,7 +8,7 @@ curl -sSL https://install.python-poetry.org | \
     POETRY_VERSION=1.8.3 \
     python3 -
 
-/opt/poetry/bin/poetry install
+POETRY_VIRTUALENVS_IN_PROJECT=true /opt/poetry/bin/poetry install
 
 curl -fsSL https://get.pnpm.io/install.sh | env PNPM_VERSION=9.11.0 PNPM_HOME=/opt/pnpm bash -
 /opt/pnpm/pnpm install --prefer-frozen-lockfile
diff --git a/cli/tests/test_main.py b/cli/tests/test_main.py
index eee1f0314..278c9b631 100644
--- a/cli/tests/test_main.py
+++ b/cli/tests/test_main.py
@@ -27,7 +27,28 @@ def fixture_home_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) ->
     return fake_home
 
 
-@pytest.mark.parametrize("query_type", [None, "string", "file"])
+@pytest.mark.parametrize(
+    ("query_type", "report_name", "expected_error"),
+    [
+        pytest.param(None, None, None, id="default_query"),
+        pytest.param("string", None, None, id="custom_query_string"),
+        pytest.param("file", None, None, id="custom_query_file"),
+        pytest.param(None, "test-report", None, id="report_query"),
+        pytest.param(None, "monthly-eval", None, id="monthly_report_query"),
+        pytest.param(
+            "string",
+            "test-report",
+            "Cannot provide both query and report_name",
+            id="error_both_query_string_and_report",
+        ),
+        pytest.param(
+            "file",
+            "monthly-eval",
+            "Cannot provide both query and report_name",
+            id="error_both_query_file_and_report",
+        ),
+    ],
+)
 @pytest.mark.parametrize("output_format", ["csv", "json", "jsonl"])
 @pytest.mark.parametrize("output_path", [None, "output.txt"])
 @pytest.mark.parametrize("runs", [[], [{"id": "123"}], [{"id": "456"}, {"id": "789"}]])
@@ -38,6 +59,8 @@ def test_query(  # noqa: PLR0913
     output_format: Literal["csv", "json", "jsonl"],
     output_path: str | None,
     query_type: str | None,
+    report_name: str | None,
+    expected_error: str | None,
     runs: list[dict[str, str]],
 ) -> None:
     cli = viv_cli.Vivaria()
@@ -59,9 +82,23 @@ def test_query(  # noqa: PLR0913
     query_runs = mocker.patch(
         "viv_cli.viv_api.query_runs", autospec=True, return_value={"rows": runs}
     )
+    mock_err_exit = mocker.patch.object(viv_cli, "err_exit", autospec=True)
+
+    cli.query(
+        output_format=output_format, query=query, output=tilde_output_path, report_name=report_name
+    )
 
-    cli.query(output_format=output_format, query=query, output=tilde_output_path)
-    query_runs.assert_called_once_with(expected_query)
+    if expected_error is not None:
+        mock_err_exit.assert_called_once()
+        assert expected_error in mock_err_exit.call_args[0][0]
+        return
+
+    mock_err_exit.assert_not_called()
+
+    if report_name is not None:
+        query_runs.assert_called_once_with(query=None, report_name=report_name)
+    else:
+        query_runs.assert_called_once_with(query=expected_query, report_name=None)
 
     if output_format == "json":
         expected_output = json.dumps(runs, indent=2)
diff --git a/cli/tests/test_viv_api.py b/cli/tests/test_viv_api.py
index bcd4f71c2..d91e54715 100644
--- a/cli/tests/test_viv_api.py
+++ b/cli/tests/test_viv_api.py
@@ -1,4 +1,7 @@
-import pathlib
+from __future__ import annotations
+
+import contextlib
+from typing import TYPE_CHECKING
 
 import pytest
 from pytest_mock import MockerFixture
@@ -6,7 +9,13 @@
 import viv_cli.viv_api as api
 
 
-def test_upload_file_max_size(tmp_path: pathlib.Path, mocker: MockerFixture) -> None:
+if TYPE_CHECKING:
+    from pathlib import Path
+
+    from _pytest.python_api import RaisesContext
+
+
+def test_upload_file_max_size(tmp_path: Path, mocker: MockerFixture) -> None:
     mocker.patch("viv_cli.viv_api.MAX_FILE_SIZE", 10)
     file_path = tmp_path / "large_file.txt"
     file_path.write_text("test" * 100)
@@ -14,7 +23,7 @@ def test_upload_file_max_size(tmp_path: pathlib.Path, mocker: MockerFixture) ->
         api.upload_file(file_path)
 
 
-def test_upload_folder_max_size(tmp_path: pathlib.Path, mocker: MockerFixture) -> None:
+def test_upload_folder_max_size(tmp_path: Path, mocker: MockerFixture) -> None:
     mocker.patch("viv_cli.viv_api.MAX_FILE_SIZE", 10)
     folder_path = tmp_path / "large_folder"
     folder_path.mkdir()
@@ -116,3 +125,60 @@ def test_update_run(
     )
 
     mock_post.assert_called_once_with("/updateAgentBranch", expected_request)
+
+
+@pytest.mark.parametrize(
+    ("query", "report_name", "expected_body", "expected_error"),
+    [
+        pytest.param(None, None, {"type": "default"}, None, id="default_query"),
+        pytest.param(
+            "SELECT * FROM runs",
+            None,
+            {"type": "custom", "query": "SELECT * FROM runs"},
+            None,
+            id="custom_query",
+        ),
+        pytest.param(
+            None,
+            "test-report",
+            {"type": "report", "reportName": "test-report"},
+            None,
+            id="report_query",
+        ),
+        pytest.param(
+            None,
+            "monthly-eval",
+            {"type": "report", "reportName": "monthly-eval"},
+            None,
+            id="monthly_report_query",
+        ),
+        pytest.param(
+            "SELECT * FROM runs",
+            "test-report",
+            None,
+            "Cannot specify both query and report_name",
+            id="error_both_query_and_report",
+        ),
+    ],
+)
+def test_query_runs(
+    mocker: MockerFixture,
+    query: str | None,
+    report_name: str | None,
+    expected_body: dict | None,
+    expected_error: RaisesContext | None,
+) -> None:
+    """Test that query_runs builds the correct request body."""
+    mock_post = mocker.patch("viv_cli.viv_api._post", autospec=True)
+    spy_err_exit = mocker.spy(api, "err_exit")
+
+    with pytest.raises(SystemExit) if expected_error is not None else contextlib.nullcontext():
+        api.query_runs(query=query, report_name=report_name)
+
+    if expected_error is not None:
+        spy_err_exit.assert_called_once_with(expected_error)
+        mock_post.assert_not_called()
+        return
+
+    mock_post.assert_called_once_with("/queryRunsMutation", expected_body)
+    spy_err_exit.assert_not_called()
diff --git a/cli/viv_cli/main.py b/cli/viv_cli/main.py
index 8e62eec95..7c12632da 100644
--- a/cli/viv_cli/main.py
+++ b/cli/viv_cli/main.py
@@ -804,24 +804,28 @@ def get_run_status(self, run_id: int) -> None:
     def query(
         self,
         query: str | None = None,
+        report_name: str | None = None,
         output_format: Literal["csv", "json", "jsonl"] = "jsonl",
         output: str | pathlib.Path | None = None,
     ) -> None:
         """Query vivaria database.
 
         Args:
-            query: The query to execute, or the path to a query. If not provided, runs the default
-                query.
+            query: The query to execute, or the path to a query. Cannot be provided if report_name
+                is provided. If neither are provided, runs the default query.
+            report_name: The name of the report to query. Cannot be provided if query is provided.
+                If neither are provided, runs the default query.
             output_format: The format to output the runs in. Either "csv" or "json".
             output: The path to a file to output the runs to. If not provided, prints to stdout.
         """
-        if query is not None:
+        if query is not None and report_name is not None:
+            err_exit("Cannot provide both query and report_name")
+        elif query is not None:
             query_file = pathlib.Path(query).expanduser()
             if query_file.exists():
-                with query_file.open() as file:
-                    query = file.read()
+                query = query_file.read_text()
 
-        runs = viv_api.query_runs(query).get("rows", [])
+        runs = viv_api.query_runs(query=query, report_name=report_name).get("rows", [])
 
         if output is not None:
             output_file = pathlib.Path(output).expanduser()
diff --git a/cli/viv_cli/viv_api.py b/cli/viv_cli/viv_api.py
index 9502463e2..9cf840ce7 100644
--- a/cli/viv_cli/viv_api.py
+++ b/cli/viv_cli/viv_api.py
@@ -329,9 +329,20 @@ def get_agent_state(run_id: int, index: int, agent_branch_number: int = 0) -> Re
     )
 
 
-def query_runs(query: str | None = None) -> dict[str, list[dict[str, Any]]]:
+def query_runs(
+    query: str | None = None, report_name: str | None = None
+) -> dict[str, list[dict[str, Any]]]:
     """Query runs."""
-    body = {"type": "default"} if query is None else {"type": "custom", "query": query}
+    if query is not None and report_name is not None:
+        err_exit("Cannot specify both query and report_name")
+
+    if query is not None:
+        body = {"type": "custom", "query": query}
+    elif report_name is not None:
+        body = {"type": "report", "reportName": report_name}
+    else:
+        body = {"type": "default"}
+
     return _post("/queryRunsMutation", body)
 
 
diff --git a/server/src/lib/db_helpers.ts b/server/src/lib/db_helpers.ts
index e666d0485..5079e6e84 100644
--- a/server/src/lib/db_helpers.ts
+++ b/server/src/lib/db_helpers.ts
@@ -1,5 +1,5 @@
 import { Client } from 'pg'
-import { TraceEntry, type Services } from 'shared'
+import { TraceEntry, type ParameterizedQuery, type Services } from 'shared'
 import type { Config } from '../services'
 import { Bouncer } from '../services'
 import { DBTraceEntries } from '../services/db/DBTraceEntries'
@@ -33,7 +33,7 @@ export async function editTraceEntry(svc: Services, te: Omit<TraceEntry, 'called
   })
 }
 
-export async function readOnlyDbQuery(config: Config, sql: string) {
+export async function readOnlyDbQuery(config: Config, query: ParameterizedQuery) {
   // This would normally be quite dangerous (sql injection / random
   // modifications of tables / etc), but it's executed with a special read-only
   // user
@@ -41,7 +41,7 @@ export async function readOnlyDbQuery(config: Config, sql: string) {
   await client.connect()
   let result
   try {
-    result = await client.query(sql)
+    result = await client.query(query)
   } finally {
     void client.end()
   }
diff --git a/server/src/migrations/20250311191208_add_report_runs_table.ts b/server/src/migrations/20250311191208_add_report_runs_table.ts
new file mode 100644
index 000000000..f07862a2b
--- /dev/null
+++ b/server/src/migrations/20250311191208_add_report_runs_table.ts
@@ -0,0 +1,23 @@
+import 'dotenv/config'
+
+import { Knex } from 'knex'
+import { sql, withClientFromKnex } from '../services/db/db'
+
+export async function up(knex: Knex) {
+  await withClientFromKnex(knex, async conn => {
+    await conn.none(sql`
+      CREATE TABLE report_runs_t (
+        "reportName" text NOT NULL,
+        "runId" integer NOT NULL REFERENCES runs_t("id"),
+        "createdAt" bigint NOT NULL DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000,
+        PRIMARY KEY ("reportName", "runId")
+      );
+    `)
+  })
+}
+
+export async function down(knex: Knex) {
+  await withClientFromKnex(knex, async conn => {
+    await conn.none(sql`DROP TABLE IF EXISTS report_runs_t CASCADE;`)
+  })
+}
diff --git a/server/src/migrations/schema.sql b/server/src/migrations/schema.sql
index efafa507a..f6304f368 100644
--- a/server/src/migrations/schema.sql
+++ b/server/src/migrations/schema.sql
@@ -312,6 +312,14 @@ CREATE TABLE public.trace_entry_summaries_t (
     PRIMARY KEY ("runId", index)
 );
 
+-- Records runs that have been used to generate a report. Used for run filtering by report.
+CREATE TABLE public.report_runs_t (
+    "reportName" text NOT NULL,
+    "runId" integer NOT NULL REFERENCES runs_t("id"),
+    "createdAt" bigint NOT NULL DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000,
+    PRIMARY KEY ("reportName", "runId")
+);
+
 -- #endregion
 
 -- #region create view statements
diff --git a/server/src/routes/general_routes.test.ts b/server/src/routes/general_routes.test.ts
index 16b8a3245..0a0bf1932 100644
--- a/server/src/routes/general_routes.test.ts
+++ b/server/src/routes/general_routes.test.ts
@@ -35,6 +35,7 @@ import {
   Auth,
   Bouncer,
   Config,
+  DB,
   DBRuns,
   DBTaskEnvironments,
   DBTraceEntries,
@@ -50,6 +51,7 @@ import { AgentContainerRunner } from '../docker'
 import { readOnlyDbQuery } from '../lib/db_helpers'
 import { decrypt } from '../secrets'
 import { AgentContext, MACHINE_PERMISSION } from '../services/Auth'
+import { reportRunsTable } from '../services/db/tables'
 import { Hosts } from '../services/Hosts'
 import { Scoring } from '../services/scoring'
 import { oneTimeBackgroundProcesses } from '../util'
@@ -163,9 +165,11 @@ describe('getTaskEnvironments', { skip: process.env.INTEGRATION_TESTING == null
 })
 
 describe.each([{ endpoint: 'queryRuns' as const }, { endpoint: 'queryRunsMutation' as const }])(
-  '$endpoint',
+  'tRPC endpoint $endpoint',
   { skip: process.env.INTEGRATION_TESTING == null },
   ({ endpoint }: { endpoint: 'queryRuns' | 'queryRunsMutation' }) => {
+    TestHelper.beforeEachClearDb()
+
     it("fails if the user doesn't have the researcher database access permission but tries to run a custom query", async () => {
       await using helper = new TestHelper()
       const trpc = getUserTrpc(helper)
@@ -216,9 +220,67 @@ describe.each([{ endpoint: 'queryRuns' as const }, { endpoint: 'queryRunsMutatio
         { name: 'metadata', tableName: 'runs_v', columnName: 'metadata' },
       ])
     })
+
+    test('returns runs filtered by report name when using report query type', async () => {
+      await using helper = new TestHelper()
+      const db = helper.get(DB)
+      const trpc = getUserTrpc(helper, { permissions: [RESEARCHER_DATABASE_ACCESS_PERMISSION] })
+
+      const runIdWithReport = await insertRunAndUser(helper, { batchName: null })
+      const runIdWithOtherReport = await insertRunAndUser(helper, { batchName: null })
+      await insertRunAndUser(helper, { batchName: null }) // run with no reports
+
+      await Promise.all(
+        [
+          { reportName: 'test-report', runId: runIdWithReport },
+          { reportName: 'another-report', runId: runIdWithReport },
+          { reportName: 'different-report', runId: runIdWithOtherReport },
+        ].map(row => db.none(reportRunsTable.buildInsertQuery(row))),
+      )
+
+      const result = await trpc[endpoint]({
+        type: 'report',
+        reportName: 'test-report',
+      })
+
+      expect(new Set(result.rows.map(row => row.id))).toEqual(new Set([runIdWithReport]))
+    })
   },
 )
 
+describe('getReportNames', { skip: process.env.INTEGRATION_TESTING == null }, () => {
+  TestHelper.beforeEachClearDb()
+  test('returns all distinct report names in alphabetical order', async () => {
+    await using helper = new TestHelper()
+    const trpc = getUserTrpc(helper)
+    const db = helper.get(DB)
+
+    const runId1 = await insertRunAndUser(helper, { batchName: null })
+    const runId2 = await insertRunAndUser(helper, { batchName: null })
+
+    await Promise.all(
+      [
+        { reportName: 'zebra-report', runId: runId1 },
+        { reportName: 'apple-report', runId: runId1 },
+        { reportName: 'apple-report', runId: runId2 },
+        { reportName: 'banana-report', runId: runId2 },
+      ].map(row => db.none(reportRunsTable.buildInsertQuery(row))),
+    )
+
+    const reportNames = await trpc.getReportNames()
+
+    expect(reportNames).toEqual(['apple-report', 'banana-report', 'zebra-report'])
+  })
+
+  test('returns empty array when no reports exist', async () => {
+    await using helper = new TestHelper()
+    const trpc = getUserTrpc(helper)
+
+    const reportNames = await trpc.getReportNames()
+    expect(reportNames).toEqual([])
+  })
+})
+
 describe('grantUserAccessToTaskEnvironment', { skip: process.env.INTEGRATION_TESTING == null }, () => {
   TestHelper.beforeEachClearDb()
 
@@ -726,10 +788,10 @@ describe('updateRunBatch', { skip: process.env.INTEGRATION_TESTING == null }, ()
   TestHelper.beforeEachClearDb()
 
   async function getRunBatchConcurrencyLimit(helper: TestHelper, name: string) {
-    const result = await readOnlyDbQuery(
-      helper.get(Config),
-      `SELECT "concurrencyLimit" FROM run_batches_t WHERE name = '${name}'`,
-    )
+    const result = await readOnlyDbQuery(helper.get(Config), {
+      text: 'SELECT "concurrencyLimit" FROM run_batches_t WHERE name = $1',
+      values: [name],
+    })
     return result.rows[0].concurrencyLimit
   }
 
@@ -1271,7 +1333,10 @@ describe('insertManualScore', { skip: process.env.INTEGRATION_TESTING == null },
       allowExisting: false,
     })
 
-    const result = await readOnlyDbQuery(helper.get(Config), `SELECT * FROM manual_scores_t ORDER BY "createdAt"`)
+    const result = await readOnlyDbQuery(helper.get(Config), {
+      text: 'SELECT * FROM manual_scores_t ORDER BY "createdAt"',
+      values: [],
+    })
     expect(result.rows.length).toEqual(2)
     assertManualScoreEqual(result.rows[0], { ...user1Score, userId: userId1 })
     assertManualScoreEqual(result.rows[1], { ...user2Score, userId: userId2 })
@@ -1289,7 +1354,10 @@ describe('insertManualScore', { skip: process.env.INTEGRATION_TESTING == null },
       allowExisting: false,
     })
 
-    const result = await readOnlyDbQuery(helper.get(Config), `SELECT * FROM manual_scores_t`)
+    const result = await readOnlyDbQuery(helper.get(Config), {
+      text: 'SELECT * FROM manual_scores_t',
+      values: [],
+    })
     expect(result.rows.length).toEqual(1)
     assertManualScoreEqual(result.rows[0], { ...score, userId: 'user-id' })
   })
@@ -1319,7 +1387,10 @@ describe('insertManualScore', { skip: process.env.INTEGRATION_TESTING == null },
       }),
     )
 
-    const result = await readOnlyDbQuery(helper.get(Config), `SELECT * FROM manual_scores_t`)
+    const result = await readOnlyDbQuery(helper.get(Config), {
+      text: 'SELECT * FROM manual_scores_t',
+      values: [],
+    })
     expect(result.rows.length).toEqual(0)
   })
 
@@ -1349,7 +1420,10 @@ describe('insertManualScore', { skip: process.env.INTEGRATION_TESTING == null },
       allowExisting: false,
     })
 
-    const result = await readOnlyDbQuery(helper.get(Config), `SELECT * FROM manual_scores_t`)
+    const result = await readOnlyDbQuery(helper.get(Config), {
+      text: 'SELECT * FROM manual_scores_t',
+      values: [],
+    })
     expect(result.rows.length).toEqual(1)
     assertManualScoreEqual(result.rows[0], { ...score, userId: 'user-id' })
   })
@@ -1384,7 +1458,10 @@ describe('insertManualScore', { skip: process.env.INTEGRATION_TESTING == null },
       }),
     )
 
-    const result = await readOnlyDbQuery(helper.get(Config), `SELECT * FROM manual_scores_t`)
+    const result = await readOnlyDbQuery(helper.get(Config), {
+      text: 'SELECT * FROM manual_scores_t',
+      values: [],
+    })
     expect(result.rows.length).toEqual(1)
     assertManualScoreEqual(result.rows[0], { ...score1, userId: 'user-id' })
   })
@@ -1408,7 +1485,10 @@ describe('insertManualScore', { skip: process.env.INTEGRATION_TESTING == null },
       allowExisting: true,
     })
 
-    const result = await readOnlyDbQuery(helper.get(Config), `SELECT * FROM manual_scores_t ORDER BY "createdAt"`)
+    const result = await readOnlyDbQuery(helper.get(Config), {
+      text: 'SELECT * FROM manual_scores_t ORDER BY "createdAt"',
+      values: [],
+    })
     expect(result.rows.length).toEqual(2)
 
     assertManualScoreEqual(result.rows[0], { ...score1, userId: 'user-id' }, true)
@@ -1569,35 +1649,10 @@ describe('getScoreLogUsers', { skip: process.env.INTEGRATION_TESTING == null },
   test('returns score log for user', async () => {
     await using helper = new TestHelper()
     const dbBranches = helper.get(DBBranches)
-    const dbUsers = helper.get(DBUsers)
     const userId = 'user-id'
-    await dbUsers.upsertUser(userId, 'username', 'email@example.com')
     const runId = await insertRunAndUser(helper, { batchName: null, userId })
     const branchKey = { runId, agentBranchNumber: TRUNK }
 
-    // Mock TaskFetcher to prevent task repo error
-    const taskFetcher = helper.get(TaskFetcher)
-    mock.method(taskFetcher, 'fetch', () =>
-      Promise.resolve(
-        new FetchedTask(
-          {
-            taskFamilyName: 'taskfamily',
-            taskName: 'taskname',
-            id: TaskId.parse('taskfamily/taskname'),
-            source: {
-              type: 'gitRepo',
-              repoName: 'METR/tasks-repo',
-              commitId: 'task-repo-commit-id',
-              isMainAncestor: true,
-            },
-            imageName: 'image',
-            containerName: 'container',
-          },
-          '/tmp/task',
-        ),
-      ),
-    )
-
     // Mock Hosts to prevent docker error
     const hosts = helper.get(Hosts)
     mock.method(hosts, 'getHostForRun', () => Promise.resolve(Host.local(PrimaryVmHost.MACHINE_ID)))
diff --git a/server/src/routes/general_routes.ts b/server/src/routes/general_routes.ts
index f56f3f2b3..c38ad0404 100644
--- a/server/src/routes/general_routes.ts
+++ b/server/src/routes/general_routes.ts
@@ -24,6 +24,7 @@ import {
   MiddlemanServerRequest,
   ModelInfo,
   OpenaiChatRole,
+  ParameterizedQuery,
   ParsedAccessToken,
   Pause,
   QueryRunsRequest,
@@ -54,7 +55,7 @@ import {
   dedent,
   exhaustiveSwitch,
   formatSummarizationPrompt,
-  getRunsPageDefaultQuery,
+  getRunsPageQuery,
   hackilyPickOption,
   isRunsViewField,
   makeTaskId,
@@ -98,6 +99,7 @@ import { RunError } from '../services/RunKiller'
 import { DBBranches, RowAlreadyExistsError, RunPauseOverride, WorkPeriod } from '../services/db/DBBranches'
 import { TagAndComment } from '../services/db/DBTraceEntries'
 import { DBRowNotFoundError } from '../services/db/db'
+import { ReportRun, reportRunsTable } from '../services/db/tables'
 import { errorToString } from '../util'
 import { getScoreLogHelper } from './shared_helpers'
 import { userAndMachineProc, userProc } from './trpc_setup'
@@ -341,18 +343,27 @@ async function queryRuns(ctx: Context, queryRequest: QueryRunsRequest, rowLimit:
   const config = ctx.svc.get(Config)
   let result
 
+  // Common query parameters
+  const orderBy = config.VIVARIA_IS_READ_ONLY ? 'score' : 'createdAt'
+  const limit = config.VIVARIA_IS_READ_ONLY ? 3000 : 500
+
+  let query: ParameterizedQuery
+  if (queryRequest.type === 'custom') {
+    query = { text: queryRequest.query, values: [] }
+  } else if (queryRequest.type === 'report') {
+    query = getRunsPageQuery({
+      orderBy,
+      limit,
+      reportName: queryRequest.reportName,
+    })
+  } else {
+    query = getRunsPageQuery({ orderBy, limit })
+  }
+
   // This query could contain arbitrary user input, so it's imperative that we
   // only execute it with a read-only postgres user
   try {
-    result = await readOnlyDbQuery(
-      config,
-      queryRequest.type === 'custom'
-        ? queryRequest.query
-        : getRunsPageDefaultQuery({
-            orderBy: config.VIVARIA_IS_READ_ONLY ? 'score' : '"createdAt"',
-            limit: config.VIVARIA_IS_READ_ONLY ? 3000 : 500,
-          }),
-    )
+    result = await readOnlyDbQuery(config, query)
   } catch (e) {
     if (e instanceof DatabaseError) {
       throw new TRPCError({
@@ -677,7 +688,10 @@ export const generalRoutes = {
           message: 'Query must select an id column from either runs_t or runs_v',
         })
       }
-      const { rows } = await readOnlyDbQuery(config, `SELECT relname FROM pg_class WHERE oid = ${idField.tableID}`)
+      const { rows } = await readOnlyDbQuery(config, {
+        text: 'SELECT relname FROM pg_class WHERE oid = $1',
+        values: [idField.tableID],
+      })
       if (!validTableNames.has(rows[0]!.relname)) {
         throw new TRPCError({
           code: 'BAD_REQUEST',
@@ -1655,4 +1669,31 @@ export const generalRoutes = {
       await bouncer.assertRunPermission(ctx, input.runId)
       return getScoreLogHelper(ctx, input, { returnScore: true })
     }),
+  getReportNames: userProc.output(z.array(z.string())).query(async ({ ctx }) => {
+    const config = ctx.svc.get(Config)
+
+    try {
+      const result = await readOnlyDbQuery(config, {
+        text: `
+          SELECT DISTINCT "reportName"
+          FROM "${reportRunsTable.tableName}"
+          ORDER BY "reportName" ASC
+        `,
+        values: [],
+      })
+
+      return z
+        .array(ReportRun.pick({ reportName: true }))
+        .parse(result.rows)
+        .map(row => row.reportName)
+    } catch (e) {
+      if (e instanceof DatabaseError) {
+        throw new TRPCError({
+          code: 'BAD_REQUEST',
+          message: errorToString(e),
+        })
+      }
+      throw e
+    }
+  }),
 } as const
diff --git a/server/src/runs_v.test.ts b/server/src/runs_v.test.ts
index 6b00d2ec4..f4201b1dc 100644
--- a/server/src/runs_v.test.ts
+++ b/server/src/runs_v.test.ts
@@ -13,7 +13,10 @@ describe.skipIf(process.env.INTEGRATION_TESTING == null)('runs_v', () => {
   TestHelper.beforeEachClearDb()
 
   async function getRunStatus(config: Config, id: RunId) {
-    const result = await readOnlyDbQuery(config, `SELECT "runStatus" from runs_v WHERE id = ${id}`)
+    const result = await readOnlyDbQuery(config, {
+      text: 'SELECT "runStatus" from runs_v WHERE id = $1',
+      values: [id],
+    })
     return result.rows[0].runStatus
   }
 
@@ -96,7 +99,10 @@ describe.skipIf(process.env.INTEGRATION_TESTING == null)('runs_v', () => {
     })
     await sleep(10)
 
-    const result = await readOnlyDbQuery(config, 'SELECT id, "queuePosition" FROM runs_v')
+    const result = await readOnlyDbQuery(config, {
+      text: 'SELECT id, "queuePosition" FROM runs_v',
+      values: [],
+    })
     const queuePositionsById = Object.fromEntries(result.rows.map(({ id, queuePosition }) => [id, queuePosition]))
     expect(queuePositionsById).toEqual({
       // High-priority runs come first. Within high-priority runs, the newer run comes first.
diff --git a/server/src/services/db/DBTraceEntries.test.ts b/server/src/services/db/DBTraceEntries.test.ts
index dbb85cc63..571de06c9 100644
--- a/server/src/services/db/DBTraceEntries.test.ts
+++ b/server/src/services/db/DBTraceEntries.test.ts
@@ -111,11 +111,11 @@ describe.skipIf(process.env.INTEGRATION_TESTING == null)('DBTraceEntries', () =>
     ])
 
     const config = helper.get(Config)
-    const readOnlyModelsResult = await readOnlyDbQuery(
-      config,
-      dedent`SELECT model FROM trace_entries_t
+    const readOnlyModelsResult = await readOnlyDbQuery(config, {
+      text: dedent`SELECT model FROM trace_entries_t
              JOIN run_models_t ON trace_entries_t."runId" = run_models_t."runId"`,
-    )
+      values: [],
+    })
     assert.deepStrictEqual(
       readOnlyModelsResult.rows.map(row => row.model),
       ['gpt-4o1'],
diff --git a/server/src/services/db/tables.ts b/server/src/services/db/tables.ts
index 4689c0050..568ce011b 100644
--- a/server/src/services/db/tables.ts
+++ b/server/src/services/db/tables.ts
@@ -416,6 +416,15 @@ export type UserPreference = z.output<typeof UserPreference>
 
 export const userPreferencesTable = DBTable.create(sqlLit`user_preferences_t`, UserPreference, UserPreference)
 
+export const ReportRun = z.object({
+  reportName: z.string(),
+  runId: RunId,
+  createdAt: uint,
+})
+export type ReportRun = z.output<typeof ReportRun>
+
+export const reportRunsTable = DBTable.create(sqlLit`report_runs_t`, ReportRun, ReportRun.omit({ createdAt: true }))
+
 // Vivaria doesn't have any TypeScript code that reads from or writes to hidden_models_t.
 // Still, we register the table here so that we can truncate it in tests.
 DBTable.create(sqlLit`hidden_models_t`, z.object({}), z.object({}))
diff --git a/shared/src/constants.test.ts b/shared/src/constants.test.ts
new file mode 100644
index 000000000..bcc3f9e61
--- /dev/null
+++ b/shared/src/constants.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, test } from 'vitest'
+import { getRunsPageQuery, RUNS_PAGE_INITIAL_COLUMNS } from './constants'
+import { dedent } from './lib/dedent'
+
+describe('getRunsPageQuery', () => {
+  test.each([
+    {
+      name: 'basic query without reportName',
+      input: { orderBy: 'createdAt', limit: 100, reportName: null },
+      expected: {
+        text: `
+        SELECT ${RUNS_PAGE_INITIAL_COLUMNS}
+        FROM runs_v
+        -- WHERE "runStatus" = 'running'
+        ORDER BY "createdAt" DESC
+        LIMIT 100`,
+        values: [],
+      },
+    },
+    {
+      name: 'query with reportName',
+      input: { orderBy: 'id', limit: 50, reportName: 'test-report' },
+      expected: {
+        text: `
+        WITH report_runs AS (
+          SELECT "runId"
+          FROM report_runs_t
+          WHERE "reportName" = $1
+        )
+        SELECT ${RUNS_PAGE_INITIAL_COLUMNS}
+        FROM runs_v
+        INNER JOIN report_runs
+          ON report_runs."runId" = runs_v.id
+        -- WHERE "runStatus" = 'running'
+        ORDER BY "id" DESC
+        LIMIT 50`,
+        values: ['test-report'],
+      },
+    },
+    {
+      name: 'query with reportName containing single quotes (SQL injection test)',
+      input: { orderBy: 'id', limit: 10, reportName: "Bobby's Report" },
+      expected: {
+        text: `
+        WITH report_runs AS (
+          SELECT "runId"
+          FROM report_runs_t
+          WHERE "reportName" = $1
+        )
+        SELECT ${RUNS_PAGE_INITIAL_COLUMNS}
+        FROM runs_v
+        INNER JOIN report_runs
+          ON report_runs."runId" = runs_v.id
+        -- WHERE "runStatus" = 'running'
+        ORDER BY "id" DESC
+        LIMIT 10`,
+        values: ["Bobby's Report"],
+      },
+    },
+    {
+      name: 'query with empty string reportName',
+      input: { orderBy: 'score', limit: 5, reportName: '' },
+      expected: {
+        text: `
+        SELECT ${RUNS_PAGE_INITIAL_COLUMNS}
+        FROM runs_v
+        -- WHERE "runStatus" = 'running'
+        ORDER BY "score" DESC
+        LIMIT 5`,
+        values: [],
+      },
+    },
+    {
+      name: 'SQL injection attempt in orderBy',
+      input: { orderBy: 'id; DROP TABLE users;', limit: 20, reportName: null },
+      expected: {
+        text: `
+        SELECT ${RUNS_PAGE_INITIAL_COLUMNS}
+        FROM runs_v
+        -- WHERE "runStatus" = 'running'
+        ORDER BY "id; DROP TABLE users;" DESC
+        LIMIT 20`,
+        values: [],
+      },
+    },
+  ])('$name', ({ input, expected }) => {
+    const result = getRunsPageQuery(input)
+    expect(result.text).toEqual(dedent(expected.text))
+    expect(result.values).toEqual(expected.values)
+  })
+})
diff --git a/shared/src/constants.ts b/shared/src/constants.ts
index acc82d385..791bd934e 100644
--- a/shared/src/constants.ts
+++ b/shared/src/constants.ts
@@ -338,14 +338,49 @@ export const RESEARCHER_DATABASE_ACCESS_PERMISSION = 'researcher-database-access
 
 export const RUNS_PAGE_INITIAL_COLUMNS = `id, "taskId", agent, "runStatus", "isContainerRunning", "createdAt", "isInteractive", submission, score, username, metadata`
 
-export function getRunsPageDefaultQuery(args: { orderBy: string; limit: number }) {
-  return dedent`
-    SELECT ${RUNS_PAGE_INITIAL_COLUMNS}
-    FROM runs_v
-    -- WHERE "runStatus" = 'running'
-    ORDER BY ${args.orderBy} DESC
-    LIMIT ${args.limit}
-  `
+export interface ParameterizedQuery {
+  text: string
+  values: any[]
+}
+
+export function getRunsPageQuery(args: {
+  orderBy: string
+  limit: number
+  reportName?: string | null
+}): ParameterizedQuery {
+  let withClause = ''
+  let fromClause = 'FROM runs_v'
+  const values: any[] = []
+
+  if (args.reportName !== undefined && args.reportName !== null && args.reportName.length > 0) {
+    withClause = dedent`
+      WITH report_runs AS (
+        SELECT "runId"
+        FROM report_runs_t
+        WHERE "reportName" = $${values.length + 1}
+      )
+    `
+    fromClause = dedent`
+      FROM runs_v
+      INNER JOIN report_runs
+        ON report_runs."runId" = runs_v.id
+    `
+    values.push(args.reportName)
+  }
+
+  const query = dedent`
+  ${withClause}
+  SELECT ${RUNS_PAGE_INITIAL_COLUMNS}
+  ${fromClause}
+  -- WHERE "runStatus" = 'running'
+  ORDER BY ${JSON.stringify(args.orderBy)} DESC
+  LIMIT ${JSON.stringify(args.limit)}
+  `.trim()
+
+  return {
+    text: query,
+    values,
+  }
 }
 
 export const MAX_ANALYSIS_RUNS = 100
diff --git a/shared/src/types.ts b/shared/src/types.ts
index 899086542..9596dd808 100644
--- a/shared/src/types.ts
+++ b/shared/src/types.ts
@@ -853,6 +853,7 @@ export type ExtraRunData = I<typeof ExtraRunData>
 export const QueryRunsRequest = z.discriminatedUnion('type', [
   z.object({ type: z.literal('default') }),
   z.object({ type: z.literal('custom'), query: z.string() }),
+  z.object({ type: z.literal('report'), reportName: z.string() }),
 ])
 export type QueryRunsRequest = I<typeof QueryRunsRequest>
 
diff --git a/ui/src/runs/RunsPage.test.tsx b/ui/src/runs/RunsPage.test.tsx
index bb8401a2b..1f73ddbf8 100644
--- a/ui/src/runs/RunsPage.test.tsx
+++ b/ui/src/runs/RunsPage.test.tsx
@@ -1,12 +1,6 @@
 import { fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { App } from 'antd'
-import {
-  ExtraRunData,
-  getRunsPageDefaultQuery,
-  RESEARCHER_DATABASE_ACCESS_PERMISSION,
-  RunQueueStatus,
-  TaskId,
-} from 'shared'
+import { ExtraRunData, getRunsPageQuery, RESEARCHER_DATABASE_ACCESS_PERMISSION, RunQueueStatus, TaskId } from 'shared'
 import { beforeEach, describe, expect, test, vi } from 'vitest'
 import { clickButton } from '../../test-util/actionUtils'
 import { assertCopiesToClipboard, assertLinkHasHref } from '../../test-util/assertions'
@@ -16,7 +10,7 @@ import { formatTimestamp } from '../run/util'
 import { trpc } from '../trpc'
 import * as auth0Client from '../util/auth0_client'
 import { getAgentRepoUrl, getRunUrl, taskRepoUrl as getTaskRepoUrl } from '../util/urls'
-import RunsPage, { QueryableRunsTable } from './RunsPage'
+import RunsPage, { interpolateQueryValues, QueryableRunsTable, ReportSelector } from './RunsPage'
 
 vi.spyOn(auth0Client, 'isAuth0Enabled', 'get').mockReturnValue(true)
 
@@ -42,6 +36,10 @@ const EXTRA_RUN_DATA: ExtraRunData = {
 }
 
 describe('RunsPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
   async function renderWithMocks(permissions: Array<string>, runQueueStatus: RunQueueStatus = RunQueueStatus.RUNNING) {
     mockExternalAPICall(trpc.getUserPermissions.query, permissions)
     mockExternalAPICall(trpc.getRunQueueStatus.query, { status: runQueueStatus })
@@ -66,10 +64,12 @@ describe('RunsPage', () => {
     await waitFor(() => {
       expect(trpc.queryRuns.query).toHaveBeenCalledWith({
         type: 'custom',
-        query: getRunsPageDefaultQuery({
-          orderBy: '"createdAt"',
-          limit: 500,
-        }),
+        query: interpolateQueryValues(
+          getRunsPageQuery({
+            orderBy: 'createdAt',
+            limit: 500,
+          }),
+        ),
       })
     })
   })
@@ -183,7 +183,7 @@ describe('run row classes', () => {
 
       const { container } = render(
         <App>
-          <QueryableRunsTable initialSql='SELECT * FROM runs_v' readOnly={false} />
+          <QueryableRunsTable initialSql='SELECT * FROM runs_v' allowCustomQueries />
         </App>,
       )
       await waitFor(() => {
@@ -197,7 +197,7 @@ describe('run row classes', () => {
 describe('QueryableRunsTable', () => {
   const DEFAULT_PROPS = {
     initialSql: "Robert'; DROP TABLE students;--",
-    readOnly: false,
+    allowCustomQueries: true,
   }
 
   beforeEach(() => {
@@ -212,7 +212,7 @@ describe('QueryableRunsTable', () => {
     mockExternalAPICall(trpc.queryRuns.query, { rows: [], fields: FIELDS, extraRunData: [] })
     const { container } = render(
       <App>
-        <QueryableRunsTable {...DEFAULT_PROPS} />
+        <QueryableRunsTable {...DEFAULT_PROPS} initialReportName={null} />
       </App>,
     )
     expect(container.textContent).toMatch('Run query')
@@ -227,7 +227,7 @@ describe('QueryableRunsTable', () => {
     mockExternalAPICall(trpc.queryRuns.query, { rows: [], fields: FIELDS, extraRunData: [] })
     const { container } = render(
       <App>
-        <QueryableRunsTable {...DEFAULT_PROPS} readOnly />
+        <QueryableRunsTable {...DEFAULT_PROPS} allowCustomQueries={false} initialReportName={null} />
       </App>,
     )
     expect(container.textContent).not.toMatch('Run query')
@@ -375,4 +375,140 @@ describe('QueryableRunsTable', () => {
       expect(container.textContent).toMatch('test-id TRUE' + 'test-id-2 FALSE')
     })
   })
+
+  test('applies report filter and sends the correct request to the server', async () => {
+    mockExternalAPICall(trpc.queryRuns.query, { rows: [], fields: [], extraRunData: [] })
+
+    render(
+      <App>
+        <QueryableRunsTable
+          initialSql={interpolateQueryValues(getRunsPageQuery({ orderBy: 'createdAt', limit: 500 }))}
+          initialReportName='test-report'
+          allowCustomQueries={false}
+        />
+      </App>,
+    )
+
+    await waitFor(() => {
+      expect(trpc.queryRuns.query).toHaveBeenCalledWith({
+        type: 'report',
+        reportName: 'test-report',
+      })
+    })
+
+    const queryEditor = screen.queryByRole('textbox')
+    expect(queryEditor).toBeNull()
+  })
+})
+
+describe('ReportSelector', () => {
+  test('calls onSelectReport with the entered report name on button click', async () => {
+    mockExternalAPICall(trpc.getReportNames.query, ['test-report'])
+
+    const onSelectReport = vi.fn()
+    render(
+      <App>
+        <ReportSelector onSelectReport={onSelectReport} />
+      </App>,
+    )
+
+    await waitFor(() => {
+      expect(trpc.getReportNames.query).toHaveBeenCalled()
+    })
+
+    const select = screen.getByTestId('report-name-select')
+
+    fireEvent.mouseDown(select.querySelector('.ant-select-selector')!)
+
+    await waitFor(() => {
+      const options = screen.getAllByText('test-report')
+      const option = options.find(el => el.closest('.ant-select-item-option-content'))
+      expect(option).not.toBeUndefined()
+      fireEvent.click(option!)
+    })
+
+    const filterButton = screen.getByTestId('apply-filter-button')
+    fireEvent.click(filterButton)
+    expect(onSelectReport).toHaveBeenCalledWith('test-report')
+  })
+
+  test('calls onSelectReport with empty string when clicking Clear Filter', () => {
+    const onSelectReport = vi.fn()
+    render(
+      <App>
+        <ReportSelector onSelectReport={onSelectReport} />
+      </App>,
+    )
+
+    const clearButton = screen.getByTestId('clear-filter-button')
+    fireEvent.click(clearButton)
+
+    expect(onSelectReport).toHaveBeenCalledWith(null)
+  })
+
+  test('initializes with the initialReportName if provided', () => {
+    const onSelectReport = vi.fn()
+    render(
+      <App>
+        <ReportSelector initialReportName='initial-report' onSelectReport={onSelectReport} />
+      </App>,
+    )
+
+    const select = screen.getByTestId('report-name-select')
+
+    const selectedOption = select.querySelector('.ant-select-selection-item')
+    expect(selectedOption?.textContent).toBe('initial-report')
+
+    const filterButton = screen.getByTestId('apply-filter-button')
+    fireEvent.click(filterButton)
+    expect(onSelectReport).toHaveBeenCalledWith('initial-report')
+  })
+})
+
+test('applies report filter from URL parameter and updates URL', async () => {
+  const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
+
+  const originalURL = window.location.href
+  const url = new URL(originalURL)
+  url.searchParams.set('report_name', 'url-report')
+  Object.defineProperty(window, 'location', {
+    value: {
+      href: url.toString(),
+    },
+    writable: true,
+  })
+
+  mockExternalAPICall(trpc.queryRuns.query, { rows: [], fields: [], extraRunData: [] })
+
+  const initialSqlQuery = interpolateQueryValues(getRunsPageQuery({ orderBy: 'createdAt', limit: 500 }))
+
+  try {
+    render(
+      <App>
+        <QueryableRunsTable initialSql={initialSqlQuery} initialReportName='url-report' allowCustomQueries={false} />
+      </App>,
+    )
+
+    await waitFor(() => {
+      expect(trpc.queryRuns.query).toHaveBeenCalledWith({
+        type: 'report',
+        reportName: 'url-report',
+      })
+
+      expect(replaceStateSpy).toHaveBeenCalled()
+    })
+
+    const select = screen.getByTestId('report-name-select')
+
+    const selectedOption = select.querySelector('.ant-select-selection-item')
+    expect(selectedOption?.textContent).toBe('url-report')
+  } finally {
+    vi.restoreAllMocks()
+    Object.defineProperty(window, 'location', {
+      value: {
+        href: originalURL,
+      },
+      writable: true,
+    })
+  }
 })
diff --git a/ui/src/runs/RunsPage.tsx b/ui/src/runs/RunsPage.tsx
index 50b8c83b0..cd5b7dfe9 100644
--- a/ui/src/runs/RunsPage.tsx
+++ b/ui/src/runs/RunsPage.tsx
@@ -10,12 +10,13 @@ import { CSVLink } from 'react-csv'
 import {
   AnalysisModel,
   AnalyzeRunsValidationResponse,
+  ParameterizedQuery,
   QueryRunsRequest,
   QueryRunsResponse,
   RESEARCHER_DATABASE_ACCESS_PERMISSION,
   RunQueueStatus,
   RunQueueStatusResponse,
-  getRunsPageDefaultQuery,
+  getRunsPageQuery,
 } from 'shared'
 import { format } from 'sql-formatter'
 import LogoutButton from '../basic-components/LogoutButton'
@@ -67,6 +68,85 @@ function CopyEvalsTokenButton() {
   )
 }
 
+export function interpolateQueryValues(query: ParameterizedQuery): string {
+  if (query.values.length === 0) {
+    return query.text
+  }
+  return format(query.text, {
+    params: query.values.map(v => JSON.stringify(v.value)),
+    language: 'postgresql',
+  })
+}
+
+export function ReportSelector({
+  initialReportName,
+  onSelectReport,
+}: {
+  initialReportName?: string | null
+  onSelectReport: (reportName: string | null) => void
+}) {
+  const { toastErr } = useToasts()
+  const [reportName, setReportName] = useState<string>(initialReportName ?? '')
+  const [reportNames, setReportNames] = useState<string[]>([])
+  const [isLoading, setIsLoading] = useState(false)
+  const isDarkMode = darkMode.value
+
+  useEffect(() => {
+    const fetchReportNames = async () => {
+      setIsLoading(true)
+      try {
+        const names = await trpc.getReportNames.query()
+        setReportNames(names)
+      } catch (error) {
+        console.error('Failed to fetch report names:', error)
+        toastErr('Failed to fetch report names')
+      } finally {
+        setIsLoading(false)
+      }
+    }
+
+    void fetchReportNames()
+  }, [])
+
+  return (
+    <div className='mx-4 mb-4 mt-3'>
+      <h3 className={isDarkMode ? 'text-gray-200' : ''}>Filter by Report</h3>
+      <div className='flex items-center space-x-2'>
+        <Select
+          style={{ width: 300 }}
+          showSearch
+          placeholder='Select a report'
+          value={reportName || undefined}
+          onChange={value => setReportName(value)}
+          loading={isLoading}
+          data-testid='report-name-select'
+          options={reportNames.map(name => ({ value: name, label: name }))}
+          filterOption={(input, option) =>
+            (option?.label?.toString().toLowerCase() ?? '').includes(input.toLowerCase())
+          }
+        />
+        <Button
+          type='primary'
+          onClick={() => onSelectReport(reportName)}
+          disabled={!reportName}
+          data-testid='apply-filter-button'
+        >
+          Filter by Report
+        </Button>
+        <Button
+          onClick={() => {
+            setReportName('')
+            onSelectReport(null)
+          }}
+          data-testid='clear-filter-button'
+        >
+          Clear Filter
+        </Button>
+      </div>
+    </div>
+  )
+}
+
 export default function RunsPage() {
   const [userPermissions, setUserPermissions] = useState<string[]>()
   const [runQueueStatus, setRunQueueStatus] = useState<RunQueueStatusResponse>()
@@ -78,6 +158,10 @@ export default function RunsPage() {
     void trpc.getRunQueueStatus.query().then(setRunQueueStatus)
   }, [])
 
+  const urlParams = new URL(window.location.href).searchParams
+  const initialSql = urlParams.get('sql')
+  const initialReportName = urlParams.get('report_name')
+
   return (
     <>
       <div className='flex justify-end' style={{ alignItems: 'center', fontSize: 14 }}>
@@ -104,46 +188,74 @@ export default function RunsPage() {
       }
       {userPermissions == null ? null : (
         <QueryableRunsTable
-          initialSql={
-            new URL(window.location.href).searchParams.get('sql') ??
-            getRunsPageDefaultQuery({
-              orderBy: isReadOnly ? 'score' : '"createdAt"',
-              limit: isReadOnly ? 3000 : 500,
-            })
-          }
-          readOnly={!userPermissions?.includes(RESEARCHER_DATABASE_ACCESS_PERMISSION)}
+          initialSql={initialSql}
+          initialReportName={initialReportName}
+          allowCustomQueries={userPermissions?.includes(RESEARCHER_DATABASE_ACCESS_PERMISSION)}
         />
       )}
     </>
   )
 }
 
-export function QueryableRunsTable({ initialSql, readOnly }: { initialSql: string; readOnly: boolean }) {
+export function QueryableRunsTable({
+  initialSql,
+  initialReportName = null,
+  allowCustomQueries,
+}: {
+  initialSql: string | null
+  initialReportName?: string | null
+  allowCustomQueries: boolean
+}) {
   const { toastErr, closeToast } = useToasts()
-  const [request, setRequest] = useState<QueryRunsRequest>(
-    readOnly ? { type: 'default' } : { type: 'custom', query: initialSql },
+  const defaultQuery = interpolateQueryValues(
+    getRunsPageQuery({
+      orderBy: isReadOnly ? 'score' : 'createdAt',
+      limit: isReadOnly ? 3000 : 500,
+    }),
   )
+  let query: QueryRunsRequest = { type: 'default' }
+  if (allowCustomQueries) {
+    query = {
+      type: 'custom',
+      query:
+        initialSql ??
+        interpolateQueryValues(
+          getRunsPageQuery({
+            orderBy: 'createdAt',
+            limit: 500,
+          }),
+        ),
+    }
+  } else if (initialReportName != null) {
+    query = { type: 'report', reportName: initialReportName }
+  }
+  const [request, setRequest] = useState<QueryRunsRequest>(query)
   const [queryRunsResponse, setQueryRunsResponse] = useState<QueryRunsResponse | null>(null)
   const [isLoading, setIsLoading] = useState(false)
   const isAnalysisModalOpen = useSignal(false)
 
+  const updateUrlParams = (params: Record<string, string | null>) => {
+    const url = new URL(window.location.href)
+
+    Object.entries(params).forEach(([key, value]) => {
+      if (value !== null && value !== undefined && value.length > 0) {
+        url.searchParams.set(key, value)
+      } else {
+        url.searchParams.delete(key)
+      }
+    })
+
+    window.history.replaceState(null, '', url.toString())
+  }
+
   useEffect(() => {
-    if (request.type === 'default') return
+    if (request.type === 'default' || request.type === 'report') return
 
-    const url = new URL(window.location.href)
-    if (
-      request.query !== '' &&
-      request.query !==
-        getRunsPageDefaultQuery({
-          orderBy: isReadOnly ? 'score' : '"createdAt"',
-          limit: isReadOnly ? 3000 : 500,
-        })
-    ) {
-      url.searchParams.set('sql', request.query)
+    if (request.query !== defaultQuery) {
+      updateUrlParams({ sql: request.query })
     } else {
-      url.searchParams.delete('sql')
+      updateUrlParams({ sql: null })
     }
-    window.history.replaceState(null, '', url.toString())
   }, [request.type, request.type === 'custom' ? request.query : null])
 
   const executeQuery = async () => {
@@ -174,13 +286,55 @@ export function QueryableRunsTable({ initialSql, readOnly }: { initialSql: strin
     }
   }
 
+  const handleReportSelect = (reportName: string | null) => {
+    if (reportName === null) {
+      setRequest({ type: 'default' })
+      updateUrlParams({ report_name: null })
+      void executeQuery()
+      return
+    }
+
+    updateUrlParams({ report_name: reportName })
+
+    const reportRequest: QueryRunsRequest = {
+      type: 'report',
+      reportName,
+    }
+    setIsLoading(true)
+    void trpc.queryRuns
+      .query(reportRequest)
+      .then(response => {
+        setQueryRunsResponse(response)
+        setIsLoading(false)
+      })
+      .catch(e => {
+        const key = 'query-error'
+        toastErr(
+          <Space>
+            {e.message}
+            <CloseOutlined
+              onClick={() => {
+                closeToast(key)
+              }}
+            />
+          </Space>,
+          { showForever: true, key },
+        )
+        setIsLoading(false)
+      })
+  }
+
   useEffect(() => {
-    void executeQuery()
+    if (initialReportName != null && initialReportName.length > 0) {
+      handleReportSelect(initialReportName)
+    } else {
+      void executeQuery()
+    }
   }, [])
 
   return (
     <>
-      {request.type === 'default' ? null : (
+      {!allowCustomQueries || request.type !== 'custom' ? null : (
         <QueryEditorAndGenerator
           sql={request.query}
           setSql={query => setRequest({ type: 'custom', query })}
@@ -192,6 +346,9 @@ export function QueryableRunsTable({ initialSql, readOnly }: { initialSql: strin
           queryRunsResponse={queryRunsResponse}
         />
       )}
+      {!allowCustomQueries && (
+        <ReportSelector initialReportName={initialReportName} onSelectReport={handleReportSelect} />
+      )}
       <RunsPageDataframe queryRunsResponse={queryRunsResponse} isLoading={isLoading} executeQuery={executeQuery} />
       <AnalysisModal
         open={isAnalysisModalOpen.value}
diff --git a/ui/test-util/testSetup.tsx b/ui/test-util/testSetup.tsx
index 128e142a5..b1da510c6 100644
--- a/ui/test-util/testSetup.tsx
+++ b/ui/test-util/testSetup.tsx
@@ -64,6 +64,7 @@ vi.mock('../src/trpc', async importOriginal => {
       getPythonCodeToReplicateAgentState: {
         query: vi.fn().mockResolvedValue({ pythonCode: 'test-python-code' }),
       },
+      getReportNames: { query: vi.fn().mockResolvedValue([]) },
       getRunComments: {
         query: vi.fn().mockResolvedValue([]),
       },


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds support for filtering runs by report name by creating a new report_runs_t table, updating query generation, and integrating UI changes with corresponding tests.  
- Introduces a ReportSelector component in the UI to allow filtering by report.  
- Updates query generation (getRunsPageQuery) to optionally join with report_runs_t when a report filter is applied.  
- Enhances both client and server test coverage for the new report filtering functionality.

### Reviewed Changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| ui/src/runs/RunsPage.tsx | Added ReportSelector component and updated QueryableRunsTable to accept an initialReportName and handle query requests of type "report". |
| server/src/migrations/20250311191208_add_report_runs_table.ts | Migration to create the report_runs_t table. |
| server/src/routes/general_routes.test.ts | Added tests to verify runs are correctly filtered by report name and the new getReportNames endpoint. |
| shared/src/constants.ts | Replaced getRunsPageDefaultQuery with getRunsPageQuery to support report filtering while maintaining backward compatibility. |
| ui/src/runs/RunsPage.test.tsx | Extended tests to verify report filter behavior and URL parameter propagation. |
| server/src/routes/general_routes.ts | Updated queryRuns endpoint to handle the new report query type. |
| cli/viv_cli/viv_api.py | Modified query_runs to support a report_name parameter and prevent conflict with custom queries. |
| server/src/services/db/tables.ts | Defined new table schema for report_runs_t using ReportRun. |
| shared/src/types.ts | Extended QueryRunsRequest union to include the new "report" type. |
</details>



<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**ui/src/runs/RunsPage.test.tsx:403**
* [nitpick] In the ReportSelector tests, onSelectReport is called directly instead of simulating a user interaction on the Select component. Consider updating the test to simulate a change event on the dropdown to better reflect end-user behavior.
```
onSelectReport('test-report')
```
**server/src/routes/general_routes.ts:345**
* [nitpick] Although the reportName is escaped in getRunsPageQuery, ensure that any reportName passed via queryRequest is validated to avoid potential SQL injection or unexpected behavior. Adding further validation here could improve robustness.
```
query = getRunsPageQuery({ orderBy, limit, reportName: queryRequest.reportName, })
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T01:28:28Z
user: login: tbroadley
body: I did a little manual testing and it looks to be working well:

- With no `report_runs_t` rows, nothing appears in the reports dropdown
- With one row, the one report name appears in the dropdown
- With multiple rows, all report names appear in the dropdown
- Can swap between reports
- Can clear the selected report and see all runs
- URL seems to be behaving well, both on page load and when changing and clearing the selected report
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T20:54:45Z
user: login: sjawhar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T02:56:37Z
user: login: sjawhar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T03:31:55Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds an option to filter runs by report name. Key changes include:
- Introducing a new report_runs_t table and related query logic for filtering.
- Updating the UI with a ReportSelector component and modifying QueryableRunsTable to conditionally perform report queries.
- Updating tests and CLI commands to support the new report filtering option.

### Reviewed Changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| shared/src/constants.test.ts | Added tests for queries with and without reportName and for SQL injection cases. |
| server/src/migrations/20250311191208_add_report_runs_table.ts | New migration for creating the report_runs_t table with a composite key. |
| ui/src/runs/RunsPage.tsx | Updated UI components to include report filtering, added ReportSelector, and modified query parameters. |
| server/src/routes/general_routes.test.ts | Added tests for report query filtering and report names retrieval. |
| cli/tests/test_viv_api.py & cli/tests/test_main.py | Updated tests to handle new query parameters and report filtering. |
| shared/src/constants.ts | Replaced getRunsPageDefaultQuery with getRunsPageQuery and updated query assembly. |
| server/src/routes/general_routes.ts | Updated queryRuns handling to incorporate report queries. |
| Other files | Various test and helper file updates to ensure compatibility with report filtering feature. |
</details>





state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T14:15:03Z
user: login: tbroadley
body: Awesome!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T17:56:56Z

Review comments:
diff_hunk: @@ -1655,4 +1666,31 @@ export const generalRoutes = {
       await bouncer.assertRunPermission(ctx, input.runId)
       return getScoreLogHelper(ctx, input, { returnScore: true })
     }),
+  getReportNames: userProc.output(z.array(z.string())).query(async ({ ctx }) => {
+    const config = ctx.svc.get(Config)
+
+    try {
+      const result = await readOnlyDbQuery(
+        config,
+        `
+          SELECT DISTINCT "reportName"
+          FROM "${reportRunsTable.tableName}"
+          ORDER BY "reportName" ASC
+        `,
+      )
path: server/src/routes/general_routes.ts
user: login: tbroadley
body: Nit: How about using `db.column` here? That would make this code less verbose by removing the explicit zod parse call.
created_at: 2025-03-12T20:19:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -338,14 +338,35 @@ export const RESEARCHER_DATABASE_ACCESS_PERMISSION = 'researcher-database-access
 
 export const RUNS_PAGE_INITIAL_COLUMNS = `id, "taskId", agent, "runStatus", "isContainerRunning", "createdAt", "isInteractive", submission, score, username, metadata`
 
-export function getRunsPageDefaultQuery(args: { orderBy: string; limit: number }) {
+export function getRunsPageQuery(args: { orderBy: string; limit: number; reportName?: string | null }) {
path: shared/src/constants.ts
user: login: tbroadley
body: Now that this function has some conditional logic, I'd suggest adding tests for it. I also think tests are a good idea because I thought the function was buggy, and tests would have helped me figure out more quickly that it wasn't.
created_at: 2025-03-12T20:27:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -338,14 +338,35 @@ export const RESEARCHER_DATABASE_ACCESS_PERMISSION = 'researcher-database-access
 
 export const RUNS_PAGE_INITIAL_COLUMNS = `id, "taskId", agent, "runStatus", "isContainerRunning", "createdAt", "isInteractive", submission, score, username, metadata`
 
-export function getRunsPageDefaultQuery(args: { orderBy: string; limit: number }) {
+export function getRunsPageQuery(args: { orderBy: string; limit: number; reportName?: string | null }) {
+  let withClause = ''
+  let fromClause = 'FROM runs_v'
+
+  if (args.reportName !== undefined && args.reportName !== null && args.reportName.length > 0) {
+    // Escape single quotes in reportName
+    const escapedReportName = args.reportName.replace(/'/g, "''")
path: shared/src/constants.ts
user: login: tbroadley
body: It seems like it wouldn't be too hard to change `readOnlyDbQuery` to accept a parameterized query and pass that to `client.query`: https://node-postgres.com/features/queries#parameterized-query What do you think about making that change in this PR? I do believe this escaping logic is correct but I'd feel marginally more confident using `pg`'s escaping logic. It also seems like a best practice to rely on `pg` for this.
created_at: 2025-03-12T20:30:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -67,6 +67,73 @@ function CopyEvalsTokenButton() {
   )
 }
 
+export function ReportSelector({
+  initialReportName,
+  onSelectReport,
+}: {
+  initialReportName?: string | null
+  onSelectReport: (reportName: string | null) => void
+}) {
+  const [reportName, setReportName] = useState<string>(initialReportName ?? '')
+  const [reportNames, setReportNames] = useState<string[]>([])
+  const [isLoading, setIsLoading] = useState(false)
+  const isDarkMode = darkMode.value
+
+  useEffect(() => {
+    const fetchReportNames = async () => {
+      setIsLoading(true)
+      try {
+        const names = await trpc.getReportNames.query()
+        setReportNames(names)
+      } catch (error) {
+        console.error('Failed to fetch report names:', error)
path: ui/src/runs/RunsPage.tsx
user: login: tbroadley
body: Could we use `toastErr` from `useToasts` here? To make the error easily visible to the user.
created_at: 2025-03-12T20:33:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -174,13 +270,61 @@ export function QueryableRunsTable({ initialSql, readOnly }: { initialSql: strin
     }
   }
 
+  const handleReportSelect = (reportName: string | null) => {
+    if (reportName === null) {
+      setRequest({ type: 'default' })
+      updateUrlParams({ report_name: null })
+      void executeQuery()
+      return
+    }
+
+    setIsLoading(true)
+
+    const reportRequest: QueryRunsRequest = {
+      type: 'report',
+      reportName,
+    }
+
+    updateUrlParams({ report_name: reportName })
path: ui/src/runs/RunsPage.tsx
user: login: tbroadley
body: If `updateUrlParams` throws an exception, then the loading spinner will never go away. I'm not sure of the best way to prevent that. Maybe a try-catch block around this that calls `setIsLoading(false)` and reraises the error.
created_at: 2025-03-12T20:37:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -174,13 +270,61 @@ export function QueryableRunsTable({ initialSql, readOnly }: { initialSql: strin
     }
   }
 
+  const handleReportSelect = (reportName: string | null) => {
+    if (reportName === null) {
+      setRequest({ type: 'default' })
+      updateUrlParams({ report_name: null })
+      void executeQuery()
+      return
+    }
+
+    setIsLoading(true)
+
+    const reportRequest: QueryRunsRequest = {
+      type: 'report',
+      reportName,
+    }
+
+    updateUrlParams({ report_name: reportName })
path: ui/src/runs/RunsPage.tsx
user: login: tbroadley
body: Or call `setIsLoading` right before calling `trpc.queryRuns`.
created_at: 2025-03-12T20:38:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -174,13 +270,61 @@ export function QueryableRunsTable({ initialSql, readOnly }: { initialSql: strin
     }
   }
 
+  const handleReportSelect = (reportName: string | null) => {
+    if (reportName === null) {
+      setRequest({ type: 'default' })
+      updateUrlParams({ report_name: null })
+      void executeQuery()
+      return
+    }
+
+    setIsLoading(true)
+
+    const reportRequest: QueryRunsRequest = {
+      type: 'report',
+      reportName,
+    }
+
+    updateUrlParams({ report_name: reportName })
+
+    void trpc.queryRuns
+      .query(reportRequest)
+      .then(response => {
+        setQueryRunsResponse(response)
+        setIsLoading(false)
+      })
+      .catch(e => {
+        const key = 'query-error'
+        toastErr(
+          <Space>
+            {e.message}
+            <CloseOutlined
+              onClick={() => {
+                closeToast(key)
+              }}
+            />
+          </Space>,
+          { showForever: true, key },
+        )
+        setIsLoading(false)
+      })
+  }
+
   useEffect(() => {
-    void executeQuery()
+    if (initialReportName != null && initialReportName.length > 0) {
+      handleReportSelect(initialReportName)
+    } else if (!readOnly && initialSql != null && initialSql.length > 0) {
+      setRequest({ type: 'custom', query: initialSql })
+      void executeQuery()
+    } else {
+      void executeQuery()
+    }
path: ui/src/runs/RunsPage.tsx
user: login: tbroadley
body: I think this can be simplified. `request` should already be set to the correct value by the logic in the `useState` call that creates it.

```suggestion
    } else {
      void executeQuery()
    }
```
created_at: 2025-03-12T20:40:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -375,4 +369,116 @@ describe('QueryableRunsTable', () => {
       expect(container.textContent).toMatch('test-id TRUE' + 'test-id-2 FALSE')
     })
   })
+
+  test('applies report filter and sends the correct request to the server', async () => {
+    mockExternalAPICall(trpc.queryRuns.query, { rows: [], fields: [], extraRunData: [] })
+
+    render(
+      <App>
+        <QueryableRunsTable
+          initialSql={getRunsPageQuery({ orderBy: '"createdAt"', limit: 500 })}
+          initialReportName='test-report'
+          readOnly
+        />
+      </App>,
+    )
+
+    await waitFor(() => {
+      expect(trpc.queryRuns.query).toHaveBeenCalledWith({
+        type: 'report',
+        reportName: 'test-report',
+      })
+    })
+
+    const queryEditor = screen.queryByRole('textbox')
+    if (queryEditor) {
+      expect((queryEditor as HTMLInputElement).value).not.toContain(`WHERE metadata->'report_names'`)
+    }
path: ui/src/runs/RunsPage.test.tsx
user: login: tbroadley
body: Since we're in read-only mode, I'd expect the query editor not to exist. The test could check that directly instead of having this conditional assertion.
created_at: 2025-03-12T20:41:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -375,4 +369,116 @@ describe('QueryableRunsTable', () => {
       expect(container.textContent).toMatch('test-id TRUE' + 'test-id-2 FALSE')
     })
   })
+
+  test('applies report filter and sends the correct request to the server', async () => {
+    mockExternalAPICall(trpc.queryRuns.query, { rows: [], fields: [], extraRunData: [] })
+
+    render(
+      <App>
+        <QueryableRunsTable
+          initialSql={getRunsPageQuery({ orderBy: '"createdAt"', limit: 500 })}
+          initialReportName='test-report'
+          readOnly
+        />
+      </App>,
+    )
+
+    await waitFor(() => {
+      expect(trpc.queryRuns.query).toHaveBeenCalledWith({
+        type: 'report',
+        reportName: 'test-report',
+      })
+    })
+
+    const queryEditor = screen.queryByRole('textbox')
+    if (queryEditor) {
+      expect((queryEditor as HTMLInputElement).value).not.toContain(`WHERE metadata->'report_names'`)
+    }
+  })
+})
+
+describe('ReportSelector', () => {
+  test('calls onSelectReport with the entered report name on button click', async () => {
+    const onSelectReport = vi.fn()
+    render(<ReportSelector onSelectReport={onSelectReport} />)
+
+    onSelectReport('test-report')
+    expect(onSelectReport).toHaveBeenCalledWith('test-report')
path: ui/src/runs/RunsPage.test.tsx
user: login: tbroadley
body: I would expect this part of the test to involve clicking a button.
created_at: 2025-03-12T20:42:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -375,4 +369,116 @@ describe('QueryableRunsTable', () => {
       expect(container.textContent).toMatch('test-id TRUE' + 'test-id-2 FALSE')
     })
   })
+
+  test('applies report filter and sends the correct request to the server', async () => {
+    mockExternalAPICall(trpc.queryRuns.query, { rows: [], fields: [], extraRunData: [] })
+
+    render(
+      <App>
+        <QueryableRunsTable
+          initialSql={getRunsPageQuery({ orderBy: '"createdAt"', limit: 500 })}
+          initialReportName='test-report'
+          readOnly
+        />
+      </App>,
+    )
+
+    await waitFor(() => {
+      expect(trpc.queryRuns.query).toHaveBeenCalledWith({
+        type: 'report',
+        reportName: 'test-report',
+      })
+    })
+
+    const queryEditor = screen.queryByRole('textbox')
+    if (queryEditor) {
+      expect((queryEditor as HTMLInputElement).value).not.toContain(`WHERE metadata->'report_names'`)
+    }
+  })
+})
+
+describe('ReportSelector', () => {
+  test('calls onSelectReport with the entered report name on button click', async () => {
+    const onSelectReport = vi.fn()
+    render(<ReportSelector onSelectReport={onSelectReport} />)
+
+    onSelectReport('test-report')
+    expect(onSelectReport).toHaveBeenCalledWith('test-report')
+
+    const clearButton = screen.getByTestId('clear-filter-button')
+    fireEvent.click(clearButton)
+    expect(onSelectReport).toHaveBeenCalledWith(null)
+  })
+
+  test('calls onSelectReport with empty string when clicking Clear Filter', () => {
+    const onSelectReport = vi.fn()
+    render(<ReportSelector onSelectReport={onSelectReport} />)
+
+    const clearButton = screen.getByTestId('clear-filter-button')
+    fireEvent.click(clearButton)
+
+    expect(onSelectReport).toHaveBeenCalledWith(null)
path: ui/src/runs/RunsPage.test.tsx
user: login: tbroadley
body: Seems to duplicate the previous test.
created_at: 2025-03-12T20:43:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -375,4 +369,116 @@ describe('QueryableRunsTable', () => {
       expect(container.textContent).toMatch('test-id TRUE' + 'test-id-2 FALSE')
     })
   })
+
+  test('applies report filter and sends the correct request to the server', async () => {
+    mockExternalAPICall(trpc.queryRuns.query, { rows: [], fields: [], extraRunData: [] })
+
+    render(
+      <App>
+        <QueryableRunsTable
+          initialSql={getRunsPageQuery({ orderBy: '"createdAt"', limit: 500 })}
+          initialReportName='test-report'
+          readOnly
+        />
+      </App>,
+    )
+
+    await waitFor(() => {
+      expect(trpc.queryRuns.query).toHaveBeenCalledWith({
+        type: 'report',
+        reportName: 'test-report',
+      })
+    })
+
+    const queryEditor = screen.queryByRole('textbox')
+    if (queryEditor) {
+      expect((queryEditor as HTMLInputElement).value).not.toContain(`WHERE metadata->'report_names'`)
+    }
+  })
+})
+
+describe('ReportSelector', () => {
+  test('calls onSelectReport with the entered report name on button click', async () => {
+    const onSelectReport = vi.fn()
+    render(<ReportSelector onSelectReport={onSelectReport} />)
+
+    onSelectReport('test-report')
+    expect(onSelectReport).toHaveBeenCalledWith('test-report')
+
+    const clearButton = screen.getByTestId('clear-filter-button')
+    fireEvent.click(clearButton)
+    expect(onSelectReport).toHaveBeenCalledWith(null)
+  })
+
+  test('calls onSelectReport with empty string when clicking Clear Filter', () => {
+    const onSelectReport = vi.fn()
+    render(<ReportSelector onSelectReport={onSelectReport} />)
+
+    const clearButton = screen.getByTestId('clear-filter-button')
+    fireEvent.click(clearButton)
+
+    expect(onSelectReport).toHaveBeenCalledWith(null)
+  })
+
+  test('initializes with the initialReportName if provided', () => {
+    const onSelectReport = vi.fn()
+    render(<ReportSelector initialReportName='initial-report' onSelectReport={onSelectReport} />)
+
+    const select = screen.getByTestId('report-name-select')
+
+    const selectedOption = select.querySelector('.ant-select-selection-item')
+    expect(selectedOption?.textContent).toBe('initial-report')
+
+    const filterButton = screen.getByTestId('apply-filter-button')
+    fireEvent.click(filterButton)
+    expect(onSelectReport).toHaveBeenCalledWith('initial-report')
+  })
+})
+
+test('applies report filter from URL parameter and updates URL', async () => {
+  const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
+
+  const originalURL = window.location.href
+  const url = new URL(originalURL)
+  url.searchParams.set('report_name', 'url-report')
+  Object.defineProperty(window, 'location', {
+    value: {
+      href: url.toString(),
+    },
+    writable: true,
+  })
+
+  mockExternalAPICall(trpc.queryRuns.query, { rows: [], fields: [], extraRunData: [] })
+
+  const initialSqlQuery = getRunsPageQuery({ orderBy: '"createdAt"', limit: 500 })
+
+  try {
+    render(
+      <App>
+        <QueryableRunsTable initialSql={initialSqlQuery} initialReportName='url-report' readOnly={true} />
+      </App>,
+    )
+
+    await waitFor(() => {
+      expect(trpc.queryRuns.query).toHaveBeenCalledWith({
+        type: 'report',
+        reportName: 'url-report',
+      })
+
+      expect(replaceStateSpy).toHaveBeenCalled()
path: ui/src/runs/RunsPage.test.tsx
user: login: tbroadley
body: Could we check what it's been called with? 

Actually, I wouldn't have expected the page's URL to change in this case.
created_at: 2025-03-12T20:44:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1655,4 +1666,31 @@ export const generalRoutes = {
       await bouncer.assertRunPermission(ctx, input.runId)
       return getScoreLogHelper(ctx, input, { returnScore: true })
     }),
+  getReportNames: userProc.output(z.array(z.string())).query(async ({ ctx }) => {
+    const config = ctx.svc.get(Config)
+
+    try {
+      const result = await readOnlyDbQuery(
+        config,
+        `
+          SELECT DISTINCT "reportName"
+          FROM "${reportRunsTable.tableName}"
+          ORDER BY "reportName" ASC
+        `,
+      )
path: server/src/routes/general_routes.ts
user: login: sjawhar
body: I think that would require major refactoring of `readOnlyDbQuery`
created_at: 2025-03-13T02:56:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -375,4 +369,116 @@ describe('QueryableRunsTable', () => {
       expect(container.textContent).toMatch('test-id TRUE' + 'test-id-2 FALSE')
     })
   })
+
+  test('applies report filter and sends the correct request to the server', async () => {
+    mockExternalAPICall(trpc.queryRuns.query, { rows: [], fields: [], extraRunData: [] })
+
+    render(
+      <App>
+        <QueryableRunsTable
+          initialSql={getRunsPageQuery({ orderBy: '"createdAt"', limit: 500 })}
+          initialReportName='test-report'
+          readOnly
+        />
+      </App>,
+    )
+
+    await waitFor(() => {
+      expect(trpc.queryRuns.query).toHaveBeenCalledWith({
+        type: 'report',
+        reportName: 'test-report',
+      })
+    })
+
+    const queryEditor = screen.queryByRole('textbox')
+    if (queryEditor) {
+      expect((queryEditor as HTMLInputElement).value).not.toContain(`WHERE metadata->'report_names'`)
+    }
path: ui/src/runs/RunsPage.test.tsx
user: login: sjawhar
body: Looks like I forgot to update these tests after the switch from metadata to a separate table 🤦 
created_at: 2025-03-13T03:31:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,46 +188,74 @@ export default function RunsPage() {
       }
       {userPermissions == null ? null : (
         <QueryableRunsTable
-          initialSql={
-            new URL(window.location.href).searchParams.get('sql') ??
-            getRunsPageDefaultQuery({
-              orderBy: isReadOnly ? 'score' : '"createdAt"',
-              limit: isReadOnly ? 3000 : 500,
-            })
-          }
+          initialSql={initialSql}
+          initialReportName={initialReportName}
           readOnly={!userPermissions?.includes(RESEARCHER_DATABASE_ACCESS_PERMISSION)}
         />
       )}
     </>
   )
 }
 
-export function QueryableRunsTable({ initialSql, readOnly }: { initialSql: string; readOnly: boolean }) {
+export function QueryableRunsTable({
+  initialSql,
+  initialReportName = null,
+  readOnly,
+}: {
+  initialSql: string | null
+  initialReportName?: string | null
+  readOnly: boolean
+}) {
   const { toastErr, closeToast } = useToasts()
-  const [request, setRequest] = useState<QueryRunsRequest>(
-    readOnly ? { type: 'default' } : { type: 'custom', query: initialSql },
+  const defaultQuery = interpolateQueryValues(
+    getRunsPageQuery({
+      orderBy: isReadOnly ? 'score' : 'createdAt',
+      limit: isReadOnly ? 3000 : 500,
path: ui/src/runs/RunsPage.tsx
user: login: Copilot
body: The variable 'isReadOnly' is referenced but not defined; consider replacing it with the 'readOnly' prop to correctly determine the query order.
```suggestion
      orderBy: readOnly ? 'score' : 'createdAt',
      limit: readOnly ? 3000 : 500,
```
created_at: 2025-03-13T14:15:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,19 +207,29 @@ export function QueryableRunsTable({
   readOnly: boolean
path: ui/src/runs/RunsPage.tsx
user: login: tbroadley
body: It does seem confusing to have:

1. `isReadOnly`, indicating whether the Vivaria instance is read-only
2. `readOnly`, indicating whether the user has permission to run custom queries

I'd suggest changing the `readOnly` prop to be named `allowCustomQueries`. (That'd involve negating the behaviour of the prop, too.)
created_at: 2025-03-13T17:54:47Z
author_association: CONTRIBUTOR

PR comments:
user: login: sjawhar
created_at: 2025-03-13T04:13:27Z
body: @mentatbot can you fix the failing UI tests? Make sure not to change the code, only the tests, and focus on maintaining meaningful tests instead of simply making them pass.
author_association: CONTRIBUTOR
user: login: mentatbot[bot]
created_at: 2025-03-13T04:13:33Z
body: I'm getting to work. See my steps and track my cost [here](https://mentat.ai/agent/922576b0-5382-4c93-91f4-b47cdb6e146b).

- [ ] Wake on any new activity.
<sub>I always see new activity while actively working. When paused, I'll only respond when tagged unless this box is checked.</sub>
author_association: CONTRIBUTOR
user: login: mentatbot[bot]
created_at: 2025-03-13T04:35:53Z
body: Mentat has exceeded its credit limit:
Current cost: $5.02
Credit limit: $5.00

To continue, tag mentat again. 
You can change your credit limit [here](https://mentat.ai/repo/METR/vivaria).

author_association: CONTRIBUTOR
user: login: mentatbot[bot]
created_at: 2025-03-13T13:41:54Z
body: The Mentat Scripts failed. You can see the logs [here ❌](https://mentat.ai/log/474f585c-e994-4c8e-a2ca-8dd08ce32b40).

author_association: CONTRIBUTOR
user: login: mentatbot[bot]
created_at: 2025-03-13T13:54:23Z
body: The Mentat Scripts were confirmed to pass. You can see the logs [here ✅](https://mentat.ai/log/53f645d8-a014-40c6-8200-d5830eabb40d).

author_association: CONTRIBUTOR
user: login: mentatbot[bot]
created_at: 2025-03-13T14:02:16Z
body: The Mentat Scripts were confirmed to pass. You can see the logs [here ✅](https://mentat.ai/log/76f31a92-4bbe-42c8-80ca-c1f699f62b3e).

author_association: CONTRIBUTOR
user: login: mentatbot[bot]
created_at: 2025-03-13T14:12:27Z
body: The Mentat Scripts were confirmed to pass. You can see the logs [here ✅](https://mentat.ai/log/8af66cd1-0e68-4678-ac03-43d1c7d4a30c).

author_association: CONTRIBUTOR
user: login: mentatbot[bot]
created_at: 2025-03-13T14:14:39Z
body: The Mentat Scripts were confirmed to pass. You can see the logs [here ✅](https://mentat.ai/log/bf674f66-adcd-498d-8b70-c4ddc66e09ab).

author_association: CONTRIBUTOR
user: login: sjawhar
created_at: 2025-03-13T14:52:32Z
body: @mentatbot please review this PR
author_association: CONTRIBUTOR
user: login: mentatbot[bot]
created_at: 2025-03-13T14:53:50Z
body: Mentat has exceeded its credit limit:
Current cost: $5.02
Credit limit: $5.00

To continue, tag mentat again. 
You can change your credit limit [here](https://mentat.ai/repo/METR/vivaria).

author_association: CONTRIBUTOR
user: login: tbroadley
created_at: 2025-03-13T17:45:58Z
body: @mentatbot please do review it!
author_association: CONTRIBUTOR
user: login: mentatbot[bot]
created_at: 2025-03-14T01:39:21Z
body: The Mentat Scripts were confirmed to pass. You can see the logs [here ✅](https://mentat.ai/log/4dae651c-cb9c-4ac0-a724-24ebd9c11831).

author_association: CONTRIBUTOR
user: login: sjawhar
created_at: 2025-03-14T01:42:16Z
body: Thanks for the great reviews, @tbroadley !
author_association: CONTRIBUTOR