Separate editing from running with a draft/publish split

Edits autosave to flow.draft.json and nodes.draft/ instead of the files the
engine reads, so the pipeline keeps running the published version until
someone publishes. Every save carries the version it was based on: a second
client editing the same flow is refused with 409 and offered the choice
between their version and its own, rather than silently overwriting.

Draft saves no longer rebuild the pipeline; validation and node status for a
draft come from a throwaway build that never touches live state.

Also fixes a latent bug where an empty state backend is falsy, so Pipeline
quietly built itself a second, private state and left message history empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-08-15 23:13:15 +02:00
co-authored by Claude Fable 5
parent 36be6f1081
commit 606ab3c423
18 changed files with 1159 additions and 132 deletions
+125 -27
View File
@@ -34,7 +34,7 @@ from app.flow.schemas import (
NodeTypeInfo,
)
from app.flow.state import as_number
from app.flow.store import FlowExists, FlowNotFound
from app.flow.store import FlowExists, FlowNotFound, StaleVersion
from app.models import Message
router = APIRouter(
@@ -46,11 +46,17 @@ ws_router = APIRouter(prefix="/flows", tags=["flows"])
class FlowDetail(BaseModel):
"""A flow plus how it is currently doing."""
"""A flow plus how it is currently doing.
``definition`` is the working copy — the unpublished draft when there is
one — because that is what the editor shows. ``nodes`` reports the
published flow, which is what is actually running.
"""
definition: FlowDef
nodes: list[NodeStatusPublic] = []
issues: list[ValidationIssue] = []
has_draft: bool = False
class ValidationResult(BaseModel):
@@ -61,6 +67,10 @@ class RenameRequest(BaseModel):
new_name: str
class PublishRequest(BaseModel):
version: int
class RunRequest(BaseModel):
inputs: dict[str, Any] = {}
@@ -70,16 +80,28 @@ class TriggerRequest(BaseModel):
def _detail(controller: FlowController, definition: FlowDef) -> FlowDetail:
name = definition.name
if controller.store.has_draft(name):
# Report the draft the editor is showing, not the version running
# underneath it — otherwise a node the author just broke looks fine.
preview = controller.preview(name)
return FlowDetail(
definition=definition,
nodes=preview.nodes,
issues=preview.issues,
has_draft=True,
)
return FlowDetail(
definition=definition,
nodes=controller.node_statuses(definition.name),
issues=controller.flow_issues(definition.name),
nodes=controller.node_statuses(name),
issues=controller.flow_issues(name),
)
def _read_flow(controller: FlowController, name: str) -> FlowDef:
"""The working copy: the draft when there is one, else what is published."""
try:
return controller.store.read_flow(name)
return controller.store.read_flow(name, draft=True)
except FlowNotFound:
raise HTTPException(status_code=404, detail=f"No flow named '{name}'")
@@ -102,14 +124,19 @@ def _flow_state(controller: FlowController, name: str) -> FlowStatePublic:
def read_flows(controller: FlowControllerDep) -> Any:
"""List every flow."""
summaries = []
for definition in controller.store.read_all():
statuses = controller.node_statuses(definition.name)
for name in controller.store.list_flows():
try:
definition = controller.store.read_flow(name, draft=True)
except FlowNotFound:
continue
statuses = controller.node_statuses(name)
summaries.append(
FlowSummary(
name=definition.name,
title=definition.title,
node_count=len(definition.nodes),
error_count=sum(1 for s in statuses if s.status == "error"),
has_draft=controller.store.has_draft(name),
)
)
return FlowsPublic(data=summaries, count=len(summaries))
@@ -135,7 +162,13 @@ async def save_flow(
definition: FlowDef,
controller: FlowControllerDep,
) -> Any:
"""Create or replace a flow. Saving the same content again changes nothing."""
"""Save unpublished changes to a flow.
This writes a draft: the running pipeline keeps the published version until
someone publishes. ``version`` is the one the editor last saw — a mismatch
means another client saved in between and answers 409 rather than throwing
their work away.
"""
if definition.name != name:
raise HTTPException(
status_code=400, detail="The flow name in the body must match the URL"
@@ -145,10 +178,61 @@ async def save_flow(
if len(duplicates) != len(definition.nodes):
raise HTTPException(status_code=400, detail="Node names must be unique")
changed = await run_in_threadpool(controller.store.write_flow, definition)
if changed:
await controller.reload()
return _detail(controller, definition)
try:
stored = await run_in_threadpool(
controller.store.write_draft, definition, definition.version
)
except StaleVersion as exc:
raise HTTPException(
status_code=409,
detail={"message": str(exc), "current_version": exc.current},
)
return _detail(controller, stored)
@router.post("/{name}/publish", response_model=FlowDetail)
async def publish_flow(
name: str,
body: PublishRequest,
controller: FlowControllerDep,
) -> Any:
"""Deploy the unpublished changes: the engine picks them up from here."""
_read_flow(controller, name)
if not controller.store.has_draft(name):
raise HTTPException(
status_code=400, detail=f"Flow '{name}' has no unpublished changes"
)
try:
published = await run_in_threadpool(
controller.store.publish_flow, name, body.version
)
except StaleVersion as exc:
raise HTTPException(
status_code=409,
detail={"message": str(exc), "current_version": exc.current},
)
await controller.reload()
return _detail(controller, published)
@router.post("/{name}/discard-draft", response_model=FlowDetail)
async def discard_draft(name: str, controller: FlowControllerDep) -> Any:
"""Throw the unpublished changes away and go back to what is running."""
_read_flow(controller, name)
if not controller.store.has_draft(name):
raise HTTPException(
status_code=400, detail=f"Flow '{name}' has no unpublished changes"
)
if not controller.store.is_published(name):
raise HTTPException(
status_code=400,
detail=(
f"Flow '{name}' has never been published — delete it instead of "
"discarding it"
),
)
published = await run_in_threadpool(controller.store.discard_draft, name)
return _detail(controller, published)
@router.delete("/{name}", response_model=Message)
@@ -200,9 +284,9 @@ def read_node_source(
node_id: str,
controller: FlowControllerDep,
) -> Any:
"""Read a node's Python source."""
"""Read a node's Python source, including unpublished edits."""
_read_flow(controller, name)
return NodeSource(code=controller.store.read_node_source(name, node_id))
return NodeSource(code=controller.store.read_node_source(name, node_id, draft=True))
@router.put("/{name}/nodes/{node_id}/source", response_model=NodeStatusPublic)
@@ -212,19 +296,24 @@ async def save_node_source(
source: NodeSource,
controller: FlowControllerDep,
) -> Any:
"""Save a node's source and report whether it loads."""
_read_flow(controller, name)
changed = await run_in_threadpool(
controller.store.write_node_source, name, node_id, source.code
)
if changed:
await controller.reload()
"""Save a node's source as an unpublished edit and report whether it loads.
status = next(
(s for s in controller.node_statuses(name) if s.id == f"{name}.{node_id}"),
None,
The answer comes from compiling the code rather than from the running
pipeline: a draft is not deployed, and compiling is both faster and more
precise about what the author just typed.
"""
_read_flow(controller, name)
await run_in_threadpool(
controller.store.write_node_source, name, node_id, source.code, True
)
error = await run_in_threadpool(
controller.compile_check, name, node_id, source.code
)
return NodeStatusPublic(
id=f"{name}.{node_id}",
status="error" if error else "active",
error=error,
)
return status or NodeStatusPublic(id=f"{name}.{node_id}")
# -----------------------------------------------------------------------------
@@ -236,6 +325,8 @@ async def save_node_source(
def validate_flow(name: str, controller: FlowControllerDep) -> Any:
"""Report what would keep this flow from running."""
_read_flow(controller, name)
if controller.store.has_draft(name):
return ValidationResult(issues=controller.preview(name).issues)
return ValidationResult(issues=controller.flow_issues(name))
@@ -245,10 +336,17 @@ async def run_flow(
body: RunRequest,
controller: FlowControllerDep,
) -> Any:
"""Run every node of a flow once."""
"""Run every node of a flow once.
With unpublished changes this runs the draft, so the button matches what is
on the canvas. Nothing is deployed by running it.
"""
_read_flow(controller, name)
inputs = {qualify(name, key): value for key, value in body.inputs.items()}
await run_in_threadpool(controller.run_flow, name, inputs)
if controller.store.has_draft(name):
await run_in_threadpool(controller.run_preview, name, inputs)
else:
await run_in_threadpool(controller.run_flow, name, inputs)
return _flow_state(controller, name)