diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f1fbb46..b44e09de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,9 @@ jobs: - name: Migrate ASAREE schema run: uv run python -m asaree.migrations upgrade --url "$ASAREE_PRODUCT_DATABASE_URL" + - name: Seed row-test users + run: uv run python scripts/seed_row_test_users.py + # The whole suite, on purpose: 769 tests in ~13s locally, so there is no # slow tier worth splitting out. - name: Pytest diff --git a/.gitignore b/.gitignore index 558ed264..401dfe9f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ sdk/src/asaree_client/_version.py # Private local planning and agent guidance docs/ issues/ +tasks/ diff --git a/CONTEXT.md b/CONTEXT.md index eb9c5554..2f5271a2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,6 +12,10 @@ factor-combination cell and compares the recorded outcomes. A factorial study comparing agent protocol conditions. Owns the design declaration, the protocol canvas, and all runs. _Avoid_: Project, study, trial +**Experiment version**: +A published snapshot of an experiment's canvas, design settings, and measurement configuration. Runs and results belong to the version that produced them; subsequent edits form the experiment's draft. +_Avoid_: Canvas version, independent design/publication versions + **Factor**: A declared experimental variable with discrete levels, bound to at least one canvas field before cells can run. _Avoid_: Parameter, variable @@ -21,7 +25,10 @@ One unique factor combination together with all its planned replicates. _Avoid_: Condition, treatment (accepted in prose, never in code) **Replicate**: -One run of one cell. Counts of pending/run/scored are replicate counts, never cell counts. +One execution unit planned under a cell. A row-mode parent Replicate owns one Row execution per original row; counts of pending/run/scored are replicate counts, never cell or row counts. Rows are neither factors nor extra stochastic replicates. + +**Row execution**: +One complete protocol execution for a (cell, original dataset row, replicate) tuple. **Primary metric**: The optional declared metric Results uses to rank cells. An editable experiment may have none; when declared, it must be unique. @@ -105,7 +112,7 @@ The draft editable graph. Production runs never read it directly. _Avoid_: Workflow, pipeline **Published revision**: -The immutable protocol snapshot a run executes; created by publishing the canvas. +The immutable published snapshot a run executes; for an experiment it includes the canvas and experiment settings together. _Avoid_: Protocol version **Gated pair**: @@ -113,4 +120,4 @@ An agent node with its critic gate. `approved` and `revisions_used` are recorded _Avoid_: Reviewer, supervisor **Attempt**: -One ProtocolRun for a replicate; later attempts supersede earlier ones but every attempt's own facts stay immutable. +One ProtocolRun under either a whole-dataset Replicate or a stable row slot; later attempts supersede earlier ones but every attempt's own facts stay immutable. A run may also retain a nullable dataset-row snapshot when no row slot exists, as for previews. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cd8ee2c5..ea31d26c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -29,7 +29,7 @@ import type { import type { LLMConnectionCheck, LLMProvider, LLMSetting, LLMSettingModelsResponse } from '@/types/llmSettings' import type { McpOAuthAuthorization, McpServer } from '@/types/mcpServers' import type { OkfBundle, OkfDocument } from '@/types/okf' -import type { CellRunBatch, PromptPreview, Protocol, ProtocolGraph, ProtocolRevision, ProtocolRun, TestRun } from '@/types/protocols' +import type { CellRunBatch, DatasetRowSchema, PromptPreview, Protocol, ProtocolGraph, ProtocolRevision, ProtocolRun, TestRun } from '@/types/protocols' import type { Run, RunStep } from '@/types/runs' import type { Skill, SkillListResponse, SkillUrlPreview } from '@/types/skills' @@ -181,6 +181,28 @@ export const tokenApi = { revoke: (id: string) => request(`/auth/me/tokens/${id}`, { method: 'DELETE' }), } +export interface ResultsScope { + protocol_id?: string + design_revision_id?: string + protocol_revision_id?: string +} + +export interface RowSelection { row_index?: number | null } + +function scopeQuery(scope?: ResultsScope): string { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(scope ?? {})) if (value !== undefined) params.set(key, value) + return params.size ? `?${params}` : '' +} + +function rowSelection(options?: RowSelection): RowSelection | undefined { + const index = options?.row_index + if (index !== undefined && index !== null && (!Number.isInteger(index) || index < 0)) { + throw new RangeError('Source row must be a nonnegative integer') + } + return options +} + export const experimentsApi = { list: (opts?: { includeArchived?: boolean }) => request(opts?.includeArchived ? '/experiments?include_archived=true' : '/experiments'), @@ -255,7 +277,8 @@ export const experimentsApi = { // anywhere in the experiment (see services.csv_export.replicates_to_csv) -- // a Blob, not JSON, so callers hand it straight to URL.createObjectURL. downloadReplicatesCsv: (id: string) => requestBlob(`/experiments/${id}/replicates.csv`), - downloadRunResultsCsv: (id: string) => requestBlob(`/experiments/${id}/run-results.csv`), + downloadRunResultsCsv: (id: string, scope?: ResultsScope) => requestBlob(`/experiments/${id}/run-results.csv${scopeQuery(scope)}`), + getRunResultsSchema: (id: string, scope?: ResultsScope) => request>(`/experiments/${id}/run-results.schema.json${scopeQuery(scope)}`), // Materializes one cell per combination and its replicate-result children. // declared factors, returning the current design's replicates. If the new design // isn't the same set of cells as the current one, the current design @@ -275,7 +298,7 @@ export const experimentsApi = { getResults: (id: string) => request(`/experiments/${id}/results`), // A current-design scorecard and the per-cell/per-replicate evidence behind // it. Unlike getResults(), no balanced-factorial assumptions are required. - getRunResults: (id: string) => request(`/experiments/${id}/run-results`), + getRunResults: (id: string, scope?: ResultsScope) => request(`/experiments/${id}/run-results${scopeQuery(scope)}`), } export const protocolsApi = { @@ -286,27 +309,29 @@ export const protocolsApi = { request(experimentId ? `/protocols?experiment_id=${experimentId}` : '/protocols'), update: (id: string, data: { name?: string; description?: string | null; graph?: ProtocolGraph }) => request(`/protocols/${id}`, { method: 'PATCH', body: data }), - publish: (id: string) => request(`/protocols/${id}/publish`, { method: 'POST' }), + publish: (id: string, annotations?: { name?: string | null; note?: string | null }) => request(`/protocols/${id}/publish`, { method: 'POST', body: annotations }), remove: (id: string) => request(`/protocols/${id}`, { method: 'DELETE' }), // 422 if the graph is empty or has a cycle -- returns immediately with // status "pending"; poll getRun for progress. cellLabel runs that one // already-generated cell for real (its own factor_values substituted in) // instead of today's ad-hoc, un-substituted whole-graph run. - run: (id: string, cellLabel?: string | null) => - request(`/protocols/${id}/runs`, { method: 'POST', body: { replicate_label: cellLabel ?? null } }), - testRun: (id: string) => request(`/protocols/${id}/test-runs`, { method: 'POST' }), + run: (id: string, cellLabel?: string | null, options?: RowSelection) => + request(`/protocols/${id}/runs`, { method: 'POST', body: { replicate_label: cellLabel ?? null, ...rowSelection(options) } }), + testRun: (id: string, options?: RowSelection) => request(`/protocols/${id}/test-runs`, { method: 'POST', body: rowSelection(options) }), getLatestTestRun: (id: string) => request(`/protocols/${id}/test-runs/latest`), // The per-node Play icon -- 422 if the node has upstream input or isn't a // runnable Agent (see validate_single_node_runnable). Same polling shape // as a plain run (getRun), just with node_runs carrying only this one key. - runNode: (id: string, nodeId: string) => request(`/protocols/${id}/nodes/${nodeId}/run`, { method: 'POST' }), + runNode: (id: string, nodeId: string, options?: RowSelection) => request(`/protocols/${id}/nodes/${nodeId}/run`, { method: 'POST', body: rowSelection(options) }), // Read-only despite the POST: the graph goes in the body because the canvas // being previewed is the one on screen, including edits autosave hasn't // flushed yet. Creates no run of any kind. 422 for a node that isn't an // agent, since only an agent is ever given a prompt. - promptPreview: (id: string, nodeId: string, graph: ProtocolGraph) => - request(`/protocols/${id}/nodes/${nodeId}/prompt-preview`, { method: 'POST', body: { graph } }), + promptPreview: (id: string, nodeId: string, graph: ProtocolGraph, options?: RowSelection) => + request(`/protocols/${id}/nodes/${nodeId}/prompt-preview`, { method: 'POST', body: { graph, ...rowSelection(options) } }), + listRevisions: (id: string) => request(`/protocols/${id}/revisions`), getRevision: (id: string, revisionId: string) => request(`/protocols/${id}/revisions/${revisionId}`), + updateRevision: (id: string, revisionId: string, annotations: { name?: string | null; note?: string | null }) => request(`/protocols/${id}/revisions/${revisionId}`, { method: 'PATCH', body: annotations }), getRun: (id: string, runId: string) => request(`/protocols/${id}/runs/${runId}`), // Queued work is cancelled immediately. Active work raises // cancel_requested_at, which its executor honors at a safe interruption @@ -316,19 +341,21 @@ export const protocolsApi = { // "Run all cells" -- 422 if there's no linked experiment or the graph // doesn't have exactly one final node; fans out one ProtocolRun per pending // replicate. The optional list explicitly re-runs selected completed rows. - runCells: (id: string, options?: { replicateLabels?: string[]; rerunReplicateLabels?: string[] }) => + runCells: (id: string, options?: { replicateLabels?: string[]; rerunReplicateLabels?: string[]; retry_row_result_ids?: string[] }) => request(`/protocols/${id}/cell-runs`, { method: 'POST', body: options ? { replicate_labels: options.replicateLabels, - rerun_replicate_labels: options.rerunReplicateLabels ?? [], + rerun_replicate_labels: options.retry_row_result_ids ? undefined : options.rerunReplicateLabels ?? [], + retry_row_result_ids: options.retry_row_result_ids, } : undefined, }), } export const datasetsApi = { + getRowSchema: (id: string) => request(`/datasets/${id}/row-schema`), // Owner-scoped, same convention as mcpServersApi.list -- backs the canvas's // dataset browser (DatasetBrowserPanel) and the Dataset node inspector's // read-out of whichever dataset the node is bound to. diff --git a/frontend/src/api/datasetRows.test.ts b/frontend/src/api/datasetRows.test.ts new file mode 100644 index 00000000..d2271cda --- /dev/null +++ b/frontend/src/api/datasetRows.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { datasetsApi, experimentsApi, protocolsApi } from './client' + +const fetchMock = vi.fn() +beforeEach(() => { + localStorage.clear() + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockImplementation(async () => new Response(JSON.stringify({ dataset_row: null, row_result_id: null }), { status: 200 })) +}) +afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() }) +const lastBody = () => { + const body = fetchMock.mock.calls.at(-1)?.[1]?.body + return body === undefined ? undefined : JSON.parse(body) +} +describe('row HTTP contracts', () => { + it('retains omitted legacy bodies and explicit null', async () => { + await protocolsApi.testRun('p') + expect(lastBody()).toBeUndefined() + await protocolsApi.runNode('p', 'a') + expect(lastBody()).toBeUndefined() + await protocolsApi.testRun('p', { row_index: null }) + expect(lastBody()).toEqual({ row_index: null }) + }) + it('sends zero-based selections on each execution interface', async () => { + await protocolsApi.testRun('p', { row_index: 2 }) + expect(lastBody()).toEqual({ row_index: 2 }) + await protocolsApi.runNode('p', 'a', { row_index: 2 }) + expect(lastBody()).toEqual({ row_index: 2 }) + await protocolsApi.run('p', null, { row_index: 2 }) + expect(lastBody()).toEqual({ replicate_label: null, row_index: 2 }) + const graph = { nodes: [], edges: [] } + await protocolsApi.promptPreview('p', 'a', graph, { row_index: 2 }) + expect(lastBody()).toEqual({ graph, row_index: 2 }) + expect(() => protocolsApi.testRun('p', { row_index: -1 })).toThrow(RangeError) + }) + it('retries exactly one row without a replicate rerun payload', async () => { + await protocolsApi.runCells('p', { retry_row_result_ids: ['slot'] }) + expect(lastBody()).toEqual({ retry_row_result_ids: ['slot'] }) + }) + it('uses identical encoded scope on projection and exports', async () => { + const scope = { protocol_id: 'p /', design_revision_id: 'd', protocol_revision_id: 'r' } + await experimentsApi.getRunResults('e', scope) + const query = fetchMock.mock.calls.at(-1)?.[0].split('?')[1] + await experimentsApi.downloadRunResultsCsv('e', scope) + expect(fetchMock.mock.calls.at(-1)?.[0].split('?')[1]).toBe(query) + await experimentsApi.getRunResultsSchema('e', scope) + expect(fetchMock.mock.calls.at(-1)?.[0].split('?')[1]).toBe(query) + }) + it('fetches row metadata and preserves returned identities', async () => { + const schema = { dataset_id: 'id', raw_sha256: 'hash', columns: ['question'], row_count: 3 } + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(schema))) + expect(await datasetsApi.getRowSchema('id')).toEqual(schema) + expect(fetchMock.mock.calls.at(-1)?.[0]).toContain('/datasets/id/row-schema') + expect(await protocolsApi.testRun('p')).toMatchObject({ dataset_row: null, row_result_id: null }) + }) +}) diff --git a/frontend/src/api/versionAnnotations.test.ts b/frontend/src/api/versionAnnotations.test.ts new file mode 100644 index 00000000..b64e2895 --- /dev/null +++ b/frontend/src/api/versionAnnotations.test.ts @@ -0,0 +1,22 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { protocolsApi } from './client' + +afterEach(() => vi.unstubAllGlobals()) + +it.each(['publish', 'updateRevision'] as const)('sends %s annotations as a JSON object accepted by the API', async operation => { + const annotations = { name: 'Baseline', note: 'Keep for comparison' } + const fetch = vi.fn(async (_url: string, options: RequestInit) => { + const body: unknown = JSON.parse(options.body as string) + // FastAPI's VersionAnnotationsRequest rejects a JSON string with 422. + if (typeof body !== 'object' || body === null) { + return new Response(JSON.stringify({ detail: [{ msg: 'Input should be a valid dictionary' }] }), { status: 422 }) + } + return new Response(JSON.stringify({ id: 'p', ...body }), { status: 200 }) + }) + vi.stubGlobal('fetch', fetch) + const result = operation === 'publish' + ? protocolsApi.publish('p', annotations) + : protocolsApi.updateRevision('p', 'v1', annotations) + await expect(result).resolves.toMatchObject({ id: 'p', ...annotations }) + expect(JSON.parse(fetch.mock.calls[0][1].body as string)).toEqual(annotations) +}) diff --git a/frontend/src/components/protocol/AgentNodeInspector.tsx b/frontend/src/components/protocol/AgentNodeInspector.tsx index 7778acd1..f1d5babe 100644 --- a/frontend/src/components/protocol/AgentNodeInspector.tsx +++ b/frontend/src/components/protocol/AgentNodeInspector.tsx @@ -67,6 +67,7 @@ export function AgentNodeInspector({ handoffPeers, wiredOutputParserLabel, fetchPromptPreview, + previewGraph, nodeRun, onChange, onDelete, @@ -90,7 +91,8 @@ export function AgentNodeInspector({ // Assembles the real prompt server-side against the live canvas. A callback // rather than an id pair because the graph it posts is the unsaved one on // screen, which only ProtocolCanvas holds. - fetchPromptPreview: (nodeId: string) => Promise + previewGraph?: import('@/types/protocols').ProtocolGraph + fetchPromptPreview: (nodeId: string, rowIndex?: number) => Promise nodeRun?: NodeRunState onChange: (nodeId: string, data: AgentNodeData) => void onDelete: (nodeId: string) => void @@ -228,8 +230,10 @@ export function AgentNodeInspector({ // The panel holds the last text it assembled; on a node switch // that text describes the previous node, so it starts over. key={node.id} - signature={JSON.stringify(data)} - fetchPreview={() => fetchPromptPreview(node.id)} + signature={JSON.stringify({ data, graph: previewGraph })} + graph={previewGraph} + nodeId={node.id} + fetchPreview={rowIndex => fetchPromptPreview(node.id, rowIndex)} /> {/* Below the design-time preview, because it supersedes it: a placeholder proves nothing about a run that actually happened. */} diff --git a/frontend/src/components/protocol/CellPresentation.tsx b/frontend/src/components/protocol/CellPresentation.tsx new file mode 100644 index 00000000..3312c0eb --- /dev/null +++ b/frontend/src/components/protocol/CellPresentation.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from 'react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { ChevronDown, ExternalLink } from 'lucide-react' + +/** Both execution modes use this layout; callers supply their progress and actions. */ +export function CellCard({ label, conditions, replicateCount, expanded, onToggle, progress, badges, actions, metric, children, results = false, listId, toggleLabel }: { + label: string; conditions: string[]; replicateCount: number; expanded: boolean; onToggle: () => void + progress?: ReactNode; badges?: ReactNode; actions?: ReactNode; metric?: ReactNode; children: ReactNode + results?: boolean; listId: string; toggleLabel?: string +}) { + const summary = (results ? conditions : conditions.slice(0, 2)).join(' · ') || (results ? 'No varying factors' : 'Cell') + return +
+ + {actions &&
{actions}
} +
+ {expanded &&
{children}
} +
+} + +export function ReplicateRow({ number, detail, badges, status, actions, onView, viewLabel = 'View result', viewAriaLabel, results = false, error }: { + number: number; detail?: ReactNode; badges?: ReactNode; status?: ReactNode; actions?: ReactNode + onView?: () => void; viewLabel?: string; viewAriaLabel?: string; results?: boolean; error?: string | null +}) { + const content = <>

Replicate {number}

{badges}
{detail &&

{detail}

}
+
{status}{results ? onView && : <>{onView && }{actions}}
+ if (results) return
  • + return
    {content}
    {error &&

    {error}

    }
    +} + +export function CellFrame({ children, results = false, expanded = false }: { children: ReactNode; results?: boolean; expanded?: boolean }) { + return
    {children}
    +} + +export function ReplicateFrame({ children }: { children: ReactNode }) { + return
  • {children}
  • +} + +export function ReplicateStatus({ status }: { status: string }) { + const classes = status === 'completed' ? 'bg-[color:var(--chart-3)]/10 text-[color:var(--chart-3)]' : status === 'failed' ? 'bg-destructive/10 text-destructive' : ['running', 'queued', 'pending', 'finalizing'].includes(status) ? 'bg-[color:var(--primary)]/10 text-[color:var(--primary)]' : 'bg-muted text-muted-foreground' + const label = status === 'pending' ? 'Queued' : status === 'not_started' ? 'Not started' : status.replaceAll('_', ' ').replace(/^./, letter => letter.toUpperCase()) + return {label} +} + +export function CellRunAction({ children, onClick, disabled, label }: { children: ReactNode; onClick: () => void; disabled?: boolean; label?: string }) { + return +} + +export function ReplicateRunAction({ children, onClick, disabled, label, title }: { children: ReactNode; onClick: () => void; disabled?: boolean; label?: string; title?: string }) { + return +} diff --git a/frontend/src/components/protocol/DatasetBrowserPanel.tsx b/frontend/src/components/protocol/DatasetBrowserPanel.tsx index 3cdbf7db..030ba63f 100644 --- a/frontend/src/components/protocol/DatasetBrowserPanel.tsx +++ b/frontend/src/components/protocol/DatasetBrowserPanel.tsx @@ -64,8 +64,8 @@ export function DatasetBrowserPanel({ ) return ( -
    -
    +
    +
    - setQuery(e.target.value)} /> + setQuery(e.target.value)} /> {/* Outside every branch below, same as SkillBrowserPanel's: an empty or failed list is exactly when you most need the way to add one. */} {cells.length}
    +
    {cells.slice(currentPage * 20, (currentPage + 1) * 20).map(cell => { + const rows = (results.row_results ?? []).filter(row => row.cell_id === cell.cell_id) + const rowCount = results.row_summary?.row_count + const expected = rowCount == null ? null : rowCount * cell.replicate_count + const completed = rows.filter(row => row.status === 'completed').length + const failed = rows.filter(row => row.status === 'failed').length + const expanded = expandedCells.has(cell.cell_id) + const replicates = cell.replicates ?? [...new Map(rows.map(row => [row.replicate_result_id, { + replicate_result_id: row.replicate_result_id, replicate_label: row.replicate_label, + replicate_number: row.replicate_number, + }])).values()] + const orderedReplicates = [...replicates].sort((a, b) => a.replicate_number - b.replicate_number) + const labels = orderedReplicates.map(replicate => replicate.replicate_label).filter(Boolean) + const rerunLabels = orderedReplicates.filter(replicate => rows.some(row => row.replicate_result_id === replicate.replicate_result_id && ['completed', 'failed', 'cancelled', 'limit_reached'].includes(row.status))).map(replicate => replicate.replicate_label) + const activeRuns = rows.filter(row => ['pending', 'running', 'finalizing'].includes(row.status) && row.latest_attempt).map(row => row.latest_attempt!.run_id) + const conditions = Object.entries(cell.factor_values).map(([name, value]) => displayFactorCondition(name, value, designSpec, cell.cell_label)) + return { setExpandedCells(current => { const next = new Set(current); if (next.has(cell.cell_id)) next.delete(cell.cell_id); else next.add(cell.cell_id); return next }); onSelect(expanded ? '' : cell.cell_id) }} + progress={<>{completed}/{expected ?? '?'} row executions complete · {rows.length} planned} + badges={failed > 0 && {failed} failed} + actions={<>{resultView && }{onRun && onRun(labels, 1, rerunLabels)} label={`Run all replicates in ${cell.cell_label}`}>{rerunLabels.length === labels.length && labels.length ? 'Re-run all replicates' : 'Run all replicates'}}{onStop?.(activeRuns)}}> + {resultView &&

    Individual replicates

    Select one for full output

    } + {!orderedReplicates.length &&

    Replicate details unavailable. Refresh after updating the server.

    } +
      {orderedReplicates.map(replicate => { + const replicateRows = rows.filter(row => row.replicate_result_id === replicate.replicate_result_id) + const hasAttempt = replicateRows.some(row => Boolean(row.latest_attempt?.run_id)) + const done = replicateRows.filter(row => row.status === 'completed').length + const active = replicateRows.filter(row => ['pending', 'running', 'finalizing'].includes(row.status)).length + const errors = replicateRows.filter(row => ['failed', 'cancelled', 'limit_reached'].includes(row.status)).length + const finished = rowCount != null && done === rowCount + return {done}/{rowCount ?? '?'} row executions complete{active > 0 && ` · ${active} active`}{errors > 0 && ` · ${errors} failed`}} + status={} + onView={hasAttempt && onSelectReplicate ? () => onSelectReplicate(cell.cell_id, replicate.replicate_result_id) : undefined} + viewAriaLabel={resultView ? `View ${cell.cell_label} replicate ${replicate.replicate_number}` : undefined} + viewLabel="View results" + actions={<>{onRun && 0 || !replicate.replicate_label} onClick={() => onRun([replicate.replicate_label], 1, done > 0 || errors > 0 ? [replicate.replicate_label] : [])} label={`Run ${cell.cell_label} replicate ${replicate.replicate_number}`}>{done > 0 || errors > 0 ? 'Re-run' : 'Run'}}{onStop?.(replicateRows.filter(row => ['pending', 'running', 'finalizing'].includes(row.status) && row.latest_attempt).map(row => row.latest_attempt!.run_id))}} /> + })}
    +
    + })}
    + {cells.length > 20 &&
    {currentPage + 1}/{Math.ceil(cells.length / 20)}
    } + +} diff --git a/frontend/src/components/protocol/DatasetRowHistory.test.tsx b/frontend/src/components/protocol/DatasetRowHistory.test.tsx new file mode 100644 index 00000000..86660bfb --- /dev/null +++ b/frontend/src/components/protocol/DatasetRowHistory.test.tsx @@ -0,0 +1,28 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { experimentsApi } from '@/api/client' +import type { Experiment, ExperimentRunResults } from '@/types/experiments' +import { ResultsInspectorPanel, ResultsTab } from './ResultsTab' +afterEach(() => vi.restoreAllMocks()) +vi.mock('./NodeRunOutputPanel', () => ({ RunStepTrace: ({ runId }: { runId: string }) =>

    Trace {runId}

    , ReceivedPromptPanel: () => null, UnresolvedReferencesNote: () => null })) +it('preserves failed attempt output while inspecting the latest row', async () => { + const row = { row_result_id: 'slot', replicate_number: 1, dataset_row: { dataset_id: 'dataset-uuid', raw_sha256: 'original-hash', row_index: 2 }, design_revision_id: 'design', protocol_revision_id: 'publication', latest_attempt: { run_id: 'B' }, attempts: [{ run_id: 'A', status: 'failed', current: false, error: 'old failure', node_runs: { agent: { run_id: 'trace-A', output_text: 'old output' } }, attempt_result: {} }, { run_id: 'B', status: 'completed', current: true, node_runs: { agent: { run_id: 'trace-B', output_text: 'new output' } }, attempt_result: {} }] } + vi.spyOn(experimentsApi, 'getRunResults').mockResolvedValue({ row_results: [row] } as unknown as ExperimentRunResults) + const scope = { protocol_id: 'p', design_revision_id: 'design', protocol_revision_id: 'publication' } + render() + await screen.findByText('new output') + expect(screen.getByText('original-hash')).toBeInTheDocument() + fireEvent.change(screen.getByRole('combobox', { name: 'Inspect attempt' }), { target: { value: 'A' } }) + expect(screen.getByText('old output')).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('old failure') + expect(screen.getByText('Trace trace-A')).toBeInTheDocument() + expect(experimentsApi.getRunResults).toHaveBeenCalledWith('e', scope) +}) + +it('a selected experiment version requests one publication scope without independent design controls', async () => { + const results = vi.spyOn(experimentsApi, 'getRunResults').mockResolvedValue({ consumption_mode: 'per_row', row_results: [], row_summary: null } as unknown as ExperimentRunResults) + render() + await waitFor(() => expect(results).toHaveBeenLastCalledWith('e', { protocol_id: 'p', protocol_revision_id: 'publication-old' })) + expect(screen.queryByRole('combobox', { name: 'Results design revision' })).not.toBeInTheDocument() +}) diff --git a/frontend/src/components/protocol/DatasetRowPlay.test.tsx b/frontend/src/components/protocol/DatasetRowPlay.test.tsx new file mode 100644 index 00000000..50e28992 --- /dev/null +++ b/frontend/src/components/protocol/DatasetRowPlay.test.tsx @@ -0,0 +1,46 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactFlowProvider } from '@xyflow/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { datasetsApi, protocolsApi } from '@/api/client' +import { defaultAgentNodeData, defaultAnthropicModelNodeData, defaultDatasetNodeData, defaultReasonActPatternNodeData, type ProtocolGraph } from '@/types/protocols' +import { ProtocolCanvas } from './ProtocolCanvas' + +vi.mock('./PythonCodeEditor', () => ({ PythonCodeEditor: () => null })) +const graph: ProtocolGraph = { nodes: [ + { id: 'a', type: 'agent', position: { x: 100, y: 100 }, data: defaultAgentNodeData('Prediction') }, + { id: 'm', type: 'model_anthropic', position: { x: 0, y: 0 }, data: defaultAnthropicModelNodeData() }, + { id: 'd', type: 'dataset', position: { x: 0, y: 200 }, data: { ...defaultDatasetNodeData(), config: { ...defaultDatasetNodeData().config, dataset_id: '11111111-1111-4111-8111-111111111111', dataset_name: 'original' } } }, + { id: 'pattern', type: 'reason_act', position: { x: 0, y: 300 }, data: defaultReasonActPatternNodeData() }, + ], edges: [ { id: 'model', source: 'm', target: 'a', targetHandle: 'model' }, { id: 'pattern', source: 'pattern', target: 'a', targetHandle: 'architectural_pattern' }, { id: 'dataset', source: 'd', target: 'a', targetHandle: 'dataset', data: { dataset_input: { mode: 'per_row', columns: ['question'] } } } ] } +function setup() { + vi.stubGlobal('DOMMatrixReadOnly', class { m22 = 1 }) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 800, width: 1000, height: 800, toJSON: () => ({}) }) + vi.stubGlobal('ResizeObserver', class { private callback: ResizeObserverCallback; constructor(callback: ResizeObserverCallback) { this.callback = callback } observe(target: Element) { this.callback([{ target, contentRect: target.getBoundingClientRect() } as ResizeObserverEntry], this as unknown as ResizeObserver) } unobserve() {} disconnect() {} }) + vi.spyOn(protocolsApi, 'get').mockResolvedValue({ id: 'p', published_revision_id: 'r' } as never) + vi.spyOn(protocolsApi, 'getRevision').mockResolvedValue({ id: 'r', graph } as never) + vi.spyOn(datasetsApi, 'getRowSchema').mockResolvedValue({ dataset_id: 'd', raw_sha256: 'hash', row_count: 3, columns: ['question','reference'] }) + vi.spyOn(protocolsApi, 'listRuns').mockResolvedValue([]) + vi.spyOn(protocolsApi, 'getLatestTestRun').mockRejectedValue(new Error('No test')) + vi.spyOn(protocolsApi, 'update').mockResolvedValue({} as never) +} +beforeEach(setup) +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) +function mount() { + render(
    ) +} +it('node hover Play pins selected row and submits exactly one node request', async () => { + const play = vi.spyOn(protocolsApi, 'runNode').mockRejectedValue(new Error('mock queue')) + const testRun = vi.spyOn(protocolsApi, 'testRun') + mount() + const button = document.querySelector('button[aria-label^="Run this node"]') + expect(button).not.toBeNull() + fireEvent.click(button!) + const selector = await screen.findByRole('spinbutton', { name: 'Source row' }) + await waitFor(() => expect(selector).not.toBeDisabled()) + fireEvent.change(selector, { target: { value: '3' } }) + fireEvent.click(screen.getByRole('button', { name: /Run anyway|Confirm & run/ })) + await waitFor(() => expect(play).toHaveBeenCalledWith('p', 'a', { row_index: 2 })) + expect(testRun).not.toHaveBeenCalled() +}) diff --git a/frontend/src/components/protocol/DatasetRowPromptPreview.test.tsx b/frontend/src/components/protocol/DatasetRowPromptPreview.test.tsx new file mode 100644 index 00000000..e4f03334 --- /dev/null +++ b/frontend/src/components/protocol/DatasetRowPromptPreview.test.tsx @@ -0,0 +1,22 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { datasetsApi, protocolsApi } from '@/api/client' +import { defaultAgentNodeData, defaultDatasetNodeData, type ProtocolGraph } from '@/types/protocols' +import { PromptPreviewPanel } from './PromptPreviewPanel' + +afterEach(() => vi.restoreAllMocks()) +it('draft preview selection requests only text with graph and index', async () => { + const graph: ProtocolGraph = { nodes: [{ id: 'agent', type: 'agent', position: { x: 0, y: 0 }, data: defaultAgentNodeData() }, { id: 'dataset', type: 'dataset', position: { x: 0, y: 0 }, data: { ...defaultDatasetNodeData(), config: { ...defaultDatasetNodeData().config, dataset_id: '11111111-1111-4111-8111-111111111111' } } }], edges: [{ id: 'e', source: 'dataset', target: 'agent', targetHandle: 'dataset', data: { dataset_input: { mode: 'per_row', columns: ['question'] } } }] } + vi.spyOn(datasetsApi, 'getRowSchema').mockResolvedValue({ dataset_id: 'draft', raw_sha256: 'draft-hash', columns: ['question'], row_count: 3 }) + const preview = vi.spyOn(protocolsApi, 'promptPreview').mockImplementation(async (_id, _node, _graph, selection) => ({ text: 'Authorized question', dataset_row: { dataset_id: 'draft', raw_sha256: 'draft-hash', row_index: selection?.row_index ?? 0, columns: ['question'], values: { question: 'Authorized question' } } })) + const run = vi.spyOn(protocolsApi, 'testRun') + render( protocolsApi.promptPreview('p', 'agent', graph, { row_index: rowIndex })} />) + fireEvent.click(screen.getByRole('button', { name: 'Prompt this agent will receive' })) + await screen.findByText('Authorized question') + expect(preview).toHaveBeenCalledWith('p', 'agent', graph, { row_index: 0 }) + fireEvent.change(screen.getByRole('spinbutton', { name: 'Source row' }), { target: { value: '3' } }) + await waitFor(() => expect(preview).toHaveBeenLastCalledWith('p', 'agent', graph, { row_index: 2 })) + await screen.findByText(/Source row 3 · draft · draft-hash/) + expect(run).not.toHaveBeenCalled() +}) diff --git a/frontend/src/components/protocol/DatasetRowResults.test.tsx b/frontend/src/components/protocol/DatasetRowResults.test.tsx new file mode 100644 index 00000000..9e74fe1a --- /dev/null +++ b/frontend/src/components/protocol/DatasetRowResults.test.tsx @@ -0,0 +1,77 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { experimentsApi } from '@/api/client' +import type { Experiment, ExperimentRunResults } from '@/types/experiments' +import { ResultsTab } from './ResultsTab' +import { DatasetRowResults } from './DatasetRowResults' + +afterEach(() => vi.restoreAllMocks()) +it('renders opaque values and distinct row output selections without ranking', async () => { + const results = { consumption_mode: 'per_row', row_summary: { planned: 4, completed: 3, failed: 1, missing_reported: 1, metric_coverage: {} }, row_results: ['12', { answer: 2 }, null, undefined].map((value,index) => ({ row_result_id: `row-${index}`, factor_values: { Prompt: 'level' }, dataset_row: { row_index: index }, replicate_number: 2, status: index === 3 ? 'failed' : 'completed', latest_attempt: { run_id: `run-${index}` }, measurement: { observations: [{ metric_id: 'answer', status: index === 3 ? 'unavailable' : 'measured', value }] } })) } as unknown as ExperimentRunResults + vi.spyOn(experimentsApi, 'getRunResults').mockResolvedValue(results) + const experiment = { id: 'e', name: 'Rows', design_spec: { metrics: [{ id: 'answer', name: 'Answer', kind: 'custom' }] } } as unknown as Experiment + const inspect = vi.fn() + render() + await screen.findByText('null') + expect(screen.getByText('12')).toBeInTheDocument() + expect(screen.getByText('{"answer":2}')).toBeInTheDocument() + expect(screen.getByText('unavailable')).toBeInTheDocument() + expect(screen.queryByText('Best current result')).not.toBeInTheDocument() + fireEvent.click(screen.getAllByRole('button', { name: 'Inspect row' })[2]) + expect(inspect).toHaveBeenCalledWith({ type: 'row', rowResultId: 'row-2', scope: { protocol_id: 'p' } }) +}) + +it('paginates dense rows, maximizes with Escape, and exports the selected scope', async () => { + const scope = { protocol_id: 'p', protocol_revision_id: 'old', design_revision_id: 'design' } + const results = { row_results: Array.from({ length: 21 }, (_, index) => ({ row_result_id: `slot-${index}`, factor_values: {}, dataset_row: { row_index: index }, replicate_number: 1, status: 'completed', latest_attempt: { run_id: `run-${index}` }, measurement: { observations: [] } })) } as unknown as ExperimentRunResults + const csv = vi.spyOn(experimentsApi, 'downloadRunResultsCsv').mockResolvedValue(new Blob(['rows'])) + const create = vi.fn(() => 'blob:csv') + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: create }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + render() + expect(screen.getAllByRole('button', { name: 'Inspect row' })).toHaveLength(20) + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + expect(screen.getAllByRole('button', { name: 'Inspect row' })).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: 'Maximize Results' })) + expect(document.body.style.overflow).toBe('hidden') + fireEvent.keyDown(document, { key: 'Escape' }) + expect(document.body.style.overflow).not.toBe('hidden') + fireEvent.click(screen.getByRole('button', { name: 'Download CSV' })) + expect(csv).toHaveBeenCalledWith('e', scope) + await screen.findByRole('button', { name: 'Download CSV' }) +}) + +it('shows generated cells with no outcomes and filters executions by cell identity', () => { + const results = { + row_cells: [ + { cell_id: 'a', cell_label: 'Prompt:short', factor_values: { Prompt: 'short' }, replicate_count: 1 }, + { cell_id: 'b', cell_label: 'Prompt:long', factor_values: { Prompt: 'long' }, replicate_count: 1 }, + ], + row_results: [ + { row_result_id: 'slot-a', cell_id: 'a', cell_label: 'Prompt:short', factor_values: { Prompt: 'short' }, dataset_row: { row_index: 0 }, replicate_number: 1, status: 'completed', latest_attempt: { run_id: 'run-a' } }, + ], + } as unknown as ExperimentRunResults + render() + fireEvent.click(screen.getByRole('button', { name: 'View cell Prompt:long' })) + expect(screen.queryByRole('button', { name: 'Inspect row' })).not.toBeInTheDocument() + expect(screen.getByText('No row executions planned for this cell.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'All cells' })) + expect(screen.getByRole('button', { name: 'Inspect row' })).toBeInTheDocument() +}) + +it('filters row outcomes to the selected replicate', () => { + const results = { + row_cells: [{ cell_id: 'a', cell_label: 'a', factor_values: {}, replicate_count: 2, replicates: [1, 2].map(number => ({ replicate_result_id: `parent-${number}`, replicate_label: `a-${number}`, replicate_number: number })) }], + row_results: [1, 2].map(number => ({ row_result_id: `slot-${number}`, cell_id: 'a', cell_label: 'a', factor_values: {}, replicate_result_id: `parent-${number}`, replicate_number: number, dataset_row: { row_index: 0 }, status: 'completed', latest_attempt: { run_id: `run-${number}` } })), + } as unknown as ExperimentRunResults + const inspect = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'View cell a' })) + expect(screen.getAllByRole('button', { name: 'View results' })).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: 'View a replicate 2' })) + expect(screen.getAllByRole('button', { name: 'Inspect row' })).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: 'Inspect row' })) + expect(inspect).toHaveBeenCalledWith('slot-2') +}) diff --git a/frontend/src/components/protocol/DatasetRowResults.tsx b/frontend/src/components/protocol/DatasetRowResults.tsx new file mode 100644 index 00000000..5878eb63 --- /dev/null +++ b/frontend/src/components/protocol/DatasetRowResults.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react' +import type { ResultsScope } from '@/api/client' +import { Button } from '@/components/ui/button' +import { reportedMetricText } from '@/lib/reportedMetrics' +import { displayFactorLevel } from '@/lib/experiment' +import type { Experiment, ExperimentRunResults, RowResult } from '@/types/experiments' +import { RunStepTrace } from './NodeRunOutputPanel' +import { DatasetRowCells } from './DatasetRowCells' +import { ChevronRight, AlertTriangle, CircleCheck, CircleHelp } from 'lucide-react' +import { ResultsPanel, ResultsScorecard } from './ResultsPanel' + +export function DatasetRowResults({ results, experiment, scope, onInspect }: { results: ExperimentRunResults; experiment: Experiment; scope: ResultsScope; onInspect: (rowId: string) => void }) { + const [page, setPage] = useState(0) + const [selectedCellId, setSelectedCellId] = useState('') + const [selectedReplicateId, setSelectedReplicateId] = useState('') + const rows = (results.row_results ?? []).filter(row => (!selectedCellId || row.cell_id === selectedCellId) && (!selectedReplicateId || row.replicate_result_id === selectedReplicateId)) + const currentPage = Math.min(page, Math.max(0, Math.ceil(rows.length / 20) - 1)) + const visibleRows = rows.slice(currentPage * 20, (currentPage + 1) * 20) + const factors = [...new Set(rows.flatMap(row => Object.keys(row.factor_values)))] + const declared = experiment.design_spec?.metrics?.filter(metric => metric.kind === 'custom') ?? [] + const observed = rows.flatMap(row => row.measurement?.observations ?? []).filter(item => item.producer?.kind !== 'runtime') + const metrics = [...new Map([...observed.map(item => ({ id: item.metric_id, name: item.metric_name })), ...declared].map(metric => [metric.id, metric])).values()] + const summary = results.row_summary + return
    Per-row results

    Individual row outcomes

    Per-row outcomes are reported individually. Aggregation, ranking and factorial analysis are unavailable.

    } + scorecards={summary && <> + + + + + } + cells={ { setSelectedCellId(cellId); setSelectedReplicateId(''); setPage(0) }} onSelectReplicate={(cellId, replicateId) => { setSelectedCellId(cellId); setSelectedReplicateId(replicateId); setPage(0) }} />} + details={
    +

    {selectedCellId ? 'Cell row outcomes' : 'Row outcomes'}

    +
    {[...factors, 'Source row', 'Replicate', 'Execution status', 'Reported coverage', ...metrics.map(metric => metric.name), 'Output'].map((heading, index) => )}{visibleRows.map(row => { + const observations = row.measurement?.observations ?? [] + const measured = metrics.filter(metric => observations.some(item => item.metric_id === metric.id && item.status === 'measured')).length + return {factors.map(factor => )}{metrics.map(metric => )} + })}
    {heading}
    {displayFactorLevel(experiment.design_spec, factor, row.factor_values[factor], row.cell_label)}{row.dataset_row.row_index + 1}{row.replicate_number}{row.status}{measured}/{metrics.length}{reportedMetricText(observations.find(item => item.metric_id === metric.id))}
    + {rows.length > 20 &&
    Page {currentPage + 1} of {Math.ceil(rows.length / 20)}
    } + {!rows.length &&

    {selectedCellId ? 'No row executions planned for this cell.' : 'No row executions planned in this scope.'}

    } +
    } + /> +} + +export function DatasetRowDetail({ row, onClose }: { row: RowResult; onClose: () => void }) { + const [attemptId, setAttemptId] = useState(row.latest_attempt?.run_id) + const attempt = row.attempts.find(item => item.run_id === attemptId) ?? row.latest_attempt + const provenance = attempt?.attempt_result?.row_provenance + return +} diff --git a/frontend/src/components/protocol/DatasetRowRetry.test.tsx b/frontend/src/components/protocol/DatasetRowRetry.test.tsx new file mode 100644 index 00000000..b47e6715 --- /dev/null +++ b/frontend/src/components/protocol/DatasetRowRetry.test.tsx @@ -0,0 +1,20 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { protocolsApi } from '@/api/client' +import type { ExperimentRunResults } from '@/types/experiments' +import type { Protocol } from '@/types/protocols' +import { DatasetRowRuns } from './DatasetRowRuns' + +afterEach(() => vi.restoreAllMocks()) +it('retries only the failed row and keeps completed unscored disabled', async () => { + const retry = vi.spyOn(protocolsApi, 'runCells').mockRejectedValue(new Error('invalid_retry_target')) + const results = { row_summary: { cell_count: 1, parent_replicate_count: 1, row_count: 2, expected: 2 }, row_results: ['failed','completed'].map((status,index) => ({ row_result_id: `slot-${index}`, cell_label: 'same', dataset_row: { row_index: index }, replicate_number: 1, status, protocol_revision_id: 'r', latest_attempt: { run_id: `run-${index}` } })) } as unknown as ExperimentRunResults + const protocol = { id: 'p', graph: { nodes: [], edges: [] }, published_revision_id: 'r' } as unknown as Protocol + render() + const buttons = screen.getAllByRole('button', { name: 'Retry' }) + expect(buttons[1]).toBeDisabled() + fireEvent.click(buttons[0]) + await waitFor(() => expect(retry).toHaveBeenCalledWith('p', { retry_row_result_ids: ['slot-0'] })) + expect(await screen.findByRole('alert')).toHaveTextContent('invalid_retry_target') +}) diff --git a/frontend/src/components/protocol/DatasetRowRuns.test.tsx b/frontend/src/components/protocol/DatasetRowRuns.test.tsx new file mode 100644 index 00000000..309c8e23 --- /dev/null +++ b/frontend/src/components/protocol/DatasetRowRuns.test.tsx @@ -0,0 +1,99 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { experimentsApi, protocolsApi } from '@/api/client' +import type { ExperimentRunResults } from '@/types/experiments' +import type { Protocol } from '@/types/protocols' +import { RunsTab } from './RunsTab' +import { DatasetRowRuns } from './DatasetRowRuns' + +export const rowResults = { + consumption_mode: 'per_row', cells: [], replicates: [], overview: {}, + row_summary: { cell_count: 5, parent_replicate_count: 10, row_count: 3, expected: 30, planned: 30, pending: 1, running: 2, completed: 26, failed: 1, cancelled: 0, scored: 25, missing_reported: 1, metric_coverage: {} }, + row_results: ['failed', 'completed', 'finalizing'].map((status, index) => ({ row_result_id: `slot-${index}`, cell_label: 'duplicate', replicate_label: 'same', replicate_number: 1, dataset_row: { row_index: index, dataset_id: 'd', raw_sha256: 'hash' }, status, protocol_revision_id: 'r', latest_attempt: { run_id: `run-${index}` }, attempts: [] })), +} as unknown as ExperimentRunResults +export const protocol = { name: 'Rows', description: null, created_at: '', updated_at: '', id: 'p', experiment_id: 'e', published_revision_id: 'r', published_revision: 1, has_unpublished_changes: false, graph: { nodes: [], edges: [] } } as Protocol +afterEach(() => vi.restoreAllMocks()) +export function mountRows(inspect = vi.fn(), results = rowResults) { + vi.spyOn(experimentsApi, 'listReplicates').mockResolvedValue([]) + vi.spyOn(experimentsApi, 'listTrials').mockResolvedValue([]) + vi.spyOn(experimentsApi, 'getRunResults').mockResolvedValue(results) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + return client +} +it('renders authoritative forecast and distinct row identities', async () => { + const inspect = vi.fn() + mountRows(inspect) + expect(await screen.findByText('5 cells / 10 replicates / 3 rows / 30 executions')).toBeInTheDocument() + expect(screen.getByText(/26 completed.*1 failed.*1 missing reported/)).toBeInTheDocument() + fireEvent.click(screen.getAllByRole('button', { name: 'View results' })[1]) + expect(inspect).toHaveBeenCalledWith('slot-1') + expect(experimentsApi.getRunResults).toHaveBeenCalledWith('e', { protocol_id: 'p' }) +}) +it('cancels exactly the active row run', async () => { + const cancel = vi.spyOn(protocolsApi, 'cancelRun').mockResolvedValue({} as never) + mountRows() + fireEvent.click(await screen.findByRole('button', { name: 'Stop' })) + await waitFor(() => expect(cancel).toHaveBeenCalledWith('p', 'run-2')) +}) + +it('shows generated cells before row executions are planned', () => { + const results = { ...rowResults, row_results: [], row_cells: [ + { cell_id: 'cell-a', cell_label: 'Prompt:short', factor_values: { Prompt: 'short' }, replicate_count: 2, replicates: [ + { replicate_result_id: 'parent-1', replicate_label: 'short', replicate_number: 1 }, + { replicate_result_id: 'parent-2', replicate_label: 'short__rep2', replicate_number: 2 }, + ] }, + { cell_id: 'cell-b', cell_label: 'Prompt:long', factor_values: { Prompt: 'long' }, replicate_count: 2 }, + ] } + render() + expect(screen.getByRole('button', { name: 'View cell Prompt:short' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'View cell Prompt:long' })).toBeInTheDocument() + expect(screen.getAllByText('0/6')).toHaveLength(2) + fireEvent.click(screen.getByRole('button', { name: 'View cell Prompt:short' })) + expect(screen.getByText('Replicate 1')).toBeInTheDocument() + expect(screen.getByText('Replicate 2')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View results' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View Prompt:short replicate 1' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Run Prompt:short replicate 1' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Run all replicates in Prompt:short' })).toBeEnabled() +}) + +it('runs only the selected replicate across source rows after confirmation', async () => { + const run = vi.spyOn(protocolsApi, 'runCells').mockResolvedValue({} as never) + const results = { ...rowResults, row_results: [], row_cells: [{ cell_id: 'a', cell_label: 'short', factor_values: {}, replicate_count: 1, replicates: [{ replicate_result_id: 'parent', replicate_label: 'short', replicate_number: 1 }] }] } + render() + fireEvent.click(screen.getByRole('button', { name: 'View cell short' })) + fireEvent.click(screen.getByRole('button', { name: 'Run short replicate 1' })) + expect(run).not.toHaveBeenCalled() + expect(screen.getByText('1 replicates × 3 rows = 3 executions')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Run selected replicates' })) + await waitFor(() => expect(run).toHaveBeenCalledWith('p', { replicateLabels: ['short'], rerunReplicateLabels: [] })) +}) + +it('keeps multiple cells expanded and explicitly reruns completed rows', async () => { + const run = vi.spyOn(protocolsApi, 'runCells').mockResolvedValue({} as never) + const cells = ['a', 'b'].map(id => ({ cell_id: id, cell_label: id, factor_values: {}, replicate_count: 1, replicates: [{ replicate_result_id: `parent-${id}`, replicate_label: id, replicate_number: 1 }] })) + const results: ExperimentRunResults = { ...rowResults, row_cells: cells, row_results: rowResults.row_results!.map((row, index) => ({ ...row, cell_id: 'a', replicate_result_id: 'parent-a', replicate_label: 'a', status: index === 2 ? 'running' : 'completed' })) } + render() + fireEvent.click(screen.getByRole('button', { name: 'View cell a' })) + fireEvent.click(screen.getByRole('button', { name: 'View cell b' })) + expect(screen.getByRole('list', { name: 'Replicates for a' })).toBeInTheDocument() + expect(screen.getByRole('list', { name: 'Replicates for b' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Run a replicate 1' })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: 'Run all replicates in a' })) + expect(screen.getByText(/2 executions will start/)).toBeInTheDocument() + expect(screen.getByText(/previous attempts remain available/)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Run selected replicates' })) + await waitFor(() => expect(run).toHaveBeenCalledWith('p', { replicateLabels: ['a'], rerunReplicateLabels: ['a'] })) +}) + +it('stops all active row runs without stopping finished rows', async () => { + const cancel = vi.spyOn(protocolsApi, 'cancelRun').mockResolvedValue({} as never) + const results: ExperimentRunResults = { ...rowResults, row_cells: [{ cell_id: 'a', cell_label: 'a', factor_values: {}, replicate_count: 1 }], row_results: rowResults.row_results!.map((row, index) => ({ ...row, cell_id: 'a', status: index === 0 ? 'completed' : 'running' })) } + render() + fireEvent.click(screen.getAllByRole('button', { name: 'Stop all' })[0]) + await waitFor(() => expect(cancel).toHaveBeenCalledTimes(2)) + expect(cancel).toHaveBeenCalledWith('p', 'run-1') + expect(cancel).toHaveBeenCalledWith('p', 'run-2') +}) diff --git a/frontend/src/components/protocol/DatasetRowRuns.tsx b/frontend/src/components/protocol/DatasetRowRuns.tsx new file mode 100644 index 00000000..9d2c3d88 --- /dev/null +++ b/frontend/src/components/protocol/DatasetRowRuns.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { protocolsApi } from '@/api/client' +import { Button } from '@/components/ui/button' +import type { DesignSpec, ExperimentRunResults, RowSummary } from '@/types/experiments' +import type { Protocol } from '@/types/protocols' +import { RunConfirmDialog } from './RunConfirmDialog' +import { DatasetRowCells } from './DatasetRowCells' +import { StopRowRuns } from './StopRowRuns' +import { ReplicateStatus } from './CellPresentation' +import { displayFactorCondition } from '@/lib/experiment' + +function rowExecutionForecast(summary: RowSummary): string { + return `${summary.cell_count} cells / ${summary.parent_replicate_count} replicates / ${summary.row_count ?? 'unknown'} rows / ${summary.expected ?? 'unknown'} executions` +} + +export function DatasetRowRuns({ results, protocol, blocked, onInspect, designSpec }: { + results: ExperimentRunResults; protocol: Protocol; blocked: boolean; onInspect?: (rowId: string) => void; designSpec?: DesignSpec | null +}) { + const queryClient = useQueryClient() + const [confirm, setConfirm] = useState<{ labels?: string[]; cellCount: number; rerunLabels: string[] } | null>(null) + const [retrying, setRetrying] = useState(null) + const [page, setPage] = useState(0) + const [selectedCellId, setSelectedCellId] = useState('') + const [selectedReplicateId, setSelectedReplicateId] = useState('') + const run = useMutation({ + mutationFn: () => protocolsApi.runCells(protocol.id, { replicateLabels: confirm?.labels, rerunReplicateLabels: confirm?.rerunLabels }), + onSuccess: () => { setConfirm(null); queryClient.invalidateQueries({ queryKey: ['experiments', protocol.experiment_id] }) }, + }) + const summary = results.row_summary + const rows = (results.row_results ?? []).filter(row => (!selectedCellId || row.cell_id === selectedCellId) && (!selectedReplicateId || row.replicate_result_id === selectedReplicateId)) + const currentPage = Math.min(page, Math.max(0, Math.ceil(rows.length / 20) - 1)) + const visibleRows = rows.slice(currentPage * 20, (currentPage + 1) * 20) + const selectedReplicateCount = confirm?.labels?.length ?? summary?.parent_replicate_count ?? 0 + const selectedRows = (results.row_results ?? []).filter(row => !confirm?.labels || confirm.labels.includes(row.replicate_label)) + const runnableExecutions = Math.max(0, selectedReplicateCount * (summary?.row_count ?? 0) - selectedRows.filter(row => ['pending', 'running', 'finalizing'].includes(row.status) || !confirm?.rerunLabels.includes(row.replicate_label)).length) + const cancel = useMutation({ mutationFn: (runId: string) => protocolsApi.cancelRun(protocol.id, runId), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['experiments', protocol.experiment_id] }) }) + const retry = useMutation({ + mutationFn: (rowId: string) => protocolsApi.runCells(protocol.id, { retry_row_result_ids: [rowId] }), + onMutate: rowId => setRetrying(rowId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['experiments', protocol.experiment_id] }), + onSettled: () => setRetrying(null), + }) + return
    +

    {summary ? rowExecutionForecast(summary) : 'Execution forecast unavailable: original source metadata unavailable.'}

    + {summary &&

    {summary.planned} planned · {summary.pending} pending · {summary.running} running · {summary.completed} completed · {summary.failed} failed · {summary.cancelled} cancelled · {summary.missing_reported} missing reported

    } + {[run.error, cancel.error, retry.error].filter(Boolean).map((error, index) =>

    {error?.message}

    )} + { setSelectedCellId(cellId); setSelectedReplicateId(''); setPage(0) }} onSelectReplicate={(cellId, replicateId) => { setSelectedCellId(cellId); setSelectedReplicateId(replicateId); setPage(0) }} onRun={(labels, cellCount, rerunLabels) => setConfirm({ labels, cellCount, rerunLabels })} runDisabled={blocked || !summary?.row_count || run.isPending} + onStop={runIds => } + headerActions={<> ['pending', 'running', 'finalizing'].includes(row.status) && row.latest_attempt).map(row => row.latest_attempt!.run_id)} />} + /> +

    {selectedCellId ? 'Cell row executions' : 'Row executions'}

    + {!rows.length &&

    {selectedCellId ? 'No row executions planned for this cell.' : 'No row executions planned. Run all cells to create them.'}

    } +
    {['Cell', 'Source row', 'Replicate', 'Status', 'Actions'].map(label => )}{visibleRows.map(row => )}
    {label}
    {Object.entries(row.factor_values ?? {}).map(([name, value]) => displayFactorCondition(name, value, designSpec, row.cell_label)).join(' · ') || 'No varying factors'}{row.dataset_row.row_index + 1}{row.replicate_number} + + {row.latest_attempt && ['pending', 'running', 'finalizing'].includes(row.status) && } + {['failed', 'cancelled'].includes(row.status) && } +
    + {rows.length > 20 &&
    Page {currentPage + 1} of {Math.ceil(rows.length / 20)}
    } + {confirm && setConfirm(null)} onConfirm={() => run.mutate()} hasUnpublishedChanges={protocol.has_unpublished_changes} publishedRevision={protocol.published_revision} confirmDisabled={blocked || run.isPending || !runnableExecutions} isConfirming={run.isPending} confirmError={run.error?.message} additionalContent={

    {confirm.labels ? `${confirm.labels.length} replicates × ${summary?.row_count ?? '?'} rows = ${(summary?.row_count ?? 0) * confirm.labels.length} executions` : summary && rowExecutionForecast(summary)}

    {runnableExecutions} executions will start. Active rows are skipped.{confirm.rerunLabels.length > 0 && ' Finished rows in the selected replicates will run again; previous attempts remain available.'}

    } />} +
    +} diff --git a/frontend/src/components/protocol/DatasetRowSelector.tsx b/frontend/src/components/protocol/DatasetRowSelector.tsx new file mode 100644 index 00000000..314cb839 --- /dev/null +++ b/frontend/src/components/protocol/DatasetRowSelector.tsx @@ -0,0 +1,6 @@ +import { Input } from '@/components/ui/input' + +export function DatasetRowSelector({ rowIndex, onChange, rowCount, disabled = false }: { rowIndex: number; onChange: (zeroBased: number) => void; rowCount: number; disabled?: boolean }) { + const valid = Number.isInteger(rowIndex) && rowIndex >= 0 && rowIndex < rowCount + return +} diff --git a/frontend/src/components/protocol/DatasetRowTestRun.test.tsx b/frontend/src/components/protocol/DatasetRowTestRun.test.tsx new file mode 100644 index 00000000..882562f0 --- /dev/null +++ b/frontend/src/components/protocol/DatasetRowTestRun.test.tsx @@ -0,0 +1,76 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactFlowProvider } from '@xyflow/react' +import { fireEvent, render, renderHook, screen, waitFor, act } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { datasetsApi, protocolsApi } from '@/api/client' +import { defaultAgentNodeData, defaultAnthropicModelNodeData, defaultDatasetNodeData, defaultReasonActPatternNodeData, type ProtocolGraph } from '@/types/protocols' +import { ProtocolCanvas } from './ProtocolCanvas' +import { useDatasetRowSelection } from './useDatasetRowSelection' + +vi.mock('./PythonCodeEditor', () => ({ PythonCodeEditor: () => null })) +const graph: ProtocolGraph = { nodes: [ + { id: 'a', type: 'agent', position: { x: 100, y: 100 }, data: defaultAgentNodeData('Prediction') }, + { id: 'm', type: 'model_anthropic', position: { x: 0, y: 0 }, data: defaultAnthropicModelNodeData() }, + { id: 'd', type: 'dataset', position: { x: 0, y: 200 }, data: { ...defaultDatasetNodeData(), config: { ...defaultDatasetNodeData().config, dataset_id: '11111111-1111-4111-8111-111111111111', dataset_name: 'original' } } }, + { id: 'pattern', type: 'reason_act', position: { x: 0, y: 300 }, data: defaultReasonActPatternNodeData() }, + ], edges: [ { id: 'model', source: 'm', target: 'a', targetHandle: 'model' }, { id: 'pattern', source: 'pattern', target: 'a', targetHandle: 'architectural_pattern' }, { id: 'dataset', source: 'd', target: 'a', targetHandle: 'dataset', data: { dataset_input: { mode: 'per_row', columns: ['question'] } } } ] } +function setup() { + vi.stubGlobal('DOMMatrixReadOnly', class { m22 = 1 }) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 800, width: 1000, height: 800, toJSON: () => ({}) }) + vi.stubGlobal('ResizeObserver', class { private callback: ResizeObserverCallback; constructor(callback: ResizeObserverCallback) { this.callback = callback } observe(target: Element) { this.callback([{ target, contentRect: target.getBoundingClientRect() } as ResizeObserverEntry], this as unknown as ResizeObserver) } unobserve() {} disconnect() {} }) + vi.spyOn(protocolsApi, 'get').mockResolvedValue({ id: 'p', published_revision_id: 'r' } as never) + vi.spyOn(protocolsApi, 'getRevision').mockResolvedValue({ id: 'r', graph } as never) + vi.spyOn(datasetsApi, 'getRowSchema').mockResolvedValue({ dataset_id: 'd', raw_sha256: 'hash', row_count: 3, columns: ['question','reference'] }) + vi.spyOn(protocolsApi, 'listRuns').mockResolvedValue([]) + vi.spyOn(protocolsApi, 'getLatestTestRun').mockRejectedValue(new Error('No test')) + vi.spyOn(protocolsApi, 'update').mockResolvedValue({} as never) +} +beforeEach(setup) +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) +function mount() { + render(
    ) +} +it('canvas Test Run selects published source row 3 and submits only index 2', async () => { + const start = vi.spyOn(protocolsApi, 'testRun').mockRejectedValue(new Error('mock queue')) + mount() + fireEvent.click(await screen.findByRole('button', { name: /Test Run/i })) + const selector = await screen.findByRole('spinbutton', { name: 'Source row' }) + await waitFor(() => expect(selector).not.toBeDisabled()) + expect(selector).toHaveValue(1) + fireEvent.change(selector, { target: { value: '3' } }) + expect(selector).toHaveValue(3) + expect(start).not.toHaveBeenCalled() + const buttons = screen.getAllByRole('button', { name: 'Test Run' }) + fireEvent.click(buttons.at(-1)!) + await waitFor(() => expect(start).toHaveBeenCalledWith('p', { row_index: 2 })) +}) +it('source row control validates bounds and converts one-based display', async () => { + const { DatasetRowSelector } = await import('./DatasetRowSelector') + const change = vi.fn() + render() + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } }) + expect(change).toHaveBeenCalledWith(2) +}) + +it('published selection resets after publication changes and rejects invalid bounds and columns', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const wrapper = ({ children }: { children: React.ReactNode }) => {children} + const { result, rerender } = renderHook(({ scope, source }) => useDatasetRowSelection(source, scope), { wrapper, initialProps: { scope: 'publication-1', source: graph } }) + await waitFor(() => expect(result.current.error).toBeNull()) + act(() => result.current.setRowIndex(2)) + expect(result.current.rowIndex).toBe(2) + rerender({ scope: 'publication-2', source: graph }) + await waitFor(() => expect(result.current.error).toBeNull()) + expect(result.current.rowIndex).toBe(0) + act(() => result.current.setRowIndex(3)) + expect(result.current.error).toMatch(/outside/) + const invalid = structuredClone(graph) + invalid.edges[2].data = { dataset_input: { mode: 'per_row', columns: ['missing'] } } + act(() => result.current.setRowIndex(0)) + rerender({ scope: 'publication-2', source: invalid }) + expect(result.current.error).toMatch(/columns are absent/) + rerender({ scope: 'whole', source: { ...graph, edges: graph.edges.filter(edge => edge.id !== 'dataset') } }) + expect(result.current.binding).toBeUndefined() + expect(result.current.error).toBeNull() +}) diff --git a/frontend/src/components/protocol/DesignTab.tsx b/frontend/src/components/protocol/DesignTab.tsx index e1a3d4d6..7a1abb93 100644 --- a/frontend/src/components/protocol/DesignTab.tsx +++ b/frontend/src/components/protocol/DesignTab.tsx @@ -25,10 +25,11 @@ import { LEVEL_TYPE_LABELS, levelTypeOf } from './factorLevels' import { FactorEditorDialog } from './FactorEditorDialog' import { InfoTooltip } from './InfoTooltip' import { MetricsEditor } from './MetricsEditor' +import { DatasetExecutionSummary } from './DatasetExecutionSummary' export { MetricsEditor } from './MetricsEditor' import { normalizeDesignMetrics } from '@/lib/metricCatalog' import type { ProtocolCanvasHandle } from './ProtocolCanvas' -import type { ProtocolEdge, ProtocolGraph, ProtocolNode } from '@/types/protocols' +import type { Protocol, ProtocolEdge, ProtocolGraph, ProtocolNode } from '@/types/protocols' import { COORDINATION_STRATEGY_CATALOG, type CoordinationStrategySlug, @@ -332,13 +333,17 @@ function FactorsEditor({ export function DesignTab({ experiment, protocolId, + protocol, canvasRef, onDesignUpdatePendingChange, + onDraftBusyChange, }: { experiment: Experiment protocolId: string | undefined + protocol?: Protocol canvasRef: RefObject onDesignUpdatePendingChange: (pending: boolean) => void + onDraftBusyChange?: (busy: boolean) => void }) { const queryClient = useQueryClient() const graphQuery = useProtocolGraph(protocolId) @@ -581,6 +586,21 @@ export function DesignTab({ onDesignUpdatePendingChange(needsDesignUpdate) return () => onDesignUpdatePendingChange(false) }, [needsDesignUpdate, onDesignUpdatePendingChange]) + useEffect(() => { + onDraftBusyChange?.(needsDesignUpdate || metadataDraftChanged || isAutosavingMetadata || generateMutation.isPending) + }, [needsDesignUpdate, metadataDraftChanged, isAutosavingMetadata, generateMutation.isPending, onDraftBusyChange]) + useEffect(() => { + if (!protocolId) return + let cancelled = false + protocolsApi.get(protocolId).then(fresh => { + if (cancelled) return + queryClient.setQueryData(['protocols', 'for-experiment', experiment.id], previous => previous + ? (previous.published_revision ?? 0) > (fresh.published_revision ?? 0) ? previous + : { ...previous, published_revision_id: fresh.published_revision_id, published_revision: fresh.published_revision, has_unpublished_changes: fresh.has_unpublished_changes } + : fresh) + }).catch(() => {}) + return () => { cancelled = true } + }, [experiment.updated_at, experiment.id, protocolId, queryClient]) // Keep the latest delayed write in a ref so unmounting (navigating away or // switching to Runs) can flush it immediately. Once a snapshot has been @@ -703,7 +723,7 @@ export function DesignTab({

    Nothing on the canvas is deleted, and switching back restores this state. Cells already generated stay - readable under the design revision that produced them, but they'll need regenerating before the new + readable under the experiment version that produced them, but they'll need regenerating before the new strategy's results are comparable.

    @@ -784,6 +804,8 @@ export function DesignTab({
    + +
    ) : (
    - {activeTab === 'design' ? ( +
    - ) : activeTab === 'runs' ? ( +
    + {viewingHistory && !version ?

    The selected version is unavailable. Return to draft to continue.

    : activeTab === 'design' && version ? : activeTab === 'runs' && version ? : activeTab === 'runs' ? ( { onResultSelection({ type: 'row', rowResultId, scope: { protocol_id: protocol?.id } }); openPanel('results') }} onViewResult={viewReplicateResult} /> - ) : ( + ) : activeTab === 'results' ? ( onResultSelection({ ...selection, scope: { protocol_id: protocol?.id, protocol_revision_id: version?.id } })} /> - )} + ) : null}
    )} diff --git a/frontend/src/components/protocol/ExperimentVersionHistory.test.tsx b/frontend/src/components/protocol/ExperimentVersionHistory.test.tsx new file mode 100644 index 00000000..df237312 --- /dev/null +++ b/frontend/src/components/protocol/ExperimentVersionHistory.test.tsx @@ -0,0 +1,51 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { protocolsApi } from '@/api/client' +import type { ProtocolRevision } from '@/types/protocols' +import { ExperimentVersionHistory } from './ExperimentVersionHistory' + +afterEach(() => vi.restoreAllMocks()) + +const versions: ProtocolRevision[] = [3, 2, 1].map(revision => ({ + id: `v${revision}`, protocol_id: 'p', revision, graph: { nodes: [], edges: [] }, + published_at: `2026-01-0${revision}`, name: revision === 1 ? 'Baseline' : null, + note: revision === 1 ? 'Lower critic threshold' : null, + run_count: revision === 2 ? 2 : 0, result_count: revision === 2 ? 1 : 0, +})) + +it('filters by provenance counts and searches notes while keeping production visible', () => { + const client = new QueryClient() + const onSelect = vi.fn() + const props = { versions, protocolId: 'p', publishedId: 'v3', selectedId: '', onSearch: vi.fn(), onSelect } + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'With results' })) + expect(screen.getByRole('button', { name: 'View Version 3' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'View Version 2' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View Version 1 · Baseline' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'With runs' })) + expect(screen.getByText('1 of 3 versions · newest first')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'All' })) + rerender() + expect(screen.getByRole('button', { name: 'View Version 1 · Baseline' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View Version 2' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'View Version 1 · Baseline' })) + expect(onSelect).toHaveBeenCalledWith('v1') +}) + +it('edits annotations and updates the cached version without changing its definition', async () => { + const client = new QueryClient() + client.setQueryData(['protocols', 'p', 'revisions'], versions) + const updated = { ...versions[2], name: 'Reference', note: 'Keep for comparison' } + const save = vi.spyOn(protocolsApi, 'updateRevision').mockResolvedValue(updated) + render() + const entry = screen.getByRole('button', { name: 'View Version 1 · Baseline' }).closest('[data-slot="card"]') as HTMLElement + fireEvent.click(within(entry).getByRole('button', { name: 'Details' })) + expect(within(entry).getByText('Lower critic threshold')).toBeInTheDocument() + fireEvent.click(within(entry).getByRole('button', { name: 'Edit name and note' })) + fireEvent.change(within(entry).getByRole('textbox', { name: 'Version name (optional)' }), { target: { value: ' Reference ' } }) + fireEvent.change(within(entry).getByRole('textbox', { name: 'Version note (optional)' }), { target: { value: 'Keep for comparison' } }) + fireEvent.click(within(entry).getByRole('button', { name: 'Save details' })) + await waitFor(() => expect(save).toHaveBeenCalledWith('p', 'v1', { name: 'Reference', note: 'Keep for comparison' })) + await waitFor(() => expect(client.getQueryData(['protocols', 'p', 'revisions'])?.[2]).toEqual(updated)) +}) diff --git a/frontend/src/components/protocol/ExperimentVersionHistory.tsx b/frontend/src/components/protocol/ExperimentVersionHistory.tsx new file mode 100644 index 00000000..732c71a8 --- /dev/null +++ b/frontend/src/components/protocol/ExperimentVersionHistory.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { protocolsApi } from '@/api/client' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import type { ProtocolRevision } from '@/types/protocols' +import { versionLabel } from '@/lib/experimentVersion' + +function changeSummary(version: ProtocolRevision, previous?: ProtocolRevision) { + if (!previous) return 'First published version' + const changes: string[] = [] + if (JSON.stringify(version.graph) !== JSON.stringify(previous.graph)) changes.push('Canvas') + for (const [key, label] of [['hypothesis', 'Hypothesis'], ['design_type', 'Design type'], ['design_spec', 'Design'], ['measurement_plan', 'Measurement plan'], ['task_brief', 'Task brief']] as const) { + if (JSON.stringify(version.experiment_snapshot?.[key]) !== JSON.stringify(previous.experiment_snapshot?.[key])) changes.push(label) + } + if (version.design_revision_id !== previous.design_revision_id) changes.push('Generated cells') + return changes.length ? `Changed: ${changes.join(', ')}` : 'Experiment definition unchanged' +} + +function VersionEntry({ version, previous, current, selected, protocolId, onSelect }: { + version: ProtocolRevision; previous?: ProtocolRevision; current: boolean; selected: boolean; + protocolId: string; onSelect: (id: string) => void +}) { + const [expanded, setExpanded] = useState(false) + const [editing, setEditing] = useState(false) + const [name, setName] = useState(version.name ?? '') + const [note, setNote] = useState(version.note ?? '') + const queryClient = useQueryClient() + const mutation = useMutation({ + mutationFn: () => protocolsApi.updateRevision(protocolId, version.id, { name: name.trim() || null, note: note.trim() || null }), + onSuccess: updated => { + queryClient.setQueryData(['protocols', protocolId, 'revisions'], versions => versions?.map(item => item.id === updated.id ? updated : item)) + setEditing(false) + }, + }) + return +
    + Version {version.revision}{version.name ? ` · ${version.name}` : ''} + {current && Production} + {new Date(version.published_at).toLocaleDateString()} +
    +

    {version.run_count ?? 0} runs · {version.result_count ?? 0} with results

    +
    + + +
    + {expanded &&
    +

    {changeSummary(version, previous)}

    + {editing ?
    { event.preventDefault(); mutation.mutate() }}> + +