Docs / docs (push) Successful in 19s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m5s
Playwright Tests / test-playwright (2, 2) (push) Failing after 20s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Successful in 2m7s
Compose Smoke Test / test-compose (push) Failing after 20s
Playwright Tests / merge-reports (push) Failing after 1m3s
Publish / publish (push) Failing after 12s
A code node in a batch run is now fingerprinted by its source, its raw settings and the values it reads — an artifact input counting as its digest, which is what the content addressing was always for. A run that finds the key restores what the earlier one returned and skips the node, recorded as `cached`. The run history is the cache: `run_node.outputs` beside the `cache_key` the schema already had, no second store. On for code nodes, never for the built-in and connector types that have side effects; off per node with `@node(cache=False)` and per run with `--no-cache`. Emissions are not replayed on a hit, so a cached training node returns its result without redrawing its curve. Recorded in NOTEPAD.md with the two other deliberate limits. `fluksio run --local` boots the real app in the command's own process and drives it through its ASGI interface behind the ordinary client, so a run no longer needs a `serve` terminal beside it — same data directory, same history, and the cache carries between the two. It always waits, because the engine it starts lives exactly as long as the command. Also: `fluksio sweep --param lr=0.1,0.01` for the product of the lists, `run --follow` for a run's numbers as they arrive, Ctrl-C cancelling a waited run rather than abandoning it, coloured statuses on a terminal, and `name` made optional on the metrics endpoint so a follower can ask for every series. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
324 lines
10 KiB
Python
324 lines
10 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 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."
|
|
),
|
|
)
|
|
cache: bool = Field(
|
|
default=True,
|
|
description=(
|
|
"Whether a batch run may reuse an earlier execution of this node "
|
|
"with the same source, settings and inputs. Turn it off for a "
|
|
"function whose answer can change on its own."
|
|
),
|
|
)
|
|
|
|
@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
|