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:
co-authored by
Claude Fable 5
parent
36be6f1081
commit
606ab3c423
+125
-27
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user