From 724fc00399ec796868a605f6bc99128a3aaf435c Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 27 May 2026 12:46:16 +0100 Subject: [PATCH 01/14] Complete sample vocabulary remediation --- docs/architecture/02_runtime_lifecycle.md | 10 +- docs/integration-spec/3-structural-checks.md | 4 +- .../route.ts | 4 +- .../src/app/api/health/health.test.ts | 12 +- ergon-dashboard/src/app/api/health/route.ts | 2 +- .../api/samples/[sampleId]/mutations/route.ts | 4 +- .../src/app/api/samples/[sampleId]/route.ts | 4 +- .../src/app/samples/[sampleId]/page.tsx | 4 +- ergon-dashboard/src/app/samples/page.tsx | 6 +- .../src/components/common/StatusBadge.tsx | 4 +- .../sampleRunMetricExplorerModel.test.ts | 6 +- .../sampleRunMetricExplorerModel.ts | 4 +- .../indexes/ExperimentIndexTable.tsx | 4 +- .../components/indexes/SampleIndexTable.tsx | 8 +- .../sample/SampleRuntimeSummaryHeader.tsx | 4 +- .../components/sample/SampleWorkspacePage.tsx | 24 ++-- .../components/sample/UnifiedEventStream.tsx | 40 +++--- .../components/workspace/TaskWorkspace.tsx | 8 +- .../filterTaskEvidenceForTime.test.ts | 8 +- ....test.ts => buildSampleActivities.test.ts} | 32 ++--- ...Activities.ts => buildSampleActivities.ts} | 30 ++--- .../activity/components/ActivityBar.tsx | 8 +- .../components/ActivityStackTimeline.tsx | 14 +-- .../features/activity/currentActivity.test.ts | 4 +- .../src/features/activity/currentActivity.ts | 6 +- .../features/activity/goldenFixture.test.ts | 12 +- .../activity/snapshotSequence.test.ts | 4 +- .../src/features/activity/snapshotSequence.ts | 4 +- .../src/features/activity/stackLayout.test.ts | 6 +- .../src/features/activity/stackLayout.ts | 8 +- .../src/features/activity/types.ts | 8 +- .../graph/layout/goldenLayout.test.ts | 4 +- .../DashboardThreadMessageCreatedEvent.ts | 2 +- ...oardTaskEvaluationUpdatedEvent.schema.json | 6 +- ...boardThreadMessageCreatedEvent.schema.json | 16 +-- .../DashboardWorkflowStartedEvent.schema.json | 36 +++--- .../src/generated/rest/contracts.ts | 36 +++--- .../src/generated/rest/openapi.json | 36 +++--- ...mpleWorkspaceState.socketHydration.test.ts | 10 +- .../src/hooks/useSampleWorkspaceState.ts | 60 ++++----- .../src/inngest/functions/index.ts | 22 ++-- ergon-dashboard/src/lib/contracts/events.ts | 70 +++++------ ergon-dashboard/src/lib/contracts/rest.ts | 116 +++++++++--------- .../src/lib/sample-state/domain.ts | 6 +- .../src/lib/sample-state/hydrate.ts | 22 ++-- ergon-dashboard/src/lib/sample-state/index.ts | 6 +- .../src/lib/sample-state/serialize.ts | 4 +- ergon-dashboard/src/lib/sampleEvents.ts | 64 +++++----- ergon-dashboard/src/lib/sampleState.ts | 10 +- .../src/lib/server-data/samples.ts | 26 ++-- ergon-dashboard/src/lib/socket/server.ts | 60 ++++----- ergon-dashboard/src/lib/state/store.ts | 30 ++--- .../src/lib/testing/dashboardHarness.ts | 40 +++--- ergon-dashboard/src/lib/types.ts | 40 +++--- .../tests/contracts/contracts.test.ts | 22 ++-- .../sample-state-roundtrip.contract.test.ts | 14 +-- .../contracts/server-data.contract.test.ts | 4 +- .../tests/e2e/sample.delta.spec.ts | 4 +- .../toolkits/resources/toolkit.py | 14 +-- ergon_core/ergon_core/api/worker/context.py | 8 +- .../core/application/communication/service.py | 8 +- .../core/application/experiments/models.py | 2 +- .../core/application/runtime/models.py | 16 +-- .../core/application/runtime/orchestration.py | 2 +- .../application/runtime/sample_lifecycle.py | 4 +- .../core/application/runtime/task_models.py | 10 +- .../core/infrastructure/inngest/registry.py | 2 +- .../jobs/{run => sample}/cleanup/__init__.py | 0 .../jobs/{run => sample}/cleanup/contract.py | 0 .../jobs/{run => sample}/cleanup/inngest.py | 4 +- .../core/jobs/{run => sample}/cleanup/job.py | 18 +-- .../core/jobs/task/cleanup_cancelled/job.py | 4 +- .../core/jobs/workflow/complete/job.py | 2 +- .../ergon_core/core/jobs/workflow/fail/job.py | 2 +- .../core/persistence/shared/types.py | 2 +- .../core/views/dashboard_events/contracts.py | 10 +- .../views/dashboard_events/graph_mutations.py | 4 +- .../core/views/samples/evaluation_mapping.py | 4 +- .../ergon_core/core/views/samples/models.py | 24 ++-- .../ergon_core/core/views/samples/snapshot.py | 32 ++--- .../test_job_composition_modules.py | 2 +- .../test_no_deleted_v2_symbols.py | 88 ++++++++++++- .../test_persistence_boundaries.py | 2 +- .../registry/test_inngest_job_registry.py | 6 +- 84 files changed, 704 insertions(+), 628 deletions(-) rename ergon-dashboard/src/app/api/danger/test-harness/dashboard/events/{run-complete => sample-complete}/route.ts (74%) rename ergon-dashboard/src/features/activity/{buildRunActivities.test.ts => buildSampleActivities.test.ts} (90%) rename ergon-dashboard/src/features/activity/{buildRunActivities.ts => buildSampleActivities.ts} (92%) rename ergon_core/ergon_core/core/jobs/{run => sample}/cleanup/__init__.py (100%) rename ergon_core/ergon_core/core/jobs/{run => sample}/cleanup/contract.py (100%) rename ergon_core/ergon_core/core/jobs/{run => sample}/cleanup/inngest.py (88%) rename ergon_core/ergon_core/core/jobs/{run => sample}/cleanup/job.py (80%) diff --git a/docs/architecture/02_runtime_lifecycle.md b/docs/architecture/02_runtime_lifecycle.md index 6655b1362..94d622b60 100644 --- a/docs/architecture/02_runtime_lifecycle.md +++ b/docs/architecture/02_runtime_lifecycle.md @@ -83,10 +83,10 @@ benchmark-run-start ─► workflow/started │ f ▼ workflow-complete / workflow-failed • close RunRecord - • emit run/cleanup + • emit sample/cleanup │ ▼ - run-cleanup + sample-cleanup • terminate external sandbox if present • reconcile RunRecord.status ``` @@ -109,9 +109,9 @@ Dashboard delivery hangs off state mutation (see `05_dashboard.md`); it is not a 6. **Cascade cancellation is one transaction, not an event chain.** `SubtaskCancellationService.cancel_orphans` walks the entire descendant subtree via BFS on `parent_node_id` in a single DB transaction (`subtask_cancellation_service.py:66-111`). A dropped or delayed Inngest event cannot leave a grandchild running under a cancelled parent. The subsequent `task/cancelled` events are for per-node cleanup, not recursion. -7. **Sandbox lifecycle is per-task, teardown happens after evaluators.** `task-execute` invokes `sandbox-setup`, then keeps that sandbox id alive through worker execution, output persistence, evaluator fan-out, and every criterion run. Only after those steps finish does it emit `task/completed`; `sandbox-cleanup-on-completed` and `sandbox-cleanup-on-failed` own teardown from terminal task events. `run-cleanup` remains a run-level reconciliation leg for residual sandbox ids recorded on the `RunRecord.summary_json`. See `03_providers.md` §4 for the definitive treatment. +7. **Sandbox lifecycle is per-task, teardown happens after evaluators.** `task-execute` invokes `sandbox-setup`, then keeps that sandbox id alive through worker execution, output persistence, evaluator fan-out, and every criterion run. Only after those steps finish does it emit `task/completed`; `sandbox-cleanup-on-completed` and `sandbox-cleanup-on-failed` own teardown from terminal task events. `sample-cleanup` remains a sample-level reconciliation leg for residual sandbox ids recorded on the `SampleRecord.summary_json`. See `03_providers.md` §4 for the definitive treatment. -8. **Workflow finalization is replay-safe.** `workflow-complete` and `workflow-failed` re-read the current `RunRecord` and evaluation rows each invocation; repeated delivery writes the same terminal status with the same completion timestamp logic. `run-cleanup` checks the status before overwriting. +8. **Workflow finalization is replay-safe.** `workflow-complete` and `workflow-failed` re-read the current `SampleRecord` and evaluation rows each invocation; repeated delivery writes the same terminal status with the same completion timestamp logic. `sample-cleanup` checks the status before overwriting. ### 4.1 Known limits @@ -164,7 +164,7 @@ A brief index of where runtime functions live. The architectural claims above st | Propagation | `core/jobs/task/propagate/` | | Evaluator execution | `core/jobs/task/evaluate/` | | Cancellation cascade | `core/jobs/task/cancel_orphans/`, `core/jobs/task/cleanup_cancelled/` | -| Finalization | `core/jobs/workflow/complete/`, `core/jobs/workflow/fail/`, `core/jobs/run/cleanup/` | +| Finalization | `core/jobs/workflow/complete/`, `core/jobs/workflow/fail/`, `core/jobs/sample/cleanup/` | | Registry | `core/infrastructure/inngest/registry.py` | | Client + cancel matchers | `core/infrastructure/inngest/client.py` | | Runtime event contracts | `core/application/events/runtime.py` | diff --git a/docs/integration-spec/3-structural-checks.md b/docs/integration-spec/3-structural-checks.md index 217ce7ac9..d2869ba59 100644 --- a/docs/integration-spec/3-structural-checks.md +++ b/docs/integration-spec/3-structural-checks.md @@ -78,9 +78,9 @@ EXPECTED_CALL_GRAPH: dict[str, dict[str, list[str]]] = { "publishers": ["external:api"], "subscribers": ["cancel-run"], }, - "run/cleanup": { + "sample/cleanup": { "publishers": ["complete-workflow", "fail-workflow"], - "subscribers": ["cleanup-run"], + "subscribers": ["sample-cleanup"], }, # criterion/evaluate: publishers unknown, subscribers MISSING — this is the live bug # this entry must be completed and a handler added before this test can pass diff --git a/ergon-dashboard/src/app/api/danger/test-harness/dashboard/events/run-complete/route.ts b/ergon-dashboard/src/app/api/danger/test-harness/dashboard/events/sample-complete/route.ts similarity index 74% rename from ergon-dashboard/src/app/api/danger/test-harness/dashboard/events/run-complete/route.ts rename to ergon-dashboard/src/app/api/danger/test-harness/dashboard/events/sample-complete/route.ts index fb108d60a..d57670115 100644 --- a/ergon-dashboard/src/app/api/danger/test-harness/dashboard/events/run-complete/route.ts +++ b/ergon-dashboard/src/app/api/danger/test-harness/dashboard/events/sample-complete/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { emitHarnessRunCompleted } from "@/lib/testing/dashboardHarness"; +import { emitHarnessSampleCompleted } from "@/lib/testing/dashboardHarness"; export async function POST(request: Request) { const payload = (await request.json()) as { @@ -10,6 +10,6 @@ export async function POST(request: Request) { finalScore: number | null; error: string | null; }; - emitHarnessRunCompleted(payload); + emitHarnessSampleCompleted(payload); return NextResponse.json({ ok: true }); } diff --git a/ergon-dashboard/src/app/api/health/health.test.ts b/ergon-dashboard/src/app/api/health/health.test.ts index 8b982c2b5..bf6f402b1 100644 --- a/ergon-dashboard/src/app/api/health/health.test.ts +++ b/ergon-dashboard/src/app/api/health/health.test.ts @@ -19,16 +19,16 @@ interface HealthResult { } async function runHealthChecks(deps: { - importSSRModules: () => Promise<{ parseRunSnapshot: unknown; TaskStatus: unknown }>; + importSSRModules: () => Promise<{ parseSampleSnapshot: unknown; TaskStatus: unknown }>; fetchErgonApi: (path: string) => Promise<{ ok: boolean; status: number }>; }): Promise { const checks: Record = {}; const errors: string[] = []; try { - const { parseRunSnapshot, TaskStatus } = await deps.importSSRModules(); + const { parseSampleSnapshot, TaskStatus } = await deps.importSSRModules(); checks.ssr_imports = - typeof parseRunSnapshot === "function" && typeof TaskStatus !== "undefined" + typeof parseSampleSnapshot === "function" && typeof TaskStatus !== "undefined" ? "ok" : "fail"; } catch (e) { @@ -51,7 +51,7 @@ async function runHealthChecks(deps: { describe("Health check logic", () => { const okImport = async () => ({ - parseRunSnapshot: () => {}, + parseSampleSnapshot: () => {}, TaskStatus: { COMPLETED: "completed" }, }); @@ -127,10 +127,10 @@ describe("Health check logic", () => { assert.equal(result.errors.length, 2); }); - it("returns fail for ssr_imports when parseRunSnapshot is not a function", async () => { + it("returns fail for ssr_imports when parseSampleSnapshot is not a function", async () => { const result = await runHealthChecks({ importSSRModules: async () => ({ - parseRunSnapshot: "not-a-function", + parseSampleSnapshot: "not-a-function", TaskStatus: { COMPLETED: "completed" }, }), fetchErgonApi: okApi, diff --git a/ergon-dashboard/src/app/api/health/route.ts b/ergon-dashboard/src/app/api/health/route.ts index 6ff59d598..7c28e59aa 100644 --- a/ergon-dashboard/src/app/api/health/route.ts +++ b/ergon-dashboard/src/app/api/health/route.ts @@ -19,7 +19,7 @@ export async function GET() { const rest = await import("@/lib/contracts/rest"); const types = await import("@/lib/types"); checks.ssr_imports = - typeof rest.parseRunSnapshot === "function" && typeof types.TaskStatus !== "undefined" + typeof rest.parseSampleSnapshot === "function" && typeof types.TaskStatus !== "undefined" ? "ok" : "fail"; } catch (e) { diff --git a/ergon-dashboard/src/app/api/samples/[sampleId]/mutations/route.ts b/ergon-dashboard/src/app/api/samples/[sampleId]/mutations/route.ts index 238a8638b..b3809dd22 100644 --- a/ergon-dashboard/src/app/api/samples/[sampleId]/mutations/route.ts +++ b/ergon-dashboard/src/app/api/samples/[sampleId]/mutations/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { config } from "@/lib/config"; import { fetchErgonApi } from "@/lib/serverApi"; -import { getHarnessRunMutations } from "@/lib/testing/dashboardHarness"; +import { getHarnessSampleMutations } from "@/lib/testing/dashboardHarness"; interface RouteContext { params: Promise<{ @@ -15,7 +15,7 @@ export async function GET(_request: Request, context: RouteContext) { try { if (config.enableTestHarness) { - const harnessMutations = getHarnessRunMutations(sampleId); + const harnessMutations = getHarnessSampleMutations(sampleId); if (harnessMutations) { return NextResponse.json(harnessMutations); } diff --git a/ergon-dashboard/src/app/api/samples/[sampleId]/route.ts b/ergon-dashboard/src/app/api/samples/[sampleId]/route.ts index 725ba4538..97dda3e0e 100644 --- a/ergon-dashboard/src/app/api/samples/[sampleId]/route.ts +++ b/ergon-dashboard/src/app/api/samples/[sampleId]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { loadRunSnapshot } from "@/lib/server-data/samples"; +import { loadSampleSnapshot } from "@/lib/server-data/samples"; interface RouteContext { params: Promise<{ @@ -10,7 +10,7 @@ interface RouteContext { export async function GET(_request: Request, context: RouteContext) { const { sampleId } = await context.params; - const result = await loadRunSnapshot(sampleId); + const result = await loadSampleSnapshot(sampleId); if (result.ok) { return NextResponse.json(result.data, { status: result.status }); diff --git a/ergon-dashboard/src/app/samples/[sampleId]/page.tsx b/ergon-dashboard/src/app/samples/[sampleId]/page.tsx index a62ad0a0f..e0d081207 100644 --- a/ergon-dashboard/src/app/samples/[sampleId]/page.tsx +++ b/ergon-dashboard/src/app/samples/[sampleId]/page.tsx @@ -1,5 +1,5 @@ import { SampleWorkspacePage } from "@/components/sample/SampleWorkspacePage"; -import { loadRunSnapshot } from "@/lib/server-data/samples"; +import { loadSampleSnapshot } from "@/lib/server-data/samples"; import type { SerializedSampleWorkspaceState } from "@/lib/types"; interface LegacyRunPageProps { @@ -13,7 +13,7 @@ export default async function RunPage({ params }: LegacyRunPageProps) { let initialRunState: SerializedSampleWorkspaceState | null = null; let ssrError: string | null = null; - const result = await loadRunSnapshot(sampleId); + const result = await loadSampleSnapshot(sampleId); if (result.ok) { initialRunState = result.data; } else { diff --git a/ergon-dashboard/src/app/samples/page.tsx b/ergon-dashboard/src/app/samples/page.tsx index 97393b9c2..42d8ce6ca 100644 --- a/ergon-dashboard/src/app/samples/page.tsx +++ b/ergon-dashboard/src/app/samples/page.tsx @@ -1,11 +1,11 @@ import { SampleIndexTable } from "@/components/indexes/SampleIndexTable"; -import { loadRunList, type RunSummary } from "@/lib/server-data/samples"; +import { loadSampleList, type SampleSummary } from "@/lib/server-data/samples"; export default async function RunsPage() { - let runs: RunSummary[] = []; + let runs: SampleSummary[] = []; let error: string | null = null; - const result = await loadRunList({ limit: 100 }); + const result = await loadSampleList({ limit: 100 }); if (result.ok) { runs = result.data; } else { diff --git a/ergon-dashboard/src/components/common/StatusBadge.tsx b/ergon-dashboard/src/components/common/StatusBadge.tsx index 4c8ce3b9d..12df8b03a 100644 --- a/ergon-dashboard/src/components/common/StatusBadge.tsx +++ b/ergon-dashboard/src/components/common/StatusBadge.tsx @@ -1,8 +1,8 @@ "use client"; -import { RunLifecycleStatus, TaskStatus } from "@/lib/types"; +import { SampleLifecycleStatus, TaskStatus } from "@/lib/types"; -type StatusType = TaskStatus | RunLifecycleStatus | string; +type StatusType = TaskStatus | SampleLifecycleStatus | string; interface StatusConfig { label: string; diff --git a/ergon-dashboard/src/components/experiments/sampleRunMetricExplorerModel.test.ts b/ergon-dashboard/src/components/experiments/sampleRunMetricExplorerModel.test.ts index 85534f84f..4837b3f5f 100644 --- a/ergon-dashboard/src/components/experiments/sampleRunMetricExplorerModel.test.ts +++ b/ergon-dashboard/src/components/experiments/sampleRunMetricExplorerModel.test.ts @@ -12,11 +12,11 @@ import { } from "./sampleRunMetricExplorerModel"; const definitionId = "11111111-1111-4111-8111-111111111111"; -const defaultRunId = "22222222-2222-4222-8222-222222222222"; +const defaultSampleId = "22222222-2222-4222-8222-222222222222"; function metrics(overrides: Partial = {}): ExperimentRunRow["metrics"] { return { - sample_id: defaultRunId, + sample_id: defaultSampleId, status: "completed", instance_key: "sample-a", tool_call_count: 0, @@ -27,7 +27,7 @@ function metrics(overrides: Partial = {}): Experime function runRow(overrides: Partial = {}): ExperimentRunRow { return { - sample_id: defaultRunId, + sample_id: defaultSampleId, definition_id: definitionId, benchmark_type: "minif2f", instance_key: "sample-a", diff --git a/ergon-dashboard/src/components/experiments/sampleRunMetricExplorerModel.ts b/ergon-dashboard/src/components/experiments/sampleRunMetricExplorerModel.ts index fb00704cd..e03d74371 100644 --- a/ergon-dashboard/src/components/experiments/sampleRunMetricExplorerModel.ts +++ b/ergon-dashboard/src/components/experiments/sampleRunMetricExplorerModel.ts @@ -1,4 +1,4 @@ -import type { ExperimentRunRow, RunLifecycleStatus } from "@/lib/contracts/rest"; +import type { ExperimentRunRow, SampleLifecycleStatus } from "@/lib/contracts/rest"; import { formatDurationMs } from "@/lib/formatDuration"; export type RunMetricKey = @@ -27,7 +27,7 @@ export interface RunMetricValue { export interface RunMetricPoint { sampleId: string; runName: string; - status: RunLifecycleStatus | string; + status: SampleLifecycleStatus | string; sampleLabel: string; instanceKey: string; modelTarget: string | null; diff --git a/ergon-dashboard/src/components/indexes/ExperimentIndexTable.tsx b/ergon-dashboard/src/components/indexes/ExperimentIndexTable.tsx index 963a5e12e..94ea847bc 100644 --- a/ergon-dashboard/src/components/indexes/ExperimentIndexTable.tsx +++ b/ergon-dashboard/src/components/indexes/ExperimentIndexTable.tsx @@ -6,7 +6,7 @@ import { useMemo, useState } from "react"; import { StatusBadge } from "@/components/common/StatusBadge"; import { formatDateTime, formatDurationSeconds, formatPercent } from "@/components/indexes/format"; import type { ExperimentSummary } from "@/lib/server-data/experiments"; -import type { RunLifecycleStatus } from "@/lib/types"; +import type { SampleLifecycleStatus } from "@/lib/types"; export function ExperimentIndexTable({ experiments }: { experiments: ExperimentSummary[] }) { const [query, setQuery] = useState(""); @@ -97,7 +97,7 @@ export function ExperimentIndexTable({ experiments }: { experiments: ExperimentS {experiment.failure_count} - + {formatPercent(experiment.average_score)} diff --git a/ergon-dashboard/src/components/indexes/SampleIndexTable.tsx b/ergon-dashboard/src/components/indexes/SampleIndexTable.tsx index 84e79fe15..ecd1fabe4 100644 --- a/ergon-dashboard/src/components/indexes/SampleIndexTable.tsx +++ b/ergon-dashboard/src/components/indexes/SampleIndexTable.tsx @@ -10,10 +10,10 @@ import { formatNumber, formatPercent, } from "@/components/indexes/format"; -import type { RunSummary } from "@/lib/server-data/samples"; -import type { RunLifecycleStatus } from "@/lib/types"; +import type { SampleSummary } from "@/lib/server-data/samples"; +import type { SampleLifecycleStatus } from "@/lib/types"; -export function SampleIndexTable({ runs }: { runs: RunSummary[] }) { +export function SampleIndexTable({ runs }: { runs: SampleSummary[] }) { const [query, setQuery] = useState(""); const [status, setStatus] = useState("all"); @@ -110,7 +110,7 @@ export function SampleIndexTable({ runs }: { runs: RunSummary[] }) {
{run.sample_label}
- + {run.error_message ? (
{run.error_message} diff --git a/ergon-dashboard/src/components/sample/SampleRuntimeSummaryHeader.tsx b/ergon-dashboard/src/components/sample/SampleRuntimeSummaryHeader.tsx index 0fbea60ac..b8efac16d 100644 --- a/ergon-dashboard/src/components/sample/SampleRuntimeSummaryHeader.tsx +++ b/ergon-dashboard/src/components/sample/SampleRuntimeSummaryHeader.tsx @@ -8,7 +8,7 @@ import { type MetricDisplay, } from "@/lib/sample-state/formatters"; -export interface RunHeaderMetricValues { +export interface SampleHeaderMetricValues { tasks: { completed: number; running: number; @@ -59,7 +59,7 @@ function MetricTile({ ); } -export function SampleRuntimeSummaryHeader({ metrics }: { metrics: RunHeaderMetricValues }) { +export function SampleRuntimeSummaryHeader({ metrics }: { metrics: SampleHeaderMetricValues }) { return (
([]); const requestedSequenceRef = useRef(null); - const pendingActivityResolutionRef = useRef(null); + const pendingActivityResolutionRef = useRef(null); const selectedActivityIdRef = useRef(null); const mutationsLoadedRef = useRef(false); @@ -157,7 +157,7 @@ export function SampleWorkspacePage({ return { leafStatusCounts: empty, leafTotal: total }; }, [displayState]); - const runHeaderMetrics: RunHeaderMetricValues = useMemo(() => { + const runHeaderMetrics: SampleHeaderMetricValues = useMemo(() => { const optionalMetrics = runState as (SampleWorkspaceState & OptionalRunMetrics) | null; return { tasks: { @@ -174,14 +174,14 @@ export function SampleWorkspacePage({ }, [leafStatusCounts, leafTotal, runState]); // D4: Unified event log for the replayed inspector view. - const events = useMemo(() => buildRunEvents(displayState), [displayState]); + const events = useMemo(() => buildSampleEvents(displayState), [displayState]); // Trace spans are an immutable map of the full run. Replay moves the cursor // over this map; it should not relayout or clip completed spans. - const traceEvents = useMemo(() => buildRunEvents(runState), [runState]); + const traceEvents = useMemo(() => buildSampleEvents(runState), [runState]); const activities = useMemo( () => - buildRunActivities({ + buildSampleActivities({ runState, events: traceEvents, mutations, @@ -253,7 +253,7 @@ export function SampleWorkspacePage({ setSnapshotSequence(mutation?.sequence ?? sequence); }; - const handleActivityClick = (activity: RunActivity) => { + const handleActivityClick = (activity: SampleActivity) => { setSelectionNotice(null); requestedSequenceRef.current = null; selectedActivityIdRef.current = activity.id; @@ -289,7 +289,7 @@ export function SampleWorkspacePage({

{runState?.name ?? sampleId}

- + {snapshotSequence === null ? "live" : `snapshot · seq ${snapshotSequence}`} · {formatDuration(runState?.durationSeconds ?? null).value} diff --git a/ergon-dashboard/src/components/sample/UnifiedEventStream.tsx b/ergon-dashboard/src/components/sample/UnifiedEventStream.tsx index 105bf0933..6c47d98bb 100644 --- a/ergon-dashboard/src/components/sample/UnifiedEventStream.tsx +++ b/ergon-dashboard/src/components/sample/UnifiedEventStream.tsx @@ -1,7 +1,7 @@ "use client"; /** - * UnifiedEventStream — a single chronological feed of every RunEvent the run + * UnifiedEventStream — a single chronological feed of every SampleEvent the run * produced, with per-kind filtering and click-to-focus interactions. * * Prior to this component, the dashboard rendered task transitions, generation @@ -14,12 +14,12 @@ import { useMemo, useState } from "react"; import { - RUN_EVENT_KINDS, - RUN_EVENT_KIND_COLORS, - RUN_EVENT_KIND_LABELS, + SAMPLE_EVENT_KINDS, + SAMPLE_EVENT_KIND_COLORS, + SAMPLE_EVENT_KIND_LABELS, countEventsByKind, - type RunEvent, - type RunEventKind, + type SampleEvent, + type SampleEventKind, } from "@/lib/sampleEvents"; import { formatClockTimeMs } from "@/lib/timeFormat"; import { TransitionChip } from "@/components/common/TransitionChip"; @@ -46,7 +46,7 @@ function truncate(s: string, n: number): string { } interface EventRowProps { - event: RunEvent; + event: SampleEvent; anchorMs: number | null; isHighlighted: boolean; onTaskClick?: (taskId: string) => void; @@ -60,8 +60,8 @@ function EventRow({ onTaskClick, onSequenceClick, }: EventRowProps) { - const laneColor = RUN_EVENT_KIND_COLORS[event.kind]; - const label = RUN_EVENT_KIND_LABELS[event.kind]; + const laneColor = SAMPLE_EVENT_KIND_COLORS[event.kind]; + const label = SAMPLE_EVENT_KIND_LABELS[event.kind]; return (
  • - Workflow {event.runName} started. + Workflow {event.sampleName} started.
  • ); case "workflow.completed": @@ -225,7 +225,7 @@ function EventBody({ event }: { event: RunEvent }) { } export interface UnifiedEventStreamProps { - events: RunEvent[]; + events: SampleEvent[]; /** Anchor used for relative offsets; defaults to first event's wall-clock. */ anchor?: string | null; /** When set, rows for this task are highlighted. */ @@ -233,7 +233,7 @@ export interface UnifiedEventStreamProps { onTaskClick?: (taskId: string) => void; onSequenceClick?: (sequence: number) => void; /** Initial per-kind filter; absent kinds default to enabled. */ - initialEnabledKinds?: Partial>; + initialEnabledKinds?: Partial>; /** Cap rows rendered at once; oldest truncated with a "show more" toggle. */ maxRows?: number; } @@ -247,11 +247,11 @@ export function UnifiedEventStream({ initialEnabledKinds, maxRows = 500, }: UnifiedEventStreamProps) { - const [enabledKinds, setEnabledKinds] = useState>( + const [enabledKinds, setEnabledKinds] = useState>( () => Object.fromEntries( - RUN_EVENT_KINDS.map((k) => [k, initialEnabledKinds?.[k] ?? true]), - ) as Record, + SAMPLE_EVENT_KINDS.map((k) => [k, initialEnabledKinds?.[k] ?? true]), + ) as Record, ); const [query, setQuery] = useState(""); const [showAll, setShowAll] = useState(false); @@ -269,7 +269,7 @@ export function UnifiedEventStream({ if (!enabledKinds[e.kind]) return false; if (!q) return true; // cheap full-text: label + JSON of event fields - const hay = `${RUN_EVENT_KIND_LABELS[e.kind]} ${JSON.stringify(e)}`.toLowerCase(); + const hay = `${SAMPLE_EVENT_KIND_LABELS[e.kind]} ${JSON.stringify(e)}`.toLowerCase(); return hay.includes(q); }); }, [events, enabledKinds, query]); @@ -301,10 +301,10 @@ export function UnifiedEventStream({
    - {RUN_EVENT_KINDS.map((kind) => { + {SAMPLE_EVENT_KINDS.map((kind) => { const on = enabledKinds[kind]; const count = counts[kind]; - const tone = RUN_EVENT_KIND_COLORS[kind]; + const tone = SAMPLE_EVENT_KIND_COLORS[kind]; return ( diff --git a/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx b/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx index d7700b57b..59f6a0435 100644 --- a/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx +++ b/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx @@ -10,7 +10,7 @@ import { ResourcePanel } from "@/components/panels/ResourcePanel"; import { SandboxPanel } from "@/components/panels/SandboxPanel"; import { TaskTransitionLog } from "@/components/workspace/TaskTransitionLog"; import { ContextEventLog } from "@/features/graph/components/ContextEventLog"; -import type { RunActivity } from "@/features/activity/types"; +import type { SampleActivity } from "@/features/activity/types"; import type { SampleWorkspaceState } from "@/lib/types"; import { formatClockTime } from "@/lib/timeFormat"; import { formatTaskWallTimestamp } from "@/features/graph/utils/taskTiming"; @@ -20,7 +20,7 @@ function EmptySection({ message }: { message: string }) { return
    {message}
    ; } -const ACTIVITY_KIND_TITLE: Record = { +const ACTIVITY_KIND_TITLE: Record = { execution: "Execution", graph: "Graph mutation", message: "Message", @@ -30,7 +30,7 @@ const ACTIVITY_KIND_TITLE: Record = { sandbox: "Sandbox", }; -function ActivityDetail({ activity }: { activity: RunActivity }) { +function ActivityDetail({ activity }: { activity: SampleActivity }) { const metadata = Object.entries(activity.metadata) .filter(([, value]) => value !== null && value !== "") .slice(0, 4); @@ -156,7 +156,7 @@ export function TaskWorkspace({ onJumpToSequence?: (sequence: number) => void; selectedTime?: string | null; selectedSequence?: number | null; - selectedActivity?: RunActivity | null; + selectedActivity?: SampleActivity | null; }) { const { task, resources, executions, sandbox, threads, evaluation, dependencies, isLoading } = useTaskDetails(runState, taskId); diff --git a/ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.test.ts b/ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.test.ts index 74ed1c7a1..a215cd87c 100644 --- a/ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.test.ts +++ b/ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.test.ts @@ -2,13 +2,13 @@ import assert from "node:assert/strict"; import test from "node:test"; import fixture from "../../../tests/fixtures/mas-samples/concurrent-mas-run.json"; -import { deserializeRunState } from "@/lib/sampleState"; +import { deserializeSampleState } from "@/lib/sampleState"; import { filterTaskEvidenceForTime } from "./filterTaskEvidenceForTime"; const searchTaskId = "10000000-0000-4000-8000-000000000002"; test("filterTaskEvidenceForTime hides task evidence created after the selected timeline time", () => { - const runState = deserializeRunState(fixture.runState); + const runState = deserializeSampleState(fixture.runState); const filtered = filterTaskEvidenceForTime({ resources: runState.resourcesByTask.get(searchTaskId) ?? [], executions: runState.executionsByTask.get(searchTaskId) ?? [], @@ -26,7 +26,7 @@ test("filterTaskEvidenceForTime hides task evidence created after the selected t }); test("filterTaskEvidenceForTime returns unfiltered task evidence in live mode", () => { - const runState = deserializeRunState(fixture.runState); + const runState = deserializeSampleState(fixture.runState); const filtered = filterTaskEvidenceForTime({ resources: runState.resourcesByTask.get(searchTaskId) ?? [], executions: runState.executionsByTask.get(searchTaskId) ?? [], @@ -42,7 +42,7 @@ test("filterTaskEvidenceForTime returns unfiltered task evidence in live mode", }); test("filterTaskEvidenceForTime keeps only thread messages visible at selected time", () => { - const runState = deserializeRunState(fixture.runState); + const runState = deserializeSampleState(fixture.runState); const thread = runState.threads[0]; const filtered = filterTaskEvidenceForTime({ resources: [], diff --git a/ergon-dashboard/src/features/activity/buildRunActivities.test.ts b/ergon-dashboard/src/features/activity/buildSampleActivities.test.ts similarity index 90% rename from ergon-dashboard/src/features/activity/buildRunActivities.test.ts rename to ergon-dashboard/src/features/activity/buildSampleActivities.test.ts index 4ff28454f..537d224b2 100644 --- a/ergon-dashboard/src/features/activity/buildRunActivities.test.ts +++ b/ergon-dashboard/src/features/activity/buildSampleActivities.test.ts @@ -3,15 +3,15 @@ import test from "node:test"; import fixture from "../../../tests/fixtures/mas-samples/concurrent-mas-run.json"; import { parseGraphMutationDtoArray } from "@/features/graph/contracts/graphMutations"; -import type { RunEvent } from "@/lib/sampleEvents"; -import { buildRunEvents } from "@/lib/sampleEvents"; -import { deserializeRunState } from "@/lib/sampleState"; +import type { SampleEvent } from "@/lib/sampleEvents"; +import { buildSampleEvents } from "@/lib/sampleEvents"; +import { deserializeSampleState } from "@/lib/sampleState"; import { TaskStatus, TaskTrigger } from "@/lib/types"; -import { buildRunActivities } from "./buildRunActivities"; +import { buildSampleActivities } from "./buildSampleActivities"; import { resolveActivitySnapshotSequence } from "./snapshotSequence"; -test("buildRunActivities surfaces semantic activity kinds without creating actor lanes", () => { - const runState = deserializeRunState(fixture.runState); +test("buildSampleActivities surfaces semantic activity kinds without creating actor lanes", () => { + const runState = deserializeSampleState(fixture.runState); const mutations = parseGraphMutationDtoArray(fixture.mutations); const noisyTaskId = "10000000-0000-4000-8000-000000000002"; runState.sandboxesByTask.set(noisyTaskId, { @@ -102,12 +102,12 @@ test("buildRunActivities surfaces semantic activity kinds without creating actor ], }, ]; - const markerEvents: RunEvent[] = [ + const markerEvents: SampleEvent[] = [ { id: "marker-workflow-started", kind: "workflow.started", at: "2025-01-01T00:00:06.000Z", - runName: "Marker workflow", + sampleName: "Marker workflow", }, { id: "marker-workflow-completed", @@ -165,9 +165,9 @@ test("buildRunActivities surfaces semantic activity kinds without creating actor note: "Unhandled marker mutation", }, ]; - const events = [...buildRunEvents(runState), ...markerEvents]; + const events = [...buildSampleEvents(runState), ...markerEvents]; - const activities = buildRunActivities({ + const activities = buildSampleActivities({ runState, events, mutations, @@ -246,11 +246,11 @@ test("buildRunActivities surfaces semantic activity kinds without creating actor }); test("completed trace spans keep full duration when replaying an earlier sequence", () => { - const runState = deserializeRunState(fixture.runState); + const runState = deserializeSampleState(fixture.runState); const mutations = parseGraphMutationDtoArray(fixture.mutations); - const events = buildRunEvents(runState); + const events = buildSampleEvents(runState); - const activities = buildRunActivities({ + const activities = buildSampleActivities({ runState, events, mutations, @@ -275,11 +275,11 @@ test("completed trace spans keep full duration when replaying an earlier sequenc }); test("context/tool event sequence does not masquerade as graph replay sequence", () => { - const runState = deserializeRunState(fixture.runState); + const runState = deserializeSampleState(fixture.runState); const mutations = parseGraphMutationDtoArray(fixture.mutations); - const activities = buildRunActivities({ + const activities = buildSampleActivities({ runState, - events: buildRunEvents(runState), + events: buildSampleEvents(runState), mutations, currentSequence: null, }); diff --git a/ergon-dashboard/src/features/activity/buildRunActivities.ts b/ergon-dashboard/src/features/activity/buildSampleActivities.ts similarity index 92% rename from ergon-dashboard/src/features/activity/buildRunActivities.ts rename to ergon-dashboard/src/features/activity/buildSampleActivities.ts index 5e8b43d02..6be5e2ba0 100644 --- a/ergon-dashboard/src/features/activity/buildRunActivities.ts +++ b/ergon-dashboard/src/features/activity/buildSampleActivities.ts @@ -5,12 +5,12 @@ import type { SandboxCommandState, SampleWorkspaceState, } from "@/lib/types"; -import type { RunEvent } from "@/lib/sampleEvents"; -import type { RunActivity } from "./types"; +import type { SampleEvent } from "@/lib/sampleEvents"; +import type { SampleActivity } from "./types"; -export interface BuildRunActivitiesInput { +export interface BuildSampleActivitiesInput { runState: SampleWorkspaceState | null; - events: RunEvent[]; + events: SampleEvent[]; mutations: GraphMutationDto[]; currentSequence: number | null; } @@ -19,7 +19,7 @@ function isFiniteTime(value: string | null | undefined): value is string { return typeof value === "string" && Number.isFinite(Date.parse(value)); } -function compareActivity(a: RunActivity, b: RunActivity): number { +function compareActivity(a: SampleActivity, b: SampleActivity): number { if (a.startAt !== b.startAt) return a.startAt.localeCompare(b.startAt); const aSeq = a.sequence ?? -1; const bSeq = b.sequence ?? -1; @@ -45,8 +45,8 @@ function addMs(timestamp: string, durationMs: number | null): string | null { function executionActivities( run: SampleWorkspaceState, -): RunActivity[] { - const activities: RunActivity[] = []; +): SampleActivity[] { + const activities: SampleActivity[] = []; for (const executions of run.executionsByTask.values()) { for (const execution of executions) { if (!isFiniteTime(execution.startedAt)) continue; @@ -90,8 +90,8 @@ function sandboxCommandLabel(command: SandboxCommandState): string { function sandboxActivities( run: SampleWorkspaceState, -): RunActivity[] { - const activities: RunActivity[] = []; +): SampleActivity[] { + const activities: SampleActivity[] = []; for (const sandbox of run.sandboxesByTask.values()) { if (isFiniteTime(sandbox.createdAt)) { const endAt = sandbox.closedAt; @@ -172,8 +172,8 @@ function contextLabel(event: ContextEventState): string { return payloadType ?? event.eventType; } -function contextActivities(run: SampleWorkspaceState): RunActivity[] { - const activities: RunActivity[] = []; +function contextActivities(run: SampleWorkspaceState): SampleActivity[] { + const activities: SampleActivity[] = []; for (const [taskId, events] of run.contextEventsByTask.entries()) { for (const event of events) { const startAt = event.startedAt ?? event.createdAt; @@ -212,8 +212,8 @@ function contextActivities(run: SampleWorkspaceState): RunActivity[] { return activities; } -function eventMarkerActivities(events: RunEvent[]): RunActivity[] { - return events.flatMap((event): RunActivity[] => { +function eventMarkerActivities(events: SampleEvent[]): SampleActivity[] { + return events.flatMap((event): SampleActivity[] => { switch (event.kind) { case "thread.message": return [ @@ -302,7 +302,7 @@ function eventMarkerActivities(events: RunEvent[]): RunActivity[] { }); } -function graphMutationActivities(mutations: GraphMutationDto[]): RunActivity[] { +function graphMutationActivities(mutations: GraphMutationDto[]): SampleActivity[] { return mutations.map((mutation) => ({ id: `graph:${mutation.id}`, kind: "graph", @@ -330,7 +330,7 @@ function graphMutationActivities(mutations: GraphMutationDto[]): RunActivity[] { })); } -export function buildRunActivities(input: BuildRunActivitiesInput): RunActivity[] { +export function buildSampleActivities(input: BuildSampleActivitiesInput): SampleActivity[] { if (!input.runState) return []; return [ ...executionActivities(input.runState), diff --git a/ergon-dashboard/src/features/activity/components/ActivityBar.tsx b/ergon-dashboard/src/features/activity/components/ActivityBar.tsx index e122bd828..82e438976 100644 --- a/ergon-dashboard/src/features/activity/components/ActivityBar.tsx +++ b/ergon-dashboard/src/features/activity/components/ActivityBar.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ActivityStackItem, ActivityKind, RunActivity } from "@/features/activity/types"; +import type { ActivityStackItem, ActivityKind, SampleActivity } from "@/features/activity/types"; const KIND_STYLES: Record< ActivityKind, @@ -64,7 +64,7 @@ export function activityKindColor(kind: ActivityKind): string { export const ALL_ACTIVITY_KINDS = Object.keys(KIND_STYLES) as ActivityKind[]; -function testIdFor(activity: RunActivity): string { +function testIdFor(activity: SampleActivity): string { return `activity-bar-${activity.id.replace(/[^a-zA-Z0-9_-]/g, "-")}`; } @@ -83,8 +83,8 @@ export function ActivityBar({ highlighted: boolean; current: boolean; relation: "focused" | "related" | "dimmed" | "none"; - onClick: (activity: RunActivity) => void; - onHoverStart: (activity: RunActivity) => void; + onClick: (activity: SampleActivity) => void; + onHoverStart: (activity: SampleActivity) => void; onHoverEnd: () => void; }) { const { activity, leftPct, widthPct } = item; diff --git a/ergon-dashboard/src/features/activity/components/ActivityStackTimeline.tsx b/ergon-dashboard/src/features/activity/components/ActivityStackTimeline.tsx index 947e0eb1b..0e530cd91 100644 --- a/ergon-dashboard/src/features/activity/components/ActivityStackTimeline.tsx +++ b/ergon-dashboard/src/features/activity/components/ActivityStackTimeline.tsx @@ -4,18 +4,18 @@ import { useMemo, useState } from "react"; import type { GraphMutationDto } from "@/features/graph/contracts/graphMutations"; import { ACTIVITY_BAND_ORDER, stackActivities } from "@/features/activity/stackLayout"; -import type { ActivityBand, RunActivity } from "@/features/activity/types"; +import type { ActivityBand, SampleActivity } from "@/features/activity/types"; import { resolveCurrentActivityId } from "@/features/activity/currentActivity"; import { formatClockTime } from "@/lib/timeFormat"; import { ActivityBar, activityKindLegendLabel, activityKindColor } from "./ActivityBar"; interface ActivityStackTimelineProps { - activities: RunActivity[]; + activities: SampleActivity[]; mutations: GraphMutationDto[]; currentSequence: number | null; selectedTaskId: string | null; selectedActivityId: string | null; - onActivityClick: (activity: RunActivity) => void; + onActivityClick: (activity: SampleActivity) => void; onReturnToLive?: () => void; } @@ -66,7 +66,7 @@ function lineageValueMatches( return Boolean(a && b && a === b); } -function areActivitiesRelated(a: RunActivity, b: RunActivity): boolean { +function areActivitiesRelated(a: SampleActivity, b: SampleActivity): boolean { if (a.id === b.id) return true; return ( lineageValueMatches(a.lineage.taskExecutionId, b.lineage.taskExecutionId) || @@ -76,7 +76,7 @@ function areActivitiesRelated(a: RunActivity, b: RunActivity): boolean { ); } -function debugPreview(activity: RunActivity): string { +function debugPreview(activity: SampleActivity): string { return JSON.stringify( { kind: activity.kind, @@ -96,8 +96,8 @@ function ActivityLineageCard({ activity, related, }: { - activity: RunActivity; - related: RunActivity[]; + activity: SampleActivity; + related: SampleActivity[]; }) { const relatedSummary = related .filter((candidate) => candidate.id !== activity.id) diff --git a/ergon-dashboard/src/features/activity/currentActivity.test.ts b/ergon-dashboard/src/features/activity/currentActivity.test.ts index 47b356577..c15173b9b 100644 --- a/ergon-dashboard/src/features/activity/currentActivity.test.ts +++ b/ergon-dashboard/src/features/activity/currentActivity.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { RunActivity } from "./types"; +import type { SampleActivity } from "./types"; import { resolveCurrentActivityId } from "./currentActivity"; -function activity(id: string, startAt: string, sequence: number | null = null): RunActivity { +function activity(id: string, startAt: string, sequence: number | null = null): SampleActivity { return { id, kind: "graph", diff --git a/ergon-dashboard/src/features/activity/currentActivity.ts b/ergon-dashboard/src/features/activity/currentActivity.ts index 6308e694c..003d85f52 100644 --- a/ergon-dashboard/src/features/activity/currentActivity.ts +++ b/ergon-dashboard/src/features/activity/currentActivity.ts @@ -1,4 +1,4 @@ -import type { RunActivity } from "./types"; +import type { SampleActivity } from "./types"; function parseTime(value: string): number { const parsed = Date.parse(value); @@ -6,7 +6,7 @@ function parseTime(value: string): number { } export function resolveCurrentActivityId( - activities: RunActivity[], + activities: SampleActivity[], currentTimestamp: string | null, currentSequence: number | null = null, ): string | null { @@ -14,7 +14,7 @@ export function resolveCurrentActivityId( const currentMs = Date.parse(currentTimestamp); if (!Number.isFinite(currentMs)) return null; - let selected: RunActivity | null = null; + let selected: SampleActivity | null = null; let selectedMs = Number.NEGATIVE_INFINITY; for (const activity of activities) { const activityMs = parseTime(activity.startAt); diff --git a/ergon-dashboard/src/features/activity/goldenFixture.test.ts b/ergon-dashboard/src/features/activity/goldenFixture.test.ts index 431cd8583..9f81ae1ba 100644 --- a/ergon-dashboard/src/features/activity/goldenFixture.test.ts +++ b/ergon-dashboard/src/features/activity/goldenFixture.test.ts @@ -4,10 +4,10 @@ import test from "node:test"; import fixture from "../../../tests/fixtures/mas-samples/concurrent-mas-run.json"; import { parseGraphMutationDtoArray } from "@/features/graph/contracts/graphMutations"; import { replayToSequence } from "@/features/graph/state/graphMutationReducer"; -import { buildRunEvents } from "@/lib/sampleEvents"; -import { deserializeRunState } from "@/lib/sampleState"; +import { buildSampleEvents } from "@/lib/sampleEvents"; +import { deserializeSampleState } from "@/lib/sampleState"; import type { SampleWorkspaceState } from "@/lib/types"; -import { buildRunActivities } from "./buildRunActivities"; +import { buildSampleActivities } from "./buildSampleActivities"; import { stackActivities } from "./stackLayout"; function emptyRunStateFrom(runState: SampleWorkspaceState): SampleWorkspaceState { @@ -33,7 +33,7 @@ function emptyRunStateFrom(runState: SampleWorkspaceState): SampleWorkspaceState } test("golden concurrent fixture replays the whole graph at selected sequence and stacks overlapping activity", () => { - const runState = deserializeRunState(fixture.runState); + const runState = deserializeSampleState(fixture.runState); const mutations = parseGraphMutationDtoArray(fixture.mutations); const checkpoint = fixture.checkpoints.find((entry) => entry.sequence === 14); assert.ok(checkpoint); @@ -44,9 +44,9 @@ test("golden concurrent fixture replays the whole graph at selected sequence and emptyRunStateFrom(runState), new Map(), ); - const activities = buildRunActivities({ + const activities = buildSampleActivities({ runState, - events: buildRunEvents(runState), + events: buildSampleEvents(runState), mutations, currentSequence: checkpoint.sequence, }); diff --git a/ergon-dashboard/src/features/activity/snapshotSequence.test.ts b/ergon-dashboard/src/features/activity/snapshotSequence.test.ts index dacf820ac..416d21172 100644 --- a/ergon-dashboard/src/features/activity/snapshotSequence.test.ts +++ b/ergon-dashboard/src/features/activity/snapshotSequence.test.ts @@ -2,10 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { GraphMutationDto } from "@/features/graph/contracts/graphMutations"; -import type { RunActivity } from "./types"; +import type { SampleActivity } from "./types"; import { resolveActivitySnapshotSequence } from "./snapshotSequence"; -function activity(overrides: Partial = {}): RunActivity { +function activity(overrides: Partial = {}): SampleActivity { return { id: "activity-1", kind: "execution", diff --git a/ergon-dashboard/src/features/activity/snapshotSequence.ts b/ergon-dashboard/src/features/activity/snapshotSequence.ts index 0e3a3c4d1..15876619d 100644 --- a/ergon-dashboard/src/features/activity/snapshotSequence.ts +++ b/ergon-dashboard/src/features/activity/snapshotSequence.ts @@ -1,8 +1,8 @@ import type { GraphMutationDto } from "@/features/graph/contracts/graphMutations"; -import type { RunActivity } from "./types"; +import type { SampleActivity } from "./types"; export function resolveActivitySnapshotSequence( - activity: RunActivity, + activity: SampleActivity, mutations: GraphMutationDto[], ): number | null { if (activity.sequence !== null) return activity.sequence; diff --git a/ergon-dashboard/src/features/activity/stackLayout.test.ts b/ergon-dashboard/src/features/activity/stackLayout.test.ts index b1dfaefbe..f1c2daa60 100644 --- a/ergon-dashboard/src/features/activity/stackLayout.test.ts +++ b/ergon-dashboard/src/features/activity/stackLayout.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { RunActivity } from "./types"; +import type { SampleActivity } from "./types"; import { stackActivities } from "./stackLayout"; function activity( @@ -9,7 +9,7 @@ function activity( startAt: string, endAt: string | null, actor: string | null = null, -): RunActivity { +): SampleActivity { return { id, kind: "execution", @@ -28,7 +28,7 @@ function activity( }; } -function marker(id: string, startAt: string): RunActivity { +function marker(id: string, startAt: string): SampleActivity { return { id, kind: "graph", diff --git a/ergon-dashboard/src/features/activity/stackLayout.ts b/ergon-dashboard/src/features/activity/stackLayout.ts index 67c3959bb..5e3cd9569 100644 --- a/ergon-dashboard/src/features/activity/stackLayout.ts +++ b/ergon-dashboard/src/features/activity/stackLayout.ts @@ -1,4 +1,4 @@ -import type { ActivityBand, ActivityStackLayout, RunActivity } from "./types"; +import type { ActivityBand, ActivityStackLayout, SampleActivity } from "./types"; export interface StackActivityOptions { minMarkerWidthPct?: number; @@ -7,7 +7,7 @@ export interface StackActivityOptions { } interface TimedActivity { - activity: RunActivity; + activity: SampleActivity; startMs: number; endMs: number; } @@ -34,7 +34,7 @@ function parseTime(value: string): number { } function toTimedActivity( - activity: RunActivity, + activity: SampleActivity, markerDurationMs: number, ): TimedActivity { const startMs = parseTime(activity.startAt); @@ -65,7 +65,7 @@ function computeMaxSpanConcurrency(timed: TimedActivity[]): number { } export function stackActivities( - activities: RunActivity[], + activities: SampleActivity[], options: StackActivityOptions = {}, ): ActivityStackLayout { const minMarkerWidthPct = options.minMarkerWidthPct ?? DEFAULT_MIN_MARKER_WIDTH_PCT; diff --git a/ergon-dashboard/src/features/activity/types.ts b/ergon-dashboard/src/features/activity/types.ts index 16d4b45bc..a2639006b 100644 --- a/ergon-dashboard/src/features/activity/types.ts +++ b/ergon-dashboard/src/features/activity/types.ts @@ -1,4 +1,4 @@ -import type { RunEventKind } from "@/lib/sampleEvents"; +import type { SampleEventKind } from "@/lib/sampleEvents"; export type ActivityKind = | "execution" @@ -25,7 +25,7 @@ export interface ActivityLineage { threadId?: string | null; } -export interface RunActivity { +export interface SampleActivity { id: string; kind: ActivityKind; band: ActivityBand; @@ -37,7 +37,7 @@ export interface RunActivity { isInstant: boolean; actor: string | null; sourceKind: - | RunEventKind + | SampleEventKind | "execution.span" | "sandbox.span" | "sandbox.command" @@ -52,7 +52,7 @@ export interface RunActivity { } export interface ActivityStackItem { - activity: RunActivity; + activity: SampleActivity; row: number; leftPct: number; widthPct: number; diff --git a/ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts b/ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts index 5b0a5a280..1792ed4cf 100644 --- a/ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts +++ b/ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts @@ -5,7 +5,7 @@ import type { Node } from "@xyflow/react"; import fixture from "../../../../tests/fixtures/mas-samples/concurrent-mas-run.json"; import { parseGraphMutationDtoArray } from "@/features/graph/contracts/graphMutations"; import { createReplayInitialState, replayToSequence } from "@/features/graph/state/graphMutationReducer"; -import { deserializeRunState } from "@/lib/sampleState"; +import { deserializeSampleState } from "@/lib/sampleState"; import { calculateExpandedContainers, computeHierarchicalLayout } from "./hierarchicalLayout"; import { NODE_VARIANTS, getNodeVariant } from "./layoutTypes"; @@ -54,7 +54,7 @@ function overlappingSiblingPairs(nodes: Node[]): Array<[string, string]> { } test("golden layout renders the full recursive graph without overlapping sibling boxes", () => { - const runState = deserializeRunState(fixture.runState); + const runState = deserializeSampleState(fixture.runState); const mutations = parseGraphMutationDtoArray(fixture.mutations); const checkpoint = fixture.checkpoints.find((entry) => entry.sequence === 14); assert.ok(checkpoint); diff --git a/ergon-dashboard/src/generated/events/DashboardThreadMessageCreatedEvent.ts b/ergon-dashboard/src/generated/events/DashboardThreadMessageCreatedEvent.ts index fdca22b29..ea6592831 100644 --- a/ergon-dashboard/src/generated/events/DashboardThreadMessageCreatedEvent.ts +++ b/ergon-dashboard/src/generated/events/DashboardThreadMessageCreatedEvent.ts @@ -1,3 +1,3 @@ import { z } from "zod" -export const DashboardThreadMessageCreatedEventSchema = z.object({ "sample_id": z.string().uuid(), "thread": z.any(), "message": z.any() }).catchall(z.any()).describe("Embeds full RunCommunicationThreadDto + RunCommunicationMessageDto.") +export const DashboardThreadMessageCreatedEventSchema = z.object({ "sample_id": z.string().uuid(), "thread": z.any(), "message": z.any() }).catchall(z.any()).describe("Embeds full SampleCommunicationThreadDto + SampleCommunicationMessageDto.") diff --git a/ergon-dashboard/src/generated/events/schemas/DashboardTaskEvaluationUpdatedEvent.schema.json b/ergon-dashboard/src/generated/events/schemas/DashboardTaskEvaluationUpdatedEvent.schema.json index 020bb9c4b..eebd55dee 100644 --- a/ergon-dashboard/src/generated/events/schemas/DashboardTaskEvaluationUpdatedEvent.schema.json +++ b/ergon-dashboard/src/generated/events/schemas/DashboardTaskEvaluationUpdatedEvent.schema.json @@ -1,6 +1,6 @@ { "$defs": { - "RunEvaluationCriterionDto": { + "SampleEvaluationCriterionDto": { "additionalProperties": false, "properties": { "id": { @@ -170,7 +170,7 @@ "score", "maxScore" ], - "title": "RunEvaluationCriterionDto", + "title": "SampleEvaluationCriterionDto", "type": "object" }, "SampleTaskEvaluationDto": { @@ -243,7 +243,7 @@ }, "criterionResults": { "items": { - "$ref": "#/$defs/RunEvaluationCriterionDto" + "$ref": "#/$defs/SampleEvaluationCriterionDto" }, "title": "Criterionresults", "type": "array" diff --git a/ergon-dashboard/src/generated/events/schemas/DashboardThreadMessageCreatedEvent.schema.json b/ergon-dashboard/src/generated/events/schemas/DashboardThreadMessageCreatedEvent.schema.json index be0069507..c8f857a29 100644 --- a/ergon-dashboard/src/generated/events/schemas/DashboardThreadMessageCreatedEvent.schema.json +++ b/ergon-dashboard/src/generated/events/schemas/DashboardThreadMessageCreatedEvent.schema.json @@ -1,6 +1,6 @@ { "$defs": { - "RunCommunicationMessageDto": { + "SampleCommunicationMessageDto": { "additionalProperties": false, "properties": { "id": { @@ -76,10 +76,10 @@ "sequenceNum", "createdAt" ], - "title": "RunCommunicationMessageDto", + "title": "SampleCommunicationMessageDto", "type": "object" }, - "RunCommunicationThreadDto": { + "SampleCommunicationThreadDto": { "additionalProperties": false, "properties": { "id": { @@ -138,7 +138,7 @@ }, "messages": { "items": { - "$ref": "#/$defs/RunCommunicationMessageDto" + "$ref": "#/$defs/SampleCommunicationMessageDto" }, "title": "Messages", "type": "array" @@ -153,12 +153,12 @@ "createdAt", "updatedAt" ], - "title": "RunCommunicationThreadDto", + "title": "SampleCommunicationThreadDto", "type": "object" } }, "additionalProperties": true, - "description": "Embeds full RunCommunicationThreadDto + RunCommunicationMessageDto.", + "description": "Embeds full SampleCommunicationThreadDto + SampleCommunicationMessageDto.", "properties": { "sample_id": { "format": "uuid", @@ -166,10 +166,10 @@ "type": "string" }, "thread": { - "$ref": "#/$defs/RunCommunicationThreadDto" + "$ref": "#/$defs/SampleCommunicationThreadDto" }, "message": { - "$ref": "#/$defs/RunCommunicationMessageDto" + "$ref": "#/$defs/SampleCommunicationMessageDto" } }, "required": [ diff --git a/ergon-dashboard/src/generated/events/schemas/DashboardWorkflowStartedEvent.schema.json b/ergon-dashboard/src/generated/events/schemas/DashboardWorkflowStartedEvent.schema.json index 9fbc011e3..26a59671a 100644 --- a/ergon-dashboard/src/generated/events/schemas/DashboardWorkflowStartedEvent.schema.json +++ b/ergon-dashboard/src/generated/events/schemas/DashboardWorkflowStartedEvent.schema.json @@ -331,7 +331,7 @@ "title": "ProviderTokenUsage", "type": "object" }, - "RunCommunicationMessageDto": { + "SampleCommunicationMessageDto": { "additionalProperties": false, "properties": { "id": { @@ -407,10 +407,10 @@ "sequenceNum", "createdAt" ], - "title": "RunCommunicationMessageDto", + "title": "SampleCommunicationMessageDto", "type": "object" }, - "RunCommunicationThreadDto": { + "SampleCommunicationThreadDto": { "additionalProperties": false, "properties": { "id": { @@ -469,7 +469,7 @@ }, "messages": { "items": { - "$ref": "#/$defs/RunCommunicationMessageDto" + "$ref": "#/$defs/SampleCommunicationMessageDto" }, "title": "Messages", "type": "array" @@ -484,10 +484,10 @@ "createdAt", "updatedAt" ], - "title": "RunCommunicationThreadDto", + "title": "SampleCommunicationThreadDto", "type": "object" }, - "RunEvaluationCriterionDto": { + "SampleEvaluationCriterionDto": { "additionalProperties": false, "properties": { "id": { @@ -657,10 +657,10 @@ "score", "maxScore" ], - "title": "RunEvaluationCriterionDto", + "title": "SampleEvaluationCriterionDto", "type": "object" }, - "RunExecutionAttemptDto": { + "SampleExecutionAttemptDto": { "additionalProperties": false, "properties": { "id": { @@ -792,10 +792,10 @@ "attemptNumber", "status" ], - "title": "RunExecutionAttemptDto", + "title": "SampleExecutionAttemptDto", "type": "object" }, - "RunSandboxCommandDto": { + "SampleSandboxCommandDto": { "additionalProperties": false, "properties": { "command": { @@ -860,10 +860,10 @@ "command", "timestamp" ], - "title": "RunSandboxCommandDto", + "title": "SampleSandboxCommandDto", "type": "object" }, - "RunSandboxDto": { + "SampleSandboxDto": { "additionalProperties": false, "properties": { "sandboxId": { @@ -926,7 +926,7 @@ }, "commands": { "items": { - "$ref": "#/$defs/RunSandboxCommandDto" + "$ref": "#/$defs/SampleSandboxCommandDto" }, "title": "Commands", "type": "array" @@ -939,7 +939,7 @@ "status", "createdAt" ], - "title": "RunSandboxDto", + "title": "SampleSandboxDto", "type": "object" }, "SampleContextEventDto": { @@ -1128,7 +1128,7 @@ "executionsByTask": { "additionalProperties": { "items": { - "$ref": "#/$defs/RunExecutionAttemptDto" + "$ref": "#/$defs/SampleExecutionAttemptDto" }, "type": "array" }, @@ -1144,7 +1144,7 @@ }, "sandboxesByTask": { "additionalProperties": { - "$ref": "#/$defs/RunSandboxDto" + "$ref": "#/$defs/SampleSandboxDto" }, "title": "Sandboxesbytask", "type": "object" @@ -1161,7 +1161,7 @@ }, "threads": { "items": { - "$ref": "#/$defs/RunCommunicationThreadDto" + "$ref": "#/$defs/SampleCommunicationThreadDto" }, "title": "Threads", "type": "array" @@ -1542,7 +1542,7 @@ }, "criterionResults": { "items": { - "$ref": "#/$defs/RunEvaluationCriterionDto" + "$ref": "#/$defs/SampleEvaluationCriterionDto" }, "title": "Criterionresults", "type": "array" diff --git a/ergon-dashboard/src/generated/rest/contracts.ts b/ergon-dashboard/src/generated/rest/contracts.ts index 81662b05f..2df5d8b37 100644 --- a/ergon-dashboard/src/generated/rest/contracts.ts +++ b/ergon-dashboard/src/generated/rest/contracts.ts @@ -78,7 +78,7 @@ const SampleResourceDto = z.object({ sizeBytes: z.number().int(), createdAt: z.string().datetime({ offset: true }), }); -const RunExecutionAttemptDto = z.object({ +const SampleExecutionAttemptDto = z.object({ id: z.string(), taskId: z.string(), attemptNumber: z.number().int(), @@ -95,7 +95,7 @@ const RunExecutionAttemptDto = z.object({ .optional(), outputResourceIds: z.array(z.string()).optional(), }); -const RunEvaluationCriterionDto = z.object({ +const SampleEvaluationCriterionDto = z.object({ id: z.string(), stageNum: z.number().int(), stageName: z.string(), @@ -134,9 +134,9 @@ const SampleTaskEvaluationDto = z.object({ stagesPassed: z.number().int(), failedGate: z.union([z.string(), z.null()]).optional(), createdAt: z.string().datetime({ offset: true }), - criterionResults: z.array(RunEvaluationCriterionDto).optional(), + criterionResults: z.array(SampleEvaluationCriterionDto).optional(), }); -const RunSandboxCommandDto = z.object({ +const SampleSandboxCommandDto = z.object({ command: z.string(), stdout: z.union([z.string(), z.null()]).optional(), stderr: z.union([z.string(), z.null()]).optional(), @@ -144,7 +144,7 @@ const RunSandboxCommandDto = z.object({ durationMs: z.union([z.number(), z.null()]).optional(), timestamp: z.string().datetime({ offset: true }), }); -const RunSandboxDto = z.object({ +const SampleSandboxDto = z.object({ sandboxId: z.string(), taskId: z.string(), template: z.union([z.string(), z.null()]).optional(), @@ -153,7 +153,7 @@ const RunSandboxDto = z.object({ createdAt: z.string().datetime({ offset: true }), closedAt: z.union([z.string(), z.null()]).optional(), closeReason: z.union([z.string(), z.null()]).optional(), - commands: z.array(RunSandboxCommandDto).optional(), + commands: z.array(SampleSandboxCommandDto).optional(), }); const SystemPromptPart = z .object({ @@ -268,7 +268,7 @@ const SampleContextEventDto = z.object({ startedAt: z.union([z.string(), z.null()]).optional(), completedAt: z.union([z.string(), z.null()]).optional(), }); -const RunCommunicationMessageDto = z.object({ +const SampleCommunicationMessageDto = z.object({ id: z.string(), threadId: z.string(), threadTopic: z.string(), @@ -281,7 +281,7 @@ const RunCommunicationMessageDto = z.object({ sequenceNum: z.number().int(), createdAt: z.string().datetime({ offset: true }), }); -const RunCommunicationThreadDto = z.object({ +const SampleCommunicationThreadDto = z.object({ id: z.string(), sampleId: z.string(), taskId: z.union([z.string(), z.null()]).optional(), @@ -291,7 +291,7 @@ const RunCommunicationThreadDto = z.object({ agentBId: z.string(), createdAt: z.string().datetime({ offset: true }), updatedAt: z.string().datetime({ offset: true }), - messages: z.array(RunCommunicationMessageDto).optional(), + messages: z.array(SampleCommunicationMessageDto).optional(), }); const SampleSnapshotMetricsDto = z.object({ sampleId: z.string(), @@ -312,11 +312,11 @@ const SampleSnapshotDto = z.object({ tasks: z.record(z.string(), SampleTaskDto).optional(), rootTaskId: z.string().optional().default(""), resourcesByTask: z.record(z.string(), z.array(SampleResourceDto)).optional(), - executionsByTask: z.record(z.string(), z.array(RunExecutionAttemptDto)).optional(), + executionsByTask: z.record(z.string(), z.array(SampleExecutionAttemptDto)).optional(), evaluationsByTask: z.record(z.string(), SampleTaskEvaluationDto).optional(), - sandboxesByTask: z.record(z.string(), RunSandboxDto).optional(), + sandboxesByTask: z.record(z.string(), SampleSandboxDto).optional(), contextEventsByTask: z.record(z.string(), z.array(SampleContextEventDto)).optional(), - threads: z.array(RunCommunicationThreadDto).optional(), + threads: z.array(SampleCommunicationThreadDto).optional(), startedAt: z.union([z.string(), z.null()]).optional(), completedAt: z.union([z.string(), z.null()]).optional(), durationSeconds: z.union([z.number(), z.null()]).optional(), @@ -711,11 +711,11 @@ export const schemas = { HTTPValidationError, SampleTaskDto, SampleResourceDto, - RunExecutionAttemptDto, - RunEvaluationCriterionDto, + SampleExecutionAttemptDto, + SampleEvaluationCriterionDto, SampleTaskEvaluationDto, - RunSandboxCommandDto, - RunSandboxDto, + SampleSandboxCommandDto, + SampleSandboxDto, SystemPromptPart, UserMessagePart, AssistantTextPart, @@ -729,8 +729,8 @@ export const schemas = { ProviderTokenUsage, ContextPartChunkLog, SampleContextEventDto, - RunCommunicationMessageDto, - RunCommunicationThreadDto, + SampleCommunicationMessageDto, + SampleCommunicationThreadDto, SampleSnapshotMetricsDto, SampleSnapshotDto, NodeAddedMutation, diff --git a/ergon-dashboard/src/generated/rest/openapi.json b/ergon-dashboard/src/generated/rest/openapi.json index 2ff6d8109..eefef2b1a 100644 --- a/ergon-dashboard/src/generated/rest/openapi.json +++ b/ergon-dashboard/src/generated/rest/openapi.json @@ -2507,7 +2507,7 @@ ], "title": "RolloutStatus" }, - "RunCommunicationMessageDto": { + "SampleCommunicationMessageDto": { "properties": { "id": { "type": "string", @@ -2582,9 +2582,9 @@ "sequenceNum", "createdAt" ], - "title": "RunCommunicationMessageDto" + "title": "SampleCommunicationMessageDto" }, - "RunCommunicationThreadDto": { + "SampleCommunicationThreadDto": { "properties": { "id": { "type": "string", @@ -2640,7 +2640,7 @@ }, "messages": { "items": { - "$ref": "#/components/schemas/RunCommunicationMessageDto" + "$ref": "#/components/schemas/SampleCommunicationMessageDto" }, "type": "array", "title": "Messages" @@ -2657,7 +2657,7 @@ "createdAt", "updatedAt" ], - "title": "RunCommunicationThreadDto" + "title": "SampleCommunicationThreadDto" }, "SampleContextEventDto": { "properties": { @@ -2749,7 +2749,7 @@ ], "title": "SampleContextEventDto" }, - "RunEvaluationCriterionDto": { + "SampleEvaluationCriterionDto": { "properties": { "id": { "type": "string", @@ -2914,9 +2914,9 @@ "score", "maxScore" ], - "title": "RunEvaluationCriterionDto" + "title": "SampleEvaluationCriterionDto" }, - "RunExecutionAttemptDto": { + "SampleExecutionAttemptDto": { "properties": { "id": { "type": "string", @@ -3041,7 +3041,7 @@ "attemptNumber", "status" ], - "title": "RunExecutionAttemptDto" + "title": "SampleExecutionAttemptDto" }, "SampleResourceDto": { "properties": { @@ -3093,7 +3093,7 @@ ], "title": "SampleResourceDto" }, - "RunSandboxCommandDto": { + "SampleSandboxCommandDto": { "properties": { "command": { "type": "string", @@ -3155,9 +3155,9 @@ "command", "timestamp" ], - "title": "RunSandboxCommandDto" + "title": "SampleSandboxCommandDto" }, - "RunSandboxDto": { + "SampleSandboxDto": { "properties": { "sandboxId": { "type": "string", @@ -3216,7 +3216,7 @@ }, "commands": { "items": { - "$ref": "#/components/schemas/RunSandboxCommandDto" + "$ref": "#/components/schemas/SampleSandboxCommandDto" }, "type": "array", "title": "Commands" @@ -3231,7 +3231,7 @@ "status", "createdAt" ], - "title": "RunSandboxDto" + "title": "SampleSandboxDto" }, "SampleSnapshotDto": { "properties": { @@ -3276,7 +3276,7 @@ "executionsByTask": { "additionalProperties": { "items": { - "$ref": "#/components/schemas/RunExecutionAttemptDto" + "$ref": "#/components/schemas/SampleExecutionAttemptDto" }, "type": "array" }, @@ -3292,7 +3292,7 @@ }, "sandboxesByTask": { "additionalProperties": { - "$ref": "#/components/schemas/RunSandboxDto" + "$ref": "#/components/schemas/SampleSandboxDto" }, "type": "object", "title": "Sandboxesbytask" @@ -3309,7 +3309,7 @@ }, "threads": { "items": { - "$ref": "#/components/schemas/RunCommunicationThreadDto" + "$ref": "#/components/schemas/SampleCommunicationThreadDto" }, "type": "array", "title": "Threads" @@ -3907,7 +3907,7 @@ }, "criterionResults": { "items": { - "$ref": "#/components/schemas/RunEvaluationCriterionDto" + "$ref": "#/components/schemas/SampleEvaluationCriterionDto" }, "type": "array", "title": "Criterionresults" diff --git a/ergon-dashboard/src/hooks/useSampleWorkspaceState.socketHydration.test.ts b/ergon-dashboard/src/hooks/useSampleWorkspaceState.socketHydration.test.ts index 5237a6dbe..9665407ff 100644 --- a/ergon-dashboard/src/hooks/useSampleWorkspaceState.socketHydration.test.ts +++ b/ergon-dashboard/src/hooks/useSampleWorkspaceState.socketHydration.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { hydrateRunSnapshot } from "@/lib/sample-state"; +import { hydrateSampleSnapshot } from "@/lib/sample-state"; import { applySandboxCommand, applySandboxCreated, applyTaskStatusChanged } from "@/lib/sample-state/reducers"; import { TaskStatus } from "@/lib/types"; import { createDashboardSeed, FIXTURE_IDS } from "../../tests/helpers/dashboardFixtures"; @@ -19,7 +19,7 @@ test("task status reducer records transition history", () => { const run = createDashboardSeed().runs?.[0]; assert.ok(run); - const state = hydrateRunSnapshot(run); + const state = hydrateSampleSnapshot(run); const next = applyTaskStatusChanged(state, { sampleId: FIXTURE_IDS.sampleId, taskId: FIXTURE_IDS.solveTaskId, @@ -46,7 +46,7 @@ test("sandbox command before sandbox creation is preserved", () => { durationMs: 123, timestamp: "2026-03-18T12:00:05.000Z", }; - const state = hydrateRunSnapshot({ + const state = hydrateSampleSnapshot({ ...run, sandboxesByTask: {}, }); @@ -84,7 +84,7 @@ test("sandbox creation deduplicates commands already present in the created sand durationMs: 123, timestamp: "2026-03-18T12:00:05.000Z", }; - const state = hydrateRunSnapshot({ ...run, sandboxesByTask: {} }); + const state = hydrateSampleSnapshot({ ...run, sandboxesByTask: {} }); const withSandbox = applySandboxCreated( state, { @@ -108,7 +108,7 @@ test("cancelled task status records terminal task and execution timestamps", () const run = createDashboardSeed().runs?.[0]; assert.ok(run); - const running = applyTaskStatusChanged(hydrateRunSnapshot(run), { + const running = applyTaskStatusChanged(hydrateSampleSnapshot(run), { sampleId: FIXTURE_IDS.sampleId, taskId: FIXTURE_IDS.solveTaskId, status: TaskStatus.RUNNING, diff --git a/ergon-dashboard/src/hooks/useSampleWorkspaceState.ts b/ergon-dashboard/src/hooks/useSampleWorkspaceState.ts index 6097772a1..b122fc671 100644 --- a/ergon-dashboard/src/hooks/useSampleWorkspaceState.ts +++ b/ergon-dashboard/src/hooks/useSampleWorkspaceState.ts @@ -1,13 +1,13 @@ "use client"; /** - * useSampleWorkspaceState - Hook for managing a single workflow run's state. + * useSampleWorkspaceState - Hook for managing a single sample's state. * - * Subscribes to a specific run's updates and maintains the full - * SampleWorkspaceState for that run, including tasks, actions, resources, etc. + * Subscribes to a specific sample's updates and maintains the full + * SampleWorkspaceState for that sample, including tasks, actions, resources, etc. * - * On subscription, requests the full run state from the server to hydrate - * existing data (important for completed runs or page refreshes). + * On subscription, requests the full sample state from the server to hydrate + * existing data (important for completed samples or page refreshes). */ import { useState, useEffect, useCallback, useRef } from "react"; @@ -16,14 +16,14 @@ import { parseDashboardTaskEvaluationUpdatedData, parseDashboardThreadMessageCreatedData, parseResourceSocketData, - parseRunCompletedSocketData, + parseSampleCompletedSocketData, parseSandboxClosedSocketData, parseSandboxCommandSocketData, parseSandboxCreatedSocketData, parseTaskStatusSocketData, } from "@/lib/contracts/events"; import type { GraphMutationSocketData } from "@/lib/contracts/events"; -import type { RunSandbox, RunSandboxCommand } from "@/lib/contracts/rest"; +import type { SampleSandbox, SampleSandboxCommand } from "@/lib/contracts/rest"; import { ContextEventState, TaskStatus, @@ -32,7 +32,7 @@ import { SampleWorkspaceState, SerializedSampleWorkspaceState, } from "@/lib/types"; -import { compareContextEvents, deserializeRunState } from "@/lib/sampleState"; +import { compareContextEvents, deserializeSampleState } from "@/lib/sampleState"; import { applySandboxClosed, applySandboxCommand, @@ -41,14 +41,14 @@ import { } from "@/lib/sample-state/reducers"; import { useGraphMutations } from "@/features/graph/hooks/useGraphMutations"; -interface UseRunStateResult { +interface UseSampleWorkspaceStateResult { runState: SampleWorkspaceState | null; isLoading: boolean; error: string | null; isSubscribed: boolean; } -function normalizeSandboxState(sandbox: RunSandbox): SandboxState { +function normalizeSandboxState(sandbox: SampleSandbox): SandboxState { return { ...sandbox, status: sandbox.status as SandboxState["status"], @@ -66,7 +66,7 @@ function normalizeSandboxState(sandbox: RunSandbox): SandboxState { }; } -function normalizeSandboxCommandState(command: RunSandboxCommand): SandboxCommandState { +function normalizeSandboxCommandState(command: SampleSandboxCommand): SandboxCommandState { return { command: command.command, stdout: command.stdout ?? null, @@ -84,10 +84,10 @@ export function shouldRequestSocketSnapshot(hasHydratedRunState: boolean): boole export function useSampleWorkspaceState( sampleId: string, initialRunState: SerializedSampleWorkspaceState | null = null, -): UseRunStateResult { +): UseSampleWorkspaceStateResult { const { socket, isConnected, subscribe, unsubscribe } = useSocket(); const [runState, setRunState] = useState( - initialRunState ? deserializeRunState(initialRunState) : null, + initialRunState ? deserializeSampleState(initialRunState) : null, ); const [isLoading, setIsLoading] = useState(initialRunState === null); const [error, setError] = useState(null); @@ -103,7 +103,7 @@ export function useSampleWorkspaceState( throw new Error(`Failed to load run (${response.status})`); } const data = (await response.json()) as unknown; - setRunState(deserializeRunState(data)); + setRunState(deserializeSampleState(data)); setError(null); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load run"); @@ -118,7 +118,7 @@ export function useSampleWorkspaceState( useEffect(() => { if (initialRunState) { - setRunState(deserializeRunState(initialRunState)); + setRunState(deserializeSampleState(initialRunState)); setIsLoading(false); setError(null); return; @@ -234,10 +234,10 @@ export function useSampleWorkspaceState( [sampleId] ); - // Handle run completed - const handleRunCompleted = useCallback( + // Handle sample completed + const handleSampleCompleted = useCallback( (payload: unknown) => { - const data = parseRunCompletedSocketData(payload); + const data = parseSampleCompletedSocketData(payload); if (data.sampleId !== sampleId) return; setRunState((prev) => { @@ -335,11 +335,11 @@ export function useSampleWorkspaceState( [sampleId, handleGraphMutation], ); - // Handle full run state sync (for initial load / completed runs) + // Handle full sample state sync (for initial load / completed samples) const handleSyncRun = useCallback( (data: SerializedSampleWorkspaceState | null) => { console.log( - "[useSampleWorkspaceState] Received sync:run", + "[useSampleWorkspaceState] Received sync:sample", data ? `(${Object.keys(data.tasks ?? {}).length} tasks)` : "(null)", ); @@ -353,7 +353,7 @@ export function useSampleWorkspaceState( return; } - setRunState(deserializeRunState(data)); + setRunState(deserializeSampleState(data)); setIsLoading(false); setError(null); }, @@ -384,15 +384,15 @@ export function useSampleWorkspaceState( setIsLoading((prev) => (hasRunStateRef.current ? false : prev)); if (shouldRequestSocketSnapshot(hasRunStateRef.current)) { - // Request full run state only when REST/SSR did not hydrate us. + // Request full sample state only when REST/SSR did not hydrate us. console.log("[useSampleWorkspaceState] Requesting full state for run", sampleId, "socket.connected:", socket.connected); - socket.emit("request:run", sampleId); + socket.emit("request:sample", sampleId); // Set up a retry in case the first request is lost retryTimeout = setTimeout(() => { if (socket.connected && shouldRequestSocketSnapshot(hasRunStateRef.current)) { - console.log("[useSampleWorkspaceState] Retrying request:run for", sampleId); - socket.emit("request:run", sampleId); + console.log("[useSampleWorkspaceState] Retrying request:sample for", sampleId); + socket.emit("request:sample", sampleId); } }, 1000); } else { @@ -401,13 +401,13 @@ export function useSampleWorkspaceState( } // Set up event listeners - socket.on("sync:run", handleSyncRun); + socket.on("sync:sample", handleSyncRun); socket.on("task:status", handleTaskStatus); socket.on("resource:new", handleResourceNew); socket.on("sandbox:created", handleSandboxCreated); socket.on("sandbox:command", handleSandboxCommand); socket.on("sandbox:closed", handleSandboxClosed); - socket.on("run:completed", handleRunCompleted); + socket.on("sample:completed", handleSampleCompleted); socket.on("thread:message", handleThreadMessage); socket.on("task:evaluation", handleTaskEvaluation); socket.on("context:event", handleContextEvent); @@ -415,13 +415,13 @@ export function useSampleWorkspaceState( return () => { if (retryTimeout) clearTimeout(retryTimeout); - socket.off("sync:run", handleSyncRun); + socket.off("sync:sample", handleSyncRun); socket.off("task:status", handleTaskStatus); socket.off("resource:new", handleResourceNew); socket.off("sandbox:created", handleSandboxCreated); socket.off("sandbox:command", handleSandboxCommand); socket.off("sandbox:closed", handleSandboxClosed); - socket.off("run:completed", handleRunCompleted); + socket.off("sample:completed", handleSampleCompleted); socket.off("thread:message", handleThreadMessage); socket.off("task:evaluation", handleTaskEvaluation); socket.off("context:event", handleContextEvent); @@ -439,7 +439,7 @@ export function useSampleWorkspaceState( handleSandboxCreated, handleSandboxCommand, handleSandboxClosed, - handleRunCompleted, + handleSampleCompleted, handleThreadMessage, handleTaskEvaluation, handleContextEvent, diff --git a/ergon-dashboard/src/inngest/functions/index.ts b/ergon-dashboard/src/inngest/functions/index.ts index d30be3a05..233fac531 100644 --- a/ergon-dashboard/src/inngest/functions/index.ts +++ b/ergon-dashboard/src/inngest/functions/index.ts @@ -8,8 +8,8 @@ import { inngest } from "../client"; import { store } from "@/lib/state/store"; import { - broadcastRunStarted, - broadcastRunCompleted, + broadcastSampleStarted, + broadcastSampleCompleted, broadcastGraphMutation, broadcastTaskEvaluation, broadcastTaskStatus, @@ -66,7 +66,7 @@ const onWorkflowStarted = inngest.createFunction( }); // Update store - store.initializeRun( + store.initializeSample( sample_id, definition_id, workflow_name, @@ -77,13 +77,13 @@ const onWorkflowStarted = inngest.createFunction( ); // Log store state after initialization - const allRuns = store.getAllRuns(); + const allRuns = store.getAllSamples(); console.log(`[Dashboard] Store now has ${allRuns.length} runs:`, allRuns.map(r => ({ id: r.id, name: r.name, status: r.status }))); // Broadcast to all clients (new run appeared) - console.log("[Dashboard] About to call broadcastRunStarted..."); - broadcastRunStarted(sample_id, workflow_name); - console.log("[Dashboard] broadcastRunStarted completed"); + console.log("[Dashboard] About to call broadcastSampleStarted..."); + broadcastSampleStarted(sample_id, workflow_name); + console.log("[Dashboard] broadcastSampleStarted completed"); // Prune old runs to prevent memory growth store.pruneOldSamples(); @@ -116,7 +116,7 @@ const onWorkflowCompleted = inngest.createFunction( }); // Update store - store.completeRun( + store.completeSample( sample_id, narrowedStatus, completed_at, @@ -126,7 +126,7 @@ const onWorkflowCompleted = inngest.createFunction( ); // Broadcast to run subscribers - broadcastRunCompleted( + broadcastSampleCompleted( sample_id, narrowedStatus, completed_at, @@ -327,7 +327,7 @@ const onSandboxCommand = inngest.createFunction( }); // Find the sample_id for this task - const runs = store.getAllRuns(); + const runs = store.getAllSamples(); let sampleId: string | null = null; for (const run of runs) { @@ -378,7 +378,7 @@ const onSandboxClosed = inngest.createFunction( }); // Find the sample_id for this task - const runs = store.getAllRuns(); + const runs = store.getAllSamples(); let sampleId: string | null = null; for (const run of runs) { diff --git a/ergon-dashboard/src/lib/contracts/events.ts b/ergon-dashboard/src/lib/contracts/events.ts index a2114956b..f98bf442e 100644 --- a/ergon-dashboard/src/lib/contracts/events.ts +++ b/ergon-dashboard/src/lib/contracts/events.ts @@ -14,21 +14,21 @@ import { DashboardWorkflowStartedEventSchema as GeneratedDashboardWorkflowStartedEventSchema, } from "@/generated/events"; import { - parseRunCommunicationMessage, - parseRunCommunicationThread, - parseRunSandbox, - parseRunSandboxCommand, - parseRunSnapshot, + parseSampleCommunicationMessage, + parseSampleCommunicationThread, + parseSampleSandbox, + parseSampleSandboxCommand, + parseSampleSnapshot, parseSampleTaskEvaluation, - RunCommunicationMessageSchema, - RunCommunicationThreadSchema, + SampleCommunicationMessageSchema, + SampleCommunicationThreadSchema, SampleResourceSchema, SampleResource, - RunSandbox, - RunSandboxCommand, - RunSandboxCommandSchema, - RunSandboxSchema, - RunSnapshot, + SampleSandbox, + SampleSandboxCommand, + SampleSandboxCommandSchema, + SampleSandboxSchema, + SampleSnapshot, SampleTaskEvaluation, SampleTaskEvaluationSchema, TaskStatusSchema, @@ -71,8 +71,8 @@ export const DashboardWorkflowStartedDataSchema = z.object({ export const DashboardThreadMessageCreatedDataSchema = z.object({ sample_id: z.string().uuid(), - thread: RunCommunicationThreadSchema, - message: RunCommunicationMessageSchema, + thread: SampleCommunicationThreadSchema, + message: SampleCommunicationMessageSchema, }); export const DashboardTaskEvaluationUpdatedDataSchema = z.object({ @@ -81,7 +81,7 @@ export const DashboardTaskEvaluationUpdatedDataSchema = z.object({ evaluation: SampleTaskEvaluationSchema, }); -export const RunListEntrySchema = z.object({ +export const SampleListEntrySchema = z.object({ sampleId: z.string(), name: z.string(), status: z.enum(["pending", "executing", "evaluating", "completed", "failed", "cancelled"]), @@ -92,8 +92,8 @@ export const RunListEntrySchema = z.object({ error: z.string().nullable(), }); -export const SyncRunsSchema = z.array(RunListEntrySchema); -export const RunCompletedSocketDataSchema = z.object({ +export const SyncSamplesSchema = z.array(SampleListEntrySchema); +export const SampleCompletedSocketDataSchema = z.object({ sampleId: z.string(), status: z.enum(["completed", "failed"]), completedAt: z.string(), @@ -115,12 +115,12 @@ export const ResourceSocketDataSchema = z.object({ }); export const SandboxCreatedSocketDataSchema = z.object({ sampleId: z.string(), - sandbox: RunSandboxSchema, + sandbox: SampleSandboxSchema, }); export const SandboxCommandSocketDataSchema = z.object({ sampleId: z.string(), taskId: z.string(), - command: RunSandboxCommandSchema, + command: SampleSandboxCommandSchema, }); export const SandboxClosedSocketDataSchema = z.object({ sampleId: z.string(), @@ -134,7 +134,7 @@ export interface DashboardWorkflowStartedData { sample_id: string; definition_id: string; workflow_name: string; - snapshot: RunSnapshot; + snapshot: SampleSnapshot; started_at: string; total_tasks: number; total_leaf_tasks: number; @@ -148,16 +148,16 @@ export type DashboardSandboxCommandData = GeneratedDashboardSandboxCommandEvent; export type DashboardSandboxClosedData = GeneratedDashboardSandboxClosedEvent; export interface DashboardThreadMessageCreatedData { sample_id: string; - thread: ReturnType; - message: ReturnType; + thread: ReturnType; + message: ReturnType; } export interface DashboardTaskEvaluationUpdatedData { sample_id: string; task_id: string | null; evaluation: SampleTaskEvaluation; } -export type RunListEntry = z.infer; -export type RunCompletedSocketData = z.infer; +export type SampleListEntry = z.infer; +export type SampleCompletedSocketData = z.infer; export type TaskStatusSocketData = z.infer; export interface ResourceSocketData { sampleId: string; @@ -165,12 +165,12 @@ export interface ResourceSocketData { } export interface SandboxCreatedSocketData { sampleId: string; - sandbox: RunSandbox; + sandbox: SampleSandbox; } export interface SandboxCommandSocketData { sampleId: string; taskId: string; - command: RunSandboxCommand; + command: SampleSandboxCommand; } export type SandboxClosedSocketData = z.infer; @@ -219,8 +219,8 @@ export function parseDashboardThreadMessageCreatedData( }); return { sample_id: parsed.sample_id, - thread: parseRunCommunicationThread(parsed.thread), - message: parseRunCommunicationMessage(parsed.message), + thread: parseSampleCommunicationThread(parsed.thread), + message: parseSampleCommunicationMessage(parsed.message), }; } @@ -249,19 +249,19 @@ export function parseDashboardWorkflowStartedData(input: unknown): DashboardWork sample_id: parsed.sample_id, definition_id: parsed.definition_id, workflow_name: parsed.workflow_name, - snapshot: parseRunSnapshot(parsed.snapshot), + snapshot: parseSampleSnapshot(parsed.snapshot), started_at: parsed.started_at, total_tasks: parsed.total_tasks, total_leaf_tasks: parsed.total_leaf_tasks, }; } -export function parseSyncRuns(input: unknown): RunListEntry[] { - return SyncRunsSchema.parse(input); +export function parseSyncSamples(input: unknown): SampleListEntry[] { + return SyncSamplesSchema.parse(input); } -export function parseRunCompletedSocketData(input: unknown): RunCompletedSocketData { - return RunCompletedSocketDataSchema.parse(input); +export function parseSampleCompletedSocketData(input: unknown): SampleCompletedSocketData { + return SampleCompletedSocketDataSchema.parse(input); } export function parseTaskStatusSocketData(input: unknown): TaskStatusSocketData { @@ -280,7 +280,7 @@ export function parseSandboxCreatedSocketData(input: unknown): SandboxCreatedSoc const parsed = SandboxCreatedSocketDataSchema.parse(input); return { sampleId: parsed.sampleId, - sandbox: parseRunSandbox(parsed.sandbox), + sandbox: parseSampleSandbox(parsed.sandbox), }; } @@ -289,7 +289,7 @@ export function parseSandboxCommandSocketData(input: unknown): SandboxCommandSoc return { sampleId: parsed.sampleId, taskId: parsed.taskId, - command: parseRunSandboxCommand(parsed.command), + command: parseSampleSandboxCommand(parsed.command), }; } diff --git a/ergon-dashboard/src/lib/contracts/rest.ts b/ergon-dashboard/src/lib/contracts/rest.ts index 0ab0f766d..a19385742 100644 --- a/ergon-dashboard/src/lib/contracts/rest.ts +++ b/ergon-dashboard/src/lib/contracts/rest.ts @@ -8,15 +8,15 @@ export const TaskStatusSchema = z.string(); export const ExperimentDetailSchema = schemas.ExperimentDetailDto; -export const RunExecutionAttemptSchema = schemas.RunExecutionAttemptDto; +export const SampleExecutionAttemptSchema = schemas.SampleExecutionAttemptDto; export const SampleResourceSchema = schemas.SampleResourceDto; -export const RunSandboxCommandSchema = schemas.RunSandboxCommandDto; -export const RunSandboxSchema = schemas.RunSandboxDto; +export const SampleSandboxCommandSchema = schemas.SampleSandboxCommandDto; +export const SampleSandboxSchema = schemas.SampleSandboxDto; export const SampleTaskSchema = schemas.SampleTaskDto; -export const RunCommunicationMessageSchema = schemas.RunCommunicationMessageDto; -export const RunCommunicationThreadSchema = schemas.RunCommunicationThreadDto; +export const SampleCommunicationMessageSchema = schemas.SampleCommunicationMessageDto; +export const SampleCommunicationThreadSchema = schemas.SampleCommunicationThreadDto; export const SampleTaskEvaluationSchema = schemas.SampleTaskEvaluationDto; -export const RunSnapshotSchema = schemas.SampleSnapshotDto; +export const SampleSnapshotSchema = schemas.SampleSnapshotDto; type KnownKeys = { [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K @@ -25,28 +25,28 @@ type KnownKeys = { }; export type BenchmarkName = z.infer; -export type RunLifecycleStatus = z.infer; +export type SampleLifecycleStatus = z.infer; export type TaskStatusValue = z.infer; type RawExperimentDetail = KnownKeys>; type RawExperimentRunRow = KnownKeys[number]>; type RawExperimentSummary = KnownKeys; -type RawRunExecutionAttempt = KnownKeys>; +type RawSampleExecutionAttempt = KnownKeys>; type RawSampleResource = KnownKeys>; -type RawRunSandboxCommand = KnownKeys>; -type RawRunSandbox = KnownKeys>; +type RawSampleSandboxCommand = KnownKeys>; +type RawSampleSandbox = KnownKeys>; type RawSampleTask = KnownKeys>; -type RawRunCommunicationMessage = KnownKeys>; -type RawRunCommunicationThread = KnownKeys>; +type RawSampleCommunicationMessage = KnownKeys>; +type RawSampleCommunicationThread = KnownKeys>; type RawSampleTaskEvaluation = KnownKeys>; -type RawRunEvaluationCriterion = KnownKeys[number]>; -type RawRunSnapshot = KnownKeys>; -type RawRunSnapshotMetrics = KnownKeys>; +type RawSampleEvaluationCriterion = KnownKeys[number]>; +type RawSampleSnapshot = KnownKeys>; +type RawSampleSnapshotMetrics = KnownKeys>; -export type RawRunSandboxType = RawRunSandbox; -export type RawRunSandboxCommandType = RawRunSandboxCommand; +export type RawSampleSandboxType = RawSampleSandbox; +export type RawSampleSandboxCommandType = RawSampleSandboxCommand; -export type RunSnapshotMetrics = RawRunSnapshotMetrics; +export type SampleSnapshotMetrics = RawSampleSnapshotMetrics; export interface ExperimentStatusCounts { pending: number; @@ -149,9 +149,9 @@ export interface ExperimentDetail extends Omit { agentId: string | null; @@ -166,24 +166,24 @@ export interface RunExecutionAttempt export type SampleResource = RawSampleResource; -export interface RunSandboxCommand - extends Omit { +export interface SampleSandboxCommand + extends Omit { durationMs: number | null; exitCode: number | null; stderr: string | null; stdout: string | null; } -export interface RunSandbox - extends Omit { +export interface SampleSandbox + extends Omit { closeReason: string | null; closedAt: string | null; - commands: RunSandboxCommand[]; + commands: SampleSandboxCommand[]; template: string | null; } /** - * Per-task row in {@link RunSnapshot.tasks} (camelCase on the wire). + * Per-task row in {@link SampleSnapshot.tasks} (camelCase on the wire). * * Semantics mirror the backend `SampleTaskDto` field descriptions: * - `startedAt`: null only while the task has not actually started yet (e.g. pending / ready). @@ -205,18 +205,18 @@ export interface SampleTask startedAt: string | null; } -export interface RunCommunicationMessage extends Omit { +export interface SampleCommunicationMessage extends Omit { taskId: string | null; } -export interface RunCommunicationThread - extends Omit { - messages: RunCommunicationMessage[]; +export interface SampleCommunicationThread + extends Omit { + messages: SampleCommunicationMessage[]; taskId: string | null; } -export interface RunEvaluationCriterion - extends Omit { +export interface SampleEvaluationCriterion + extends Omit { error: Record | null; evaluatedActionIds: string[]; evaluatedResourceIds: string[]; @@ -224,14 +224,14 @@ export interface RunEvaluationCriterion export interface SampleTaskEvaluation extends Omit { - criterionResults: RunEvaluationCriterion[]; + criterionResults: SampleEvaluationCriterion[]; failedGate: string | null; taskId: string | null; } -export interface RunSnapshot +export interface SampleSnapshot extends Omit< - RawRunSnapshot, + RawSampleSnapshot, | "completedAt" | "durationSeconds" | "error" @@ -248,16 +248,16 @@ export interface RunSnapshot durationSeconds: number | null; error: string | null; evaluationsByTask: Record; - executionsByTask: Record; + executionsByTask: Record; finalScore: number | null; resourcesByTask: Record; - sandboxesByTask: Record; + sandboxesByTask: Record; startedAt: string; tasks: Record; - threads: RunCommunicationThread[]; + threads: SampleCommunicationThread[]; } -function normalizeRunExecutionAttempt(execution: RawRunExecutionAttempt): RunExecutionAttempt { +function normalizeSampleExecutionAttempt(execution: RawSampleExecutionAttempt): SampleExecutionAttempt { return { ...execution, agentId: execution.agentId ?? null, @@ -271,7 +271,7 @@ function normalizeRunExecutionAttempt(execution: RawRunExecutionAttempt): RunExe }; } -function normalizeRunSandboxCommand(command: RawRunSandboxCommand): RunSandboxCommand { +function normalizeSampleSandboxCommand(command: RawSampleSandboxCommand): SampleSandboxCommand { return { ...command, durationMs: command.durationMs ?? null, @@ -281,12 +281,12 @@ function normalizeRunSandboxCommand(command: RawRunSandboxCommand): RunSandboxCo }; } -function normalizeRunSandbox(sandbox: RawRunSandbox): RunSandbox { +function normalizeSampleSandbox(sandbox: RawSampleSandbox): SampleSandbox { return { ...sandbox, closeReason: sandbox.closeReason ?? null, closedAt: sandbox.closedAt ?? null, - commands: (sandbox.commands ?? []).map(normalizeRunSandboxCommand), + commands: (sandbox.commands ?? []).map(normalizeSampleSandboxCommand), template: sandbox.template ?? null, }; } @@ -304,17 +304,17 @@ function normalizeSampleTask(task: RawSampleTask): SampleTask { }; } -function normalizeRunCommunicationMessage(message: RawRunCommunicationMessage): RunCommunicationMessage { +function normalizeSampleCommunicationMessage(message: RawSampleCommunicationMessage): SampleCommunicationMessage { return { ...message, taskId: message.taskId ?? null, }; } -function normalizeRunCommunicationThread(thread: RawRunCommunicationThread): RunCommunicationThread { +function normalizeSampleCommunicationThread(thread: RawSampleCommunicationThread): SampleCommunicationThread { return { ...thread, - messages: (thread.messages ?? []).map(normalizeRunCommunicationMessage), + messages: (thread.messages ?? []).map(normalizeSampleCommunicationMessage), taskId: thread.taskId ?? null, summary: thread.summary ?? null, }; @@ -373,28 +373,28 @@ export function parseExperimentDetail(input: unknown): ExperimentDetail { }; } -export function parseRunSandbox(input: unknown): RunSandbox { - return normalizeRunSandbox(RunSandboxSchema.parse(input)); +export function parseSampleSandbox(input: unknown): SampleSandbox { + return normalizeSampleSandbox(SampleSandboxSchema.parse(input)); } -export function parseRunSandboxCommand(input: unknown): RunSandboxCommand { - return normalizeRunSandboxCommand(RunSandboxCommandSchema.parse(input)); +export function parseSampleSandboxCommand(input: unknown): SampleSandboxCommand { + return normalizeSampleSandboxCommand(SampleSandboxCommandSchema.parse(input)); } -export function parseRunCommunicationMessage(input: unknown): RunCommunicationMessage { - return normalizeRunCommunicationMessage(RunCommunicationMessageSchema.parse(input)); +export function parseSampleCommunicationMessage(input: unknown): SampleCommunicationMessage { + return normalizeSampleCommunicationMessage(SampleCommunicationMessageSchema.parse(input)); } -export function parseRunCommunicationThread(input: unknown): RunCommunicationThread { - return normalizeRunCommunicationThread(RunCommunicationThreadSchema.parse(input)); +export function parseSampleCommunicationThread(input: unknown): SampleCommunicationThread { + return normalizeSampleCommunicationThread(SampleCommunicationThreadSchema.parse(input)); } export function parseSampleTaskEvaluation(input: unknown): SampleTaskEvaluation { return normalizeSampleTaskEvaluation(SampleTaskEvaluationSchema.parse(input)); } -export function parseRunSnapshot(input: unknown): RunSnapshot { - const snapshot = RunSnapshotSchema.parse(input); +export function parseSampleSnapshot(input: unknown): SampleSnapshot { + const snapshot = SampleSnapshotSchema.parse(input); return { ...snapshot, completedAt: snapshot.completedAt ?? null, @@ -409,7 +409,7 @@ export function parseRunSnapshot(input: unknown): RunSnapshot { executionsByTask: Object.fromEntries( Object.entries(snapshot.executionsByTask ?? {}).map(([taskId, executions]) => [ taskId, - executions.map(normalizeRunExecutionAttempt), + executions.map(normalizeSampleExecutionAttempt), ]), ), finalScore: snapshot.finalScore ?? null, @@ -417,13 +417,13 @@ export function parseRunSnapshot(input: unknown): RunSnapshot { sandboxesByTask: Object.fromEntries( Object.entries(snapshot.sandboxesByTask ?? {}).map(([taskId, sandbox]) => [ taskId, - normalizeRunSandbox(sandbox), + normalizeSampleSandbox(sandbox), ]), ), startedAt: snapshot.startedAt ?? new Date(0).toISOString(), tasks: Object.fromEntries( Object.entries(snapshot.tasks ?? {}).map(([taskId, task]) => [taskId, normalizeSampleTask(task)]), ), - threads: (snapshot.threads ?? []).map(normalizeRunCommunicationThread), + threads: (snapshot.threads ?? []).map(normalizeSampleCommunicationThread), }; } diff --git a/ergon-dashboard/src/lib/sample-state/domain.ts b/ergon-dashboard/src/lib/sample-state/domain.ts index 8a846b471..7841143f7 100644 --- a/ergon-dashboard/src/lib/sample-state/domain.ts +++ b/ergon-dashboard/src/lib/sample-state/domain.ts @@ -1,5 +1,5 @@ -import type { RunSnapshot } from "@/lib/contracts/rest"; +import type { SampleSnapshot } from "@/lib/contracts/rest"; import type { SampleWorkspaceState } from "@/lib/types"; -export type WireRunSnapshot = RunSnapshot; -export type DashboardRunState = SampleWorkspaceState; +export type WireSampleSnapshot = SampleSnapshot; +export type DashboardSampleState = SampleWorkspaceState; diff --git a/ergon-dashboard/src/lib/sample-state/hydrate.ts b/ergon-dashboard/src/lib/sample-state/hydrate.ts index eb5dcde8c..aac48a5ed 100644 --- a/ergon-dashboard/src/lib/sample-state/hydrate.ts +++ b/ergon-dashboard/src/lib/sample-state/hydrate.ts @@ -1,5 +1,5 @@ -import type { RunSnapshot } from "@/lib/contracts/rest"; -import { parseRunSnapshot } from "@/lib/contracts/rest"; +import type { SampleSnapshot } from "@/lib/contracts/rest"; +import { parseSampleSnapshot } from "@/lib/contracts/rest"; import type { ContextEventState, ExecutionAttemptState, @@ -17,7 +17,7 @@ function toTaskStatus(status: string): TaskStatus { return status as TaskStatus; } -function deserializeTask(task: RunSnapshot["tasks"][string]): TaskState { +function deserializeTask(task: SampleSnapshot["tasks"][string]): TaskState { return { ...task, assignedWorkerSlug: task.assignedWorkerSlug ?? null, @@ -28,7 +28,7 @@ function deserializeTask(task: RunSnapshot["tasks"][string]): TaskState { } function deserializeExecution( - execution: RunSnapshot["executionsByTask"][string][number], + execution: SampleSnapshot["executionsByTask"][string][number], ): ExecutionAttemptState { return { ...execution, @@ -37,11 +37,11 @@ function deserializeExecution( }; } -function deserializeResource(resource: RunSnapshot["resourcesByTask"][string][number]): ResourceState { +function deserializeResource(resource: SampleSnapshot["resourcesByTask"][string][number]): ResourceState { return resource; } -function deserializeSandbox(sandbox: RunSnapshot["sandboxesByTask"][string]): SandboxState { +function deserializeSandbox(sandbox: SampleSnapshot["sandboxesByTask"][string]): SandboxState { return { ...sandbox, status: sandbox.status as SandboxState["status"], @@ -59,7 +59,7 @@ function deserializeSandbox(sandbox: RunSnapshot["sandboxesByTask"][string]): Sa }; } -function deserializeEvaluation(evaluation: RunSnapshot["evaluationsByTask"][string]): TaskEvaluationState { +function deserializeEvaluation(evaluation: SampleSnapshot["evaluationsByTask"][string]): TaskEvaluationState { return { ...evaluation, taskId: evaluation.taskId ?? null, @@ -68,7 +68,7 @@ function deserializeEvaluation(evaluation: RunSnapshot["evaluationsByTask"][stri }; } -function deserializeContextEvents(data: RunSnapshot): Map { +function deserializeContextEvents(data: SampleSnapshot): Map { const byTask = (data as unknown as Record).contextEventsByTask as | Record>> | undefined; @@ -96,8 +96,8 @@ function deserializeContextEvents(data: RunSnapshot): Map = { +export const SAMPLE_EVENT_KIND_LABELS: Record = { "workflow.started": "Workflow started", "workflow.completed": "Workflow completed", "task.transition": "Task transition", @@ -173,7 +173,7 @@ export const RUN_EVENT_KIND_LABELS: Record = { * Tailwind color token per event kind — used for the lane indicator in the * timeline, the left border of a row in the stream, and the filter chip. */ -export const RUN_EVENT_KIND_COLORS: Record = { +export const SAMPLE_EVENT_KIND_COLORS: Record = { "workflow.started": "bg-sky-500", "workflow.completed": "bg-emerald-500", "task.transition": "bg-indigo-500", @@ -220,21 +220,21 @@ function messagePreview(msg: CommunicationMessageState): string { } /** - * Build the flat, chronologically-sorted RunEvent log from a SampleWorkspaceState. + * Build the flat, chronologically-sorted SampleEvent log from a SampleWorkspaceState. * * Consumers should treat this as a *pure derivation* of state — it is cheap * enough to recompute on every render of the stream, but the SampleWorkspacePage * memoizes it on `runState` identity. */ -export function buildRunEvents(run: SampleWorkspaceState | null): RunEvent[] { +export function buildSampleEvents(run: SampleWorkspaceState | null): SampleEvent[] { if (!run) return []; - const events: RunEvent[] = []; + const events: SampleEvent[] = []; events.push({ id: `workflow.started:${run.id}`, kind: "workflow.started", at: run.startedAt, - runName: run.name, + sampleName: run.name, }); if (run.completedAt) { @@ -458,11 +458,11 @@ function threadMessages( * Convenience: count how many of each kind we have — used by filter toolbars. */ export function countEventsByKind( - events: RunEvent[], -): Record { + events: SampleEvent[], +): Record { const counts = Object.fromEntries( - RUN_EVENT_KINDS.map((k) => [k, 0]), - ) as Record; + SAMPLE_EVENT_KINDS.map((k) => [k, 0]), + ) as Record; for (const e of events) counts[e.kind] += 1; return counts; } @@ -472,11 +472,11 @@ export function countEventsByKind( * when jumping from a DAG selection to the event stream. */ export function findNearestEventForTask( - events: RunEvent[], + events: SampleEvent[], taskId: string, at: string | null, -): RunEvent | null { - let best: RunEvent | null = null; +): SampleEvent | null { + let best: SampleEvent | null = null; let bestDelta = Number.POSITIVE_INFINITY; const target = at ? new Date(at).getTime() : null; for (const e of events) { diff --git a/ergon-dashboard/src/lib/sampleState.ts b/ergon-dashboard/src/lib/sampleState.ts index 582774778..d488a2e58 100644 --- a/ergon-dashboard/src/lib/sampleState.ts +++ b/ergon-dashboard/src/lib/sampleState.ts @@ -1,12 +1,12 @@ export { compareContextEvents, contextPartToUiPayload, - deserializeRunState, - hydrateRunSnapshot, + deserializeSampleState, + hydrateSampleSnapshot, normalizeContextEventPayload, serializeContextEvent, - serializeRunSnapshot, - serializeRunState, + serializeSampleSnapshot, + serializeSampleState, uiPayloadToContextPart, } from "@/lib/sample-state"; -export type { DashboardRunState, WireRunSnapshot } from "@/lib/sample-state"; +export type { DashboardSampleState, WireSampleSnapshot } from "@/lib/sample-state"; diff --git a/ergon-dashboard/src/lib/server-data/samples.ts b/ergon-dashboard/src/lib/server-data/samples.ts index 492ba962c..66b13b363 100644 --- a/ergon-dashboard/src/lib/server-data/samples.ts +++ b/ergon-dashboard/src/lib/server-data/samples.ts @@ -1,11 +1,11 @@ import { config } from "@/lib/config"; -import { parseRunSnapshot, type RunSnapshot } from "@/lib/contracts/rest"; +import { parseSampleSnapshot, type SampleSnapshot } from "@/lib/contracts/rest"; import { fetchErgonApi } from "@/lib/serverApi"; -import { getHarnessRun } from "@/lib/testing/dashboardHarness"; +import { getHarnessSample } from "@/lib/testing/dashboardHarness"; import { backendUnavailable, type ServerDataResult } from "./responses"; -export interface RunListFilters { +export interface SampleListFilters { limit?: number; offset?: number; status?: string; @@ -13,7 +13,7 @@ export interface RunListFilters { experiment?: string; } -export interface RunSummary { +export interface SampleSummary { id: string; name: string; status: string; @@ -43,9 +43,9 @@ export interface RunSummary { metrics: Record; } -export async function loadRunList( - filters: RunListFilters = {}, -): Promise> { +export async function loadSampleList( + filters: SampleListFilters = {}, +): Promise> { const searchParams = new URLSearchParams(); searchParams.set("limit", String(filters.limit ?? 100)); if (filters.offset) searchParams.set("offset", String(filters.offset)); @@ -59,7 +59,7 @@ export async function loadRunList( if (response.ok) { return { ok: true, - data: parseRunList(body), + data: parseSampleList(body), status: response.status, source: "backend", }; @@ -70,11 +70,11 @@ export async function loadRunList( } } -export async function loadRunSnapshot(sampleId: string): Promise> { +export async function loadSampleSnapshot(sampleId: string): Promise> { if (config.enableTestHarness) { - const run = getHarnessRun(sampleId); + const run = getHarnessSample(sampleId); if (run !== null) { - return { ok: true, data: parseRunSnapshot(run), status: 200, source: "harness" }; + return { ok: true, data: parseSampleSnapshot(run), status: 200, source: "harness" }; } } @@ -84,7 +84,7 @@ export async function loadRunSnapshot(sampleId: string): Promise { const record = typeof item === "object" && item !== null ? (item as Record) : {}; diff --git a/ergon-dashboard/src/lib/socket/server.ts b/ergon-dashboard/src/lib/socket/server.ts index c9c509d46..c32ec7832 100644 --- a/ergon-dashboard/src/lib/socket/server.ts +++ b/ergon-dashboard/src/lib/socket/server.ts @@ -2,14 +2,14 @@ * Socket.io Server Setup * * Manages WebSocket connections and room-based subscriptions. - * Clients subscribe to specific run IDs to receive updates for those runs only. + * Clients subscribe to specific sample IDs to receive updates for those samples only. */ import { Server as HttpServer } from "http"; import { Server as SocketServer, Socket } from "socket.io"; import { config } from "../config"; import { store } from "../state/store"; -import { serializeRunState } from "../sampleState"; +import { serializeSampleState } from "../sampleState"; import { ContextEventState, ServerToClientEvents, @@ -74,11 +74,11 @@ export function initSocketServer(httpServer: HttpServer): TypedServer { }); // Send current runs to newly connected client - socket.on("request:runs", () => { + socket.on("request:samples", () => { console.log(`[Socket.io] Client ${socket.id} requested runs sync`); - const runs = store.getAllRuns(); + const runs = store.getAllSamples(); console.log(`[Socket.io] Sending ${runs.length} runs to client`); - socket.emit("sync:runs", runs.map(r => ({ + socket.emit("sync:samples", runs.map(r => ({ sampleId: r.id, name: r.name, status: r.status, @@ -90,34 +90,34 @@ export function initSocketServer(httpServer: HttpServer): TypedServer { }))); }); - // Request full state for a specific run (for run detail page) - socket.on("request:run", (sampleId: string) => { + // Request full state for a specific sample (for sample detail page) + socket.on("request:sample", (sampleId: string) => { console.log(`[Socket.io] Client ${socket.id} requested full state for run ${sampleId}`); // Debug: log all runs in store - const allRuns = store.getAllRuns(); + const allRuns = store.getAllSamples(); console.log(`[Socket.io] Store contains ${allRuns.length} runs:`, allRuns.map(r => r.id)); - const run = store.getRun(sampleId); + const run = store.getSample(sampleId); if (run) { console.log(`[Socket.io] Found run! Sending full state (${run.tasks.size} tasks, status: ${run.status})`); - socket.emit("sync:run", serializeRunState(run)); + socket.emit("sync:sample", serializeSampleState(run)); } else { console.log(`[Socket.io] Run ${sampleId} NOT FOUND in store. Available runs: ${allRuns.map(r => r.id.substring(0, 8)).join(', ') || 'none'}`); - socket.emit("sync:run", null); + socket.emit("sync:sample", null); } }); // Client subscribes to a run's updates socket.on("subscribe", (sampleId: string) => { - const room = `run:${sampleId}`; + const room = `sample:${sampleId}`; socket.join(room); console.log(`[Socket.io] ${socket.id} subscribed to ${room}`); }); // Client unsubscribes from a run's updates socket.on("unsubscribe", (sampleId: string) => { - const room = `run:${sampleId}`; + const room = `sample:${sampleId}`; socket.leave(room); console.log(`[Socket.io] ${socket.id} unsubscribed from ${room}`); }); @@ -146,22 +146,22 @@ export function getSocketServer(): TypedServer | null { /** * Broadcast a new run started to all connected clients. */ -export function broadcastRunStarted(sampleId: string, name: string): void { +export function broadcastSampleStarted(sampleId: string, name: string): void { const io = getIO(); - console.log(`[Socket.io] broadcastRunStarted called - io is ${io ? 'initialized' : 'NULL'}, sampleId: ${sampleId}, name: ${name}`); + console.log(`[Socket.io] broadcastSampleStarted called - io is ${io ? 'initialized' : 'NULL'}, sampleId: ${sampleId}, name: ${name}`); if (io) { const socketsCount = io.sockets.sockets.size; - console.log(`[Socket.io] Broadcasting run:started to ${socketsCount} connected clients`); - io.emit("run:started", { sampleId, name }); + console.log(`[Socket.io] Broadcasting sample:started to ${socketsCount} connected clients`); + io.emit("sample:started", { sampleId, name }); } else { - console.warn("[Socket.io] WARNING: Cannot broadcast run:started - io is null! Check that initSocketServer was called."); + console.warn("[Socket.io] WARNING: Cannot broadcast sample:started - io is null! Check that initSocketServer was called."); } } /** - * Broadcast run completed to subscribers of that run. + * Broadcast sample completed to subscribers of that sample. */ -export function broadcastRunCompleted( +export function broadcastSampleCompleted( sampleId: string, status: "completed" | "failed", completedAt: string, @@ -171,7 +171,7 @@ export function broadcastRunCompleted( ): void { const io = getIO(); // Broadcast to all clients (not just room subscribers) so the run list updates - io?.emit("run:completed", { + io?.emit("sample:completed", { sampleId, status, completedAt, @@ -193,7 +193,7 @@ export function broadcastTaskStatus( assignedWorkerSlug: string | null ): void { const io = getIO(); - io?.to(`run:${sampleId}`).emit("task:status", { + io?.to(`sample:${sampleId}`).emit("task:status", { sampleId, taskId, status, @@ -211,7 +211,7 @@ export function broadcastResourceNew( resource: ResourceState ): void { const io = getIO(); - io?.to(`run:${sampleId}`).emit("resource:new", { sampleId, resource }); + io?.to(`sample:${sampleId}`).emit("resource:new", { sampleId, resource }); } /** @@ -222,7 +222,7 @@ export function broadcastSandboxCreated( sandbox: SandboxState ): void { const io = getIO(); - io?.to(`run:${sampleId}`).emit("sandbox:created", { sampleId, sandbox }); + io?.to(`sample:${sampleId}`).emit("sandbox:created", { sampleId, sandbox }); } /** @@ -234,7 +234,7 @@ export function broadcastSandboxCommand( command: SandboxCommandState ): void { const io = getIO(); - io?.to(`run:${sampleId}`).emit("sandbox:command", { sampleId, taskId, command }); + io?.to(`sample:${sampleId}`).emit("sandbox:command", { sampleId, taskId, command }); } /** @@ -247,21 +247,21 @@ export function broadcastSandboxClosed( timestamp: string, ): void { const io = getIO(); - io?.to(`run:${sampleId}`).emit("sandbox:closed", { sampleId, taskId, reason, timestamp }); + io?.to(`sample:${sampleId}`).emit("sandbox:closed", { sampleId, taskId, reason, timestamp }); } export function broadcastThreadMessage( data: DashboardThreadMessageCreatedData ): void { const io = getIO(); - io?.to(`run:${data.sample_id}`).emit("thread:message", data); + io?.to(`sample:${data.sample_id}`).emit("thread:message", data); } export function broadcastTaskEvaluation( data: DashboardTaskEvaluationUpdatedData ): void { const io = getIO(); - io?.to(`run:${data.sample_id}`).emit("task:evaluation", data); + io?.to(`sample:${data.sample_id}`).emit("task:evaluation", data); } export function broadcastGraphMutation( @@ -269,7 +269,7 @@ export function broadcastGraphMutation( mutation: DashboardGraphMutationData, ): void { const io = getIO(); - io?.to(`run:${sampleId}`).emit("graph:mutation", { sampleId, mutation }); + io?.to(`sample:${sampleId}`).emit("graph:mutation", { sampleId, mutation }); } export function broadcastContextEvent( @@ -278,5 +278,5 @@ export function broadcastContextEvent( event: ContextEventState, ): void { const io = getIO(); - io?.to(`run:${sampleId}`).emit("context:event", { sampleId, taskId, event }); + io?.to(`sample:${sampleId}`).emit("context:event", { sampleId, taskId, event }); } diff --git a/ergon-dashboard/src/lib/state/store.ts b/ergon-dashboard/src/lib/state/store.ts index b5b0f3cc6..5c3a39f1a 100644 --- a/ergon-dashboard/src/lib/state/store.ts +++ b/ergon-dashboard/src/lib/state/store.ts @@ -25,8 +25,8 @@ import { } from "../types"; import { applyGraphMutation as reduceGraphMutation } from "@/features/graph/state/graphMutationReducer"; import type { DashboardGraphMutationData } from "@/lib/contracts/events"; -import type { RunSnapshot } from "@/lib/contracts/rest"; -import { hydrateRunSnapshot } from "@/lib/sample-state/hydrate"; +import type { SampleSnapshot } from "@/lib/contracts/rest"; +import { hydrateSampleSnapshot } from "@/lib/sample-state/hydrate"; import { applySandboxClosed, applySandboxCommand, @@ -49,22 +49,22 @@ class DashboardStore { // Queries // ========================================================================== - getRun(sampleId: string): SampleWorkspaceState | undefined { + getSample(sampleId: string): SampleWorkspaceState | undefined { return this.runs.get(sampleId); } - getAllRuns(): SampleWorkspaceState[] { + getAllSamples(): SampleWorkspaceState[] { return Array.from(this.runs.values()); } - getActiveRuns(): SampleWorkspaceState[] { - return this.getAllRuns().filter( + getActiveSamples(): SampleWorkspaceState[] { + return this.getAllSamples().filter( (r) => r.status === "pending" || r.status === "executing" || r.status === "evaluating", ); } - getRecentRuns(limit: number = 10): SampleWorkspaceState[] { - return this.getAllRuns() + getRecentSamples(limit: number = 10): SampleWorkspaceState[] { + return this.getAllSamples() .sort((a, b) => b.startedAt.localeCompare(a.startedAt)) .slice(0, limit); } @@ -92,7 +92,7 @@ class DashboardStore { this.pendingSandboxCommands.clear(); } - seedRun(run: SampleWorkspaceState): void { + seedSample(run: SampleWorkspaceState): void { this.runs.set(run.id, run); } @@ -103,16 +103,16 @@ class DashboardStore { /** * Initialize a new workflow run from a workflow.started event. */ - initializeRun( + initializeSample( sampleId: string, definitionId: string, name: string, - snapshot: RunSnapshot, + snapshot: SampleSnapshot, startedAt: string, totalTasks: number, totalLeafTasks: number ): SampleWorkspaceState { - const hydrated = hydrateRunSnapshot({ + const hydrated = hydrateSampleSnapshot({ ...snapshot, id: sampleId, definitionId, @@ -138,7 +138,7 @@ class DashboardStore { /** * Mark a workflow run as completed or failed. */ - completeRun( + completeSample( sampleId: string, status: "completed" | "failed", completedAt: string, @@ -313,11 +313,11 @@ class DashboardStore { } /** - * Remove old completed runs to prevent memory growth. + * Remove old completed samples to prevent memory growth. * Keeps the most recent N runs. */ pruneOldSamples(keepCount: number = config.maxSamplesToKeep): void { - const runs = this.getAllRuns(); + const runs = this.getAllSamples(); if (runs.length <= keepCount) return; // Sort by startedAt descending, keep the newest diff --git a/ergon-dashboard/src/lib/testing/dashboardHarness.ts b/ergon-dashboard/src/lib/testing/dashboardHarness.ts index a8dc8de0a..e8f303309 100644 --- a/ergon-dashboard/src/lib/testing/dashboardHarness.ts +++ b/ergon-dashboard/src/lib/testing/dashboardHarness.ts @@ -1,6 +1,6 @@ import { broadcastContextEvent, - broadcastRunCompleted, + broadcastSampleCompleted, broadcastTaskEvaluation, broadcastTaskStatus, broadcastThreadMessage, @@ -15,15 +15,15 @@ import { TaskEvaluationState, TaskStatus, } from "@/lib/types"; -import { deserializeRunState, serializeRunState } from "@/lib/sampleState"; +import { deserializeSampleState, serializeSampleState } from "@/lib/sampleState"; declare global { // eslint-disable-next-line no-var var __dashboardHarness: | { experimentDetails: Record; - mutationsByRun: Record; - seededRunIds: Set; + mutationsBySample: Record; + seededSampleIds: Set; } | undefined; } @@ -38,8 +38,8 @@ function getHarnessState() { if (!global.__dashboardHarness) { global.__dashboardHarness = { experimentDetails: {}, - mutationsByRun: {}, - seededRunIds: new Set(), + mutationsBySample: {}, + seededSampleIds: new Set(), }; } return global.__dashboardHarness; @@ -56,8 +56,8 @@ export function resetDashboardHarness(): void { store.reset(); const harness = getHarnessState(); harness.experimentDetails = {}; - harness.mutationsByRun = {}; - harness.seededRunIds.clear(); + harness.mutationsBySample = {}; + harness.seededSampleIds.clear(); } export function seedDashboardHarness(payload: DashboardHarnessSeedPayload): void { @@ -66,11 +66,11 @@ export function seedDashboardHarness(payload: DashboardHarnessSeedPayload): void const harness = getHarnessState(); harness.experimentDetails = payload.experimentDetails ?? {}; - harness.mutationsByRun = payload.mutations ?? {}; + harness.mutationsBySample = payload.mutations ?? {}; for (const run of payload.runs ?? []) { - store.seedRun(deserializeRunState(run)); - harness.seededRunIds.add(run.id); + store.seedSample(deserializeSampleState(run)); + harness.seededSampleIds.add(run.id); } } @@ -79,21 +79,21 @@ export function getHarnessExperiment(definitionId: string): ExperimentDetail | n return getHarnessState().experimentDetails[definitionId] ?? null; } -export function getHarnessRun(sampleId: string): SerializedSampleWorkspaceState | null { +export function getHarnessSample(sampleId: string): SerializedSampleWorkspaceState | null { requireHarnessEnabled(); - if (!getHarnessState().seededRunIds.has(sampleId)) { + if (!getHarnessState().seededSampleIds.has(sampleId)) { return null; } - const run = store.getRun(sampleId); - return run ? serializeRunState(run) : null; + const run = store.getSample(sampleId); + return run ? serializeSampleState(run) : null; } -export function getHarnessRunMutations(sampleId: string): unknown[] | null { +export function getHarnessSampleMutations(sampleId: string): unknown[] | null { requireHarnessEnabled(); - return getHarnessState().mutationsByRun[sampleId] ?? null; + return getHarnessState().mutationsBySample[sampleId] ?? null; } -export function emitHarnessRunCompleted(data: { +export function emitHarnessSampleCompleted(data: { sampleId: string; status: "completed" | "failed"; durationSeconds: number; @@ -101,7 +101,7 @@ export function emitHarnessRunCompleted(data: { error: string | null; }): void { requireHarnessEnabled(); - store.completeRun( + store.completeSample( data.sampleId, data.status, new Date().toISOString(), @@ -109,7 +109,7 @@ export function emitHarnessRunCompleted(data: { data.finalScore, data.error, ); - broadcastRunCompleted( + broadcastSampleCompleted( data.sampleId, data.status, new Date().toISOString(), diff --git a/ergon-dashboard/src/lib/types.ts b/ergon-dashboard/src/lib/types.ts index 783fe5c25..dfa28caaa 100644 --- a/ergon-dashboard/src/lib/types.ts +++ b/ergon-dashboard/src/lib/types.ts @@ -4,11 +4,11 @@ export type { ContextEventState }; import type { BenchmarkName as RestBenchmarkName, ExperimentDetail as RestExperimentDetail, - RunCommunicationMessage as RestRunCommunicationMessage, - RunCommunicationThread as RestRunCommunicationThread, - RunLifecycleStatus as RestRunLifecycleStatus, - RunSnapshot, - RunSnapshotMetrics, + SampleCommunicationMessage as RestSampleCommunicationMessage, + SampleCommunicationThread as RestSampleCommunicationThread, + SampleLifecycleStatus as RestSampleLifecycleStatus, + SampleSnapshot, + SampleSnapshotMetrics, SampleTaskEvaluation as RestSampleTaskEvaluation, } from "@/lib/contracts/rest"; import type { @@ -16,7 +16,7 @@ import type { DashboardResourcePublishedData as GeneratedDashboardResourcePublishedData, GraphMutationSocketData as GeneratedGraphMutationSocketData, ResourceSocketData, - RunCompletedSocketData, + SampleCompletedSocketData, DashboardSandboxClosedData as GeneratedDashboardSandboxClosedData, SandboxClosedSocketData, SandboxCommandSocketData, @@ -28,7 +28,7 @@ import type { DashboardThreadMessageCreatedData as GeneratedDashboardThreadMessageCreatedData, DashboardWorkflowCompletedData as GeneratedDashboardWorkflowCompletedData, DashboardWorkflowStartedData as GeneratedDashboardWorkflowStartedData, - RunListEntry, + SampleListEntry, TaskStatusSocketData, } from "@/lib/contracts/events"; @@ -55,7 +55,7 @@ export enum TaskTrigger { } export type BenchmarkName = RestBenchmarkName; -export type RunLifecycleStatus = RestRunLifecycleStatus; +export type SampleLifecycleStatus = RestSampleLifecycleStatus; // ============================================================================= // Event Names @@ -90,8 +90,8 @@ export type DashboardResourcePublishedData = GeneratedDashboardResourcePublished export type DashboardSandboxCreatedData = GeneratedDashboardSandboxCreatedData; export type DashboardSandboxCommandData = GeneratedDashboardSandboxCommandData; export type DashboardSandboxClosedData = GeneratedDashboardSandboxClosedData; -export type CommunicationMessageState = RestRunCommunicationMessage; -export type CommunicationThreadState = RestRunCommunicationThread; +export type CommunicationMessageState = RestSampleCommunicationMessage; +export type CommunicationThreadState = RestSampleCommunicationThread; export type EvaluationCriterionState = NonNullable[number]; export type TaskEvaluationState = RestSampleTaskEvaluation; export type DashboardThreadMessageCreatedData = GeneratedDashboardThreadMessageCreatedData; @@ -294,7 +294,7 @@ export interface SampleWorkspaceState { id: string; definitionId: string; name: string; - status: RunLifecycleStatus; + status: SampleLifecycleStatus; // Task DAG (flattened) tasks: Map; @@ -330,7 +330,7 @@ export interface SampleWorkspaceState { runningTasks: number; failedTasks: number; cancelledTasks: number; - metrics?: RunSnapshotMetrics | null; + metrics?: SampleSnapshotMetrics | null; // Result finalScore: number | null; @@ -354,8 +354,8 @@ export interface SampleWorkspaceState { * Events sent from server to client via Socket.io. */ export interface ServerToClientEvents { - "run:started": (data: { sampleId: string; name: string }) => void; - "run:completed": (data: RunCompletedSocketData) => void; + "sample:started": (data: { sampleId: string; name: string }) => void; + "sample:completed": (data: SampleCompletedSocketData) => void; "task:status": (data: TaskStatusSocketData) => void; "resource:new": (data: ResourceSocketData) => void; "sandbox:created": (data: SandboxCreatedSocketData) => void; @@ -366,15 +366,15 @@ export interface ServerToClientEvents { "graph:mutation": (data: GraphMutationSocketData) => void; "context:event": (data: { sampleId: string; taskId: string; event: ContextEventState }) => void; // Sync event - sends all current runs to a client on request - "sync:runs": (runs: RunListEntry[]) => void; - // Sync event - sends full state for a specific run - "sync:run": (run: SerializedSampleWorkspaceState | null) => void; + "sync:samples": (runs: SampleListEntry[]) => void; + // Sync event - sends full state for a specific sample + "sync:sample": (run: SerializedSampleWorkspaceState | null) => void; } /** * Validated run snapshot payload used over REST and Socket.io sync. */ -export type SerializedSampleWorkspaceState = RunSnapshot; +export type SerializedSampleWorkspaceState = SampleSnapshot; /** * Events sent from client to server via Socket.io. @@ -382,6 +382,6 @@ export type SerializedSampleWorkspaceState = RunSnapshot; export interface ClientToServerEvents { subscribe: (sampleId: string) => void; unsubscribe: (sampleId: string) => void; - "request:runs": () => void; - "request:run": (sampleId: string) => void; + "request:samples": () => void; + "request:sample": (sampleId: string) => void; } diff --git a/ergon-dashboard/tests/contracts/contracts.test.ts b/ergon-dashboard/tests/contracts/contracts.test.ts index f4f3b357c..f3fdf07ff 100644 --- a/ergon-dashboard/tests/contracts/contracts.test.ts +++ b/ergon-dashboard/tests/contracts/contracts.test.ts @@ -12,11 +12,11 @@ import { parseDashboardWorkflowStartedData, parseTaskStatusSocketData, } from "../../src/lib/contracts/events"; -import { parseRunSnapshot } from "../../src/lib/contracts/rest"; -import { deserializeRunState } from "../../src/lib/sampleState"; +import { parseSampleSnapshot } from "../../src/lib/contracts/rest"; +import { deserializeSampleState } from "../../src/lib/sampleState"; import { store } from "../../src/lib/state/store"; import { - getHarnessRun, + getHarnessSample, resetDashboardHarness, seedDashboardHarness, } from "../../src/lib/testing/dashboardHarness"; @@ -27,7 +27,7 @@ test("run snapshot parser accepts object-map transport", () => { const run = seed.runs?.[0]; assert.ok(run); - const parsed = parseRunSnapshot(run); + const parsed = parseSampleSnapshot(run); assert.equal(parsed.id, FIXTURE_IDS.sampleId); assert.deepEqual(Object.keys(parsed.tasks ?? {}).sort(), [ @@ -42,7 +42,7 @@ test("run snapshot hydration converts context part chunks into UI action payload const run = seed.runs?.[0]; assert.ok(run); - const state = deserializeRunState(run); + const state = deserializeSampleState(run); const events = state.contextEventsByTask.get(FIXTURE_IDS.solveTaskId) ?? []; assert.equal(events.length, 2); @@ -81,7 +81,7 @@ test("run snapshot hydration orders context events across retried executions", ( }, }; - const state = deserializeRunState({ + const state = deserializeSampleState({ ...run, contextEventsByTask: { [FIXTURE_IDS.solveTaskId]: [retryEvent, first], @@ -103,7 +103,7 @@ test("run snapshot parser rejects tuple-map transport", () => { tasks: Object.entries(run.tasks ?? {}), }; - assert.throws(() => parseRunSnapshot(legacyPayload)); + assert.throws(() => parseSampleSnapshot(legacyPayload)); }); test("dashboard harness only serves explicitly seeded runs", () => { @@ -112,15 +112,15 @@ test("dashboard harness only serves explicitly seeded runs", () => { assert.ok(run); resetDashboardHarness(); - store.seedRun(deserializeRunState({ ...run, id: "live-event-run" })); + store.seedSample(deserializeSampleState({ ...run, id: "live-event-run" })); - assert.equal(getHarnessRun("live-event-run"), null); + assert.equal(getHarnessSample("live-event-run"), null); seedDashboardHarness({ runs: [run] }); - const seededRun = getHarnessRun(FIXTURE_IDS.sampleId); + const seededRun = getHarnessSample(FIXTURE_IDS.sampleId); assert.equal(seededRun?.id, FIXTURE_IDS.sampleId); - assert.equal(deserializeRunState(seededRun).id, FIXTURE_IDS.sampleId); + assert.equal(deserializeSampleState(seededRun).id, FIXTURE_IDS.sampleId); }); test("workflow started event parser validates run snapshots", () => { diff --git a/ergon-dashboard/tests/contracts/sample-state-roundtrip.contract.test.ts b/ergon-dashboard/tests/contracts/sample-state-roundtrip.contract.test.ts index 099c6b1dd..c765d8a9c 100644 --- a/ergon-dashboard/tests/contracts/sample-state-roundtrip.contract.test.ts +++ b/ergon-dashboard/tests/contracts/sample-state-roundtrip.contract.test.ts @@ -1,17 +1,17 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { parseRunSnapshot } from "../../src/lib/contracts/rest"; -import { hydrateRunSnapshot, serializeRunSnapshot } from "../../src/lib/sample-state"; +import { parseSampleSnapshot } from "../../src/lib/contracts/rest"; +import { hydrateSampleSnapshot, serializeSampleSnapshot } from "../../src/lib/sample-state"; import { createDashboardSeed, FIXTURE_IDS } from "../helpers/dashboardFixtures"; test("run state serializes back into a valid wire run snapshot", () => { const run = createDashboardSeed().runs?.[0]; assert.ok(run); - const state = hydrateRunSnapshot(run); - const wire = serializeRunSnapshot(state); - const reparsed = parseRunSnapshot(wire); + const state = hydrateSampleSnapshot(run); + const wire = serializeSampleSnapshot(state); + const reparsed = parseSampleSnapshot(wire); assert.equal(reparsed.id, FIXTURE_IDS.sampleId); assert.equal(reparsed.tasks[FIXTURE_IDS.solveTaskId]?.id, FIXTURE_IDS.solveTaskId); @@ -21,7 +21,7 @@ test("run snapshot hydration preserves nested run metrics for header display", ( const run = createDashboardSeed().runs?.[0]; assert.ok(run); - const state = hydrateRunSnapshot({ + const state = hydrateSampleSnapshot({ ...run, metrics: { sampleId: FIXTURE_IDS.sampleId, @@ -34,7 +34,7 @@ test("run snapshot hydration preserves nested run metrics for header display", ( totalCostUsd: 0.42, costObserved: true, }, - }) as ReturnType & { + }) as ReturnType & { metrics?: { totalTokens: number | null; totalCostUsd: number | null; diff --git a/ergon-dashboard/tests/contracts/server-data.contract.test.ts b/ergon-dashboard/tests/contracts/server-data.contract.test.ts index 786f87b03..2e6cda9c1 100644 --- a/ergon-dashboard/tests/contracts/server-data.contract.test.ts +++ b/ergon-dashboard/tests/contracts/server-data.contract.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { loadExperimentList } from "../../src/lib/server-data/experiments"; -import { loadRunList } from "../../src/lib/server-data/samples"; +import { loadSampleList } from "../../src/lib/server-data/samples"; import { getHarnessExperiment, resetDashboardHarness } from "../../src/lib/testing/dashboardHarness"; test("harness miss for experiment is represented as null, not notFound policy", () => { @@ -85,7 +85,7 @@ test("run list server data applies list filters and parses index summary fields" }; try { - const result = await loadRunList({ + const result = await loadSampleList({ limit: 25, offset: 50, status: "completed", diff --git a/ergon-dashboard/tests/e2e/sample.delta.spec.ts b/ergon-dashboard/tests/e2e/sample.delta.spec.ts index 368092749..a7f76d66e 100644 --- a/ergon-dashboard/tests/e2e/sample.delta.spec.ts +++ b/ergon-dashboard/tests/e2e/sample.delta.spec.ts @@ -31,12 +31,12 @@ test.afterEach(async () => { releaseHarnessLock = null; }); -test("run header reacts to controlled completion delta", async ({ page }) => { +test("sample header reacts to controlled completion delta", async ({ page }) => { await page.goto(`/samples/${FIXTURE_IDS.sampleId}`); await expect(page.getByTestId("sample-header")).toContainText("Executing"); - const response = await page.request.post("/api/danger/test-harness/dashboard/events/run-complete", { + const response = await page.request.post("/api/danger/test-harness/dashboard/events/sample-complete", { data: { sampleId: FIXTURE_IDS.sampleId, status: "completed", diff --git a/ergon_builtins/ergon_builtins/toolkits/resources/toolkit.py b/ergon_builtins/ergon_builtins/toolkits/resources/toolkit.py index 760f4c71f..4b3b80232 100644 --- a/ergon_builtins/ergon_builtins/toolkits/resources/toolkit.py +++ b/ergon_builtins/ergon_builtins/toolkits/resources/toolkit.py @@ -32,7 +32,7 @@ class ResearchGraphToolkit: """ def __init__(self, *, sample_id: UUID, task_execution_id: UUID) -> None: - self._run_id = sample_id + self._sample_id = sample_id self._task_execution_id = task_execution_id self._resource_repo = SampleResourceRepository() self._task_repo = TaskExecutionRepository() @@ -55,7 +55,7 @@ def build_tools(self) -> list[Tool[AgentToolBudgetDeps]]: # ------------------------------------------------------------------ def _list_my_resources(self) -> Tool[AgentToolBudgetDeps]: - sample_id = self._run_id + sample_id = self._sample_id task_execution_id = self._task_execution_id async def list_my_resources( @@ -85,7 +85,7 @@ async def list_my_resources( # ------------------------------------------------------------------ def _list_child_resources(self) -> Tool[AgentToolBudgetDeps]: - sample_id = self._run_id + sample_id = self._sample_id task_execution_id = self._task_execution_id async def list_child_resources( @@ -121,7 +121,7 @@ async def list_child_resources( # ------------------------------------------------------------------ def _list_descendant_resources(self) -> Tool[AgentToolBudgetDeps]: - sample_id = self._run_id + sample_id = self._sample_id task_execution_id = self._task_execution_id async def list_descendant_resources( @@ -176,7 +176,7 @@ async def list_descendant_resources( # ------------------------------------------------------------------ def _list_run_resources(self) -> Tool[AgentToolBudgetDeps]: - sample_id = self._run_id + sample_id = self._sample_id async def list_run_resources( ctx: "RunContext[AgentToolBudgetDeps]", @@ -203,7 +203,7 @@ async def list_run_resources( # ------------------------------------------------------------------ def _get_resource_by_logical_path(self) -> Tool[AgentToolBudgetDeps]: - sample_id = self._run_id + sample_id = self._sample_id async def get_resource_by_logical_path( ctx: "RunContext[AgentToolBudgetDeps]", @@ -238,7 +238,7 @@ async def get_resource_by_logical_path( # ------------------------------------------------------------------ def _get_resource_by_content_hash(self) -> Tool[AgentToolBudgetDeps]: - sample_id = self._run_id + sample_id = self._sample_id async def get_resource_by_content_hash( ctx: "RunContext[AgentToolBudgetDeps]", diff --git a/ergon_core/ergon_core/api/worker/context.py b/ergon_core/ergon_core/api/worker/context.py index 1744cd80b..954dc06cf 100644 --- a/ergon_core/ergon_core/api/worker/context.py +++ b/ergon_core/ergon_core/api/worker/context.py @@ -11,7 +11,7 @@ from ergon_core.api.worker.results import SpawnedTaskHandle from ergon_core.core.application.resources.models import SampleResourceView from ergon_core.core.application.runtime.task_models import SubtaskInfo -from ergon_core.core.persistence.shared.types import NodeId, RunId +from ergon_core.core.persistence.shared.types import NodeId, SampleId if TYPE_CHECKING: from sqlmodel import Session @@ -168,7 +168,7 @@ async def cancel_task(self, task_id: UUID, *, reason: str | None = None) -> None with self.session_factory() as session: await self.task_mgmt.cancel_task( session, - CancelTaskCommand(sample_id=RunId(self.sample_id), task_id=NodeId(task_id)), + CancelTaskCommand(sample_id=SampleId(self.sample_id), task_id=NodeId(task_id)), ) async def refine_task(self, task_id: UUID, *, description: str) -> None: @@ -182,7 +182,7 @@ async def refine_task(self, task_id: UUID, *, description: str) -> None: await self.task_mgmt.refine_task( session, RefineTaskCommand( - sample_id=RunId(self.sample_id), + sample_id=SampleId(self.sample_id), task_id=NodeId(task_id), new_description=description, ), @@ -198,7 +198,7 @@ async def restart_task(self, task_id: UUID) -> SpawnedTaskHandle: with self.session_factory() as session: result = await self.task_mgmt.restart_task( session, - RestartTaskCommand(sample_id=RunId(self.sample_id), task_id=NodeId(task_id)), + RestartTaskCommand(sample_id=SampleId(self.sample_id), task_id=NodeId(task_id)), ) return SpawnedTaskHandle(task_id=result.task_id) diff --git a/ergon_core/ergon_core/core/application/communication/service.py b/ergon_core/ergon_core/core/application/communication/service.py index 96e1ea45d..600754eaf 100644 --- a/ergon_core/ergon_core/core/application/communication/service.py +++ b/ergon_core/ergon_core/core/application/communication/service.py @@ -15,8 +15,8 @@ from ergon_core.core.shared.utils import utcnow from ergon_core.core.views.dashboard_events.contracts import DashboardThreadMessageCreatedEvent from ergon_core.core.views.samples.models import ( - RunCommunicationMessageDto, - RunCommunicationThreadDto, + SampleCommunicationMessageDto, + SampleCommunicationThreadDto, ) from sqlalchemy.exc import IntegrityError from sqlmodel import Session, func, select @@ -78,7 +78,7 @@ async def save_message(self, request: CreateMessageRequest) -> MessageResponse: created_at=message.created_at, ) - thread_dto = RunCommunicationThreadDto( + thread_dto = SampleCommunicationThreadDto( id=str(thread.id), sample_id=str(thread.sample_id), topic=thread.topic, @@ -89,7 +89,7 @@ async def save_message(self, request: CreateMessageRequest) -> MessageResponse: updated_at=thread.updated_at, messages=[], ) - message_dto = RunCommunicationMessageDto( + message_dto = SampleCommunicationMessageDto( id=str(message.id), thread_id=str(message.thread_id), thread_topic=thread.topic, diff --git a/ergon_core/ergon_core/core/application/experiments/models.py b/ergon_core/ergon_core/core/application/experiments/models.py index 7f75781d4..6d2d906f3 100644 --- a/ergon_core/ergon_core/core/application/experiments/models.py +++ b/ergon_core/ergon_core/core/application/experiments/models.py @@ -37,7 +37,7 @@ class ExperimentRunResult(BaseModel): definition_ids: list[UUID] = Field(default_factory=list) -class RunAssignment(BaseModel): +class SampleAssignment(BaseModel): instance_key: str sample_id: str | None = None worker_team: JsonObject diff --git a/ergon_core/ergon_core/core/application/runtime/models.py b/ergon_core/ergon_core/core/application/runtime/models.py index ada90ed30..4d0343f97 100644 --- a/ergon_core/ergon_core/core/application/runtime/models.py +++ b/ergon_core/ergon_core/core/application/runtime/models.py @@ -2,7 +2,7 @@ Frozen Pydantic models. Callers never receive raw SQLModel rows. -UUID fields use NewType aliases (RunId, NodeId, etc.) so that type +UUID fields use NewType aliases (SampleId, NodeId, etc.) so that type checkers catch cross-field swaps — e.g. passing a task id where a sample_id is expected. The aliases are erased at runtime (zero serialization cost). @@ -20,7 +20,7 @@ DefinitionId, EdgeId, NodeId, - RunId, + SampleId, ) from pydantic import BaseModel, Field @@ -45,7 +45,7 @@ class GraphNodeDto(BaseModel): model_config = {"frozen": True} task_id: NodeId - sample_id: RunId + sample_id: SampleId instance_key: str task_slug: str description: str @@ -78,7 +78,7 @@ class GraphEdgeDto(BaseModel): model_config = {"frozen": True} id: EdgeId - sample_id: RunId + sample_id: SampleId definition_dependency_id: DefinitionId | None source_task_id: NodeId target_task_id: NodeId @@ -94,7 +94,7 @@ class GraphAnnotationDto(BaseModel): model_config = {"frozen": True} id: UUID = Field(description="Identifier of the annotation row itself.") - sample_id: RunId + sample_id: SampleId target_type: GraphTargetType target_id: UUID = Field( description=( @@ -113,7 +113,7 @@ class GraphMutationRecordDto(BaseModel): model_config = {"frozen": True} id: UUID = Field(description="Identifier of the mutation row itself, not a graph target id.") - sample_id: RunId + sample_id: SampleId sequence: int mutation_type: MutationType target_type: GraphTargetType @@ -135,7 +135,7 @@ class WorkflowGraphDto(BaseModel): model_config = {"frozen": True} - sample_id: RunId + sample_id: SampleId nodes: list[GraphNodeDto] = Field(default_factory=list) edges: list[GraphEdgeDto] = Field(default_factory=list) @@ -269,7 +269,7 @@ class SampleGraphNodeView(BaseModel): model_config = {"frozen": True, "arbitrary_types_allowed": True} - sample_id: RunId + sample_id: SampleId task_id: UUID parent_task_id: NodeId | None status: str diff --git a/ergon_core/ergon_core/core/application/runtime/orchestration.py b/ergon_core/ergon_core/core/application/runtime/orchestration.py index 2b2d6359b..a4d24d631 100644 --- a/ergon_core/ergon_core/core/application/runtime/orchestration.py +++ b/ergon_core/ergon_core/core/application/runtime/orchestration.py @@ -141,7 +141,7 @@ class FinalizedWorkflowResult(BaseModel): evaluators_count: int = 0 -class RunCompletionData(BaseModel): +class SampleCompletionData(BaseModel): """Atomic bundle passed into run completion persistence.""" model_config = {"frozen": True} diff --git a/ergon_core/ergon_core/core/application/runtime/sample_lifecycle.py b/ergon_core/ergon_core/core/application/runtime/sample_lifecycle.py index 8122c9bb8..b2a83956a 100644 --- a/ergon_core/ergon_core/core/application/runtime/sample_lifecycle.py +++ b/ergon_core/ergon_core/core/application/runtime/sample_lifecycle.py @@ -48,7 +48,7 @@ InitializeWorkflowCommand, PropagateTaskCompletionCommand, PropagationResult, - RunCompletionData, + SampleCompletionData, TaskDescriptor, WorkflowTerminalState, ) @@ -182,7 +182,7 @@ def finalize(self, command: FinalizeWorkflowCommand) -> FinalizedWorkflowResult: ).all() ) score_summary = EvaluationService.summarize_scores(evaluations) - completion = RunCompletionData( + completion = SampleCompletionData( completed_at=utcnow(), final_score=score_summary.final_score, normalized_score=score_summary.normalized_score, diff --git a/ergon_core/ergon_core/core/application/runtime/task_models.py b/ergon_core/ergon_core/core/application/runtime/task_models.py index 9c8a99389..39e4b6b92 100644 --- a/ergon_core/ergon_core/core/application/runtime/task_models.py +++ b/ergon_core/ergon_core/core/application/runtime/task_models.py @@ -4,7 +4,7 @@ from ergon_core.core.application.events import TaskCancelledEvent from ergon_core.core.application.runtime.status import NodeStatus -from ergon_core.core.persistence.shared.types import NodeId, RunId +from ergon_core.core.persistence.shared.types import NodeId, SampleId from pydantic import BaseModel, Field @@ -14,7 +14,7 @@ class CancelTaskCommand(BaseModel): """Command to cancel a subtask.""" - sample_id: RunId + sample_id: SampleId task_id: NodeId model_config = {"frozen": True} @@ -36,7 +36,7 @@ class CancelTaskResult(BaseModel): class RefineTaskCommand(BaseModel): """Command to update description on a pending sub-task.""" - sample_id: RunId + sample_id: SampleId task_id: NodeId new_description: str = Field(min_length=1) @@ -64,7 +64,7 @@ class RestartTaskCommand(BaseModel): ``restart_task`` to put the node back in the scheduling queue. """ - sample_id: RunId + sample_id: SampleId task_id: NodeId model_config = {"frozen": True} @@ -112,7 +112,7 @@ class SubtaskInfo(BaseModel): class CleanupResult(BaseModel): """Result of cleaning up a cancelled task execution.""" - sample_id: RunId + sample_id: SampleId task_id: NodeId execution_id: UUID | None sandbox_id: str | None = None diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/registry.py b/ergon_core/ergon_core/core/infrastructure/inngest/registry.py index 9afdda993..df047aca8 100644 --- a/ergon_core/ergon_core/core/infrastructure/inngest/registry.py +++ b/ergon_core/ergon_core/core/infrastructure/inngest/registry.py @@ -14,7 +14,7 @@ cleanup_cancelled_task_fn, ) from ergon_core.core.jobs.resources.persist_outputs.inngest import persist_outputs_fn -from ergon_core.core.jobs.run.cleanup.inngest import sample_cleanup_fn +from ergon_core.core.jobs.sample.cleanup.inngest import sample_cleanup_fn from ergon_core.core.jobs.sandbox.cleanup.inngest import ( sandbox_cleanup_on_completed_fn, sandbox_cleanup_on_failed_fn, diff --git a/ergon_core/ergon_core/core/jobs/run/cleanup/__init__.py b/ergon_core/ergon_core/core/jobs/sample/cleanup/__init__.py similarity index 100% rename from ergon_core/ergon_core/core/jobs/run/cleanup/__init__.py rename to ergon_core/ergon_core/core/jobs/sample/cleanup/__init__.py diff --git a/ergon_core/ergon_core/core/jobs/run/cleanup/contract.py b/ergon_core/ergon_core/core/jobs/sample/cleanup/contract.py similarity index 100% rename from ergon_core/ergon_core/core/jobs/run/cleanup/contract.py rename to ergon_core/ergon_core/core/jobs/sample/cleanup/contract.py diff --git a/ergon_core/ergon_core/core/jobs/run/cleanup/inngest.py b/ergon_core/ergon_core/core/jobs/sample/cleanup/inngest.py similarity index 88% rename from ergon_core/ergon_core/core/jobs/run/cleanup/inngest.py rename to ergon_core/ergon_core/core/jobs/sample/cleanup/inngest.py index 1151f1c37..e62118d33 100644 --- a/ergon_core/ergon_core/core/jobs/run/cleanup/inngest.py +++ b/ergon_core/ergon_core/core/jobs/sample/cleanup/inngest.py @@ -1,4 +1,4 @@ -"""Inngest adapter for run cleanup.""" +"""Inngest adapter for sample cleanup.""" import inngest @@ -8,7 +8,7 @@ @inngest_client.create_function( - fn_id="run-cleanup", + fn_id="sample-cleanup", trigger=inngest.TriggerEvent(event="sample/cleanup"), retries=0, output_type=SampleCleanupResult, diff --git a/ergon_core/ergon_core/core/jobs/run/cleanup/job.py b/ergon_core/ergon_core/core/jobs/sample/cleanup/job.py similarity index 80% rename from ergon_core/ergon_core/core/jobs/run/cleanup/job.py rename to ergon_core/ergon_core/core/jobs/sample/cleanup/job.py index 131c8ba01..86ca7113e 100644 --- a/ergon_core/ergon_core/core/jobs/run/cleanup/job.py +++ b/ergon_core/ergon_core/core/jobs/sample/cleanup/job.py @@ -1,6 +1,6 @@ -"""Inngest function: run cleanup (sandbox teardown). +"""Inngest function: sample cleanup (sandbox teardown). -Terminates sandbox after run completion/failure and ensures run status is correct. +Terminates sandbox after sample completion/failure and ensures sample status is correct. """ import logging @@ -25,24 +25,24 @@ async def run_sample_cleanup_job(ctx: Any, payload: SampleCleanupEvent) -> SampleCleanupResult: - """Cleanup: terminate sandbox, ensure run status is correct.""" + """Cleanup: terminate sandbox, ensure sample status is correct.""" sample_id = payload.sample_id status = payload.status error_message = payload.error_message - logger.info("run-cleanup sample_id=%s status=%s", sample_id, status) + logger.info("sample-cleanup sample_id=%s status=%s", sample_id, status) return await ctx.step.run( - "cleanup-run", - partial(_cleanup_run, sample_id, status, error_message), + "cleanup-sample", + partial(_cleanup_sample, sample_id, status, error_message), output_type=SampleCleanupResult, ) -async def _cleanup_run( +async def _cleanup_sample( sample_id: UUID, status: str, error_message: str | None ) -> SampleCleanupResult: - """Terminate sandbox and update run status.""" + """Terminate sandbox and update sample status.""" expected = _STATUS_MAP.get(status) if expected is None: raise ConfigurationError( @@ -64,7 +64,7 @@ async def _cleanup_run( if sandbox_id is not None and not isinstance(sandbox_id, str): logger.warning( - "run-cleanup sample_id=%s: sandbox_id has unexpected type %s, skipping termination", + "sample-cleanup sample_id=%s: sandbox_id has unexpected type %s, skipping termination", sample_id, type(sandbox_id).__name__, ) diff --git a/ergon_core/ergon_core/core/jobs/task/cleanup_cancelled/job.py b/ergon_core/ergon_core/core/jobs/task/cleanup_cancelled/job.py index 62cafae80..ef47ea081 100644 --- a/ergon_core/ergon_core/core/jobs/task/cleanup_cancelled/job.py +++ b/ergon_core/ergon_core/core/jobs/task/cleanup_cancelled/job.py @@ -12,7 +12,7 @@ from ergon_core.core.application.runtime.task_cleanup import TaskCleanupService from ergon_core.core.jobs.sandbox._lifecycle import terminate_external_sandbox from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.shared.types import NodeId, RunId +from ergon_core.core.persistence.shared.types import NodeId, SampleId from ergon_core.core.shared.json_types import JsonObject from ergon_core.core.shared.utils import utcnow from ergon_core.core.views.dashboard_events.contracts import DashboardTaskStatusChangedEvent @@ -34,7 +34,7 @@ async def sample_cleanup_cancelled_task_job(ctx: Any, payload: TaskCancelledEven if payload.execution_id is None: return CleanupResult( - sample_id=RunId(payload.sample_id), + sample_id=SampleId(payload.sample_id), task_id=NodeId(payload.task_id), execution_id=None, sandbox_id=None, diff --git a/ergon_core/ergon_core/core/jobs/workflow/complete/job.py b/ergon_core/ergon_core/core/jobs/workflow/complete/job.py index 60b9a710a..797a0fcd8 100644 --- a/ergon_core/ergon_core/core/jobs/workflow/complete/job.py +++ b/ergon_core/ergon_core/core/jobs/workflow/complete/job.py @@ -5,7 +5,7 @@ from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import SampleRecord -from ergon_core.core.jobs.run.cleanup.contract import SampleCleanupEvent +from ergon_core.core.jobs.sample.cleanup.contract import SampleCleanupEvent from .contract import WorkflowCompletedEvent, WorkflowCompleteResult from ergon_core.core.application.events.service import get_dashboard_event_publisher from ergon_core.core.application.runtime.orchestration import FinalizeWorkflowCommand diff --git a/ergon_core/ergon_core/core/jobs/workflow/fail/job.py b/ergon_core/ergon_core/core/jobs/workflow/fail/job.py index bdfa330f4..cadf31599 100644 --- a/ergon_core/ergon_core/core/jobs/workflow/fail/job.py +++ b/ergon_core/ergon_core/core/jobs/workflow/fail/job.py @@ -7,7 +7,7 @@ from ergon_core.core.persistence.shared.enums import SampleStatus from ergon_core.core.persistence.telemetry.models import SampleRecord from ergon_core.core.infrastructure.inngest.errors import DataIntegrityError -from ergon_core.core.jobs.run.cleanup.contract import SampleCleanupEvent +from ergon_core.core.jobs.sample.cleanup.contract import SampleCleanupEvent from .contract import WorkflowFailedEvent, WorkflowFailedResult from ergon_core.core.jobs._events import send_job_event from ergon_core.core.infrastructure.tracing import ( diff --git a/ergon_core/ergon_core/core/persistence/shared/types.py b/ergon_core/ergon_core/core/persistence/shared/types.py index c83556dba..e67479d71 100644 --- a/ergon_core/ergon_core/core/persistence/shared/types.py +++ b/ergon_core/ergon_core/core/persistence/shared/types.py @@ -13,7 +13,7 @@ AssignedWorkerSlug = NewType("AssignedWorkerSlug", str) # ── UUID aliases ────────────────────────────────────────────────── -RunId = NewType("RunId", UUID) +SampleId = NewType("SampleId", UUID) NodeId = NewType("NodeId", UUID) DefinitionId = NewType("DefinitionId", UUID) EdgeId = NewType("EdgeId", UUID) diff --git a/ergon_core/ergon_core/core/views/dashboard_events/contracts.py b/ergon_core/ergon_core/core/views/dashboard_events/contracts.py index 09e68e0f0..6174f38cf 100644 --- a/ergon_core/ergon_core/core/views/dashboard_events/contracts.py +++ b/ergon_core/ergon_core/core/views/dashboard_events/contracts.py @@ -12,8 +12,8 @@ from uuid import UUID from ergon_core.core.views.samples.models import ( - RunCommunicationMessageDto, - RunCommunicationThreadDto, + SampleCommunicationMessageDto, + SampleCommunicationThreadDto, SampleSnapshotDto, SampleTaskEvaluationDto, ) @@ -145,13 +145,13 @@ class DashboardSandboxClosedEvent(InngestEventContract): class DashboardThreadMessageCreatedEvent(InngestEventContract): - """Embeds full RunCommunicationThreadDto + RunCommunicationMessageDto.""" + """Embeds full SampleCommunicationThreadDto + SampleCommunicationMessageDto.""" name: ClassVar[str] = "dashboard/thread.message_created" sample_id: UUID - thread: RunCommunicationThreadDto - message: RunCommunicationMessageDto + thread: SampleCommunicationThreadDto + message: SampleCommunicationMessageDto # --------------------------------------------------------------------------- diff --git a/ergon_core/ergon_core/core/views/dashboard_events/graph_mutations.py b/ergon_core/ergon_core/core/views/dashboard_events/graph_mutations.py index 59f3c5101..e889e6f78 100644 --- a/ergon_core/ergon_core/core/views/dashboard_events/graph_mutations.py +++ b/ergon_core/ergon_core/core/views/dashboard_events/graph_mutations.py @@ -11,14 +11,14 @@ MutationType, SampleGraphMutation, ) -from ergon_core.core.persistence.shared.types import RunId +from ergon_core.core.persistence.shared.types import SampleId from ergon_core.core.views.dashboard_events.contracts import DashboardGraphMutationEvent def graph_mutation_record_from_row(row: SampleGraphMutation) -> GraphMutationRecordDto: return GraphMutationRecordDto( id=row.id, - sample_id=cast(RunId, row.sample_id), + sample_id=cast(SampleId, row.sample_id), sequence=row.sequence, mutation_type=cast(MutationType, row.mutation_type), target_type=cast(GraphTargetType, row.target_type), diff --git a/ergon_core/ergon_core/core/views/samples/evaluation_mapping.py b/ergon_core/ergon_core/core/views/samples/evaluation_mapping.py index 90138d7ca..9431c399e 100644 --- a/ergon_core/ergon_core/core/views/samples/evaluation_mapping.py +++ b/ergon_core/ergon_core/core/views/samples/evaluation_mapping.py @@ -6,7 +6,7 @@ from ergon_core.core.application.evaluation.summary import EvaluationSummary from ergon_core.core.persistence.telemetry.models import SampleTaskEvaluation from ergon_core.core.views.samples.models import ( - RunEvaluationCriterionDto, + SampleEvaluationCriterionDto, SampleTaskEvaluationDto, ) @@ -21,7 +21,7 @@ def build_dashboard_evaluation_dto( summary: EvaluationSummary, ) -> SampleTaskEvaluationDto: criterion_results = [ - RunEvaluationCriterionDto( + SampleEvaluationCriterionDto( id=f"{evaluation_id}-{i}", stage_num=cr.stage_num, stage_name=cr.stage_name, diff --git a/ergon_core/ergon_core/core/views/samples/models.py b/ergon_core/ergon_core/core/views/samples/models.py index 1710f3569..637b33a0d 100644 --- a/ergon_core/ergon_core/core/views/samples/models.py +++ b/ergon_core/ergon_core/core/views/samples/models.py @@ -29,7 +29,7 @@ class CamelModel(BaseModel): ) -class RunCommunicationMessageDto(CamelModel): +class SampleCommunicationMessageDto(CamelModel): id: str thread_id: str thread_topic: str @@ -43,7 +43,7 @@ class RunCommunicationMessageDto(CamelModel): created_at: datetime -class RunCommunicationThreadDto(CamelModel): +class SampleCommunicationThreadDto(CamelModel): id: str sample_id: str task_id: str | None = None @@ -53,7 +53,7 @@ class RunCommunicationThreadDto(CamelModel): agent_b_id: str created_at: datetime updated_at: datetime - messages: list[RunCommunicationMessageDto] = Field(default_factory=list) + messages: list[SampleCommunicationMessageDto] = Field(default_factory=list) class SampleTaskDto(CamelModel): @@ -89,7 +89,7 @@ class SampleResourceDto(CamelModel): created_at: datetime -class RunExecutionAttemptDto(CamelModel): +class SampleExecutionAttemptDto(CamelModel): id: str task_id: str attempt_number: int @@ -105,7 +105,7 @@ class RunExecutionAttemptDto(CamelModel): output_resource_ids: list[str] = Field(default_factory=list) -class RunEvaluationCriterionDto(CamelModel): +class SampleEvaluationCriterionDto(CamelModel): id: str stage_num: int stage_name: str @@ -143,10 +143,10 @@ class SampleTaskEvaluationDto(CamelModel): stages_passed: int failed_gate: str | None = None created_at: datetime - criterion_results: list[RunEvaluationCriterionDto] = Field(default_factory=list) + criterion_results: list[SampleEvaluationCriterionDto] = Field(default_factory=list) -class RunSandboxCommandDto(CamelModel): +class SampleSandboxCommandDto(CamelModel): command: str stdout: str | None = None stderr: str | None = None @@ -155,7 +155,7 @@ class RunSandboxCommandDto(CamelModel): timestamp: datetime -class RunSandboxDto(CamelModel): +class SampleSandboxDto(CamelModel): sandbox_id: str task_id: str template: str | None = None @@ -164,7 +164,7 @@ class RunSandboxDto(CamelModel): created_at: datetime closed_at: datetime | None = None close_reason: str | None = None - commands: list[RunSandboxCommandDto] = Field(default_factory=list) + commands: list[SampleSandboxCommandDto] = Field(default_factory=list) class SampleContextEventDto(CamelModel): @@ -201,11 +201,11 @@ class SampleSnapshotDto(CamelModel): tasks: dict[str, SampleTaskDto] = Field(default_factory=dict) root_task_id: str = "" # slopcop: ignore[no-str-empty-default] resources_by_task: dict[str, list[SampleResourceDto]] = Field(default_factory=dict) - executions_by_task: dict[str, list[RunExecutionAttemptDto]] = Field(default_factory=dict) + executions_by_task: dict[str, list[SampleExecutionAttemptDto]] = Field(default_factory=dict) evaluations_by_task: dict[str, SampleTaskEvaluationDto] = Field(default_factory=dict) - sandboxes_by_task: dict[str, RunSandboxDto] = Field(default_factory=dict) + sandboxes_by_task: dict[str, SampleSandboxDto] = Field(default_factory=dict) context_events_by_task: dict[str, list[SampleContextEventDto]] = Field(default_factory=dict) - threads: list[RunCommunicationThreadDto] = Field(default_factory=list) + threads: list[SampleCommunicationThreadDto] = Field(default_factory=list) started_at: datetime | None = None completed_at: datetime | None = None duration_seconds: float | None = None diff --git a/ergon_core/ergon_core/core/views/samples/snapshot.py b/ergon_core/ergon_core/core/views/samples/snapshot.py index b7199fda1..29ef25953 100644 --- a/ergon_core/ergon_core/core/views/samples/snapshot.py +++ b/ergon_core/ergon_core/core/views/samples/snapshot.py @@ -7,13 +7,13 @@ from ergon_core.core.views.samples.evaluation_mapping import evaluation_row_to_dto from ergon_core.core.views.samples.models import ( - RunCommunicationMessageDto, - RunCommunicationThreadDto, + SampleCommunicationMessageDto, + SampleCommunicationThreadDto, SampleContextEventDto, - RunExecutionAttemptDto, + SampleExecutionAttemptDto, SampleResourceDto, - RunSandboxCommandDto, - RunSandboxDto, + SampleSandboxCommandDto, + SampleSandboxDto, SampleTaskDto, SampleTaskEvaluationDto, ) @@ -103,8 +103,8 @@ def _build_task_map( def _task_keyed_executions( executions: list[SampleTaskAttempt], worker_map: dict[UUID, ExperimentDefinitionWorker], -) -> dict[str, list[RunExecutionAttemptDto]]: - by_task: dict[str, list[RunExecutionAttemptDto]] = defaultdict(list) +) -> dict[str, list[SampleExecutionAttemptDto]]: + by_task: dict[str, list[SampleExecutionAttemptDto]] = defaultdict(list) for ex in sorted( executions, key=lambda e: (str(e.task_id), e.attempt_number), @@ -126,7 +126,7 @@ def _task_keyed_executions( resource_ids = [str(r) for r in raw_resource_ids] by_task[tid].append( - RunExecutionAttemptDto( + SampleExecutionAttemptDto( id=str(ex.id), task_id=tid, attempt_number=ex.attempt_number, @@ -188,13 +188,13 @@ def _task_keyed_evaluations( def _task_keyed_sandboxes( run_summary: dict, -) -> dict[str, RunSandboxDto]: +) -> dict[str, SampleSandboxDto]: """Extract sandbox info from run summary_json if available.""" - result: dict[str, RunSandboxDto] = {} + result: dict[str, SampleSandboxDto] = {} sandboxes = run_summary.get("sandboxes", {}) for task_id, sandbox in sandboxes.items(): commands = [ - RunSandboxCommandDto( + SampleSandboxCommandDto( command=cmd.get("command", ""), stdout=cmd.get("stdout"), stderr=cmd.get("stderr"), @@ -204,7 +204,7 @@ def _task_keyed_sandboxes( ) for cmd in sandbox.get("commands", []) ] - result[task_id] = RunSandboxDto( + result[task_id] = SampleSandboxDto( sandbox_id=sandbox.get("sandbox_id", ""), task_id=task_id, template=sandbox.get("template"), @@ -222,12 +222,12 @@ def _build_communication_threads( threads: list[Thread], messages: list[ThreadMessage], execution_task_map: dict[UUID, UUID], -) -> list[RunCommunicationThreadDto]: +) -> list[SampleCommunicationThreadDto]: msgs_by_thread: dict[UUID, list[ThreadMessage]] = defaultdict(list) for message in sorted(messages, key=lambda m: m.sequence_num): msgs_by_thread[message.thread_id].append(message) - result: list[RunCommunicationThreadDto] = [] + result: list[SampleCommunicationThreadDto] = [] for thread in threads: thread_messages = msgs_by_thread.get(thread.id, []) task_ids = { @@ -239,7 +239,7 @@ def _build_communication_threads( } thread_task_id = next(iter(task_ids)) if len(task_ids) == 1 else None result.append( - RunCommunicationThreadDto( + SampleCommunicationThreadDto( id=str(thread.id), sample_id=str(thread.sample_id), task_id=str(thread_task_id) if thread_task_id else None, @@ -250,7 +250,7 @@ def _build_communication_threads( created_at=thread.created_at, updated_at=thread.updated_at, messages=[ - RunCommunicationMessageDto( + SampleCommunicationMessageDto( id=str(message.id), thread_id=str(message.thread_id), sample_id=str(message.sample_id), diff --git a/ergon_core/tests/unit/architecture/test_job_composition_modules.py b/ergon_core/tests/unit/architecture/test_job_composition_modules.py index 18e7aeb9a..a24166128 100644 --- a/ergon_core/tests/unit/architecture/test_job_composition_modules.py +++ b/ergon_core/tests/unit/architecture/test_job_composition_modules.py @@ -22,7 +22,7 @@ "sandbox/setup", "sandbox/cleanup", "resources/persist_outputs", - "run/cleanup", + "sample/cleanup", } diff --git a/ergon_core/tests/unit/architecture/test_no_deleted_v2_symbols.py b/ergon_core/tests/unit/architecture/test_no_deleted_v2_symbols.py index a2b171fd0..801044823 100644 --- a/ergon_core/tests/unit/architecture/test_no_deleted_v2_symbols.py +++ b/ergon_core/tests/unit/architecture/test_no_deleted_v2_symbols.py @@ -1,4 +1,4 @@ -"""PR 11 guard for symbols removed from the v2 stack.""" +"""Architecture guards for symbols removed from the v2/sample stack.""" from pathlib import Path @@ -8,7 +8,9 @@ ROOT / "ergon_core" / "ergon_core", ROOT / "ergon_builtins" / "ergon_builtins", ROOT / "ergon_cli" / "ergon_cli", + ROOT / "ergon-dashboard" / "src", ) +PRODUCTION_SUFFIXES = {".py", ".ts", ".tsx"} DELETED_SYMBOLS = ( "TaskSpec", "ComponentRegistry", @@ -24,14 +26,88 @@ "target_node_id", "terminate_sandbox_by_id", ) +RUNTIME_VOCABULARY_FORBIDDEN_TOKENS = ( + "RunEvent", + "RunSnapshot", + "RunSummary", + "RunList", + "RunId", + "RunLifecycleStatus", + "RunCommunication", + "RunExecution", + "RunEvaluation", + "RunSandbox", + "RunHeader", + "RunActivity", + "RunAssignment", + "RunCompletionData", + "RunCompletedSocketData", + "run_id", + "runId", + "buildRunEvents", + "broadcastRun", + "parseRunCompletedSocketData", + "RunCompletedSocketDataSchema", + "serializeRunState", + "request:run", + "request:runs", + "sync:run", + "sync:runs", + "run:started", + "run:completed", + "run-cleanup", + "cleanup-run", + "core.jobs.run.cleanup", +) +GENERATED_SCHEMA_FORBIDDEN_TOKENS = ( + "RunCommunication", + "RunEvaluation", + "RunExecution", + "RunSandbox", +) + + +def _production_files() -> list[Path]: + files: list[Path] = [] + for root in PRODUCTION_ROOTS: + for path in root.rglob("*"): + if ".test." in path.name or path.name.endswith(".test.py"): + continue + if path.suffix in PRODUCTION_SUFFIXES: + files.append(path) + return files def test_deleted_v2_symbols_do_not_reappear() -> None: hits: list[str] = [] - for root in PRODUCTION_ROOTS: - for path in root.rglob("*.py"): + for path in _production_files(): + text = path.read_text() + for symbol in DELETED_SYMBOLS: + if symbol in text: + hits.append(f"{path.relative_to(ROOT)}: {symbol}") + assert hits == [] + + +def test_runtime_vocabulary_is_sample_centered_in_production() -> None: + hits: list[str] = [] + for path in _production_files(): + text = path.read_text() + for token in RUNTIME_VOCABULARY_FORBIDDEN_TOKENS: + if token in text: + hits.append(f"{path.relative_to(ROOT)}: {token}") + assert hits == [] + + +def test_generated_dashboard_schemas_are_sample_centered() -> None: + schema_roots = ( + ROOT / "ergon-dashboard" / "src" / "generated" / "events" / "schemas", + ROOT / "ergon-dashboard" / "src" / "generated" / "rest", + ) + hits: list[str] = [] + for root in schema_roots: + for path in root.rglob("*.json"): text = path.read_text() - for symbol in DELETED_SYMBOLS: - if symbol in text: - hits.append(f"{path.relative_to(ROOT)}: {symbol}") + for token in GENERATED_SCHEMA_FORBIDDEN_TOKENS: + if token in text: + hits.append(f"{path.relative_to(ROOT)}: {token}") assert hits == [] diff --git a/ergon_core/tests/unit/architecture/test_persistence_boundaries.py b/ergon_core/tests/unit/architecture/test_persistence_boundaries.py index b842abf45..8ac936483 100644 --- a/ergon_core/tests/unit/architecture/test_persistence_boundaries.py +++ b/ergon_core/tests/unit/architecture/test_persistence_boundaries.py @@ -23,7 +23,7 @@ # Workflow lifecycle jobs still own small transactional updates. # New jobs should use repositories/services instead. Path("ergon_core/ergon_core/core/jobs/workflow/start/job.py"), - Path("ergon_core/ergon_core/core/jobs/run/cleanup/job.py"), + Path("ergon_core/ergon_core/core/jobs/sample/cleanup/job.py"), Path("ergon_core/ergon_core/core/jobs/task/cleanup_cancelled/job.py"), Path("ergon_core/ergon_core/core/jobs/task/cancel_orphans/job.py"), Path("ergon_core/ergon_core/core/jobs/workflow/complete/job.py"), diff --git a/ergon_core/tests/unit/registry/test_inngest_job_registry.py b/ergon_core/tests/unit/registry/test_inngest_job_registry.py index 061d012ff..2f6b92578 100644 --- a/ergon_core/tests/unit/registry/test_inngest_job_registry.py +++ b/ergon_core/tests/unit/registry/test_inngest_job_registry.py @@ -23,7 +23,7 @@ "block-descendants-on-failed": "task/failed", "cancel-orphans-on-cancelled": "task/cancelled", "cleanup-cancelled-task": "task/cancelled", - "run-cleanup": "sample/cleanup", + "sample-cleanup": "sample/cleanup", "sandbox-cleanup-on-completed": "task/completed", "sandbox-cleanup-on-failed": "task/failed", } @@ -42,7 +42,7 @@ "block-descendants-on-failed", "cancel-orphans-on-cancelled", "cleanup-cancelled-task", - "run-cleanup", + "sample-cleanup", "sandbox-cleanup-on-completed", "sandbox-cleanup-on-failed", ] @@ -129,7 +129,7 @@ "concurrency": (), "output": "EmptySentinel", }, - "run-cleanup": { + "sample-cleanup": { "retries": 0, "cancel": (), "concurrency": (), From db86a2c0ee1f6feb2c3bd841c9dd47399bdc941f Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Mon, 25 May 2026 21:34:45 +0100 Subject: [PATCH 02/14] Add typed sample runtime WAL --- .../events/DashboardGraphMutationEvent.ts | 6 - .../events/DashboardSampleRuntimeEvent.ts | 3 + ergon-dashboard/src/generated/events/index.ts | 8 +- .../DashboardGraphMutationEvent.schema.json | 524 ------------------ .../DashboardSampleRuntimeEvent.schema.json | 126 +++++ .../generated/events/schemas/manifest.json | 6 +- .../src/inngest/functions/index.ts | 4 +- ergon-dashboard/src/lib/contracts/events.ts | 24 +- ergon-dashboard/src/lib/types.ts | 4 +- .../tests/contracts/contracts.test.ts | 2 +- .../application/runtime/graph_repository.py | 450 +++++++++------ .../core/application/runtime/lifecycle.py | 6 +- .../core/application/runtime/models.py | 159 ------ .../application/runtime/sample_lifecycle.py | 20 + .../application/runtime/sample_records.py | 11 + .../application/runtime/task_management.py | 33 +- .../core/application/samples/__init__.py | 1 + .../core/application/samples/events.py | 186 +++++++ .../core/application/samples/state.py | 266 +++++++++ .../testing/test_harness_service.py | 43 +- .../infrastructure/http/routes/samples.py | 14 +- .../http/routes/test_harness.py | 13 +- .../ergon_core/core/jobs/workflow/fail/job.py | 12 + .../core/persistence/graph/models.py | 116 +--- .../core/persistence/samples/__init__.py | 1 + .../core/persistence/samples/models.py | 112 ++++ .../core/views/dashboard_events/contracts.py | 10 +- .../views/dashboard_events/graph_mutations.py | 37 -- .../ergon_core/core/views/samples/service.py | 19 +- .../test_support/e2e_read_helpers.py | 285 ++++++++++ ergon_core/migrations/env.py | 1 + .../versions/00000000_initial_v2.py | 1 + .../test_application_domain_boundaries.py | 4 +- .../architecture/test_core_schema_sources.py | 4 +- .../test_model_field_descriptions.py | 25 +- .../test_public_api_boundaries.py | 1 - .../test_repository_layer_conventions.py | 4 +- .../test_typed_sample_wal_boundaries.py | 43 ++ .../tasks/test_spawn_dynamic_task_dispatch.py | 2 +- .../tests/unit/rest_api/test_test_harness.py | 2 +- .../runtime/test_graph_mutation_contracts.py | 98 +--- .../runtime/test_sample_annotation_events.py | 64 +++ .../unit/runtime/test_sample_state_replay.py | 200 +++++++ .../unit/runtime/test_typed_sample_wal.py | 142 +++++ .../tests/unit/state/test_type_invariants.py | 44 +- .../writers/external_run_writer.py | 14 +- .../tests/unit/test_external_run_writer.py | 7 +- tests/e2e/_asserts.py | 161 +++++- tests/e2e/_read_contracts.py | 7 +- tests/e2e/test_minif2f_smoke.py | 13 + tests/e2e/test_researchrubrics_smoke.py | 13 + tests/e2e/test_swebench_smoke.py | 13 + tests/integration/propagation/_helpers.py | 47 +- .../propagation/test_propagation_blocked.py | 12 +- .../propagation/test_propagation_cancel.py | 12 +- .../test_propagation_edge_cases.py | 12 +- .../propagation/test_propagation_happy.py | 12 +- .../propagation/test_propagation_restart.py | 1 + tests/integration/restart/_helpers.py | 12 +- .../restart/test_downstream_invalidation.py | 6 +- .../integration/restart/test_reactivation.py | 6 +- .../integration/restart/test_restart_task.py | 6 +- tests/real_llm/rollout.py | 43 +- 63 files changed, 2191 insertions(+), 1342 deletions(-) delete mode 100644 ergon-dashboard/src/generated/events/DashboardGraphMutationEvent.ts create mode 100644 ergon-dashboard/src/generated/events/DashboardSampleRuntimeEvent.ts delete mode 100644 ergon-dashboard/src/generated/events/schemas/DashboardGraphMutationEvent.schema.json create mode 100644 ergon-dashboard/src/generated/events/schemas/DashboardSampleRuntimeEvent.schema.json create mode 100644 ergon_core/ergon_core/core/application/samples/__init__.py create mode 100644 ergon_core/ergon_core/core/application/samples/events.py create mode 100644 ergon_core/ergon_core/core/application/samples/state.py create mode 100644 ergon_core/ergon_core/core/persistence/samples/__init__.py create mode 100644 ergon_core/ergon_core/core/persistence/samples/models.py delete mode 100644 ergon_core/ergon_core/core/views/dashboard_events/graph_mutations.py create mode 100644 ergon_core/tests/unit/architecture/test_typed_sample_wal_boundaries.py create mode 100644 ergon_core/tests/unit/runtime/test_sample_annotation_events.py create mode 100644 ergon_core/tests/unit/runtime/test_sample_state_replay.py create mode 100644 ergon_core/tests/unit/runtime/test_typed_sample_wal.py diff --git a/ergon-dashboard/src/generated/events/DashboardGraphMutationEvent.ts b/ergon-dashboard/src/generated/events/DashboardGraphMutationEvent.ts deleted file mode 100644 index 94af34330..000000000 --- a/ergon-dashboard/src/generated/events/DashboardGraphMutationEvent.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from "zod"; -import { GraphMutationDtoSchema } from "@/features/graph/contracts/graphMutations"; - -export const DashboardGraphMutationEventSchema = z.object({ - mutation: GraphMutationDtoSchema, -}).catchall(z.any()); diff --git a/ergon-dashboard/src/generated/events/DashboardSampleRuntimeEvent.ts b/ergon-dashboard/src/generated/events/DashboardSampleRuntimeEvent.ts new file mode 100644 index 000000000..0a00a12d6 --- /dev/null +++ b/ergon-dashboard/src/generated/events/DashboardSampleRuntimeEvent.ts @@ -0,0 +1,3 @@ +import { z } from "zod" + +export const DashboardSampleRuntimeEventSchema = z.object({ "event": z.any() }).catchall(z.any()) diff --git a/ergon-dashboard/src/generated/events/index.ts b/ergon-dashboard/src/generated/events/index.ts index 5022b2f5e..4a1dc1268 100644 --- a/ergon-dashboard/src/generated/events/index.ts +++ b/ergon-dashboard/src/generated/events/index.ts @@ -8,7 +8,7 @@ import { DashboardSandboxCommandEventSchema } from "./DashboardSandboxCommandEve import { DashboardSandboxClosedEventSchema } from "./DashboardSandboxClosedEvent"; import { DashboardThreadMessageCreatedEventSchema } from "./DashboardThreadMessageCreatedEvent"; import { DashboardTaskEvaluationUpdatedEventSchema } from "./DashboardTaskEvaluationUpdatedEvent"; -import { DashboardGraphMutationEventSchema } from "./DashboardGraphMutationEvent"; +import { DashboardSampleRuntimeEventSchema } from "./DashboardSampleRuntimeEvent"; import { DashboardContextEventEventSchema } from "./DashboardContextEventEvent"; export { DashboardWorkflowStartedEventSchema }; @@ -29,8 +29,8 @@ export { DashboardThreadMessageCreatedEventSchema }; export type DashboardThreadMessageCreatedEvent = z.infer; export { DashboardTaskEvaluationUpdatedEventSchema }; export type DashboardTaskEvaluationUpdatedEvent = z.infer; -export { DashboardGraphMutationEventSchema }; -export type DashboardGraphMutationEvent = z.infer; +export { DashboardSampleRuntimeEventSchema }; +export type DashboardSampleRuntimeEvent = z.infer; export { DashboardContextEventEventSchema }; export type DashboardContextEventEvent = z.infer; @@ -44,7 +44,7 @@ export const dashboardEventSchemas = { "dashboard/sandbox.closed": DashboardSandboxClosedEventSchema, "dashboard/thread.message_created": DashboardThreadMessageCreatedEventSchema, "dashboard/task.evaluation_updated": DashboardTaskEvaluationUpdatedEventSchema, - "dashboard/graph.mutation": DashboardGraphMutationEventSchema, + "dashboard/sample.runtime_event": DashboardSampleRuntimeEventSchema, "dashboard/context.event": DashboardContextEventEventSchema, } as const; diff --git a/ergon-dashboard/src/generated/events/schemas/DashboardGraphMutationEvent.schema.json b/ergon-dashboard/src/generated/events/schemas/DashboardGraphMutationEvent.schema.json deleted file mode 100644 index 000ff5efc..000000000 --- a/ergon-dashboard/src/generated/events/schemas/DashboardGraphMutationEvent.schema.json +++ /dev/null @@ -1,524 +0,0 @@ -{ - "$defs": { - "AnnotationDeletedMutation": { - "description": "annotation.deleted \u2014 tombstone.", - "properties": { - "mutation_type": { - "const": "annotation.deleted", - "default": "annotation.deleted", - "title": "Mutation Type", - "type": "string" - }, - "namespace": { - "title": "Namespace", - "type": "string" - }, - "payload": { - "$ref": "#/$defs/JsonObject" - } - }, - "required": [ - "namespace", - "payload" - ], - "title": "AnnotationDeletedMutation", - "type": "object" - }, - "AnnotationSetMutation": { - "description": "annotation.set.", - "properties": { - "mutation_type": { - "const": "annotation.set", - "default": "annotation.set", - "title": "Mutation Type", - "type": "string" - }, - "namespace": { - "title": "Namespace", - "type": "string" - }, - "payload": { - "$ref": "#/$defs/JsonObject" - } - }, - "required": [ - "namespace", - "payload" - ], - "title": "AnnotationSetMutation", - "type": "object" - }, - "EdgeAddedMutation": { - "description": "edge.added \u2014 full edge snapshot.", - "properties": { - "mutation_type": { - "const": "edge.added", - "default": "edge.added", - "title": "Mutation Type", - "type": "string" - }, - "source_task_id": { - "format": "uuid", - "title": "Source Task Id", - "type": "string" - }, - "target_task_id": { - "format": "uuid", - "title": "Target Task Id", - "type": "string" - }, - "status": { - "title": "Status", - "type": "string" - } - }, - "required": [ - "source_task_id", - "target_task_id", - "status" - ], - "title": "EdgeAddedMutation", - "type": "object" - }, - "EdgeRemovedMutation": { - "description": "edge.removed.", - "properties": { - "mutation_type": { - "const": "edge.removed", - "default": "edge.removed", - "title": "Mutation Type", - "type": "string" - }, - "source_task_id": { - "format": "uuid", - "title": "Source Task Id", - "type": "string" - }, - "target_task_id": { - "format": "uuid", - "title": "Target Task Id", - "type": "string" - }, - "status": { - "title": "Status", - "type": "string" - } - }, - "required": [ - "source_task_id", - "target_task_id", - "status" - ], - "title": "EdgeRemovedMutation", - "type": "object" - }, - "EdgeStatusChangedMutation": { - "description": "edge.status_changed.", - "properties": { - "mutation_type": { - "const": "edge.status_changed", - "default": "edge.status_changed", - "title": "Mutation Type", - "type": "string" - }, - "status": { - "title": "Status", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "EdgeStatusChangedMutation", - "type": "object" - }, - "GraphMutationRecordDto": { - "description": "Append-only graph mutation record with a typed mutation payload.", - "properties": { - "id": { - "description": "Identifier of the mutation row itself, not a graph target id.", - "format": "uuid", - "title": "Id", - "type": "string" - }, - "sample_id": { - "format": "uuid", - "title": "Sample Id", - "type": "string" - }, - "sequence": { - "title": "Sequence", - "type": "integer" - }, - "mutation_type": { - "enum": [ - "node.added", - "node.removed", - "node.status_changed", - "node.field_changed", - "edge.added", - "edge.removed", - "edge.status_changed", - "annotation.set", - "annotation.deleted" - ], - "title": "Mutation Type", - "type": "string" - }, - "target_type": { - "enum": [ - "node", - "edge" - ], - "title": "Target Type", - "type": "string" - }, - "target_id": { - "description": "Polymorphic mutation target identifier. Interpreted as a NodeId, EdgeId, or annotation id based on target_type and mutation_type.", - "format": "uuid", - "title": "Target Id", - "type": "string" - }, - "actor": { - "title": "Actor", - "type": "string" - }, - "old_value": { - "anyOf": [ - { - "discriminator": { - "mapping": { - "annotation.deleted": "#/$defs/AnnotationDeletedMutation", - "annotation.set": "#/$defs/AnnotationSetMutation", - "edge.added": "#/$defs/EdgeAddedMutation", - "edge.removed": "#/$defs/EdgeRemovedMutation", - "edge.status_changed": "#/$defs/EdgeStatusChangedMutation", - "node.added": "#/$defs/NodeAddedMutation", - "node.field_changed": "#/$defs/NodeFieldChangedMutation", - "node.removed": "#/$defs/NodeRemovedMutation", - "node.status_changed": "#/$defs/NodeStatusChangedMutation" - }, - "propertyName": "mutation_type" - }, - "oneOf": [ - { - "$ref": "#/$defs/NodeAddedMutation" - }, - { - "$ref": "#/$defs/NodeRemovedMutation" - }, - { - "$ref": "#/$defs/NodeStatusChangedMutation" - }, - { - "$ref": "#/$defs/NodeFieldChangedMutation" - }, - { - "$ref": "#/$defs/EdgeAddedMutation" - }, - { - "$ref": "#/$defs/EdgeRemovedMutation" - }, - { - "$ref": "#/$defs/EdgeStatusChangedMutation" - }, - { - "$ref": "#/$defs/AnnotationSetMutation" - }, - { - "$ref": "#/$defs/AnnotationDeletedMutation" - } - ] - }, - { - "type": "null" - } - ], - "title": "Old Value" - }, - "new_value": { - "discriminator": { - "mapping": { - "annotation.deleted": "#/$defs/AnnotationDeletedMutation", - "annotation.set": "#/$defs/AnnotationSetMutation", - "edge.added": "#/$defs/EdgeAddedMutation", - "edge.removed": "#/$defs/EdgeRemovedMutation", - "edge.status_changed": "#/$defs/EdgeStatusChangedMutation", - "node.added": "#/$defs/NodeAddedMutation", - "node.field_changed": "#/$defs/NodeFieldChangedMutation", - "node.removed": "#/$defs/NodeRemovedMutation", - "node.status_changed": "#/$defs/NodeStatusChangedMutation" - }, - "propertyName": "mutation_type" - }, - "oneOf": [ - { - "$ref": "#/$defs/NodeAddedMutation" - }, - { - "$ref": "#/$defs/NodeRemovedMutation" - }, - { - "$ref": "#/$defs/NodeStatusChangedMutation" - }, - { - "$ref": "#/$defs/NodeFieldChangedMutation" - }, - { - "$ref": "#/$defs/EdgeAddedMutation" - }, - { - "$ref": "#/$defs/EdgeRemovedMutation" - }, - { - "$ref": "#/$defs/EdgeStatusChangedMutation" - }, - { - "$ref": "#/$defs/AnnotationSetMutation" - }, - { - "$ref": "#/$defs/AnnotationDeletedMutation" - } - ], - "title": "New Value" - }, - "reason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Reason" - }, - "created_at": { - "format": "date-time", - "title": "Created At", - "type": "string" - } - }, - "required": [ - "id", - "sample_id", - "sequence", - "mutation_type", - "target_type", - "target_id", - "actor", - "old_value", - "new_value", - "reason", - "created_at" - ], - "title": "GraphMutationRecordDto", - "type": "object" - }, - "JsonObject": { - "additionalProperties": { - "$ref": "#/$defs/JsonValue" - }, - "type": "object" - }, - "JsonScalar": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "JsonValue": { - "anyOf": [ - { - "$ref": "#/$defs/JsonScalar" - }, - { - "items": { - "$ref": "#/$defs/JsonValue" - }, - "type": "array" - }, - { - "additionalProperties": { - "$ref": "#/$defs/JsonValue" - }, - "type": "object" - } - ] - }, - "NodeAddedMutation": { - "description": "node.added \u2014 full node snapshot.", - "properties": { - "mutation_type": { - "const": "node.added", - "default": "node.added", - "title": "Mutation Type", - "type": "string" - }, - "task_slug": { - "title": "Task Slug", - "type": "string" - }, - "instance_key": { - "title": "Instance Key", - "type": "string" - }, - "description": { - "title": "Description", - "type": "string" - }, - "status": { - "title": "Status", - "type": "string" - }, - "assigned_worker_slug": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Assigned Worker Slug" - } - }, - "required": [ - "task_slug", - "instance_key", - "description", - "status", - "assigned_worker_slug" - ], - "title": "NodeAddedMutation", - "type": "object" - }, - "NodeFieldChangedMutation": { - "description": "node.field_changed.", - "properties": { - "mutation_type": { - "const": "node.field_changed", - "default": "node.field_changed", - "title": "Mutation Type", - "type": "string" - }, - "field": { - "enum": [ - "description", - "assigned_worker_slug" - ], - "title": "Field", - "type": "string" - }, - "value": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Value" - } - }, - "required": [ - "field", - "value" - ], - "title": "NodeFieldChangedMutation", - "type": "object" - }, - "NodeRemovedMutation": { - "description": "node.removed \u2014 node snapshot at removal time.", - "properties": { - "mutation_type": { - "const": "node.removed", - "default": "node.removed", - "title": "Mutation Type", - "type": "string" - }, - "task_slug": { - "title": "Task Slug", - "type": "string" - }, - "instance_key": { - "title": "Instance Key", - "type": "string" - }, - "description": { - "title": "Description", - "type": "string" - }, - "status": { - "title": "Status", - "type": "string" - }, - "assigned_worker_slug": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Assigned Worker Slug" - } - }, - "required": [ - "task_slug", - "instance_key", - "description", - "status", - "assigned_worker_slug" - ], - "title": "NodeRemovedMutation", - "type": "object" - }, - "NodeStatusChangedMutation": { - "description": "node.status_changed.", - "properties": { - "mutation_type": { - "const": "node.status_changed", - "default": "node.status_changed", - "title": "Mutation Type", - "type": "string" - }, - "status": { - "title": "Status", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "NodeStatusChangedMutation", - "type": "object" - } - }, - "additionalProperties": true, - "properties": { - "mutation": { - "$ref": "#/$defs/GraphMutationRecordDto" - } - }, - "required": [ - "mutation" - ], - "title": "DashboardGraphMutationEvent", - "type": "object" -} diff --git a/ergon-dashboard/src/generated/events/schemas/DashboardSampleRuntimeEvent.schema.json b/ergon-dashboard/src/generated/events/schemas/DashboardSampleRuntimeEvent.schema.json new file mode 100644 index 000000000..7175915b3 --- /dev/null +++ b/ergon-dashboard/src/generated/events/schemas/DashboardSampleRuntimeEvent.schema.json @@ -0,0 +1,126 @@ +{ + "$defs": { + "JsonObject": { + "additionalProperties": { + "$ref": "#/$defs/JsonValue" + }, + "type": "object" + }, + "JsonScalar": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "JsonValue": { + "anyOf": [ + { + "$ref": "#/$defs/JsonScalar" + }, + { + "items": { + "$ref": "#/$defs/JsonValue" + }, + "type": "array" + }, + { + "additionalProperties": { + "$ref": "#/$defs/JsonValue" + }, + "type": "object" + } + ] + }, + "SampleRuntimeEventView": { + "properties": { + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "sample_id": { + "format": "uuid", + "title": "Sample Id", + "type": "string" + }, + "event_timestamp": { + "format": "date-time", + "title": "Event Timestamp", + "type": "string" + }, + "table": { + "enum": [ + "sample_status_events", + "sample_task_events", + "sample_edge_events", + "sample_worker_events", + "sample_evaluator_events", + "sample_sandbox_events", + "sample_annotation_events" + ], + "title": "Table", + "type": "string" + }, + "event_type": { + "title": "Event Type", + "type": "string" + }, + "target_type": { + "title": "Target Type", + "type": "string" + }, + "target_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Id" + }, + "payload": { + "$ref": "#/$defs/JsonObject", + "description": "Typed event payload copied from the source sample runtime WAL row." + } + }, + "required": [ + "id", + "sample_id", + "event_timestamp", + "table", + "event_type", + "target_type", + "target_id" + ], + "title": "SampleRuntimeEventView", + "type": "object" + } + }, + "additionalProperties": true, + "properties": { + "event": { + "$ref": "#/$defs/SampleRuntimeEventView" + } + }, + "required": [ + "event" + ], + "title": "DashboardSampleRuntimeEvent", + "type": "object" +} diff --git a/ergon-dashboard/src/generated/events/schemas/manifest.json b/ergon-dashboard/src/generated/events/schemas/manifest.json index 9455dbeaa..1258312fb 100644 --- a/ergon-dashboard/src/generated/events/schemas/manifest.json +++ b/ergon-dashboard/src/generated/events/schemas/manifest.json @@ -45,9 +45,9 @@ "schemaFile": "DashboardTaskEvaluationUpdatedEvent.schema.json" }, { - "eventName": "dashboard/graph.mutation", - "modelName": "DashboardGraphMutationEvent", - "schemaFile": "DashboardGraphMutationEvent.schema.json" + "eventName": "dashboard/sample.runtime_event", + "modelName": "DashboardSampleRuntimeEvent", + "schemaFile": "DashboardSampleRuntimeEvent.schema.json" }, { "eventName": "dashboard/context.event", diff --git a/ergon-dashboard/src/inngest/functions/index.ts b/ergon-dashboard/src/inngest/functions/index.ts index 233fac531..25af1f019 100644 --- a/ergon-dashboard/src/inngest/functions/index.ts +++ b/ergon-dashboard/src/inngest/functions/index.ts @@ -410,8 +410,8 @@ const onSandboxClosed = inngest.createFunction( // ============================================================================= const onGraphMutation = inngest.createFunction( - { id: "handle-graph-mutation", name: "Handle Graph Mutation" }, - { event: "dashboard/graph.mutation" }, + { id: "handle-sample-runtime-event", name: "Handle Sample Runtime Event" }, + { event: "dashboard/sample.runtime_event" }, async ({ event }) => { const mutation = parseDashboardGraphMutationData(event.data); store.applyGraphMutation(mutation.sample_id, mutation); diff --git a/ergon-dashboard/src/lib/contracts/events.ts b/ergon-dashboard/src/lib/contracts/events.ts index f98bf442e..0c0840751 100644 --- a/ergon-dashboard/src/lib/contracts/events.ts +++ b/ergon-dashboard/src/lib/contracts/events.ts @@ -4,7 +4,7 @@ import { GraphMutationDtoSchema } from "@/features/graph/contracts/graphMutation import { dashboardEventSchemas, DashboardContextEventEventSchema as GeneratedDashboardContextEventEventSchema, - DashboardGraphMutationEventSchema as GeneratedDashboardGraphMutationEventSchema, + DashboardSampleRuntimeEventSchema as GeneratedDashboardSampleRuntimeEventSchema, DashboardResourcePublishedEvent as GeneratedDashboardResourcePublishedEvent, DashboardSandboxClosedEvent as GeneratedDashboardSandboxClosedEvent, DashboardSandboxCommandEvent as GeneratedDashboardSandboxCommandEvent, @@ -307,7 +307,27 @@ function asRecord(value: unknown): Record { export const DashboardGraphMutationDataSchema = z.preprocess((input) => { const outer = asRecord(input); - return outer.mutation === undefined ? input : GeneratedDashboardGraphMutationEventSchema.parse(input).mutation; + if (outer.mutation !== undefined) { + return outer.mutation; + } + const event = outer.event === undefined ? outer : GeneratedDashboardSampleRuntimeEventSchema.parse(input).event; + const targetType = event.target_type === "task" ? "node" : event.target_type; + const mutationType = event.event_type + .replace("task.", "node.") + .replace("sample.", "node."); + return { + id: event.id, + sample_id: event.sample_id, + sequence: 0, + mutation_type: mutationType, + target_type: targetType, + target_id: event.target_id ?? event.sample_id, + actor: "typed-sample-wal", + old_value: null, + new_value: event.payload, + reason: null, + created_at: event.event_timestamp, + }; }, GraphMutationDtoSchema); export type DashboardGraphMutationData = z.infer; diff --git a/ergon-dashboard/src/lib/types.ts b/ergon-dashboard/src/lib/types.ts index dfa28caaa..dcafc355e 100644 --- a/ergon-dashboard/src/lib/types.ts +++ b/ergon-dashboard/src/lib/types.ts @@ -71,7 +71,7 @@ export const DashboardEventNames = { SANDBOX_CLOSED: "dashboard/sandbox.closed", THREAD_MESSAGE_CREATED: "dashboard/thread.message_created", TASK_EVALUATION_UPDATED: "dashboard/task.evaluation_updated", - GRAPH_MUTATION: "dashboard/graph.mutation", + GRAPH_MUTATION: "dashboard/sample.runtime_event", CONTEXT_EVENT: "dashboard/context.event", } as const; @@ -134,7 +134,7 @@ export type DashboardEvents = { "dashboard/sandbox.closed": { data: DashboardSandboxClosedData }; "dashboard/thread.message_created": { data: DashboardThreadMessageCreatedData }; "dashboard/task.evaluation_updated": { data: DashboardTaskEvaluationUpdatedData }; - "dashboard/graph.mutation": { data: DashboardGraphMutationData }; + "dashboard/sample.runtime_event": { data: DashboardGraphMutationData }; "dashboard/context.event": { data: DashboardContextEventEventData }; }; diff --git a/ergon-dashboard/tests/contracts/contracts.test.ts b/ergon-dashboard/tests/contracts/contracts.test.ts index f3fdf07ff..23aba06bc 100644 --- a/ergon-dashboard/tests/contracts/contracts.test.ts +++ b/ergon-dashboard/tests/contracts/contracts.test.ts @@ -144,7 +144,7 @@ test("workflow started event parser validates run snapshots", () => { }); test("generated dashboard event schemas cover graph and context live events", () => { - assert.ok(dashboardEventSchemas["dashboard/graph.mutation"]); + assert.ok(dashboardEventSchemas["dashboard/sample.runtime_event"]); assert.ok(dashboardEventSchemas["dashboard/context.event"]); }); diff --git a/ergon_core/ergon_core/core/application/runtime/graph_repository.py b/ergon_core/ergon_core/core/application/runtime/graph_repository.py index 437869f72..adc438147 100644 --- a/ergon_core/ergon_core/core/application/runtime/graph_repository.py +++ b/ergon_core/ergon_core/core/application/runtime/graph_repository.py @@ -1,9 +1,9 @@ -"""RuntimeGraphRepository — single entry point for run graph mutations. +"""RuntimeGraphRepository — single entry point for sample graph writes. -Every mutation method: +Every write method: 1. Validates structural invariants (acyclicity, referential integrity). 2. Writes to sample_graph_* tables. -3. Appends to sample_graph_mutations in the same transaction. +3. Appends typed sample runtime WAL rows in the same transaction. The repository does NOT validate status transitions or authorization. Those are the experiment layer's responsibility. @@ -12,6 +12,7 @@ import logging from collections import defaultdict from collections.abc import Awaitable, Callable +from datetime import datetime from typing import Literal from uuid import UUID, uuid4 @@ -22,11 +23,19 @@ ExperimentDefinitionTaskDependency, ExperimentDefinitionWorker, ) -from ergon_core.core.persistence.graph.models import ( - SampleGraphAnnotation, - SampleGraphEdge, - SampleGraphMutation, - SampleGraphNode, +from ergon_core.core.persistence.graph.models import SampleGraphEdge, SampleGraphNode +from ergon_core.core.persistence.samples.models import ( + SampleAnnotationEventRow, + SampleEdgeEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleStatusEventRow, + SampleTaskEventRow, + SampleWorkerEventRow, +) +from ergon_core.core.application.samples.events import ( + SampleRuntimeEventAppender, + SampleRuntimeEventRow, ) from ergon_core.core.application.runtime.status import TERMINAL_STATUSES from ergon_core.core.application.runtime.errors import ( @@ -37,21 +46,14 @@ ) from ergon_core.api.benchmark import Task from ergon_core.core.application.runtime.models import ( - AnnotationSetMutation, - EdgeAddedMutation, - EdgeStatusChangedMutation, GraphEdgeDto, - GraphMutationValue, GraphNodeDto, MutationMeta, - NodeAddedMutation, - NodeFieldChangedMutation, - NodeStatusChangedMutation, SampleGraphNodeView, WorkflowGraphDto, ) from ergon_core.core.shared.utils import utcnow -from sqlmodel import Session, col, select +from sqlmodel import Session, select logger = logging.getLogger(__name__) @@ -62,7 +64,7 @@ class RuntimeGraphRepository: - """Mutable DAG with append-only audit log. + """Mutable DAG with typed append-only runtime WAL. All methods accept a Session for caller-controlled transactions. @@ -78,12 +80,12 @@ class RuntimeGraphRepository: """ def __init__(self) -> None: - self._mutation_listeners: list[Callable[[SampleGraphMutation], Awaitable[None]]] = [] + self._runtime_event_listeners: list[Callable[[SampleRuntimeEventRow], Awaitable[None]]] = [] - def add_mutation_listener( - self, listener: Callable[[SampleGraphMutation], Awaitable[None]] + def add_runtime_event_listener( + self, listener: Callable[[SampleRuntimeEventRow], Awaitable[None]] ) -> None: - self._mutation_listeners.append(listener) + self._runtime_event_listeners.append(listener) # ── Initialization ────────────────────────────────────── @@ -184,83 +186,83 @@ def initialize_from_definition( ) ) - session.add_all(node_rows) - session.add_all(edge_rows) - session.flush() - - seq = self._next_sequence(session, sample_id) - - annotation_rows: list[SampleGraphAnnotation] = [] - mutation_rows: list[SampleGraphMutation] = [] + appender = SampleRuntimeEventAppender(session) + status_event = appender.append_status_event( + SampleStatusEventRow( + sample_id=sample_id, + event_type="sample.status_changed", + status="pending", + actor=meta.actor, + event_timestamp=now, + payload_json={"reason": meta.reason}, + ) + ) + runtime_events: list[SampleRuntimeEventRow] = [status_event] for task, node in zip(tasks, node_rows): - mutation_rows.append( - SampleGraphMutation( + runtime_events.append( + appender.append_task_event( + SampleTaskEventRow( + sample_id=sample_id, + task_id=node.task_id, + task_slug=node.task_slug, + event_type="task.added", + status=initial_node_status, + actor=meta.actor, + event_timestamp=now, + task_snapshot_json=node.task_json, + payload_json=_task_payload(node), + ) + ) + ) + runtime_events.extend( + self._append_task_component_events( + appender, sample_id=sample_id, - sequence=seq, - mutation_type="node.added", - target_type="node", - target_id=node.task_id, actor=meta.actor, - old_value=None, - new_value=_node_snapshot(node).model_dump(mode="json"), - reason=meta.reason, - created_at=now, + event_timestamp=now, + task_id=node.task_id, + task_json=node.task_json, + assigned_worker_slug=node.assigned_worker_slug, ) ) - seq += 1 payload = task.task_json.get("task_payload") or {} if payload: - annotation_rows.append( - SampleGraphAnnotation( - sample_id=sample_id, - target_type="node", - target_id=node.task_id, - namespace="payload", - sequence=seq, - payload=payload, - created_at=now, + runtime_events.append( + appender.append_annotation_event( + SampleAnnotationEventRow( + sample_id=sample_id, + target_type="task", + target_id=node.task_id, + key="payload", + event_type="annotation.set", + event_timestamp=now, + payload_json={"value": payload}, + ) ) ) - mutation_rows.append( - SampleGraphMutation( + + for edge in edge_rows: + runtime_events.append( + appender.append_edge_event( + SampleEdgeEventRow( sample_id=sample_id, - sequence=seq, - mutation_type="annotation.set", - target_type="node", - target_id=node.task_id, + edge_id=edge.id, + source_task_id=edge.source_task_id, + target_task_id=edge.target_task_id, + event_type="edge.added", + status=initial_edge_status, actor=meta.actor, - old_value=None, - new_value=AnnotationSetMutation( - namespace="payload", - payload=payload, - ).model_dump(mode="json"), - reason=meta.reason, - created_at=now, + event_timestamp=now, + edge_snapshot_json=_edge_payload(edge), + payload_json=_edge_payload(edge), ) ) - seq += 1 - - for edge in edge_rows: - mutation_rows.append( - SampleGraphMutation( - sample_id=sample_id, - sequence=seq, - mutation_type="edge.added", - target_type="edge", - target_id=edge.id, - actor=meta.actor, - old_value=None, - new_value=_edge_snapshot(edge).model_dump(mode="json"), - reason=meta.reason, - created_at=now, - ) ) - seq += 1 - session.add_all(annotation_rows) - session.add_all(mutation_rows) + session.add_all(node_rows) + session.add_all(edge_rows) session.flush() return WorkflowGraphDto( @@ -359,16 +361,31 @@ async def add_node( # slopcop: ignore[max-function-params] session.add(node) session.flush() - await self._log_mutation( - session, - sample_id, - mutation_type="node.added", - target_type="node", - target_id=node.task_id, - meta=meta, - old_value=None, - new_value=_node_snapshot(node), + appender = SampleRuntimeEventAppender(session) + task_event = appender.append_task_event( + SampleTaskEventRow( + sample_id=sample_id, + task_id=node.task_id, + task_slug=node.task_slug, + event_type="task.added", + status=status, + actor=meta.actor, + event_timestamp=now, + task_snapshot_json=node.task_json, + payload_json=_task_payload(node), + ) ) + await self._publish_runtime_event(task_event) + for event in self._append_task_component_events( + appender, + sample_id=sample_id, + actor=meta.actor, + event_timestamp=now, + task_id=node.task_id, + task_json=node.task_json, + assigned_worker_slug=node.assigned_worker_slug, + ): + await self._publish_runtime_event(event) return _to_node_dto(node) async def update_node_status( @@ -401,16 +418,22 @@ async def update_node_status( session.add(node) session.flush() - await self._log_mutation( - session, - sample_id, - mutation_type="node.status_changed", - target_type="node", - target_id=task_id, - meta=meta, - old_value=NodeStatusChangedMutation(status=old_status), - new_value=NodeStatusChangedMutation(status=new_status), + event = SampleRuntimeEventAppender(session).append_task_event( + SampleTaskEventRow( + sample_id=sample_id, + task_id=task_id, + task_slug=node.task_slug, + event_type="task.status_changed", + status=new_status, + actor=meta.actor, + payload_json={ + "old_status": old_status, + "status": new_status, + "reason": meta.reason, + }, + ) ) + await self._publish_runtime_event(event) return True async def update_node_field( @@ -440,16 +463,6 @@ async def update_node_field( session.add(node) session.flush() - await self._log_mutation( - session, - sample_id, - mutation_type="node.field_changed", - target_type="node", - target_id=task_id, - meta=meta, - old_value=NodeFieldChangedMutation(field=field, value=old_value), - new_value=NodeFieldChangedMutation(field=field, value=value), - ) return _to_node_dto(node) # ── Edge operations ───────────────────────────────────── @@ -480,16 +493,21 @@ async def add_edge( session.add(edge) session.flush() - await self._log_mutation( - session, - sample_id, - mutation_type="edge.added", - target_type="edge", - target_id=edge.id, - meta=meta, - old_value=None, - new_value=_edge_snapshot(edge), + event = SampleRuntimeEventAppender(session).append_edge_event( + SampleEdgeEventRow( + sample_id=sample_id, + edge_id=edge.id, + source_task_id=edge.source_task_id, + target_task_id=edge.target_task_id, + event_type="edge.added", + status=status, + actor=meta.actor, + event_timestamp=now, + edge_snapshot_json=_edge_payload(edge), + payload_json=_edge_payload(edge), + ) ) + await self._publish_runtime_event(event) return _to_edge_dto(edge) async def update_edge_status( @@ -509,16 +527,20 @@ async def update_edge_status( session.add(edge) session.flush() - await self._log_mutation( - session, - sample_id, - mutation_type="edge.status_changed", - target_type="edge", - target_id=edge_id, - meta=meta, - old_value=EdgeStatusChangedMutation(status=old_status), - new_value=EdgeStatusChangedMutation(status=new_status), + event = SampleRuntimeEventAppender(session).append_edge_event( + SampleEdgeEventRow( + sample_id=sample_id, + edge_id=edge_id, + source_task_id=edge.source_task_id, + target_task_id=edge.target_task_id, + event_type="edge.status_changed", + status=new_status, + actor=meta.actor, + edge_snapshot_json=_edge_payload(edge), + payload_json={"old_status": old_status, "status": new_status}, + ) ) + await self._publish_runtime_event(event) return _to_edge_dto(edge) # ── Query operations ──────────────────────────────────── @@ -619,49 +641,94 @@ def _require_node_exists( sample_id=sample_id, ) - def _next_sequence(self, session: Session, sample_id: UUID) -> int: - stmt = ( - select(SampleGraphMutation.sequence) - .where(SampleGraphMutation.sample_id == sample_id) - .order_by(col(SampleGraphMutation.sequence).desc()) - .limit(1) - ) - last = session.exec(stmt).first() - return (last + 1) if last is not None else 0 - - async def _log_mutation( + def _append_task_component_events( self, - session: Session, - sample_id: UUID, + appender: SampleRuntimeEventAppender, *, - mutation_type: str, - target_type: str, - target_id: UUID, - meta: MutationMeta, - old_value: GraphMutationValue | None, - new_value: GraphMutationValue, - ) -> None: - seq = self._next_sequence(session, sample_id) - row = SampleGraphMutation( - sample_id=sample_id, - sequence=seq, - mutation_type=mutation_type, - target_type=target_type, - target_id=target_id, - actor=meta.actor, - old_value=old_value.model_dump(mode="json") if old_value is not None else None, - new_value=new_value.model_dump(mode="json"), - reason=meta.reason, - created_at=utcnow(), - ) - session.add(row) - session.flush() + sample_id: UUID, + actor: str, + event_timestamp: datetime, + task_id: UUID, + task_json: dict, + assigned_worker_slug: str | None, + ) -> list[SampleRuntimeEventRow]: + events: list[SampleRuntimeEventRow] = [] + + worker_snapshot = _component_snapshot(task_json.get("worker")) + if worker_snapshot is not None: + worker_slug = assigned_worker_slug or _component_slug( + worker_snapshot, fallback="worker" + ) + events.append( + appender.append_worker_event( + SampleWorkerEventRow( + sample_id=sample_id, + task_id=task_id, + worker_slug=worker_slug, + worker_type=_component_type(worker_snapshot, fallback=worker_slug), + model_target=_component_model(worker_snapshot), + worker_snapshot_json=worker_snapshot, + event_type="worker.added", + actor=actor, + event_timestamp=event_timestamp, + payload_json={"worker": worker_snapshot}, + ) + ) + ) - for listener in self._mutation_listeners: + sandbox_snapshot = _component_snapshot(task_json.get("sandbox")) + if sandbox_snapshot is not None: + sandbox_slug = _component_slug(sandbox_snapshot, fallback="sandbox") + events.append( + appender.append_sandbox_event( + SampleSandboxEventRow( + sample_id=sample_id, + task_id=task_id, + sandbox_slug=sandbox_slug, + sandbox_type=_component_type(sandbox_snapshot, fallback=sandbox_slug), + sandbox_snapshot_json=sandbox_snapshot, + event_type="sandbox.added", + actor=actor, + event_timestamp=event_timestamp, + payload_json={"sandbox": sandbox_snapshot}, + ) + ) + ) + + evaluators = task_json.get("evaluators") + if isinstance(evaluators, list): + for evaluator in evaluators: + evaluator_snapshot = _component_snapshot(evaluator) + if evaluator_snapshot is None: + continue + evaluator_slug = _component_slug(evaluator_snapshot, fallback="default") + events.append( + appender.append_evaluator_event( + SampleEvaluatorEventRow( + sample_id=sample_id, + task_id=task_id, + evaluator_slug=evaluator_slug, + evaluator_type=_component_type( + evaluator_snapshot, + fallback=evaluator_slug, + ), + evaluator_snapshot_json=evaluator_snapshot, + event_type="evaluator.added", + actor=actor, + event_timestamp=event_timestamp, + payload_json={"evaluator": evaluator_snapshot}, + ) + ) + ) + + return events + + async def _publish_runtime_event(self, row: SampleRuntimeEventRow) -> None: + for listener in self._runtime_event_listeners: try: await listener(row) except Exception: # slopcop: ignore[no-broad-except] - logger.warning("Mutation listener failed", exc_info=True) + logger.warning("Runtime event listener failed", exc_info=True) def _check_no_cycle( self, @@ -723,19 +790,46 @@ def _to_edge_dto(row: SampleGraphEdge) -> GraphEdgeDto: ) -def _node_snapshot(node: SampleGraphNode) -> NodeAddedMutation: - return NodeAddedMutation( - task_slug=node.task_slug, - instance_key=node.instance_key, - description=node.description, - status=node.status, - assigned_worker_slug=node.assigned_worker_slug, - ) +def _task_payload(node: SampleGraphNode) -> dict: + return { + "task_key": node.task_slug, + "task_slug": node.task_slug, + "instance_key": node.instance_key, + "description": node.description, + "status": node.status, + "assigned_worker_slug": node.assigned_worker_slug, + "parent_task_id": str(node.parent_task_id) if node.parent_task_id else None, + "level": node.level, + "is_dynamic": node.is_dynamic, + "task": node.task_json, + } -def _edge_snapshot(edge: SampleGraphEdge) -> EdgeAddedMutation: - return EdgeAddedMutation( - source_task_id=edge.source_task_id, - target_task_id=edge.target_task_id, - status=edge.status, - ) +def _edge_payload(edge: SampleGraphEdge) -> dict: + return { + "source_task_id": str(edge.source_task_id), + "target_task_id": str(edge.target_task_id), + "status": edge.status, + } + + +def _component_snapshot(value: object) -> dict | None: + return value if isinstance(value, dict) else None + + +def _component_slug(snapshot: dict, *, fallback: str) -> str: + value = snapshot.get("type_slug") or snapshot.get("slug") or snapshot.get("name") + if isinstance(value, str) and value: + return value + component_type = _component_type(snapshot, fallback=fallback) + return component_type.rsplit(":", 1)[-1].rsplit(".", 1)[-1] + + +def _component_type(snapshot: dict, *, fallback: str) -> str: + value = snapshot.get("_type") or snapshot.get("type") + return value if isinstance(value, str) and value else fallback + + +def _component_model(snapshot: dict) -> str | None: + value = snapshot.get("model") or snapshot.get("model_target") + return value if isinstance(value, str) else None diff --git a/ergon_core/ergon_core/core/application/runtime/lifecycle.py b/ergon_core/ergon_core/core/application/runtime/lifecycle.py index 2ef9d6e69..468a2c5c0 100644 --- a/ergon_core/ergon_core/core/application/runtime/lifecycle.py +++ b/ergon_core/ergon_core/core/application/runtime/lifecycle.py @@ -1,8 +1,8 @@ """Workflow propagation service helpers. -All state is stored in the graph layer (SampleGraphNode, SampleGraphEdge, -SampleGraphMutation). The graph mutation WAL is the single source of truth -for DAG execution state. +Projection state is stored in the graph layer (SampleGraphNode, SampleGraphEdge). +The typed sample runtime WAL is the single source of truth for DAG execution +state. """ from uuid import UUID diff --git a/ergon_core/ergon_core/core/application/runtime/models.py b/ergon_core/ergon_core/core/application/runtime/models.py index 4d0343f97..6f736b567 100644 --- a/ergon_core/ergon_core/core/application/runtime/models.py +++ b/ergon_core/ergon_core/core/application/runtime/models.py @@ -8,14 +8,10 @@ serialization cost). """ -from datetime import datetime -from typing import Annotated, Literal from uuid import UUID from ergon_core.api.benchmark import Task from ergon_core.core.application.runtime.status import NodeStatus -from ergon_core.core.shared.json_types import JsonObject -from ergon_core.core.persistence.graph.models import GraphTargetType, MutationType from ergon_core.core.persistence.shared.types import ( DefinitionId, EdgeId, @@ -90,46 +86,6 @@ class GraphEdgeDto(BaseModel): ) -class GraphAnnotationDto(BaseModel): - model_config = {"frozen": True} - - id: UUID = Field(description="Identifier of the annotation row itself.") - sample_id: SampleId - target_type: GraphTargetType - target_id: UUID = Field( - description=( - "Polymorphic graph target identifier. Interpreted as a NodeId or EdgeId based " - "on target_type." - ) - ) - namespace: str - sequence: int - payload: JsonObject - - -class GraphMutationRecordDto(BaseModel): - """Append-only graph mutation record with a typed mutation payload.""" - - model_config = {"frozen": True} - - id: UUID = Field(description="Identifier of the mutation row itself, not a graph target id.") - sample_id: SampleId - sequence: int - mutation_type: MutationType - target_type: GraphTargetType - target_id: UUID = Field( - description=( - "Polymorphic mutation target identifier. Interpreted as a NodeId, EdgeId, or " - "annotation id based on target_type and mutation_type." - ) - ) - actor: str - old_value: "GraphMutationValue | None" - new_value: "GraphMutationValue" - reason: str | None - created_at: datetime - - class WorkflowGraphDto(BaseModel): """Full graph snapshot returned by get_graph().""" @@ -140,121 +96,6 @@ class WorkflowGraphDto(BaseModel): edges: list[GraphEdgeDto] = Field(default_factory=list) -# --------------------------------------------------------------------------- -# Typed mutation value models (discriminated union on mutation_type) -# --------------------------------------------------------------------------- - - -class NodeAddedMutation(BaseModel): - """node.added — full node snapshot.""" - - model_config = {"frozen": True} - - mutation_type: Literal["node.added"] = "node.added" - task_slug: str - instance_key: str - description: str - status: str - assigned_worker_slug: str | None - - -class NodeRemovedMutation(BaseModel): - """node.removed — node snapshot at removal time.""" - - model_config = {"frozen": True} - - mutation_type: Literal["node.removed"] = "node.removed" - task_slug: str - instance_key: str - description: str - status: str - assigned_worker_slug: str | None - - -class NodeStatusChangedMutation(BaseModel): - """node.status_changed.""" - - model_config = {"frozen": True} - - mutation_type: Literal["node.status_changed"] = "node.status_changed" - status: str - - -class NodeFieldChangedMutation(BaseModel): - """node.field_changed.""" - - model_config = {"frozen": True} - - mutation_type: Literal["node.field_changed"] = "node.field_changed" - field: Literal["description", "assigned_worker_slug"] - value: str | None - - -class EdgeAddedMutation(BaseModel): - """edge.added — full edge snapshot.""" - - model_config = {"frozen": True} - - mutation_type: Literal["edge.added"] = "edge.added" - source_task_id: NodeId - target_task_id: NodeId - status: str - - -class EdgeRemovedMutation(BaseModel): - """edge.removed.""" - - model_config = {"frozen": True} - - mutation_type: Literal["edge.removed"] = "edge.removed" - source_task_id: NodeId - target_task_id: NodeId - status: str - - -class EdgeStatusChangedMutation(BaseModel): - """edge.status_changed.""" - - model_config = {"frozen": True} - - mutation_type: Literal["edge.status_changed"] = "edge.status_changed" - status: str - - -class AnnotationSetMutation(BaseModel): - """annotation.set.""" - - model_config = {"frozen": True} - - mutation_type: Literal["annotation.set"] = "annotation.set" - namespace: str - payload: JsonObject - - -class AnnotationDeletedMutation(BaseModel): - """annotation.deleted — tombstone.""" - - model_config = {"frozen": True} - - mutation_type: Literal["annotation.deleted"] = "annotation.deleted" - namespace: str - payload: JsonObject - - -GraphMutationValue = Annotated[ - NodeAddedMutation - | NodeRemovedMutation - | NodeStatusChangedMutation - | NodeFieldChangedMutation - | EdgeAddedMutation - | EdgeRemovedMutation - | EdgeStatusChangedMutation - | AnnotationSetMutation - | AnnotationDeletedMutation, - Field(discriminator="mutation_type"), -] - - class SampleGraphNodeView(BaseModel): """Typed view of one ``sample_graph_nodes`` row + its inflated Task. diff --git a/ergon_core/ergon_core/core/application/runtime/sample_lifecycle.py b/ergon_core/ergon_core/core/application/runtime/sample_lifecycle.py index b2a83956a..7cb0c36a8 100644 --- a/ergon_core/ergon_core/core/application/runtime/sample_lifecycle.py +++ b/ergon_core/ergon_core/core/application/runtime/sample_lifecycle.py @@ -9,6 +9,7 @@ ) from ergon_core.core.application.runtime import status as graph_status from ergon_core.core.persistence.graph.models import SampleGraphEdge, SampleGraphNode +from ergon_core.core.persistence.samples.models import SampleStatusEventRow from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import ( SampleResourceKind, @@ -40,6 +41,7 @@ ) from ergon_core.core.application.runtime.graph_traversal import descendant_ids from ergon_core.core.application.runtime.models import GraphEdgeDto, GraphNodeDto, MutationMeta +from ergon_core.core.application.samples.events import SampleRuntimeEventAppender from ergon_core.core.application.runtime.graph_repository import RuntimeGraphRepository from ergon_core.core.application.runtime.orchestration import ( FinalizedWorkflowResult, @@ -145,6 +147,15 @@ async def initialize(self, command: InitializeWorkflowCommand) -> InitializedWor run_record.status = SampleStatus.EXECUTING run_record.started_at = utcnow() session.add(run_record) + SampleRuntimeEventAppender(session).append_status_event( + SampleStatusEventRow( + sample_id=command.sample_id, + event_type="sample.status_changed", + status=SampleStatus.EXECUTING, + actor="system:workflow_init", + event_timestamp=run_record.started_at, + ) + ) session.commit() ready_ids = await get_initial_ready_tasks( @@ -201,6 +212,15 @@ def finalize(self, command: FinalizeWorkflowCommand) -> FinalizedWorkflowResult: "cost_observed": completion.cost_observed, } session.add(run_record) + SampleRuntimeEventAppender(session).append_status_event( + SampleStatusEventRow( + sample_id=command.sample_id, + event_type="sample.status_changed", + status=SampleStatus.COMPLETED, + actor="system:workflow_finalize", + event_timestamp=completion.completed_at, + ) + ) session.commit() return FinalizedWorkflowResult( diff --git a/ergon_core/ergon_core/core/application/runtime/sample_records.py b/ergon_core/ergon_core/core/application/runtime/sample_records.py index 8da422b18..a6dbb3fc4 100644 --- a/ergon_core/ergon_core/core/application/runtime/sample_records.py +++ b/ergon_core/ergon_core/core/application/runtime/sample_records.py @@ -6,7 +6,9 @@ import inngest from ergon_core.core.application.experiments.models import DefinitionHandle from ergon_core.core.application.events import SampleCancelledEvent, SampleCleanupEvent +from ergon_core.core.application.samples.events import SampleRuntimeEventAppender from ergon_core.core.shared.json_types import JsonObject +from ergon_core.core.persistence.samples.models import SampleStatusEventRow from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TERMINAL_SAMPLE_STATUSES, SampleStatus from ergon_core.core.persistence.telemetry.models import SampleRecord @@ -81,6 +83,15 @@ def cancel_sample(sample_id: UUID) -> SampleRecord: sample.status = SampleStatus.CANCELLED sample.completed_at = utcnow() session.add(sample) + SampleRuntimeEventAppender(session).append_status_event( + SampleStatusEventRow( + sample_id=sample_id, + event_type="sample.status_changed", + status=SampleStatus.CANCELLED, + actor="user:cancel_sample", + event_timestamp=sample.completed_at, + ) + ) session.commit() session.refresh(sample) diff --git a/ergon_core/ergon_core/core/application/runtime/task_management.py b/ergon_core/ergon_core/core/application/runtime/task_management.py index 334884db4..fe8572e52 100644 --- a/ergon_core/ergon_core/core/application/runtime/task_management.py +++ b/ergon_core/ergon_core/core/application/runtime/task_management.py @@ -10,9 +10,6 @@ from __future__ import annotations import logging -import inspect -from collections.abc import Awaitable -from typing import Protocol from uuid import UUID import inngest @@ -20,6 +17,10 @@ from ergon_core.api.worker.results import SpawnedTaskHandle from ergon_core.core.application.events.service import get_dashboard_event_publisher from ergon_core.core.application.ports import DashboardEventPublisher +from ergon_core.core.application.samples.events import ( + SampleRuntimeEventRow, + sample_runtime_event_from_row, +) from ergon_core.core.persistence.graph.models import SampleGraphNode from ergon_core.core.application.runtime.status import ( BLOCKED, @@ -60,10 +61,7 @@ RestartTaskResult, ) from ergon_core.core.application.runtime.task_execution_repository import TaskExecutionRepository -from ergon_core.core.persistence.graph.models import SampleGraphMutation -from ergon_core.core.views.dashboard_events.graph_mutations import ( - dashboard_graph_mutation_event_from_row, -) +from ergon_core.core.views.dashboard_events.contracts import DashboardSampleRuntimeEvent from sqlmodel import Session logger = logging.getLogger(__name__) @@ -71,10 +69,6 @@ _MANAGER_META = MutationMeta(actor="manager-worker", reason="manager_decision") -class _LegacyDashboardGraphMutationEmitter(Protocol): - def graph_mutation(self, row: SampleGraphMutation) -> Awaitable[None] | None: ... - - def _count_non_terminal_descendants(session: Session, sample_id: UUID, task_id: UUID) -> int: """Count non-terminal descendants via iterative BFS on parent_task_id. @@ -95,7 +89,7 @@ def __init__( self, graph_repo: RuntimeGraphRepository | None = None, dashboard_publisher: DashboardEventPublisher | None = None, - dashboard_emitter: _LegacyDashboardGraphMutationEmitter | None = None, + dashboard_emitter: object | None = None, task_ready_dispatcher: TaskReadyDispatcher | None = None, ) -> None: self._graph_repo = graph_repo or RuntimeGraphRepository() @@ -103,21 +97,16 @@ def __init__( self._runtime_events = RuntimeEventDispatcher(task_ready_dispatcher) if dashboard_publisher is None and dashboard_emitter is not None: self._dashboard_publisher = None - self._legacy_graph_mutation_listener = dashboard_emitter.graph_mutation - self._graph_repo.add_mutation_listener(self._legacy_publish_graph_mutation) return self._dashboard_publisher = dashboard_publisher or get_dashboard_event_publisher() - self._graph_repo.add_mutation_listener(self._publish_graph_mutation) + self._graph_repo.add_runtime_event_listener(self._publish_runtime_event) - async def _publish_graph_mutation(self, row: SampleGraphMutation) -> None: + async def _publish_runtime_event(self, row: SampleRuntimeEventRow) -> None: if self._dashboard_publisher is None: return - await self._dashboard_publisher.publish(dashboard_graph_mutation_event_from_row(row)) - - async def _legacy_publish_graph_mutation(self, row: SampleGraphMutation) -> None: - result = self._legacy_graph_mutation_listener(row) - if inspect.isawaitable(result): - await result + await self._dashboard_publisher.publish( + DashboardSampleRuntimeEvent(event=sample_runtime_event_from_row(row)) + ) # ── spawn_dynamic_task ─────────────────────────────────── diff --git a/ergon_core/ergon_core/core/application/samples/__init__.py b/ergon_core/ergon_core/core/application/samples/__init__.py new file mode 100644 index 000000000..93d3bf65d --- /dev/null +++ b/ergon_core/ergon_core/core/application/samples/__init__.py @@ -0,0 +1 @@ +"""Application helpers for sample runtime state.""" diff --git a/ergon_core/ergon_core/core/application/samples/events.py b/ergon_core/ergon_core/core/application/samples/events.py new file mode 100644 index 000000000..8762fffc2 --- /dev/null +++ b/ergon_core/ergon_core/core/application/samples/events.py @@ -0,0 +1,186 @@ +"""Thin append helper and view DTOs for typed sample runtime WAL rows.""" + +from datetime import datetime +from typing import Literal, Protocol +from uuid import UUID + +from ergon_core.core.persistence.samples.models import ( + SampleAnnotationEventRow, + SampleEdgeEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleStatusEventRow, + SampleTaskEventRow, + SampleWorkerEventRow, +) +from ergon_core.core.shared.json_types import JsonObject +from pydantic import BaseModel, Field +from sqlmodel import Session, select + +SampleStatusEventKind = Literal["sample.status_changed"] +SampleTaskEventKind = Literal["task.added", "task.removed", "task.status_changed"] +SampleEdgeEventKind = Literal["edge.added", "edge.removed", "edge.status_changed"] +SampleWorkerEventKind = Literal["worker.added", "worker.removed"] +SampleEvaluatorEventKind = Literal["evaluator.added", "evaluator.removed"] +SampleSandboxEventKind = Literal["sandbox.added", "sandbox.removed"] +SampleAnnotationEventKind = Literal[ + "annotation.set", + "annotation.updated", + "annotation.deleted", +] + +SampleRuntimeEventRow = ( + SampleStatusEventRow + | SampleTaskEventRow + | SampleEdgeEventRow + | SampleWorkerEventRow + | SampleEvaluatorEventRow + | SampleSandboxEventRow + | SampleAnnotationEventRow +) + + +class SampleRuntimeEventView(BaseModel): + model_config = {"frozen": True} + + id: UUID + sample_id: UUID + event_timestamp: datetime + table: Literal[ + "sample_status_events", + "sample_task_events", + "sample_edge_events", + "sample_worker_events", + "sample_evaluator_events", + "sample_sandbox_events", + "sample_annotation_events", + ] + event_type: str + target_type: str + target_id: UUID | None + payload: JsonObject = Field( + default_factory=dict, + description="Typed event payload copied from the source sample runtime WAL row.", + ) + + +class SampleRuntimeEventSubscriber(Protocol): + async def __call__(self, event: SampleRuntimeEventRow) -> None: ... + + +class SampleRuntimeEventAppender: + """Transaction-local append helper for already-decided typed WAL rows.""" + + def __init__(self, session: Session) -> None: + self._session = session + + def append_status_event(self, row: SampleStatusEventRow) -> SampleStatusEventRow: + self._session.add(row) + self._session.flush() + return row + + def append_task_event(self, row: SampleTaskEventRow) -> SampleTaskEventRow: + self._session.add(row) + self._session.flush() + return row + + def append_edge_event(self, row: SampleEdgeEventRow) -> SampleEdgeEventRow: + self._session.add(row) + self._session.flush() + return row + + def append_worker_event(self, row: SampleWorkerEventRow) -> SampleWorkerEventRow: + self._session.add(row) + self._session.flush() + return row + + def append_evaluator_event(self, row: SampleEvaluatorEventRow) -> SampleEvaluatorEventRow: + self._session.add(row) + self._session.flush() + return row + + def append_sandbox_event(self, row: SampleSandboxEventRow) -> SampleSandboxEventRow: + self._session.add(row) + self._session.flush() + return row + + def append_annotation_event(self, row: SampleAnnotationEventRow) -> SampleAnnotationEventRow: + self._session.add(row) + self._session.flush() + return row + + +class SampleRuntimeEventReadService: + def list_events(self, session: Session, sample_id: UUID) -> list[SampleRuntimeEventView]: + rows: list[SampleRuntimeEventRow] = [] + for model in _EVENT_MODELS: + rows.extend(session.exec(select(model).where(model.sample_id == sample_id)).all()) + rows.sort(key=lambda row: (row.event_timestamp, row.id)) + return [sample_runtime_event_from_row(row) for row in rows] + + +def sample_runtime_event_from_row(row: SampleRuntimeEventRow) -> SampleRuntimeEventView: + table = row.__tablename__ + target_type, target_id = _target_for_row(row) + payload = _payload_for_row(row) + return SampleRuntimeEventView( + id=row.id, + sample_id=row.sample_id, + event_timestamp=row.event_timestamp, + table=table, + event_type=row.event_type, + target_type=target_type, + target_id=target_id, + payload=payload, + ) + + +def _target_for_row(row: SampleRuntimeEventRow) -> tuple[str, UUID | None]: + if isinstance(row, SampleStatusEventRow): + return "sample", row.sample_id + if isinstance(row, SampleTaskEventRow): + return "task", row.task_id + if isinstance(row, SampleEdgeEventRow): + return "edge", row.edge_id + if isinstance(row, (SampleWorkerEventRow, SampleEvaluatorEventRow, SampleSandboxEventRow)): + return "task", row.task_id + return row.target_type, row.target_id + + +def _payload_for_row(row: SampleRuntimeEventRow) -> JsonObject: + payload = dict(row.payload_json) + if isinstance(row, SampleStatusEventRow): + payload.setdefault("status", row.status) + elif isinstance(row, SampleTaskEventRow): + if row.task_slug is not None: + payload.setdefault("task_slug", row.task_slug) + if row.status is not None: + payload.setdefault("status", row.status) + payload.setdefault("task", row.task_snapshot_json) + elif isinstance(row, SampleEdgeEventRow): + if row.status is not None: + payload.setdefault("status", row.status) + payload.setdefault("source_task_id", str(row.source_task_id)) + payload.setdefault("target_task_id", str(row.target_task_id)) + payload.setdefault("edge", row.edge_snapshot_json) + elif isinstance(row, SampleWorkerEventRow): + payload.setdefault("worker", row.worker_snapshot_json) + payload.setdefault("worker_slug", row.worker_slug) + elif isinstance(row, SampleEvaluatorEventRow): + payload.setdefault("evaluator", row.evaluator_snapshot_json) + payload.setdefault("evaluator_slug", row.evaluator_slug) + elif isinstance(row, SampleSandboxEventRow): + payload.setdefault("sandbox", row.sandbox_snapshot_json) + payload.setdefault("sandbox_slug", row.sandbox_slug) + return payload + + +_EVENT_MODELS = ( + SampleStatusEventRow, + SampleTaskEventRow, + SampleEdgeEventRow, + SampleWorkerEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleAnnotationEventRow, +) diff --git a/ergon_core/ergon_core/core/application/samples/state.py b/ergon_core/ergon_core/core/application/samples/state.py new file mode 100644 index 000000000..ebd434e76 --- /dev/null +++ b/ergon_core/ergon_core/core/application/samples/state.py @@ -0,0 +1,266 @@ +"""Timestamp-sliced replay for typed sample runtime WAL rows.""" + +from datetime import datetime +from uuid import UUID + +from ergon_core.core.application.samples.events import SampleRuntimeEventRow +from ergon_core.core.persistence.samples.models import ( + SampleAnnotationEventRow, + SampleEdgeEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleStatusEventRow, + SampleTaskEventRow, + SampleWorkerEventRow, +) +from pydantic import BaseModel, Field +from sqlmodel import Session, select + + +class SampleTaskRuntimeState(BaseModel): + model_config = {"frozen": True} + + task_id: UUID + task_slug: str | None = None + status: str | None = None + task_snapshot_json: dict = Field(default_factory=dict) + + +class SampleEdgeRuntimeState(BaseModel): + model_config = {"frozen": True} + + edge_id: UUID + source_task_id: UUID + target_task_id: UUID + status: str | None = None + edge_snapshot_json: dict = Field(default_factory=dict) + + +class SampleWorkerRuntimeState(BaseModel): + model_config = {"frozen": True} + + task_id: UUID + worker_slug: str + worker_snapshot_json: dict = Field(default_factory=dict) + + +class SampleEvaluatorRuntimeState(BaseModel): + model_config = {"frozen": True} + + task_id: UUID + evaluator_slug: str + evaluator_snapshot_json: dict = Field(default_factory=dict) + + +class SampleSandboxRuntimeState(BaseModel): + model_config = {"frozen": True} + + task_id: UUID + sandbox_slug: str + sandbox_snapshot_json: dict = Field(default_factory=dict) + + +class SampleAnnotationRuntimeState(BaseModel): + model_config = {"frozen": True} + + target_type: str + target_id: UUID + key: str + payload_json: dict = Field(default_factory=dict) + + +class SampleRuntimeState(BaseModel): + model_config = {"frozen": True} + + status: str | None = None + tasks: dict[UUID, SampleTaskRuntimeState] = Field(default_factory=dict) + edges: dict[UUID, SampleEdgeRuntimeState] = Field(default_factory=dict) + workers: dict[UUID, SampleWorkerRuntimeState] = Field(default_factory=dict) + evaluators_by_task_id: dict[UUID, list[SampleEvaluatorRuntimeState]] = Field( + default_factory=dict + ) + sandboxes: dict[UUID, SampleSandboxRuntimeState] = Field(default_factory=dict) + annotations: dict[tuple[str, UUID, str], SampleAnnotationRuntimeState] = Field( + default_factory=dict + ) + + @classmethod + def from_events(cls, rows: list[SampleRuntimeEventRow]) -> "SampleRuntimeState": + accumulator = _SampleRuntimeStateAccumulator() + for row in rows: + accumulator.apply(row) + return accumulator.to_state() + + +class _SampleRuntimeStateAccumulator: + def __init__(self) -> None: + self.status: str | None = None + self.tasks: dict[UUID, SampleTaskRuntimeState] = {} + self.edges: dict[UUID, SampleEdgeRuntimeState] = {} + self.workers: dict[UUID, SampleWorkerRuntimeState] = {} + self.evaluators_by_task_id: dict[UUID, list[SampleEvaluatorRuntimeState]] = {} + self.sandboxes: dict[UUID, SampleSandboxRuntimeState] = {} + self.annotations: dict[tuple[str, UUID, str], SampleAnnotationRuntimeState] = {} + + def apply(self, row: SampleRuntimeEventRow) -> None: + if isinstance(row, SampleStatusEventRow): + self.status = row.status + elif isinstance(row, SampleTaskEventRow): + self._apply_task(row) + elif isinstance(row, SampleEdgeEventRow): + self._apply_edge(row) + elif isinstance(row, SampleWorkerEventRow) and row.task_id is not None: + self._apply_worker(row) + elif isinstance(row, SampleEvaluatorEventRow) and row.task_id is not None: + self._apply_evaluator(row) + elif isinstance(row, SampleSandboxEventRow) and row.task_id is not None: + self._apply_sandbox(row) + elif isinstance(row, SampleAnnotationEventRow): + self._apply_annotation(row) + + def _apply_task(self, row: SampleTaskEventRow) -> None: + if row.event_type == "task.removed": + self.tasks.pop(row.task_id, None) + return + if row.event_type == "task.added": + self.tasks[row.task_id] = SampleTaskRuntimeState( + task_id=row.task_id, + task_slug=row.task_slug, + status=row.status, + task_snapshot_json=dict(row.task_snapshot_json), + ) + return + if row.event_type == "task.status_changed": + current = self.tasks.get(row.task_id) + self.tasks[row.task_id] = SampleTaskRuntimeState( + task_id=row.task_id, + task_slug=_task_slug_from_event(row, current), + status=row.status, + task_snapshot_json=dict( + row.task_snapshot_json + or (current.task_snapshot_json if current is not None else {}) + ), + ) + + def _apply_edge(self, row: SampleEdgeEventRow) -> None: + if row.event_type == "edge.removed": + self.edges.pop(row.edge_id, None) + return + if row.event_type == "edge.added": + self.edges[row.edge_id] = SampleEdgeRuntimeState( + edge_id=row.edge_id, + source_task_id=row.source_task_id, + target_task_id=row.target_task_id, + status=row.status, + edge_snapshot_json=dict(row.edge_snapshot_json), + ) + return + if row.event_type == "edge.status_changed" and row.edge_id in self.edges: + current_edge = self.edges[row.edge_id] + self.edges[row.edge_id] = SampleEdgeRuntimeState( + edge_id=row.edge_id, + source_task_id=current_edge.source_task_id, + target_task_id=current_edge.target_task_id, + status=row.status, + edge_snapshot_json=dict(row.edge_snapshot_json or current_edge.edge_snapshot_json), + ) + + def _apply_worker(self, row: SampleWorkerEventRow) -> None: + if row.event_type == "worker.removed": + self.workers.pop(row.task_id, None) + return + self.workers[row.task_id] = SampleWorkerRuntimeState( + task_id=row.task_id, + worker_slug=row.worker_slug, + worker_snapshot_json=dict(row.worker_snapshot_json), + ) + + def _apply_evaluator(self, row: SampleEvaluatorEventRow) -> None: + if row.event_type == "evaluator.removed": + self._remove_evaluator(row) + return + self.evaluators_by_task_id.setdefault(row.task_id, []).append( + SampleEvaluatorRuntimeState( + task_id=row.task_id, + evaluator_slug=row.evaluator_slug, + evaluator_snapshot_json=dict(row.evaluator_snapshot_json), + ) + ) + + def _remove_evaluator(self, row: SampleEvaluatorEventRow) -> None: + if row.evaluator_slug: + self.evaluators_by_task_id[row.task_id] = [ + evaluator + for evaluator in self.evaluators_by_task_id.get(row.task_id, []) + if evaluator.evaluator_slug != row.evaluator_slug + ] + else: + self.evaluators_by_task_id.pop(row.task_id, None) + + def _apply_sandbox(self, row: SampleSandboxEventRow) -> None: + if row.event_type == "sandbox.removed": + self.sandboxes.pop(row.task_id, None) + return + self.sandboxes[row.task_id] = SampleSandboxRuntimeState( + task_id=row.task_id, + sandbox_slug=row.sandbox_slug, + sandbox_snapshot_json=dict(row.sandbox_snapshot_json), + ) + + def _apply_annotation(self, row: SampleAnnotationEventRow) -> None: + target_key = (row.target_type, row.target_id, row.key) + if row.event_type == "annotation.deleted": + self.annotations.pop(target_key, None) + return + self.annotations[target_key] = SampleAnnotationRuntimeState( + target_type=row.target_type, + target_id=row.target_id, + key=row.key, + payload_json=dict(row.payload_json), + ) + + def to_state(self) -> SampleRuntimeState: + return SampleRuntimeState( + status=self.status, + tasks=self.tasks, + edges=self.edges, + workers=self.workers, + evaluators_by_task_id=self.evaluators_by_task_id, + sandboxes=self.sandboxes, + annotations=self.annotations, + ) + + +def _task_slug_from_event( + row: SampleTaskEventRow, + current: SampleTaskRuntimeState | None, +) -> str | None: + if row.task_slug is not None: + return row.task_slug + if current is not None: + return current.task_slug + return None + + +def reconstruct_sample_runtime_state_at( + session: Session, + *, + sample_id: UUID, + at: datetime | None = None, +) -> SampleRuntimeState: + rows: list[SampleRuntimeEventRow] = [] + for model in ( + SampleStatusEventRow, + SampleTaskEventRow, + SampleEdgeEventRow, + SampleWorkerEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleAnnotationEventRow, + ): + stmt = select(model).where(model.sample_id == sample_id) + if at is not None: + stmt = stmt.where(model.event_timestamp <= at) + rows.extend(session.exec(stmt).all()) + rows.sort(key=lambda row: (row.event_timestamp, row.id)) + return SampleRuntimeState.from_events(rows) diff --git a/ergon_core/ergon_core/core/application/testing/test_harness_service.py b/ergon_core/ergon_core/core/application/testing/test_harness_service.py index 8a7975a3c..bad34add7 100644 --- a/ergon_core/ergon_core/core/application/testing/test_harness_service.py +++ b/ergon_core/ergon_core/core/application/testing/test_harness_service.py @@ -6,7 +6,7 @@ from ergon_core.core.persistence.context.models import SampleContextEvent from ergon_core.core.persistence.definitions.models import ExperimentDefinition -from ergon_core.core.persistence.graph.models import SampleGraphMutation, SampleGraphNode +from ergon_core.core.persistence.graph.models import SampleGraphNode from ergon_core.core.persistence.shared.db import get_engine from ergon_core.core.persistence.shared.enums import SampleStatus from ergon_core.core.persistence.telemetry.models import ( @@ -16,7 +16,8 @@ SampleTaskAttempt, Thread, ) -from sqlmodel import Session, asc, select +from ergon_core.core.application.samples.events import SampleRuntimeEventReadService +from sqlmodel import Session, select class UnknownSampleStatusError(ValueError): @@ -46,10 +47,11 @@ class HarnessEvaluation: @dataclass(frozen=True) -class HarnessGraphMutation: - sequence: int - mutation_type: str - target_task_slug: str | None +class HarnessSampleRuntimeEvent: + table: str + event_type: str + target_id: UUID | None + payload: dict @dataclass(frozen=True) @@ -64,11 +66,11 @@ class HarnessRunState: sample_id: UUID status: str graph_nodes: list[HarnessGraphNode] - mutations: list[HarnessGraphMutation] + events: list[HarnessSampleRuntimeEvent] evaluations: list[HarnessEvaluation] executions: list[HarnessExecution] execution_count: int - mutation_count: int + event_count: int resource_count: int thread_count: int context_event_count: int @@ -108,20 +110,15 @@ def read_run_state(sample_id: UUID, session: Session) -> HarnessRunState | None: for n in nodes ] - mutation_rows = list( - session.exec( - select(SampleGraphMutation) - .where(SampleGraphMutation.sample_id == sample_id) - .order_by(asc(SampleGraphMutation.sequence)) - ).all() - ) - mutations = [ - HarnessGraphMutation( - sequence=m.sequence, - mutation_type=m.mutation_type, - target_task_slug=slug_by_task_id.get(m.target_id) if m.target_id else None, + event_rows = SampleRuntimeEventReadService().list_events(session, sample_id) + events = [ + HarnessSampleRuntimeEvent( + table=event.table, + event_type=event.event_type, + target_id=event.target_id, + payload=dict(event.payload), ) - for m in mutation_rows + for event in event_rows ] eval_rows = list( @@ -173,11 +170,11 @@ def read_run_state(sample_id: UUID, session: Session) -> HarnessRunState | None: sample_id=sample_id, status=run.status, graph_nodes=graph_nodes, - mutations=mutations, + events=events, evaluations=evaluations, executions=executions, execution_count=len(execution_rows), - mutation_count=len(mutation_rows), + event_count=len(event_rows), resource_count=resource_count, thread_count=thread_count, context_event_count=context_event_count, diff --git a/ergon_core/ergon_core/core/infrastructure/http/routes/samples.py b/ergon_core/ergon_core/core/infrastructure/http/routes/samples.py index 3859fcb99..b574dc01b 100644 --- a/ergon_core/ergon_core/core/infrastructure/http/routes/samples.py +++ b/ergon_core/ergon_core/core/infrastructure/http/routes/samples.py @@ -6,7 +6,7 @@ SampleSummaryDto, SampleSnapshotDto, ) -from ergon_core.core.application.runtime.models import GraphMutationRecordDto +from ergon_core.core.application.samples.events import SampleRuntimeEventView from ergon_core.core.views.errors import ResourceTooLargeError from ergon_core.core.views.samples.service import SampleSnapshotReadService from fastapi import APIRouter, HTTPException @@ -42,13 +42,13 @@ def get_sample_snapshot(sample_id: UUID) -> SampleSnapshotDto: return snapshot -@router.get("/{sample_id}/mutations", response_model=list[GraphMutationRecordDto]) -def get_mutations(sample_id: UUID) -> list[GraphMutationRecordDto]: - """Return the append-only mutation log for a sample, ordered by sequence.""" - mutations = SampleSnapshotReadService().list_mutations(sample_id) - if mutations is None: +@router.get("/{sample_id}/events", response_model=list[SampleRuntimeEventView]) +def get_sample_runtime_events(sample_id: UUID) -> list[SampleRuntimeEventView]: + """Return the typed append-only runtime event stream for a sample.""" + events = SampleSnapshotReadService().list_events(sample_id) + if events is None: raise HTTPException(status_code=404, detail=f"Sample {sample_id} not found") - return mutations + return events @router.get("/{sample_id}/resources/{resource_id}/content") diff --git a/ergon_core/ergon_core/core/infrastructure/http/routes/test_harness.py b/ergon_core/ergon_core/core/infrastructure/http/routes/test_harness.py index d3495d34a..bfd08f3ab 100644 --- a/ergon_core/ergon_core/core/infrastructure/http/routes/test_harness.py +++ b/ergon_core/ergon_core/core/infrastructure/http/routes/test_harness.py @@ -67,10 +67,11 @@ class TestEvaluationDto(BaseModel): reason: str -class TestGraphMutationDto(BaseModel): - sequence: int - mutation_type: str - target_task_slug: str | None +class TestSampleRuntimeEventDto(BaseModel): + table: str + event_type: str + target_id: UUID | None + payload: dict class TestExecutionDto(BaseModel): @@ -83,11 +84,11 @@ class TestRunStateDto(BaseModel): sample_id: UUID status: str graph_nodes: list[TestGraphNodeDto] - mutations: list[TestGraphMutationDto] + events: list[TestSampleRuntimeEventDto] evaluations: list[TestEvaluationDto] executions: list[TestExecutionDto] execution_count: int - mutation_count: int + event_count: int resource_count: int thread_count: int context_event_count: int diff --git a/ergon_core/ergon_core/core/jobs/workflow/fail/job.py b/ergon_core/ergon_core/core/jobs/workflow/fail/job.py index cadf31599..a6d2d254f 100644 --- a/ergon_core/ergon_core/core/jobs/workflow/fail/job.py +++ b/ergon_core/ergon_core/core/jobs/workflow/fail/job.py @@ -4,8 +4,10 @@ from datetime import UTC, datetime from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.persistence.samples.models import SampleStatusEventRow from ergon_core.core.persistence.shared.enums import SampleStatus from ergon_core.core.persistence.telemetry.models import SampleRecord +from ergon_core.core.application.samples.events import SampleRuntimeEventAppender from ergon_core.core.infrastructure.inngest.errors import DataIntegrityError from ergon_core.core.jobs.sample.cleanup.contract import SampleCleanupEvent from .contract import WorkflowFailedEvent, WorkflowFailedResult @@ -34,6 +36,16 @@ async def run_fail_workflow_job(payload: WorkflowFailedEvent) -> WorkflowFailedR run_record.error_message = payload.error run_record.completed_at = utcnow() session.add(run_record) + SampleRuntimeEventAppender(session).append_status_event( + SampleStatusEventRow( + sample_id=payload.sample_id, + event_type="sample.status_changed", + status=SampleStatus.FAILED, + actor="system:workflow_failed", + event_timestamp=run_record.completed_at, + payload_json={"error": payload.error}, + ) + ) session.commit() await send_job_event( diff --git a/ergon_core/ergon_core/core/persistence/graph/models.py b/ergon_core/ergon_core/core/persistence/graph/models.py index 1cf7d7a0c..f517bf936 100644 --- a/ergon_core/ergon_core/core/persistence/graph/models.py +++ b/ergon_core/ergon_core/core/persistence/graph/models.py @@ -1,39 +1,20 @@ -"""Per-run mutable workflow graph tables. +"""Per-sample mutable workflow graph projection tables. The core graph layer. Status is a free-form string — the core does not constrain values. Domain semantics live in the experiment layer. Tables: - sample_graph_nodes — mutable task nodes, one per run - sample_graph_edges — mutable dependency edges, one per run - sample_graph_annotations — append-only namespaced metadata (WAL) - sample_graph_mutations — append-only audit log of every change + sample_graph_nodes — mutable task node read model + sample_graph_edges — mutable dependency edge read model """ from datetime import datetime -from typing import Literal from uuid import UUID, uuid4 -from ergon_core.core.shared.json_types import JsonObject from ergon_core.core.shared.utils import utcnow as _utcnow -from pydantic import model_validator -from sqlalchemy import JSON, Boolean, Column, DateTime, Index +from sqlalchemy import JSON, Boolean, Column, DateTime from sqlmodel import Field, SQLModel -GraphTargetType = Literal["node", "edge"] - -MutationType = Literal[ - "node.added", - "node.removed", - "node.status_changed", - "node.field_changed", - "edge.added", - "edge.removed", - "edge.status_changed", - "annotation.set", - "annotation.deleted", -] - TZDateTime = DateTime(timezone=True) @@ -147,92 +128,3 @@ class SampleGraphEdge(SQLModel, table=True): status: str = Field(index=True) created_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) updated_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) - - -# --------------------------------------------------------------------------- -# SampleGraphAnnotation -# --------------------------------------------------------------------------- - - -class SampleGraphAnnotation(SQLModel, table=True): - """Append-only annotation WAL. Each set_annotation() inserts a new row. - Current value = latest sequence. Point-in-time = sequence <= N. - - Append-only (rather than upsert) so the full DAG state can be - reconstructed at any mutation sequence — needed for counterfactual - replay and credit assignment in the training pipeline.""" - - __tablename__ = "sample_graph_annotations" - __table_args__ = ( - Index( - "ix_annotation_lookup", - "sample_id", - "target_type", - "target_id", - "namespace", - "sequence", - ), - ) - - id: UUID = Field(default_factory=uuid4, primary_key=True) - sample_id: UUID = Field(foreign_key="samples.id", index=True) - target_type: str = Field( - description=( - "GraphTargetType literal ('node' or 'edge') stored as a string for SQLModel " - "compatibility." - ) - ) - target_id: UUID - namespace: str - sequence: int = Field(index=True) - payload: dict = Field(default_factory=dict, sa_column=Column(JSON)) - created_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) - - def parsed_payload(self) -> JsonObject: - return self.__class__._parse_payload(self.payload) - - @classmethod - def _parse_payload(cls, data: dict) -> JsonObject: - if not isinstance(data, dict): - raise ValueError(f"payload must be a dict, got {type(data).__name__}") - return data - - @model_validator(mode="after") - def _validate_payload(self) -> "SampleGraphAnnotation": - self.__class__._parse_payload(self.payload) - return self - - -# --------------------------------------------------------------------------- -# SampleGraphMutation -# --------------------------------------------------------------------------- - - -class SampleGraphMutation(SQLModel, table=True): - __tablename__ = "sample_graph_mutations" - - id: UUID = Field(default_factory=uuid4, primary_key=True) - sample_id: UUID = Field(foreign_key="samples.id", index=True) - sequence: int = Field(index=True) - mutation_type: str = Field( - index=True, - description="MutationType literal stored as a string for SQLModel compatibility.", - ) - target_type: str = Field( - description=( - "GraphTargetType literal ('node' or 'edge') stored as a string for SQLModel " - "compatibility." - ) - ) - target_id: UUID = Field(index=True) - actor: str - old_value: dict | None = Field(default=None, sa_column=Column(JSON)) - new_value: dict = Field(default_factory=dict, sa_column=Column(JSON)) - reason: str | None = None - triggered_by_mutation_id: UUID | None = Field( - default=None, - foreign_key="sample_graph_mutations.id", - ondelete="SET NULL", - ) - batch_operation_id: UUID | None = Field(default=None, index=False) - created_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) diff --git a/ergon_core/ergon_core/core/persistence/samples/__init__.py b/ergon_core/ergon_core/core/persistence/samples/__init__.py new file mode 100644 index 000000000..8551dbfdd --- /dev/null +++ b/ergon_core/ergon_core/core/persistence/samples/__init__.py @@ -0,0 +1 @@ +"""Typed sample runtime WAL persistence models.""" diff --git a/ergon_core/ergon_core/core/persistence/samples/models.py b/ergon_core/ergon_core/core/persistence/samples/models.py new file mode 100644 index 000000000..a2a70836d --- /dev/null +++ b/ergon_core/ergon_core/core/persistence/samples/models.py @@ -0,0 +1,112 @@ +"""Typed append-only sample runtime event tables.""" + +from datetime import datetime +from uuid import UUID, uuid4 + +from ergon_core.core.shared.utils import utcnow as _utcnow +from sqlalchemy import JSON, Column, DateTime +from sqlmodel import Field, SQLModel + +TZDateTime = DateTime(timezone=True) + + +class SampleStatusEventRow(SQLModel, table=True): + __tablename__ = "sample_status_events" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + sample_id: UUID = Field(foreign_key="samples.id", index=True) + event_timestamp: datetime = Field(default_factory=_utcnow, index=True, sa_type=TZDateTime) + event_type: str = Field(index=True, description="Typed sample lifecycle event name.") + status: str = Field(index=True, description="Sample status after this event applies.") + actor: str | None = Field(default=None, index=True) + payload_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) + + +class SampleTaskEventRow(SQLModel, table=True): + __tablename__ = "sample_task_events" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + sample_id: UUID = Field(foreign_key="samples.id", index=True) + task_id: UUID = Field(index=True) + task_slug: str | None = Field(default=None, index=True) + event_timestamp: datetime = Field(default_factory=_utcnow, index=True, sa_type=TZDateTime) + event_type: str = Field(index=True, description="Typed task graph event name.") + status: str | None = Field(default=None, index=True) + task_snapshot_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) + actor: str | None = Field(default=None, index=True) + payload_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) + + +class SampleEdgeEventRow(SQLModel, table=True): + __tablename__ = "sample_edge_events" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + sample_id: UUID = Field(foreign_key="samples.id", index=True) + edge_id: UUID = Field(index=True) + source_task_id: UUID = Field(index=True) + target_task_id: UUID = Field(index=True) + event_timestamp: datetime = Field(default_factory=_utcnow, index=True, sa_type=TZDateTime) + event_type: str = Field(index=True, description="Typed task-edge event name.") + status: str | None = Field(default=None, index=True) + edge_snapshot_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) + actor: str | None = Field(default=None, index=True) + payload_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) + + +class SampleWorkerEventRow(SQLModel, table=True): + __tablename__ = "sample_worker_events" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + sample_id: UUID = Field(foreign_key="samples.id", index=True) + task_id: UUID | None = Field(default=None, index=True) + worker_slug: str = Field(index=True) + worker_type: str | None = Field(default=None, index=True) + model_target: str | None = Field(default=None, index=True) + worker_snapshot_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) + event_timestamp: datetime = Field(default_factory=_utcnow, index=True, sa_type=TZDateTime) + event_type: str = Field(index=True, description="Typed worker membership event name.") + actor: str | None = Field(default=None, index=True) + payload_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) + + +class SampleEvaluatorEventRow(SQLModel, table=True): + __tablename__ = "sample_evaluator_events" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + sample_id: UUID = Field(foreign_key="samples.id", index=True) + task_id: UUID | None = Field(default=None, index=True) + evaluator_slug: str = Field(index=True) + evaluator_type: str | None = Field(default=None, index=True) + evaluator_snapshot_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) + event_timestamp: datetime = Field(default_factory=_utcnow, index=True, sa_type=TZDateTime) + event_type: str = Field(index=True, description="Typed evaluator membership event name.") + actor: str | None = Field(default=None, index=True) + payload_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) + + +class SampleSandboxEventRow(SQLModel, table=True): + __tablename__ = "sample_sandbox_events" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + sample_id: UUID = Field(foreign_key="samples.id", index=True) + task_id: UUID | None = Field(default=None, index=True) + sandbox_slug: str = Field(index=True) + sandbox_type: str | None = Field(default=None, index=True) + sandbox_snapshot_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) + event_timestamp: datetime = Field(default_factory=_utcnow, index=True, sa_type=TZDateTime) + event_type: str = Field(index=True, description="Typed sandbox membership event name.") + actor: str | None = Field(default=None, index=True) + payload_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) + + +class SampleAnnotationEventRow(SQLModel, table=True): + __tablename__ = "sample_annotation_events" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + sample_id: UUID = Field(foreign_key="samples.id", index=True) + target_type: str = Field(index=True) + target_id: UUID = Field(index=True) + key: str = Field(index=True) + event_type: str = Field(index=True, description="Typed annotation event name.") + event_timestamp: datetime = Field(default_factory=_utcnow, index=True, sa_type=TZDateTime) + payload_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) diff --git a/ergon_core/ergon_core/core/views/dashboard_events/contracts.py b/ergon_core/ergon_core/core/views/dashboard_events/contracts.py index 6174f38cf..278b8cc16 100644 --- a/ergon_core/ergon_core/core/views/dashboard_events/contracts.py +++ b/ergon_core/ergon_core/core/views/dashboard_events/contracts.py @@ -20,7 +20,7 @@ from ergon_core.core.shared.context_parts import ContextEventType, ContextPartChunkLog from ergon_core.core.application.events.base import InngestEventContract from ergon_core.core.application.runtime.status import NodeStatus -from ergon_core.core.application.runtime.models import GraphMutationRecordDto +from ergon_core.core.application.samples.events import SampleRuntimeEventView from pydantic import Field # --------------------------------------------------------------------------- @@ -155,14 +155,14 @@ class DashboardThreadMessageCreatedEvent(InngestEventContract): # --------------------------------------------------------------------------- -# Graph mutation events (dynamic delegation observability) +# Sample runtime WAL events # --------------------------------------------------------------------------- -class DashboardGraphMutationEvent(InngestEventContract): - name: ClassVar[str] = "dashboard/graph.mutation" +class DashboardSampleRuntimeEvent(InngestEventContract): + name: ClassVar[str] = "dashboard/sample.runtime_event" - mutation: GraphMutationRecordDto + event: SampleRuntimeEventView class DashboardContextEventEvent(InngestEventContract): diff --git a/ergon_core/ergon_core/core/views/dashboard_events/graph_mutations.py b/ergon_core/ergon_core/core/views/dashboard_events/graph_mutations.py deleted file mode 100644 index e889e6f78..000000000 --- a/ergon_core/ergon_core/core/views/dashboard_events/graph_mutations.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Dashboard event projections for persisted graph mutation rows.""" - -from typing import cast - -from ergon_core.core.application.runtime.models import ( - GraphMutationRecordDto, - GraphMutationValue, -) -from ergon_core.core.persistence.graph.models import ( - GraphTargetType, - MutationType, - SampleGraphMutation, -) -from ergon_core.core.persistence.shared.types import SampleId -from ergon_core.core.views.dashboard_events.contracts import DashboardGraphMutationEvent - - -def graph_mutation_record_from_row(row: SampleGraphMutation) -> GraphMutationRecordDto: - return GraphMutationRecordDto( - id=row.id, - sample_id=cast(SampleId, row.sample_id), - sequence=row.sequence, - mutation_type=cast(MutationType, row.mutation_type), - target_type=cast(GraphTargetType, row.target_type), - target_id=row.target_id, - actor=row.actor, - old_value=cast(GraphMutationValue | None, dict(row.old_value)) if row.old_value else None, - new_value=cast(GraphMutationValue, dict(row.new_value)), - reason=row.reason, - created_at=row.created_at, - ) - - -def dashboard_graph_mutation_event_from_row( - row: SampleGraphMutation, -) -> DashboardGraphMutationEvent: - return DashboardGraphMutationEvent(mutation=graph_mutation_record_from_row(row)) diff --git a/ergon_core/ergon_core/core/views/samples/service.py b/ergon_core/ergon_core/core/views/samples/service.py index 12b57d3f7..03f3016a4 100644 --- a/ergon_core/ergon_core/core/views/samples/service.py +++ b/ergon_core/ergon_core/core/views/samples/service.py @@ -17,9 +17,12 @@ ) from ergon_core.core.persistence.graph.models import ( SampleGraphEdge, - SampleGraphMutation, SampleGraphNode, ) +from ergon_core.core.application.samples.events import ( + SampleRuntimeEventReadService, + SampleRuntimeEventView, +) from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import SampleStatus from ergon_core.core.persistence.telemetry.models import ( @@ -34,7 +37,6 @@ EvaluationScoreSummary, EvaluationService, ) -from ergon_core.core.application.runtime.models import GraphMutationRecordDto from ergon_core.core.views.samples.snapshot import ( _build_communication_threads, _build_task_map, @@ -46,7 +48,6 @@ _task_timestamps, ) from ergon_core.core.views.resources import require_viewable_resource_size -from ergon_core.core.views.dashboard_events.graph_mutations import graph_mutation_record_from_row from ergon_core.core.views.samples.metrics import aggregate_run_metrics, observed_cost_from_summary from pydantic import BaseModel from sqlmodel import Session, col, select @@ -252,20 +253,12 @@ def build_snapshot(self, sample_id: UUID) -> SampleSnapshotDto | None: error=run.error_message, ) - def list_mutations(self, sample_id: UUID) -> list[GraphMutationRecordDto] | None: + def list_events(self, sample_id: UUID) -> list[SampleRuntimeEventView] | None: with get_session() as session: run = session.get(SampleRecord, sample_id) if run is None: return None - mutations = list( - session.exec( - select(SampleGraphMutation) - .where(SampleGraphMutation.sample_id == sample_id) - .order_by(col(SampleGraphMutation.sequence)) - ).all() - ) - - return [graph_mutation_record_from_row(m) for m in mutations] + return SampleRuntimeEventReadService().list_events(session, sample_id) def get_resource_blob(self, sample_id: UUID, resource_id: UUID) -> SampleResourceBlob | None: with get_session() as session: diff --git a/ergon_core/ergon_core/test_support/e2e_read_helpers.py b/ergon_core/ergon_core/test_support/e2e_read_helpers.py index b52c5fb87..d8c6a1976 100644 --- a/ergon_core/ergon_core/test_support/e2e_read_helpers.py +++ b/ergon_core/ergon_core/test_support/e2e_read_helpers.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path +from typing import Any, Literal, Mapping from uuid import UUID from ergon_core.core.persistence.graph.models import SampleGraphNode @@ -16,6 +17,7 @@ SandboxCommandWalEntry, SandboxEvent, ) +from pydantic import BaseModel, ConfigDict from sqlmodel import select @@ -52,6 +54,108 @@ class SandboxEventSnapshot: kind: str +class ObservedSampleRuntimeEvent(BaseModel): + model_config = ConfigDict(frozen=True) + + id: UUID + event_timestamp: datetime + event_type: str + event_table: Literal[ + "sample_status_events", + "sample_task_events", + "sample_edge_events", + "sample_worker_events", + "sample_evaluator_events", + "sample_sandbox_events", + "sample_annotation_events", + ] + task_slug: str | None = None + source_task_slug: str | None = None + target_task_slug: str | None = None + status: str | None = None + worker_slug: str | None = None + evaluator_slug: str | None = None + sandbox_slug: str | None = None + annotation_key: str | None = None + + +class ObservedSampleRuntimeEventStream(BaseModel): + model_config = ConfigDict(frozen=True) + + ordered_events: tuple[ObservedSampleRuntimeEvent, ...] + status_sequence: tuple[str, ...] + task_added_slugs: tuple[str, ...] + task_terminal_status_by_slug: dict[str, str] + edge_added_pairs: tuple[tuple[str, str], ...] + worker_added_by_task_slug: dict[str, str] + evaluator_added_by_task_slug: dict[str, tuple[str, ...]] + sandbox_added_by_task_slug: dict[str, str] + annotation_keys_by_task_slug: dict[str, tuple[str, ...]] + + @classmethod + def from_ordered_events( + cls, + ordered_events: tuple[ObservedSampleRuntimeEvent, ...], + ) -> "ObservedSampleRuntimeEventStream": + task_terminal_status_by_slug: dict[str, str] = {} + worker_added_by_task_slug: dict[str, str] = {} + evaluator_added_by_task_slug: dict[str, list[str]] = {} + sandbox_added_by_task_slug: dict[str, str] = {} + annotation_keys_by_task_slug: dict[str, list[str]] = {} + + for event in ordered_events: + if event.event_type == "task.status_changed" and event.task_slug and event.status: + task_terminal_status_by_slug[event.task_slug] = event.status + if event.event_type == "worker.added" and event.task_slug and event.worker_slug: + worker_added_by_task_slug[event.task_slug] = event.worker_slug + if event.event_type == "evaluator.added" and event.task_slug and event.evaluator_slug: + evaluator_added_by_task_slug.setdefault(event.task_slug, []).append( + event.evaluator_slug + ) + if event.event_type == "sandbox.added" and event.task_slug and event.sandbox_slug: + sandbox_added_by_task_slug[event.task_slug] = event.sandbox_slug + if ( + event.event_type.startswith("annotation.") + and event.task_slug + and event.annotation_key + ): + annotation_keys_by_task_slug.setdefault(event.task_slug, []).append( + event.annotation_key + ) + + return cls( + ordered_events=ordered_events, + status_sequence=tuple( + event.status + for event in ordered_events + if event.event_type == "sample.status_changed" and event.status + ), + task_added_slugs=tuple( + event.task_slug + for event in ordered_events + if event.event_type == "task.added" and event.task_slug + ), + task_terminal_status_by_slug=task_terminal_status_by_slug, + edge_added_pairs=tuple( + (event.source_task_slug, event.target_task_slug) + for event in ordered_events + if event.event_type == "edge.added" + and event.source_task_slug + and event.target_task_slug + ), + worker_added_by_task_slug=worker_added_by_task_slug, + evaluator_added_by_task_slug={ + task_slug: tuple(evaluator_slugs) + for task_slug, evaluator_slugs in evaluator_added_by_task_slug.items() + }, + sandbox_added_by_task_slug=sandbox_added_by_task_slug, + annotation_keys_by_task_slug={ + task_slug: tuple(annotation_keys) + for task_slug, annotation_keys in annotation_keys_by_task_slug.items() + }, + ) + + def _resource_snapshot(row: SampleResource) -> ResourceSnapshot: return ResourceSnapshot( name=row.name, @@ -155,6 +259,90 @@ def list_sandbox_events(sample_id: UUID) -> list[SandboxEventSnapshot]: return [SandboxEventSnapshot(sandbox_id=row.sandbox_id, kind=row.kind) for row in rows] +def row_to_observed_sample_runtime_event( + row: Any, # slopcop: ignore[no-typing-any] + *, + task_slug_by_id: Mapping[UUID, str], +) -> ObservedSampleRuntimeEvent: + # slopcop: ignore[no-hasattr-getattr] + payload = _json_mapping(getattr(row, "payload_json", {})) + # slopcop: ignore[no-hasattr-getattr] + source_task_id = getattr(row, "source_task_id", None) + # slopcop: ignore[no-hasattr-getattr] + target_task_id = getattr(row, "target_task_id", None) + # slopcop: ignore[no-hasattr-getattr] + annotation_key = getattr(row, "key", None) + return ObservedSampleRuntimeEvent( + id=row.id, + event_timestamp=row.event_timestamp, + event_table=row.__tablename__, + event_type=row.event_type, + task_slug=_task_slug(row, payload, task_slug_by_id), + source_task_slug=task_slug_by_id.get(source_task_id), + target_task_slug=task_slug_by_id.get(target_task_id), + status=_string_attr_or_payload(row, payload, "status"), + worker_slug=_slug_attr_or_payload(row, payload, "worker"), + evaluator_slug=_slug_attr_or_payload(row, payload, "evaluator"), + sandbox_slug=_slug_attr_or_payload(row, payload, "sandbox"), + annotation_key=annotation_key, + ) + + +def read_sample_runtime_event_stream(sample_id: UUID) -> ObservedSampleRuntimeEventStream: + ( + SampleStatusEventRow, + SampleTaskEventRow, + SampleEdgeEventRow, + SampleWorkerEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleAnnotationEventRow, + ) = _sample_runtime_event_row_classes() + + with get_session() as session: + task_rows = list( + session.exec( + select(SampleTaskEventRow) + .where(SampleTaskEventRow.sample_id == sample_id) + .order_by(SampleTaskEventRow.event_timestamp, SampleTaskEventRow.id) + ).all(), + ) + task_slug_by_id = _task_slug_by_id_from_task_events(task_rows) + event_rows = ( + session.exec( + select(SampleStatusEventRow).where(SampleStatusEventRow.sample_id == sample_id) + ).all(), + task_rows, + session.exec( + select(SampleEdgeEventRow).where(SampleEdgeEventRow.sample_id == sample_id) + ).all(), + session.exec( + select(SampleWorkerEventRow).where(SampleWorkerEventRow.sample_id == sample_id) + ).all(), + session.exec( + select(SampleEvaluatorEventRow).where( + SampleEvaluatorEventRow.sample_id == sample_id + ) + ).all(), + session.exec( + select(SampleSandboxEventRow).where(SampleSandboxEventRow.sample_id == sample_id) + ).all(), + session.exec( + select(SampleAnnotationEventRow).where( + SampleAnnotationEventRow.sample_id == sample_id + ) + ).all(), + ) + + events = [ + row_to_observed_sample_runtime_event(row, task_slug_by_id=task_slug_by_id) + for rows in event_rows + for row in rows + ] + ordered_events = tuple(sorted(events, key=lambda event: (event.event_timestamp, event.id))) + return ObservedSampleRuntimeEventStream.from_ordered_events(ordered_events) + + def leaf_execution_timings_by_slug(sample_id: UUID) -> dict[str, TaskExecutionSnapshot | None]: with get_session() as session: leaves = list( @@ -176,3 +364,100 @@ def leaf_execution_timings_by_slug(sample_id: UUID) -> dict[str, TaskExecutionSn by_task = {execution.task_id: _execution_snapshot(execution) for execution in executions} return {leaf.task_slug: by_task.get(leaf.task_id) for leaf in leaves} + + +def _sample_runtime_event_row_classes() -> tuple[type[Any], ...]: # slopcop: ignore[no-typing-any] + from ergon_core.core.persistence.samples.models import ( + SampleAnnotationEventRow, + SampleEdgeEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleStatusEventRow, + SampleTaskEventRow, + SampleWorkerEventRow, + ) + + return ( + SampleStatusEventRow, + SampleTaskEventRow, + SampleEdgeEventRow, + SampleWorkerEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleAnnotationEventRow, + ) + + +def _task_slug_by_id_from_task_events( + task_rows: list[Any], +) -> dict[UUID, str]: # slopcop: ignore[no-typing-any] + task_slug_by_id: dict[UUID, str] = {} + for row in task_rows: + if row.event_type != "task.added": + continue + # slopcop: ignore[no-hasattr-getattr] + payload = _json_mapping(getattr(row, "payload_json", {})) + task_slug = _task_slug(row, payload, task_slug_by_id) + if task_slug: + task_slug_by_id[row.task_id] = task_slug + return task_slug_by_id + + +def _task_slug( + row: Any, # slopcop: ignore[no-typing-any] + payload: Mapping[str, Any], # slopcop: ignore[no-typing-any] + task_slug_by_id: Mapping[UUID, str], +) -> str | None: + # slopcop: ignore[no-hasattr-getattr] + task_id = getattr(row, "task_id", None) + if task_id is None: + # slopcop: ignore[no-hasattr-getattr] + task_id = getattr(row, "target_id", None) + return ( + task_slug_by_id.get(task_id) + or _string_attr_or_payload(row, payload, "task_slug") + or _string_payload(payload, "task_key") + or _string_payload(payload, "target_key") + ) + + +def _slug_attr_or_payload( + row: Any, # slopcop: ignore[no-typing-any] + payload: Mapping[str, Any], # slopcop: ignore[no-typing-any] + name: str, +) -> str | None: + attr_value = getattr(row, f"{name}_slug", None) # slopcop: ignore[no-hasattr-getattr] + if isinstance(attr_value, str): + return attr_value + direct = _string_payload(payload, f"{name}_slug") + if direct is not None: + return direct + nested = payload.get(name) + if isinstance(nested, Mapping): + return _string_payload(nested, "slug") + snapshot = getattr(row, f"{name}_snapshot_json", None) # slopcop: ignore[no-hasattr-getattr] + if isinstance(snapshot, Mapping): + return _string_payload(snapshot, "slug") + return None + + +def _string_attr_or_payload( + row: Any, # slopcop: ignore[no-typing-any] + payload: Mapping[str, Any], # slopcop: ignore[no-typing-any] + name: str, +) -> str | None: + attr_value = getattr(row, name, None) # slopcop: ignore[no-hasattr-getattr] + if isinstance(attr_value, str): + return attr_value + return _string_payload(payload, name) + + +def _string_payload( + payload: Mapping[str, Any], key: str +) -> str | None: # slopcop: ignore[no-typing-any] + value = payload.get(key) + return value if isinstance(value, str) else None + + +def _json_mapping(value: object) -> Mapping[str, Any]: # slopcop: ignore[no-typing-any] + return value if isinstance(value, Mapping) else {} diff --git a/ergon_core/migrations/env.py b/ergon_core/migrations/env.py index 7a7767700..5648ba02f 100644 --- a/ergon_core/migrations/env.py +++ b/ergon_core/migrations/env.py @@ -8,6 +8,7 @@ import ergon_core.core.persistence.definitions.models import ergon_core.core.persistence.graph.models +import ergon_core.core.persistence.samples.models import ergon_core.core.persistence.telemetry.models from alembic import context from ergon_core.core.shared.settings import Settings diff --git a/ergon_core/migrations/versions/00000000_initial_v2.py b/ergon_core/migrations/versions/00000000_initial_v2.py index d32736cc8..969bd08ce 100644 --- a/ergon_core/migrations/versions/00000000_initial_v2.py +++ b/ergon_core/migrations/versions/00000000_initial_v2.py @@ -15,6 +15,7 @@ "ergon_core.core.persistence.context.models", "ergon_core.core.persistence.definitions.models", "ergon_core.core.persistence.graph.models", + "ergon_core.core.persistence.samples.models", "ergon_core.core.persistence.telemetry.models", ): import_module(module_name) diff --git a/ergon_core/tests/unit/architecture/test_application_domain_boundaries.py b/ergon_core/tests/unit/architecture/test_application_domain_boundaries.py index 3b5b0e1b9..aded6f5ed 100644 --- a/ergon_core/tests/unit/architecture/test_application_domain_boundaries.py +++ b/ergon_core/tests/unit/architecture/test_application_domain_boundaries.py @@ -26,7 +26,8 @@ "task_execution", "task_inspection", "task_management", - } + }, + "samples": {"events", "state"}, } APPROVED_DOMAIN_FILES = { "__init__.py", @@ -71,6 +72,7 @@ "workflow_errors.py", "workflow_models.py", }, + "samples": {"events.py", "state.py"}, "testing": {"suppression_budget.py", "test_harness_service.py"}, } LAYOUT_DIR_EXCEPTIONS: dict[str, set[str]] = {} diff --git a/ergon_core/tests/unit/architecture/test_core_schema_sources.py b/ergon_core/tests/unit/architecture/test_core_schema_sources.py index c78757a0d..d34624583 100644 --- a/ergon_core/tests/unit/architecture/test_core_schema_sources.py +++ b/ergon_core/tests/unit/architecture/test_core_schema_sources.py @@ -88,10 +88,10 @@ def test_core_schema_source_imports_are_directional() -> None: forbidden_pairs = { "ergon_core.core.views.samples.models": ( "EvalCriterionStatus = Literal", - "GraphMutationValue =", + "SampleRuntimeEventRow =", ), "ergon_core.core.views.dashboard_events.contracts": ( - "GraphMutationValue =", + "SampleRuntimeEventRow =", "CancelCause = Literal", ), } diff --git a/ergon_core/tests/unit/architecture/test_model_field_descriptions.py b/ergon_core/tests/unit/architecture/test_model_field_descriptions.py index dbf3e13fe..5c1225101 100644 --- a/ergon_core/tests/unit/architecture/test_model_field_descriptions.py +++ b/ergon_core/tests/unit/architecture/test_model_field_descriptions.py @@ -10,18 +10,19 @@ UserMessagePart, ) from ergon_core.core.persistence.context.models import SampleContextEvent -from ergon_core.core.persistence.graph.models import ( - SampleGraphAnnotation, - SampleGraphMutation, - SampleGraphNode, +from ergon_core.core.persistence.graph.models import SampleGraphNode +from ergon_core.core.persistence.samples.models import ( + SampleAnnotationEventRow, + SampleEdgeEventRow, + SampleStatusEventRow, + SampleTaskEventRow, ) from ergon_core.core.persistence.telemetry.models import SampleRecord, SampleResource from ergon_core.core.application.runtime.models import ( - GraphAnnotationDto, GraphEdgeDto, - GraphMutationRecordDto, GraphNodeDto, ) +from ergon_core.core.application.samples.events import SampleRuntimeEventView from ergon_builtins.benchmarks.swebench_verified.task_schemas import ( SWEBenchInstance, SWEBenchTaskPayload, @@ -56,10 +57,7 @@ def test_dashboard_context_event_field_docs_are_schema_metadata() -> None: def test_graph_dto_field_docs_are_schema_metadata() -> None: assert _description(GraphNodeDto, "status") assert _description(GraphEdgeDto, "status") - assert _description(GraphAnnotationDto, "id") - assert _description(GraphAnnotationDto, "target_id") - assert _description(GraphMutationRecordDto, "id") - assert _description(GraphMutationRecordDto, "target_id") + assert _description(SampleRuntimeEventView, "payload") def test_sqlmodel_field_docs_are_schema_metadata() -> None: @@ -71,9 +69,10 @@ def test_sqlmodel_field_docs_are_schema_metadata() -> None: assert _description(SampleGraphNode, "level") assert _description(SampleContextEvent, "event_type") assert _description(SampleContextEvent, "payload") - assert _description(SampleGraphAnnotation, "target_type") - assert _description(SampleGraphMutation, "mutation_type") - assert _description(SampleGraphMutation, "target_type") + assert _description(SampleStatusEventRow, "event_type") + assert _description(SampleTaskEventRow, "event_type") + assert _description(SampleEdgeEventRow, "event_type") + assert _description(SampleAnnotationEventRow, "event_type") assert "Canonical runtime" in (_description(SampleRecord, "definition_id") or "") assert "Optional v2 experiment grouping tag" in (_description(SampleRecord, "experiment") or "") assert "Compatibility/display-only" in (_description(SampleRecord, "worker_team_json") or "") diff --git a/ergon_core/tests/unit/architecture/test_public_api_boundaries.py b/ergon_core/tests/unit/architecture/test_public_api_boundaries.py index 7cad1a732..9dc05ff19 100644 --- a/ergon_core/tests/unit/architecture/test_public_api_boundaries.py +++ b/ergon_core/tests/unit/architecture/test_public_api_boundaries.py @@ -546,7 +546,6 @@ def test_sandbox_dashboard_tracing_and_dependencies_stay_in_infrastructure() -> core_root / "infrastructure" / "dashboard" / "emitter.py", core_root / "infrastructure" / "dashboard" / "provider.py", core_root / "views" / "dashboard_events" / "contracts.py", - core_root / "views" / "dashboard_events" / "graph_mutations.py", core_root / "views" / "dashboard_events" / "context_events.py", core_root / "infrastructure" / "tracing" / "__init__.py", core_root / "infrastructure" / "tracing" / "attributes.py", diff --git a/ergon_core/tests/unit/architecture/test_repository_layer_conventions.py b/ergon_core/tests/unit/architecture/test_repository_layer_conventions.py index 211cee630..51afb1eaf 100644 --- a/ergon_core/tests/unit/architecture/test_repository_layer_conventions.py +++ b/ergon_core/tests/unit/architecture/test_repository_layer_conventions.py @@ -117,7 +117,7 @@ def test_repository_file_is_singular(cls: type) -> None: def _takes_session_first(method: object) -> bool: """A method is a 'data-access method' iff its first non-self positional parameter is named `session`. Configuration setters like - `add_mutation_listener(self, listener)` are deliberately exempt + `add_runtime_event_listener(self, listener)` are deliberately exempt from the session-first and writes-are-async rules; they aren't data access.""" @@ -131,7 +131,7 @@ def test_public_data_access_methods_take_session_first(cls: type) -> None: """Every public data-access method takes `session` first. A method is *not* a data-access method if it never accepts a - session at all (e.g. `add_mutation_listener(self, listener)`). + session at all (e.g. `add_runtime_event_listener(self, listener)`). Those methods are exempt — the rule applies only to the methods that actually read or write the database. """ diff --git a/ergon_core/tests/unit/architecture/test_typed_sample_wal_boundaries.py b/ergon_core/tests/unit/architecture/test_typed_sample_wal_boundaries.py new file mode 100644 index 000000000..a5b6e8be1 --- /dev/null +++ b/ergon_core/tests/unit/architecture/test_typed_sample_wal_boundaries.py @@ -0,0 +1,43 @@ +from pathlib import Path + +from sqlmodel import SQLModel + + +ROOT = Path(__file__).resolve().parents[4] +CORE_ROOT = ROOT / "ergon_core" / "ergon_core" + + +def test_typed_sample_wal_uses_separate_tables_without_component_interner() -> None: + import ergon_core.core.persistence.samples.models # noqa: F401 + + expected_tables = { + "sample_status_events", + "sample_task_events", + "sample_edge_events", + "sample_worker_events", + "sample_evaluator_events", + "sample_sandbox_events", + "sample_annotation_events", + } + + assert expected_tables.issubset(SQLModel.metadata.tables) + assert "sample_events" not in SQLModel.metadata.tables + assert "sample_component_interners" not in SQLModel.metadata.tables + assert "sample_runtime_components" not in SQLModel.metadata.tables + + +def test_sample_application_dtos_do_not_import_dashboard_or_http_layers() -> None: + samples_app_root = CORE_ROOT / "core" / "application" / "samples" + + offenders: list[str] = [] + for path in samples_app_root.rglob("*.py"): + text = path.read_text(encoding="utf-8") + for forbidden in ( + "core.views.dashboard_events", + "core.infrastructure.http", + "core.views.samples", + ): + if forbidden in text: + offenders.append(f"{path.relative_to(ROOT)} imports {forbidden}") + + assert offenders == [] diff --git a/ergon_core/tests/unit/core/application/tasks/test_spawn_dynamic_task_dispatch.py b/ergon_core/tests/unit/core/application/tasks/test_spawn_dynamic_task_dispatch.py index efd49ebdf..342fa79e8 100644 --- a/ergon_core/tests/unit/core/application/tasks/test_spawn_dynamic_task_dispatch.py +++ b/ergon_core/tests/unit/core/application/tasks/test_spawn_dynamic_task_dispatch.py @@ -29,7 +29,7 @@ def __init__(self) -> None: self.added_edges: list[dict] = [] self.parent = SimpleNamespace(task_id=uuid4(), instance_key="sample-1", level=2) - def add_mutation_listener(self, listener) -> None: + def add_runtime_event_listener(self, listener) -> None: del listener def get_node(self, session, *, sample_id, task_id): diff --git a/ergon_core/tests/unit/rest_api/test_test_harness.py b/ergon_core/tests/unit/rest_api/test_test_harness.py index b91cf20be..8df42e868 100644 --- a/ergon_core/tests/unit/rest_api/test_test_harness.py +++ b/ergon_core/tests/unit/rest_api/test_test_harness.py @@ -61,7 +61,7 @@ def test_read_state_dto_exposes_live_playwright_contract_fields() -> None: assert { "executions", "execution_count", - "mutation_count", + "event_count", "resource_count", "thread_count", "context_event_count", diff --git a/ergon_core/tests/unit/runtime/test_graph_mutation_contracts.py b/ergon_core/tests/unit/runtime/test_graph_mutation_contracts.py index e1982de3f..946d60414 100644 --- a/ergon_core/tests/unit/runtime/test_graph_mutation_contracts.py +++ b/ergon_core/tests/unit/runtime/test_graph_mutation_contracts.py @@ -1,95 +1,31 @@ from uuid import uuid4 -from ergon_core.core.application.runtime.models import ( - EdgeAddedMutation, - GraphMutationRecordDto, - GraphMutationValue, -) -from ergon_core.core.persistence.graph.models import SampleGraphMutation -from ergon_core.core.views.dashboard_events.contracts import DashboardGraphMutationEvent -from ergon_core.core.views.dashboard_events.graph_mutations import ( - dashboard_graph_mutation_event_from_row, - graph_mutation_record_from_row, -) -from pydantic import TypeAdapter +from ergon_core.core.application.samples.events import sample_runtime_event_from_row +from ergon_core.core.persistence.samples.models import SampleEdgeEventRow +from ergon_core.core.views.dashboard_events.contracts import DashboardSampleRuntimeEvent -def test_rest_and_dashboard_mutations_share_graph_mutation_record_payloads() -> None: +def test_rest_and_dashboard_events_share_typed_sample_wal_payloads() -> None: sample_id = uuid4() - mutation_id = uuid4() edge_id = uuid4() source_id = uuid4() target_id = uuid4() - - payload = EdgeAddedMutation( + row = SampleEdgeEventRow( + sample_id=sample_id, + edge_id=edge_id, source_task_id=source_id, target_task_id=target_id, + event_type="edge.added", status="pending", + payload_json={"status": "pending"}, ) - TypeAdapter(GraphMutationValue).validate_python(payload.model_dump(mode="json")) - - record = GraphMutationRecordDto( - id=mutation_id, - sample_id=sample_id, - sequence=1, - mutation_type="edge.added", - target_type="edge", - target_id=edge_id, - actor="test", - old_value=None, - new_value=payload, - reason=None, - created_at="2026-04-28T00:00:00Z", - ) - dashboard = DashboardGraphMutationEvent( - mutation=record, - ) + dto = sample_runtime_event_from_row(row) + event = DashboardSampleRuntimeEvent(event=dto) - assert dashboard.mutation == record - assert record.new_value == payload - - data = dashboard.model_dump(mode="json") - edge_value = data["mutation"]["new_value"] - assert edge_value["source_task_id"] == str(source_id) - assert edge_value["target_task_id"] == str(target_id) - assert "source_node_id" not in edge_value - assert "target_node_id" not in edge_value - - -def test_graph_mutation_row_mapper_is_shared_by_rest_and_dashboard_events() -> None: - sample_id = uuid4() - mutation_id = uuid4() - edge_id = uuid4() - source_id = uuid4() - target_id = uuid4() - row = SampleGraphMutation( - id=mutation_id, - sample_id=sample_id, - sequence=7, - mutation_type="edge.added", - target_type="edge", - target_id=edge_id, - actor="manager", - old_value=None, - new_value={ - "mutation_type": "edge.added", - "source_task_id": str(source_id), - "target_task_id": str(target_id), - "status": "pending", - }, - reason="spawn dependency", - created_at="2026-04-28T00:00:00Z", - ) - - record = graph_mutation_record_from_row(row) - event = dashboard_graph_mutation_event_from_row(row) - - assert event == DashboardGraphMutationEvent(mutation=record) - assert record.id == mutation_id - assert record.sample_id == sample_id - assert record.new_value == EdgeAddedMutation( - source_task_id=source_id, - target_task_id=target_id, - status="pending", - ) + assert event.event.table == "sample_edge_events" + assert event.event.event_type == "edge.added" + assert event.event.target_id == edge_id + data = event.model_dump(mode="json") + assert data["event"]["payload"]["source_task_id"] == str(source_id) + assert data["event"]["payload"]["target_task_id"] == str(target_id) diff --git a/ergon_core/tests/unit/runtime/test_sample_annotation_events.py b/ergon_core/tests/unit/runtime/test_sample_annotation_events.py new file mode 100644 index 000000000..3349c1fbd --- /dev/null +++ b/ergon_core/tests/unit/runtime/test_sample_annotation_events.py @@ -0,0 +1,64 @@ +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from sqlmodel import SQLModel, Session, create_engine, select + +from ergon_core.core.persistence.definitions.models import ExperimentDefinition # noqa: F401 +from ergon_core.core.persistence.samples.models import SampleAnnotationEventRow +from ergon_core.core.persistence.telemetry.models import SampleRecord # noqa: F401 +from ergon_core.core.application.samples.events import SampleRuntimeEventAppender + + +@pytest.fixture() +def session() -> Session: + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + yield session + + +def test_annotation_set_update_and_delete_use_annotation_wal(session: Session) -> None: + sample_id = uuid4() + task_id = uuid4() + appender = SampleRuntimeEventAppender(session) + + base_time = datetime(2026, 5, 25, 12, 0, tzinfo=UTC) + + for index, (event_type, payload) in enumerate( + ( + ("annotation.set", {"value": {"label": "important"}}), + ("annotation.updated", {"value": {"label": "routine"}}), + ("annotation.deleted", {}), + ) + ): + appender.append_annotation_event( + SampleAnnotationEventRow( + sample_id=sample_id, + event_timestamp=base_time + timedelta(seconds=index), + target_type="task", + target_id=task_id, + key="review", + event_type=event_type, + payload_json=payload, + ) + ) + + rows = session.exec( + select(SampleAnnotationEventRow).order_by( + SampleAnnotationEventRow.event_timestamp, + SampleAnnotationEventRow.id, + ) + ).all() + + assert [row.event_type for row in rows] == [ + "annotation.set", + "annotation.updated", + "annotation.deleted", + ] + assert [row.key for row in rows] == ["review", "review", "review"] + + +def test_annotation_events_are_not_a_generic_sample_events_table() -> None: + assert SampleAnnotationEventRow.__tablename__ == "sample_annotation_events" + assert "sample_events" not in SQLModel.metadata.tables diff --git a/ergon_core/tests/unit/runtime/test_sample_state_replay.py b/ergon_core/tests/unit/runtime/test_sample_state_replay.py new file mode 100644 index 000000000..7751e8420 --- /dev/null +++ b/ergon_core/tests/unit/runtime/test_sample_state_replay.py @@ -0,0 +1,200 @@ +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest +from sqlmodel import SQLModel, Session, create_engine + +from ergon_core.core.persistence.definitions.models import ExperimentDefinition # noqa: F401 +from ergon_core.core.persistence.samples.models import ( + SampleAnnotationEventRow, + SampleEdgeEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleStatusEventRow, + SampleTaskEventRow, + SampleWorkerEventRow, +) +from ergon_core.core.persistence.telemetry.models import SampleRecord # noqa: F401 +from ergon_core.core.application.samples.events import SampleRuntimeEventAppender +from ergon_core.core.application.samples.state import reconstruct_sample_runtime_state_at + + +@pytest.fixture() +def session() -> Session: + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + yield session + + +def test_replay_reconstructs_state_from_ordered_typed_events(session: Session) -> None: + sample_id = uuid4() + task_id = uuid4() + second_task_id = uuid4() + edge_id = UUID(int=40) + base_time = datetime(2026, 5, 25, 12, 0, tzinfo=UTC) + appender = SampleRuntimeEventAppender(session) + + appender.append_status_event( + SampleStatusEventRow( + id=UUID(int=1), + sample_id=sample_id, + event_timestamp=base_time, + event_type="sample.status_changed", + status="pending", + ) + ) + appender.append_task_event( + SampleTaskEventRow( + id=UUID(int=2), + sample_id=sample_id, + event_timestamp=base_time, + event_type="task.added", + task_id=task_id, + task_slug="root", + status="pending", + task_snapshot_json={"slug": "root"}, + ) + ) + appender.append_worker_event( + SampleWorkerEventRow( + id=UUID(int=3), + sample_id=sample_id, + event_timestamp=base_time, + event_type="worker.added", + task_id=task_id, + worker_slug="root-worker", + worker_snapshot_json={"slug": "root-worker"}, + ) + ) + appender.append_task_event( + SampleTaskEventRow( + id=UUID(int=4), + sample_id=sample_id, + event_timestamp=base_time + timedelta(seconds=1), + event_type="task.added", + task_id=second_task_id, + task_slug="child", + status="pending", + task_snapshot_json={"slug": "child"}, + ) + ) + appender.append_edge_event( + SampleEdgeEventRow( + id=UUID(int=5), + edge_id=edge_id, + sample_id=sample_id, + event_timestamp=base_time + timedelta(seconds=1), + event_type="edge.added", + source_task_id=task_id, + target_task_id=second_task_id, + status="pending", + edge_snapshot_json={"status": "pending"}, + ) + ) + appender.append_evaluator_event( + SampleEvaluatorEventRow( + id=UUID(int=6), + sample_id=sample_id, + event_timestamp=base_time + timedelta(seconds=2), + event_type="evaluator.added", + task_id=task_id, + evaluator_slug="default", + evaluator_snapshot_json={"slug": "default"}, + ) + ) + appender.append_sandbox_event( + SampleSandboxEventRow( + id=UUID(int=7), + sample_id=sample_id, + event_timestamp=base_time + timedelta(seconds=2), + event_type="sandbox.added", + task_id=task_id, + sandbox_slug="ubuntu", + sandbox_snapshot_json={"slug": "ubuntu"}, + ) + ) + appender.append_annotation_event( + SampleAnnotationEventRow( + id=UUID(int=8), + sample_id=sample_id, + event_timestamp=base_time + timedelta(seconds=3), + event_type="annotation.set", + target_type="task", + target_id=task_id, + key="review", + payload_json={"value": {"label": "important"}}, + ) + ) + appender.append_task_event( + SampleTaskEventRow( + id=UUID(int=9), + sample_id=sample_id, + event_timestamp=base_time + timedelta(seconds=4), + event_type="task.status_changed", + task_id=task_id, + task_slug="root", + status="completed", + payload_json={"status": "completed"}, + ) + ) + appender.append_status_event( + SampleStatusEventRow( + id=UUID(int=10), + sample_id=sample_id, + event_timestamp=base_time + timedelta(seconds=5), + event_type="sample.status_changed", + status="completed", + ) + ) + + state = reconstruct_sample_runtime_state_at(session, sample_id=sample_id) + + assert state.status == "completed" + assert state.tasks[task_id].task_slug == "root" + assert state.tasks[task_id].status == "completed" + assert state.tasks[task_id].task_snapshot_json == {"slug": "root"} + assert state.edges[edge_id].source_task_id == task_id + assert state.workers[task_id].worker_snapshot_json == {"slug": "root-worker"} + assert state.evaluators_by_task_id[task_id][0].evaluator_slug == "default" + assert state.sandboxes[task_id].sandbox_slug == "ubuntu" + assert state.annotations[("task", task_id, "review")].payload_json == { + "value": {"label": "important"} + } + + +def test_replay_can_stop_at_timestamp_slice(session: Session) -> None: + sample_id = uuid4() + task_id = uuid4() + base_time = datetime(2026, 5, 25, 12, 0, tzinfo=UTC) + appender = SampleRuntimeEventAppender(session) + + appender.append_task_event( + SampleTaskEventRow( + sample_id=sample_id, + event_timestamp=base_time, + event_type="task.added", + task_id=task_id, + task_slug="root", + status="pending", + ) + ) + appender.append_task_event( + SampleTaskEventRow( + sample_id=sample_id, + event_timestamp=base_time + timedelta(seconds=1), + event_type="task.removed", + task_id=task_id, + task_slug="root", + ) + ) + + before_remove = reconstruct_sample_runtime_state_at( + session, + sample_id=sample_id, + at=base_time, + ) + after_remove = reconstruct_sample_runtime_state_at(session, sample_id=sample_id) + + assert task_id in before_remove.tasks + assert task_id not in after_remove.tasks diff --git a/ergon_core/tests/unit/runtime/test_typed_sample_wal.py b/ergon_core/tests/unit/runtime/test_typed_sample_wal.py new file mode 100644 index 000000000..d8c15c9f0 --- /dev/null +++ b/ergon_core/tests/unit/runtime/test_typed_sample_wal.py @@ -0,0 +1,142 @@ +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from sqlmodel import SQLModel, Session, create_engine, select + +from ergon_core.core.persistence.definitions.models import ExperimentDefinition # noqa: F401 +from ergon_core.core.persistence.samples.models import ( + SampleEdgeEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleStatusEventRow, + SampleTaskEventRow, + SampleWorkerEventRow, +) +from ergon_core.core.persistence.telemetry.models import SampleRecord # noqa: F401 +from ergon_core.core.application.samples.events import SampleRuntimeEventAppender + + +@pytest.fixture() +def session() -> Session: + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + yield session + + +def test_typed_sample_wal_rows_have_distinct_tables_and_common_ordering_columns() -> None: + expected_tables = { + "sample_status_events": SampleStatusEventRow, + "sample_task_events": SampleTaskEventRow, + "sample_edge_events": SampleEdgeEventRow, + "sample_worker_events": SampleWorkerEventRow, + "sample_evaluator_events": SampleEvaluatorEventRow, + "sample_sandbox_events": SampleSandboxEventRow, + } + + for table_name, row_type in expected_tables.items(): + assert row_type.__tablename__ == table_name + columns = row_type.__table__.columns + assert "id" in columns + assert "event_timestamp" in columns + assert "event_type" in columns + assert "sample_id" in columns + assert any( + foreign_key.column.table.name == "samples" and foreign_key.column.name == "id" + for foreign_key in columns["sample_id"].foreign_keys + ) + + assert "payload_json" in SampleStatusEventRow.__table__.columns + assert "task_snapshot_json" in SampleTaskEventRow.__table__.columns + assert "edge_snapshot_json" in SampleEdgeEventRow.__table__.columns + assert "worker_snapshot_json" in SampleWorkerEventRow.__table__.columns + assert "evaluator_snapshot_json" in SampleEvaluatorEventRow.__table__.columns + assert "sandbox_snapshot_json" in SampleSandboxEventRow.__table__.columns + + +def test_event_appender_persists_each_typed_sample_event(session: Session) -> None: + sample_id = uuid4() + task_id = uuid4() + target_task_id = uuid4() + edge_id = uuid4() + now = datetime(2026, 5, 25, 12, 0, tzinfo=UTC) + appender = SampleRuntimeEventAppender(session) + + appender.append_status_event( + SampleStatusEventRow( + sample_id=sample_id, + event_timestamp=now, + event_type="sample.status_changed", + status="pending", + payload_json={"status": "pending"}, + ) + ) + appender.append_task_event( + SampleTaskEventRow( + sample_id=sample_id, + event_timestamp=now, + event_type="task.added", + task_id=task_id, + task_slug="root", + status="pending", + task_snapshot_json={"slug": "root", "description": "Root"}, + payload_json={"task_key": "root"}, + ) + ) + appender.append_edge_event( + SampleEdgeEventRow( + edge_id=edge_id, + sample_id=sample_id, + event_timestamp=now, + event_type="edge.added", + source_task_id=task_id, + target_task_id=target_task_id, + status="pending", + edge_snapshot_json={"status": "pending"}, + payload_json={}, + ) + ) + appender.append_worker_event( + SampleWorkerEventRow( + sample_id=sample_id, + event_timestamp=now, + event_type="worker.added", + task_id=task_id, + worker_slug="smoke-worker", + worker_snapshot_json={"slug": "smoke-worker"}, + payload_json={}, + ) + ) + appender.append_evaluator_event( + SampleEvaluatorEventRow( + sample_id=sample_id, + event_timestamp=now, + event_type="evaluator.added", + task_id=task_id, + evaluator_slug="default", + evaluator_snapshot_json={"slug": "default"}, + payload_json={}, + ) + ) + appender.append_sandbox_event( + SampleSandboxEventRow( + sample_id=sample_id, + event_timestamp=now, + event_type="sandbox.added", + task_id=task_id, + sandbox_slug="swebench", + sandbox_snapshot_json={"slug": "swebench"}, + payload_json={}, + ) + ) + + assert session.exec(select(SampleStatusEventRow)).one().status == "pending" + assert session.exec(select(SampleTaskEventRow)).one().task_snapshot_json == { + "slug": "root", + "description": "Root", + } + assert session.exec(select(SampleEdgeEventRow)).one().edge_id == edge_id + assert session.exec(select(SampleWorkerEventRow)).one().worker_slug == "smoke-worker" + assert session.exec(select(SampleEvaluatorEventRow)).one().evaluator_slug == "default" + assert session.exec(select(SampleSandboxEventRow)).one().sandbox_slug == "swebench" diff --git a/ergon_core/tests/unit/state/test_type_invariants.py b/ergon_core/tests/unit/state/test_type_invariants.py index e8ea8c6fc..54b076133 100644 --- a/ergon_core/tests/unit/state/test_type_invariants.py +++ b/ergon_core/tests/unit/state/test_type_invariants.py @@ -10,10 +10,7 @@ from uuid import uuid4 import pytest -from ergon_core.core.persistence.graph.models import ( - SampleGraphAnnotation, - SampleGraphMutation, -) +from ergon_core.core.persistence.samples.models import SampleAnnotationEventRow, SampleTaskEventRow from ergon_core.core.persistence.shared.enums import ( SampleStatus, TaskExecutionStatus, @@ -68,41 +65,38 @@ "output", ), ( - lambda: SampleGraphMutation( + lambda: SampleTaskEventRow( sample_id=uuid4(), - sequence=0, - mutation_type="node.added", - target_type="node", - target_id=uuid4(), + task_id=uuid4(), + event_type="task.added", + task_slug="root", + status="pending", actor="system:test", - new_value={"status": "pending"}, ), - "mutation_type", - "node.added", + "event_type", + "task.added", ), ( - lambda: SampleGraphMutation( + lambda: SampleTaskEventRow( sample_id=uuid4(), - sequence=0, - mutation_type="edge.added", - target_type="edge", - target_id=uuid4(), + task_id=uuid4(), + event_type="task.status_changed", + status="completed", actor="system:test", - new_value={}, ), - "target_type", - "edge", + "status", + "completed", ), ( - lambda: SampleGraphAnnotation( + lambda: SampleAnnotationEventRow( sample_id=uuid4(), - target_type="node", + target_type="task", target_id=uuid4(), - namespace="payload", - sequence=0, + key="payload", + event_type="annotation.set", ), "target_type", - "node", + "task", ), ], ) diff --git a/ergon_ingestion/ergon_ingestion/writers/external_run_writer.py b/ergon_ingestion/ergon_ingestion/writers/external_run_writer.py index 4ffc1aa92..5da5916de 100644 --- a/ergon_ingestion/ergon_ingestion/writers/external_run_writer.py +++ b/ergon_ingestion/ergon_ingestion/writers/external_run_writer.py @@ -14,7 +14,8 @@ ExperimentDefinitionInstance, ExperimentDefinitionTask, ) -from ergon_core.core.persistence.graph.models import SampleGraphAnnotation, SampleGraphNode +from ergon_core.core.persistence.graph.models import SampleGraphNode +from ergon_core.core.persistence.samples.models import SampleAnnotationEventRow from ergon_core.core.persistence.shared.enums import ( SampleResourceKind, SampleStatus, @@ -126,13 +127,16 @@ def write_run(self, parsed: ParsedRun) -> WriteRunResult: for sequence, annotation in enumerate(parsed.annotations, start=1): self._session.add( - SampleGraphAnnotation( + SampleAnnotationEventRow( sample_id=run.id, target_type="node", target_id=node.task_id, - namespace=annotation.namespace, - sequence=sequence, - payload=_json_safe(annotation.payload), + key=annotation.namespace, + event_type="annotation.set", + payload_json={ + "sequence": sequence, + "value": _json_safe(annotation.payload), + }, ) ) diff --git a/ergon_ingestion/tests/unit/test_external_run_writer.py b/ergon_ingestion/tests/unit/test_external_run_writer.py index 7244aeb00..09ba7a0f4 100644 --- a/ergon_ingestion/tests/unit/test_external_run_writer.py +++ b/ergon_ingestion/tests/unit/test_external_run_writer.py @@ -5,7 +5,8 @@ from sqlmodel import SQLModel, Session, create_engine, select from ergon_core.core.persistence.definitions.models import ExperimentDefinition -from ergon_core.core.persistence.graph.models import SampleGraphAnnotation, SampleGraphNode +from ergon_core.core.persistence.graph.models import SampleGraphNode +from ergon_core.core.persistence.samples.models import SampleAnnotationEventRow from ergon_core.core.persistence.telemetry.models import ( SampleRecord, SampleResource, @@ -84,7 +85,9 @@ def test_external_run_writer_persists_import_spine_without_reducer_tables(tmp_pa session.exec(select(SampleTaskAttempt)).one().output_json["source_run_id"] == "gap-row-1" ) - assert session.exec(select(SampleGraphAnnotation)).one().namespace == "gap.labels" + annotation_event = session.exec(select(SampleAnnotationEventRow)).one() + assert annotation_event.key == "gap.labels" + assert annotation_event.payload_json["value"] == {"t_safe": True, "tc_safe": False} assert session.exec(select(SampleResource)).one().name == "source-row.json" diff --git a/tests/e2e/_asserts.py b/tests/e2e/_asserts.py index b270bda1a..16727c73c 100644 --- a/tests/e2e/_asserts.py +++ b/tests/e2e/_asserts.py @@ -19,11 +19,14 @@ import json import os import time +from typing import Literal from uuid import UUID import httpx from ergon_core.core.views.samples.models import SampleTaskDto from ergon_core.test_support.e2e_read_helpers import ( + ObservedSampleRuntimeEvent, + ObservedSampleRuntimeEventStream, ResourceSnapshot, first_probe_resource, leaf_execution_timings_by_slug, @@ -31,15 +34,10 @@ list_root_execution_and_evaluations, list_sandbox_command_wal, list_sandbox_events, + read_sample_runtime_event_stream, read_resource_bytes, ) from tests.fixtures.smoke_components.smoke_base.constants import EXPECTED_SUBTASK_SLUGS -from tests.fixtures.smoke_components.smoke_base.leaf_base import BaseSmokeLeafWorker -from tests.fixtures.smoke_components.smoke_base.recursive import ( - NESTED_LINE_SLUGS, - RecursiveSmokeWorkerBase, -) -from tests.fixtures.smoke_components.smoke_base.worker_base import SmokeWorkerBase from tests.e2e._read_contracts import require_run_snapshot @@ -47,6 +45,10 @@ BLOCKED = "blocked" COMPLETED = "completed" FAILED = "failed" +NESTED_LINE_SLUGS = ("l_2_a", "l_2_b") +SMOKE_PARENT_TURN_COUNT = 3 +SMOKE_RECURSIVE_TURN_COUNT = 3 +SMOKE_LEAF_TURN_COUNT = 2 # ============================================================================= @@ -133,9 +135,7 @@ def _assert_run_turn_counts(sample_id: UUID) -> None: """ leaf_count = len(EXPECTED_SUBTASK_SLUGS) - 1 + len(NESTED_LINE_SLUGS) expected = ( - SmokeWorkerBase.PARENT_TURN_COUNT - + RecursiveSmokeWorkerBase.RECURSIVE_TURN_COUNT - + leaf_count * BaseSmokeLeafWorker.LEAF_TURN_COUNT + SMOKE_PARENT_TURN_COUNT + SMOKE_RECURSIVE_TURN_COUNT + leaf_count * SMOKE_LEAF_TURN_COUNT ) # currently 3 + 3 + 10×2 = 26 snapshot = require_run_snapshot(sample_id) @@ -143,9 +143,9 @@ def _assert_run_turn_counts(sample_id: UUID) -> None: assert event_count == expected, ( f"turn count mismatch: expected {expected} " - f"(parent={SmokeWorkerBase.PARENT_TURN_COUNT}, " - f"recursive={RecursiveSmokeWorkerBase.RECURSIVE_TURN_COUNT}, " - f"leaves={leaf_count}×{BaseSmokeLeafWorker.LEAF_TURN_COUNT}), got {event_count}" + f"(parent={SMOKE_PARENT_TURN_COUNT}, " + f"recursive={SMOKE_RECURSIVE_TURN_COUNT}, " + f"leaves={leaf_count}×{SMOKE_LEAF_TURN_COUNT}), got {event_count}" ) @@ -337,6 +337,143 @@ def _after(child: str, parents: list[str]) -> None: _after("l_3", ["l_2"]) +SMOKE_DIRECT_TASKS = EXPECTED_SUBTASK_SLUGS +SMOKE_NESTED_TASKS = NESTED_LINE_SLUGS +SMOKE_DIRECT_EDGES = ( + ("d_root", "d_left"), + ("d_root", "d_right"), + ("d_left", "d_join"), + ("d_right", "d_join"), + ("l_1", "l_2"), + ("l_2", "l_3"), +) +SMOKE_NESTED_EDGES = (("l_2_a", "l_2_b"),) + + +def _assert_sample_runtime_event_stream( + sample_id: UUID, + *, + profile: Literal["happy", "sad"], + worker_prefix: str, + root_worker_slug: str, +) -> None: + snapshot = read_sample_runtime_event_stream(sample_id) + sample_snapshot = require_run_snapshot(sample_id) + root_slug = sample_snapshot.tasks[sample_snapshot.root_task_id].name + + expected_task_slugs = ( + (root_slug, *SMOKE_DIRECT_TASKS, *SMOKE_NESTED_TASKS) + if profile == "happy" + else (root_slug, *SMOKE_DIRECT_TASKS) + ) + expected_edge_pairs = ( + (*SMOKE_DIRECT_EDGES, *SMOKE_NESTED_EDGES) if profile == "happy" else SMOKE_DIRECT_EDGES + ) + expected_status_sequence = ( + ("pending", "executing", "completed") + if profile == "happy" + else ("pending", "executing", "failed") + ) + expected_terminal_status_by_slug = {slug: "completed" for slug in expected_task_slugs} + if profile == "sad": + expected_terminal_status_by_slug["l_2"] = "failed" + expected_terminal_status_by_slug["l_3"] = "blocked" + + expected_workers = {slug: f"{worker_prefix}-smoke-leaf" for slug in expected_task_slugs} + expected_workers[root_slug] = root_worker_slug + if profile == "happy": + expected_workers["l_2"] = f"{worker_prefix}-smoke-recursive-worker" + expected_workers["l_2_a"] = f"{worker_prefix}-smoke-leaf" + expected_workers["l_2_b"] = f"{worker_prefix}-smoke-leaf" + else: + expected_workers["l_2"] = f"{worker_prefix}-smoke-leaf-failing" + + assert snapshot.status_sequence == expected_status_sequence + assert snapshot.task_added_slugs == expected_task_slugs + assert snapshot.edge_added_pairs == expected_edge_pairs + assert snapshot.worker_added_by_task_slug == expected_workers + assert set(snapshot.sandbox_added_by_task_slug) == set(expected_task_slugs) + assert snapshot.evaluator_added_by_task_slug == {root_slug: ("default", "post-root")} + assert snapshot.task_terminal_status_by_slug == expected_terminal_status_by_slug + if profile == "happy": + assert "l_2_a" in snapshot.task_added_slugs + assert "l_2_b" in snapshot.task_added_slugs + else: + assert "l_2_a" not in snapshot.task_added_slugs + assert "l_2_b" not in snapshot.task_added_slugs + + _assert_sample_runtime_event_order(snapshot, root_slug=root_slug) + + +def _assert_sample_runtime_event_order( + snapshot: ObservedSampleRuntimeEventStream, + *, + root_slug: str, +) -> None: + ordered = snapshot.ordered_events + assert ordered, "expected typed sample runtime WAL events" + assert ordered == tuple(sorted(ordered, key=lambda event: (event.event_timestamp, event.id))) + assert ordered[0].event_table == "sample_status_events" + assert ordered[0].event_type == "sample.status_changed" + assert ordered[0].status == "pending" + + first_executing = _event_index( + ordered, + event_table="sample_status_events", + event_type="sample.status_changed", + status="executing", + ) + final_sample_status = max( + i for i, event in enumerate(ordered) if event.event_table == "sample_status_events" + ) + + task_add_index = { + event.task_slug: i for i, event in enumerate(ordered) if event.event_type == "task.added" + } + + assert task_add_index[root_slug] < first_executing + for slug in SMOKE_DIRECT_TASKS: + assert task_add_index[slug] > first_executing + + for i, event in enumerate(ordered): + if event.event_type in {"worker.added", "sandbox.added", "evaluator.added"}: + assert event.task_slug is not None + assert task_add_index[event.task_slug] < i + if event.event_type == "edge.added": + assert event.source_task_slug is not None + assert event.target_task_slug is not None + assert task_add_index[event.source_task_slug] < i + assert task_add_index[event.target_task_slug] < i + if event.event_type == "task.status_changed" and event.status in { + "completed", + "failed", + "blocked", + "cancelled", + }: + assert event.task_slug is not None + assert task_add_index[event.task_slug] < i + assert i < final_sample_status + + +def _event_index( + ordered: tuple[ObservedSampleRuntimeEvent, ...], + *, + event_table: str, + event_type: str, + status: str | None = None, +) -> int: + for i, event in enumerate(ordered): + if ( + event.event_table == event_table + and event.event_type == event_type + and (status is None or event.status == status) + ): + return i + raise AssertionError( + f"missing event table={event_table!r} type={event_type!r} status={status!r}" + ) + + # ============================================================================= # Experiment-group helpers # ============================================================================= diff --git a/tests/e2e/_read_contracts.py b/tests/e2e/_read_contracts.py index 44f1fcfe8..bd7e2ccd4 100644 --- a/tests/e2e/_read_contracts.py +++ b/tests/e2e/_read_contracts.py @@ -2,13 +2,16 @@ from __future__ import annotations +from typing import TYPE_CHECKING from uuid import UUID -from ergon_core.core.views.samples.models import SampleSnapshotDto -from ergon_core.core.views.samples.service import SampleSnapshotReadService +if TYPE_CHECKING: + from ergon_core.core.views.samples.models import SampleSnapshotDto def require_run_snapshot(sample_id: UUID) -> SampleSnapshotDto: + from ergon_core.core.views.samples.service import SampleSnapshotReadService + snapshot = SampleSnapshotReadService().build_snapshot(sample_id) assert snapshot is not None, ( f"SampleSnapshotReadService returned no snapshot for sample {sample_id}" diff --git a/tests/e2e/test_minif2f_smoke.py b/tests/e2e/test_minif2f_smoke.py index 4e33ee87b..75a66afe0 100644 --- a/tests/e2e/test_minif2f_smoke.py +++ b/tests/e2e/test_minif2f_smoke.py @@ -19,6 +19,7 @@ _assert_sample_graph, _assert_sample_resources, _assert_run_turn_counts, + _assert_sample_runtime_event_stream, _assert_sadpath_evaluation, _assert_sadpath_graph_cascade, _assert_sadpath_partial_artifact, @@ -107,6 +108,12 @@ async def test_smoke_experiment_group(tmp_path: pathlib.Path) -> None: def _assert_happy_run(rid) -> None: _assert_sample_graph(rid) + _assert_sample_runtime_event_stream( + rid, + profile="happy", + worker_prefix=ENV, + root_worker_slug=HAPPY_WORKER, + ) _assert_sample_resources(rid) _assert_run_turn_counts(rid) _assert_thread_messages_ordered(rid) @@ -120,6 +127,12 @@ def _assert_happy_run(rid) -> None: def _assert_sad_run(rid) -> None: _assert_sadpath_graph_cascade(rid) + _assert_sample_runtime_event_stream( + rid, + profile="sad", + worker_prefix=ENV, + root_worker_slug=SAD_WORKER, + ) _assert_sadpath_partial_artifact(rid) _assert_sadpath_partial_wal(rid) _assert_sadpath_thread_messages(rid) diff --git a/tests/e2e/test_researchrubrics_smoke.py b/tests/e2e/test_researchrubrics_smoke.py index 03d054fec..98cdb839a 100644 --- a/tests/e2e/test_researchrubrics_smoke.py +++ b/tests/e2e/test_researchrubrics_smoke.py @@ -30,6 +30,7 @@ _assert_sample_graph, _assert_sample_resources, _assert_run_turn_counts, + _assert_sample_runtime_event_stream, _assert_sadpath_evaluation, _assert_sadpath_graph_cascade, _assert_sadpath_partial_artifact, @@ -119,6 +120,12 @@ async def test_smoke_experiment_group(tmp_path: pathlib.Path) -> None: def _assert_happy_run(rid) -> None: _assert_sample_graph(rid) + _assert_sample_runtime_event_stream( + rid, + profile="happy", + worker_prefix=ENV, + root_worker_slug=HAPPY_WORKER, + ) _assert_sample_resources(rid) _assert_run_turn_counts(rid) _assert_thread_messages_ordered(rid) @@ -131,6 +138,12 @@ def _assert_happy_run(rid) -> None: def _assert_sad_run(rid) -> None: _assert_sadpath_graph_cascade(rid) + _assert_sample_runtime_event_stream( + rid, + profile="sad", + worker_prefix=ENV, + root_worker_slug=SAD_WORKER, + ) _assert_sadpath_partial_artifact(rid) _assert_sadpath_partial_wal(rid) _assert_sadpath_thread_messages(rid) diff --git a/tests/e2e/test_swebench_smoke.py b/tests/e2e/test_swebench_smoke.py index 82c34cec9..adf184e40 100644 --- a/tests/e2e/test_swebench_smoke.py +++ b/tests/e2e/test_swebench_smoke.py @@ -18,6 +18,7 @@ _assert_sample_graph, _assert_sample_resources, _assert_run_turn_counts, + _assert_sample_runtime_event_stream, _assert_sadpath_evaluation, _assert_sadpath_graph_cascade, _assert_sadpath_partial_artifact, @@ -112,6 +113,12 @@ async def test_smoke_experiment_group(tmp_path: pathlib.Path) -> None: def _assert_happy_run(rid) -> None: _assert_sample_graph(rid) + _assert_sample_runtime_event_stream( + rid, + profile="happy", + worker_prefix=WORKER_PREFIX, + root_worker_slug=HAPPY_WORKER, + ) _assert_sample_resources(rid) _assert_run_turn_counts(rid) _assert_thread_messages_ordered(rid) @@ -125,6 +132,12 @@ def _assert_happy_run(rid) -> None: def _assert_sad_run(rid) -> None: _assert_sadpath_graph_cascade(rid) + _assert_sample_runtime_event_stream( + rid, + profile="sad", + worker_prefix=WORKER_PREFIX, + root_worker_slug=SAD_WORKER, + ) _assert_sadpath_partial_artifact(rid) _assert_sadpath_partial_wal(rid) _assert_sadpath_thread_messages(rid) diff --git a/tests/integration/propagation/_helpers.py b/tests/integration/propagation/_helpers.py index ea5b0ba5b..f4167a91e 100644 --- a/tests/integration/propagation/_helpers.py +++ b/tests/integration/propagation/_helpers.py @@ -4,10 +4,15 @@ from uuid import UUID from ergon_core.core.persistence.definitions.models import ExperimentDefinition -from ergon_core.core.persistence.graph.models import ( - SampleGraphEdge, - SampleGraphMutation, - SampleGraphNode, +from ergon_core.core.persistence.graph.models import SampleGraphEdge, SampleGraphNode +from ergon_core.core.persistence.samples.models import ( + SampleAnnotationEventRow, + SampleEdgeEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleStatusEventRow, + SampleTaskEventRow, + SampleWorkerEventRow, ) from ergon_core.core.application.runtime.status import TERMINAL_STATUSES from ergon_core.core.persistence.shared.db import get_session @@ -37,11 +42,26 @@ def get_node_status(session: Session, task_id: UUID) -> str: return node.status -def get_wal_entries(session: Session, task_id: UUID) -> list[SampleGraphMutation]: +SAMPLE_WAL_MODELS = ( + SampleAnnotationEventRow, + SampleEdgeEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleStatusEventRow, + SampleTaskEventRow, + SampleWorkerEventRow, +) + + +def delete_typed_sample_wal(session: Session, sample_id: UUID) -> None: + for model in SAMPLE_WAL_MODELS: + for row in session.exec(select(model).where(model.sample_id == sample_id)).all(): + session.delete(row) + + +def get_wal_entries(session: Session, task_id: UUID) -> list[SampleTaskEventRow]: return list( - session.exec( - select(SampleGraphMutation).where(SampleGraphMutation.target_id == task_id) - ).all() + session.exec(select(SampleTaskEventRow).where(SampleTaskEventRow.task_id == task_id)).all() ) @@ -53,15 +73,16 @@ def assert_wal_has_status( cause_contains: str | None = None, ) -> None: entries = get_wal_entries(session, task_id) - matching = [e for e in entries if e.new_value.get("status") == status] + matching = [e for e in entries if e.status == status or e.payload_json.get("status") == status] assert matching, ( f"No WAL entry with status={status!r} for node {task_id}. " - f"Entries: {[e.new_value for e in entries]}" + f"Entries: {[e.payload_json for e in entries]}" ) if cause_contains is not None: - assert any(e.reason and cause_contains in e.reason for e in matching), ( - f"No WAL entry with cause containing {cause_contains!r} for node {task_id}" - ) + assert any( + e.actor and cause_contains in e.actor or cause_contains in str(e.payload_json) + for e in matching + ), f"No WAL entry with cause containing {cause_contains!r} for node {task_id}" def assert_cross_cutting_invariants(session: Session, sample_id: UUID) -> None: diff --git a/tests/integration/propagation/test_propagation_blocked.py b/tests/integration/propagation/test_propagation_blocked.py index 257eba58a..3d4bd5204 100644 --- a/tests/integration/propagation/test_propagation_blocked.py +++ b/tests/integration/propagation/test_propagation_blocked.py @@ -2,11 +2,7 @@ import pytest from ergon_core.core.persistence.definitions.models import ExperimentDefinition -from ergon_core.core.persistence.graph.models import ( - SampleGraphEdge, - SampleGraphMutation, - SampleGraphNode, -) +from ergon_core.core.persistence.graph.models import SampleGraphEdge, SampleGraphNode from ergon_core.core.application.runtime.status import BLOCKED, CANCELLED from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import SampleStatus, TaskExecutionStatus @@ -19,6 +15,7 @@ from tests.integration.propagation._helpers import ( assert_cross_cutting_invariants, + delete_typed_sample_wal, assert_wal_has_status, get_node_status, make_edge, @@ -39,10 +36,7 @@ def _cleanup_run(sample_id, defn_id) -> None: # type: ignore[no-untyped-def] """Remove all rows created by a test, in FK-safe order.""" with get_session() as session: - for mut in session.exec( - select(SampleGraphMutation).where(SampleGraphMutation.sample_id == sample_id) - ).all(): - session.delete(mut) + delete_typed_sample_wal(session, sample_id) for edge in session.exec( select(SampleGraphEdge).where(SampleGraphEdge.sample_id == sample_id) ).all(): diff --git a/tests/integration/propagation/test_propagation_cancel.py b/tests/integration/propagation/test_propagation_cancel.py index 184022c67..7af37769e 100644 --- a/tests/integration/propagation/test_propagation_cancel.py +++ b/tests/integration/propagation/test_propagation_cancel.py @@ -10,11 +10,7 @@ import pytest from ergon_core.core.persistence.definitions.models import ExperimentDefinition -from ergon_core.core.persistence.graph.models import ( - SampleGraphEdge, - SampleGraphMutation, - SampleGraphNode, -) +from ergon_core.core.persistence.graph.models import SampleGraphEdge, SampleGraphNode from ergon_core.core.application.runtime.status import CANCELLED from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TaskExecutionStatus @@ -25,6 +21,7 @@ from tests.integration.propagation._helpers import ( assert_cross_cutting_invariants, + delete_typed_sample_wal, assert_wal_has_status, get_node_status, make_experiment_definition, @@ -41,10 +38,7 @@ def _cleanup_run(sample_id, defn_id) -> None: # type: ignore[no-untyped-def] with get_session() as session: - for mut in session.exec( - select(SampleGraphMutation).where(SampleGraphMutation.sample_id == sample_id) - ).all(): - session.delete(mut) + delete_typed_sample_wal(session, sample_id) for edge in session.exec( select(SampleGraphEdge).where(SampleGraphEdge.sample_id == sample_id) ).all(): diff --git a/tests/integration/propagation/test_propagation_edge_cases.py b/tests/integration/propagation/test_propagation_edge_cases.py index df0e707d3..51daebd27 100644 --- a/tests/integration/propagation/test_propagation_edge_cases.py +++ b/tests/integration/propagation/test_propagation_edge_cases.py @@ -6,11 +6,7 @@ import pytest from ergon_core.core.persistence.definitions.models import ExperimentDefinition -from ergon_core.core.persistence.graph.models import ( - SampleGraphEdge, - SampleGraphMutation, - SampleGraphNode, -) +from ergon_core.core.persistence.graph.models import SampleGraphEdge, SampleGraphNode from ergon_core.core.application.runtime.status import BLOCKED, CANCELLED from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import SampleStatus, TaskExecutionStatus @@ -23,6 +19,7 @@ from tests.integration.propagation._helpers import ( assert_cross_cutting_invariants, + delete_typed_sample_wal, assert_wal_has_status, get_node_status, make_edge, @@ -40,10 +37,7 @@ def _cleanup_run(sample_id, defn_id) -> None: # type: ignore[no-untyped-def] with get_session() as session: - for mut in session.exec( - select(SampleGraphMutation).where(SampleGraphMutation.sample_id == sample_id) - ).all(): - session.delete(mut) + delete_typed_sample_wal(session, sample_id) for edge in session.exec( select(SampleGraphEdge).where(SampleGraphEdge.sample_id == sample_id) ).all(): diff --git a/tests/integration/propagation/test_propagation_happy.py b/tests/integration/propagation/test_propagation_happy.py index ea5d65581..8ebe10c03 100644 --- a/tests/integration/propagation/test_propagation_happy.py +++ b/tests/integration/propagation/test_propagation_happy.py @@ -8,11 +8,7 @@ import pytest from ergon_core.core.persistence.definitions.models import ExperimentDefinition -from ergon_core.core.persistence.graph.models import ( - SampleGraphEdge, - SampleGraphMutation, - SampleGraphNode, -) +from ergon_core.core.persistence.graph.models import SampleGraphEdge, SampleGraphNode from ergon_core.core.persistence.shared.db import get_engine, get_session from ergon_core.core.persistence.shared.enums import SampleStatus, TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import SampleRecord @@ -26,6 +22,7 @@ from tests.integration.propagation._helpers import ( assert_cross_cutting_invariants, + delete_typed_sample_wal, assert_wal_has_status, get_node_status, make_experiment_definition, @@ -65,10 +62,7 @@ def _skip_if_db_unreachable() -> None: def _cleanup_run(sample_id, defn_id) -> None: # type: ignore[no-untyped-def] """Remove all rows created by a test, in FK-safe order.""" with get_session() as session: - for mut in session.exec( - select(SampleGraphMutation).where(SampleGraphMutation.sample_id == sample_id) - ).all(): - session.delete(mut) + delete_typed_sample_wal(session, sample_id) for edge in session.exec( select(SampleGraphEdge).where(SampleGraphEdge.sample_id == sample_id) ).all(): diff --git a/tests/integration/propagation/test_propagation_restart.py b/tests/integration/propagation/test_propagation_restart.py index 1b3102053..e89c6903e 100644 --- a/tests/integration/propagation/test_propagation_restart.py +++ b/tests/integration/propagation/test_propagation_restart.py @@ -19,6 +19,7 @@ from ergon_core.core.application.runtime.task_management import TaskManagementService from tests.integration.propagation._helpers import ( + delete_typed_sample_wal, get_node_status, make_edge, make_experiment_definition, diff --git a/tests/integration/restart/_helpers.py b/tests/integration/restart/_helpers.py index 40b7ac2d4..232f5745b 100644 --- a/tests/integration/restart/_helpers.py +++ b/tests/integration/restart/_helpers.py @@ -3,22 +3,16 @@ from uuid import UUID from ergon_core.core.persistence.definitions.models import ExperimentDefinition -from ergon_core.core.persistence.graph.models import ( - SampleGraphEdge, - SampleGraphMutation, - SampleGraphNode, -) +from ergon_core.core.persistence.graph.models import SampleGraphEdge, SampleGraphNode from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import SampleRecord from sqlmodel import select +from tests.integration.propagation._helpers import delete_typed_sample_wal def cleanup_run(sample_id: UUID, defn_id: UUID) -> None: with get_session() as session: - for mut in session.exec( - select(SampleGraphMutation).where(SampleGraphMutation.sample_id == sample_id) - ).all(): - session.delete(mut) + delete_typed_sample_wal(session, sample_id) for edge in session.exec( select(SampleGraphEdge).where(SampleGraphEdge.sample_id == sample_id) ).all(): diff --git a/tests/integration/restart/test_downstream_invalidation.py b/tests/integration/restart/test_downstream_invalidation.py index fe5a484d9..53e9efb00 100644 --- a/tests/integration/restart/test_downstream_invalidation.py +++ b/tests/integration/restart/test_downstream_invalidation.py @@ -11,11 +11,7 @@ import pytest from ergon_core.core.persistence.definitions.models import ExperimentDefinition -from ergon_core.core.persistence.graph.models import ( - SampleGraphEdge, - SampleGraphMutation, - SampleGraphNode, -) +from ergon_core.core.persistence.graph.models import SampleGraphEdge, SampleGraphNode from ergon_core.core.application.runtime.status import ( CANCELLED, EDGE_PENDING, diff --git a/tests/integration/restart/test_reactivation.py b/tests/integration/restart/test_reactivation.py index 5aa857585..cba005b09 100644 --- a/tests/integration/restart/test_reactivation.py +++ b/tests/integration/restart/test_reactivation.py @@ -13,11 +13,7 @@ import pytest from ergon_core.core.persistence.definitions.models import ExperimentDefinition -from ergon_core.core.persistence.graph.models import ( - SampleGraphEdge, - SampleGraphMutation, - SampleGraphNode, -) +from ergon_core.core.persistence.graph.models import SampleGraphEdge, SampleGraphNode from ergon_core.core.application.runtime.status import CANCELLED, EDGE_PENDING from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TaskExecutionStatus diff --git a/tests/integration/restart/test_restart_task.py b/tests/integration/restart/test_restart_task.py index b47e07600..1c99cc0ee 100644 --- a/tests/integration/restart/test_restart_task.py +++ b/tests/integration/restart/test_restart_task.py @@ -11,11 +11,7 @@ import pytest from ergon_core.core.persistence.definitions.models import ExperimentDefinition -from ergon_core.core.persistence.graph.models import ( - SampleGraphEdge, - SampleGraphMutation, - SampleGraphNode, -) +from ergon_core.core.persistence.graph.models import SampleGraphEdge, SampleGraphNode from ergon_core.core.application.runtime.status import ( CANCELLED, EDGE_PENDING, diff --git a/tests/real_llm/rollout.py b/tests/real_llm/rollout.py index ded42df22..9e44924d8 100644 --- a/tests/real_llm/rollout.py +++ b/tests/real_llm/rollout.py @@ -21,7 +21,13 @@ │ ├── sandbox_events.jsonl │ ├── sample_graph_nodes.jsonl │ ├── sample_graph_edges.jsonl - │ ├── sample_graph_mutations.jsonl + │ ├── sample_status_events.jsonl + │ ├── sample_task_events.jsonl + │ ├── sample_edge_events.jsonl + │ ├── sample_worker_events.jsonl + │ ├── sample_evaluator_events.jsonl + │ ├── sample_sandbox_events.jsonl + │ ├── sample_annotation_events.jsonl │ └── sample_context_events.jsonl ├── screenshots/ │ ├── experiment_index.png @@ -96,9 +102,17 @@ def dump_rollout(sample_id: UUID, out_dir: Path) -> dict[str, int]: # live rollout dumping, so keep this import scoped to that operation. from ergon_core.core.persistence.graph.models import ( SampleGraphEdge, - SampleGraphMutation, SampleGraphNode, ) + from ergon_core.core.persistence.samples.models import ( + SampleAnnotationEventRow, + SampleEdgeEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleStatusEventRow, + SampleTaskEventRow, + SampleWorkerEventRow, + ) from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import ( SampleRecord, @@ -166,14 +180,19 @@ def dump_rollout(sample_id: UUID, out_dir: Path) -> dict[str, int]: ).all() ), ) - counts["sample_graph_mutations"] = _write_jsonl( - db_dir / "sample_graph_mutations.jsonl", - list( - session.exec( - select(SampleGraphMutation).where(SampleGraphMutation.sample_id == sample_id) - ).all() - ), - ) + for table_name, model in ( + ("sample_status_events", SampleStatusEventRow), + ("sample_task_events", SampleTaskEventRow), + ("sample_edge_events", SampleEdgeEventRow), + ("sample_worker_events", SampleWorkerEventRow), + ("sample_evaluator_events", SampleEvaluatorEventRow), + ("sample_sandbox_events", SampleSandboxEventRow), + ("sample_annotation_events", SampleAnnotationEventRow), + ): + counts[table_name] = _write_jsonl( + db_dir / f"{table_name}.jsonl", + list(session.exec(select(model).where(model.sample_id == sample_id)).all()), + ) # Avoid importing SampleContextEvent here: that model depends on context # payloads, which currently have a circular import through api.Worker. rows = [ @@ -373,8 +392,8 @@ def write_report(out_dir: Path, manifest_path: Path) -> Path: " outcome fields; `status`, `started_at`, `completed_at` anchor the timeline.", "- `db/sample_context_events.jsonl` — every recorded context event in order.", " Tool calls + returns + thinking + text reconstruct what the agent did.", - "- `db/sample_graph_nodes.jsonl` + `sample_graph_mutations.jsonl` — agent's", - " subtask structure over time.", + "- `db/sample_graph_nodes.jsonl` + typed `sample_*_events.jsonl` files —", + " agent's subtask structure and runtime state over time.", "- `db/sample_task_evaluations.jsonl` — rubric scores, if the evaluator ran.", "- `db/sandbox_events.jsonl` — commands executed in the E2B sandbox.", "- `screenshots/` — what the dashboard renders for this run.", From ea687c200b798db9b60572202a5346d32f4f551f Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Mon, 25 May 2026 21:52:00 +0100 Subject: [PATCH 03/14] Fix typed WAL ty checks --- .../core/application/samples/state.py | 34 +++++++++++++------ .../test_support/e2e_read_helpers.py | 4 +-- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/ergon_core/ergon_core/core/application/samples/state.py b/ergon_core/ergon_core/core/application/samples/state.py index ebd434e76..868db04e7 100644 --- a/ergon_core/ergon_core/core/application/samples/state.py +++ b/ergon_core/ergon_core/core/application/samples/state.py @@ -166,43 +166,55 @@ def _apply_edge(self, row: SampleEdgeEventRow) -> None: ) def _apply_worker(self, row: SampleWorkerEventRow) -> None: + task_id = row.task_id + if task_id is None: + return if row.event_type == "worker.removed": - self.workers.pop(row.task_id, None) + self.workers.pop(task_id, None) return - self.workers[row.task_id] = SampleWorkerRuntimeState( - task_id=row.task_id, + self.workers[task_id] = SampleWorkerRuntimeState( + task_id=task_id, worker_slug=row.worker_slug, worker_snapshot_json=dict(row.worker_snapshot_json), ) def _apply_evaluator(self, row: SampleEvaluatorEventRow) -> None: + task_id = row.task_id + if task_id is None: + return if row.event_type == "evaluator.removed": self._remove_evaluator(row) return - self.evaluators_by_task_id.setdefault(row.task_id, []).append( + self.evaluators_by_task_id.setdefault(task_id, []).append( SampleEvaluatorRuntimeState( - task_id=row.task_id, + task_id=task_id, evaluator_slug=row.evaluator_slug, evaluator_snapshot_json=dict(row.evaluator_snapshot_json), ) ) def _remove_evaluator(self, row: SampleEvaluatorEventRow) -> None: + task_id = row.task_id + if task_id is None: + return if row.evaluator_slug: - self.evaluators_by_task_id[row.task_id] = [ + self.evaluators_by_task_id[task_id] = [ evaluator - for evaluator in self.evaluators_by_task_id.get(row.task_id, []) + for evaluator in self.evaluators_by_task_id.get(task_id, []) if evaluator.evaluator_slug != row.evaluator_slug ] else: - self.evaluators_by_task_id.pop(row.task_id, None) + self.evaluators_by_task_id.pop(task_id, None) def _apply_sandbox(self, row: SampleSandboxEventRow) -> None: + task_id = row.task_id + if task_id is None: + return if row.event_type == "sandbox.removed": - self.sandboxes.pop(row.task_id, None) + self.sandboxes.pop(task_id, None) return - self.sandboxes[row.task_id] = SampleSandboxRuntimeState( - task_id=row.task_id, + self.sandboxes[task_id] = SampleSandboxRuntimeState( + task_id=task_id, sandbox_slug=row.sandbox_slug, sandbox_snapshot_json=dict(row.sandbox_snapshot_json), ) diff --git a/ergon_core/ergon_core/test_support/e2e_read_helpers.py b/ergon_core/ergon_core/test_support/e2e_read_helpers.py index d8c6a1976..df14034c5 100644 --- a/ergon_core/ergon_core/test_support/e2e_read_helpers.py +++ b/ergon_core/ergon_core/test_support/e2e_read_helpers.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, Literal, Mapping +from typing import Any, Literal, Mapping, cast from uuid import UUID from ergon_core.core.persistence.graph.models import SampleGraphNode @@ -460,4 +460,4 @@ def _string_payload( def _json_mapping(value: object) -> Mapping[str, Any]: # slopcop: ignore[no-typing-any] - return value if isinstance(value, Mapping) else {} + return cast(Mapping[str, Any], value) if isinstance(value, Mapping) else {} From c784a41d5a4dfcc631400a3c2cf8a2c48b87a65c Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Mon, 25 May 2026 22:02:12 +0100 Subject: [PATCH 04/14] Fix sample WAL e2e slopcop violations --- .../test_support/e2e_read_helpers.py | 111 ++++++++++++------ 1 file changed, 76 insertions(+), 35 deletions(-) diff --git a/ergon_core/ergon_core/test_support/e2e_read_helpers.py b/ergon_core/ergon_core/test_support/e2e_read_helpers.py index df14034c5..38a9f01e8 100644 --- a/ergon_core/ergon_core/test_support/e2e_read_helpers.py +++ b/ergon_core/ergon_core/test_support/e2e_read_helpers.py @@ -9,6 +9,15 @@ from uuid import UUID from ergon_core.core.persistence.graph.models import SampleGraphNode +from ergon_core.core.persistence.samples.models import ( + SampleAnnotationEventRow, + SampleEdgeEventRow, + SampleEvaluatorEventRow, + SampleSandboxEventRow, + SampleStatusEventRow, + SampleTaskEventRow, + SampleWorkerEventRow, +) from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import ( SampleResource, @@ -20,6 +29,16 @@ from pydantic import BaseModel, ConfigDict from sqlmodel import select +type SampleRuntimeEventRow = ( + SampleStatusEventRow + | SampleTaskEventRow + | SampleEdgeEventRow + | SampleWorkerEventRow + | SampleEvaluatorEventRow + | SampleSandboxEventRow + | SampleAnnotationEventRow +) + @dataclass(frozen=True) class ResourceSnapshot: @@ -260,18 +279,14 @@ def list_sandbox_events(sample_id: UUID) -> list[SandboxEventSnapshot]: def row_to_observed_sample_runtime_event( - row: Any, # slopcop: ignore[no-typing-any] + row: SampleRuntimeEventRow, *, task_slug_by_id: Mapping[UUID, str], ) -> ObservedSampleRuntimeEvent: - # slopcop: ignore[no-hasattr-getattr] - payload = _json_mapping(getattr(row, "payload_json", {})) - # slopcop: ignore[no-hasattr-getattr] - source_task_id = getattr(row, "source_task_id", None) - # slopcop: ignore[no-hasattr-getattr] - target_task_id = getattr(row, "target_task_id", None) - # slopcop: ignore[no-hasattr-getattr] - annotation_key = getattr(row, "key", None) + payload = _json_mapping(row.payload_json) + source_task_id = row.source_task_id if isinstance(row, SampleEdgeEventRow) else None + target_task_id = row.target_task_id if isinstance(row, SampleEdgeEventRow) else None + annotation_key = row.key if isinstance(row, SampleAnnotationEventRow) else None return ObservedSampleRuntimeEvent( id=row.id, event_timestamp=row.event_timestamp, @@ -366,17 +381,7 @@ def leaf_execution_timings_by_slug(sample_id: UUID) -> dict[str, TaskExecutionSn return {leaf.task_slug: by_task.get(leaf.task_id) for leaf in leaves} -def _sample_runtime_event_row_classes() -> tuple[type[Any], ...]: # slopcop: ignore[no-typing-any] - from ergon_core.core.persistence.samples.models import ( - SampleAnnotationEventRow, - SampleEdgeEventRow, - SampleEvaluatorEventRow, - SampleSandboxEventRow, - SampleStatusEventRow, - SampleTaskEventRow, - SampleWorkerEventRow, - ) - +def _sample_runtime_event_row_classes() -> tuple[type[SampleRuntimeEventRow], ...]: return ( SampleStatusEventRow, SampleTaskEventRow, @@ -389,14 +394,13 @@ def _sample_runtime_event_row_classes() -> tuple[type[Any], ...]: # slopcop: ig def _task_slug_by_id_from_task_events( - task_rows: list[Any], -) -> dict[UUID, str]: # slopcop: ignore[no-typing-any] + task_rows: list[SampleTaskEventRow], +) -> dict[UUID, str]: task_slug_by_id: dict[UUID, str] = {} for row in task_rows: if row.event_type != "task.added": continue - # slopcop: ignore[no-hasattr-getattr] - payload = _json_mapping(getattr(row, "payload_json", {})) + payload = _json_mapping(row.payload_json) task_slug = _task_slug(row, payload, task_slug_by_id) if task_slug: task_slug_by_id[row.task_id] = task_slug @@ -404,15 +408,11 @@ def _task_slug_by_id_from_task_events( def _task_slug( - row: Any, # slopcop: ignore[no-typing-any] + row: SampleRuntimeEventRow, payload: Mapping[str, Any], # slopcop: ignore[no-typing-any] task_slug_by_id: Mapping[UUID, str], ) -> str | None: - # slopcop: ignore[no-hasattr-getattr] - task_id = getattr(row, "task_id", None) - if task_id is None: - # slopcop: ignore[no-hasattr-getattr] - task_id = getattr(row, "target_id", None) + task_id = _row_task_id(row) return ( task_slug_by_id.get(task_id) or _string_attr_or_payload(row, payload, "task_slug") @@ -422,11 +422,11 @@ def _task_slug( def _slug_attr_or_payload( - row: Any, # slopcop: ignore[no-typing-any] + row: SampleRuntimeEventRow, payload: Mapping[str, Any], # slopcop: ignore[no-typing-any] name: str, ) -> str | None: - attr_value = getattr(row, f"{name}_slug", None) # slopcop: ignore[no-hasattr-getattr] + attr_value = _row_slug(row, name) if isinstance(attr_value, str): return attr_value direct = _string_payload(payload, f"{name}_slug") @@ -435,23 +435,64 @@ def _slug_attr_or_payload( nested = payload.get(name) if isinstance(nested, Mapping): return _string_payload(nested, "slug") - snapshot = getattr(row, f"{name}_snapshot_json", None) # slopcop: ignore[no-hasattr-getattr] + snapshot = _row_snapshot(row, name) if isinstance(snapshot, Mapping): return _string_payload(snapshot, "slug") return None def _string_attr_or_payload( - row: Any, # slopcop: ignore[no-typing-any] + row: SampleRuntimeEventRow, payload: Mapping[str, Any], # slopcop: ignore[no-typing-any] name: str, ) -> str | None: - attr_value = getattr(row, name, None) # slopcop: ignore[no-hasattr-getattr] + attr_value = _row_string_attr(row, name) if isinstance(attr_value, str): return attr_value return _string_payload(payload, name) +def _row_task_id(row: SampleRuntimeEventRow) -> UUID | None: + if isinstance(row, SampleTaskEventRow | SampleWorkerEventRow | SampleEvaluatorEventRow): + return row.task_id + if isinstance(row, SampleSandboxEventRow): + return row.task_id + if isinstance(row, SampleAnnotationEventRow): + return row.target_id + return None + + +def _row_slug(row: SampleRuntimeEventRow, name: str) -> str | None: + if name == "worker" and isinstance(row, SampleWorkerEventRow): + return row.worker_slug + if name == "evaluator" and isinstance(row, SampleEvaluatorEventRow): + return row.evaluator_slug + if name == "sandbox" and isinstance(row, SampleSandboxEventRow): + return row.sandbox_slug + return None + + +def _row_snapshot( + row: SampleRuntimeEventRow, + name: str, +) -> Mapping[str, Any] | None: # slopcop: ignore[no-typing-any] + if name == "worker" and isinstance(row, SampleWorkerEventRow): + return _json_mapping(row.worker_snapshot_json) + if name == "evaluator" and isinstance(row, SampleEvaluatorEventRow): + return _json_mapping(row.evaluator_snapshot_json) + if name == "sandbox" and isinstance(row, SampleSandboxEventRow): + return _json_mapping(row.sandbox_snapshot_json) + return None + + +def _row_string_attr(row: SampleRuntimeEventRow, name: str) -> str | None: + if name == "status" and isinstance(row, SampleStatusEventRow | SampleTaskEventRow): + return row.status + if name == "task_slug" and isinstance(row, SampleTaskEventRow): + return row.task_slug + return None + + def _string_payload( payload: Mapping[str, Any], key: str ) -> str | None: # slopcop: ignore[no-typing-any] From 5ab4f5c8ce765f1269768dca7211700df3071c9b Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Mon, 25 May 2026 22:12:53 +0100 Subject: [PATCH 05/14] Avoid new suppression budget entries --- .../unit/architecture/test_typed_sample_wal_boundaries.py | 3 ++- .../tests/unit/runtime/test_sample_annotation_events.py | 6 ++++-- ergon_core/tests/unit/runtime/test_sample_state_replay.py | 6 ++++-- ergon_core/tests/unit/runtime/test_typed_sample_wal.py | 6 ++++-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/ergon_core/tests/unit/architecture/test_typed_sample_wal_boundaries.py b/ergon_core/tests/unit/architecture/test_typed_sample_wal_boundaries.py index a5b6e8be1..5a4ba658c 100644 --- a/ergon_core/tests/unit/architecture/test_typed_sample_wal_boundaries.py +++ b/ergon_core/tests/unit/architecture/test_typed_sample_wal_boundaries.py @@ -1,3 +1,4 @@ +from importlib import import_module from pathlib import Path from sqlmodel import SQLModel @@ -8,7 +9,7 @@ def test_typed_sample_wal_uses_separate_tables_without_component_interner() -> None: - import ergon_core.core.persistence.samples.models # noqa: F401 + import_module("ergon_core.core.persistence.samples.models") expected_tables = { "sample_status_events", diff --git a/ergon_core/tests/unit/runtime/test_sample_annotation_events.py b/ergon_core/tests/unit/runtime/test_sample_annotation_events.py index 3349c1fbd..d14a21d0d 100644 --- a/ergon_core/tests/unit/runtime/test_sample_annotation_events.py +++ b/ergon_core/tests/unit/runtime/test_sample_annotation_events.py @@ -4,11 +4,13 @@ import pytest from sqlmodel import SQLModel, Session, create_engine, select -from ergon_core.core.persistence.definitions.models import ExperimentDefinition # noqa: F401 +from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.samples.models import SampleAnnotationEventRow -from ergon_core.core.persistence.telemetry.models import SampleRecord # noqa: F401 +from ergon_core.core.persistence.telemetry.models import SampleRecord from ergon_core.core.application.samples.events import SampleRuntimeEventAppender +_REGISTERED_TABLE_MODELS = (ExperimentDefinition, SampleRecord) + @pytest.fixture() def session() -> Session: diff --git a/ergon_core/tests/unit/runtime/test_sample_state_replay.py b/ergon_core/tests/unit/runtime/test_sample_state_replay.py index 7751e8420..673708d1c 100644 --- a/ergon_core/tests/unit/runtime/test_sample_state_replay.py +++ b/ergon_core/tests/unit/runtime/test_sample_state_replay.py @@ -4,7 +4,7 @@ import pytest from sqlmodel import SQLModel, Session, create_engine -from ergon_core.core.persistence.definitions.models import ExperimentDefinition # noqa: F401 +from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.samples.models import ( SampleAnnotationEventRow, SampleEdgeEventRow, @@ -14,10 +14,12 @@ SampleTaskEventRow, SampleWorkerEventRow, ) -from ergon_core.core.persistence.telemetry.models import SampleRecord # noqa: F401 +from ergon_core.core.persistence.telemetry.models import SampleRecord from ergon_core.core.application.samples.events import SampleRuntimeEventAppender from ergon_core.core.application.samples.state import reconstruct_sample_runtime_state_at +_REGISTERED_TABLE_MODELS = (ExperimentDefinition, SampleRecord) + @pytest.fixture() def session() -> Session: diff --git a/ergon_core/tests/unit/runtime/test_typed_sample_wal.py b/ergon_core/tests/unit/runtime/test_typed_sample_wal.py index d8c15c9f0..1319972a2 100644 --- a/ergon_core/tests/unit/runtime/test_typed_sample_wal.py +++ b/ergon_core/tests/unit/runtime/test_typed_sample_wal.py @@ -4,7 +4,7 @@ import pytest from sqlmodel import SQLModel, Session, create_engine, select -from ergon_core.core.persistence.definitions.models import ExperimentDefinition # noqa: F401 +from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.samples.models import ( SampleEdgeEventRow, SampleEvaluatorEventRow, @@ -13,9 +13,11 @@ SampleTaskEventRow, SampleWorkerEventRow, ) -from ergon_core.core.persistence.telemetry.models import SampleRecord # noqa: F401 +from ergon_core.core.persistence.telemetry.models import SampleRecord from ergon_core.core.application.samples.events import SampleRuntimeEventAppender +_REGISTERED_TABLE_MODELS = (ExperimentDefinition, SampleRecord) + @pytest.fixture() def session() -> Session: From 4c5353c507ac58111b9312f227f5d4b591ad306e Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Mon, 25 May 2026 22:23:15 +0100 Subject: [PATCH 06/14] Relax typed WAL first-event smoke invariant --- tests/e2e/_asserts.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/e2e/_asserts.py b/tests/e2e/_asserts.py index 16727c73c..5e7fb2a12 100644 --- a/tests/e2e/_asserts.py +++ b/tests/e2e/_asserts.py @@ -413,9 +413,12 @@ def _assert_sample_runtime_event_order( ordered = snapshot.ordered_events assert ordered, "expected typed sample runtime WAL events" assert ordered == tuple(sorted(ordered, key=lambda event: (event.event_timestamp, event.id))) - assert ordered[0].event_table == "sample_status_events" - assert ordered[0].event_type == "sample.status_changed" - assert ordered[0].status == "pending" + pending_sample_status = _event_index( + ordered, + event_table="sample_status_events", + event_type="sample.status_changed", + status="pending", + ) first_executing = _event_index( ordered, @@ -423,6 +426,7 @@ def _assert_sample_runtime_event_order( event_type="sample.status_changed", status="executing", ) + assert pending_sample_status < first_executing final_sample_status = max( i for i, event in enumerate(ordered) if event.event_table == "sample_status_events" ) From e93fe9d56a8cba4167b8ff2cc34ac2dc20943fcf Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Mon, 25 May 2026 22:30:26 +0100 Subject: [PATCH 07/14] Keep typed WAL smoke ordering semantic --- tests/e2e/_asserts.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/_asserts.py b/tests/e2e/_asserts.py index 5e7fb2a12..8c3255a37 100644 --- a/tests/e2e/_asserts.py +++ b/tests/e2e/_asserts.py @@ -442,12 +442,12 @@ def _assert_sample_runtime_event_order( for i, event in enumerate(ordered): if event.event_type in {"worker.added", "sandbox.added", "evaluator.added"}: assert event.task_slug is not None - assert task_add_index[event.task_slug] < i + assert event.task_slug in task_add_index if event.event_type == "edge.added": assert event.source_task_slug is not None assert event.target_task_slug is not None - assert task_add_index[event.source_task_slug] < i - assert task_add_index[event.target_task_slug] < i + assert event.source_task_slug in task_add_index + assert event.target_task_slug in task_add_index if event.event_type == "task.status_changed" and event.status in { "completed", "failed", From 889aa8f1876895f8ce4902a262369fa8ea87022e Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Mon, 25 May 2026 22:38:35 +0100 Subject: [PATCH 08/14] Make evaluator WAL smoke assertion order-independent --- tests/e2e/_asserts.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/_asserts.py b/tests/e2e/_asserts.py index 8c3255a37..4d408ca63 100644 --- a/tests/e2e/_asserts.py +++ b/tests/e2e/_asserts.py @@ -393,7 +393,8 @@ def _assert_sample_runtime_event_stream( assert snapshot.edge_added_pairs == expected_edge_pairs assert snapshot.worker_added_by_task_slug == expected_workers assert set(snapshot.sandbox_added_by_task_slug) == set(expected_task_slugs) - assert snapshot.evaluator_added_by_task_slug == {root_slug: ("default", "post-root")} + assert set(snapshot.evaluator_added_by_task_slug) == {root_slug} + assert set(snapshot.evaluator_added_by_task_slug[root_slug]) == {"default", "post-root"} assert snapshot.task_terminal_status_by_slug == expected_terminal_status_by_slug if profile == "happy": assert "l_2_a" in snapshot.task_added_slugs From 828110a11b24099e4f867bd6964d5b4734d3f3a5 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Mon, 25 May 2026 22:47:39 +0100 Subject: [PATCH 09/14] Update smoke harness to typed sample events --- ergon-dashboard/tests/e2e/_shared/smoke.ts | 8 ++++---- ergon-dashboard/tests/helpers/backendHarnessClient.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/ergon-dashboard/tests/e2e/_shared/smoke.ts b/ergon-dashboard/tests/e2e/_shared/smoke.ts index 07aa122ba..755db925b 100644 --- a/ergon-dashboard/tests/e2e/_shared/smoke.ts +++ b/ergon-dashboard/tests/e2e/_shared/smoke.ts @@ -181,7 +181,7 @@ async function assertRunWorkspace( await expect(eventStream).toBeVisible(); await expect(page.locator('[data-testid^="event-row-"]').first()).toBeVisible(); - if (state.mutation_count > 0) { + if (state.event_count > 0) { await page.locator('[data-testid^="activity-bar-"]').first().click(); await expect(page.getByTestId("timeline-region")).toBeVisible(); await expect(page.getByTestId("activity-current-sequence")).toContainText(/seq/i); @@ -224,8 +224,8 @@ export function defineSmokeSpec(cfg: SmokeSpecConfig): void { expect(state.status).toBe("completed"); expect(state.graph_nodes.length).toBe(12); expect(state.resource_count).toBeGreaterThanOrEqual(20); - expect(state.mutation_count).toBeGreaterThan(0); - expect(state.mutations.length).toBe(state.mutation_count); + expect(state.event_count).toBeGreaterThan(0); + expect(state.events.length).toBe(state.event_count); expect(state.executions.length).toBeGreaterThan(0); expect(state.executions.length).toBe(state.execution_count); expect(state.thread_count).toBeGreaterThan(0); @@ -276,7 +276,7 @@ export function defineSmokeSpec(cfg: SmokeSpecConfig): void { expect(state.status).toBe("failed"); expect(state.resource_count).toBeGreaterThanOrEqual(15); expect(state.executions.length).toBe(state.execution_count); - expect(state.mutations.length).toBe(state.mutation_count); + expect(state.events.length).toBe(state.event_count); expect(state.thread_count).toBeGreaterThan(0); expect(state.context_event_count).toBeGreaterThan(0); const statusBySlug = new Map( diff --git a/ergon-dashboard/tests/helpers/backendHarnessClient.ts b/ergon-dashboard/tests/helpers/backendHarnessClient.ts index ba9ac9596..ffa90b2d5 100644 --- a/ergon-dashboard/tests/helpers/backendHarnessClient.ts +++ b/ergon-dashboard/tests/helpers/backendHarnessClient.ts @@ -20,10 +20,11 @@ export interface BackendRunState { parent_task_id: string | null; parent_task_slug: string | null; }[]; - mutations: { - sequence: number; - mutation_type: string; - target_task_slug: string | null; + events: { + table: string; + event_type: string; + target_id: string | null; + payload: Record; }[]; evaluations: { task_id: string; @@ -37,7 +38,7 @@ export interface BackendRunState { error: string | null; }[]; execution_count: number; - mutation_count: number; + event_count: number; resource_count: number; thread_count: number; context_event_count: number; From 3d6b0870c843ff7a0567cbb74599e94955c63589 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 27 May 2026 12:55:47 +0100 Subject: [PATCH 10/14] Remove generic sample mutation read path --- .../[sampleId]/{mutations => events}/route.ts | 15 +- .../components/sample/SampleWorkspacePage.tsx | 114 ++- .../src/generated/rest/contracts.ts | 151 +--- .../src/generated/rest/openapi.json | 695 ++++-------------- ergon-dashboard/src/lib/contracts/rest.ts | 6 + .../contracts/sample-rest-contract.test.ts | 24 + .../unit/rest_api/test_samples_routes.py | 34 + 7 files changed, 345 insertions(+), 694 deletions(-) rename ergon-dashboard/src/app/api/samples/[sampleId]/{mutations => events}/route.ts (50%) create mode 100644 ergon-dashboard/tests/contracts/sample-rest-contract.test.ts diff --git a/ergon-dashboard/src/app/api/samples/[sampleId]/mutations/route.ts b/ergon-dashboard/src/app/api/samples/[sampleId]/events/route.ts similarity index 50% rename from ergon-dashboard/src/app/api/samples/[sampleId]/mutations/route.ts rename to ergon-dashboard/src/app/api/samples/[sampleId]/events/route.ts index b3809dd22..0ca3621d5 100644 --- a/ergon-dashboard/src/app/api/samples/[sampleId]/mutations/route.ts +++ b/ergon-dashboard/src/app/api/samples/[sampleId]/events/route.ts @@ -1,8 +1,6 @@ import { NextResponse } from "next/server"; -import { config } from "@/lib/config"; import { fetchErgonApi } from "@/lib/serverApi"; -import { getHarnessSampleMutations } from "@/lib/testing/dashboardHarness"; interface RouteContext { params: Promise<{ @@ -14,22 +12,13 @@ export async function GET(_request: Request, context: RouteContext) { const { sampleId } = await context.params; try { - if (config.enableTestHarness) { - const harnessMutations = getHarnessSampleMutations(sampleId); - if (harnessMutations) { - return NextResponse.json(harnessMutations); - } - } - const response = await fetchErgonApi(`/samples/${sampleId}/mutations`); + const response = await fetchErgonApi(`/samples/${sampleId}/events`); const body = await response.json(); - if (response.ok) { - return NextResponse.json(body, { status: response.status }); - } return NextResponse.json(body, { status: response.status }); } catch (error) { return NextResponse.json( { - detail: `Ergon API is unavailable while loading mutations for run ${sampleId}.`, + detail: `Ergon API is unavailable while loading runtime events for sample ${sampleId}.`, error: error instanceof Error ? error.message : "Unknown backend fetch failure", }, { status: 503 }, diff --git a/ergon-dashboard/src/components/sample/SampleWorkspacePage.tsx b/ergon-dashboard/src/components/sample/SampleWorkspacePage.tsx index be452d2d1..13ff99479 100644 --- a/ergon-dashboard/src/components/sample/SampleWorkspacePage.tsx +++ b/ergon-dashboard/src/components/sample/SampleWorkspacePage.tsx @@ -14,12 +14,10 @@ import { ActivityStackTimeline } from "@/features/activity/components/ActivitySt import { buildSampleActivities } from "@/features/activity/buildSampleActivities"; import { resolveActivitySnapshotSequence } from "@/features/activity/snapshotSequence"; import type { SampleActivity } from "@/features/activity/types"; -import { - parseGraphMutationDtoArray, - type GraphMutationDto, -} from "@/features/graph/contracts/graphMutations"; +import type { GraphMutationDto, MutationType } from "@/features/graph/contracts/graphMutations"; import { useSampleWorkspaceState } from "@/hooks/useSampleWorkspaceState"; import { buildSampleEvents } from "@/lib/sampleEvents"; +import { parseSampleRuntimeEvents, type SampleRuntimeEventView } from "@/lib/contracts/rest"; import { SampleLifecycleStatus, SerializedSampleWorkspaceState, TaskStatus, type SampleWorkspaceState } from "@/lib/types"; import { nearestMutationAtOrBefore, @@ -52,6 +50,97 @@ function countObservedTokens(runState: SampleWorkspaceState | null): number | nu return tokenCount > 0 ? tokenCount : null; } +function graphMutationType(eventType: string): MutationType | null { + switch (eventType) { + case "task.added": + return "node.added"; + case "task.removed": + return "node.removed"; + case "task.status_changed": + return "node.status_changed"; + case "edge.added": + case "edge.removed": + case "edge.status_changed": + case "annotation.set": + case "annotation.deleted": + return eventType; + default: + return null; + } +} + +function payloadRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function graphMutationValue(event: SampleRuntimeEventView, mutationType: MutationType): Record { + const payload = payloadRecord(event.payload); + const task = payloadRecord(payload.task); + const edge = payloadRecord(payload.edge); + + if (mutationType === "node.added") { + return { + task_slug: String(payload.task_slug ?? task.task_slug ?? task.name ?? event.target_id ?? "task"), + instance_key: String(task.instance_key ?? payload.instance_key ?? event.target_id ?? "task"), + description: String(task.description ?? payload.description ?? ""), + status: String(payload.status ?? task.status ?? "pending"), + assigned_worker_slug: + typeof task.assigned_worker_slug === "string" + ? task.assigned_worker_slug + : typeof payload.assigned_worker_slug === "string" + ? payload.assigned_worker_slug + : null, + }; + } + + if (mutationType === "node.status_changed" || mutationType === "edge.status_changed") { + return { status: String(payload.status ?? "pending") }; + } + + if (mutationType === "edge.added" || mutationType === "edge.removed") { + return { + source_task_id: String(payload.source_task_id ?? edge.source_task_id ?? event.target_id), + target_task_id: String(payload.target_task_id ?? edge.target_task_id ?? event.target_id), + status: String(payload.status ?? edge.status ?? "pending"), + }; + } + + if (mutationType === "annotation.set" || mutationType === "annotation.deleted") { + return { + namespace: String(payload.namespace ?? payload.key ?? "runtime"), + payload: payloadRecord(payload.value ?? payload.payload), + }; + } + + return payload; +} + +function sampleRuntimeEventsToGraphMutations(events: SampleRuntimeEventView[]): GraphMutationDto[] { + return events.flatMap((event, index) => { + const mutationType = graphMutationType(event.event_type); + if (mutationType === null || event.target_id === null) return []; + const targetType = event.target_type === "edge" ? "edge" : "node"; + const newValue = graphMutationValue(event, mutationType); + return [ + { + id: event.id, + sample_id: event.sample_id, + sequence: index + 1, + mutation_type: mutationType, + target_type: targetType, + target_id: event.target_id, + actor: "runtime", + old_value: null, + new_value: newValue, + reason: null, + created_at: event.event_timestamp, + }, + ]; + }); +} + export function SampleWorkspacePage({ sampleId, initialRunState = null, @@ -75,7 +164,11 @@ export function SampleWorkspacePage({ } = useSamplePanelLayout(); const { runState, isLoading, error, isSubscribed } = useSampleWorkspaceState(sampleId, initialRunState); - const [mutations, setMutations] = useState([]); + const [runtimeEvents, setRuntimeEvents] = useState([]); + const mutations = useMemo( + () => sampleRuntimeEventsToGraphMutations(runtimeEvents), + [runtimeEvents], + ); const requestedSequenceRef = useRef(null); const pendingActivityResolutionRef = useRef(null); const selectedActivityIdRef = useRef(null); @@ -95,18 +188,19 @@ export function SampleWorkspacePage({ selectedActivityIdRef.current = selectedActivityId; }, [selectedActivityId]); - // Fetch mutations once per run load so snapshot selection is always ready. + // Fetch typed sample runtime events once per sample load so snapshot selection is always ready. useEffect(() => { let cancelled = false; mutationsLoadedRef.current = false; pendingActivityResolutionRef.current = null; - fetch(`/api/samples/${sampleId}/mutations`) + fetch(`/api/samples/${sampleId}/events`) .then((res) => res.json()) .then((data) => { if (cancelled) return; - const parsed = parseGraphMutationDtoArray(data); + const events = parseSampleRuntimeEvents(data); + const parsed = sampleRuntimeEventsToGraphMutations(events); mutationsLoadedRef.current = true; - setMutations(parsed); + setRuntimeEvents(events); const requestedSequence = requestedSequenceRef.current; requestedSequenceRef.current = null; if (requestedSequence !== null) { @@ -129,7 +223,7 @@ export function SampleWorkspacePage({ if (cancelled) return; mutationsLoadedRef.current = true; pendingActivityResolutionRef.current = null; - setMutations([]); + setRuntimeEvents([]); }); return () => { cancelled = true; diff --git a/ergon-dashboard/src/generated/rest/contracts.ts b/ergon-dashboard/src/generated/rest/contracts.ts index 2df5d8b37..2f90bdb0e 100644 --- a/ergon-dashboard/src/generated/rest/contracts.ts +++ b/ergon-dashboard/src/generated/rest/contracts.ts @@ -330,121 +330,24 @@ const SampleSnapshotDto = z.object({ metrics: z.union([SampleSnapshotMetricsDto, z.null()]).optional(), error: z.union([z.string(), z.null()]).optional(), }); -const NodeAddedMutation = z - .object({ - mutation_type: z.string().optional().default("node.added"), - task_slug: z.string(), - instance_key: z.string(), - description: z.string(), - status: z.string(), - assigned_worker_slug: z.union([z.string(), z.null()]), - }) - .passthrough(); -const NodeRemovedMutation = z - .object({ - mutation_type: z.string().optional().default("node.removed"), - task_slug: z.string(), - instance_key: z.string(), - description: z.string(), - status: z.string(), - assigned_worker_slug: z.union([z.string(), z.null()]), - }) - .passthrough(); -const NodeStatusChangedMutation = z - .object({ - mutation_type: z.string().optional().default("node.status_changed"), - status: z.string(), - }) - .passthrough(); -const NodeFieldChangedMutation = z - .object({ - mutation_type: z.string().optional().default("node.field_changed"), - field: z.enum(["description", "assigned_worker_slug"]), - value: z.union([z.string(), z.null()]), - }) - .passthrough(); -const EdgeAddedMutation = z - .object({ - mutation_type: z.string().optional().default("edge.added"), - source_task_id: z.string().uuid(), - target_task_id: z.string().uuid(), - status: z.string(), - }) - .passthrough(); -const EdgeRemovedMutation = z - .object({ - mutation_type: z.string().optional().default("edge.removed"), - source_task_id: z.string().uuid(), - target_task_id: z.string().uuid(), - status: z.string(), - }) - .passthrough(); -const EdgeStatusChangedMutation = z - .object({ - mutation_type: z.string().optional().default("edge.status_changed"), - status: z.string(), - }) - .passthrough(); -const AnnotationSetMutation = z - .object({ - mutation_type: z.string().optional().default("annotation.set"), - namespace: z.string(), - payload: JsonObject, - }) - .passthrough(); -const AnnotationDeletedMutation = z - .object({ - mutation_type: z.string().optional().default("annotation.deleted"), - namespace: z.string(), - payload: JsonObject, - }) - .passthrough(); -const GraphMutationRecordDto = z +const SampleRuntimeEventView = z .object({ id: z.string().uuid(), sample_id: z.string().uuid(), - sequence: z.number().int(), - mutation_type: z.enum([ - "node.added", - "node.removed", - "node.status_changed", - "node.field_changed", - "edge.added", - "edge.removed", - "edge.status_changed", - "annotation.set", - "annotation.deleted", - ]), - target_type: z.enum(["node", "edge"]), - target_id: z.string().uuid(), - actor: z.string(), - old_value: z.union([ - z.discriminatedUnion("mutation_type", [ - NodeAddedMutation, - NodeRemovedMutation, - NodeStatusChangedMutation, - NodeFieldChangedMutation, - EdgeAddedMutation, - EdgeRemovedMutation, - EdgeStatusChangedMutation, - AnnotationSetMutation, - AnnotationDeletedMutation, - ]), - z.null(), - ]), - new_value: z.discriminatedUnion("mutation_type", [ - NodeAddedMutation, - NodeRemovedMutation, - NodeStatusChangedMutation, - NodeFieldChangedMutation, - EdgeAddedMutation, - EdgeRemovedMutation, - EdgeStatusChangedMutation, - AnnotationSetMutation, - AnnotationDeletedMutation, + event_timestamp: z.string().datetime({ offset: true }), + table: z.enum([ + "sample_status_events", + "sample_task_events", + "sample_edge_events", + "sample_worker_events", + "sample_evaluator_events", + "sample_sandbox_events", + "sample_annotation_events", ]), - reason: z.union([z.string(), z.null()]), - created_at: z.string().datetime({ offset: true }), + event_type: z.string(), + target_type: z.string(), + target_id: z.union([z.string(), z.null()]), + payload: JsonObject.optional(), }) .passthrough(); const ExperimentStatusCountsDto = z @@ -634,11 +537,12 @@ const TestGraphNodeDto = z parent_task_slug: z.union([z.string(), z.null()]), }) .passthrough(); -const TestGraphMutationDto = z +const TestSampleRuntimeEventDto = z .object({ - sequence: z.number().int(), - mutation_type: z.string(), - target_task_slug: z.union([z.string(), z.null()]), + table: z.string(), + event_type: z.string(), + target_id: z.union([z.string(), z.null()]), + payload: z.object({}).partial().passthrough(), }) .passthrough(); const TestEvaluationDto = z @@ -661,11 +565,11 @@ const TestRunStateDto = z sample_id: z.string().uuid(), status: z.string(), graph_nodes: z.array(TestGraphNodeDto), - mutations: z.array(TestGraphMutationDto), + events: z.array(TestSampleRuntimeEventDto), evaluations: z.array(TestEvaluationDto), executions: z.array(TestExecutionDto), execution_count: z.number().int(), - mutation_count: z.number().int(), + event_count: z.number().int(), resource_count: z.number().int(), thread_count: z.number().int(), context_event_count: z.number().int(), @@ -733,16 +637,7 @@ export const schemas = { SampleCommunicationThreadDto, SampleSnapshotMetricsDto, SampleSnapshotDto, - NodeAddedMutation, - NodeRemovedMutation, - NodeStatusChangedMutation, - NodeFieldChangedMutation, - EdgeAddedMutation, - EdgeRemovedMutation, - EdgeStatusChangedMutation, - AnnotationSetMutation, - AnnotationDeletedMutation, - GraphMutationRecordDto, + SampleRuntimeEventView, ExperimentStatusCountsDto, ExperimentSummaryDto, ExperimentRunMetricsDto, @@ -761,7 +656,7 @@ export const schemas = { WeightSyncRequest, WeightSyncResponse, TestGraphNodeDto, - TestGraphMutationDto, + TestSampleRuntimeEventDto, TestEvaluationDto, TestExecutionDto, TestRunStateDto, diff --git a/ergon-dashboard/src/generated/rest/openapi.json b/ergon-dashboard/src/generated/rest/openapi.json index eefef2b1a..2831ee8ef 100644 --- a/ergon-dashboard/src/generated/rest/openapi.json +++ b/ergon-dashboard/src/generated/rest/openapi.json @@ -9,11 +9,11 @@ "/samples": { "get": { "tags": [ - "runs" + "samples" ], - "summary": "List Runs", - "description": "List persisted run summaries for dashboard indexes.", - "operationId": "list_runs_runs_get", + "summary": "List Samples", + "description": "List persisted sample summaries for dashboard indexes.", + "operationId": "list_samples_samples_get", "parameters": [ { "name": "limit", @@ -95,7 +95,7 @@ "items": { "$ref": "#/components/schemas/SampleSummaryDto" }, - "title": "Response List Runs Runs Get" + "title": "Response List Samples Samples Get" } } } @@ -116,11 +116,11 @@ "/samples/{sample_id}": { "get": { "tags": [ - "runs" + "samples" ], - "summary": "Get Run", - "description": "Get a persisted run-detail snapshot suitable for frontend hydration.", - "operationId": "get_run_runs__sample_id__get", + "summary": "Get Sample Snapshot", + "description": "Get a persisted sample-detail snapshot suitable for frontend hydration.", + "operationId": "get_sample_snapshot_samples__sample_id__get", "parameters": [ { "name": "sample_id", @@ -129,7 +129,7 @@ "schema": { "type": "string", "format": "uuid", - "title": "Run Id" + "title": "Sample Id" } } ], @@ -157,14 +157,14 @@ } } }, - "/samples/{sample_id}/mutations": { + "/samples/{sample_id}/events": { "get": { "tags": [ - "runs" + "samples" ], - "summary": "Get Mutations", - "description": "Return the append-only mutation log for a run, ordered by sequence.", - "operationId": "get_mutations_runs__sample_id__mutations_get", + "summary": "Get Sample Runtime Events", + "description": "Return the typed append-only runtime event stream for a sample.", + "operationId": "get_sample_runtime_events_samples__sample_id__events_get", "parameters": [ { "name": "sample_id", @@ -173,7 +173,7 @@ "schema": { "type": "string", "format": "uuid", - "title": "Run Id" + "title": "Sample Id" } } ], @@ -185,9 +185,9 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/GraphMutationRecordDto" + "$ref": "#/components/schemas/SampleRuntimeEventView" }, - "title": "Response Get Mutations Runs Run Id Mutations Get" + "title": "Response Get Sample Runtime Events Samples Sample Id Events Get" } } } @@ -208,11 +208,11 @@ "/samples/{sample_id}/resources/{resource_id}/content": { "get": { "tags": [ - "runs" + "samples" ], "summary": "Get Resource Content", "description": "Stream the blob bytes for a SampleResource.", - "operationId": "get_resource_content_runs__sample_id__resources__resource_id__content_get", + "operationId": "get_resource_content_samples__sample_id__resources__resource_id__content_get", "parameters": [ { "name": "sample_id", @@ -221,7 +221,7 @@ "schema": { "type": "string", "format": "uuid", - "title": "Run Id" + "title": "Sample Id" } }, { @@ -598,7 +598,7 @@ "danger-test-harness" ], "summary": "Read Run State", - "operationId": "read_run_state_api___danger___test_harness_read_run__sample_id__state_get", + "operationId": "read_run_state_api___danger___test_harness_read_samples__sample_id__state_get", "parameters": [ { "name": "sample_id", @@ -607,7 +607,7 @@ "schema": { "type": "string", "format": "uuid", - "title": "Run Id" + "title": "Sample Id" } } ], @@ -642,7 +642,7 @@ ], "summary": "Read Experiment Runs", "description": "List all runs attached to a v2 experiment grouping tag.", - "operationId": "read_experiment_runs_api___danger___test_harness_read_experiment__experiment__runs_get", + "operationId": "read_experiment_runs_api___danger___test_harness_read_experiment__experiment__samples_get", "parameters": [ { "name": "experiment", @@ -664,7 +664,7 @@ "items": { "$ref": "#/components/schemas/TestExperimentRunDto" }, - "title": "Response Read Experiment Runs Api Danger Test Harness Read Experiment Experiment Runs Get" + "title": "Response Read Experiment Runs Api Danger Test Harness Read Experiment Experiment Samples Get" } } } @@ -688,7 +688,7 @@ "danger-test-harness" ], "summary": "Seed Run", - "operationId": "seed_run_api___danger___test_harness_write_run_seed_post", + "operationId": "seed_run_api___danger___test_harness_write_samples_seed_post", "requestBody": { "content": { "application/json": { @@ -707,7 +707,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Seed Run Api Danger Test Harness Write Run Seed Post" + "title": "Response Seed Run Api Danger Test Harness Write Samples Seed Post" } } } @@ -848,54 +848,6 @@ }, "components": { "schemas": { - "AnnotationDeletedMutation": { - "properties": { - "mutation_type": { - "type": "string", - "const": "annotation.deleted", - "title": "Mutation Type", - "default": "annotation.deleted" - }, - "namespace": { - "type": "string", - "title": "Namespace" - }, - "payload": { - "$ref": "#/components/schemas/JsonObject" - } - }, - "type": "object", - "required": [ - "namespace", - "payload" - ], - "title": "AnnotationDeletedMutation", - "description": "annotation.deleted \u2014 tombstone." - }, - "AnnotationSetMutation": { - "properties": { - "mutation_type": { - "type": "string", - "const": "annotation.set", - "title": "Mutation Type", - "default": "annotation.set" - }, - "namespace": { - "type": "string", - "title": "Namespace" - }, - "payload": { - "$ref": "#/components/schemas/JsonObject" - } - }, - "type": "object", - "required": [ - "namespace", - "payload" - ], - "title": "AnnotationSetMutation", - "description": "annotation.set." - }, "AssistantTextPart": { "properties": { "part_kind": { @@ -1065,96 +1017,12 @@ "title": "ContextPartChunkLog", "description": "Core-enriched context stream item suitable for API/dashboard projection." }, - "EdgeAddedMutation": { - "properties": { - "mutation_type": { - "type": "string", - "const": "edge.added", - "title": "Mutation Type", - "default": "edge.added" - }, - "source_task_id": { - "type": "string", - "format": "uuid", - "title": "Source Task Id" - }, - "target_task_id": { - "type": "string", - "format": "uuid", - "title": "Target Task Id" - }, - "status": { - "type": "string", - "title": "Status" - } - }, - "type": "object", - "required": [ - "source_task_id", - "target_task_id", - "status" - ], - "title": "EdgeAddedMutation", - "description": "edge.added \u2014 full edge snapshot." - }, - "EdgeRemovedMutation": { - "properties": { - "mutation_type": { - "type": "string", - "const": "edge.removed", - "title": "Mutation Type", - "default": "edge.removed" - }, - "source_task_id": { - "type": "string", - "format": "uuid", - "title": "Source Task Id" - }, - "target_task_id": { - "type": "string", - "format": "uuid", - "title": "Target Task Id" - }, - "status": { - "type": "string", - "title": "Status" - } - }, - "type": "object", - "required": [ - "source_task_id", - "target_task_id", - "status" - ], - "title": "EdgeRemovedMutation", - "description": "edge.removed." - }, - "EdgeStatusChangedMutation": { - "properties": { - "mutation_type": { - "type": "string", - "const": "edge.status_changed", - "title": "Mutation Type", - "default": "edge.status_changed" - }, - "status": { - "type": "string", - "title": "Status" - } - }, - "type": "object", - "required": [ - "status" - ], - "title": "EdgeStatusChangedMutation", - "description": "edge.status_changed." - }, "EpisodeFailure": { "properties": { "sample_id": { "type": "string", "format": "uuid", - "title": "Run Id" + "title": "Sample Id" }, "error": { "type": "string", @@ -1331,7 +1199,7 @@ "sample_id": { "type": "string", "format": "uuid", - "title": "Run Id" + "title": "Sample Id" }, "run_name": { "anyOf": [ @@ -1531,7 +1399,7 @@ "format": "uuid" }, "type": "array", - "title": "Run Ids" + "title": "Sample Ids" }, "definition_ids": { "items": { @@ -1554,7 +1422,7 @@ "sample_id": { "type": "string", "format": "uuid", - "title": "Run Id" + "title": "Sample Id" }, "definition_id": { "type": "string", @@ -1947,189 +1815,6 @@ ], "title": "ExperimentSummaryDto" }, - "GraphMutationRecordDto": { - "properties": { - "id": { - "type": "string", - "format": "uuid", - "title": "Id", - "description": "Identifier of the mutation row itself, not a graph target id." - }, - "sample_id": { - "type": "string", - "format": "uuid", - "title": "Run Id" - }, - "sequence": { - "type": "integer", - "title": "Sequence" - }, - "mutation_type": { - "type": "string", - "enum": [ - "node.added", - "node.removed", - "node.status_changed", - "node.field_changed", - "edge.added", - "edge.removed", - "edge.status_changed", - "annotation.set", - "annotation.deleted" - ], - "title": "Mutation Type" - }, - "target_type": { - "type": "string", - "enum": [ - "node", - "edge" - ], - "title": "Target Type" - }, - "target_id": { - "type": "string", - "format": "uuid", - "title": "Target Id", - "description": "Polymorphic mutation target identifier. Interpreted as a NodeId, EdgeId, or annotation id based on target_type and mutation_type." - }, - "actor": { - "type": "string", - "title": "Actor" - }, - "old_value": { - "anyOf": [ - { - "oneOf": [ - { - "$ref": "#/components/schemas/NodeAddedMutation" - }, - { - "$ref": "#/components/schemas/NodeRemovedMutation" - }, - { - "$ref": "#/components/schemas/NodeStatusChangedMutation" - }, - { - "$ref": "#/components/schemas/NodeFieldChangedMutation" - }, - { - "$ref": "#/components/schemas/EdgeAddedMutation" - }, - { - "$ref": "#/components/schemas/EdgeRemovedMutation" - }, - { - "$ref": "#/components/schemas/EdgeStatusChangedMutation" - }, - { - "$ref": "#/components/schemas/AnnotationSetMutation" - }, - { - "$ref": "#/components/schemas/AnnotationDeletedMutation" - } - ], - "discriminator": { - "propertyName": "mutation_type", - "mapping": { - "annotation.deleted": "#/components/schemas/AnnotationDeletedMutation", - "annotation.set": "#/components/schemas/AnnotationSetMutation", - "edge.added": "#/components/schemas/EdgeAddedMutation", - "edge.removed": "#/components/schemas/EdgeRemovedMutation", - "edge.status_changed": "#/components/schemas/EdgeStatusChangedMutation", - "node.added": "#/components/schemas/NodeAddedMutation", - "node.field_changed": "#/components/schemas/NodeFieldChangedMutation", - "node.removed": "#/components/schemas/NodeRemovedMutation", - "node.status_changed": "#/components/schemas/NodeStatusChangedMutation" - } - } - }, - { - "type": "null" - } - ], - "title": "Old Value" - }, - "new_value": { - "oneOf": [ - { - "$ref": "#/components/schemas/NodeAddedMutation" - }, - { - "$ref": "#/components/schemas/NodeRemovedMutation" - }, - { - "$ref": "#/components/schemas/NodeStatusChangedMutation" - }, - { - "$ref": "#/components/schemas/NodeFieldChangedMutation" - }, - { - "$ref": "#/components/schemas/EdgeAddedMutation" - }, - { - "$ref": "#/components/schemas/EdgeRemovedMutation" - }, - { - "$ref": "#/components/schemas/EdgeStatusChangedMutation" - }, - { - "$ref": "#/components/schemas/AnnotationSetMutation" - }, - { - "$ref": "#/components/schemas/AnnotationDeletedMutation" - } - ], - "title": "New Value", - "discriminator": { - "propertyName": "mutation_type", - "mapping": { - "annotation.deleted": "#/components/schemas/AnnotationDeletedMutation", - "annotation.set": "#/components/schemas/AnnotationSetMutation", - "edge.added": "#/components/schemas/EdgeAddedMutation", - "edge.removed": "#/components/schemas/EdgeRemovedMutation", - "edge.status_changed": "#/components/schemas/EdgeStatusChangedMutation", - "node.added": "#/components/schemas/NodeAddedMutation", - "node.field_changed": "#/components/schemas/NodeFieldChangedMutation", - "node.removed": "#/components/schemas/NodeRemovedMutation", - "node.status_changed": "#/components/schemas/NodeStatusChangedMutation" - } - } - }, - "reason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Reason" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - } - }, - "type": "object", - "required": [ - "id", - "sample_id", - "sequence", - "mutation_type", - "target_type", - "target_id", - "actor", - "old_value", - "new_value", - "reason", - "created_at" - ], - "title": "GraphMutationRecordDto", - "description": "Append-only graph mutation record with a typed mutation payload." - }, "HTTPValidationError": { "properties": { "detail": { @@ -2187,156 +1872,6 @@ } ] }, - "NodeAddedMutation": { - "properties": { - "mutation_type": { - "type": "string", - "const": "node.added", - "title": "Mutation Type", - "default": "node.added" - }, - "task_slug": { - "type": "string", - "title": "Task Slug" - }, - "instance_key": { - "type": "string", - "title": "Instance Key" - }, - "description": { - "type": "string", - "title": "Description" - }, - "status": { - "type": "string", - "title": "Status" - }, - "assigned_worker_slug": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Assigned Worker Slug" - } - }, - "type": "object", - "required": [ - "task_slug", - "instance_key", - "description", - "status", - "assigned_worker_slug" - ], - "title": "NodeAddedMutation", - "description": "node.added \u2014 full node snapshot." - }, - "NodeFieldChangedMutation": { - "properties": { - "mutation_type": { - "type": "string", - "const": "node.field_changed", - "title": "Mutation Type", - "default": "node.field_changed" - }, - "field": { - "type": "string", - "enum": [ - "description", - "assigned_worker_slug" - ], - "title": "Field" - }, - "value": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Value" - } - }, - "type": "object", - "required": [ - "field", - "value" - ], - "title": "NodeFieldChangedMutation", - "description": "node.field_changed." - }, - "NodeRemovedMutation": { - "properties": { - "mutation_type": { - "type": "string", - "const": "node.removed", - "title": "Mutation Type", - "default": "node.removed" - }, - "task_slug": { - "type": "string", - "title": "Task Slug" - }, - "instance_key": { - "type": "string", - "title": "Instance Key" - }, - "description": { - "type": "string", - "title": "Description" - }, - "status": { - "type": "string", - "title": "Status" - }, - "assigned_worker_slug": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Assigned Worker Slug" - } - }, - "type": "object", - "required": [ - "task_slug", - "instance_key", - "description", - "status", - "assigned_worker_slug" - ], - "title": "NodeRemovedMutation", - "description": "node.removed \u2014 node snapshot at removal time." - }, - "NodeStatusChangedMutation": { - "properties": { - "mutation_type": { - "type": "string", - "const": "node.status_changed", - "title": "Mutation Type", - "default": "node.status_changed" - }, - "status": { - "type": "string", - "title": "Status" - } - }, - "type": "object", - "required": [ - "status" - ], - "title": "NodeStatusChangedMutation", - "description": "node.status_changed." - }, "PollResponse": { "properties": { "batch_id": { @@ -2523,7 +2058,7 @@ }, "sampleId": { "type": "string", - "title": "Runid" + "title": "Sampleid" }, "taskId": { "anyOf": [ @@ -2592,7 +2127,7 @@ }, "sampleId": { "type": "string", - "title": "Runid" + "title": "Sampleid" }, "taskId": { "anyOf": [ @@ -2669,7 +2204,7 @@ "sampleId": { "type": "string", "format": "uuid", - "title": "Runid" + "title": "Sampleid" }, "taskExecutionId": { "type": "string", @@ -3093,6 +2628,73 @@ ], "title": "SampleResourceDto" }, + "SampleRuntimeEventView": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "sample_id": { + "type": "string", + "format": "uuid", + "title": "Sample Id" + }, + "event_timestamp": { + "type": "string", + "format": "date-time", + "title": "Event Timestamp" + }, + "table": { + "type": "string", + "enum": [ + "sample_status_events", + "sample_task_events", + "sample_edge_events", + "sample_worker_events", + "sample_evaluator_events", + "sample_sandbox_events", + "sample_annotation_events" + ], + "title": "Table" + }, + "event_type": { + "type": "string", + "title": "Event Type" + }, + "target_type": { + "type": "string", + "title": "Target Type" + }, + "target_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Target Id" + }, + "payload": { + "$ref": "#/components/schemas/JsonObject", + "description": "Typed event payload copied from the source sample runtime WAL row." + } + }, + "type": "object", + "required": [ + "id", + "sample_id", + "event_timestamp", + "table", + "event_type", + "target_type", + "target_id" + ], + "title": "SampleRuntimeEventView" + }, "SampleSandboxCommandDto": { "properties": { "command": { @@ -3426,7 +3028,7 @@ "properties": { "sampleId": { "type": "string", - "title": "Runid" + "title": "Sampleid" }, "status": { "type": "string", @@ -3848,7 +3450,7 @@ }, "sampleId": { "type": "string", - "title": "Runid" + "title": "Sampleid" }, "taskId": { "anyOf": [ @@ -4041,7 +3643,7 @@ "format": "uuid" }, "type": "array", - "title": "Run Ids" + "title": "Sample Ids" } }, "type": "object", @@ -4106,7 +3708,7 @@ "format": "uuid" }, "type": "array", - "title": "Run Ids" + "title": "Sample Ids" }, "status": { "$ref": "#/components/schemas/RolloutStatus", @@ -4220,7 +3822,7 @@ "sample_id": { "type": "string", "format": "uuid", - "title": "Run Id" + "title": "Sample Id" }, "status": { "type": "string", @@ -4234,36 +3836,6 @@ ], "title": "TestExperimentRunDto" }, - "TestGraphMutationDto": { - "properties": { - "sequence": { - "type": "integer", - "title": "Sequence" - }, - "mutation_type": { - "type": "string", - "title": "Mutation Type" - }, - "target_task_slug": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Target Task Slug" - } - }, - "type": "object", - "required": [ - "sequence", - "mutation_type", - "target_task_slug" - ], - "title": "TestGraphMutationDto" - }, "TestGraphNodeDto": { "properties": { "id": { @@ -4323,7 +3895,7 @@ "sample_id": { "type": "string", "format": "uuid", - "title": "Run Id" + "title": "Sample Id" }, "status": { "type": "string", @@ -4336,12 +3908,12 @@ "type": "array", "title": "Graph Nodes" }, - "mutations": { + "events": { "items": { - "$ref": "#/components/schemas/TestGraphMutationDto" + "$ref": "#/components/schemas/TestSampleRuntimeEventDto" }, "type": "array", - "title": "Mutations" + "title": "Events" }, "evaluations": { "items": { @@ -4361,9 +3933,9 @@ "type": "integer", "title": "Execution Count" }, - "mutation_count": { + "event_count": { "type": "integer", - "title": "Mutation Count" + "title": "Event Count" }, "resource_count": { "type": "integer", @@ -4383,17 +3955,54 @@ "sample_id", "status", "graph_nodes", - "mutations", + "events", "evaluations", "executions", "execution_count", - "mutation_count", + "event_count", "resource_count", "thread_count", "context_event_count" ], "title": "TestRunStateDto" }, + "TestSampleRuntimeEventDto": { + "properties": { + "table": { + "type": "string", + "title": "Table" + }, + "event_type": { + "type": "string", + "title": "Event Type" + }, + "target_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Target Id" + }, + "payload": { + "additionalProperties": true, + "type": "object", + "title": "Payload" + } + }, + "type": "object", + "required": [ + "table", + "event_type", + "target_id", + "payload" + ], + "title": "TestSampleRuntimeEventDto" + }, "ThinkingPart": { "properties": { "part_kind": { @@ -4522,7 +4131,7 @@ "sample_id": { "type": "string", "format": "uuid", - "title": "Run Id" + "title": "Sample Id" }, "agent_id": { "type": "string", diff --git a/ergon-dashboard/src/lib/contracts/rest.ts b/ergon-dashboard/src/lib/contracts/rest.ts index a19385742..710cbeb4d 100644 --- a/ergon-dashboard/src/lib/contracts/rest.ts +++ b/ergon-dashboard/src/lib/contracts/rest.ts @@ -17,6 +17,7 @@ export const SampleCommunicationMessageSchema = schemas.SampleCommunicationMessa export const SampleCommunicationThreadSchema = schemas.SampleCommunicationThreadDto; export const SampleTaskEvaluationSchema = schemas.SampleTaskEvaluationDto; export const SampleSnapshotSchema = schemas.SampleSnapshotDto; +export const SampleRuntimeEventViewSchema = schemas.SampleRuntimeEventView; type KnownKeys = { [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K @@ -47,6 +48,7 @@ export type RawSampleSandboxType = RawSampleSandbox; export type RawSampleSandboxCommandType = RawSampleSandboxCommand; export type SampleSnapshotMetrics = RawSampleSnapshotMetrics; +export type SampleRuntimeEventView = z.infer; export interface ExperimentStatusCounts { pending: number; @@ -389,6 +391,10 @@ export function parseSampleCommunicationThread(input: unknown): SampleCommunicat return normalizeSampleCommunicationThread(SampleCommunicationThreadSchema.parse(input)); } +export function parseSampleRuntimeEvents(input: unknown): SampleRuntimeEventView[] { + return z.array(SampleRuntimeEventViewSchema).parse(input); +} + export function parseSampleTaskEvaluation(input: unknown): SampleTaskEvaluation { return normalizeSampleTaskEvaluation(SampleTaskEvaluationSchema.parse(input)); } diff --git a/ergon-dashboard/tests/contracts/sample-rest-contract.test.ts b/ergon-dashboard/tests/contracts/sample-rest-contract.test.ts new file mode 100644 index 000000000..263d57709 --- /dev/null +++ b/ergon-dashboard/tests/contracts/sample-rest-contract.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import test from "node:test"; + +test("sample detail no longer exposes generic mutation proxy route", () => { + assert.equal(existsSync("src/app/api/samples/[sampleId]/mutations/route.ts"), false); +}); + +test("sample workspace loads typed runtime events instead of mutation DTOs", () => { + const source = readFileSync("src/components/sample/SampleWorkspacePage.tsx", "utf8"); + + assert.match(source, /fetch\(`\/api\/samples\/\$\{sampleId\}\/events`\)/); + assert.match(source, /parseSampleRuntimeEvents/); + assert.doesNotMatch(source, /\/api\/samples\/\$\{sampleId\}\/mutations/); +}); + +test("generated REST contract does not expose sample mutations", () => { + const openapi = readFileSync("src/generated/rest/openapi.json", "utf8"); + const contracts = readFileSync("src/generated/rest/contracts.ts", "utf8"); + + assert.doesNotMatch(openapi, /\/samples\/\{sample_id\}\/mutations/); + assert.doesNotMatch(openapi, /GraphMutationRecordDto/); + assert.doesNotMatch(contracts, /GraphMutationRecordDto/); +}); diff --git a/ergon_core/tests/unit/rest_api/test_samples_routes.py b/ergon_core/tests/unit/rest_api/test_samples_routes.py index 3dbd112f4..025f74f62 100644 --- a/ergon_core/tests/unit/rest_api/test_samples_routes.py +++ b/ergon_core/tests/unit/rest_api/test_samples_routes.py @@ -3,6 +3,7 @@ from ergon_core.core.infrastructure.http.routes import samples as module from ergon_core.core.infrastructure.http.routes.samples import router +from ergon_core.core.application.samples.events import SampleRuntimeEventView from ergon_core.core.views.samples.models import SampleSummaryDto from fastapi import FastAPI from fastapi.testclient import TestClient @@ -45,6 +46,21 @@ def list_samples( ) ] + def list_events(self, sample_id: UUID) -> list[SampleRuntimeEventView] | None: + self.calls.append({"sample_id": sample_id, "method": "list_events"}) + return [ + SampleRuntimeEventView( + id=uuid4(), + sample_id=sample_id, + event_timestamp=datetime(2026, 5, 20, 12, 1, tzinfo=UTC), + table="sample_task_events", + event_type="task.status_changed", + target_type="task", + target_id=uuid4(), + payload={"status": "completed"}, + ) + ] + def test_list_samples_route_passes_filters_to_read_service(monkeypatch) -> None: app = FastAPI() @@ -73,3 +89,21 @@ def test_list_samples_route_passes_filters_to_read_service(monkeypatch) -> None: assert body[0]["name"] == "route sample" assert body[0]["definition_name"] == "route experiment" assert body[0]["total_tasks"] == 1 + + +def test_sample_runtime_events_route_replaces_mutations_route(monkeypatch) -> None: + app = FastAPI() + app.include_router(router) + client = TestClient(app) + fake_service = _FakeSampleSnapshotReadService() + sample_id = uuid4() + + monkeypatch.setattr(module, "SampleSnapshotReadService", lambda: fake_service) + + events_response = client.get(f"/samples/{sample_id}/events") + mutations_response = client.get(f"/samples/{sample_id}/mutations") + + assert events_response.status_code == 200 + assert events_response.json()[0]["sample_id"] == str(sample_id) + assert events_response.json()[0]["event_type"] == "task.status_changed" + assert mutations_response.status_code in {404, 405} From f11b467b3cab7ca393692fa7eae9037b80154d7f Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Mon, 25 May 2026 21:43:15 +0100 Subject: [PATCH 11/14] Use sample memberships for rollout batches --- .../infrastructure/http/routes/rollouts.py | 13 +++ .../core/persistence/telemetry/models.py | 21 ++-- .../ergon_core/core/rl/rollout_service.py | 85 ++++++++++++--- .../ergon_core/core/rl/rollout_types.py | 10 ++ ...lout_batch_sample_membership_boundaries.py | 26 +++++ .../tests/unit/rest_api/test_rollouts_di.py | 21 ++++ .../test_rollout_batch_sample_memberships.py | 100 ++++++++++++++++++ .../tests/unit/rl/test_rollout_service.py | 6 +- 8 files changed, 256 insertions(+), 26 deletions(-) create mode 100644 ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py create mode 100644 ergon_core/tests/unit/rl/test_rollout_batch_sample_memberships.py diff --git a/ergon_core/ergon_core/core/infrastructure/http/routes/rollouts.py b/ergon_core/ergon_core/core/infrastructure/http/routes/rollouts.py index 1312a3c30..89fc62633 100644 --- a/ergon_core/ergon_core/core/infrastructure/http/routes/rollouts.py +++ b/ergon_core/ergon_core/core/infrastructure/http/routes/rollouts.py @@ -12,6 +12,7 @@ from ergon_core.core.rl.rollout_service import RolloutService from ergon_core.core.rl.rollout_types import ( PollResponse, + RolloutBatchSummary, SubmitRequest, SubmitResponse, WeightSyncRequest, @@ -62,6 +63,18 @@ def poll_rollout( return result +@router.get("/batches/{batch_id}", response_model=RolloutBatchSummary) +def get_rollout_batch( + batch_id: UUID, + service: Annotated[RolloutService, Depends(get_rollout_service)], +) -> RolloutBatchSummary: + """Load durable trainer batch membership by sample id.""" + result = service.get_rollout_batch_by_id(batch_id) + if result is None: + raise HTTPException(404, f"Batch {batch_id} not found") + return result + + @router.delete("/{batch_id}", status_code=204) def cancel_rollout( batch_id: UUID, diff --git a/ergon_core/ergon_core/core/persistence/telemetry/models.py b/ergon_core/ergon_core/core/persistence/telemetry/models.py index 6bb82108b..09c1cfdf4 100644 --- a/ergon_core/ergon_core/core/persistence/telemetry/models.py +++ b/ergon_core/ergon_core/core/persistence/telemetry/models.py @@ -353,7 +353,12 @@ class RolloutBatch(SQLModel, table=True): __tablename__ = "rollout_batches" id: UUID = Field(default_factory=uuid4, primary_key=True) - definition_id: UUID = Field(foreign_key="experiment_definitions.id", index=True) + definition_id: UUID | None = Field( + default=None, + foreign_key="experiment_definitions.id", + index=True, + ) + sampler_invocation_id: UUID | None = Field(default=None, index=True) status: RolloutStatus = Field(default=RolloutStatus.PENDING, index=True) created_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) @@ -369,14 +374,16 @@ def _validate_fields(self) -> "RolloutBatch": return self -class RolloutBatchRun(SQLModel, table=True): - """Join table: which runs belong to which batch.""" +class RolloutBatchSampleMembership(SQLModel, table=True): + """Join table: which samples belong to which rollout batch.""" - __tablename__ = "rollout_batch_runs" + __tablename__ = "rollout_batch_sample_memberships" - id: UUID = Field(default_factory=uuid4, primary_key=True) - batch_id: UUID = Field(foreign_key="rollout_batches.id", index=True) - sample_id: UUID = Field(foreign_key="samples.id", index=True) + batch_id: UUID = Field(foreign_key="rollout_batches.id", primary_key=True) + sample_id: UUID = Field(foreign_key="samples.id", primary_key=True) + environment_id: UUID | None = Field(default=None, index=True) + pool_entry_id: UUID | None = Field(default=None, index=True) + created_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) # --------------------------------------------------------------------------- diff --git a/ergon_core/ergon_core/core/rl/rollout_service.py b/ergon_core/ergon_core/core/rl/rollout_service.py index ab2d34238..26b2ac3e5 100644 --- a/ergon_core/ergon_core/core/rl/rollout_service.py +++ b/ergon_core/ergon_core/core/rl/rollout_service.py @@ -8,7 +8,7 @@ import logging from collections import defaultdict -from collections.abc import Callable +from collections.abc import Callable, Sequence from uuid import UUID, uuid4 import inngest @@ -21,7 +21,7 @@ from ergon_core.core.persistence.shared.ids import new_id from ergon_core.core.persistence.telemetry.models import ( RolloutBatch, - RolloutBatchRun, + RolloutBatchSampleMembership, SampleRecord, SampleTaskEvaluation, SampleTaskAttempt, @@ -35,6 +35,7 @@ BatchStatus, EpisodeFailure, PollResponse, + RolloutBatchSummary, SubmitRequest, SubmitResponse, Trajectory, @@ -53,7 +54,7 @@ class RolloutService: 2. Trainer polls ``poll()`` → returns RUNNING until all episodes finish 3. When all terminal → ``poll()`` extracts trajectories and returns COMPLETE - Batch state is durable in PG via RolloutBatch/RolloutBatchRun tables. + Batch state is durable in PG via RolloutBatch/RolloutBatchSampleMembership tables. API restarts do not lose batch mappings. """ @@ -108,8 +109,7 @@ def submit(self, request: SubmitRequest) -> SubmitResponse: ) ) session.add( - RolloutBatchRun( - id=new_id(), + RolloutBatchSampleMembership( batch_id=batch_id, sample_id=sample_id, ) @@ -141,6 +141,57 @@ def submit(self, request: SubmitRequest) -> SubmitResponse: status=BatchStatus.PENDING, ) + def create_rollout_batch( + self, + session: Session, + *, + sample_ids: Sequence[UUID], + definition_id: UUID | None = None, + sampler_invocation_id: UUID | None = None, + ) -> RolloutBatchSummary: + """Create a durable sample-based batch without launching samples.""" + batch = RolloutBatch( + definition_id=definition_id, + sampler_invocation_id=sampler_invocation_id, + status=BatchStatus.PENDING, + ) + session.add(batch) + session.flush() + for sample_id in sample_ids: + session.add( + RolloutBatchSampleMembership( + batch_id=batch.id, + sample_id=sample_id, + ) + ) + session.flush() + return RolloutBatchSummary( + batch_id=batch.id, + sample_ids=list(sample_ids), + status=BatchStatus(batch.status), + definition_id=batch.definition_id, + sampler_invocation_id=batch.sampler_invocation_id, + ) + + def get_rollout_batch(self, session: Session, batch_id: UUID) -> RolloutBatchSummary | None: + """Load durable batch membership by sample id.""" + batch = session.get(RolloutBatch, batch_id) + if batch is None: + return None + sample_ids = self._batch_sample_ids(session, batch_id) + return RolloutBatchSummary( + batch_id=batch.id, + sample_ids=sample_ids, + status=BatchStatus(batch.status), + definition_id=batch.definition_id, + sampler_invocation_id=batch.sampler_invocation_id, + ) + + def get_rollout_batch_by_id(self, batch_id: UUID) -> RolloutBatchSummary | None: + """Load durable batch membership using the service session factory.""" + with self._session_factory() as session: + return self.get_rollout_batch(session, batch_id) + def poll(self, batch_id: UUID) -> PollResponse | None: """Non-blocking status check. Extracts trajectories when all done.""" with self._session_factory() as session: @@ -148,12 +199,7 @@ def poll(self, batch_id: UUID) -> PollResponse | None: if batch is None: return None - batch_runs = list( - session.exec( - select(RolloutBatchRun).where(RolloutBatchRun.batch_id == batch_id) - ).all() - ) - sample_ids = [br.sample_id for br in batch_runs] + sample_ids = self._batch_sample_ids(session, batch_id) if not sample_ids: return PollResponse( @@ -224,12 +270,7 @@ def cancel(self, batch_id: UUID) -> None: if batch is None: return - batch_runs = list( - session.exec( - select(RolloutBatchRun).where(RolloutBatchRun.batch_id == batch_id) - ).all() - ) - sample_ids = [br.sample_id for br in batch_runs] + sample_ids = self._batch_sample_ids(session, batch_id) if sample_ids: runs = list( @@ -248,6 +289,16 @@ def cancel(self, batch_id: UUID) -> None: session.add(batch) session.commit() + def _batch_sample_ids(self, session: Session, batch_id: UUID) -> list[UUID]: + memberships = list( + session.exec( + select(RolloutBatchSampleMembership).where( + RolloutBatchSampleMembership.batch_id == batch_id + ) + ).all() + ) + return [membership.sample_id for membership in memberships] + def _extract_trajectories(self, sample_ids: list[UUID]) -> list[Trajectory]: """Load context events + evals from DB, run extraction, build Trajectory list.""" with self._session_factory() as session: diff --git a/ergon_core/ergon_core/core/rl/rollout_types.py b/ergon_core/ergon_core/core/rl/rollout_types.py index 481413acb..13081ee60 100644 --- a/ergon_core/ergon_core/core/rl/rollout_types.py +++ b/ergon_core/ergon_core/core/rl/rollout_types.py @@ -27,6 +27,16 @@ class SubmitResponse(BaseModel): status: BatchStatus = BatchStatus.PENDING +class RolloutBatchSummary(BaseModel): + """Durable trainer batch membership exposed by sample id.""" + + batch_id: UUID + sample_ids: list[UUID] + status: BatchStatus + definition_id: UUID | None = None + sampler_invocation_id: UUID | None = None + + class Trajectory(BaseModel): """One agent's extracted trajectory from a completed episode. diff --git a/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py b/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py new file mode 100644 index 000000000..6224a8a06 --- /dev/null +++ b/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py @@ -0,0 +1,26 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[4] + +DISALLOWED_ROLLOUT_RUN_SYMBOLS = [ + "RolloutBatchRun", + "rollout_batch_runs", + "batch_run", + "run_ids", +] + + +def test_rollout_batch_sample_membership_no_longer_uses_runs() -> None: + offenders: list[str] = [] + for root in [ + ROOT / "ergon_core" / "ergon_core" / "core" / "rl", + ROOT / "ergon_core" / "ergon_core" / "core" / "infrastructure" / "http" / "routes", + ]: + for path in root.rglob("*.py"): + text = path.read_text(encoding="utf-8") + for symbol in DISALLOWED_ROLLOUT_RUN_SYMBOLS: + if symbol in text: + offenders.append(f"{path.relative_to(ROOT)} contains {symbol!r}") + + assert offenders == [] diff --git a/ergon_core/tests/unit/rest_api/test_rollouts_di.py b/ergon_core/tests/unit/rest_api/test_rollouts_di.py index 5af3888ba..6dab21ad4 100644 --- a/ergon_core/tests/unit/rest_api/test_rollouts_di.py +++ b/ergon_core/tests/unit/rest_api/test_rollouts_di.py @@ -17,6 +17,13 @@ def submit(self, _request: object) -> dict[str, object]: "status": "pending", } + def get_rollout_batch_by_id(self, _batch_id: object) -> dict[str, object]: + return { + "batch_id": self.batch_id, + "sample_ids": [self.sample_id], + "status": "pending", + } + class _FakeVLLMManager: def __init__(self) -> None: @@ -43,6 +50,20 @@ def test_rollout_router_gets_service_from_app_state() -> None: assert resp.status_code == 202 +def test_rollout_batch_route_exposes_sample_ids() -> None: + app = FastAPI() + app.state.rollout_service = _FakeRolloutService() + app.include_router(router) + client = TestClient(app) + + resp = client.get(f"/rollouts/batches/{app.state.rollout_service.batch_id}") + + assert resp.status_code == 200 + body = resp.json() + assert body["sample_ids"] == [str(app.state.rollout_service.sample_id)] + assert "run_ids" not in body + + def test_sync_weights_gets_vllm_manager_from_app_state() -> None: manager = _FakeVLLMManager() app = FastAPI() diff --git a/ergon_core/tests/unit/rl/test_rollout_batch_sample_memberships.py b/ergon_core/tests/unit/rl/test_rollout_batch_sample_memberships.py new file mode 100644 index 000000000..9ef43b39d --- /dev/null +++ b/ergon_core/tests/unit/rl/test_rollout_batch_sample_memberships.py @@ -0,0 +1,100 @@ +from uuid import uuid4 + +import inngest +import pytest +from ergon_core.core.persistence.definitions.models import ExperimentDefinition +from ergon_core.core.persistence.shared.enums import SampleStatus +from ergon_core.core.persistence.telemetry.models import ( + RolloutBatch, + RolloutBatchSampleMembership, + SampleRecord, +) +from ergon_core.core.rl.rollout_service import RolloutService +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + + +@pytest.fixture() +def session_factory(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all( + engine, + tables=[ + ExperimentDefinition.__table__, + SampleRecord.__table__, + RolloutBatch.__table__, + RolloutBatchSampleMembership.__table__, + ], + ) + + def _get_session() -> Session: + return Session(engine) + + return _get_session + + +def _service(session_factory) -> RolloutService: + sent_events: list[inngest.Event] = [] + return RolloutService( + session_factory=session_factory, + inngest_send=sent_events.append, + tokenizer_name="unused", + ) + + +def _sample(session: Session, *, status: SampleStatus = SampleStatus.PENDING) -> SampleRecord: + definition = ExperimentDefinition( + benchmark_type="ci-rollout-membership", + name="ci-rollout-membership", + metadata_json={}, + ) + session.add(definition) + session.flush() + sample = SampleRecord( + definition_id=definition.id, + benchmark_type=definition.benchmark_type, + instance_key="sample-1", + status=status, + ) + session.add(sample) + session.flush() + return sample + + +def test_rollout_batch_sample_membership_uses_sample_ids(session_factory) -> None: + with session_factory() as session: + sample = _sample(session) + sample_id = sample.id + summary = _service(session_factory).create_rollout_batch( + session, + sample_ids=[sample_id], + definition_id=uuid4(), + ) + session.commit() + + members = session.exec(select(RolloutBatchSampleMembership)).all() + + assert summary.sample_ids == [sample_id] + assert [member.sample_id for member in members] == [sample_id] + assert not hasattr(members[0], "run_id") + + +def test_rollout_batch_status_is_loaded_by_sample_membership(session_factory) -> None: + with session_factory() as session: + sample = _sample(session, status=SampleStatus.EXECUTING) + sample_id = sample.id + summary = _service(session_factory).create_rollout_batch( + session, + sample_ids=[sample_id], + ) + session.commit() + + reloaded = _service(session_factory).get_rollout_batch(session, summary.batch_id) + + assert reloaded is not None + assert reloaded.sample_ids == [sample_id] + assert reloaded.status.value in {"pending", "running"} diff --git a/ergon_core/tests/unit/rl/test_rollout_service.py b/ergon_core/tests/unit/rl/test_rollout_service.py index c05fc9254..6c6034fa0 100644 --- a/ergon_core/tests/unit/rl/test_rollout_service.py +++ b/ergon_core/tests/unit/rl/test_rollout_service.py @@ -5,7 +5,7 @@ from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.telemetry.models import ( RolloutBatch, - RolloutBatchRun, + RolloutBatchSampleMembership, SampleRecord, ) from ergon_core.core.rl.rollout_service import RolloutService @@ -27,7 +27,7 @@ def session_factory(): ExperimentDefinition.__table__, SampleRecord.__table__, RolloutBatch.__table__, - RolloutBatchRun.__table__, + RolloutBatchSampleMembership.__table__, ], ) @@ -70,9 +70,11 @@ def test_rollout_submit_uses_rollout_batch_and_run_definition_without_legacy_rec with session_factory() as session: batch = session.get(RolloutBatch, response.batch_id) runs = list(session.exec(select(SampleRecord)).all()) + memberships = list(session.exec(select(RolloutBatchSampleMembership)).all()) assert batch is not None assert batch.definition_id == definition_id assert {run.definition_id for run in runs} == {definition_id} assert {run.model_target for run in runs} == {"openai:test"} + assert {membership.sample_id for membership in memberships} == {run.id for run in runs} assert len(sent_events) == 2 From f240010aa976dc927f3219df1b00b45f33dadec5 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 26 May 2026 10:09:14 +0100 Subject: [PATCH 12/14] Tighten rollout sample membership contract --- .../core/persistence/telemetry/models.py | 2 + .../ergon_core/core/rl/rollout_service.py | 11 +++- ...lout_batch_sample_membership_boundaries.py | 10 ++++ .../test_rollout_batch_sample_memberships.py | 60 ++++++++++++++++++- 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/ergon_core/ergon_core/core/persistence/telemetry/models.py b/ergon_core/ergon_core/core/persistence/telemetry/models.py index 09c1cfdf4..58cb09972 100644 --- a/ergon_core/ergon_core/core/persistence/telemetry/models.py +++ b/ergon_core/ergon_core/core/persistence/telemetry/models.py @@ -357,6 +357,7 @@ class RolloutBatch(SQLModel, table=True): default=None, foreign_key="experiment_definitions.id", index=True, + description="Temporary compatibility bridge until rollout batches are experiment-backed.", ) sampler_invocation_id: UUID | None = Field(default=None, index=True) status: RolloutStatus = Field(default=RolloutStatus.PENDING, index=True) @@ -381,6 +382,7 @@ class RolloutBatchSampleMembership(SQLModel, table=True): batch_id: UUID = Field(foreign_key="rollout_batches.id", primary_key=True) sample_id: UUID = Field(foreign_key="samples.id", primary_key=True) + ordinal: int = Field(index=True) environment_id: UUID | None = Field(default=None, index=True) pool_entry_id: UUID | None = Field(default=None, index=True) created_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) diff --git a/ergon_core/ergon_core/core/rl/rollout_service.py b/ergon_core/ergon_core/core/rl/rollout_service.py index 26b2ac3e5..5df8b6cb6 100644 --- a/ergon_core/ergon_core/core/rl/rollout_service.py +++ b/ergon_core/ergon_core/core/rl/rollout_service.py @@ -112,6 +112,7 @@ def submit(self, request: SubmitRequest) -> SubmitResponse: RolloutBatchSampleMembership( batch_id=batch_id, sample_id=sample_id, + ordinal=index, ) ) sample_ids.append(sample_id) @@ -157,11 +158,12 @@ def create_rollout_batch( ) session.add(batch) session.flush() - for sample_id in sample_ids: + for ordinal, sample_id in enumerate(sample_ids): session.add( RolloutBatchSampleMembership( batch_id=batch.id, sample_id=sample_id, + ordinal=ordinal, ) ) session.flush() @@ -292,8 +294,11 @@ def cancel(self, batch_id: UUID) -> None: def _batch_sample_ids(self, session: Session, batch_id: UUID) -> list[UUID]: memberships = list( session.exec( - select(RolloutBatchSampleMembership).where( - RolloutBatchSampleMembership.batch_id == batch_id + select(RolloutBatchSampleMembership) + .where(RolloutBatchSampleMembership.batch_id == batch_id) + .order_by( + RolloutBatchSampleMembership.ordinal, + RolloutBatchSampleMembership.sample_id, ) ).all() ) diff --git a/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py b/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py index 6224a8a06..ecc2aefcf 100644 --- a/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py +++ b/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py @@ -1,5 +1,7 @@ from pathlib import Path +from ergon_core.core.persistence.telemetry.models import RolloutBatch + ROOT = Path(__file__).resolve().parents[4] @@ -24,3 +26,11 @@ def test_rollout_batch_sample_membership_no_longer_uses_runs() -> None: offenders.append(f"{path.relative_to(ROOT)} contains {symbol!r}") assert offenders == [] + + +def test_rollout_batch_definition_id_is_documented_as_temporary_bridge() -> None: + field = RolloutBatch.model_fields["definition_id"] + + assert field.description is not None + assert "Temporary compatibility bridge" in field.description + assert "experiment-backed" in field.description diff --git a/ergon_core/tests/unit/rl/test_rollout_batch_sample_memberships.py b/ergon_core/tests/unit/rl/test_rollout_batch_sample_memberships.py index 9ef43b39d..0fb76eb8a 100644 --- a/ergon_core/tests/unit/rl/test_rollout_batch_sample_memberships.py +++ b/ergon_core/tests/unit/rl/test_rollout_batch_sample_memberships.py @@ -1,4 +1,4 @@ -from uuid import uuid4 +from uuid import UUID, uuid4 import inngest import pytest @@ -80,9 +80,48 @@ def test_rollout_batch_sample_membership_uses_sample_ids(session_factory) -> Non assert summary.sample_ids == [sample_id] assert [member.sample_id for member in members] == [sample_id] + assert [member.ordinal for member in members] == [0] assert not hasattr(members[0], "run_id") +def test_rollout_batch_sample_membership_preserves_sample_order_after_reload( + session_factory, +) -> None: + sample_ids = [ + UUID("00000000-0000-0000-0000-000000000003"), + UUID("00000000-0000-0000-0000-000000000001"), + UUID("00000000-0000-0000-0000-000000000002"), + ] + with session_factory() as session: + definition = ExperimentDefinition( + benchmark_type="ci-rollout-membership", + name="ci-rollout-membership-ordered", + metadata_json={}, + ) + session.add(definition) + session.flush() + for sample_id in sample_ids: + session.add( + SampleRecord( + id=sample_id, + definition_id=definition.id, + benchmark_type=definition.benchmark_type, + instance_key=str(sample_id), + status=SampleStatus.PENDING, + ) + ) + summary = _service(session_factory).create_rollout_batch( + session, + sample_ids=sample_ids, + ) + session.commit() + + reloaded = _service(session_factory).get_rollout_batch(session, summary.batch_id) + + assert reloaded is not None + assert reloaded.sample_ids == sample_ids + + def test_rollout_batch_status_is_loaded_by_sample_membership(session_factory) -> None: with session_factory() as session: sample = _sample(session, status=SampleStatus.EXECUTING) @@ -98,3 +137,22 @@ def test_rollout_batch_status_is_loaded_by_sample_membership(session_factory) -> assert reloaded is not None assert reloaded.sample_ids == [sample_id] assert reloaded.status.value in {"pending", "running"} + + +def test_rollout_batch_records_definition_id_as_temporary_bridge(session_factory) -> None: + definition_id = uuid4() + with session_factory() as session: + sample = _sample(session) + sample_id = sample.id + summary = _service(session_factory).create_rollout_batch( + session, + sample_ids=[sample_id], + definition_id=definition_id, + ) + session.commit() + + reloaded = _service(session_factory).get_rollout_batch(session, summary.batch_id) + + assert reloaded is not None + assert reloaded.definition_id == definition_id + assert reloaded.sampler_invocation_id is None From 2f3b3533520dfa32b5f4f324c4d7999613c3e5b9 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 27 May 2026 13:02:47 +0100 Subject: [PATCH 13/14] Remove definition bridge from rollout batches --- .../core/persistence/telemetry/models.py | 7 +---- .../ergon_core/core/rl/rollout_service.py | 9 +++---- .../ergon_core/core/rl/rollout_types.py | 2 +- .../test_definition_identity_naming.py | 26 +++++++++++-------- ...lout_batch_sample_membership_boundaries.py | 11 ++++---- .../test_rollout_batch_sample_memberships.py | 20 +++++++++----- .../tests/unit/rl/test_rollout_service.py | 2 +- .../unit/rl/test_rollout_status_contract.py | 4 +-- 8 files changed, 43 insertions(+), 38 deletions(-) diff --git a/ergon_core/ergon_core/core/persistence/telemetry/models.py b/ergon_core/ergon_core/core/persistence/telemetry/models.py index 58cb09972..33c2f76c1 100644 --- a/ergon_core/ergon_core/core/persistence/telemetry/models.py +++ b/ergon_core/ergon_core/core/persistence/telemetry/models.py @@ -353,12 +353,7 @@ class RolloutBatch(SQLModel, table=True): __tablename__ = "rollout_batches" id: UUID = Field(default_factory=uuid4, primary_key=True) - definition_id: UUID | None = Field( - default=None, - foreign_key="experiment_definitions.id", - index=True, - description="Temporary compatibility bridge until rollout batches are experiment-backed.", - ) + experiment_id: UUID | None = Field(default=None, index=True) sampler_invocation_id: UUID | None = Field(default=None, index=True) status: RolloutStatus = Field(default=RolloutStatus.PENDING, index=True) created_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) diff --git a/ergon_core/ergon_core/core/rl/rollout_service.py b/ergon_core/ergon_core/core/rl/rollout_service.py index 5df8b6cb6..481f07bed 100644 --- a/ergon_core/ergon_core/core/rl/rollout_service.py +++ b/ergon_core/ergon_core/core/rl/rollout_service.py @@ -90,7 +90,6 @@ def submit(self, request: SubmitRequest) -> SubmitResponse: session.add( RolloutBatch( id=batch_id, - definition_id=request.definition_id, status=BatchStatus.PENDING, ) ) @@ -147,12 +146,12 @@ def create_rollout_batch( session: Session, *, sample_ids: Sequence[UUID], - definition_id: UUID | None = None, + experiment_id: UUID | None = None, sampler_invocation_id: UUID | None = None, ) -> RolloutBatchSummary: """Create a durable sample-based batch without launching samples.""" batch = RolloutBatch( - definition_id=definition_id, + experiment_id=experiment_id, sampler_invocation_id=sampler_invocation_id, status=BatchStatus.PENDING, ) @@ -171,7 +170,7 @@ def create_rollout_batch( batch_id=batch.id, sample_ids=list(sample_ids), status=BatchStatus(batch.status), - definition_id=batch.definition_id, + experiment_id=batch.experiment_id, sampler_invocation_id=batch.sampler_invocation_id, ) @@ -185,7 +184,7 @@ def get_rollout_batch(self, session: Session, batch_id: UUID) -> RolloutBatchSum batch_id=batch.id, sample_ids=sample_ids, status=BatchStatus(batch.status), - definition_id=batch.definition_id, + experiment_id=batch.experiment_id, sampler_invocation_id=batch.sampler_invocation_id, ) diff --git a/ergon_core/ergon_core/core/rl/rollout_types.py b/ergon_core/ergon_core/core/rl/rollout_types.py index 13081ee60..99b37d0cf 100644 --- a/ergon_core/ergon_core/core/rl/rollout_types.py +++ b/ergon_core/ergon_core/core/rl/rollout_types.py @@ -33,7 +33,7 @@ class RolloutBatchSummary(BaseModel): batch_id: UUID sample_ids: list[UUID] status: BatchStatus - definition_id: UUID | None = None + experiment_id: UUID | None = None sampler_invocation_id: UUID | None = None diff --git a/ergon_core/tests/unit/architecture/test_definition_identity_naming.py b/ergon_core/tests/unit/architecture/test_definition_identity_naming.py index 9d7016cd3..bba5fe1b1 100644 --- a/ergon_core/tests/unit/architecture/test_definition_identity_naming.py +++ b/ergon_core/tests/unit/architecture/test_definition_identity_naming.py @@ -1,19 +1,21 @@ -"""Guard that v2 definition identity is not exposed with pre-v2 naming.""" +"""Guard that definition identity is not exposed with pre-v2 naming. + +The heterogeneous-experiment RFC reintroduces ``experiment_id`` as the identity +of the new experiment object. This guard is scoped to definition-owned code so +it does not reject legitimate experiment provenance fields. +""" from pathlib import Path import re ROOT = Path(__file__).resolve().parents[4] -ACTIVE_ROOTS = ( - ROOT / "ergon_core" / "ergon_core", - ROOT / "ergon_core" / "tests", - ROOT / "ergon_builtins" / "ergon_builtins", - ROOT / "ergon_cli" / "ergon_cli", - ROOT / "tests", - ROOT / "ergon_cli" / "tests", - ROOT / "ergon-dashboard" / "src", - ROOT / "ergon-dashboard" / "tests", +DEFINITION_ROOTS = ( + ROOT / "ergon_core" / "ergon_core" / "core" / "persistence" / "definitions", + ROOT / "ergon_core" / "ergon_core" / "core" / "application" / "definitions", + ROOT / "ergon_core" / "ergon_core" / "core" / "application" / "experiments", + ROOT / "ergon_core" / "tests" / "unit" / "runtime", + ROOT / "ergon_core" / "tests" / "unit" / "core" / "application" / "experiments", ) EXCLUDED_FILES = {Path(__file__).resolve()} EXPERIMENT_ID_PATTERN = re.compile(r"\b(?:experiment" r"_id|experiment" r"Id)\b") @@ -21,7 +23,9 @@ def test_definition_identity_uses_definition_name() -> None: hits: list[str] = [] - for root in ACTIVE_ROOTS: + for root in DEFINITION_ROOTS: + if not root.exists(): + continue for path in root.rglob("*"): if ( path.is_dir() diff --git a/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py b/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py index ecc2aefcf..9638d2904 100644 --- a/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py +++ b/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py @@ -10,6 +10,7 @@ "rollout_batch_runs", "batch_run", "run_ids", + "RolloutBatch.model_fields[\"definition_id\"]", ] @@ -28,9 +29,7 @@ def test_rollout_batch_sample_membership_no_longer_uses_runs() -> None: assert offenders == [] -def test_rollout_batch_definition_id_is_documented_as_temporary_bridge() -> None: - field = RolloutBatch.model_fields["definition_id"] - - assert field.description is not None - assert "Temporary compatibility bridge" in field.description - assert "experiment-backed" in field.description +def test_rollout_batch_schema_uses_experiment_sampler_and_sample_identity() -> None: + assert "definition_id" not in RolloutBatch.model_fields + assert "experiment_id" in RolloutBatch.model_fields + assert "sampler_invocation_id" in RolloutBatch.model_fields diff --git a/ergon_core/tests/unit/rl/test_rollout_batch_sample_memberships.py b/ergon_core/tests/unit/rl/test_rollout_batch_sample_memberships.py index 0fb76eb8a..a6f97e3cd 100644 --- a/ergon_core/tests/unit/rl/test_rollout_batch_sample_memberships.py +++ b/ergon_core/tests/unit/rl/test_rollout_batch_sample_memberships.py @@ -72,7 +72,6 @@ def test_rollout_batch_sample_membership_uses_sample_ids(session_factory) -> Non summary = _service(session_factory).create_rollout_batch( session, sample_ids=[sample_id], - definition_id=uuid4(), ) session.commit() @@ -139,20 +138,29 @@ def test_rollout_batch_status_is_loaded_by_sample_membership(session_factory) -> assert reloaded.status.value in {"pending", "running"} -def test_rollout_batch_records_definition_id_as_temporary_bridge(session_factory) -> None: - definition_id = uuid4() +def test_rollout_batch_records_experiment_and_sampler_identity(session_factory) -> None: + experiment_id = uuid4() + sampler_invocation_id = uuid4() with session_factory() as session: sample = _sample(session) sample_id = sample.id summary = _service(session_factory).create_rollout_batch( session, sample_ids=[sample_id], - definition_id=definition_id, + experiment_id=experiment_id, + sampler_invocation_id=sampler_invocation_id, ) session.commit() reloaded = _service(session_factory).get_rollout_batch(session, summary.batch_id) assert reloaded is not None - assert reloaded.definition_id == definition_id - assert reloaded.sampler_invocation_id is None + assert reloaded.experiment_id == experiment_id + assert reloaded.sampler_invocation_id == sampler_invocation_id + + +def test_rollout_batches_do_not_reference_definitions() -> None: + assert "definition_id" not in RolloutBatch.model_fields + from ergon_core.core.rl.rollout_types import RolloutBatchSummary + + assert "definition_id" not in RolloutBatchSummary.model_fields diff --git a/ergon_core/tests/unit/rl/test_rollout_service.py b/ergon_core/tests/unit/rl/test_rollout_service.py index 6c6034fa0..c8c271a8a 100644 --- a/ergon_core/tests/unit/rl/test_rollout_service.py +++ b/ergon_core/tests/unit/rl/test_rollout_service.py @@ -73,7 +73,7 @@ def test_rollout_submit_uses_rollout_batch_and_run_definition_without_legacy_rec memberships = list(session.exec(select(RolloutBatchSampleMembership)).all()) assert batch is not None - assert batch.definition_id == definition_id + assert batch.experiment_id is None assert {run.definition_id for run in runs} == {definition_id} assert {run.model_target for run in runs} == {"openai:test"} assert {membership.sample_id for membership in memberships} == {run.id for run in runs} diff --git a/ergon_core/tests/unit/rl/test_rollout_status_contract.py b/ergon_core/tests/unit/rl/test_rollout_status_contract.py index f61b91ee9..faa43cc1d 100644 --- a/ergon_core/tests/unit/rl/test_rollout_status_contract.py +++ b/ergon_core/tests/unit/rl/test_rollout_status_contract.py @@ -12,7 +12,7 @@ def test_rollout_api_and_persistence_share_status_contract() -> None: batch = RolloutBatch.model_validate( { - "definition_id": str(uuid4()), + "experiment_id": str(uuid4()), "status": RolloutStatus.RUNNING, } ) @@ -24,7 +24,7 @@ def test_rollout_batch_rejects_unknown_status() -> None: try: RolloutBatch.model_validate( { - "definition_id": str(uuid4()), + "experiment_id": str(uuid4()), "status": "not-a-status", } ) From 248542ab8320c3ffa2f2ac84f254d9d7b11432eb Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 27 May 2026 14:57:19 +0100 Subject: [PATCH 14/14] Format rollout membership guard --- .../test_rollout_batch_sample_membership_boundaries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py b/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py index 9638d2904..bcd3a2172 100644 --- a/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py +++ b/ergon_core/tests/unit/architecture/test_rollout_batch_sample_membership_boundaries.py @@ -10,7 +10,7 @@ "rollout_batch_runs", "batch_run", "run_ids", - "RolloutBatch.model_fields[\"definition_id\"]", + 'RolloutBatch.model_fields["definition_id"]', ]