A run keeps the inputs its flow declared at submit

Run.declared (migration 2e7ca7d10044) snapshots the declarations on submit
and import. The run detail's Inputs panel reads them, and a run from before
falls back to the flow's current declarations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 10:20:29 +02:00
co-authored by Claude Opus 5.5
parent edd01ee330
commit df2620cede
10 changed files with 77 additions and 6 deletions
@@ -0,0 +1,30 @@
"""run.declared
The inputs the flow declared when a run was submitted, so its record reads
against that version of the flow and not against whatever the flow declares
now.
Revision ID: 2e7ca7d10044
Revises: 930ffa094aad
Create Date: 2026-10-15
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "2e7ca7d10044"
down_revision = "930ffa094aad"
branch_labels = None
depends_on = None
def upgrade():
# Nullable: a run submitted before this kept no snapshot, and its readers
# fall back to the flow's current declarations.
op.add_column("run", sa.Column("declared", sa.JSON(), nullable=True))
def downgrade():
op.drop_column("run", "declared")
+4
View File
@@ -210,6 +210,10 @@ class RunRow(BaseModel):
class RunDetail(RunRow):
result: dict[str, Any] = Field(default_factory=dict)
flow_version: int = 1
#: The inputs the flow declared when this was submitted, with their
#: declared values. Null for a run from before this was kept, which reads
#: against the flow's current declarations instead.
declared: dict[str, Any] | None = None
nodes: list[RunNodeRow] = Field(default_factory=list)
artifacts: list[ArtifactRow] = Field(default_factory=list)
+10
View File
@@ -454,6 +454,14 @@ def seed_values(
return values
def declared_inputs(flow: FlowDef) -> dict[str, Any]:
"""What a flow declares it can be given, by name, with the declared values.
Kept on each run, since the flow can change after it.
"""
return {one.spec.name: one.initial for one in flow.inputs}
def collect_result(flow: FlowDef, state: StateBackend) -> dict[str, Any]:
"""What the run produced, keyed by message name without the flow prefix."""
prefix = f"{flow.name}."
@@ -925,6 +933,7 @@ class RunService:
code_digest=run_digest(flow),
params=params,
passed=named if passed is None else passed,
declared=declared_inputs(flow),
params_digest=digest_of(params, seed),
seed=seed,
group_id=group_id,
@@ -1047,6 +1056,7 @@ class RunService:
flow_version=flow.version,
params=params,
passed=passed,
declared=declared_inputs(flow),
params_digest=digest_of(params, seed),
seed=seed,
group_id=entry["group_id"],
+3
View File
@@ -362,6 +362,9 @@ class Run(SQLModel, table=True):
#: The names in `params` the caller chose; the rest are the flow's declared
#: values. Null for a run recorded before this was kept.
passed: list[str] | None = Field(sa_column=Column(JSON), default=None)
#: The inputs the flow declared when this was submitted, by name, with
#: their declared values. Null for a run recorded before this was kept.
declared: dict[str, Any] | None = Field(sa_column=Column(JSON), default=None)
#: sha256 over params and seed. Two runs of the same thing share it, which
#: is what makes "have I already run this?" a lookup.
params_digest: str = Field(default="", index=True, max_length=64)
+3
View File
@@ -456,6 +456,9 @@ def test_a_run_records_the_inputs_it_actually_starts_from():
assert spelled.params_digest == defaulted.params_digest
# Only what the caller chose says which value was the flow's own.
assert (defaulted.passed, spelled.passed) == (["epochs"], ["epochs", "lr"])
# And what the flow declared then, so an input it adds later is not
# drawn on this run as a default it never received.
assert defaulted.declared == {"lr": 0.01, "epochs": None}
finally:
with Session(db_engine) as session:
for run in session.exec(select(Run).where(col(Run.id).in_(made))).all():