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:
@@ -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")
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlmodel import Session, col, select
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.core.db import engine as db_engine
|
||||
from fluksio.flow.artifacts import ArtifactStore
|
||||
from fluksio.flow.controller import FlowController
|
||||
from fluksio.flow.messages import DType, MessageSpec
|
||||
from fluksio.flow.pipeline import NodeOutcome
|
||||
from fluksio.flow.runs import (
|
||||
@@ -27,6 +28,7 @@ from fluksio.flow.runs import (
|
||||
seed_values,
|
||||
)
|
||||
from fluksio.flow.schemas import FlowDef, FlowInput, NodeDef
|
||||
from fluksio.flow.store import FlowStore
|
||||
from fluksio.models import Run, RunArtifact, RunMetric, RunNode
|
||||
|
||||
|
||||
@@ -492,6 +494,56 @@ def test_the_seed_is_recorded_the_same_way_however_it_arrived():
|
||||
session.commit()
|
||||
|
||||
|
||||
DRAFT_SOURCE = "def process():\n return {'answer': 'draft'}\n"
|
||||
PUBLISHED_SOURCE = "def process():\n return {'answer': 'published'}\n"
|
||||
|
||||
|
||||
def test_a_draft_run_executes_the_draft(tmp_path):
|
||||
"""The flag was checked at submit and forgotten by the time it ran.
|
||||
|
||||
Which made trying an edit before publishing it impossible from anywhere:
|
||||
the submit validated the working copy and the driver then executed what
|
||||
was published, and the two only agree when there is no draft.
|
||||
"""
|
||||
flow = FlowDef(
|
||||
name="study",
|
||||
mode="batch",
|
||||
outputs=["answer"],
|
||||
nodes=[
|
||||
NodeDef(
|
||||
id="answer",
|
||||
provides=[MessageSpec(name="answer", dtype=DType.STR)],
|
||||
)
|
||||
],
|
||||
)
|
||||
store = FlowStore(tmp_path / "flows")
|
||||
store.write_flow(flow)
|
||||
store.write_node_source("study", "answer", PUBLISHED_SOURCE)
|
||||
store.write_node_source("study", "answer", DRAFT_SOURCE, draft=True)
|
||||
|
||||
service = RunService(controller=FlowController(store), queue=_Collect())
|
||||
made = []
|
||||
try:
|
||||
for draft, expected in ((True, "draft"), (False, "published")):
|
||||
run = service.submit("study", {}, draft=draft)
|
||||
made.append(run.id)
|
||||
assert run.draft is draft
|
||||
service._drive(run.id)
|
||||
with Session(db_engine) as session:
|
||||
stored = session.get(Run, run.id)
|
||||
assert stored.status == "ok", stored.status_reason
|
||||
assert stored.result == {"answer": expected}
|
||||
finally:
|
||||
with Session(db_engine) as session:
|
||||
for run in session.exec(select(Run).where(col(Run.id).in_(made))).all():
|
||||
session.delete(run)
|
||||
for node in session.exec(
|
||||
select(RunNode).where(col(RunNode.run_id).in_(made))
|
||||
).all():
|
||||
session.delete(node)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_a_retry_is_a_new_run_that_names_the_one_it_repeats():
|
||||
"""The way back from a run an engine restart interrupted.
|
||||
|
||||
@@ -516,6 +568,7 @@ def test_a_retry_is_a_new_run_that_names_the_one_it_repeats():
|
||||
status="abandoned",
|
||||
params={"lr": 0.3},
|
||||
group_id="sweep-9",
|
||||
draft=True,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
@@ -537,6 +590,9 @@ def test_a_retry_is_a_new_run_that_names_the_one_it_repeats():
|
||||
"sweep-9",
|
||||
"retry",
|
||||
)
|
||||
# What it was a run of comes with it: retrying a run of the working
|
||||
# copy that silently ran the published one would say nothing at all.
|
||||
assert again.draft is True
|
||||
assert queue.items[-1].run_id == again.id
|
||||
|
||||
# A run that has not finished is cancelled, not retried.
|
||||
|
||||
Reference in New Issue
Block a user