A node type can now say which outside thing its parameters point at, and nodes sharing one — a broker topic, a URL, a bucket — are drawn as a single neuron on a new /brain canvas. That makes the wiring which runs between flows through a broker visible for the first time; no single flow's canvas can show it. The key is read off stored parameters, so a credential reference never reaches an id. Layout is a d3 force simulation settled once and then frozen, lit by the socket the editor already listens to: a neuron pulses when any node behind it publishes, and its connections light as values pass. Fixes the message pulse while here: interpolating the stroke against the edge's `color-mix()` resting colour went through oklab and left the gamut, which turned every pulse on both canvases fluorescent yellow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
232 lines
5.9 KiB
Python
232 lines
5.9 KiB
Python
"""The persisted shape of a flow, shared by the store, the API and the editor.
|
|
|
|
A flow is structure plus code: this module is the structure. Node logic for
|
|
``python`` nodes lives beside it as a plain ``.py`` file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
from app.flow.messages import MessageSpec
|
|
|
|
NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
|
|
|
|
def _validate_name(value: str) -> str:
|
|
if not NAME_PATTERN.match(value):
|
|
raise ValueError(
|
|
"Use lowercase letters, digits and underscores, starting with a letter"
|
|
)
|
|
return value
|
|
|
|
|
|
class Position(BaseModel):
|
|
"""Where a node sits on the canvas."""
|
|
|
|
x: float = 0
|
|
y: float = 0
|
|
|
|
|
|
class NodeDef(BaseModel):
|
|
"""A node as stored: identity, placement, configuration and ports."""
|
|
|
|
id: str
|
|
type: str = "python"
|
|
title: str = ""
|
|
position: Position = Position()
|
|
params: dict[str, Any] = Field(default_factory=dict)
|
|
requires: list[MessageSpec] = Field(default_factory=list)
|
|
provides: list[MessageSpec] = Field(default_factory=list)
|
|
#: Name of a shared source in the library, instead of this node's own file.
|
|
#: Editing it edits the copy every flow using it runs.
|
|
source_ref: str | None = None
|
|
timeout: float | None = Field(
|
|
default=None,
|
|
gt=0,
|
|
description=(
|
|
"Seconds this node's code may run before it is stopped. Above 60 "
|
|
"the engine may deliver its work again while it is still running."
|
|
),
|
|
)
|
|
|
|
@field_validator("id")
|
|
@classmethod
|
|
def _check_id(cls, value: str) -> str:
|
|
return _validate_name(value)
|
|
|
|
|
|
class FlowInput(BaseModel):
|
|
"""A message the flow starts with rather than computes."""
|
|
|
|
spec: MessageSpec
|
|
initial: Any | None = None
|
|
|
|
|
|
class FlowDef(BaseModel):
|
|
"""One atomic flow."""
|
|
|
|
name: str
|
|
title: str = ""
|
|
nodes: list[NodeDef] = Field(default_factory=list)
|
|
inputs: list[FlowInput] = Field(default_factory=list)
|
|
version: int = 1
|
|
|
|
@field_validator("name")
|
|
@classmethod
|
|
def _check_name(cls, value: str) -> str:
|
|
return _validate_name(value)
|
|
|
|
|
|
class NodeSource(BaseModel):
|
|
"""The Python source of a node."""
|
|
|
|
code: str
|
|
|
|
|
|
Health = Literal["ok", "degraded", "down"]
|
|
|
|
|
|
class NodeStatusPublic(BaseModel):
|
|
"""Whether a node loaded, and how its connection is doing."""
|
|
|
|
id: str
|
|
status: str = "active"
|
|
error: str | None = None
|
|
health: Health = "ok"
|
|
health_detail: str | None = None
|
|
|
|
|
|
class MessageValue(BaseModel):
|
|
"""The last payload seen on a message."""
|
|
|
|
value: Any = None
|
|
ts: float | None = None
|
|
|
|
|
|
class HistoryPoint(BaseModel):
|
|
"""One numeric value a message carried, and when."""
|
|
|
|
ts: float
|
|
value: float
|
|
|
|
|
|
class MessageHistory(BaseModel):
|
|
"""A message's recent numeric values, oldest first.
|
|
|
|
Only numbers are recorded, so ``numeric`` tells the panel whether an empty
|
|
series means "nothing plottable here" or "nothing has arrived yet".
|
|
"""
|
|
|
|
message: str
|
|
numeric: bool = False
|
|
points: list[HistoryPoint] = Field(default_factory=list)
|
|
|
|
|
|
class FlowSummary(BaseModel):
|
|
name: str
|
|
title: str = ""
|
|
node_count: int = 0
|
|
error_count: int = 0
|
|
has_draft: bool = False
|
|
enabled: bool = True
|
|
paused: bool = False
|
|
# Its background tasks kept crashing, so the engine stopped restarting them.
|
|
quarantined: bool = False
|
|
|
|
|
|
class FlowsPublic(BaseModel):
|
|
data: list[FlowSummary]
|
|
count: int
|
|
|
|
|
|
class LibraryNode(BaseModel):
|
|
"""A node source shared across flows, and who is using it."""
|
|
|
|
name: str
|
|
used_by: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class FlowStatePublic(BaseModel):
|
|
values: dict[str, MessageValue] = Field(default_factory=dict)
|
|
nodes: list[NodeStatusPublic] = Field(default_factory=list)
|
|
|
|
|
|
class ModulePackage(BaseModel):
|
|
"""One package installed in the venv node code runs on."""
|
|
|
|
name: str
|
|
version: str
|
|
|
|
|
|
class ModulesInfo(BaseModel):
|
|
"""The venv node code imports from, and the manifest that describes it."""
|
|
|
|
python_version: str = ""
|
|
venv_path: str = ""
|
|
requirements: str = ""
|
|
packages: list[ModulePackage] = Field(default_factory=list)
|
|
#: Whether what is installed matches the manifest.
|
|
applied: bool = False
|
|
|
|
|
|
class ApplyRequest(BaseModel):
|
|
"""A pip manifest, one requirement per line."""
|
|
|
|
requirements: str = ""
|
|
|
|
|
|
class ApplyResult(BaseModel):
|
|
ok: bool
|
|
output: str = ""
|
|
|
|
|
|
class BrainNode(BaseModel):
|
|
"""One neuron: a thing the engine talks to, or a node that only computes.
|
|
|
|
Nodes of the same type pointing at the same outside thing — one broker
|
|
topic, one URL, one bucket — are a single entry here, whichever flows they
|
|
sit in. ``members`` are the ``flow.node_id`` names behind it, which is also
|
|
what the live events are keyed by.
|
|
"""
|
|
|
|
id: str
|
|
label: str
|
|
kind: str
|
|
members: list[str] = Field(default_factory=list)
|
|
flows: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class BrainEdge(BaseModel):
|
|
"""Messages carrying values from one neuron to another."""
|
|
|
|
source: str
|
|
target: str
|
|
messages: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class BrainGraph(BaseModel):
|
|
"""Every published flow at once, merged on what its nodes talk to."""
|
|
|
|
nodes: list[BrainNode] = Field(default_factory=list)
|
|
edges: list[BrainEdge] = Field(default_factory=list)
|
|
|
|
|
|
class NodeTypeInfo(BaseModel):
|
|
"""A node type the editor can offer, with its parameter schema."""
|
|
|
|
type: str
|
|
title: str
|
|
description: str
|
|
params_schema: dict[str, Any] = Field(default_factory=dict)
|
|
has_source: bool = False
|
|
#: Whether this type takes settings beyond the ones its schema declares.
|
|
#: A function node's params are its author's to name, and reach `process`
|
|
#: as whatever they put there.
|
|
free_params: bool = False
|
|
#: The package a connector came from; empty for the built-in types.
|
|
plugin: str | None = None
|