seed: write the shared library before anything points at it
Sharing after the flows were created left every flow between the owner and the end of the list pointing at a library entry that did not exist yet. A node that fails to load provides nothing, so one missing entry reported as inputs nothing carries, in eighteen places. The library is written *from* a node, so this is one throwaway flow that carries both sources in and is deleted again — the entries outlive it. Order stops mattering, which also covers the case that would have broken the previous fix: two instances of one shared node inside a single flow. Verified against the installation: nineteen flows, zero validation issues. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+112
-91
@@ -113,76 +113,6 @@ def check(flows: list[Flow]) -> list[str]:
|
||||
return problems
|
||||
|
||||
|
||||
def _check_schemas(flows: list[Flow]) -> list[str]:
|
||||
"""Hand every node to the engine's own models before the API sees them.
|
||||
|
||||
The API would say the same thing, one flow at a time, after creating the
|
||||
ones before it. Importing the models is free and answers it here — a
|
||||
misspelled setting on a connector is otherwise found halfway through a
|
||||
house.
|
||||
"""
|
||||
try:
|
||||
from fluksio.flow.controller import NODE_TYPES
|
||||
from fluksio.flow.schemas import FlowDef
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
problems = []
|
||||
missing: set[str] = set()
|
||||
for flow in flows:
|
||||
try:
|
||||
FlowDef.model_validate(
|
||||
{
|
||||
"name": flow.name,
|
||||
"title": flow.title,
|
||||
"nodes": flow.nodes,
|
||||
"inputs": flow.inputs,
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - reported, not raised
|
||||
problems.append(f"{flow.name}: {exc}")
|
||||
continue
|
||||
|
||||
for node in flow.nodes:
|
||||
spec = NODE_TYPES.get(node["type"])
|
||||
if spec is None:
|
||||
# Connectors are installed into the image, not into the venv
|
||||
# this runs in, so their absence here says nothing. A type that
|
||||
# is neither built in nor a connector this house uses is a
|
||||
# typo, and that is what is worth saying.
|
||||
if node["type"] not in CONNECTORS:
|
||||
problems.append(
|
||||
f"{flow.name}.{node['id']}: no node type '{node['type']}'"
|
||||
)
|
||||
else:
|
||||
missing.add(node["type"])
|
||||
continue
|
||||
if spec.free_params:
|
||||
continue
|
||||
params = getattr(spec.cls, "Params", None)
|
||||
if params is None:
|
||||
continue
|
||||
try:
|
||||
params.model_validate(_without_secrets(node.get("params", {})))
|
||||
except Exception as exc: # noqa: BLE001 - reported, not raised
|
||||
problems.append(f"{flow.name}.{node['id']} settings: {exc}")
|
||||
|
||||
if missing:
|
||||
print(f" (from the image, not checked here: {', '.join(sorted(missing))})")
|
||||
return problems
|
||||
|
||||
|
||||
def _without_secrets(params: Any) -> Any:
|
||||
"""A secret reference is an ordinary string by the time a node is built."""
|
||||
if isinstance(params, dict):
|
||||
if set(params) == {"$secret"}:
|
||||
return "from-the-store"
|
||||
return {k: _without_secrets(v) for k, v in params.items()}
|
||||
if isinstance(params, list):
|
||||
return [_without_secrets(v) for v in params]
|
||||
return params
|
||||
|
||||
|
||||
#: Node types that come from connector packages. `make connectors` installs
|
||||
#: them into the image, so a checkout does not have them and their absence
|
||||
#: here is not a problem to report.
|
||||
@@ -353,34 +283,74 @@ def _check_widgets(known: set[str]) -> list[str]:
|
||||
# ── pushing it ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def push(api: Api, flows: list[Flow]) -> None:
|
||||
"""Create every flow, then share the two nodes the rest reference.
|
||||
def _check_schemas(flows: list[Flow]) -> list[str]:
|
||||
"""Hand every node to the engine's own models before the API sees them.
|
||||
|
||||
Sharing happens after the flows exist because the library is written from
|
||||
a node that already holds the code — so the first flow to use each carries
|
||||
it in, and the others point at it.
|
||||
The API would say the same thing, one flow at a time, after creating the
|
||||
ones before it. Importing the models is free and answers it here — a
|
||||
misspelled setting on a connector is otherwise found halfway through a
|
||||
house.
|
||||
"""
|
||||
library = {"arbiter": ARBITER, "motor": MOTOR}
|
||||
owners: dict[str, tuple[str, str]] = {}
|
||||
try:
|
||||
from fluksio.flow.controller import NODE_TYPES
|
||||
from fluksio.flow.schemas import FlowDef
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
problems = []
|
||||
missing: set[str] = set()
|
||||
for flow in flows:
|
||||
try:
|
||||
FlowDef.model_validate(
|
||||
{
|
||||
"name": flow.name,
|
||||
"title": flow.title,
|
||||
"nodes": flow.nodes,
|
||||
"inputs": flow.inputs,
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - reported, not raised
|
||||
problems.append(f"{flow.name}: {exc}")
|
||||
continue
|
||||
|
||||
for node in flow.nodes:
|
||||
ref = node.get("source_ref")
|
||||
if ref in library and ref not in owners:
|
||||
owners[ref] = (flow.name, node["id"])
|
||||
# This one carries the code in; the rest reference it.
|
||||
node.pop("source_ref")
|
||||
flow.sources[node["id"]] = library[ref]
|
||||
spec = NODE_TYPES.get(node["type"])
|
||||
if spec is None:
|
||||
# Connectors are installed into the image, not into the venv
|
||||
# this runs in, so their absence here says nothing. A type that
|
||||
# is neither built in nor a connector this house uses is a
|
||||
# typo, and that is what is worth saying.
|
||||
if node["type"] not in CONNECTORS:
|
||||
problems.append(
|
||||
f"{flow.name}.{node['id']}: no node type '{node['type']}'"
|
||||
)
|
||||
else:
|
||||
missing.add(node["type"])
|
||||
continue
|
||||
if spec.free_params:
|
||||
continue
|
||||
params = getattr(spec.cls, "Params", None)
|
||||
if params is None:
|
||||
continue
|
||||
try:
|
||||
params.model_validate(_without_secrets(node.get("params", {})))
|
||||
except Exception as exc: # noqa: BLE001 - reported, not raised
|
||||
problems.append(f"{flow.name}.{node['id']} settings: {exc}")
|
||||
|
||||
for flow in flows:
|
||||
report(flow.name, len(flow.nodes), api.put_flow(flow))
|
||||
if missing:
|
||||
print(f" (from the image, not checked here: {', '.join(sorted(missing))})")
|
||||
return problems
|
||||
|
||||
for ref, (flow_name, node_id) in owners.items():
|
||||
api.share(flow_name, node_id, ref)
|
||||
print(f" library: '{ref}' shared from {flow_name}.{node_id}")
|
||||
|
||||
for name, title, glyph, widgets in dashboards.SCREENS:
|
||||
api.put_dashboard(name, title, glyph, widgets)
|
||||
print(f" dashboard '{name}': {len(widgets)} widgets, published")
|
||||
def _without_secrets(params: Any) -> Any:
|
||||
"""A secret reference is an ordinary string by the time a node is built."""
|
||||
if isinstance(params, dict):
|
||||
if set(params) == {"$secret"}:
|
||||
return "from-the-store"
|
||||
return {k: _without_secrets(v) for k, v in params.items()}
|
||||
if isinstance(params, list):
|
||||
return [_without_secrets(v) for v in params]
|
||||
return params
|
||||
|
||||
|
||||
#: Settings these flows depend on that a node type gained for this port. A node
|
||||
@@ -398,6 +368,9 @@ NEEDED = {
|
||||
"http": {
|
||||
"query": "the weather key would have to sit in the flow file in clear",
|
||||
},
|
||||
"artnet": {
|
||||
"baseline": "the first frame would darken what it is not driving",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -416,6 +389,54 @@ def _engine_too_old(api: Api) -> list[tuple[str, str]]:
|
||||
return stale
|
||||
|
||||
|
||||
LIBRARY = {"arbiter": ARBITER, "motor": MOTOR}
|
||||
|
||||
|
||||
def seed_library(api: Api) -> None:
|
||||
"""Write the shared sources before anything references them.
|
||||
|
||||
A node pointing at a library entry that does not exist yet fails to load,
|
||||
and a node that failed to load provides nothing — so one missing entry
|
||||
reports as inputs nothing carries, in every flow downstream of it. The
|
||||
library is written *from* a node, though, so this is one throwaway flow
|
||||
that carries the sources in and is deleted again; the entries outlive it.
|
||||
"""
|
||||
existing = {node["name"] for node in api("GET", "/flows/library")}
|
||||
wanted = {name: code for name, code in LIBRARY.items() if name not in existing}
|
||||
if not wanted:
|
||||
return
|
||||
|
||||
bootstrap = Flow("librarybootstrap", "Library bootstrap")
|
||||
for name in wanted:
|
||||
bootstrap.add(
|
||||
{
|
||||
"id": name,
|
||||
"type": "python",
|
||||
"title": name,
|
||||
"requires": [{"name": "unused", "dtype": "json"}],
|
||||
"provides": [{"name": f"{name}_unused", "dtype": "json"}],
|
||||
},
|
||||
wanted[name],
|
||||
)
|
||||
bootstrap.input("unused", "json", {})
|
||||
|
||||
api.put_flow(bootstrap)
|
||||
for name in wanted:
|
||||
api.share(bootstrap.name, name, name)
|
||||
print(f" library: '{name}' written")
|
||||
api.drop(f"/flows/{bootstrap.name}")
|
||||
|
||||
|
||||
def push(api: Api, flows: list[Flow]) -> None:
|
||||
"""The library first, then every flow, then the screens."""
|
||||
seed_library(api)
|
||||
for flow in flows:
|
||||
report(flow.name, len(flow.nodes), api.put_flow(flow))
|
||||
for name, title, glyph, widgets in dashboards.SCREENS:
|
||||
api.put_dashboard(name, title, glyph, widgets)
|
||||
print(f" dashboard '{name}': {len(widgets)} widgets, published")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
dry = "--dry" in argv
|
||||
h = house()
|
||||
|
||||
Reference in New Issue
Block a user