- NodeDef `device`/`device_policy` become `worker`/`worker_policy`, the issue `ignored_device` becomes `ignored_worker`, and the SDK's `@node` takes `worker=` and `worker_policy=` - FlowStore._migrate renames the keys in published and draft documents once, bumping their version; a queued run's `needs` reads the old key - placement passes the label as `label`, client regenerated - docs: worker placement wording, MCP issuer without DOMAIN, device rows in the CLI data directory and the API table, device links in compose comments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
206 lines
7.1 KiB
Python
206 lines
7.1 KiB
Python
"""Editing writes drafts; only publishing changes what the engine reads."""
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from fluksio.flow.controller import FlowController
|
|
from fluksio.flow.messages import MessageSpec
|
|
from fluksio.flow.schemas import FlowDef, FlowOrigin, NodeDef
|
|
from fluksio.flow.store import FlowStore, StaleVersion
|
|
|
|
SOURCE = "def process():\n return {}\n"
|
|
EDITED = "def process():\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_a_published_node_with_no_body_is_reported(store: FlowStore):
|
|
"""It would run the new-node template, which returns nothing and says so."""
|
|
store.write_flow(a_flow())
|
|
controller = FlowController(store)
|
|
|
|
asyncio.run(controller.reload())
|
|
(issue,) = [i for i in controller.issues if i.code == "missing_source"]
|
|
assert issue.node == "heating.sensor"
|
|
assert not issue.advisory
|
|
# It still loads: one node with no body does not take the flow down.
|
|
assert controller.get_node("heating.sensor") is not None
|
|
|
|
store.write_node_source("heating", "sensor", SOURCE)
|
|
asyncio.run(controller.reload())
|
|
assert [i for i in controller.issues if i.code == "missing_source"] == []
|
|
|
|
|
|
def test_a_worker_on_a_node_without_code_is_reported(store: FlowStore):
|
|
"""Only code travels to a worker, so the worker would be ignored silently."""
|
|
store.write_flow(
|
|
FlowDef(
|
|
name="heating",
|
|
nodes=[
|
|
NodeDef(
|
|
id="relay",
|
|
type="change",
|
|
worker="gpu",
|
|
provides=[MessageSpec(name="temp")],
|
|
)
|
|
],
|
|
)
|
|
)
|
|
controller = FlowController(store)
|
|
|
|
asyncio.run(controller.reload())
|
|
(issue,) = [i for i in controller.issues if i.code == "ignored_worker"]
|
|
assert issue.node == "heating.relay"
|
|
assert issue.advisory
|
|
|
|
|
|
def test_a_node_being_written_in_the_editor_is_not_reported(store: FlowStore):
|
|
"""A draft legitimately has no published body yet — that is what a draft is."""
|
|
store.write_draft(a_flow(), 0)
|
|
controller = FlowController(store)
|
|
|
|
assert controller.preview("heating").issues == []
|
|
|
|
|
|
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"
|
|
)
|
|
|
|
|
|
def test_a_duplicate_is_a_local_draft_of_what_the_editor_shows(store: FlowStore):
|
|
flow = a_flow(title="Warm").model_copy(update={"origin": FlowOrigin()})
|
|
flow.nodes.append(NodeDef(id="reader", requires=[MessageSpec(name="heating.temp")]))
|
|
store.write_flow(flow)
|
|
store.write_node_source("heating", "sensor", SOURCE)
|
|
store.write_node_source("heating", "sensor", EDITED, draft=True)
|
|
store.write_enabled("heating", False)
|
|
|
|
copy = store.duplicate_flow("heating")
|
|
|
|
assert (copy.name, copy.title, copy.version) == ("heating_copy", "Warm (copy)", 1)
|
|
assert copy.origin is None
|
|
assert copy.nodes[1].requires[0].name == "heating_copy.temp"
|
|
assert store.has_draft("heating_copy") and not store.is_published("heating_copy")
|
|
assert store.read_node_source("heating_copy", "sensor", draft=True) == EDITED
|
|
assert not (store.root / "heating_copy" / "runtime.json").exists()
|
|
assert store.duplicate_flow("heating").name == "heating_copy_2"
|
|
|
|
store.publish_flow("heating_copy", copy.version)
|
|
assert store.read_node_source("heating_copy", "sensor") == EDITED
|