Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,14 +341,15 @@ 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[]; retry_row_result_ids?: string[] }) =>
runCells: (id: string, options?: { replicateLabels?: string[]; rerunReplicateLabels?: string[]; retry_row_result_ids?: string[]; row_indices?: number[] }) =>
request<CellRunBatch>(`/protocols/${id}/cell-runs`, {
method: 'POST',
body: options
? {
replicate_labels: options.replicateLabels,
rerun_replicate_labels: options.retry_row_result_ids ? undefined : options.rerunReplicateLabels ?? [],
retry_row_result_ids: options.retry_row_result_ids,
row_indices: options.row_indices,
}
: undefined,
}),
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/api/datasetRows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ describe('row HTTP contracts', () => {
await protocolsApi.runCells('p', { retry_row_result_ids: ['slot'] })
expect(lastBody()).toEqual({ retry_row_result_ids: ['slot'] })
})
it('selects a source row within a replicate for a run or rerun', async () => {
await protocolsApi.runCells('p', { replicateLabels: ['rep'], row_indices: [0] })
expect(lastBody()).toEqual({ replicate_labels: ['rep'], rerun_replicate_labels: [], row_indices: [0] })
await protocolsApi.runCells('p', { replicateLabels: ['rep'], rerunReplicateLabels: ['rep'], row_indices: [2] })
expect(lastBody()).toEqual({ replicate_labels: ['rep'], rerun_replicate_labels: ['rep'], row_indices: [2] })
})
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)
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/protocol/DatasetRowCells.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ export function DatasetRowCells({ results, selectedCellId, onSelect, designSpec,
return <ReplicateRow key={replicate.replicate_result_id} number={replicate.replicate_number} results={resultView}
detail={<>{done}/{rowCount ?? '?'} row executions complete{active > 0 && ` · ${active} active`}{errors > 0 && ` · ${errors} failed`}</>}
status={<ReplicateStatus status={finished ? 'completed' : active ? 'running' : errors ? 'failed' : 'not_started'} />}
onView={hasAttempt && onSelectReplicate ? () => onSelectReplicate(cell.cell_id, replicate.replicate_result_id) : undefined}
onView={(hasAttempt || !!onRun) && onSelectReplicate ? () => onSelectReplicate(cell.cell_id, replicate.replicate_result_id) : undefined}
viewAriaLabel={resultView ? `View ${cell.cell_label} replicate ${replicate.replicate_number}` : undefined}
viewLabel="View results"
viewLabel={resultView ? 'View results' : 'View rows'}
actions={<>{onRun && <ReplicateRunAction disabled={runDisabled || active > 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'}</ReplicateRunAction>}{onStop?.(replicateRows.filter(row => ['pending', 'running', 'finalizing'].includes(row.status) && row.latest_attempt).map(row => row.latest_attempt!.run_id))}</>} />
})}</ul>
</CellCard>
Expand Down
53 changes: 52 additions & 1 deletion frontend/src/components/protocol/DatasetRowHistory.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,62 @@
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 { experimentsApi, protocolsApi } from '@/api/client'
import type { ProtocolRevision } from '@/types/protocols'
import type { Experiment, ExperimentRunResults } from '@/types/experiments'
import { ResultsInspectorPanel, ResultsTab } from './ResultsTab'
import { DatasetRowDetail } from './DatasetRowResults'
import type { RowResult } from '@/types/experiments'
afterEach(() => vi.restoreAllMocks())
vi.mock('./NodeRunOutputPanel', () => ({ RunStepTrace: ({ runId }: { runId: string }) => <p>Trace {runId}</p>, ReceivedPromptPanel: () => null, UnresolvedReferencesNote: () => null }))
it('hides empty configuration nodes but preserves output, traces and execution failures', () => {
const attempt = { run_id: 'run', status: 'completed', node_labels: {
model: 'Model config', dataset: 'Dataset config', skipped: 'Skipped node', answer: 'Answer agent',
trace: 'Trace agent', failed: 'Failed agent', active: 'Active agent', cancelled: 'Cancelled agent',
}, node_runs: {
model: { status: 'completed', output_text: null, error: null },
dataset: { status: 'completed', output_text: '' },
skipped: { status: 'skipped' },
answer: { status: 'completed', output_text: 'Final answer' },
trace: { status: 'completed', run_id: 'agent-run' },
failed: { status: 'failed', error: 'Execution failed' },
active: { status: 'running' },
cancelled: { status: 'cancelled' },
} }
const row = { row_result_id: 'slot', replicate_number: 1, dataset_row: { row_index: 0 }, latest_attempt: attempt, attempts: [attempt] } as unknown as RowResult
render(<QueryClientProvider client={new QueryClient()}><DatasetRowDetail row={row} onClose={vi.fn()} /></QueryClientProvider>)
for (const name of ['Model config', 'Dataset config', 'Skipped node']) expect(screen.queryByText(name)).not.toBeInTheDocument()
for (const name of ['Answer agent', 'Trace agent', 'Failed agent', 'Active agent', 'Cancelled agent']) expect(screen.getByText(name)).toBeInTheDocument()
expect(screen.getAllByRole('listitem')).toHaveLength(5)
})
it('resolves friendly names from the pinned publication when row results omit labels', async () => {
const attempt = { run_id: 'run', status: 'completed', protocol_revision_id: 'publication-old', node_runs: { 'node-123': { status: 'completed', output_text: 'Answer' } } }
const row = { row_result_id: 'slot', replicate_number: 1, protocol_revision_id: 'publication-old', dataset_row: { row_index: 0 }, latest_attempt: attempt, attempts: [attempt] }
vi.spyOn(experimentsApi, 'getRunResults').mockResolvedValue({ row_results: [row] } as unknown as ExperimentRunResults)
const revision = vi.spyOn(protocolsApi, 'getRevision').mockResolvedValue({ graph: { nodes: [{ id: 'node-123', type: 'agent', data: { label: 'Published researcher' } }], edges: [] } } as unknown as ProtocolRevision)
render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}><ResultsInspectorPanel experimentId="e" experiment={{} as Experiment} selection={{ type: 'row', rowResultId: 'slot', scope: { protocol_id: 'p' } }} onClose={vi.fn()} /></QueryClientProvider>)
expect(await screen.findByRole('button', { name: /Published researcher/ })).toBeInTheDocument()
expect(screen.queryByText('Canvas node')).not.toBeInTheDocument()
expect(revision).toHaveBeenCalledWith('p', 'publication-old')
})
it('shows published node names with expandable text outputs instead of raw attempt JSON', () => {
const attempt = { run_id: 'run', status: 'completed', node_labels: { 'node-123': 'Researcher', 'node-456': 'Reviewer' },
node_runs: { 'node-123': { status: 'completed', output_text: 'Research findings' }, 'node-456': { status: 'completed', output_text: 'Review answer' } },
attempt_result: { internal_marker: 'Raw attempt dump' },
}
const row = { row_result_id: 'slot', replicate_number: 1, dataset_row: { row_index: 0 }, latest_attempt: attempt, attempts: [attempt] } as unknown as RowResult
render(<QueryClientProvider client={new QueryClient()}><DatasetRowDetail row={row} onClose={vi.fn()} /></QueryClientProvider>)
expect(screen.getByRole('button', { name: /Researcher/ })).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByRole('button', { name: /Reviewer/ })).toHaveAttribute('aria-expanded', 'true')
expect(screen.queryByText('Research findings')).not.toBeInTheDocument()
expect(screen.getByText('Review answer')).toBeInTheDocument()
expect(screen.queryByText(/Raw attempt dump/)).not.toBeInTheDocument()
expect(screen.queryByText('node-123')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Researcher/ }))
expect(screen.getByText('Research findings')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }))
expect(screen.queryByText('Review answer')).not.toBeInTheDocument()
})
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)
Expand Down
39 changes: 33 additions & 6 deletions frontend/src/components/protocol/DatasetRowResults.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useState } from 'react'
import type { ResultsScope } from '@/api/client'
import { useQuery } from '@tanstack/react-query'
import { protocolsApi, 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 { ResultTimelineNode } from './ResultTimelineNode'
import { referenceLabel } from '@/lib/promptReferences'
import { DatasetRowCells } from './DatasetRowCells'
import { ChevronRight, AlertTriangle, CircleCheck, CircleHelp } from 'lucide-react'
import { ResultsPanel, ResultsScorecard } from './ResultsPanel'
Expand Down Expand Up @@ -43,14 +45,39 @@ export function DatasetRowResults({ results, experiment, scope, onInspect }: { r
/>
}

export function DatasetRowDetail({ row, onClose }: { row: RowResult; onClose: () => void }) {
export function DatasetRowDetail({ row, protocolId, onClose }: { row: RowResult; protocolId?: string; 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
// Match the whole-dataset timeline: completed configuration nodes have
// no execution details, while agent traces and failures remain inspectable.
const visibleNodeRuns = Object.entries(attempt?.node_runs ?? {}).filter(([, node]) =>
!!node.run_id || !!node.output_text || !!node.error || ['running', 'failed', 'cancelled'].includes(node.status),
)
const suppliedLabels = { ...row.latest_attempt?.node_labels, ...attempt?.node_labels }
const revisionId = attempt?.protocol_revision_id ?? row.protocol_revision_id
const needsLabels = visibleNodeRuns.some(([id]) => !suppliedLabels[id])
const revision = useQuery({
queryKey: ['protocols', protocolId, 'revision', revisionId],
queryFn: () => protocolsApi.getRevision(protocolId!, revisionId),
enabled: !!protocolId && !!revisionId && needsLabels,
staleTime: Infinity,
})
const publishedLabels = Object.fromEntries((revision.data?.graph.nodes ?? []).map(node => {
const name = node.data.label
return [node.id, typeof name === 'string' && name.trim() ? name.trim() : node.type === 'agent' ? 'Agent' : 'Canvas node']
}))
const nodeLabels = { ...publishedLabels, ...suppliedLabels }
const nodes = visibleNodeRuns.map(([id, node]) => ({
node_id: id, node_label: nodeLabels[id] ?? (revision.isFetching ? 'Loading node name…' : 'Canvas node'), status: node.status,
output_text: node.output_text ?? null, error: node.error ?? null, agent_run_id: node.run_id ?? null,
unresolved_reference_labels: (node.unresolved_references ?? []).map(ref => referenceLabel(ref, nodeLabels)),
input_tokens: null, output_tokens: null, total_tokens: null, cost_usd: null,
}))
const defaultOpenNodeId = nodes.findLast(node => !!node.output_text || !!node.error)?.node_id
return <aside className="absolute inset-0 z-20 flex flex-col overflow-y-auto border-l bg-card p-3" aria-label="Row result details">
<div className="flex items-center justify-between"><h2 className="text-sm">Source row {row.dataset_row.row_index + 1} · Replicate {row.replicate_number}</h2><Button variant="ghost" size="sm" onClick={onClose}>Close</Button></div>
<dl className="my-3 break-all font-mono text-xs">{Object.entries({ 'Dataset UUID': row.dataset_row.dataset_id, 'Original SHA-256': row.dataset_row.raw_sha256, 'Design revision': row.design_revision_id, 'Protocol revision': row.protocol_revision_id, 'Row result': row.row_result_id }).map(([label,value]) => <div key={label}><dt className="text-muted-foreground">{label}</dt><dd>{value}</dd></div>)}</dl>
<details className="my-3 text-xs"><summary className="cursor-pointer text-muted-foreground">Source and revision details</summary><dl className="mt-2 space-y-1 break-all font-mono">{Object.entries({ 'Dataset UUID': row.dataset_row.dataset_id, 'Original SHA-256': row.dataset_row.raw_sha256, 'Design revision': row.design_revision_id, 'Protocol revision': row.protocol_revision_id, 'Row result': row.row_result_id }).map(([label,value]) => <div key={label}><dt className="text-muted-foreground">{label}</dt><dd>{value}</dd></div>)}</dl></details>
<label className="space-y-1 text-xs">Attempt<select className="w-full rounded border bg-background p-2 font-mono" aria-label="Inspect attempt" value={attemptId ?? ''} onChange={event => setAttemptId(event.target.value)}>{row.attempts.map(item => <option key={item.run_id} value={item.run_id}>{item.status} · {item.run_id}{item.current ? ' · latest' : ''}</option>)}</select></label>
{attempt && <><p className="my-2 font-mono text-xs">{attempt.run_id} · {attempt.status}</p>{attempt.error && <p role="alert" className="text-xs text-destructive">{attempt.error}</p>}<pre className="overflow-auto whitespace-pre-wrap break-all font-mono text-xs">{JSON.stringify(attempt.attempt_result, null, 2)}</pre>{provenance ? <pre className="font-mono text-xs">{JSON.stringify(provenance, null, 2)}</pre> : null}{Object.entries(attempt.node_runs).map(([id,node]) => <section key={id} className="my-3 space-y-2"><p className="font-mono text-xs">{id}</p><pre className="whitespace-pre-wrap font-mono text-xs">{node.output_text}</pre>{node.error && <p className="text-xs text-destructive">{node.error}</p>}{node.run_id && <RunStepTrace runId={node.run_id} />}</section>)}</>}
{attempt && <><p className="my-2 font-mono text-xs">{attempt.run_id} · {attempt.status}</p>{attempt.error && <p role="alert" className="text-xs text-destructive">{attempt.error}</p>}<h3 className="mb-2 text-sm font-medium">Node outputs</h3>{nodes.length ? <ol className="space-y-2">{nodes.map(node => <ResultTimelineNode key={`${attempt.run_id}-${node.node_id}`} node={node} defaultOpen={node.node_id === defaultOpenNodeId} />)}</ol> : <p className="text-xs text-muted-foreground">No node output recorded for this attempt.</p>}</>}
</aside>
}
4 changes: 2 additions & 2 deletions frontend/src/components/protocol/DatasetRowRetry.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ 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 () => {
it('retries only the failed row', 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(<QueryClientProvider client={new QueryClient()}><DatasetRowRuns results={results} protocol={protocol} blocked={false} /></QueryClientProvider>)
const buttons = screen.getAllByRole('button', { name: 'Retry' })
expect(buttons[1]).toBeDisabled()
expect(buttons).toHaveLength(1)
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')
Expand Down
Loading
Loading