New run: start a run from the app, on the working copy

The site promises simulated inputs and mocked sensor values, and nothing in
the app was that. A run already is: the values are the caller's, the state is
the run's own namespace, and nothing it computes reaches the live flow. What
was missing was a screen to do it from, and the draft flag being honoured.

`/runs/new` is a flow, a field per declared input, a seed and Run; `/runs`
stays the log. A comma-separated list in a number field expands into the grid
`fluksio sweep --param` builds and goes to the sweep route, so launching one
no longer needs a terminal. Only numbers split: a comma in a string is
content, and one in JSON is syntax.

`RunCreate.draft` was validated at submit and dropped before the run
executed, so "try the working copy" ran the published one. `Run.draft` is a
column now, the driver reads the same copy the submit checked, and a retry
carries it. `FlowSummary.mode` came with it so the rail can say which flows
are batch before one is picked.

Also here: a Retry button on a finished run, which the route has always had
and the UI never did, and parameter cells truncated to their column with the
full value on hover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TTfoK82awm8wvxXhHz3XF
This commit is contained in:
2026-09-02 15:10:24 +02:00
co-authored by Claude Opus 5
parent d471614e6a
commit 5b122341d5
20 changed files with 926 additions and 91 deletions
@@ -0,0 +1,33 @@
"""run.draft
A run submitted against the working copy validated the draft and then executed
what was published. The row now records which copy was asked for, so the
driver reads the same one the submit checked.
Revision ID: a8f6f79e40be
Revises: e7d3b1a9c624
Create Date: 2026-09-02
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "a8f6f79e40be"
down_revision = "e7d3b1a9c624"
branch_labels = None
depends_on = None
def upgrade():
# Not null with a default, so every run recorded before this reads as what
# it was: a run of the published flow.
op.add_column(
"run",
sa.Column("draft", sa.Boolean(), nullable=False, server_default=sa.text("0")),
)
def downgrade():
op.drop_column("run", "draft")
+1
View File
@@ -303,6 +303,7 @@ def read_flows(controller: FlowControllerDep) -> Any:
FlowSummary(
name=definition.name,
title=definition.title,
mode=definition.mode,
node_count=len(definition.nodes),
error_count=sum(1 for s in statuses if s.status == "error"),
has_draft=controller.store.has_draft(name),
+2
View File
@@ -134,6 +134,8 @@ class RunRow(BaseModel):
commit: str = ""
seed: int | None
group_id: str | None
#: Whether it ran the working copy rather than what is published.
draft: bool = False
labels: list[str]
created_at: Any
started_at: Any = None
+11 -2
View File
@@ -906,6 +906,7 @@ class RunService:
group_id=group_id,
cause=cause,
no_cache=no_cache,
draft=draft,
status="queued",
labels=required_labels(flow),
needs=required_resources(flow),
@@ -973,6 +974,10 @@ class RunService:
inputs, the same group, so a sweep is completed rather than repeated.
The stage cache is what makes it cheap — the nodes that finished are
restored rather than run again.
A run of the working copy retries as one, which means whatever the
draft is now rather than what it was — there is no older draft to go
back to, and the point of retrying a draft run is the code on disk.
"""
with Session(db_engine) as session:
run = session.get(Run, run_id)
@@ -989,6 +994,7 @@ class RunService:
cause="retry",
actor=actor,
no_cache=source.no_cache,
draft=source.draft,
parent_id=source.id,
)
@@ -1203,17 +1209,20 @@ class RunService:
sink = MetricSink(run_id)
try:
flow = self.controller.store.read_flow(run.flow)
flow = self.controller.store.read_flow(run.flow, draft=run.draft)
# Read again, now that it is this run's turn: a sweep queues every
# run at once, and the code on disk is free to move in the hours
# before the last of them starts. What the record must name is the
# state that ran, not the state that was submitted.
# state that ran, not the state that was submitted. A draft that
# was published meanwhile reads as the published copy, which is
# the same document under a different name.
digests = node_digests(flow)
run.code_digest = self._restamp(run, run_digest(flow))
state = self._state_factory(f"{RUN_NAMESPACE}:{run_id}")
pipeline = self.controller.build_run_pipeline(
flow,
state=state,
draft=run.draft,
observer=observe,
emission_observer=sink.handle,
run=RunContext(
+2
View File
@@ -355,6 +355,8 @@ class MessageHistory(BaseModel):
class FlowSummary(BaseModel):
name: str
title: str = ""
#: Whether running it means one finite execution or leaving it running.
mode: Literal["live", "batch"] = "live"
node_count: int = 0
error_count: int = 0
has_draft: bool = False
+3
View File
@@ -374,6 +374,9 @@ class Run(SQLModel, table=True):
cause: str = Field(default="api", max_length=32)
#: Re-execute every node, whatever the stage cache holds for it.
no_cache: bool = False
#: Run the unpublished working copy rather than what is published. Read
#: again when the run starts, so a draft published in between is what runs.
draft: bool = False
#: queued, running, ok, error, cancelled or abandoned.
status: str = Field(default="queued", index=True, max_length=16)
#: Why it is where it is: what it waits for, or what went wrong.