"""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 fluksio.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 NodeDef(BaseModel): """A node as stored: identity, configuration and ports. Deliberately no canvas position. The editor lays a flow out itself, so where a node sits is a fact about the drawing rather than about the flow — and a graph nobody can arrange is one worth keeping small. """ id: str type: str = "python" title: str = "" #: This node's settings: constants of its function, stored with the flow. #: A function node reads them as keyword arguments beside its ports, so a #: setting cannot share a name with one. 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. This " "covers the first call's imports, which can be much slower than " "the body. Above 60 the engine may deliver its work again while " "it is still running — in a batch run, which never redelivers, " "it is an idle timeout instead: silence this long is a kill." ), ) device: str | None = Field( default=None, description=( "Label of the worker this node's code must run on, such as 'gpu'. " "Empty means the engine's own workers. A run needing a label no " "attached worker carries waits rather than failing." ), ) device_policy: Literal["require", "prefer"] = Field( default="require", description=( "What to do when no worker carries `device`: wait for one, or run " "locally anyway." ), ) @field_validator("id") @classmethod def _check_id(cls, value: str) -> str: return _validate_name(value) class FlowOrigin(BaseModel): """Where a flow was declared, when that was somewhere other than here. A flow drawn on the canvas has no origin: the store is where it lives. One stamped with this was declared with the decorators in somebody's own repository and put here by ``fluksio sync``, so the node bodies below it are generated imports and the code they run is versioned twice — once here and once there. Its presence is what makes a flow code-defined. Deliberately no timestamp. The store commits every change it is given, so when a flow was last synced is a fact its own history already holds — and one that would otherwise change on every sync, making an unchanged upload look like a new version of the flow. """ kind: Literal["python"] = "python" #: The repository root on the machine that ran ``sync``. repo: str = "" #: Its commit, and whether the tree had uncommitted changes at the time — #: a run stamped with a dirty commit names code that was never stored. commit: str = "" dirty: bool = False 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 mode: Literal["live", "batch"] = Field( default="live", description=( "A live flow reacts to what arrives: its subscriptions, schedules " "and webhooks run until it is stopped. A batch flow only runs when " "a run asks it to, from its inputs to its outputs, and is never " "activated." ), ) outputs: list[str] = Field( default_factory=list, description=( "Messages a batch run reports as its result, unqualified. Empty " "means every message the flow ends up holding." ), ) origin: FlowOrigin | None = Field( default=None, description=( "Set when the flow was declared in code elsewhere and uploaded by " "`fluksio sync`. Absent for a flow drawn on the canvas." ), ) @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 #: The node's last runtime failure, kept after it runs again: a failure #: that fired an alert should leave a trace of what it was. last_error: str = "" last_error_ts: float | 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 #: Of the working copy, so publishing from a list needs no second read. version: int = 1 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 #: True when node code runs on the venv Fluksio was installed into rather #: than one the engine built. That venv belongs to whoever made it, so the #: manifest does not describe it and nothing here installs into it. adopted: 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) issue: str | None = Field( default=None, description=( "Why this neuron cannot run, if validation found something. A " "failure the engine hits while running arrives over the socket " "instead; this is the part that is already true before anything " "fires, and so has to travel with the graph." ), ) 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 settings are its author's to name, and reach `process` #: as keyword arguments beside its ports. free_params: bool = False #: The package a connector came from; empty for the built-in types. plugin: str | None = None