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>
134 lines
4.3 KiB
Python
134 lines
4.3 KiB
Python
"""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"
|
|
)
|