From 242d44b1de04da831fa0022c646deeb50c2818c9 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:51:43 -0500 Subject: [PATCH 01/19] feat: add Spark REST eval suite --- .env.example | 3 + .github/workflows/eval-refresh.yml | 4 +- README.md | 6 +- apps/framework/harness/run-eval.ts | 15 +- apps/framework/harness/spark-scoring.test.ts | 149 ++++++++++++++++++ apps/framework/scripts/run-vercel-evals.ts | 5 +- .../build-database-001-task-due-dates/EVAL.ts | 109 +++++++++++++ .../PROMPT.md | 14 ++ .../remote/project.sql | 12 ++ .../spark/codex-gpt-6-luna.experiment.ts | 8 + packages/core/src/eval-metadata.ts | 4 +- packages/core/src/index.ts | 3 + packages/core/src/spark-runtime.test.ts | 123 +++++++++++++++ packages/core/src/spark-runtime.ts | 109 +++++++++++++ packages/sandbox/src/bare-sandbox.ts | 2 + packages/sandbox/test/unit.test.ts | 9 +- skills/spark-api/SKILL.md | 17 ++ 17 files changed, 574 insertions(+), 18 deletions(-) create mode 100644 apps/framework/harness/spark-scoring.test.ts create mode 100644 evals/spark/build-database-001-task-due-dates/EVAL.ts create mode 100644 evals/spark/build-database-001-task-due-dates/PROMPT.md create mode 100644 evals/spark/build-database-001-task-due-dates/remote/project.sql create mode 100644 experiments/spark/codex-gpt-6-luna.experiment.ts create mode 100644 packages/core/src/spark-runtime.test.ts create mode 100644 packages/core/src/spark-runtime.ts create mode 100644 skills/spark-api/SKILL.md diff --git a/.env.example b/.env.example index a076d26f5..04fa7979c 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,6 @@ XAI_API_KEY= BRAINTRUST_PROJECT_ID= BRAINTRUST_API_KEY= + +# Spark control API used to provision fresh hosted projects. +SPARK_API_URL= diff --git a/.github/workflows/eval-refresh.yml b/.github/workflows/eval-refresh.yml index 24c16b7d7..03f9f3da7 100644 --- a/.github/workflows/eval-refresh.yml +++ b/.github/workflows/eval-refresh.yml @@ -296,6 +296,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + SPARK_API_URL: ${{ vars.SPARK_API_URL }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} @@ -330,6 +331,7 @@ jobs: echo "OPENAI_API_KEY=${OPENAI_API_KEY}" echo "AI_GATEWAY_API_KEY=${AI_GATEWAY_API_KEY}" echo "XAI_API_KEY=${XAI_API_KEY}" + echo "SPARK_API_URL=${SPARK_API_URL}" echo "SUPABASE_CLI_STABLE_VERSION=${SUPABASE_CLI_STABLE_VERSION}" echo "SUPABASE_CLI_BETA_VERSION=${SUPABASE_CLI_BETA_VERSION}" echo "SUPABASE_CLI_NEXT_VERSION=${SUPABASE_CLI_NEXT_VERSION}" @@ -496,7 +498,7 @@ jobs: pnpm --filter @supabase-evals/framework export-results -- "${export_args[@]}" fi - for eval_suite in regression docs cli; do + for eval_suite in regression docs cli spark; do if jq -e --arg s "$eval_suite" 'any(.[]; .eval_suite == $s)' <<< "$pairs" > /dev/null; then export_args=(--suite "$eval_suite" --runs "${{ needs.prepare.outputs.runs }}" --output "apps/web/src/data/${eval_suite}-eval-results.json") if [ "${{ needs.prepare.outputs.do_merge }}" = "true" ]; then diff --git a/README.md b/README.md index ce0bb0644..b859d7ee7 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ Agent-backed runs require the relevant provider key in `.env` (e.g. `OPENAI_API_ - An **eval** is one scenario under `evals///`. It contains the prompt, scorer, and optional starting state for the two environments: `remote/` (the hosted project) and `local/` (the agent's working files). - An **experiment** is one agent/runtime/model setup under `experiments//.experiment.ts`. Its ID remains ``. -- A **suite** is a named set of evals and the experiments that run them. Each suite gets a folder under `evals/` and `experiments/`. Experiments used in several suites can use a shared preset from `experiments/presets.ts`. The suites are `benchmark`, `regression`, `docs`, and `cli`. +- A **suite** is a named set of evals and the experiments that run them. Each suite gets a folder under `evals/` and `experiments/`. Experiments used in several suites can use a shared preset from `experiments/presets.ts`. The suites are defined in `packages/core/src/eval-metadata.ts`. - An **agent** is the model driver that receives the eval prompt and calls the configured tools. -- A **runtime** is the local Supabase-like environment and tool surface an experiment gives to the agent. +- A **runtime** is the local or hosted environment and tool surface an experiment gives to the agent. - `platform-lite` exposes a Supabase Management API-compatible HTTP surface backed by [`@supabase/lite`](https://github.com/supabase/supabase-lite), so real tools like `@supabase/mcp-server-supabase` can run against a lightweight project. ## Running evals @@ -119,7 +119,7 @@ There are two runtimes, chosen automatically per eval: - **Tools evals** run the agent against the experiment's MCP/tool surface (no `local/` directory, no `interface: cli`), then score the resulting project state or report. - **Local-stack evals** run the agent inside a Docker sandbox — a `bash` tool plus file tools with the real Supabase CLI installed — so it can run `supabase init/start/db/test` against a real local stack. An eval uses this runtime when it ships a `local/` workspace **or** declares `interface: cli` (the latter covers bootstrap scenarios that start from an empty workspace). -`interface` (`mcp` | `cli`) is otherwise a benchmark dimension (a cross-team KPI label), not the runtime switch — the `local/` directory and `interface: cli` are what decide whether a sandbox boots. +`interface` (`mcp` | `cli` | `rest`) is otherwise a benchmark dimension (a cross-team KPI label), not the runtime switch — the `local/` directory and `interface: cli` are what decide whether a sandbox boots. ### Local-stack evals diff --git a/apps/framework/harness/run-eval.ts b/apps/framework/harness/run-eval.ts index 2eeb145f7..a64926d76 100644 --- a/apps/framework/harness/run-eval.ts +++ b/apps/framework/harness/run-eval.ts @@ -520,22 +520,21 @@ async function runOne( }; } - // Tools mode: a CLI agent gets a bare sandbox reaching platform-lite via - // host.docker.internal. An in-process agent runs host-side with no sandbox. + await using session = disposable( + await exp.runtime.startSession({ + ...readSessionSeedArgs(ev), + hostname: agentRunsInSandbox ? '0.0.0.0' : undefined, + }) + ); await using cliSandbox = agentRunsInSandbox ? disposable( await createBareSandbox({ + env: session.env, skills: skillSources, mounts: supabaseMcpServerMounts(), }) ) : undefined; - await using session = disposable( - await exp.runtime.startSession({ - ...readSessionSeedArgs(ev), - hostname: agentRunsInSandbox ? '0.0.0.0' : undefined, - }) - ); // A CLI agent discovers its installed skills itself. An in-process agent has // no filesystem, so its skills are advertised in the prompt and pulled on diff --git a/apps/framework/harness/spark-scoring.test.ts b/apps/framework/harness/spark-scoring.test.ts new file mode 100644 index 000000000..b9bfe84f7 --- /dev/null +++ b/apps/framework/harness/spark-scoring.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { sparkRestRuntime } from '@supabase-evals/core'; +import scorer, { + checkTasks, +} from '../../../evals/spark/build-database-001-task-due-dates/EVAL.js'; + +const tasks = [ + { + id: 1, + title: 'Write release notes', + completed: false, + created_at: '2026-11-01 12:00:00', + due_date: null, + }, + { + id: 2, + title: 'Ship task tracker', + completed: false, + created_at: '2026-11-01 12:01:00', + due_date: '2026-11-10', + }, + { + id: 3, + title: 'Review schema', + completed: true, + created_at: '2026-11-01 12:02:00', + due_date: null, + }, + { + id: 4, + title: 'Plan next iteration', + completed: false, + created_at: '2026-11-01 12:03:00', + due_date: null, + }, +]; + +describe('task state checks', () => { + it('accepts the requested change', () => { + expect(checkTasks(tasks).every((check) => check.passed)).toBe(true); + }); + it('rejects missing dates, altered seed data and deleted rows', () => { + for (const changed of [ + tasks.map((task) => ({ ...task, due_date: null })), + tasks.map((task) => ({ ...task, title: 'Replaced' })), + tasks.map((task) => ({ ...task, created_at: '2026-11-02T00:00:00' })), + tasks.slice(1), + [], + ]) + expect(checkTasks(changed).some((check) => !check.passed)).toBe(true); + }); +}); + +afterEach(() => vi.unstubAllGlobals()); + +async function score( + options: { + columns?: Record[]; + tableStatus?: number; + queryStatus?: number; + reportPasses?: boolean; + } = {} +) { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request) => { + const url = + input instanceof Request ? new URL(input.url) : new URL(input); + if (url.pathname === '/v1/projects') + return Response.json({ + ref: 'project', + url: 'https://project.example', + api_keys: [ + { type: 'secret', api_key: 'secret' }, + { type: 'publishable', api_key: 'publishable' }, + ], + }); + if (url.pathname === '/database/query') { + if (options.queryStatus) + return new Response('query unavailable', { + status: options.queryStatus, + }); + return Response.json( + options.columns ?? [{ type: 'date', not_null: 0 }] + ); + } + expect(url.pathname).toBe('/rest/v1/tasks'); + return options.tableStatus + ? Response.json( + { message: 'relation "tasks" does not exist', code: '42P01' }, + { status: options.tableStatus } + ) + : Response.json(tasks); + }) + ); + const session = await sparkRestRuntime( + 'https://control.example' + ).startSession({}); + const judge = vi.fn(async () => ({ passed: options.reportPasses ?? true })); + try { + const result = await scorer({ + ...session.scoringContext, + toolCalls: [], + transcript: [], + judge, + }); + return { result, judge }; + } finally { + await session.close(); + } +} + +describe('hosted task scorer', () => { + it('accepts the complete result and rejects an incorrect report', async () => { + expect((await score()).result.passed).toBe(true); + expect((await score({ reportPasses: false })).result.passed).toBe(false); + }); + + it('rejects missing, non-date and required columns', async () => { + for (const columns of [ + [], + [{ type: 'text', not_null: 0 }], + [{ type: 'date', not_null: 1 }], + ]) { + const { result } = await score({ columns }); + expect(result.passed).toBe(false); + expect( + result.checks?.find( + (check) => check.name === 'Optional date column added' + )?.passed + ).toBe(false); + } + }); + + it('records unreadable state and still judges the report', async () => { + for (const options of [{ tableStatus: 404 }, { queryStatus: 503 }]) { + const { result, judge } = await score(options); + expect(result.passed).toBe(false); + expect(result.checks).toContainEqual( + expect.objectContaining({ + name: 'Hosted task state readable', + passed: false, + notes: expect.any(String), + }) + ); + expect(judge).toHaveBeenCalledOnce(); + } + }); +}); diff --git a/apps/framework/scripts/run-vercel-evals.ts b/apps/framework/scripts/run-vercel-evals.ts index aaf7318fb..1f9d5f8a9 100644 --- a/apps/framework/scripts/run-vercel-evals.ts +++ b/apps/framework/scripts/run-vercel-evals.ts @@ -61,13 +61,14 @@ export const BROKERED_KEYS: { ]; export const BROKERED_KEY_PLACEHOLDER = 'injected-by-sandbox-firewall'; /** - * Pins the CLI channel version resolved for this run across sandbox jobs. + * Non-secret configuration forwarded to sandbox jobs. * Provider keys belong in `BROKERED_KEYS` so the sandbox never sees them. */ export const FORWARDED_ENV_NAMES = [ 'SUPABASE_CLI_STABLE_VERSION', 'SUPABASE_CLI_BETA_VERSION', 'SUPABASE_CLI_NEXT_VERSION', + 'SPARK_API_URL', ]; const CLI_CHANNEL_ENV: Record = { stable: 'SUPABASE_CLI_STABLE_VERSION', @@ -824,7 +825,7 @@ export function brokeredNetworkPolicy(): NetworkPolicy { return { allow }; } -/** Builds the sandbox `.env` from key placeholders and CLI channel pins. An explicit pin wins over process.env. */ +/** Builds the sandbox `.env` from key placeholders and forwarded configuration. An explicit pin wins over process.env. */ export function agentEnvironment(pins: Record = {}): string { const lines: string[] = []; for (const { name } of BROKERED_KEYS) { diff --git a/evals/spark/build-database-001-task-due-dates/EVAL.ts b/evals/spark/build-database-001-task-due-dates/EVAL.ts new file mode 100644 index 000000000..e47bdfa28 --- /dev/null +++ b/evals/spark/build-database-001-task-due-dates/EVAL.ts @@ -0,0 +1,109 @@ +import type { CheckResult, ToolScorer } from '@supabase-evals/core'; +import { isRecord } from '@supabase-evals/core/json'; + +type Task = { + id: number; + title: string; + completed: boolean; + created_at: string; + due_date?: string | null; +}; + +function isTask(value: unknown): value is Task { + return ( + isRecord(value) && + typeof value.id === 'number' && + typeof value.title === 'string' && + typeof value.completed === 'boolean' && + typeof value.created_at === 'string' && + (value.due_date === undefined || + value.due_date === null || + typeof value.due_date === 'string') + ); +} + +export function checkTasks(value: unknown): CheckResult[] { + if (!Array.isArray(value) || !value.every(isTask)) + return [ + { + name: 'Existing task data preserved', + passed: false, + notes: 'Unexpected task response shape', + }, + ]; + const createdAt = [ + '2026-11-01 12:00:00', + '2026-11-01 12:01:00', + '2026-11-01 12:02:00', + '2026-11-01 12:03:00', + ]; + const tasks = value; + const titles = [ + 'Write release notes', + 'Ship task tracker', + 'Review schema', + 'Plan next iteration', + ]; + return [ + { + name: 'Existing task data preserved', + passed: + tasks.length === titles.length && + tasks.every( + (task, index) => + task.id === index + 1 && + task.title === titles[index] && + task.completed === (task.id === 3) && + task.created_at === createdAt[index] + ), + }, + { + name: 'Requested due date saved and other tasks undated', + passed: + tasks.length === titles.length && + tasks.every( + (task) => task.due_date === (task.id === 2 ? '2026-11-10' : null) + ), + }, + ]; +} + +const scorer: ToolScorer = async (ctx) => { + const checks: CheckResult[] = []; + try { + const { rows: columns } = await ctx.query( + `select type, "notnull" as not_null from pragma_table_info('tasks') where name = 'due_date'` + ); + const { data, error } = await ctx.client + .from('tasks') + .select('*') + .order('id'); + if (error) throw new Error(error.message); + checks.push( + { + name: 'Optional date column added', + passed: + columns.length === 1 && + typeof columns[0]?.type === 'string' && + columns[0].type.toLowerCase() === 'date' && + columns[0].not_null === 0, + }, + ...checkTasks(data) + ); + } catch (error) { + checks.push({ + name: 'Hosted task state readable', + passed: false, + notes: error instanceof Error ? error.message : String(error), + }); + } + const report = await ctx.judge({ + input: ctx.agentReport ?? '', + rubric: + 'The response must list exactly these open tasks in this order, including their IDs, titles and due dates: 2, Ship task tracker, 2026-11-10; 1, Write release notes, no due date; 4, Plan next iteration, no due date. Task 3 is complete and must not appear as an open task. Accept equivalent date formatting and descriptions of an absent date.', + }); + checks.push({ name: 'Open tasks reported in due-date order', ...report }); + return { passed: checks.every((check) => check.passed), checks }; +}; + +export default scorer; diff --git a/evals/spark/build-database-001-task-due-dates/PROMPT.md b/evals/spark/build-database-001-task-due-dates/PROMPT.md new file mode 100644 index 000000000..a5548c50a --- /dev/null +++ b/evals/spark/build-database-001-task-due-dates/PROMPT.md @@ -0,0 +1,14 @@ +--- +stage: build +interface: rest +product: + - database + - data-api +topic: + - migrations +motivation: https://linear.app/supabase/issue/AI-1399/add-an-agent-eval-suite-for-spark-rest-api +--- + +Our task tracker already has tasks in the connected Spark project. Add optional due dates to tasks, preserving all existing tasks and their current fields. Use a date column named `due_date`. + +Set the due date of "Ship task tracker" to 2026-11-10. Leave the other tasks undated. Verify the saved changes, then list all open tasks ordered by due date ascending, with undated tasks last and ties ordered by ID ascending. Include each task's ID, title, and due date in your answer. diff --git a/evals/spark/build-database-001-task-due-dates/remote/project.sql b/evals/spark/build-database-001-task-due-dates/remote/project.sql new file mode 100644 index 000000000..43218843d --- /dev/null +++ b/evals/spark/build-database-001-task-due-dates/remote/project.sql @@ -0,0 +1,12 @@ +create table public.tasks ( + id integer primary key, + title text not null, + completed boolean not null default false, + created_at timestamptz not null default now() +); + +insert into public.tasks (id, title, completed, created_at) values + (1, 'Write release notes', false, '2026-11-01T12:00:00Z'), + (2, 'Ship task tracker', false, '2026-11-01T12:01:00Z'), + (3, 'Review schema', true, '2026-11-01T12:02:00Z'), + (4, 'Plan next iteration', false, '2026-11-01T12:03:00Z'); diff --git a/experiments/spark/codex-gpt-6-luna.experiment.ts b/experiments/spark/codex-gpt-6-luna.experiment.ts new file mode 100644 index 000000000..ee4122ce9 --- /dev/null +++ b/experiments/spark/codex-gpt-6-luna.experiment.ts @@ -0,0 +1,8 @@ +import { defineExperiment, sparkRestRuntime } from '@supabase-evals/core'; +import { codexGpt6Luna } from '../presets.js'; + +export default defineExperiment({ + agent: codexGpt6Luna.agent, + runtime: sparkRestRuntime(process.env.SPARK_API_URL ?? ''), + skills: ['spark-api'], +}); diff --git a/packages/core/src/eval-metadata.ts b/packages/core/src/eval-metadata.ts index 2a678a399..0762689f1 100644 --- a/packages/core/src/eval-metadata.ts +++ b/packages/core/src/eval-metadata.ts @@ -46,6 +46,7 @@ export const evalSuiteSchema = z.enum([ 'docs', 'cli', 'docs-nav', + 'spark', ]); export const EVAL_SUITES = evalSuiteSchema.options; export type EvalSuite = z.infer; @@ -90,6 +91,7 @@ export type ExperimentDisplayMetadata = z.infer< * Interface(s) the agent uses to act on Supabase — a benchmark dimension * (cross-team KPI), not the runtime switch. `mcp` = the platform-lite MCP/tool * surface; `cli` = the real Supabase CLI inside a local-stack Docker sandbox. + * `rest` = direct HTTP access to the hosted API. * Required on every scenario, so each one is categorized on this dimension * rather than silently exported without a value. * @@ -98,7 +100,7 @@ export type ExperimentDisplayMetadata = z.infer< * tools runtime. `interface: cli` additionally forces a sandbox for scenarios * that start from an empty workspace (no `local/`). */ -export const evalInterfaceSchema = z.enum(['mcp', 'cli']); +export const evalInterfaceSchema = z.enum(['mcp', 'cli', 'rest']); export const EVAL_INTERFACES = evalInterfaceSchema.options; export type EvalInterface = z.infer; // Matches VERSION_RE in packages/sandbox/src/cli-channel.ts, so a resolved diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 895383bb6..094d10e1d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +export { sparkRestRuntime } from './spark-runtime.js'; import vm from 'node:vm'; import { createRequire } from 'node:module'; import { createHash, createHmac } from 'node:crypto'; @@ -961,6 +962,8 @@ export type EvalSessionArgs = { export type EvalSession = { mcpServers: Record; + /** Connection settings available to every process in the agent sandbox. */ + env?: Record; /** * Text for the agent's system prompt, joined from the MCP servers' * `promptAddendum`s. Must be empty for a CLI agent, which runs with its own diff --git a/packages/core/src/spark-runtime.test.ts b/packages/core/src/spark-runtime.test.ts new file mode 100644 index 000000000..da25a4a59 --- /dev/null +++ b/packages/core/src/spark-runtime.test.ts @@ -0,0 +1,123 @@ +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, it, vi } from 'vitest'; +import { sparkRestRuntime } from './spark-runtime.js'; + +afterEach(() => vi.unstubAllGlobals()); + +it('creates fresh projects, seeds before execution and reads scoring state', async () => { + const dir = await mkdtemp(join(tmpdir(), 'spark-runtime-')); + const fixture = join(dir, 'project.sql'); + await writeFile(fixture, 'create table tasks (id integer);'); + let projectCount = 0; + const fetchMock = vi.fn(async (url: URL, options?: RequestInit) => { + if (url.pathname === '/v1/projects') { + projectCount++; + return Response.json({ + ref: `project${projectCount}`, + url: `https://project${projectCount}.example`, + api_keys: [ + { type: 'secret', api_key: 'secret' }, + { type: 'publishable', api_key: 'publishable' }, + ], + }); + } + expect(options?.headers).toEqual({ + apikey: 'secret', + 'content-type': 'application/json', + }); + if (url.pathname === '/system/v1/migrations') { + expect(JSON.parse(String(options?.body))).toEqual({ + version: '1', + name: 'eval_fixture', + sql: 'create table tasks (id integer);', + }); + return Response.json({}); + } + expect(url.pathname).toBe('/database/query'); + expect(JSON.parse(String(options?.body))).toEqual({ + query: 'select 1', + }); + return Response.json([{ value: 1 }]); + }); + vi.stubGlobal('fetch', fetchMock); + try { + const runtime = sparkRestRuntime('https://control.example'); + const session = await runtime.startSession({ projectSeedSql: fixture }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(session.env?.SPARK_PROJECT_URL).toBe('https://project1.example'); + expect(session.mcpServers).toEqual({}); + expect(await session.scoringContext.query('select 1')).toEqual({ + rows: [{ value: 1 }], + }); + const next = await runtime.startSession({}); + expect(next.scoringContext.ref).not.toBe(session.scoringContext.ref); + await session.close(); + await next.close(); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +it('rejects configuration and unsupported fixtures before provisioning', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + await expect(sparkRestRuntime('').startSession({})).rejects.toThrow( + 'SPARK_API_URL' + ); + await expect( + sparkRestRuntime('https://control.example').startSession({ + logsSeedJsonl: 'logs.jsonl', + }) + ).rejects.toThrow('project.sql'); + await expect( + sparkRestRuntime('https://control.example').startSession({ + projectSeedSql: '/missing-fixture.sql', + }) + ).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +it('rejects failed provisioning and missing project keys', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce( + Response.json({ ref: 'p', url: 'https://p.example', api_keys: [] }) + ); + vi.stubGlobal('fetch', fetchMock); + const runtime = sparkRestRuntime('https://control.example'); + await expect(runtime.startSession({})).rejects.toThrow('503'); + await expect(runtime.startSession({})).rejects.toThrow('missing API keys'); +}); + +it('stops setup when Spark rejects the fixture', async () => { + const dir = await mkdtemp(join(tmpdir(), 'spark-fixture-')); + const fixture = join(dir, 'project.sql'); + await writeFile(fixture, 'invalid SQL'); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + ref: 'project', + url: 'https://project.example', + api_keys: [ + { type: 'secret', api_key: 'secret' }, + { type: 'publishable', api_key: 'publishable' }, + ], + }) + ) + .mockResolvedValueOnce(new Response('fixture rejected', { status: 400 })); + vi.stubGlobal('fetch', fetchMock); + try { + await expect( + sparkRestRuntime('https://control.example').startSession({ + projectSeedSql: fixture, + }) + ).rejects.toThrow('Spark fixture failed (400): fixture rejected'); + expect(fetchMock).toHaveBeenCalledTimes(2); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/core/src/spark-runtime.ts b/packages/core/src/spark-runtime.ts new file mode 100644 index 000000000..12ee928c5 --- /dev/null +++ b/packages/core/src/spark-runtime.ts @@ -0,0 +1,109 @@ +import { readFile } from 'node:fs/promises'; +import { createClient } from '@supabase/supabase-js'; +import { createManagementApiClient } from '@supabase-evals/platform-lite'; +import { z } from 'zod'; +import type { EvalRuntime } from './index.js'; + +const projectSchema = z.object({ + ref: z.string().min(1), + url: z.string().url(), + api_keys: z.array(z.object({ type: z.string(), api_key: z.string().min(1) })), +}); +const rowsSchema = z.array(z.record(z.string(), z.unknown())); + +export function sparkRestRuntime(apiUrl: string): EvalRuntime { + return { + id: 'spark-rest', + async startSession(args) { + if (!apiUrl.trim()) + throw new Error('Set SPARK_API_URL to the Spark control API URL'); + if (args.logsSeedJsonl || args.functionsSeedDir || args.pgvector) + throw new Error( + 'Spark REST runtime only supports project.sql fixtures' + ); + const seedSql = args.projectSeedSql + ? await readFile(args.projectSeedSql, 'utf8') + : undefined; + const created = await fetch(new URL('/v1/projects', apiUrl), { + method: 'POST', + signal: AbortSignal.timeout(30_000), + }); + if (!created.ok) + throw new Error(`Spark project creation failed (${created.status})`); + const project = projectSchema.parse(await created.json()); + const secret = project.api_keys.find( + (key) => key.type === 'secret' + )?.api_key; + const publishable = project.api_keys.find( + (key) => key.type === 'publishable' + )?.api_key; + if (!secret || !publishable) + throw new Error(`Spark project ${project.ref} is missing API keys`); + // shortcut: projects remain allocated, add teardown once a deletion endpoint is confirmed. + process.stderr.write( + `Spark project created: ${project.ref} (automatic cleanup unavailable)\n` + ); + const headers = { apikey: secret, 'content-type': 'application/json' }; + if (seedSql) { + const seeded = await fetch( + new URL('/system/v1/migrations', project.url), + { + method: 'POST', + headers, + body: JSON.stringify({ + version: '1', + name: 'eval_fixture', + sql: seedSql, + }), + signal: AbortSignal.timeout(30_000), + } + ); + if (!seeded.ok) + throw new Error( + `Spark fixture failed (${seeded.status}): ${await seeded.text()}` + ); + } + const getClient = () => + createClient(project.url, secret, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + return { + mcpServers: {}, + env: { + SPARK_PROJECT_URL: project.url, + SPARK_SECRET_KEY: secret, + SPARK_PUBLISHABLE_KEY: publishable, + }, + scoringContext: { + ref: project.ref, + // Spark's management surface is incomplete; REST scorers use client/query. + mgmt: createManagementApiClient(apiUrl, secret), + client: getClient(), + getClient, + async query(sql) { + const response = await fetch( + new URL('/database/query', project.url), + { + method: 'POST', + headers, + body: JSON.stringify({ query: sql }), + signal: AbortSignal.timeout(30_000), + } + ); + if (!response.ok) + throw new Error( + `Spark scoring query failed (${response.status}): ${await response.text()}` + ); + return { rows: rowsSchema.parse(await response.json()) }; + }, + async invokeFunction() { + throw new Error( + 'Spark REST runtime does not support function invocation' + ); + }, + }, + async close() {}, + }; + }, + }; +} diff --git a/packages/sandbox/src/bare-sandbox.ts b/packages/sandbox/src/bare-sandbox.ts index fa8895782..6b6772d08 100644 --- a/packages/sandbox/src/bare-sandbox.ts +++ b/packages/sandbox/src/bare-sandbox.ts @@ -12,6 +12,7 @@ export interface BareSandboxHandle { } export interface BareSandboxOptions { + env?: Record; /** Supabase CLI version baked into the sandbox image. */ cliVersion?: string; /** Skills to install into the sandbox. */ @@ -38,6 +39,7 @@ export async function createBareSandbox( options: BareSandboxOptions ): Promise { const env = await createAgentEnvironment({ + env: options.env, cliVersion: options.cliVersion, skills: options.skills, mounts: options.mounts, diff --git a/packages/sandbox/test/unit.test.ts b/packages/sandbox/test/unit.test.ts index 16e723c12..50db7dec3 100644 --- a/packages/sandbox/test/unit.test.ts +++ b/packages/sandbox/test/unit.test.ts @@ -604,6 +604,9 @@ describe('interface frontmatter', () => { expect( parseEvalMarkdown(buildMarkdown('interface: mcp')).metadata.interface ).toBe('mcp'); + expect( + parseEvalMarkdown(buildMarkdown('interface: rest')).metadata.interface + ).toBe('rest'); }); it('rejects a scenario with no interface, naming the source file', () => { @@ -613,9 +616,9 @@ describe('interface frontmatter', () => { }); it('rejects an unknown interface', () => { - expect(() => parseEvalMarkdown(buildMarkdown('interface: rest'))).toThrow( - /interface/ - ); + expect(() => + parseEvalMarkdown(buildMarkdown('interface: unknown')) + ).toThrow(/interface/); }); }); diff --git a/skills/spark-api/SKILL.md b/skills/spark-api/SKILL.md new file mode 100644 index 000000000..5f5ca0fa6 --- /dev/null +++ b/skills/spark-api/SKILL.md @@ -0,0 +1,17 @@ +--- +name: spark-api +description: Spark REST API for database schema and data operations. +--- + +Base URL: `$SPARK_PROJECT_URL`. Authenticate with `apikey: $SPARK_SECRET_KEY` (owner access). `$SPARK_PUBLISHABLE_KEY` is also available for Data API calls. Keep keys out of output. + +Send `Content-Type: application/json` for JSON bodies. + +| Method | Path | Request / behavior | +| --- | --- | --- | +| GET | `/system/v1/migrations` | List applied migrations and their statements. | +| POST | `/system/v1/migrations` | `{"version":"...","name":"...","sql":"..."}`. Version is a unique, increasing numeric string. | +| GET, POST, PATCH, DELETE | `/rest/v1/` | PostgREST reads and writes. Supports `select`, `order`, and filters such as `id=eq.1`. | +| POST | `/database/query` | `{"query":"SELECT ...","parameters":[]}`. Read-only SQL inspection. | + +Apply DDL through migrations and DML through the Data API. From f478e430fbe21dcad721f79e8c5dfe57d4906276 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:55:46 -0500 Subject: [PATCH 02/19] chore: defer Spark CI integration --- .github/workflows/eval-refresh.yml | 4 +--- apps/framework/scripts/run-vercel-evals.ts | 5 ++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/eval-refresh.yml b/.github/workflows/eval-refresh.yml index 03f9f3da7..24c16b7d7 100644 --- a/.github/workflows/eval-refresh.yml +++ b/.github/workflows/eval-refresh.yml @@ -296,7 +296,6 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_API_KEY }} - SPARK_API_URL: ${{ vars.SPARK_API_URL }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} @@ -331,7 +330,6 @@ jobs: echo "OPENAI_API_KEY=${OPENAI_API_KEY}" echo "AI_GATEWAY_API_KEY=${AI_GATEWAY_API_KEY}" echo "XAI_API_KEY=${XAI_API_KEY}" - echo "SPARK_API_URL=${SPARK_API_URL}" echo "SUPABASE_CLI_STABLE_VERSION=${SUPABASE_CLI_STABLE_VERSION}" echo "SUPABASE_CLI_BETA_VERSION=${SUPABASE_CLI_BETA_VERSION}" echo "SUPABASE_CLI_NEXT_VERSION=${SUPABASE_CLI_NEXT_VERSION}" @@ -498,7 +496,7 @@ jobs: pnpm --filter @supabase-evals/framework export-results -- "${export_args[@]}" fi - for eval_suite in regression docs cli spark; do + for eval_suite in regression docs cli; do if jq -e --arg s "$eval_suite" 'any(.[]; .eval_suite == $s)' <<< "$pairs" > /dev/null; then export_args=(--suite "$eval_suite" --runs "${{ needs.prepare.outputs.runs }}" --output "apps/web/src/data/${eval_suite}-eval-results.json") if [ "${{ needs.prepare.outputs.do_merge }}" = "true" ]; then diff --git a/apps/framework/scripts/run-vercel-evals.ts b/apps/framework/scripts/run-vercel-evals.ts index 1f9d5f8a9..aaf7318fb 100644 --- a/apps/framework/scripts/run-vercel-evals.ts +++ b/apps/framework/scripts/run-vercel-evals.ts @@ -61,14 +61,13 @@ export const BROKERED_KEYS: { ]; export const BROKERED_KEY_PLACEHOLDER = 'injected-by-sandbox-firewall'; /** - * Non-secret configuration forwarded to sandbox jobs. + * Pins the CLI channel version resolved for this run across sandbox jobs. * Provider keys belong in `BROKERED_KEYS` so the sandbox never sees them. */ export const FORWARDED_ENV_NAMES = [ 'SUPABASE_CLI_STABLE_VERSION', 'SUPABASE_CLI_BETA_VERSION', 'SUPABASE_CLI_NEXT_VERSION', - 'SPARK_API_URL', ]; const CLI_CHANNEL_ENV: Record = { stable: 'SUPABASE_CLI_STABLE_VERSION', @@ -825,7 +824,7 @@ export function brokeredNetworkPolicy(): NetworkPolicy { return { allow }; } -/** Builds the sandbox `.env` from key placeholders and forwarded configuration. An explicit pin wins over process.env. */ +/** Builds the sandbox `.env` from key placeholders and CLI channel pins. An explicit pin wins over process.env. */ export function agentEnvironment(pins: Record = {}): string { const lines: string[] = []; for (const { name } of BROKERED_KEYS) { From d0662e34f6b84ec57c0d10f43219849f6aab8238 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:59:55 -0500 Subject: [PATCH 03/19] refactor: simplify Spark task prompt --- apps/framework/harness/spark-scoring.test.ts | 32 ++++++++++++--- .../build-database-001-task-due-dates/EVAL.ts | 41 ++++++++++--------- .../PROMPT.md | 4 +- 3 files changed, 49 insertions(+), 28 deletions(-) diff --git a/apps/framework/harness/spark-scoring.test.ts b/apps/framework/harness/spark-scoring.test.ts index b9bfe84f7..ea5fc7650 100644 --- a/apps/framework/harness/spark-scoring.test.ts +++ b/apps/framework/harness/spark-scoring.test.ts @@ -37,7 +37,9 @@ const tasks = [ describe('task state checks', () => { it('accepts the requested change', () => { - expect(checkTasks(tasks).every((check) => check.passed)).toBe(true); + expect(checkTasks(tasks, 'due_date').every((check) => check.passed)).toBe( + true + ); }); it('rejects missing dates, altered seed data and deleted rows', () => { for (const changed of [ @@ -47,7 +49,9 @@ describe('task state checks', () => { tasks.slice(1), [], ]) - expect(checkTasks(changed).some((check) => !check.passed)).toBe(true); + expect( + checkTasks(changed, 'due_date').some((check) => !check.passed) + ).toBe(true); }); }); @@ -56,6 +60,7 @@ afterEach(() => vi.unstubAllGlobals()); async function score( options: { columns?: Record[]; + tasks?: unknown; tableStatus?: number; queryStatus?: number; reportPasses?: boolean; @@ -81,7 +86,7 @@ async function score( status: options.queryStatus, }); return Response.json( - options.columns ?? [{ type: 'date', not_null: 0 }] + options.columns ?? [{ name: 'due_date', type: 'date', not_null: 0 }] ); } expect(url.pathname).toBe('/rest/v1/tasks'); @@ -90,7 +95,7 @@ async function score( { message: 'relation "tasks" does not exist', code: '42P01' }, { status: options.tableStatus } ) - : Response.json(tasks); + : Response.json(options.tasks ?? tasks); }) ); const session = await sparkRestRuntime( @@ -116,11 +121,26 @@ describe('hosted task scorer', () => { expect((await score({ reportPasses: false })).result.passed).toBe(false); }); + it('accepts a different due-date column name', async () => { + const renamed = tasks.map(({ due_date, ...task }) => ({ + ...task, + deadline: due_date, + })); + expect( + ( + await score({ + columns: [{ name: 'deadline', type: 'date', not_null: 0 }], + tasks: renamed, + }) + ).result.passed + ).toBe(true); + }); + it('rejects missing, non-date and required columns', async () => { for (const columns of [ [], - [{ type: 'text', not_null: 0 }], - [{ type: 'date', not_null: 1 }], + [{ name: 'due_date', type: 'text', not_null: 0 }], + [{ name: 'due_date', type: 'date', not_null: 1 }], ]) { const { result } = await score({ columns }); expect(result.passed).toBe(false); diff --git a/evals/spark/build-database-001-task-due-dates/EVAL.ts b/evals/spark/build-database-001-task-due-dates/EVAL.ts index e47bdfa28..32b3ab636 100644 --- a/evals/spark/build-database-001-task-due-dates/EVAL.ts +++ b/evals/spark/build-database-001-task-due-dates/EVAL.ts @@ -1,12 +1,11 @@ import type { CheckResult, ToolScorer } from '@supabase-evals/core'; import { isRecord } from '@supabase-evals/core/json'; -type Task = { +type Task = Record & { id: number; title: string; completed: boolean; created_at: string; - due_date?: string | null; }; function isTask(value: unknown): value is Task { @@ -15,14 +14,14 @@ function isTask(value: unknown): value is Task { typeof value.id === 'number' && typeof value.title === 'string' && typeof value.completed === 'boolean' && - typeof value.created_at === 'string' && - (value.due_date === undefined || - value.due_date === null || - typeof value.due_date === 'string') + typeof value.created_at === 'string' ); } -export function checkTasks(value: unknown): CheckResult[] { +export function checkTasks( + value: unknown, + dueDateColumn: string | undefined +): CheckResult[] { if (!Array.isArray(value) || !value.every(isTask)) return [ { @@ -60,9 +59,11 @@ export function checkTasks(value: unknown): CheckResult[] { { name: 'Requested due date saved and other tasks undated', passed: + dueDateColumn !== undefined && tasks.length === titles.length && tasks.every( - (task) => task.due_date === (task.id === 2 ? '2026-11-10' : null) + (task) => + task[dueDateColumn] === (task.id === 2 ? '2026-11-10' : null) ), }, ]; @@ -72,23 +73,25 @@ const scorer: ToolScorer = async (ctx) => { const checks: CheckResult[] = []; try { const { rows: columns } = await ctx.query( - `select type, "notnull" as not_null from pragma_table_info('tasks') where name = 'due_date'` + `select name, type, "notnull" as not_null from pragma_table_info('tasks') where name not in ('id', 'title', 'completed', 'created_at')` ); const { data, error } = await ctx.client .from('tasks') .select('*') .order('id'); if (error) throw new Error(error.message); + const dueDateColumn = columns.find( + (column) => + typeof column.name === 'string' && + typeof column.type === 'string' && + column.type.toLowerCase() === 'date' && + column.not_null === 0 + )?.name; + const columnName = + typeof dueDateColumn === 'string' ? dueDateColumn : undefined; checks.push( - { - name: 'Optional date column added', - passed: - columns.length === 1 && - typeof columns[0]?.type === 'string' && - columns[0].type.toLowerCase() === 'date' && - columns[0].not_null === 0, - }, - ...checkTasks(data) + { name: 'Optional date column added', passed: columnName !== undefined }, + ...checkTasks(data, columnName) ); } catch (error) { checks.push({ @@ -100,7 +103,7 @@ const scorer: ToolScorer = async (ctx) => { const report = await ctx.judge({ input: ctx.agentReport ?? '', rubric: - 'The response must list exactly these open tasks in this order, including their IDs, titles and due dates: 2, Ship task tracker, 2026-11-10; 1, Write release notes, no due date; 4, Plan next iteration, no due date. Task 3 is complete and must not appear as an open task. Accept equivalent date formatting and descriptions of an absent date.', + 'The response must list exactly these open tasks with their due dates: Ship task tracker, November 10, 2026; Write release notes, no due date; Plan next iteration, no due date. Ship task tracker must appear before the undated tasks. Either order among the undated tasks is valid. Review schema is complete and must not appear as an open task. Task IDs are optional. Accept equivalent date formatting and descriptions of an absent date.', }); checks.push({ name: 'Open tasks reported in due-date order', ...report }); return { passed: checks.every((check) => check.passed), checks }; diff --git a/evals/spark/build-database-001-task-due-dates/PROMPT.md b/evals/spark/build-database-001-task-due-dates/PROMPT.md index a5548c50a..cbc52c3b0 100644 --- a/evals/spark/build-database-001-task-due-dates/PROMPT.md +++ b/evals/spark/build-database-001-task-due-dates/PROMPT.md @@ -9,6 +9,4 @@ topic: motivation: https://linear.app/supabase/issue/AI-1399/add-an-agent-eval-suite-for-spark-rest-api --- -Our task tracker already has tasks in the connected Spark project. Add optional due dates to tasks, preserving all existing tasks and their current fields. Use a date column named `due_date`. - -Set the due date of "Ship task tracker" to 2026-11-10. Leave the other tasks undated. Verify the saved changes, then list all open tasks ordered by due date ascending, with undated tasks last and ties ordered by ID ascending. Include each task's ID, title, and due date in your answer. +Can you add optional due dates to our task tracker? Set "Ship task tracker" to November 10, 2026 and leave the other tasks undated. Then show me the open tasks with their due dates, soonest first and undated tasks last. From bfbc7f25c6072205c34003859fc58dce48d5a095 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:22:43 -0500 Subject: [PATCH 04/19] docs: expand Spark API skill reference --- skills/spark-api/SKILL.md | 49 ++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/skills/spark-api/SKILL.md b/skills/spark-api/SKILL.md index 5f5ca0fa6..96e6a870e 100644 --- a/skills/spark-api/SKILL.md +++ b/skills/spark-api/SKILL.md @@ -1,17 +1,50 @@ --- name: spark-api -description: Spark REST API for database schema and data operations. +description: Spark REST API for database schema, data, and email/password authentication. --- -Base URL: `$SPARK_PROJECT_URL`. Authenticate with `apikey: $SPARK_SECRET_KEY` (owner access). `$SPARK_PUBLISHABLE_KEY` is also available for Data API calls. Keep keys out of output. +Base URL: `$SPARK_PROJECT_URL`. Send the API key in the `apikey` header, not a query parameter. `$SPARK_SECRET_KEY` acts as `service_role`; `$SPARK_PUBLISHABLE_KEY` acts as `anon`. For a user session, also send `Authorization: Bearer `. Keep keys and tokens out of output. -Send `Content-Type: application/json` for JSON bodies. +Use `Content-Type: application/json` for JSON bodies. Error responses contain diagnostic details in the body. + +## Schema and SQL + +| Method | Path | Request / behavior | +| --- | --- | --- | +| GET | `/system/v1/migrations` | Applied migrations, oldest first, including their SQL statements. Secret key required. | +| POST | `/system/v1/migrations` | `{"version":"...","name":"...","sql":"..."}`. Applies SQL and reloads the Data API schema cache. Use an unused version. Secret key required. | +| POST | `/database/query` | `{"query":"SELECT ...","parameters":[]}`. Read-only SQL inspection. Secret key required. | + +Apply DDL through migrations and DML through the Data API. SQL compatibility is partial: `information_schema.tables` and `information_schema.columns` are unavailable. Inspect schema through migration statements or `SELECT * FROM pragma_table_info('
')`. + +## Data API + +Tables in `public` are available at `/rest/v1/
`. + +| Method | Behavior | +| --- | --- | +| GET, HEAD | Read rows or headers. Supports `select`, filters such as `id=eq.1`, `order`, `limit`, and `offset`. | +| POST | Insert one JSON object or an array of rows. | +| PATCH | Update rows selected by query-string filters. | +| DELETE | Delete rows selected by query-string filters. | + +`Prefer: return=representation` returns inserted or updated rows. `Prefer: count=exact` requests a count. `Accept: application/vnd.pgrst.object+json` requests one row as an object. + +## Auth + +Email/password only. Signup confirms the email immediately without sending mail. Signup and token requests return `access_token`, `refresh_token`, and `user`. | Method | Path | Request / behavior | | --- | --- | --- | -| GET | `/system/v1/migrations` | List applied migrations and their statements. | -| POST | `/system/v1/migrations` | `{"version":"...","name":"...","sql":"..."}`. Version is a unique, increasing numeric string. | -| GET, POST, PATCH, DELETE | `/rest/v1/
` | PostgREST reads and writes. Supports `select`, `order`, and filters such as `id=eq.1`. | -| POST | `/database/query` | `{"query":"SELECT ...","parameters":[]}`. Read-only SQL inspection. | +| POST | `/auth/v1/signup` | `{"email":"...","password":"..."}`. Creates a user and session. | +| POST | `/auth/v1/token?grant_type=password` | `{"email":"...","password":"..."}`. Signs in. | +| POST | `/auth/v1/token?grant_type=refresh_token` | `{"refresh_token":"..."}`. Refreshes a session. | +| GET | `/auth/v1/user` | Current user; requires the session bearer token. | +| POST | `/auth/v1/logout` | Ends the session; requires the session bearer token. | + +## Limits -Apply DDL through migrations and DML through the Data API. +- No row-level security: publishable keys can read and write every row. RLS statements and policies are unsupported. +- No OpenAPI root (`GET /rest/v1/`), RPC, or views. +- Upsert and PUT on an existing row with an integer primary key are known to fail. +- No OTP, magic links, OAuth, password recovery, or MFA. A project secret key does not act as an Auth admin session. From 5db7d0d80da3832705a482668ffc86cc73249790 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:26:20 -0500 Subject: [PATCH 05/19] fix: use recommended skills in Spark experiment --- experiments/spark/codex-gpt-6-luna.experiment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experiments/spark/codex-gpt-6-luna.experiment.ts b/experiments/spark/codex-gpt-6-luna.experiment.ts index ee4122ce9..afd2d629a 100644 --- a/experiments/spark/codex-gpt-6-luna.experiment.ts +++ b/experiments/spark/codex-gpt-6-luna.experiment.ts @@ -4,5 +4,5 @@ import { codexGpt6Luna } from '../presets.js'; export default defineExperiment({ agent: codexGpt6Luna.agent, runtime: sparkRestRuntime(process.env.SPARK_API_URL ?? ''), - skills: ['spark-api'], + skills: [...codexGpt6Luna.skills, 'spark-api'], }); From 298a18eadd251c92a43842a262e17b0ab233d94c Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:33:58 -0500 Subject: [PATCH 06/19] fix: simplify Spark scorer checks --- apps/framework/harness/spark-scoring.test.ts | 139 +++++++----------- .../build-database-001-task-due-dates/EVAL.ts | 25 ++-- 2 files changed, 66 insertions(+), 98 deletions(-) diff --git a/apps/framework/harness/spark-scoring.test.ts b/apps/framework/harness/spark-scoring.test.ts index ea5fc7650..56a06b84c 100644 --- a/apps/framework/harness/spark-scoring.test.ts +++ b/apps/framework/harness/spark-scoring.test.ts @@ -1,46 +1,24 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { sparkRestRuntime } from '@supabase-evals/core'; +import { describe, expect, it, vi } from 'vitest'; +import { createClient } from '@supabase/supabase-js'; +import { createManagementApiClient } from '@supabase-evals/platform-lite'; import scorer, { checkTasks, } from '../../../evals/spark/build-database-001-task-due-dates/EVAL.js'; const tasks = [ - { - id: 1, - title: 'Write release notes', - completed: false, - created_at: '2026-11-01 12:00:00', - due_date: null, - }, - { - id: 2, - title: 'Ship task tracker', - completed: false, - created_at: '2026-11-01 12:01:00', - due_date: '2026-11-10', - }, - { - id: 3, - title: 'Review schema', - completed: true, - created_at: '2026-11-01 12:02:00', - due_date: null, - }, - { - id: 4, - title: 'Plan next iteration', - completed: false, - created_at: '2026-11-01 12:03:00', - due_date: null, - }, -]; + 'Write release notes', + 'Ship task tracker', + 'Review schema', + 'Plan next iteration', +].map((title, index) => ({ + id: index + 1, + title, + completed: index === 2, + created_at: `2026-11-01 12:0${index}:00`, + due_date: index === 1 ? '2026-11-10' : null, +})); describe('task state checks', () => { - it('accepts the requested change', () => { - expect(checkTasks(tasks, 'due_date').every((check) => check.passed)).toBe( - true - ); - }); it('rejects missing dates, altered seed data and deleted rows', () => { for (const changed of [ tasks.map((task) => ({ ...task, due_date: null })), @@ -50,13 +28,11 @@ describe('task state checks', () => { [], ]) expect( - checkTasks(changed, 'due_date').some((check) => !check.passed) + checkTasks(changed, ['due_date']).some((check) => !check.passed) ).toBe(true); }); }); -afterEach(() => vi.unstubAllGlobals()); - async function score( options: { columns?: Record[]; @@ -66,53 +42,39 @@ async function score( reportPasses?: boolean; } = {} ) { - vi.stubGlobal( - 'fetch', - vi.fn(async (input: string | URL | Request) => { - const url = - input instanceof Request ? new URL(input.url) : new URL(input); - if (url.pathname === '/v1/projects') - return Response.json({ - ref: 'project', - url: 'https://project.example', - api_keys: [ - { type: 'secret', api_key: 'secret' }, - { type: 'publishable', api_key: 'publishable' }, - ], - }); - if (url.pathname === '/database/query') { - if (options.queryStatus) - return new Response('query unavailable', { - status: options.queryStatus, - }); - return Response.json( - options.columns ?? [{ name: 'due_date', type: 'date', not_null: 0 }] - ); - } - expect(url.pathname).toBe('/rest/v1/tasks'); - return options.tableStatus - ? Response.json( - { message: 'relation "tasks" does not exist', code: '42P01' }, - { status: options.tableStatus } - ) - : Response.json(options.tasks ?? tasks); - }) - ); - const session = await sparkRestRuntime( - 'https://control.example' - ).startSession({}); + const client = createClient('https://project.example', 'secret', { + global: { + fetch: async () => + options.tableStatus + ? Response.json( + { message: 'table unavailable' }, + { status: options.tableStatus } + ) + : Response.json(options.tasks ?? tasks), + }, + }); const judge = vi.fn(async () => ({ passed: options.reportPasses ?? true })); - try { - const result = await scorer({ - ...session.scoringContext, - toolCalls: [], - transcript: [], - judge, - }); - return { result, judge }; - } finally { - await session.close(); - } + const result = await scorer({ + ref: 'project', + mgmt: createManagementApiClient('https://control.example', 'secret'), + client, + getClient: () => client, + query: async () => { + if (options.queryStatus) throw new Error('query unavailable'); + return { + rows: options.columns ?? [ + { name: 'due_date', type: 'date', not_null: 0 }, + ], + }; + }, + invokeFunction: async () => { + throw new Error('unused'); + }, + toolCalls: [], + transcript: [], + judge, + }); + return { result, judge }; } describe('hosted task scorer', () => { @@ -121,7 +83,7 @@ describe('hosted task scorer', () => { expect((await score({ reportPasses: false })).result.passed).toBe(false); }); - it('accepts a different due-date column name', async () => { + it('accepts a matching date column after an unrelated date column', async () => { const renamed = tasks.map(({ due_date, ...task }) => ({ ...task, deadline: due_date, @@ -129,7 +91,10 @@ describe('hosted task scorer', () => { expect( ( await score({ - columns: [{ name: 'deadline', type: 'date', not_null: 0 }], + columns: [ + { name: 'another_date', type: 'date', not_null: 0 }, + { name: 'deadline', type: 'date', not_null: 0 }, + ], tasks: renamed, }) ).result.passed diff --git a/evals/spark/build-database-001-task-due-dates/EVAL.ts b/evals/spark/build-database-001-task-due-dates/EVAL.ts index 32b3ab636..2a52e1806 100644 --- a/evals/spark/build-database-001-task-due-dates/EVAL.ts +++ b/evals/spark/build-database-001-task-due-dates/EVAL.ts @@ -20,7 +20,7 @@ function isTask(value: unknown): value is Task { export function checkTasks( value: unknown, - dueDateColumn: string | undefined + dueDateColumns: unknown[] ): CheckResult[] { if (!Array.isArray(value) || !value.every(isTask)) return [ @@ -59,11 +59,13 @@ export function checkTasks( { name: 'Requested due date saved and other tasks undated', passed: - dueDateColumn !== undefined && tasks.length === titles.length && - tasks.every( - (task) => - task[dueDateColumn] === (task.id === 2 ? '2026-11-10' : null) + dueDateColumns.some( + (column) => + typeof column === 'string' && + tasks.every( + (task) => task[column] === (task.id === 2 ? '2026-11-10' : null) + ) ), }, ]; @@ -80,18 +82,19 @@ const scorer: ToolScorer = async (ctx) => { .select('*') .order('id'); if (error) throw new Error(error.message); - const dueDateColumn = columns.find( + const dateColumns = columns.filter( (column) => typeof column.name === 'string' && typeof column.type === 'string' && column.type.toLowerCase() === 'date' && column.not_null === 0 - )?.name; - const columnName = - typeof dueDateColumn === 'string' ? dueDateColumn : undefined; + ); checks.push( - { name: 'Optional date column added', passed: columnName !== undefined }, - ...checkTasks(data, columnName) + { name: 'Optional date column added', passed: dateColumns.length > 0 }, + ...checkTasks( + data, + dateColumns.map((column) => column.name) + ) ); } catch (error) { checks.push({ From fd31f3a59ada0dff51d900354854e0efaaf37479 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:44:14 -0500 Subject: [PATCH 07/19] refactor: simplify Spark naming --- experiments/spark/codex-gpt-6-luna.experiment.ts | 6 +++--- packages/core/src/index.ts | 2 +- packages/core/src/spark-runtime.test.ts | 14 +++++++------- packages/core/src/spark-runtime.ts | 4 ++-- skills/{spark-api => spark}/SKILL.md | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) rename skills/{spark-api => spark}/SKILL.md (99%) diff --git a/experiments/spark/codex-gpt-6-luna.experiment.ts b/experiments/spark/codex-gpt-6-luna.experiment.ts index afd2d629a..32c688066 100644 --- a/experiments/spark/codex-gpt-6-luna.experiment.ts +++ b/experiments/spark/codex-gpt-6-luna.experiment.ts @@ -1,8 +1,8 @@ -import { defineExperiment, sparkRestRuntime } from '@supabase-evals/core'; +import { defineExperiment, sparkRuntime } from '@supabase-evals/core'; import { codexGpt6Luna } from '../presets.js'; export default defineExperiment({ agent: codexGpt6Luna.agent, - runtime: sparkRestRuntime(process.env.SPARK_API_URL ?? ''), - skills: [...codexGpt6Luna.skills, 'spark-api'], + runtime: sparkRuntime(process.env.SPARK_API_URL ?? ''), + skills: [...codexGpt6Luna.skills, 'spark'], }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 094d10e1d..89a665057 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export { sparkRestRuntime } from './spark-runtime.js'; +export { sparkRuntime } from './spark-runtime.js'; import vm from 'node:vm'; import { createRequire } from 'node:module'; import { createHash, createHmac } from 'node:crypto'; diff --git a/packages/core/src/spark-runtime.test.ts b/packages/core/src/spark-runtime.test.ts index da25a4a59..e79165d86 100644 --- a/packages/core/src/spark-runtime.test.ts +++ b/packages/core/src/spark-runtime.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, expect, it, vi } from 'vitest'; -import { sparkRestRuntime } from './spark-runtime.js'; +import { sparkRuntime } from './spark-runtime.js'; afterEach(() => vi.unstubAllGlobals()); @@ -43,7 +43,7 @@ it('creates fresh projects, seeds before execution and reads scoring state', asy }); vi.stubGlobal('fetch', fetchMock); try { - const runtime = sparkRestRuntime('https://control.example'); + const runtime = sparkRuntime('https://control.example'); const session = await runtime.startSession({ projectSeedSql: fixture }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(session.env?.SPARK_PROJECT_URL).toBe('https://project1.example'); @@ -63,16 +63,16 @@ it('creates fresh projects, seeds before execution and reads scoring state', asy it('rejects configuration and unsupported fixtures before provisioning', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); - await expect(sparkRestRuntime('').startSession({})).rejects.toThrow( + await expect(sparkRuntime('').startSession({})).rejects.toThrow( 'SPARK_API_URL' ); await expect( - sparkRestRuntime('https://control.example').startSession({ + sparkRuntime('https://control.example').startSession({ logsSeedJsonl: 'logs.jsonl', }) ).rejects.toThrow('project.sql'); await expect( - sparkRestRuntime('https://control.example').startSession({ + sparkRuntime('https://control.example').startSession({ projectSeedSql: '/missing-fixture.sql', }) ).rejects.toThrow(); @@ -87,7 +87,7 @@ it('rejects failed provisioning and missing project keys', async () => { Response.json({ ref: 'p', url: 'https://p.example', api_keys: [] }) ); vi.stubGlobal('fetch', fetchMock); - const runtime = sparkRestRuntime('https://control.example'); + const runtime = sparkRuntime('https://control.example'); await expect(runtime.startSession({})).rejects.toThrow('503'); await expect(runtime.startSession({})).rejects.toThrow('missing API keys'); }); @@ -112,7 +112,7 @@ it('stops setup when Spark rejects the fixture', async () => { vi.stubGlobal('fetch', fetchMock); try { await expect( - sparkRestRuntime('https://control.example').startSession({ + sparkRuntime('https://control.example').startSession({ projectSeedSql: fixture, }) ).rejects.toThrow('Spark fixture failed (400): fixture rejected'); diff --git a/packages/core/src/spark-runtime.ts b/packages/core/src/spark-runtime.ts index 12ee928c5..e7b681bb4 100644 --- a/packages/core/src/spark-runtime.ts +++ b/packages/core/src/spark-runtime.ts @@ -11,9 +11,9 @@ const projectSchema = z.object({ }); const rowsSchema = z.array(z.record(z.string(), z.unknown())); -export function sparkRestRuntime(apiUrl: string): EvalRuntime { +export function sparkRuntime(apiUrl: string): EvalRuntime { return { - id: 'spark-rest', + id: 'spark', async startSession(args) { if (!apiUrl.trim()) throw new Error('Set SPARK_API_URL to the Spark control API URL'); diff --git a/skills/spark-api/SKILL.md b/skills/spark/SKILL.md similarity index 99% rename from skills/spark-api/SKILL.md rename to skills/spark/SKILL.md index 96e6a870e..c980c3af5 100644 --- a/skills/spark-api/SKILL.md +++ b/skills/spark/SKILL.md @@ -1,5 +1,5 @@ --- -name: spark-api +name: spark description: Spark REST API for database schema, data, and email/password authentication. --- From 412d42ca9d060557e8d1bf4154454a7ab93f8fff Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:50:47 -0500 Subject: [PATCH 08/19] refactor: validate Spark tasks with Zod --- apps/framework/harness/spark-scoring.test.ts | 3 +- .../build-database-001-task-due-dates/EVAL.ts | 35 ++++++++----------- package.json | 3 +- pnpm-lock.yaml | 3 ++ 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/framework/harness/spark-scoring.test.ts b/apps/framework/harness/spark-scoring.test.ts index 56a06b84c..8e902f0f8 100644 --- a/apps/framework/harness/spark-scoring.test.ts +++ b/apps/framework/harness/spark-scoring.test.ts @@ -24,6 +24,7 @@ describe('task state checks', () => { tasks.map((task) => ({ ...task, due_date: null })), tasks.map((task) => ({ ...task, title: 'Replaced' })), tasks.map((task) => ({ ...task, created_at: '2026-11-02T00:00:00' })), + tasks.map((task) => ({ ...task, completed: 'false' })), tasks.slice(1), [], ]) @@ -111,7 +112,7 @@ describe('hosted task scorer', () => { expect(result.passed).toBe(false); expect( result.checks?.find( - (check) => check.name === 'Optional date column added' + (check) => check.name === 'Nullable date column added' )?.passed ).toBe(false); } diff --git a/evals/spark/build-database-001-task-due-dates/EVAL.ts b/evals/spark/build-database-001-task-due-dates/EVAL.ts index 2a52e1806..9d4dd0da0 100644 --- a/evals/spark/build-database-001-task-due-dates/EVAL.ts +++ b/evals/spark/build-database-001-task-due-dates/EVAL.ts @@ -1,28 +1,23 @@ import type { CheckResult, ToolScorer } from '@supabase-evals/core'; -import { isRecord } from '@supabase-evals/core/json'; +import { z } from 'zod'; -type Task = Record & { - id: number; - title: string; - completed: boolean; - created_at: string; -}; - -function isTask(value: unknown): value is Task { - return ( - isRecord(value) && - typeof value.id === 'number' && - typeof value.title === 'string' && - typeof value.completed === 'boolean' && - typeof value.created_at === 'string' - ); -} +const tasksSchema = z.array( + z + .object({ + id: z.number(), + title: z.string(), + completed: z.boolean(), + created_at: z.string(), + }) + .catchall(z.unknown()) +); export function checkTasks( value: unknown, dueDateColumns: unknown[] ): CheckResult[] { - if (!Array.isArray(value) || !value.every(isTask)) + const result = tasksSchema.safeParse(value); + if (!result.success) return [ { name: 'Existing task data preserved', @@ -36,7 +31,7 @@ export function checkTasks( '2026-11-01 12:02:00', '2026-11-01 12:03:00', ]; - const tasks = value; + const tasks = result.data; const titles = [ 'Write release notes', 'Ship task tracker', @@ -90,7 +85,7 @@ const scorer: ToolScorer = async (ctx) => { column.not_null === 0 ); checks.push( - { name: 'Optional date column added', passed: dateColumns.length > 0 }, + { name: 'Nullable date column added', passed: dateColumns.length > 0 }, ...checkTasks( data, dateColumns.map((column) => column.name) diff --git a/package.json b/package.json index afcc38414..d52bf8a87 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "@ai-sdk/openai": "catalog:", "common-tags": "^1.8.2", "shell-quote": "^1.11.0", - "smol-toml": "^1.9.1" + "smol-toml": "^1.9.1", + "zod": "catalog:" }, "devDependencies": { "@biomejs/biome": "1.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4497a57f..c16dc1717 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: smol-toml: specifier: ^1.9.1 version: 1.9.1 + zod: + specifier: 'catalog:' + version: 4.6.7 devDependencies: '@biomejs/biome': specifier: 1.9.4 From 4b5dc5f301aa085fd80a71fa2c5d85de4f6e66e9 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:54:20 -0500 Subject: [PATCH 09/19] refactor: clarify Spark runtime setup --- .../build-database-001-task-due-dates/EVAL.ts | 7 +++--- packages/core/src/spark-runtime.ts | 22 +++++++++++++------ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/evals/spark/build-database-001-task-due-dates/EVAL.ts b/evals/spark/build-database-001-task-due-dates/EVAL.ts index 9d4dd0da0..cd3475788 100644 --- a/evals/spark/build-database-001-task-due-dates/EVAL.ts +++ b/evals/spark/build-database-001-task-due-dates/EVAL.ts @@ -16,8 +16,8 @@ export function checkTasks( value: unknown, dueDateColumns: unknown[] ): CheckResult[] { - const result = tasksSchema.safeParse(value); - if (!result.success) + const parsed = tasksSchema.safeParse(value); + if (!parsed.success) return [ { name: 'Existing task data preserved', @@ -25,13 +25,13 @@ export function checkTasks( notes: 'Unexpected task response shape', }, ]; + const tasks = parsed.data; const createdAt = [ '2026-11-01 12:00:00', '2026-11-01 12:01:00', '2026-11-01 12:02:00', '2026-11-01 12:03:00', ]; - const tasks = result.data; const titles = [ 'Write release notes', 'Ship task tracker', @@ -98,6 +98,7 @@ const scorer: ToolScorer = async (ctx) => { notes: error instanceof Error ? error.message : String(error), }); } + const report = await ctx.judge({ input: ctx.agentReport ?? '', rubric: diff --git a/packages/core/src/spark-runtime.ts b/packages/core/src/spark-runtime.ts index e7b681bb4..6b1d5a00b 100644 --- a/packages/core/src/spark-runtime.ts +++ b/packages/core/src/spark-runtime.ts @@ -17,20 +17,25 @@ export function sparkRuntime(apiUrl: string): EvalRuntime { async startSession(args) { if (!apiUrl.trim()) throw new Error('Set SPARK_API_URL to the Spark control API URL'); + if (args.logsSeedJsonl || args.functionsSeedDir || args.pgvector) throw new Error( 'Spark REST runtime only supports project.sql fixtures' ); + const seedSql = args.projectSeedSql ? await readFile(args.projectSeedSql, 'utf8') : undefined; - const created = await fetch(new URL('/v1/projects', apiUrl), { + const createResponse = await fetch(new URL('/v1/projects', apiUrl), { method: 'POST', signal: AbortSignal.timeout(30_000), }); - if (!created.ok) - throw new Error(`Spark project creation failed (${created.status})`); - const project = projectSchema.parse(await created.json()); + if (!createResponse.ok) + throw new Error( + `Spark project creation failed (${createResponse.status})` + ); + const project = projectSchema.parse(await createResponse.json()); + const secret = project.api_keys.find( (key) => key.type === 'secret' )?.api_key; @@ -39,13 +44,15 @@ export function sparkRuntime(apiUrl: string): EvalRuntime { )?.api_key; if (!secret || !publishable) throw new Error(`Spark project ${project.ref} is missing API keys`); + // shortcut: projects remain allocated, add teardown once a deletion endpoint is confirmed. process.stderr.write( `Spark project created: ${project.ref} (automatic cleanup unavailable)\n` ); + const headers = { apikey: secret, 'content-type': 'application/json' }; if (seedSql) { - const seeded = await fetch( + const seedResponse = await fetch( new URL('/system/v1/migrations', project.url), { method: 'POST', @@ -58,11 +65,12 @@ export function sparkRuntime(apiUrl: string): EvalRuntime { signal: AbortSignal.timeout(30_000), } ); - if (!seeded.ok) + if (!seedResponse.ok) throw new Error( - `Spark fixture failed (${seeded.status}): ${await seeded.text()}` + `Spark fixture failed (${seedResponse.status}): ${await seedResponse.text()}` ); } + const getClient = () => createClient(project.url, secret, { auth: { persistSession: false, autoRefreshToken: false }, From 9f0dec4c19dfb85681de6a1f2873ce701b9fdcae Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:56:35 -0500 Subject: [PATCH 10/19] docs: clarify Spark management client --- packages/core/src/spark-runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/spark-runtime.ts b/packages/core/src/spark-runtime.ts index 6b1d5a00b..c02efbd33 100644 --- a/packages/core/src/spark-runtime.ts +++ b/packages/core/src/spark-runtime.ts @@ -84,7 +84,7 @@ export function sparkRuntime(apiUrl: string): EvalRuntime { }, scoringContext: { ref: project.ref, - // Spark's management surface is incomplete; REST scorers use client/query. + // Required by ToolScoringContext, but Spark's Management API is incomplete. mgmt: createManagementApiClient(apiUrl, secret), client: getClient(), getClient, From c7409be0ac5f8401024434a4528c6a5a2988907b Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:04:36 -0500 Subject: [PATCH 11/19] test: clarify Spark scorer failures --- apps/framework/harness/spark-scoring.test.ts | 56 +++++++++++--------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/apps/framework/harness/spark-scoring.test.ts b/apps/framework/harness/spark-scoring.test.ts index 8e902f0f8..90eea90ae 100644 --- a/apps/framework/harness/spark-scoring.test.ts +++ b/apps/framework/harness/spark-scoring.test.ts @@ -20,17 +20,29 @@ const tasks = [ describe('task state checks', () => { it('rejects missing dates, altered seed data and deleted rows', () => { - for (const changed of [ - tasks.map((task) => ({ ...task, due_date: null })), - tasks.map((task) => ({ ...task, title: 'Replaced' })), - tasks.map((task) => ({ ...task, created_at: '2026-11-02T00:00:00' })), - tasks.map((task) => ({ ...task, completed: 'false' })), - tasks.slice(1), - [], + for (const [changed, name] of [ + [ + tasks.map((task) => ({ ...task, due_date: null })), + 'Requested due date saved and other tasks undated', + ], + [ + tasks.map((task) => ({ ...task, title: 'Replaced' })), + 'Existing task data preserved', + ], + [ + tasks.map((task) => ({ ...task, created_at: '2026-11-02T00:00:00' })), + 'Existing task data preserved', + ], + [ + tasks.map((task) => ({ ...task, completed: 'false' })), + 'Existing task data preserved', + ], + [tasks.slice(1), 'Existing task data preserved'], + [[], 'Existing task data preserved'], ]) - expect( - checkTasks(changed, ['due_date']).some((check) => !check.passed) - ).toBe(true); + expect(checkTasks(changed, ['due_date'])).toContainEqual( + expect.objectContaining({ name, passed: false }) + ); }); }); @@ -39,7 +51,6 @@ async function score( columns?: Record[]; tasks?: unknown; tableStatus?: number; - queryStatus?: number; reportPasses?: boolean; } = {} ) { @@ -61,7 +72,6 @@ async function score( client, getClient: () => client, query: async () => { - if (options.queryStatus) throw new Error('query unavailable'); return { rows: options.columns ?? [ { name: 'due_date', type: 'date', not_null: 0 }, @@ -119,17 +129,15 @@ describe('hosted task scorer', () => { }); it('records unreadable state and still judges the report', async () => { - for (const options of [{ tableStatus: 404 }, { queryStatus: 503 }]) { - const { result, judge } = await score(options); - expect(result.passed).toBe(false); - expect(result.checks).toContainEqual( - expect.objectContaining({ - name: 'Hosted task state readable', - passed: false, - notes: expect.any(String), - }) - ); - expect(judge).toHaveBeenCalledOnce(); - } + const { result, judge } = await score({ tableStatus: 404 }); + expect(result.passed).toBe(false); + expect(result.checks).toContainEqual( + expect.objectContaining({ + name: 'Hosted task state readable', + passed: false, + notes: expect.any(String), + }) + ); + expect(judge).toHaveBeenCalledOnce(); }); }); From 61e98c93e8cbd86a38dad9b82d333212714a79af Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:09:46 -0500 Subject: [PATCH 12/19] test: colocate Spark scorer tests --- apps/framework/package.json | 3 +- apps/framework/vitest.cli-lib.config.ts | 2 +- .../EVAL.test.ts | 77 ++++++++++--------- 3 files changed, 44 insertions(+), 38 deletions(-) rename apps/framework/harness/spark-scoring.test.ts => evals/spark/build-database-001-task-due-dates/EVAL.test.ts (69%) diff --git a/apps/framework/package.json b/apps/framework/package.json index 2b667f3ac..ee4d4ef65 100644 --- a/apps/framework/package.json +++ b/apps/framework/package.json @@ -10,10 +10,11 @@ "eval:smoke": "node --env-file=../../.env --import tsx/esm harness/run-eval.ts --smoke", "eval:vercel": "node --env-file=../../.env --import tsx/esm scripts/run-vercel-evals.ts", "typecheck": "tsc --noEmit", - "test": "vitest run && pnpm test:framework && pnpm test:cli-lib", + "test": "vitest run && pnpm test:framework && pnpm test:cli-lib && pnpm test:spark", "test:framework": "node --env-file-if-exists=../../.env --import tsx/esm scripts/smoke-framework.ts", "test:vercel-runner": "vitest run scripts/run-vercel-evals.test.ts scripts/export-results.test.ts lib/cli-args.test.ts lib/experiment-files.test.ts lib/sample-sets.test.ts", "test:cli-lib": "vitest run --config vitest.cli-lib.config.ts --passWithNoTests experiments/cli evals/cli", + "test:spark": "vitest run --config vitest.cli-lib.config.ts evals/spark", "export-results": "node --import tsx/esm scripts/export-results.ts", "demo:mcp": "node --env-file=../../.env --import tsx/esm scripts/mcp-demo.ts", "demo:executor": "node --env-file=../../.env --import tsx/esm scripts/executor-demo.ts", diff --git a/apps/framework/vitest.cli-lib.config.ts b/apps/framework/vitest.cli-lib.config.ts index 571d03546..f009d83a7 100644 --- a/apps/framework/vitest.cli-lib.config.ts +++ b/apps/framework/vitest.cli-lib.config.ts @@ -1,3 +1,3 @@ import { defineConfig } from 'vitest/config'; -export default defineConfig({ test: { name: 'cli evals', root: '../..' } }); +export default defineConfig({ test: { name: 'evals', root: '../..' } }); diff --git a/apps/framework/harness/spark-scoring.test.ts b/evals/spark/build-database-001-task-due-dates/EVAL.test.ts similarity index 69% rename from apps/framework/harness/spark-scoring.test.ts rename to evals/spark/build-database-001-task-due-dates/EVAL.test.ts index 90eea90ae..754323d3c 100644 --- a/apps/framework/harness/spark-scoring.test.ts +++ b/evals/spark/build-database-001-task-due-dates/EVAL.test.ts @@ -1,9 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createClient } from '@supabase/supabase-js'; -import { createManagementApiClient } from '@supabase-evals/platform-lite'; -import scorer, { - checkTasks, -} from '../../../evals/spark/build-database-001-task-due-dates/EVAL.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { sparkRuntime } from '@supabase-evals/core'; +import scorer, { checkTasks } from './EVAL.js'; const tasks = [ 'Write release notes', @@ -46,6 +43,8 @@ describe('task state checks', () => { }); }); +afterEach(() => vi.unstubAllGlobals()); + async function score( options: { columns?: Record[]; @@ -54,38 +53,44 @@ async function score( reportPasses?: boolean; } = {} ) { - const client = createClient('https://project.example', 'secret', { - global: { - fetch: async () => - options.tableStatus - ? Response.json( - { message: 'table unavailable' }, - { status: options.tableStatus } - ) - : Response.json(options.tasks ?? tasks), - }, - }); - const judge = vi.fn(async () => ({ passed: options.reportPasses ?? true })); - const result = await scorer({ - ref: 'project', - mgmt: createManagementApiClient('https://control.example', 'secret'), - client, - getClient: () => client, - query: async () => { - return { - rows: options.columns ?? [ - { name: 'due_date', type: 'date', not_null: 0 }, + vi.stubGlobal('fetch', async (input: string | URL | Request) => { + const url = input instanceof Request ? new URL(input.url) : new URL(input); + if (url.pathname === '/v1/projects') + return Response.json({ + ref: 'project', + url: 'https://project.example', + api_keys: [ + { type: 'secret', api_key: 'secret' }, + { type: 'publishable', api_key: 'publishable' }, ], - }; - }, - invokeFunction: async () => { - throw new Error('unused'); - }, - toolCalls: [], - transcript: [], - judge, + }); + if (url.pathname === '/database/query') + return Response.json( + options.columns ?? [{ name: 'due_date', type: 'date', not_null: 0 }] + ); + expect(url.pathname).toBe('/rest/v1/tasks'); + return options.tableStatus + ? Response.json( + { message: 'table unavailable' }, + { status: options.tableStatus } + ) + : Response.json(options.tasks ?? tasks); }); - return { result, judge }; + const session = await sparkRuntime('https://control.example').startSession( + {} + ); + const judge = vi.fn(async () => ({ passed: options.reportPasses ?? true })); + try { + const result = await scorer({ + ...session.scoringContext, + toolCalls: [], + transcript: [], + judge, + }); + return { result, judge }; + } finally { + await session.close(); + } } describe('hosted task scorer', () => { From a3b06afa82b20c7501d064672a479b48a7085849 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:12:43 -0500 Subject: [PATCH 13/19] chore: trim Spark project output --- packages/core/src/spark-runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/spark-runtime.ts b/packages/core/src/spark-runtime.ts index c02efbd33..86a2eb1aa 100644 --- a/packages/core/src/spark-runtime.ts +++ b/packages/core/src/spark-runtime.ts @@ -47,7 +47,7 @@ export function sparkRuntime(apiUrl: string): EvalRuntime { // shortcut: projects remain allocated, add teardown once a deletion endpoint is confirmed. process.stderr.write( - `Spark project created: ${project.ref} (automatic cleanup unavailable)\n` + `Spark project created: ${project.ref}\n` ); const headers = { apikey: secret, 'content-type': 'application/json' }; From 347d16ce0bb067bf6118d3d0d80e1a69334122d6 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:14:11 -0500 Subject: [PATCH 14/19] style: format Spark output --- packages/core/src/spark-runtime.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/spark-runtime.ts b/packages/core/src/spark-runtime.ts index 86a2eb1aa..64d587f32 100644 --- a/packages/core/src/spark-runtime.ts +++ b/packages/core/src/spark-runtime.ts @@ -46,9 +46,7 @@ export function sparkRuntime(apiUrl: string): EvalRuntime { throw new Error(`Spark project ${project.ref} is missing API keys`); // shortcut: projects remain allocated, add teardown once a deletion endpoint is confirmed. - process.stderr.write( - `Spark project created: ${project.ref}\n` - ); + process.stderr.write(`Spark project created: ${project.ref}\n`); const headers = { apikey: secret, 'content-type': 'application/json' }; if (seedSql) { From 26af6cd065e06c80260df3a86b5d2d6077bc1e2d Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:21:55 -0500 Subject: [PATCH 15/19] fix: clarify Spark scenario context --- evals/spark/build-database-001-task-due-dates/PROMPT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evals/spark/build-database-001-task-due-dates/PROMPT.md b/evals/spark/build-database-001-task-due-dates/PROMPT.md index cbc52c3b0..99608fc35 100644 --- a/evals/spark/build-database-001-task-due-dates/PROMPT.md +++ b/evals/spark/build-database-001-task-due-dates/PROMPT.md @@ -9,4 +9,4 @@ topic: motivation: https://linear.app/supabase/issue/AI-1399/add-an-agent-eval-suite-for-spark-rest-api --- -Can you add optional due dates to our task tracker? Set "Ship task tracker" to November 10, 2026 and leave the other tasks undated. Then show me the open tasks with their due dates, soonest first and undated tasks last. +Can you add optional due dates to our task tracker in the provided Spark project? Set "Ship task tracker" to November 10, 2026 and leave the other tasks undated. Then show me the open tasks with their due dates, soonest first and undated tasks last. From 6545171b8b1dbbb67d1288902fb48ac33798438e Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:44:02 -0500 Subject: [PATCH 16/19] chore: assign Spark suite code owners --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f63d16b19..9f56b5e6b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,3 +9,6 @@ /evals/cli/ @supabase/cli /experiments/cli/ @supabase/cli /apps/web/src/data/cli-eval-results.json @supabase/cli + +/evals/spark/ @supabase/spark +/experiments/spark/ @supabase/spark From 1d5b242c3000103ac7575ef03f1f35bbe5917d83 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:46:41 -0500 Subject: [PATCH 17/19] chore: extend Spark code ownership --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9f56b5e6b..404f9762b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -12,3 +12,6 @@ /evals/spark/ @supabase/spark /experiments/spark/ @supabase/spark +/skills/spark/ @supabase/spark +/packages/core/src/spark-runtime.ts @supabase/spark +/packages/core/src/spark-runtime.test.ts @supabase/spark From 8774a278dc546ea7624b0482ad944b780dc48977 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:37:27 -0500 Subject: [PATCH 18/19] refactor: move Spark runtime into suite --- .github/CODEOWNERS | 2 -- apps/framework/package.json | 2 +- .../spark/build-database-001-task-due-dates/EVAL.test.ts | 2 +- experiments/spark/codex-gpt-6-luna.experiment.ts | 3 ++- .../spark/lib/runtime.test.ts | 2 +- .../spark-runtime.ts => experiments/spark/lib/runtime.ts | 4 ++-- package.json | 5 ++++- packages/core/src/index.ts | 1 - pnpm-lock.yaml | 9 +++++++++ 9 files changed, 20 insertions(+), 10 deletions(-) rename packages/core/src/spark-runtime.test.ts => experiments/spark/lib/runtime.test.ts (98%) rename packages/core/src/spark-runtime.ts => experiments/spark/lib/runtime.ts (98%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 404f9762b..19194a394 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,5 +13,3 @@ /evals/spark/ @supabase/spark /experiments/spark/ @supabase/spark /skills/spark/ @supabase/spark -/packages/core/src/spark-runtime.ts @supabase/spark -/packages/core/src/spark-runtime.test.ts @supabase/spark diff --git a/apps/framework/package.json b/apps/framework/package.json index ee4d4ef65..df349fbb4 100644 --- a/apps/framework/package.json +++ b/apps/framework/package.json @@ -14,7 +14,7 @@ "test:framework": "node --env-file-if-exists=../../.env --import tsx/esm scripts/smoke-framework.ts", "test:vercel-runner": "vitest run scripts/run-vercel-evals.test.ts scripts/export-results.test.ts lib/cli-args.test.ts lib/experiment-files.test.ts lib/sample-sets.test.ts", "test:cli-lib": "vitest run --config vitest.cli-lib.config.ts --passWithNoTests experiments/cli evals/cli", - "test:spark": "vitest run --config vitest.cli-lib.config.ts evals/spark", + "test:spark": "vitest run --config vitest.cli-lib.config.ts experiments/spark evals/spark", "export-results": "node --import tsx/esm scripts/export-results.ts", "demo:mcp": "node --env-file=../../.env --import tsx/esm scripts/mcp-demo.ts", "demo:executor": "node --env-file=../../.env --import tsx/esm scripts/executor-demo.ts", diff --git a/evals/spark/build-database-001-task-due-dates/EVAL.test.ts b/evals/spark/build-database-001-task-due-dates/EVAL.test.ts index 754323d3c..6cb39fc06 100644 --- a/evals/spark/build-database-001-task-due-dates/EVAL.test.ts +++ b/evals/spark/build-database-001-task-due-dates/EVAL.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { sparkRuntime } from '@supabase-evals/core'; +import { sparkRuntime } from '../../../experiments/spark/lib/runtime.js'; import scorer, { checkTasks } from './EVAL.js'; const tasks = [ diff --git a/experiments/spark/codex-gpt-6-luna.experiment.ts b/experiments/spark/codex-gpt-6-luna.experiment.ts index 32c688066..3e5c543d3 100644 --- a/experiments/spark/codex-gpt-6-luna.experiment.ts +++ b/experiments/spark/codex-gpt-6-luna.experiment.ts @@ -1,5 +1,6 @@ -import { defineExperiment, sparkRuntime } from '@supabase-evals/core'; +import { defineExperiment } from '@supabase-evals/core'; import { codexGpt6Luna } from '../presets.js'; +import { sparkRuntime } from './lib/runtime.js'; export default defineExperiment({ agent: codexGpt6Luna.agent, diff --git a/packages/core/src/spark-runtime.test.ts b/experiments/spark/lib/runtime.test.ts similarity index 98% rename from packages/core/src/spark-runtime.test.ts rename to experiments/spark/lib/runtime.test.ts index e79165d86..d062dc9d1 100644 --- a/packages/core/src/spark-runtime.test.ts +++ b/experiments/spark/lib/runtime.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, expect, it, vi } from 'vitest'; -import { sparkRuntime } from './spark-runtime.js'; +import { sparkRuntime } from './runtime.js'; afterEach(() => vi.unstubAllGlobals()); diff --git a/packages/core/src/spark-runtime.ts b/experiments/spark/lib/runtime.ts similarity index 98% rename from packages/core/src/spark-runtime.ts rename to experiments/spark/lib/runtime.ts index 64d587f32..239c86872 100644 --- a/packages/core/src/spark-runtime.ts +++ b/experiments/spark/lib/runtime.ts @@ -2,11 +2,11 @@ import { readFile } from 'node:fs/promises'; import { createClient } from '@supabase/supabase-js'; import { createManagementApiClient } from '@supabase-evals/platform-lite'; import { z } from 'zod'; -import type { EvalRuntime } from './index.js'; +import type { EvalRuntime } from '@supabase-evals/core'; const projectSchema = z.object({ ref: z.string().min(1), - url: z.string().url(), + url: z.url(), api_keys: z.array(z.object({ type: z.string(), api_key: z.string().min(1) })), }); const rowsSchema = z.array(z.record(z.string(), z.unknown())); diff --git a/package.json b/package.json index d52bf8a87..fb5834507 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "dependencies": { "@ai-sdk/anthropic": "catalog:", "@ai-sdk/openai": "catalog:", + "@supabase-evals/platform-lite": "workspace:*", + "@supabase/supabase-js": "catalog:", "common-tags": "^1.8.2", "shell-quote": "^1.11.0", "smol-toml": "^1.9.1", @@ -36,7 +38,8 @@ "@types/common-tags": "^1.8.4", "@types/node": "catalog:", "@types/shell-quote": "^1.7.5", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" }, "engines": { "node": "24.x", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 89a665057..e0c66961c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,3 @@ -export { sparkRuntime } from './spark-runtime.js'; import vm from 'node:vm'; import { createRequire } from 'node:module'; import { createHash, createHmac } from 'node:crypto'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c16dc1717..34b1e010f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,12 @@ importers: '@ai-sdk/openai': specifier: 'catalog:' version: 3.0.69(zod@4.6.7) + '@supabase-evals/platform-lite': + specifier: workspace:* + version: link:packages/platform-lite + '@supabase/supabase-js': + specifier: 'catalog:' + version: 2.108.1 common-tags: specifier: ^1.8.2 version: 1.8.2 @@ -105,6 +111,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@22.19.20)(happy-dom@21.0.4)(vite@7.3.7(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)) apps/framework: dependencies: From 88a0fe36f57ece020db8efa7e53fb674cc4b6930 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:51:37 -0500 Subject: [PATCH 19/19] refactor: clarify Spark eval checks --- .../EVAL.test.ts | 74 +++++----- .../build-database-001-task-due-dates/EVAL.ts | 129 ++++++++---------- 2 files changed, 99 insertions(+), 104 deletions(-) diff --git a/evals/spark/build-database-001-task-due-dates/EVAL.test.ts b/evals/spark/build-database-001-task-due-dates/EVAL.test.ts index 6cb39fc06..5a5464f5e 100644 --- a/evals/spark/build-database-001-task-due-dates/EVAL.test.ts +++ b/evals/spark/build-database-001-task-due-dates/EVAL.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { sparkRuntime } from '../../../experiments/spark/lib/runtime.js'; -import scorer, { checkTasks } from './EVAL.js'; +import scorer from './EVAL.js'; const tasks = [ 'Write release notes', @@ -15,34 +15,6 @@ const tasks = [ due_date: index === 1 ? '2026-11-10' : null, })); -describe('task state checks', () => { - it('rejects missing dates, altered seed data and deleted rows', () => { - for (const [changed, name] of [ - [ - tasks.map((task) => ({ ...task, due_date: null })), - 'Requested due date saved and other tasks undated', - ], - [ - tasks.map((task) => ({ ...task, title: 'Replaced' })), - 'Existing task data preserved', - ], - [ - tasks.map((task) => ({ ...task, created_at: '2026-11-02T00:00:00' })), - 'Existing task data preserved', - ], - [ - tasks.map((task) => ({ ...task, completed: 'false' })), - 'Existing task data preserved', - ], - [tasks.slice(1), 'Existing task data preserved'], - [[], 'Existing task data preserved'], - ]) - expect(checkTasks(changed, ['due_date'])).toContainEqual( - expect.objectContaining({ name, passed: false }) - ); - }); -}); - afterEach(() => vi.unstubAllGlobals()); async function score( @@ -93,6 +65,34 @@ async function score( } } +describe('task state checks', () => { + it('rejects missing dates, altered seed data and deleted rows', async () => { + for (const [changed, name] of [ + [ + tasks.map((task) => ({ ...task, due_date: null })), + 'Requested due date saved and other tasks undated', + ], + [ + tasks.map((task) => ({ ...task, title: 'Replaced' })), + 'Existing task data preserved', + ], + [ + tasks.map((task) => ({ ...task, created_at: '2026-11-02T00:00:00' })), + 'Existing task data preserved', + ], + [ + tasks.map((task) => ({ ...task, completed: 'false' })), + 'Existing task data preserved', + ], + [tasks.slice(1), 'Existing task data preserved'], + [[], 'Existing task data preserved'], + ]) + expect((await score({ tasks: changed })).result.checks).toContainEqual( + expect.objectContaining({ name, passed: false }) + ); + }); +}); + describe('hosted task scorer', () => { it('accepts the complete result and rejects an incorrect report', async () => { expect((await score()).result.passed).toBe(true); @@ -136,12 +136,16 @@ describe('hosted task scorer', () => { it('records unreadable state and still judges the report', async () => { const { result, judge } = await score({ tableStatus: 404 }); expect(result.passed).toBe(false); - expect(result.checks).toContainEqual( - expect.objectContaining({ - name: 'Hosted task state readable', - passed: false, - notes: expect.any(String), - }) + expect(result.checks?.map((check) => check.name)).toEqual([ + 'Nullable date column added', + 'Existing task data preserved', + 'Requested due date saved and other tasks undated', + 'Open tasks reported in due-date order', + ]); + expect(result.checks?.slice(0, 3)).toEqual( + Array(3).fill( + expect.objectContaining({ passed: false, notes: expect.any(String) }) + ) ); expect(judge).toHaveBeenCalledOnce(); }); diff --git a/evals/spark/build-database-001-task-due-dates/EVAL.ts b/evals/spark/build-database-001-task-due-dates/EVAL.ts index cd3475788..f7cc4b32b 100644 --- a/evals/spark/build-database-001-task-due-dates/EVAL.ts +++ b/evals/spark/build-database-001-task-due-dates/EVAL.ts @@ -1,4 +1,8 @@ -import type { CheckResult, ToolScorer } from '@supabase-evals/core'; +import type { + CheckResult, + ToolScorer, + ToolScoringContext, +} from '@supabase-evals/core'; import { z } from 'zod'; const tasksSchema = z.array( @@ -12,33 +16,60 @@ const tasksSchema = z.array( .catchall(z.unknown()) ); -export function checkTasks( - value: unknown, - dueDateColumns: unknown[] -): CheckResult[] { - const parsed = tasksSchema.safeParse(value); - if (!parsed.success) - return [ - { - name: 'Existing task data preserved', - passed: false, - notes: 'Unexpected task response shape', - }, - ]; - const tasks = parsed.data; - const createdAt = [ - '2026-11-01 12:00:00', - '2026-11-01 12:01:00', - '2026-11-01 12:02:00', - '2026-11-01 12:03:00', - ]; +async function readTaskState(ctx: ToolScoringContext) { + try { + const { rows: addedColumns } = await ctx.query( + `select name, type, "notnull" as not_null from pragma_table_info('tasks') where name not in ('id', 'title', 'completed', 'created_at')` + ); + const { data: rawTasks, error: taskReadError } = await ctx.client + .from('tasks') + .select('*') + .order('id'); + if (taskReadError) throw new Error(taskReadError.message); + const tasks = tasksSchema.parse(rawTasks); + const dateColumns = addedColumns.flatMap((column) => + typeof column.name === 'string' && + typeof column.type === 'string' && + column.type.toLowerCase() === 'date' && + column.not_null === 0 + ? [column.name] + : [] + ); + return { tasks, dateColumns, error: undefined }; + } catch (error) { + return { + tasks: [], + dateColumns: [], + error: error instanceof Error ? error.message : String(error), + }; + } +} + +const scorer: ToolScorer = async (ctx) => { + const { tasks, dateColumns, error } = await readTaskState(ctx); const titles = [ 'Write release notes', 'Ship task tracker', 'Review schema', 'Plan next iteration', ]; - return [ + const createdAt = [ + '2026-11-01 12:00:00', + '2026-11-01 12:01:00', + '2026-11-01 12:02:00', + '2026-11-01 12:03:00', + ]; + const report = await ctx.judge({ + input: ctx.agentReport ?? '', + rubric: + 'The response must list exactly these open tasks with their due dates: Ship task tracker, November 10, 2026; Write release notes, no due date; Plan next iteration, no due date. Ship task tracker must appear before the undated tasks. Either order among the undated tasks is valid. Review schema is complete and must not appear as an open task. Task IDs are optional. Accept equivalent date formatting and descriptions of an absent date.', + }); + const checks: CheckResult[] = [ + { + name: 'Nullable date column added', + passed: dateColumns.length > 0, + notes: error, + }, { name: 'Existing task data preserved', passed: @@ -50,61 +81,21 @@ export function checkTasks( task.completed === (task.id === 3) && task.created_at === createdAt[index] ), + notes: error, }, { name: 'Requested due date saved and other tasks undated', passed: tasks.length === titles.length && - dueDateColumns.some( - (column) => - typeof column === 'string' && - tasks.every( - (task) => task[column] === (task.id === 2 ? '2026-11-10' : null) - ) + dateColumns.some((column) => + tasks.every( + (task) => task[column] === (task.id === 2 ? '2026-11-10' : null) + ) ), + notes: error, }, + { name: 'Open tasks reported in due-date order', ...report }, ]; -} - -const scorer: ToolScorer = async (ctx) => { - const checks: CheckResult[] = []; - try { - const { rows: columns } = await ctx.query( - `select name, type, "notnull" as not_null from pragma_table_info('tasks') where name not in ('id', 'title', 'completed', 'created_at')` - ); - const { data, error } = await ctx.client - .from('tasks') - .select('*') - .order('id'); - if (error) throw new Error(error.message); - const dateColumns = columns.filter( - (column) => - typeof column.name === 'string' && - typeof column.type === 'string' && - column.type.toLowerCase() === 'date' && - column.not_null === 0 - ); - checks.push( - { name: 'Nullable date column added', passed: dateColumns.length > 0 }, - ...checkTasks( - data, - dateColumns.map((column) => column.name) - ) - ); - } catch (error) { - checks.push({ - name: 'Hosted task state readable', - passed: false, - notes: error instanceof Error ? error.message : String(error), - }); - } - - const report = await ctx.judge({ - input: ctx.agentReport ?? '', - rubric: - 'The response must list exactly these open tasks with their due dates: Ship task tracker, November 10, 2026; Write release notes, no due date; Plan next iteration, no due date. Ship task tracker must appear before the undated tasks. Either order among the undated tasks is valid. Review schema is complete and must not appear as an open task. Task IDs are optional. Accept equivalent date formatting and descriptions of an absent date.', - }); - checks.push({ name: 'Open tasks reported in due-date order', ...report }); return { passed: checks.every((check) => check.passed), checks }; };