From 9b4505ccb88172ae3ba3f16eb465781ea52b39ec 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 1/8] 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 d30be3a05..181c4b0e6 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 a2114956b..d4c5828d3 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 783fe5c25..734e0ff0e 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 f4f3b357c..020870884 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 ada90ed30..e0e557751 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: RunId - 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: RunId - 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 8122c9bb8..887c8e12c 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 bdfa330f4..100158522 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.run.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 09e68e0f0..60da7425f 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 59f3c5101..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 RunId -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), - 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 bc464c16877dd7086fcce5042e892e8bd91fc65a 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 2/8] 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 8fe80959117d8197e39f350176f5df6dc35b0df0 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 3/8] 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 b048da1c4c8ffcae4f18a7e09afbdea8cfa9b315 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 4/8] 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 553cd7b5a167a73b8d5483717140a1c09e036273 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 5/8] 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 d79fdacccdb0abaa74168856af6872eccf89c7cb 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 6/8] 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 cacaa8b5b3d440375e542ea64044bd7079952129 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 7/8] 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 a77c678be9bdb9cd97be5d706cc89efeeff0190a 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 8/8] 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;