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
+12
View File
@@ -8,8 +8,20 @@ Deferring because out of scope is fine, but don't mention deferring than.
## Open ## Open
- BUG/UI: enlarge the icon in the sidebar slightly
- BUG/UI: clicking outside the panel does not discard the flow edit panel
- BUG/UI: the graph showed in the node edit panel should also be shown for a specific edge inside the pop-up panel when clicking the edge
- BUG/UI: the "run" button in the toolbar should be stateful; i.e. when a flow is running (i.e. with nodes that automatically emit data such as triggers), it should be possible to pause the flow and resume it. Also for flows without a trigger, the button should change state to allow interrupting a running node
- BUG/UI: the enlarged panel (for code editing) should still maintain its floating style
- BUG/UI: `SidePanel`'s mobile branch does not set `data-testid` on the `SheetContent`, so - BUG/UI: `SidePanel`'s mobile branch does not set `data-testid` on the `SheetContent`, so
`[data-testid=node-panel]` does not exist on a phone. Mobile specs cannot address the panel. `[data-testid=node-panel]` does not exist on a phone. Mobile specs cannot address the panel.
- CHORE/API: node source saves carry no version precondition, so two clients editing the same
node's code are last-writer-wins. The flow document is what the optimistic lock protects;
code files would need their own, and an exact-match one produces false conflicts against a
single client's own interleaved flow and source saves.
- FEAT/UI: publishing and discarding are only reachable while no side panel is open, since the
floating chrome hides for the panel. Editing a node's code and publishing it is therefore
close-panel-then-publish.
- FEAT/UI: no way to point a node parameter at a stored secret. The store and the - FEAT/UI: no way to point a node parameter at a stored secret. The store and the
`{"$secret": "name"}` reference both work, but the params form only produces literals, so a `{"$secret": "name"}` reference both work, but the params form only produces literals, so a
webhook secret typed there lands in `flow.json`. webhook secret typed there lands in `flow.json`.
+6
View File
@@ -54,6 +54,10 @@ external interfaces. See `docs/architecture/structure.canvas` → *Backend M
- [x] Dependency-loop detection and graph validation surfaced as API errors - [x] Dependency-loop detection and graph validation surfaced as API errors
- [ ] MQTT broker / InfluxDB compose services for local development - [ ] MQTT broker / InfluxDB compose services for local development
- [x] Git-based versioning of the flow store (one commit per saved change) - [x] Git-based versioning of the flow store (one commit per saved change)
- [x] Draft/publish split: edits autosave to `flow.draft.json` / `nodes.draft/`,
the engine runs only the published files, and publishing promotes the
draft. Saves carry the version they were based on, so a second client
editing the same flow is refused rather than overwritten
- [ ] Import/export of a flow as human-readable code plus a JSON structure - [ ] Import/export of a flow as human-readable code plus a JSON structure
- [ ] Per-input/-output discretization interval setting - [ ] Per-input/-output discretization interval setting
- [ ] Alert / notification handler - [ ] Alert / notification handler
@@ -88,6 +92,8 @@ React + Vite, primarily desktop but usable on mobile. See `docs/architecture/str
- [x] Embedded code editor (Monaco) for node source - [x] Embedded code editor (Monaco) for node source
- [x] Live values on the edges, with the last payload and its time on click - [x] Live values on the edges, with the last payload and its time on click
- [x] Validation shown on the node it belongs to, and summarised in the dock - [x] Validation shown on the node it belongs to, and summarised in the dock
- [x] Publish control and draft markers in the flow bar, discard in the flow
panel, and a conflict dialog when another client got there first
- [ ] Device assignment per node, selectable from compatible devices - [ ] Device assignment per node, selectable from compatible devices
- [ ] Test-node affordance on the canvas - [ ] Test-node affordance on the canvas
- [ ] User management screens - [ ] User management screens
+125 -27
View File
@@ -34,7 +34,7 @@ from app.flow.schemas import (
NodeTypeInfo, NodeTypeInfo,
) )
from app.flow.state import as_number 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 from app.models import Message
router = APIRouter( router = APIRouter(
@@ -46,11 +46,17 @@ ws_router = APIRouter(prefix="/flows", tags=["flows"])
class FlowDetail(BaseModel): 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 definition: FlowDef
nodes: list[NodeStatusPublic] = [] nodes: list[NodeStatusPublic] = []
issues: list[ValidationIssue] = [] issues: list[ValidationIssue] = []
has_draft: bool = False
class ValidationResult(BaseModel): class ValidationResult(BaseModel):
@@ -61,6 +67,10 @@ class RenameRequest(BaseModel):
new_name: str new_name: str
class PublishRequest(BaseModel):
version: int
class RunRequest(BaseModel): class RunRequest(BaseModel):
inputs: dict[str, Any] = {} inputs: dict[str, Any] = {}
@@ -70,16 +80,28 @@ class TriggerRequest(BaseModel):
def _detail(controller: FlowController, definition: FlowDef) -> FlowDetail: 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( return FlowDetail(
definition=definition, definition=definition,
nodes=controller.node_statuses(definition.name), nodes=controller.node_statuses(name),
issues=controller.flow_issues(definition.name), issues=controller.flow_issues(name),
) )
def _read_flow(controller: FlowController, name: str) -> FlowDef: def _read_flow(controller: FlowController, name: str) -> FlowDef:
"""The working copy: the draft when there is one, else what is published."""
try: try:
return controller.store.read_flow(name) return controller.store.read_flow(name, draft=True)
except FlowNotFound: except FlowNotFound:
raise HTTPException(status_code=404, detail=f"No flow named '{name}'") 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: def read_flows(controller: FlowControllerDep) -> Any:
"""List every flow.""" """List every flow."""
summaries = [] summaries = []
for definition in controller.store.read_all(): for name in controller.store.list_flows():
statuses = controller.node_statuses(definition.name) try:
definition = controller.store.read_flow(name, draft=True)
except FlowNotFound:
continue
statuses = controller.node_statuses(name)
summaries.append( summaries.append(
FlowSummary( FlowSummary(
name=definition.name, name=definition.name,
title=definition.title, title=definition.title,
node_count=len(definition.nodes), node_count=len(definition.nodes),
error_count=sum(1 for s in statuses if s.status == "error"), 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)) return FlowsPublic(data=summaries, count=len(summaries))
@@ -135,7 +162,13 @@ async def save_flow(
definition: FlowDef, definition: FlowDef,
controller: FlowControllerDep, controller: FlowControllerDep,
) -> Any: ) -> 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: if definition.name != name:
raise HTTPException( raise HTTPException(
status_code=400, detail="The flow name in the body must match the URL" 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): if len(duplicates) != len(definition.nodes):
raise HTTPException(status_code=400, detail="Node names must be unique") raise HTTPException(status_code=400, detail="Node names must be unique")
changed = await run_in_threadpool(controller.store.write_flow, definition) try:
if changed: stored = await run_in_threadpool(
await controller.reload() controller.store.write_draft, definition, definition.version
return _detail(controller, definition) )
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) @router.delete("/{name}", response_model=Message)
@@ -200,9 +284,9 @@ def read_node_source(
node_id: str, node_id: str,
controller: FlowControllerDep, controller: FlowControllerDep,
) -> Any: ) -> Any:
"""Read a node's Python source.""" """Read a node's Python source, including unpublished edits."""
_read_flow(controller, name) _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) @router.put("/{name}/nodes/{node_id}/source", response_model=NodeStatusPublic)
@@ -212,19 +296,24 @@ async def save_node_source(
source: NodeSource, source: NodeSource,
controller: FlowControllerDep, controller: FlowControllerDep,
) -> Any: ) -> Any:
"""Save a node's source and report whether it loads.""" """Save a node's source as an unpublished edit 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()
status = next( The answer comes from compiling the code rather than from the running
(s for s in controller.node_statuses(name) if s.id == f"{name}.{node_id}"), pipeline: a draft is not deployed, and compiling is both faster and more
None, 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: def validate_flow(name: str, controller: FlowControllerDep) -> Any:
"""Report what would keep this flow from running.""" """Report what would keep this flow from running."""
_read_flow(controller, name) _read_flow(controller, name)
if controller.store.has_draft(name):
return ValidationResult(issues=controller.preview(name).issues)
return ValidationResult(issues=controller.flow_issues(name)) return ValidationResult(issues=controller.flow_issues(name))
@@ -245,10 +336,17 @@ async def run_flow(
body: RunRequest, body: RunRequest,
controller: FlowControllerDep, controller: FlowControllerDep,
) -> Any: ) -> 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) _read_flow(controller, name)
inputs = {qualify(name, key): value for key, value in body.inputs.items()} 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) return _flow_state(controller, name)
+147 -53
View File
@@ -32,10 +32,10 @@ from app.flow.nodes import (
Node, Node,
) )
from app.flow.pipeline import Pipeline, ValidationIssue from app.flow.pipeline import Pipeline, ValidationIssue
from app.flow.schemas import NodeDef, NodeStatusPublic, NodeTypeInfo from app.flow.schemas import FlowDef, NodeDef, NodeStatusPublic, NodeTypeInfo
from app.flow.secrets import SecretNotFound, resolve_params from app.flow.secrets import SecretNotFound, resolve_params
from app.flow.state import MemoryState, StateBackend from app.flow.state import MemoryState, StateBackend
from app.flow.store import FlowStore from app.flow.store import FlowNotFound, FlowStore
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -58,6 +58,14 @@ class LoadedNode:
error: str | None = None error: str | None = None
@dataclass
class Preview:
"""A draft as it would run, without running it."""
nodes: list[NodeStatusPublic]
issues: list[ValidationIssue]
@dataclass @dataclass
class NodeType: class NodeType:
"""A node type the editor can place on a canvas.""" """A node type the editor can place on a canvas."""
@@ -172,7 +180,7 @@ class FlowController:
fastapi_app: FastAPI | None = None, fastapi_app: FastAPI | None = None,
) -> None: ) -> None:
self.store = store self.store = store
self.state = state or MemoryState() self.state = state if state is not None else MemoryState()
self.events = events self.events = events
self.max_workers = max_workers self.max_workers = max_workers
self.app = fastapi_app self.app = fastapi_app
@@ -197,25 +205,9 @@ class FlowController:
async with self._lock: async with self._lock:
await self._teardown() await self._teardown()
nodes: list[Node] = [] nodes, loaded, initial_values, flow_inputs = self._build_flows(
loaded: dict[str, LoadedNode] = {} [(flow, False) for flow in self.store.read_all()]
initial_values: dict[str, Any] = {} )
# Declared flow inputs, mapped to whether they start with a value.
flow_inputs: dict[str, bool] = {}
for flow in self.store.read_all():
for node_def in flow.nodes:
entry = self._build_node(flow.name, node_def)
loaded[entry.id] = entry
if entry.node is not None:
nodes.append(entry.node)
for flow_input in flow.inputs:
name = qualify(flow.name, flow_input.spec.name)
if not name:
continue
flow_inputs[name] = flow_input.initial is not None
if flow_input.initial is not None:
initial_values[name] = flow_input.initial
self.loaded = loaded self.loaded = loaded
self.pipeline = Pipeline( self.pipeline = Pipeline(
@@ -225,35 +217,7 @@ class FlowController:
max_workers=self.max_workers, max_workers=self.max_workers,
initial_values=initial_values, initial_values=initial_values,
) )
self.issues = self.pipeline.validate(flow_inputs) self.issues = _collect_issues(loaded, self.pipeline, flow_inputs)
self.issues += [
ValidationIssue(
code="node_error",
message=entry.error or "This node could not be loaded.",
flow=entry.flow,
node=entry.id,
)
for entry in loaded.values()
if entry.status is NodeStatus.ERROR
]
# Webhooks are mounted without authentication, so a hook without a
# secret is open to anyone who guesses its URL. Flows written before
# the parameter existed keep running, but say so.
self.issues += [
ValidationIssue(
code="unauthenticated_hook",
message=(
f"Webhook '{entry.node.local_id}' has no secret — "
"anyone who knows its URL can trigger it."
),
flow=entry.flow,
node=entry.id,
)
for entry in loaded.values()
if isinstance(entry.node, HttpNode)
and entry.node.mode == HttpNode.Mode.TRIGGER
and not entry.node.secret
]
await self._activate() await self._activate()
@@ -311,7 +275,35 @@ class FlowController:
# Building # Building
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def _build_node(self, flow: str, node_def: NodeDef) -> LoadedNode: def _build_flows(
self, flows: list[tuple[FlowDef, bool]]
) -> tuple[list[Node], dict[str, LoadedNode], dict[str, Any], dict[str, bool]]:
"""Instantiate the nodes of several flows, each published or draft."""
nodes: list[Node] = []
loaded: dict[str, LoadedNode] = {}
initial_values: dict[str, Any] = {}
# Declared flow inputs, mapped to whether they start with a value.
flow_inputs: dict[str, bool] = {}
for flow, draft in flows:
for node_def in flow.nodes:
entry = self._build_node(flow.name, node_def, draft=draft)
loaded[entry.id] = entry
if entry.node is not None:
nodes.append(entry.node)
for flow_input in flow.inputs:
name = qualify(flow.name, flow_input.spec.name)
if not name:
continue
flow_inputs[name] = flow_input.initial is not None
if flow_input.initial is not None:
initial_values[name] = flow_input.initial
return nodes, loaded, initial_values, flow_inputs
def _build_node(
self, flow: str, node_def: NodeDef, draft: bool = False
) -> LoadedNode:
node_id = f"{flow}.{node_def.id}" node_id = f"{flow}.{node_def.id}"
entry = LoadedNode(id=node_id, flow=flow) entry = LoadedNode(id=node_id, flow=flow)
try: try:
@@ -322,7 +314,7 @@ class FlowController:
params = resolve_params(node_def.params) params = resolve_params(node_def.params)
if node_type.has_source: if node_type.has_source:
code = self.store.read_node_source(flow, node_def.id) code = self.store.read_node_source(flow, node_def.id, draft=draft)
node = Node( node = Node(
f=_load_function(flow, node_def.id, code), f=_load_function(flow, node_def.id, code),
requires=_bound(node_def.requires), requires=_bound(node_def.requires),
@@ -368,6 +360,51 @@ class FlowController:
def flow_issues(self, flow: str) -> list[ValidationIssue]: def flow_issues(self, flow: str) -> list[ValidationIssue]:
return [issue for issue in self.issues if not issue.flow or issue.flow == flow] return [issue for issue in self.issues if not issue.flow or issue.flow == flow]
def preview(self, name: str) -> Preview:
"""Build a flow's unpublished draft without deploying it.
The draft stands in for its published self among the other flows, so
cross-flow messages resolve the way they would after publishing. The
graph is thrown away afterwards and seeded into a throwaway state, so
nothing here touches what is running.
"""
flows: list[tuple[FlowDef, bool]] = [
(flow, False) for flow in self.store.read_all() if flow.name != name
]
try:
flows.append((self.store.read_flow(name, draft=True), True))
except FlowNotFound:
return Preview(nodes=[], issues=[])
nodes, loaded, initial_values, flow_inputs = self._build_flows(flows)
pipeline = Pipeline(
nodes=nodes,
state=MemoryState(),
max_workers=self.max_workers,
initial_values=initial_values,
)
issues = _collect_issues(loaded, pipeline, flow_inputs)
return Preview(
nodes=[
NodeStatusPublic(
id=entry.id, status=entry.status.value, error=entry.error
)
for entry in loaded.values()
if entry.flow == name
],
issues=[
issue for issue in issues if not issue.flow or issue.flow == name
],
)
def compile_check(self, flow: str, node_id: str, code: str) -> str | None:
"""Does this source load? Returns what to show the author, or None."""
try:
_load_function(flow, node_id, code)
except Exception as exc:
return _short_error(exc)
return None
def values(self, flow: str | None = None) -> dict[str, dict[str, Any]]: def values(self, flow: str | None = None) -> dict[str, dict[str, Any]]:
return self.pipeline.values(flow) if self.pipeline else {} return self.pipeline.values(flow) if self.pipeline else {}
@@ -385,6 +422,25 @@ class FlowController:
return return
self.pipeline.run(inputs or {}, nodes=self.pipeline.flow_nodes(flow)) self.pipeline.run(inputs or {}, nodes=self.pipeline.flow_nodes(flow))
def run_preview(self, flow_name: str, inputs: dict[str, Any] | None = None) -> None:
"""Run a flow's unpublished draft once, as the editor shows it.
The nodes are built and run but never activated, so a draft never
acquires subscriptions, schedules or webhooks — only publishing does
that. State and events are the live ones, so values land on the canvas
exactly as they do for a published run.
"""
flow = self.store.read_flow(flow_name, draft=True)
nodes, _, initial_values, _ = self._build_flows([(flow, True)])
pipeline = Pipeline(
nodes=nodes,
state=self.state,
events=self.events,
max_workers=self.max_workers,
initial_values=initial_values,
)
pipeline.run(inputs or {})
def trigger_node(self, node_id: str, values: dict[str, Any] | None = None) -> None: def trigger_node(self, node_id: str, values: dict[str, Any] | None = None) -> None:
"""Feed values into one node. Blocking — call from a worker thread.""" """Feed values into one node. Blocking — call from a worker thread."""
node = self.get_node(node_id) node = self.get_node(node_id)
@@ -405,6 +461,44 @@ def _bound(specs: list[MessageSpec]) -> list[MessageSpec]:
return [spec for spec in specs if spec.name] return [spec for spec in specs if spec.name]
def _collect_issues(
loaded: dict[str, LoadedNode],
pipeline: Pipeline,
flow_inputs: dict[str, bool],
) -> list[ValidationIssue]:
"""Everything wrong with a built graph, whether it is running or not."""
issues = pipeline.validate(flow_inputs)
issues += [
ValidationIssue(
code="node_error",
message=entry.error or "This node could not be loaded.",
flow=entry.flow,
node=entry.id,
)
for entry in loaded.values()
if entry.status is NodeStatus.ERROR
]
# Webhooks are mounted without authentication, so a hook without a secret is
# open to anyone who guesses its URL. Flows written before the parameter
# existed keep running, but say so.
issues += [
ValidationIssue(
code="unauthenticated_hook",
message=(
f"Webhook '{entry.node.local_id}' has no secret — "
"anyone who knows its URL can trigger it."
),
flow=entry.flow,
node=entry.id,
)
for entry in loaded.values()
if isinstance(entry.node, HttpNode)
and entry.node.mode == HttpNode.Mode.TRIGGER
and not entry.node.secret
]
return issues
def _short_error(exc: Exception) -> str: def _short_error(exc: Exception) -> str:
"""One line a node author can act on: what broke, and where in their code. """One line a node author can act on: what broke, and where in their code.
+4 -1
View File
@@ -66,7 +66,10 @@ class Pipeline:
initial_values: dict[str, Any] | None = None, initial_values: dict[str, Any] | None = None,
) -> None: ) -> None:
self._nodes = nodes or [] self._nodes = nodes or []
self._state: StateBackend = state or MemoryState() # An empty state backend is falsy, so this cannot be ``state or ...``:
# that would quietly hand the pipeline a second, private state and
# leave everyone reading the shared one seeing nothing.
self._state: StateBackend = state if state is not None else MemoryState()
self._events = events self._events = events
self._max_workers = max_workers self._max_workers = max_workers
+1
View File
@@ -115,6 +115,7 @@ class FlowSummary(BaseModel):
title: str = "" title: str = ""
node_count: int = 0 node_count: int = 0
error_count: int = 0 error_count: int = 0
has_draft: bool = False
class FlowsPublic(BaseModel): class FlowsPublic(BaseModel):
+162 -21
View File
@@ -4,6 +4,12 @@ Every flow is a directory: ``flow.json`` for the structure, ``nodes/*.py`` for
node logic. The whole tree is a git repository and each saved change is a node logic. The whole tree is a git repository and each saved change is a
commit, so a flow's history is readable with ordinary git tooling and two commit, so a flow's history is readable with ordinary git tooling and two
flows never collide in one file. flows never collide in one file.
Editing is separated from running. The editor writes ``flow.draft.json`` and
``nodes.draft/*.py``; the engine reads only the published ``flow.json`` and
``nodes/*.py``. Publishing promotes the draft over the published files and
removes it, so a flow directory without draft files is simply one with nothing
unpublished — which is what every flow written before this existed looks like.
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,6 +17,7 @@ from __future__ import annotations
import logging import logging
import shutil import shutil
import subprocess import subprocess
import threading
from pathlib import Path from pathlib import Path
from app.flow.schemas import FlowDef from app.flow.schemas import FlowDef
@@ -43,12 +50,34 @@ class FlowExists(ValueError):
return f"There is already a flow named '{self.name}'" return f"There is already a flow named '{self.name}'"
class StaleVersion(ValueError):
"""Someone else saved this flow since the version being written was read."""
def __init__(self, name: str, current: int) -> None:
super().__init__(name)
self.name = name
self.current = current
def __str__(self) -> str:
return f"Flow '{self.name}' has changed since you loaded it"
def _same_content(left: FlowDef, right: FlowDef) -> bool:
"""Equal but for the version counter, which the server owns."""
return left.model_copy(update={"version": 0}) == right.model_copy(
update={"version": 0}
)
class FlowStore: class FlowStore:
"""Reads and writes flows, committing every change.""" """Reads and writes flows, committing every change."""
def __init__(self, root: Path) -> None: def __init__(self, root: Path) -> None:
self.root = root self.root = root
self.root.mkdir(parents=True, exist_ok=True) self.root.mkdir(parents=True, exist_ok=True)
# Draft writes are check-and-set, so two clients saving at once must not
# interleave between reading the current version and writing the next.
self._write_lock = threading.Lock()
if not (self.root / ".git").exists(): if not (self.root / ".git").exists():
self._git("init", "-q") self._git("init", "-q")
self._commit("Initialise flow store", allow_empty=True) self._commit("Initialise flow store", allow_empty=True)
@@ -91,28 +120,57 @@ class FlowStore:
def _flow_file(self, name: str) -> Path: def _flow_file(self, name: str) -> Path:
return self._flow_dir(name) / "flow.json" return self._flow_dir(name) / "flow.json"
def _draft_file(self, name: str) -> Path:
return self._flow_dir(name) / "flow.draft.json"
def _node_file(self, flow: str, node_id: str) -> Path: def _node_file(self, flow: str, node_id: str) -> Path:
return self._flow_dir(flow) / "nodes" / f"{node_id}.py" return self._flow_dir(flow) / "nodes" / f"{node_id}.py"
def _draft_nodes_dir(self, flow: str) -> Path:
return self._flow_dir(flow) / "nodes.draft"
def _draft_node_file(self, flow: str, node_id: str) -> Path:
return self._draft_nodes_dir(flow) / f"{node_id}.py"
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Flows # Flows
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def list_flows(self) -> list[str]: def list_flows(self) -> list[str]:
return sorted(path.parent.name for path in self.root.glob("*/flow.json")) """Every flow the editor knows, published or not."""
names = {path.parent.name for path in self.root.glob("*/flow.json")}
names |= {path.parent.name for path in self.root.glob("*/flow.draft.json")}
return sorted(names)
def exists(self, name: str) -> bool: def exists(self, name: str) -> bool:
return self._flow_file(name).exists() or self._draft_file(name).exists()
def is_published(self, name: str) -> bool:
return self._flow_file(name).exists() return self._flow_file(name).exists()
def read_flow(self, name: str) -> FlowDef: def has_draft(self, name: str) -> bool:
"""Are there unpublished changes — to the structure or to any source?"""
if self._draft_file(name).exists():
return True
drafts = self._draft_nodes_dir(name)
return drafts.exists() and any(drafts.glob("*.py"))
def read_flow(self, name: str, draft: bool = False) -> FlowDef:
"""The published flow, or with ``draft`` the working copy."""
if draft:
path = self._draft_file(name)
if path.exists():
return FlowDef.model_validate_json(path.read_text())
path = self._flow_file(name) path = self._flow_file(name)
if not path.exists(): if not path.exists():
raise FlowNotFound(name) raise FlowNotFound(name)
return FlowDef.model_validate_json(path.read_text()) return FlowDef.model_validate_json(path.read_text())
def read_all(self) -> list[FlowDef]: def read_all(self) -> list[FlowDef]:
"""Every published flow — what the engine runs."""
flows = [] flows = []
for name in self.list_flows(): for path in sorted(self.root.glob("*/flow.json")):
name = path.parent.name
try: try:
flows.append(self.read_flow(name)) flows.append(self.read_flow(name))
except Exception: except Exception:
@@ -120,7 +178,7 @@ class FlowStore:
return flows return flows
def write_flow(self, flow: FlowDef) -> bool: def write_flow(self, flow: FlowDef) -> bool:
"""Save a flow. Returns False when nothing actually changed.""" """Publish a flow directly. Returns False when nothing actually changed."""
path = self._flow_file(flow.name) path = self._flow_file(flow.name)
content = flow.model_dump_json(indent=2) + "\n" content = flow.model_dump_json(indent=2) + "\n"
if path.exists() and path.read_text() == content: if path.exists() and path.read_text() == content:
@@ -131,6 +189,68 @@ class FlowStore:
self._commit(f"Update flow '{flow.name}'") self._commit(f"Update flow '{flow.name}'")
return True return True
def write_draft(self, flow: FlowDef, base_version: int) -> FlowDef:
"""Save unpublished changes, refusing to overwrite someone else's.
``base_version`` is the version the editor last saw. It has to match
what is stored, otherwise another client saved in between and this
write would silently discard their work.
"""
with self._write_lock:
current: FlowDef | None = None
if self.exists(flow.name):
current = self.read_flow(flow.name, draft=True)
if base_version != current.version:
raise StaleVersion(flow.name, current.version)
if _same_content(current, flow):
return current
stored = flow.model_copy(
update={"version": (current.version if current else 0) + 1}
)
path = self._draft_file(flow.name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(stored.model_dump_json(indent=2) + "\n")
self._commit(f"Update draft of flow '{flow.name}'")
return stored
def publish_flow(self, name: str, base_version: int) -> FlowDef:
"""Promote the working copy to what the engine runs."""
with self._write_lock:
current = self.read_flow(name, draft=True)
if base_version != current.version:
raise StaleVersion(name, current.version)
draft = self._draft_file(name)
if draft.exists():
self._flow_file(name).write_text(
current.model_dump_json(indent=2) + "\n"
)
draft.unlink()
drafts = self._draft_nodes_dir(name)
if drafts.exists():
target = self._flow_dir(name) / "nodes"
target.mkdir(parents=True, exist_ok=True)
for source in sorted(drafts.glob("*.py")):
shutil.copy2(source, target / source.name)
shutil.rmtree(drafts)
self._commit(f"Publish flow '{name}'")
return current
def discard_draft(self, name: str) -> FlowDef:
"""Throw the unpublished changes away and go back to what is running."""
with self._write_lock:
draft = self._draft_file(name)
if draft.exists():
draft.unlink()
drafts = self._draft_nodes_dir(name)
if drafts.exists():
shutil.rmtree(drafts)
self._commit(f"Discard draft of flow '{name}'")
return self.read_flow(name)
def delete_flow(self, name: str) -> None: def delete_flow(self, name: str) -> None:
directory = self._flow_dir(name) directory = self._flow_dir(name)
if not directory.exists(): if not directory.exists():
@@ -150,22 +270,30 @@ class FlowStore:
if self.exists(new_name): if self.exists(new_name):
raise FlowExists(new_name) raise FlowExists(new_name)
flow = self.read_flow(name)
self._flow_dir(name).rename(self._flow_dir(new_name)) self._flow_dir(name).rename(self._flow_dir(new_name))
renamed = flow.model_copy(update={"name": new_name}) # Published and draft alike carry the name, and both have to move.
self._flow_file(new_name).write_text(renamed.model_dump_json(indent=2) + "\n") for path in (self._flow_file(new_name), self._draft_file(new_name)):
if not path.exists():
for other in self.read_all():
if other.name == new_name:
continue continue
if self._retarget(other, f"{name}.", f"{new_name}."): flow = FlowDef.model_validate_json(path.read_text())
self._flow_file(other.name).write_text( path.write_text(
other.model_dump_json(indent=2) + "\n" flow.model_copy(update={"name": new_name}).model_dump_json(indent=2)
) + "\n"
)
for other in self.list_flows():
if other == new_name:
continue
for path in (self._flow_file(other), self._draft_file(other)):
if not path.exists():
continue
flow = FlowDef.model_validate_json(path.read_text())
if self._retarget(flow, f"{name}.", f"{new_name}."):
path.write_text(flow.model_dump_json(indent=2) + "\n")
self._commit(f"Rename flow '{name}' to '{new_name}'") self._commit(f"Rename flow '{name}' to '{new_name}'")
return renamed return self.read_flow(new_name, draft=True)
@staticmethod @staticmethod
def _retarget(flow: FlowDef, old_prefix: str, new_prefix: str) -> bool: def _retarget(flow: FlowDef, old_prefix: str, new_prefix: str) -> bool:
@@ -186,18 +314,31 @@ class FlowStore:
# Node source # Node source
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def read_node_source(self, flow: str, node_id: str) -> str: def read_node_source(self, flow: str, node_id: str, draft: bool = False) -> str:
if draft:
path = self._draft_node_file(flow, node_id)
if path.exists():
return path.read_text()
path = self._node_file(flow, node_id) path = self._node_file(flow, node_id)
return path.read_text() if path.exists() else DEFAULT_SOURCE return path.read_text() if path.exists() else DEFAULT_SOURCE
def write_node_source(self, flow: str, node_id: str, code: str) -> bool: def write_node_source(
path = self._node_file(flow, node_id) self, flow: str, node_id: str, code: str, draft: bool = False
if path.exists() and path.read_text() == code: ) -> bool:
return False if draft:
# Compared against the working copy, so re-saving what is already
# published does not create a draft file that never gets promoted.
if self.read_node_source(flow, node_id, draft=True) == code:
return False
path = self._draft_node_file(flow, node_id)
else:
path = self._node_file(flow, node_id)
if path.exists() and path.read_text() == code:
return False
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(code) path.write_text(code)
self._commit(f"Update node '{flow}.{node_id}'") self._commit(f"Update {'draft of ' if draft else ''}node '{flow}.{node_id}'")
return True return True
def delete_node_source(self, flow: str, node_id: str) -> None: def delete_node_source(self, flow: str, node_id: str) -> None:
+83
View File
@@ -110,6 +110,89 @@ def test_running_a_flow_produces_values(
assert response.json()["values"]["demo.reading"]["value"] == 21.5 assert response.json()["values"]["demo.reading"]["value"] == 21.5
def test_editing_does_not_deploy_until_published(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
saved = client.put(
f"{PREFIX}/staged", headers=superuser_token_headers, json=a_flow("staged")
).json()
assert saved["has_draft"] is True
# Nothing is running yet, so the engine knows no nodes of this flow.
assert client.get(f"{PREFIX}/staged/state", headers=superuser_token_headers).json()[
"nodes"
] == []
published = client.post(
f"{PREFIX}/staged/publish",
headers=superuser_token_headers,
json={"version": saved["definition"]["version"]},
)
assert published.status_code == 200
assert published.json()["has_draft"] is False
assert {
node["id"]
for node in client.get(
f"{PREFIX}/staged/state", headers=superuser_token_headers
).json()["nodes"]
} == {"staged.sensor", "staged.logger"}
client.delete(f"{PREFIX}/staged", headers=superuser_token_headers)
def test_a_stale_save_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
saved = client.put(
f"{PREFIX}/contested", headers=superuser_token_headers, json=a_flow("contested")
).json()
stale = saved["definition"]
client.put(
f"{PREFIX}/contested",
headers=superuser_token_headers,
json={**stale, "title": "Mine"},
)
response = client.put(
f"{PREFIX}/contested",
headers=superuser_token_headers,
json={**stale, "title": "Theirs"},
)
assert response.status_code == 409
assert response.json()["detail"]["current_version"] == stale["version"] + 1
client.delete(f"{PREFIX}/contested", headers=superuser_token_headers)
def test_discarding_a_draft_restores_what_is_running(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
saved = client.put(
f"{PREFIX}/reverted", headers=superuser_token_headers, json=a_flow("reverted")
).json()
client.post(
f"{PREFIX}/reverted/publish",
headers=superuser_token_headers,
json={"version": saved["definition"]["version"]},
)
published = client.get(f"{PREFIX}/reverted", headers=superuser_token_headers).json()
client.put(
f"{PREFIX}/reverted",
headers=superuser_token_headers,
json={**published["definition"], "title": "Scratch that"},
)
response = client.post(
f"{PREFIX}/reverted/discard-draft", headers=superuser_token_headers
)
assert response.status_code == 200
assert response.json()["has_draft"] is False
assert response.json()["definition"]["title"] == "Demo"
client.delete(f"{PREFIX}/reverted", headers=superuser_token_headers)
def test_unconnected_input_is_surfaced( def test_unconnected_input_is_surfaced(
client: TestClient, superuser_token_headers: dict[str, str] client: TestClient, superuser_token_headers: dict[str, str]
) -> None: ) -> None:
+133
View File
@@ -0,0 +1,133 @@
"""Editing writes drafts; only publishing changes what the engine reads."""
from pathlib import Path
import pytest
from app.flow.messages import MessageSpec
from app.flow.schemas import FlowDef, NodeDef
from app.flow.store import FlowStore, StaleVersion
SOURCE = "def process(params):\n return {}\n"
EDITED = "def process(params):\n return {'temp': 1}\n"
@pytest.fixture
def store(tmp_path: Path) -> FlowStore:
return FlowStore(tmp_path / "flows")
def a_flow(title: str = "") -> FlowDef:
return FlowDef(
name="heating",
title=title,
nodes=[NodeDef(id="sensor", provides=[MessageSpec(name="temp")])],
)
def test_a_draft_is_invisible_to_the_engine(store: FlowStore):
store.write_draft(a_flow(), 0)
assert store.has_draft("heating")
assert store.list_flows() == ["heating"] # the editor sees it
assert store.read_all() == [] # the engine does not
def test_the_version_counter_advances_per_save(store: FlowStore):
first = store.write_draft(a_flow(), 0)
assert first.version == 1
second = store.write_draft(a_flow(title="Warm"), first.version)
assert second.version == 2
def test_resaving_identical_content_neither_bumps_nor_commits(store: FlowStore):
stored = store.write_draft(a_flow(), 0)
again = store.write_draft(a_flow(), stored.version)
assert again.version == stored.version
def test_a_stale_write_is_refused(store: FlowStore):
store.write_draft(a_flow(), 0)
store.write_draft(a_flow(title="Mine"), 1)
# A second client still holding version 1 must not overwrite version 2.
with pytest.raises(StaleVersion) as excinfo:
store.write_draft(a_flow(title="Theirs"), 1)
assert excinfo.value.current == 2
def test_publishing_promotes_the_draft_and_its_sources(store: FlowStore):
stored = store.write_draft(a_flow(title="Warm"), 0)
store.write_node_source("heating", "sensor", EDITED, draft=True)
published = store.publish_flow("heating", stored.version)
assert published.title == "Warm"
assert not store.has_draft("heating")
assert store.read_flow("heating").title == "Warm"
assert store.read_node_source("heating", "sensor") == EDITED
assert [flow.name for flow in store.read_all()] == ["heating"]
def test_publishing_a_stale_draft_is_refused(store: FlowStore):
store.write_draft(a_flow(), 0)
with pytest.raises(StaleVersion):
store.publish_flow("heating", 99)
def test_discarding_goes_back_to_what_is_running(store: FlowStore):
store.write_flow(a_flow(title="Published"))
store.write_node_source("heating", "sensor", SOURCE)
store.write_draft(a_flow(title="Edited"), a_flow().version)
store.write_node_source("heating", "sensor", EDITED, draft=True)
restored = store.discard_draft("heating")
assert restored.title == "Published"
assert not store.has_draft("heating")
assert store.read_node_source("heating", "sensor", draft=True) == SOURCE
def test_an_edited_source_alone_counts_as_a_draft(store: FlowStore):
"""Editing only code still has to be published before the engine runs it."""
store.write_flow(a_flow())
store.write_node_source("heating", "sensor", SOURCE)
store.write_node_source("heating", "sensor", EDITED, draft=True)
assert store.has_draft("heating")
assert store.read_node_source("heating", "sensor") == SOURCE
assert store.read_node_source("heating", "sensor", draft=True) == EDITED
store.publish_flow("heating", store.read_flow("heating").version)
assert store.read_node_source("heating", "sensor") == EDITED
def test_resaving_the_published_source_creates_no_draft(store: FlowStore):
store.write_flow(a_flow())
store.write_node_source("heating", "sensor", SOURCE)
assert store.write_node_source("heating", "sensor", SOURCE, draft=True) is False
assert not store.has_draft("heating")
def test_renaming_carries_the_draft_and_repoints_other_drafts(store: FlowStore):
store.write_draft(a_flow(), 0)
store.write_draft(
FlowDef(
name="display",
nodes=[NodeDef(id="gauge", requires=[MessageSpec(name="heating.temp")])],
),
0,
)
renamed = store.rename_flow("heating", "warmth")
assert renamed.name == "warmth"
assert store.has_draft("warmth")
assert store.read_flow("display", draft=True).nodes[0].requires[0].name == (
"warmth.temp"
)
+27 -1
View File
@@ -161,12 +161,21 @@ export const FlowDetailSchema = {
type: 'array', type: 'array',
title: 'Issues', title: 'Issues',
default: [] default: []
},
has_draft: {
type: 'boolean',
title: 'Has Draft',
default: false
} }
}, },
type: 'object', type: 'object',
required: ['definition'], required: ['definition'],
title: 'FlowDetail', title: 'FlowDetail',
description: 'A flow plus how it is currently doing.' description: `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.`
} as const; } as const;
export const FlowInput_InputSchema = { export const FlowInput_InputSchema = {
@@ -252,6 +261,11 @@ export const FlowSummarySchema = {
type: 'integer', type: 'integer',
title: 'Error Count', title: 'Error Count',
default: 0 default: 0
},
has_draft: {
type: 'boolean',
title: 'Has Draft',
default: false
} }
}, },
type: 'object', type: 'object',
@@ -631,6 +645,18 @@ export const PrivateUserCreateSchema = {
title: 'PrivateUserCreate' title: 'PrivateUserCreate'
} as const; } as const;
export const PublishRequestSchema = {
properties: {
version: {
type: 'integer',
title: 'Version'
}
},
type: 'object',
required: ['version'],
title: 'PublishRequest'
} as const;
export const RenameRequestSchema = { export const RenameRequestSchema = {
properties: { properties: {
new_name: { new_name: {
+61 -4
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise'; import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI'; import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request'; import { request as __request } from './core/request';
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
export class FlowsService { export class FlowsService {
/** /**
@@ -55,7 +55,12 @@ export class FlowsService {
/** /**
* Save Flow * Save Flow
* 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.
* @param data The data for the request. * @param data The data for the request.
* @param data.name * @param data.name
* @param data.requestBody * @param data.requestBody
@@ -98,6 +103,51 @@ export class FlowsService {
}); });
} }
/**
* Publish Flow
* Deploy the unpublished changes: the engine picks them up from here.
* @param data The data for the request.
* @param data.name
* @param data.requestBody
* @returns FlowDetail Successful Response
* @throws ApiError
*/
public static publishFlow(data: FlowsPublishFlowData): CancelablePromise<FlowsPublishFlowResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/flows/{name}/publish',
path: {
name: data.name
},
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Discard Draft
* Throw the unpublished changes away and go back to what is running.
* @param data The data for the request.
* @param data.name
* @returns FlowDetail Successful Response
* @throws ApiError
*/
public static discardDraft(data: FlowsDiscardDraftData): CancelablePromise<FlowsDiscardDraftResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/flows/{name}/discard-draft',
path: {
name: data.name
},
errors: {
422: 'Validation Error'
}
});
}
/** /**
* Rename Flow * Rename Flow
* Rename a flow, along with every reference to its messages. * Rename a flow, along with every reference to its messages.
@@ -124,7 +174,7 @@ export class FlowsService {
/** /**
* Read Node Source * Read Node Source
* Read a node's Python source. * Read a node's Python source, including unpublished edits.
* @param data The data for the request. * @param data The data for the request.
* @param data.name * @param data.name
* @param data.nodeId * @param data.nodeId
@@ -147,7 +197,11 @@ export class FlowsService {
/** /**
* Save Node Source * Save Node Source
* Save a node's source and report whether it loads. * Save a node's source as an unpublished edit and report whether it loads.
*
* 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.
* @param data The data for the request. * @param data The data for the request.
* @param data.name * @param data.name
* @param data.nodeId * @param data.nodeId
@@ -195,6 +249,9 @@ export class FlowsService {
/** /**
* Run Flow * Run Flow
* 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.
* @param data The data for the request. * @param data The data for the request.
* @param data.name * @param data.name
* @param data.requestBody * @param data.requestBody
+23
View File
@@ -41,11 +41,16 @@ export type FlowDef_Output = {
/** /**
* 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.
*/ */
export type FlowDetail = { export type FlowDetail = {
definition: FlowDef_Output; definition: FlowDef_Output;
nodes?: Array<NodeStatusPublic>; nodes?: Array<NodeStatusPublic>;
issues?: Array<ValidationIssue>; issues?: Array<ValidationIssue>;
has_draft?: boolean;
}; };
/** /**
@@ -81,6 +86,7 @@ export type FlowSummary = {
title?: string; title?: string;
node_count?: number; node_count?: number;
error_count?: number; error_count?: number;
has_draft?: boolean;
}; };
/** /**
@@ -213,6 +219,10 @@ export type PrivateUserCreate = {
is_verified?: boolean; is_verified?: boolean;
}; };
export type PublishRequest = {
version: number;
};
export type RenameRequest = { export type RenameRequest = {
new_name: string; new_name: string;
}; };
@@ -337,6 +347,19 @@ export type FlowsDeleteFlowData = {
export type FlowsDeleteFlowResponse = (Message); export type FlowsDeleteFlowResponse = (Message);
export type FlowsPublishFlowData = {
name: string;
requestBody: PublishRequest;
};
export type FlowsPublishFlowResponse = (FlowDetail);
export type FlowsDiscardDraftData = {
name: string;
};
export type FlowsDiscardDraftResponse = (FlowDetail);
export type FlowsRenameFlowData = { export type FlowsRenameFlowData = {
name: string; name: string;
requestBody: RenameRequest; requestBody: RenameRequest;
+92 -7
View File
@@ -24,6 +24,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { import {
type FlowDef_Input, type FlowDef_Input,
type FlowDetail,
FlowsService, FlowsService,
type MessageSpec, type MessageSpec,
type NodeDef_Input, type NodeDef_Input,
@@ -55,6 +56,8 @@ import {
flowsQueryOptions, flowsQueryOptions,
nodeTypesQueryOptions, nodeTypesQueryOptions,
useAutosave, useAutosave,
useDiscardDraft,
usePublish,
} from "./queries" } from "./queries"
import { useFlowSocket } from "./useFlowSocket" import { useFlowSocket } from "./useFlowSocket"
@@ -184,7 +187,13 @@ function uniqueNodeId(existing: NodeDef_Input[], type: string): string {
} }
} }
function FlowEditorInner({ flowName }: { flowName: string }) { function FlowEditorInner({
flowName,
onReload,
}: {
flowName: string
onReload: () => void
}) {
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast() const { showErrorToast } = useCustomToast()
@@ -195,7 +204,15 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
const { data: detail } = useSuspenseQuery(flowQueryOptions(flowName)) const { data: detail } = useSuspenseQuery(flowQueryOptions(flowName))
const { data: nodeTypeInfo } = useQuery(nodeTypesQueryOptions()) const { data: nodeTypeInfo } = useQuery(nodeTypesQueryOptions())
const { save, flush, mutation: saving } = useAutosave(flowName) const {
save,
flush,
conflict,
resolveConflict,
mutation: saving,
} = useAutosave(flowName)
const publish = usePublish(flowName)
const discard = useDiscardDraft(flowName)
const [definitions, setDefinitions] = useState<NodeDef_Input[]>( const [definitions, setDefinitions] = useState<NodeDef_Input[]>(
() => detail.definition.nodes ?? [], () => detail.definition.nodes ?? [],
@@ -672,6 +689,17 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
flows={flows.data} flows={flows.data}
active={flowName} active={flowName}
saving={saving.isPending} saving={saving.isPending}
hasDraft={detail.has_draft ?? false}
publishing={publish.isPending || saving.isPending}
onPublish={async () => {
// Publish what was actually stored: the version only advances
// once the queued save has landed.
await flush()
const current = queryClient.getQueryData<FlowDetail>(
flowKeys.detail(flowName),
)
publish.mutate(current?.definition.version ?? 1)
}}
onEditFlow={() => { onEditFlow={() => {
setSelectedId(null) setSelectedId(null)
setFlowPanelOpen(true) setFlowPanelOpen(true)
@@ -685,8 +713,9 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
issues={issues} issues={issues}
running={runMutation.isPending} running={runMutation.isPending}
onAddNode={() => setPaletteOpen(true)} onAddNode={() => setPaletteOpen(true)}
onRun={() => { onRun={async () => {
flush() // Running executes what is stored, so the queued edit goes first.
await flush()
runMutation.mutate() runMutation.mutate()
}} }}
onFocusNode={focusNode} onFocusNode={focusNode}
@@ -718,11 +747,23 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
flush() flush()
save({ ...next, nodes: definitions }) save({ ...next, nodes: definitions })
}} }}
onRename={(newName) => { onRename={async (newName) => {
flush() await flush()
renameMutation.mutate(newName) renameMutation.mutate(newName)
}} }}
onDelete={() => deleteMutation.mutate()} onDelete={() => deleteMutation.mutate()}
hasDraft={detail.has_draft ?? false}
discarding={discard.isPending}
onDiscardDraft={() => {
discard.mutate(undefined, {
// The published document replaces what is on the canvas, and the
// version counter goes back with it.
onSuccess: () => {
setFlowPanelOpen(false)
onReload()
},
})
}}
onClose={() => setFlowPanelOpen(false)} onClose={() => setFlowPanelOpen(false)}
/> />
@@ -833,6 +874,42 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/*
* Not dismissable: until one version wins, every further save fails, so
* there is nothing useful to go back to.
*/}
<Dialog open={conflict}>
<DialogContent
data-testid="save-conflict"
showCloseButton={false}
onEscapeKeyDown={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle>Someone else changed this flow</DialogTitle>
<DialogDescription>
Another editor saved <span className="font-mono">{flowName}</span>{" "}
while you were working on it. Load their version, or keep yours
and write over theirs.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-between">
<Button
variant="ghost"
onClick={() => {
void resolveConflict("theirs")
onReload()
}}
>
Load theirs
</Button>
<Button onClick={() => void resolveConflict("mine")}>
Keep mine
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
) )
} }
@@ -857,6 +934,10 @@ export function FlowEditor({ flowName }: { flowName: string }) {
const onAuthFailure = useCallback(() => { const onAuthFailure = useCallback(() => {
navigate({ to: "/login" }) navigate({ to: "/login" })
}, [navigate]) }, [navigate])
// Bumped when the canvas has to take the server's document over its own:
// local edits live in state seeded on mount, so a remount is the reset.
const [epoch, setEpoch] = useState(0)
const reload = useCallback(() => setEpoch((n) => n + 1), [])
useFlowSocket(onAuthFailure) useFlowSocket(onAuthFailure)
@@ -871,7 +952,11 @@ export function FlowEditor({ flowName }: { flowName: string }) {
* it is what makes `fitView` run once per flow: xyflow queues the fit on * it is what makes `fitView` run once per flow: xyflow queues the fit on
* mount and resolves it as soon as the nodes have been measured. * mount and resolves it as soon as the nodes have been measured.
*/} */}
<FlowEditorInner key={flowName} flowName={flowName} /> <FlowEditorInner
key={`${flowName}:${epoch}`}
flowName={flowName}
onReload={reload}
/>
</ReactFlowProvider> </ReactFlowProvider>
) )
} }
@@ -29,6 +29,9 @@ export function FlowPanel({
onChange, onChange,
onRename, onRename,
onDelete, onDelete,
hasDraft,
discarding,
onDiscardDraft,
onClose, onClose,
}: { }: {
open: boolean open: boolean
@@ -38,10 +41,14 @@ export function FlowPanel({
onChange: (next: FlowDef_Input) => void onChange: (next: FlowDef_Input) => void
onRename: (newName: string) => void onRename: (newName: string) => void
onDelete: () => void onDelete: () => void
hasDraft: boolean
discarding: boolean
onDiscardDraft: () => void
onClose: () => void onClose: () => void
}) { }) {
const [name, setName] = useState(definition.name) const [name, setName] = useState(definition.name)
const [confirmOpen, setConfirmOpen] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false)
const [discardOpen, setDiscardOpen] = useState(false)
const valid = NAME_PATTERN.test(name) const valid = NAME_PATTERN.test(name)
const changed = name !== definition.name const changed = name !== definition.name
@@ -117,6 +124,26 @@ export function FlowPanel({
: `${nodeCount} node${nodeCount === 1 ? "" : "s"}.`} : `${nodeCount} node${nodeCount === 1 ? "" : "s"}.`}
</p> </p>
</div> </div>
{hasDraft ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Unpublished changes</span>
<p className="text-sm text-muted-foreground">
The engine is still running the last published version of this
flow.
</p>
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
disabled={discarding}
onClick={() => setDiscardOpen(true)}
data-testid="discard-draft"
>
{discarding ? "Discarding…" : "Discard changes"}
</Button>
</div>
) : null}
</div> </div>
</SidePanel> </SidePanel>
@@ -149,6 +176,34 @@ export function FlowPanel({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Discard the unpublished changes?</DialogTitle>
<DialogDescription>
The canvas goes back to the version the engine is running. What
you edited since is dropped, though the flow store's git history
keeps it.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
Keep editing
</Button>
<Button
variant="destructive"
onClick={() => {
setDiscardOpen(false)
onDiscardDraft()
}}
data-testid="confirm-discard-draft"
>
Discard changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
) )
} }
+28 -2
View File
@@ -114,11 +114,17 @@ export function FlowTabs({
flows, flows,
active, active,
saving, saving,
hasDraft,
publishing,
onPublish,
onEditFlow, onEditFlow,
}: { }: {
flows: FlowSummary[] flows: FlowSummary[]
active: string active: string
saving: boolean saving: boolean
hasDraft: boolean
publishing: boolean
onPublish: () => void
onEditFlow: () => void onEditFlow: () => void
}) { }) {
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
@@ -143,13 +149,18 @@ export function FlowTabs({
to="/flows/$flowName" to="/flows/$flowName"
params={{ flowName: flow.name }} params={{ flowName: flow.name }}
className={cn( className={cn(
"shrink-0 snap-start rounded-full px-3 py-1.5 text-sm transition-colors", "flex shrink-0 snap-start items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors",
flow.name === active flow.name === active
? "bg-accent text-accent-foreground" ? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50", : "text-muted-foreground hover:bg-accent/50",
)} )}
> >
{flow.title || flow.name} {flow.title || flow.name}
{flow.has_draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</Link> </Link>
))} ))}
</div> </div>
@@ -202,9 +213,24 @@ export function FlowTabs({
? "Reconnecting to the engine" ? "Reconnecting to the engine"
: saving : saving
? "Saving" ? "Saving"
: "All changes saved"} : hasDraft
? "Saved — publish to put it live"
: "All changes saved"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{hasDraft ? (
<Button
variant="outline"
size="sm"
className="shrink-0 rounded-full"
onClick={onPublish}
disabled={publishing}
data-testid="publish-flow"
>
{publishing ? "Publishing…" : "Publish"}
</Button>
) : null}
</motion.div> </motion.div>
<NewFlowDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <NewFlowDialog open={dialogOpen} onOpenChange={setDialogOpen} />
+99 -15
View File
@@ -3,9 +3,9 @@ import {
useMutation, useMutation,
useQueryClient, useQueryClient,
} from "@tanstack/react-query" } from "@tanstack/react-query"
import { useCallback, useEffect, useRef } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { type FlowDef_Input, FlowsService } from "@/client" import { ApiError, type FlowDef_Input, FlowsService } from "@/client"
export const flowKeys = { export const flowKeys = {
all: ["flows"] as const, all: ["flows"] as const,
@@ -45,55 +45,139 @@ export const messageHistoryQueryOptions = (name: string, message: string) => ({
}) })
const AUTOSAVE_DELAY = 800 const AUTOSAVE_DELAY = 800
/** How long to wait for a save in flight before sending the next one. */
const RETRY_DELAY = 100
/** Publish the unpublished changes, which is what puts them on the engine. */
export function usePublish(name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (version: number) =>
FlowsService.publishFlow({ name, requestBody: { version } }),
onSuccess: (detail) => {
queryClient.setQueryData(flowKeys.detail(name), detail)
queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true })
},
})
}
/** Throw the unpublished changes away and go back to what is running. */
export function useDiscardDraft(name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => FlowsService.discardDraft({ name }),
onSuccess: (detail) => {
queryClient.setQueryData(flowKeys.detail(name), detail)
queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true })
},
})
}
/** /**
* Saves the flow a moment after the last edit, and immediately when the editor * Saves the flow a moment after the last edit, and immediately when the editor
* needs the server to be current (closing a panel, switching flow, running). * needs the server to be current (closing a panel, switching flow, running).
* *
* Identical documents are skipped server-side, so a quiet canvas writes nothing. * Saving writes a draft — the engine keeps running the published version until
* someone publishes. Identical documents are skipped server-side, so a quiet
* canvas writes nothing.
*
* Every save carries the version it is based on. If another client saved in
* between, the server refuses rather than discarding their work, and `conflict`
* turns true for the editor to ask which version wins.
*/ */
export function useAutosave(name: string): { export function useAutosave(name: string): {
save: (definition: FlowDef_Input) => void save: (definition: FlowDef_Input) => void
flush: () => void /** Send what is queued and resolve once the server has it. */
flush: () => Promise<void>
conflict: boolean
resolveConflict: (mode: "theirs" | "mine") => Promise<void>
mutation: UseMutationResult<unknown, unknown, FlowDef_Input, unknown> mutation: UseMutationResult<unknown, unknown, FlowDef_Input, unknown>
} { } {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const pending = useRef<FlowDef_Input | null>(null) const pending = useRef<FlowDef_Input | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null) const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
// The freshest version this client has seen. Local edits keep the document
// they were made against, so the version has to be stamped on at send time.
const version = useRef<number | null>(null)
const sent = useRef<FlowDef_Input | null>(null)
const inFlight = useRef(false)
const [conflict, setConflict] = useState(false)
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (definition: FlowDef_Input) => mutationFn: (definition: FlowDef_Input) =>
FlowsService.saveFlow({ name, requestBody: definition }), FlowsService.saveFlow({
name,
requestBody: { ...definition, version: version.current ?? undefined },
}),
onMutate: (definition) => {
inFlight.current = true
sent.current = definition
},
onSettled: () => {
inFlight.current = false
},
onSuccess: (detail) => { onSuccess: (detail) => {
version.current = detail.definition.version ?? null
// Write the server's answer straight into the cache: invalidating would // Write the server's answer straight into the cache: invalidating would
// pull the document back out from under edits still in flight. // pull the document back out from under edits still in flight.
queryClient.setQueryData(flowKeys.detail(name), detail) queryClient.setQueryData(flowKeys.detail(name), detail)
queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true }) queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true })
}, },
onError: (error) => {
if (error instanceof ApiError && error.status === 409) setConflict(true)
},
}) })
// react-query hands back a new mutation object on every render, so flushing // react-query hands back a new mutation object on every render, so flushing
// has to hang off `mutate`, which is stable. Depending on the whole mutation // has to hang off `mutate`, which is stable. Depending on the whole mutation
// re-ran the effect below on every render, and its cleanup cancelled the // re-ran the effect below on every render, and its cleanup cancelled the
// pending save before it ever fired. // pending save before it ever fired.
const { mutate } = mutation const { mutate, mutateAsync } = mutation
const flush = useCallback(() => { const flush = useCallback(async (): Promise<void> => {
if (timer.current) { if (timer.current) {
clearTimeout(timer.current) clearTimeout(timer.current)
timer.current = null timer.current = null
} }
const definition = pending.current const definition = pending.current
pending.current = null if (!definition) return
if (definition) { // Two saves in flight at once would race for the same version, and the
mutate(definition) // loser would look like someone else's edit.
if (inFlight.current) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY))
return flush()
} }
}, [mutate]) pending.current = null
// A rejection here is a conflict, which the dialog handles; callers waiting
// on the flush only need to know the attempt is over.
await mutateAsync(definition).catch(() => undefined)
}, [mutateAsync])
const resolveConflict = useCallback(
async (mode: "theirs" | "mine") => {
setConflict(false)
if (mode === "theirs") {
pending.current = null
sent.current = null
version.current = null
await queryClient.invalidateQueries({
queryKey: flowKeys.detail(name),
})
return
}
const current = await FlowsService.readFlow({ name })
version.current = current.definition.version ?? null
const definition = pending.current ?? sent.current
pending.current = null
if (definition) mutate(definition)
},
[mutate, name, queryClient],
)
const save = useCallback( const save = useCallback(
(definition: FlowDef_Input) => { (definition: FlowDef_Input) => {
pending.current = definition pending.current = definition
if (timer.current) clearTimeout(timer.current) if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(flush, AUTOSAVE_DELAY) timer.current = setTimeout(() => void flush(), AUTOSAVE_DELAY)
}, },
[flush], [flush],
) )
@@ -101,15 +185,15 @@ export function useAutosave(name: string): {
// Leaving the tab is the last chance to persist what is still queued. // Leaving the tab is the last chance to persist what is still queued.
useEffect(() => { useEffect(() => {
const onHidden = () => { const onHidden = () => {
if (document.visibilityState === "hidden") flush() if (document.visibilityState === "hidden") void flush()
} }
document.addEventListener("visibilitychange", onHidden) document.addEventListener("visibilitychange", onHidden)
return () => { return () => {
document.removeEventListener("visibilitychange", onHidden) document.removeEventListener("visibilitychange", onHidden)
// Unmounting is a flow switch, not a reason to drop a queued edit. // Unmounting is a flow switch, not a reason to drop a queued edit.
flush() void flush()
} }
}, [flush]) }, [flush])
return { save, flush, mutation } return { save, flush, conflict, resolveConflict, mutation }
} }
@@ -1,7 +1,9 @@
import { useQueryClient } from "@tanstack/react-query"
import { useEffect, useRef } from "react" import { useEffect, useRef } from "react"
import { OpenAPI } from "@/client" import { OpenAPI } from "@/client"
import { liveStore } from "./liveStore" import { liveStore } from "./liveStore"
import { flowKeys } from "./queries"
const RECONNECT_MIN = 1000 const RECONNECT_MIN = 1000
const RECONNECT_MAX = 30000 const RECONNECT_MAX = 30000
@@ -42,6 +44,7 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
const retry = useRef(RECONNECT_MIN) const retry = useRef(RECONNECT_MIN)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null) const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const closed = useRef(false) const closed = useRef(false)
const queryClient = useQueryClient()
useEffect(() => { useEffect(() => {
closed.current = false closed.current = false
@@ -87,6 +90,9 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
break break
case "pipeline_rebuilt": case "pipeline_rebuilt":
liveStore.setStatuses(message.nodes) liveStore.setStatuses(message.nodes)
// Someone published, here or in another tab: the draft markers on
// the flow chips are stale until the list is fetched again.
queryClient.invalidateQueries({ queryKey: flowKeys.all })
break break
} }
} }
@@ -111,5 +117,5 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
socket.current?.close() socket.current?.close()
liveStore.setConnected(false) liveStore.setConnected(false)
} }
}, [onAuthFailure]) }, [onAuthFailure, queryClient])
} }
+94
View File
@@ -0,0 +1,94 @@
import { expect, type Page, test } from "@playwright/test"
/**
* Editing writes a draft; only publishing hands it to the engine. The two
* things worth proving here are that the engine ignores a draft until it is
* published, and that a second client cannot quietly overwrite the first.
*/
const flowName = `test_draft_${Date.now().toString(36)}`
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
const apiUrl = process.env.VITE_API_URL || "http://api.localhost"
async function api(page: Page, path: string, init: Record<string, unknown> = {}) {
const token = await page.evaluate(() => localStorage.getItem("access_token"))
return page.request.fetch(`${apiUrl}/api/v1${path}`, {
...init,
headers: { Authorization: `Bearer ${token}` },
})
}
test("a draft stays off the engine until it is published", async ({ page }) => {
await page.goto("/flows")
await page.getByRole("button", { name: "New flow" }).click()
await page.getByTestId("flow-name-input").fill(flowName)
await page.getByRole("button", { name: "Create flow" }).click()
await page.waitForURL(`/flows/${flowName}`)
await page.getByTestId("add-node").click()
await page
.getByRole("option", { name: /function/i })
.first()
.click()
await expect(page.locator(".react-flow__node")).toHaveCount(1)
// Adding a node opens its panel, and the floating chrome steps aside for it.
await page.keyboard.press("Escape")
await expect(page.getByTestId("publish-flow")).toBeVisible()
// Nothing of this flow is loaded while it is only a draft.
await page.waitForTimeout(1500)
const before = await (await api(page, `/flows/${flowName}/state`)).json()
expect(before.nodes).toHaveLength(0)
await page.getByTestId("publish-flow").click()
await expect(page.getByTestId("publish-flow")).toBeHidden()
const after = await (await api(page, `/flows/${flowName}/state`)).json()
expect(after.nodes.length).toBeGreaterThan(0)
})
test("a save against a version someone else moved on from is refused", async ({
page,
}) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")
const detail = await (await api(page, `/flows/${flowName}`)).json()
// Stand in for a second client that saved first.
const first = await api(page, `/flows/${flowName}`, {
method: "PUT",
data: { ...detail.definition, title: "Theirs" },
})
expect(first.ok()).toBeTruthy()
const stale = await api(page, `/flows/${flowName}`, {
method: "PUT",
data: { ...detail.definition, title: "Mine" },
})
expect(stale.status()).toBe(409)
expect((await stale.json()).detail.current_version).toBe(
detail.definition.version + 1,
)
})
test("discarding a draft goes back to the published flow", async ({ page }) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")
await page.getByTestId("edit-flow").click()
await page.getByTestId("discard-draft").click()
await page.getByTestId("confirm-discard-draft").click()
await expect(page.getByTestId("publish-flow")).toBeHidden()
const detail = await (await api(page, `/flows/${flowName}`)).json()
expect(detail.has_draft).toBe(false)
expect(detail.definition.title).not.toBe("Theirs")
await api(page, `/flows/${flowName}`, { method: "DELETE" })
})