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():
+2 -1
View File
@@ -183,7 +183,8 @@ cached stages. Cancel active runs before retrying them.
The **Runs** screen filters by flow, status or sweep. Each run shows its
parameters, per-node timings and logs, traceback, metrics, artifacts and
result.
result. Its inputs are listed as the flow declared them when the run was
submitted, and the ones it took from the flow are marked as defaults.
Select two or more runs to compare curves and the parameters that differ.
Plot against step, elapsed seconds or another metric from the same runs.
+12
View File
@@ -3498,6 +3498,18 @@ export const RunDetailSchema = {
title: 'Flow Version',
default: 1
},
declared: {
anyOf: [
{
additionalProperties: true,
type: 'object'
},
{
type: 'null'
}
],
title: 'Declared'
},
nodes: {
items: {
'$ref': '#/components/schemas/RunNodeRow'
+3
View File
@@ -1189,6 +1189,9 @@ export type RunDetail = {
[key: string]: unknown;
};
flow_version?: number;
declared?: ({
[key: string]: unknown;
} | null);
nodes?: Array<RunNodeRow>;
artifacts?: Array<ArtifactRow>;
};
+7 -2
View File
@@ -44,7 +44,7 @@ export function RunDetail({ id }: { id: string }) {
const cancel = useCancelRun()
const retry = useRetryRun()
const names = useMetricNames(id)
const { declared, gone } = useFlowInputs(run?.flow)
const { declared: current, gone } = useFlowInputs(run?.flow)
const [metric, setMetric] = useState("")
if (isPending || !run) return <Skeleton className="h-96 w-full rounded-lg" />
@@ -58,6 +58,11 @@ export function RunDetail({ id }: { id: string }) {
// Said once, where it applies.
const cached = nodes.some((node) => node.status === "cached")
// What the flow declared when this run was submitted. A run from before
// that was kept reads against the flow's current declarations.
const declared = run.declared
? new Map(Object.entries(run.declared))
: current
// Declared order first, so the panel reads the way the flow does; anything
// the run carries that the flow no longer declares still shows, since it is
// what the run was actually given.
@@ -173,7 +178,7 @@ export function RunDetail({ id }: { id: string }) {
<h2 className="font-medium text-sm">Inputs</h2>
{fed.length === 0 ? (
<p className="text-muted-foreground text-sm">
{gone
{gone && !run.declared
? "The run carried no parameters of its own, and its flow is gone, so what it took from the flow cannot be read back."
: "This flow declares no inputs, so there was nothing to choose."}
</p>
+3 -3
View File
@@ -187,9 +187,9 @@ const isMissing = (error: unknown) =>
* ones it names and takes the flow's own for the rest — so this is what turns
* `run.params` from "what was passed" into "what the run actually ran with".
*
* The declarations are the flow's *current* ones, while a run carries the
* `flow_version` it was submitted against. An input added since is shown on an
* older run as a default it never actually received. And the flow may not be
* The declarations are the flow's *current* ones. A run keeps the ones it was
* submitted against in `run.declared`, and the run detail reads those, so this
* is the fallback for a run from before that was kept. And the flow may not be
* there at all: deleting one leaves its runs standing, so `gone` is an ordinary
* answer here rather than a fault, and the screen says so instead of drawing a
* run that declares nothing.