Files
app/backend/app/flow/schemas.py
T
stroblmeandClaude Fable 5 db60b289e7 Runs: a flow taken from its inputs to its outputs, once
A cascade has no end worth recording; a run does. Parameters go in, the graph
executes until it drains, and the result is kept — which is what an ML
experiment is and what a CI-style job is, so both are one entity.

Each run gets a state backend namespaced to itself, so two runs of one flow
cannot overwrite each other's messages; that is a constructor argument rather
than a change to the pipeline, because every key the engine keeps already goes
through the state backend. Its record is written by the driver thread rather
than folded off the event bus, which drops what it cannot keep up with. Its
own Redis stream wakes an engine up, and from the claim onwards the database
row is the truth: redelivering hours of training because an acknowledgement
was late is not recovery, so a stale lease is what marks a run whose engine
died.

Flows gain mode: batch, which are built and validated but never activated, and
nodes gain a device label for the worker that must run them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
2026-08-18 16:55:29 +02:00

272 lines
7.7 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 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 = ""
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 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."
),
)
@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)
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 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