Files
app/backend/fluksio/api/routes/flows.py
T
stroblme e9941b770d wren node: Train in the panel, as a run of wren's training flow
POST/GET /flows/{name}/nodes/{id}/train submit wren_train on the node's
targets, examples and tests and read its latest run (run.for_node,
cause "train"); "Use this model" writes `model` into the draft.
2026-10-25 01:18:54 +02:00

1908 lines
72 KiB
Python

"""The flow API. Everything the editor can do is available here first."""
import asyncio
import logging
import os
import re
import time
from pathlib import Path
from typing import Any
import orjson
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
WebSocket,
WebSocketDisconnect,
)
from fastapi.concurrency import run_in_threadpool
from jwt.exceptions import InvalidTokenError
from pydantic import BaseModel
from sqlmodel import Session, col, select
from fluksio.api.deps import (
CAMERA_IS_SUPERUSERS,
CODE_IS_SUPERUSERS,
CurrentUser,
FlowControllerDep,
HiddenDep,
SessionDep,
TokenDep,
decode_token,
get_code_author,
get_current_user,
hidden_from,
user_from_token,
)
from fluksio.api.routes.messages import port_source
from fluksio.api.socket_auth import TICKET_TTL_S, issue_ticket, socket_token
from fluksio.core.config import settings
from fluksio.core.db import engine
from fluksio.flow import live as live_view
from fluksio.flow.artifacts import is_reference
from fluksio.flow.controller import NODE_TYPES, FlowController, follow_header
from fluksio.flow.dashboards import DashboardStore
from fluksio.flow.demand import demand
from fluksio.flow.devices import (
Binding,
DeviceDef,
DeviceStore,
DeviceUnavailable,
dashboards_for,
messages_for,
messages_of,
presence,
)
from fluksio.flow.events import event_bus
from fluksio.flow.messages import MEDIA_FAMILIES, DType, flow_of, qualify
from fluksio.flow.nodes.wren import TRAIN_FLOW, WrenNode, pack_meta
from fluksio.flow.pipeline import ValidationIssue
from fluksio.flow.runs import RunRejected
from fluksio.flow.schemas import (
NAME_PATTERN,
BrainGraph,
FlowDef,
FlowsPublic,
FlowStatePublic,
FlowSummary,
HistoryPoint,
LibraryNode,
MessageHistory,
MessageValue,
NodeDef,
NodeSource,
NodeSourceSaved,
NodeStatusPublic,
NodeTypeInfo,
)
from fluksio.flow.state import as_number
from fluksio.flow.store import (
FlowExists,
FlowNotFound,
LibExists,
LibNotFound,
StaleVersion,
)
from fluksio.models import Flavor, Message, Run, RunArtifact, RunNode
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/flows", tags=["flows"], dependencies=[Depends(get_current_user)]
)
# The websocket authenticates from its handshake, a header or a ticket, so it
# stays off the router that enforces the HTTP bearer scheme.
ws_router = APIRouter(prefix="/flows", tags=["flows"])
class Endpoint(BaseModel):
"""Something wired into this flow that is not a node in it.
A dashboard control setting one of its messages, a tile showing one, or a
node in another flow on the far side of a dotted name. The canvas draws these so a value never appears to come
from nowhere — or worse, appears to come from whichever node happens to be
drawn as a producer.
"""
#: dashboard or flow.
kind: str
#: Stable within its kind, and used as the canvas node id.
id: str
label: str
#: What sort of widget, or the node type in the other flow.
detail: str = ""
#: Messages of this flow it publishes, and ones it reads.
provides: list[str] = []
requires: list[str] = []
class FlowDetail(BaseModel):
"""A flow plus how it is currently doing.
``definition`` is the working copy — the unpublished draft when there is
one — because that is what the editor shows. ``nodes`` reports the
published flow, which is what is actually running.
"""
definition: FlowDef
nodes: list[NodeStatusPublic] = []
issues: list[ValidationIssue] = []
has_draft: bool = False
enabled: bool = True
paused: bool = False
#: Dashboards and other flows wired into this one.
endpoints: list[Endpoint] = []
class ValidationResult(BaseModel):
issues: list[ValidationIssue] = []
class RenameRequest(BaseModel):
new_name: str
class PublishRequest(BaseModel):
version: int
class ShareRequest(BaseModel):
lib_name: str
class RunRequest(BaseModel):
inputs: dict[str, Any] = {}
class TriggerRequest(BaseModel):
values: dict[str, Any] = {}
class Training(BaseModel):
"""A wren node's latest training run, as its panel shows it."""
id: str
#: queued, running, ok, error, cancelled or abandoned.
status: str
#: What went wrong, when it failed.
error: str = ""
#: final_loss, n_params, exact_match, threshold and coverage, once it ended.
result: dict[str, Any] = {}
#: The trained model's digest, for the node's ``model`` setting.
model: str = ""
#: From the model's own record: the confidence it recommends, and its
#: score on the node's ``tests`` when there were any.
threshold: float | None = None
test_exact_match: float | None = None
def _endpoints(controller: FlowController, definition: FlowDef) -> list[Endpoint]:
"""Everything wired into this flow from outside it."""
flow = definition.name
found: dict[str, Endpoint] = {}
dashboards: DashboardStore | None = getattr(controller, "dashboards", None)
if dashboards is not None:
for binding in dashboards.bindings_for(flow):
found[f"dashboard:{binding['dashboard']}:{binding['widget']}"] = Endpoint(
kind="dashboard",
id=f"dashboard:{binding['dashboard']}:{binding['widget']}",
label=binding["title"],
detail=binding["type"],
provides=[binding["provides"]] if binding["provides"] else [],
requires=binding["requires"],
)
def _flow_endpoint(key: str, detail: str) -> Endpoint:
"""One label per node on the far side, however many names reach it."""
return found.setdefault(
f"flow:{key}",
Endpoint(kind="flow", id=f"flow:{key}", label=key, detail=detail),
)
# An outsider reaching into this flow.
for other, node_id, provides, requires in controller.cross_flow_nodes(flow):
endpoint = _flow_endpoint(f"{other}.{node_id}", "flow")
endpoint.provides += provides
endpoint.requires += requires
# And this flow reaching out: its own ports bound to a message of another
# flow. Read from the working document rather than from the store, so a
# name just typed is drawn before it has been published — the same reason
# the canvas draws its own boundary from the document.
for node in definition.nodes:
for spec, ours_publishes in [
*((spec, False) for spec in node.requires),
*((spec, True) for spec in node.provides),
]:
message = qualify(flow, spec.name or "")
if not message or flow_of(message) == flow:
continue
# The node at the other end, so the label reads like a dashboard's:
# what it is on the first line, what sort of thing it is on the
# second. A message no published flow declares yet has no other end
# to name, so it is drawn as the message it is.
far = controller.message_node(
message, published=not ours_publishes, exclude=flow
)
endpoint = _flow_endpoint(
f"{far.flow}.{far.node}" if far else message,
far.type if far else "flow",
)
# An endpoint publishing into this flow is what this flow reads.
side = endpoint.requires if ours_publishes else endpoint.provides
if message not in side:
side.append(message)
return list(found.values())
def _detail(controller: FlowController, definition: FlowDef) -> FlowDetail:
"""The flow and how its nodes are doing. Blocking: call it off the loop.
A draft is built to report on it, which asks a worker to compile its
nodes, and that waits on the socket the loop serves.
"""
name = definition.name
running = {
"enabled": controller.is_enabled(name),
"paused": controller.is_paused(name),
}
endpoints = _endpoints(controller, definition)
if controller.store.has_draft(name):
# Report the draft the editor is showing, not the version running
# underneath it — otherwise a node the author just broke looks fine.
preview = controller.preview(name)
return FlowDetail(
definition=definition,
nodes=preview.nodes,
issues=preview.issues,
has_draft=True,
endpoints=endpoints,
**running,
)
return FlowDetail(
definition=definition,
nodes=controller.node_statuses(name),
issues=controller.flow_issues(name),
endpoints=endpoints,
**running,
)
def _read_flow(controller: FlowController, name: str) -> FlowDef:
"""The working copy: the draft when there is one, else what is published."""
try:
return controller.store.read_flow(name, draft=True)
except FlowNotFound:
raise HTTPException(status_code=404, detail=f"No flow named '{name}'")
def _audit(action: str, flow: str, user: CurrentUser) -> None:
"""Record who changed what. The collector writes it down; the bus carries it."""
event_bus.publish(
{
"type": "audit",
"action": action,
"flow": flow,
"user": user.email,
"ts": time.time(),
}
)
def _source_ref(definition: FlowDef, node_id: str) -> str | None:
"""The library source this node runs, if it is a shared one."""
node = next((n for n in definition.nodes if n.id == node_id), None)
return node.source_ref if node else None
def _require_enabled(controller: FlowController, name: str) -> None:
if not controller.is_enabled(name):
raise HTTPException(
status_code=409,
detail=f"Flow '{name}' is stopped — start it before running it",
)
def _flow_state(
controller: FlowController, name: str, hidden: frozenset[str]
) -> FlowStatePublic:
"""The flow's values, but those of a camera the caller may not see."""
return FlowStatePublic(
values={
key: MessageValue(**value)
for key, value in controller.values(name).items()
if key not in hidden
},
nodes=controller.node_statuses(name),
)
# -----------------------------------------------------------------------------
# Flows
# -----------------------------------------------------------------------------
@router.get("/", response_model=FlowsPublic)
def read_flows(controller: FlowControllerDep) -> Any:
"""List every flow."""
summaries = []
for name in controller.store.list_flows():
try:
definition = controller.store.read_flow(name, draft=True)
except FlowNotFound:
continue
statuses = controller.node_statuses(name)
summaries.append(
FlowSummary(
name=definition.name,
title=definition.title,
mode=definition.mode,
node_count=len(definition.nodes),
error_count=sum(1 for s in statuses if s.status == "error"),
has_draft=controller.store.has_draft(name),
enabled=controller.is_enabled(name),
paused=controller.is_paused(name),
quarantined=controller.is_quarantined(name),
version=definition.version,
updated_at=controller.store.updated_at(name),
)
)
return FlowsPublic(data=summaries, count=len(summaries))
@router.get("/node-types", response_model=list[NodeTypeInfo])
def read_node_types() -> Any:
"""The node types that can be placed on a canvas."""
from fluksio.flow.controller import node_type_info
return node_type_info()
# Above "/{name}" for the same reason "library" is: a flow called "graph" would
# otherwise be unreachable.
@router.get("/graph", response_model=BrainGraph)
def read_graph(controller: FlowControllerDep) -> Any:
"""Every flow as one graph, with nodes talking to the same thing merged."""
return controller.brain_graph()
# -----------------------------------------------------------------------------
# Shared nodes
#
# Declared above the "/{name}" routes: "library" would otherwise be read as a
# flow name.
# -----------------------------------------------------------------------------
@router.get("/library", response_model=list[LibraryNode])
def read_library(controller: FlowControllerDep) -> Any:
"""The node sources shared across flows, and which nodes use each."""
return [
LibraryNode(name=name, used_by=controller.store.usages(name))
for name in controller.store.list_lib()
]
@router.delete("/library/{lib_name}", response_model=Message)
async def delete_shared_node(
lib_name: str, controller: FlowControllerDep, detach: bool = False
) -> Any:
"""Remove a shared source, as long as no flow still runs it.
With ``detach``, every node using it keeps a copy of the code as its own
first, so the source can be retired while flows still use it.
"""
if detach:
running = await run_in_threadpool(controller.store.usages, lib_name, False)
try:
await run_in_threadpool(controller.store.detach_lib, lib_name)
except LibNotFound:
raise HTTPException(status_code=404, detail=f"No shared node '{lib_name}'")
for flow in sorted({usage.split(".", 1)[0] for usage in running}):
await controller.reload_flow(flow)
return Message(message=f"Deleted shared node '{lib_name}'")
# The published flows too: a draft that unshared a node still runs it.
used_by = sorted(
{*controller.store.usages(lib_name), *controller.store.usages(lib_name, False)}
)
if used_by:
raise HTTPException(
status_code=409,
detail=f"'{lib_name}' is still used by {', '.join(used_by)}",
)
try:
await run_in_threadpool(controller.store.delete_lib_source, lib_name)
except LibNotFound:
raise HTTPException(status_code=404, detail=f"No shared node '{lib_name}'")
return Message(message=f"Deleted shared node '{lib_name}'")
@router.get("/{name}", response_model=FlowDetail)
def read_flow(name: str, controller: FlowControllerDep) -> Any:
"""Read one flow, with the state of its nodes."""
return _detail(controller, _read_flow(controller, name))
def _check_flavors(definition: FlowDef) -> None:
"""Refuse a size nobody stored, here rather than when the node is built.
Catching it at the save covers the canvas and ``fluksio sync`` in one
place; a node that only finds out at build time is a red node somebody has
to go and look at.
"""
named = {
node.resources.flavor
for node in definition.nodes
if node.resources is not None and node.resources.flavor
}
if not named:
return
with Session(engine) as session:
known = set(session.exec(select(Flavor.name)).all())
unknown = sorted(named - known)
if unknown:
raise HTTPException(
status_code=422,
detail=f"No flavor named '{unknown[0]}' "
f"(known: {', '.join(sorted(known)) or 'none'})",
)
def _check_origin(definition: FlowDef) -> None:
"""Refuse an ``origin.repo`` that is not a project's own directory.
Every run of a code-defined flow hashes the python files below it, so
``/`` or a home directory makes each run walk far more than the project,
and a relative path names nothing the engine can find.
"""
repo = definition.origin.repo if definition.origin is not None else ""
if not repo:
return
if not os.path.isabs(repo):
problem = "is a relative path"
elif os.path.realpath(repo) == "/":
problem = "is the filesystem root"
elif os.path.realpath(repo) == os.path.realpath(Path.home()):
problem = "is the home directory"
else:
return
raise HTTPException(
status_code=422,
detail=(
f"origin.repo '{repo}' {problem}. It has to be the absolute path "
"of the repository the flow is declared in, since every run hashes "
"the python files below it. Sync from inside that repository."
),
)
def _worker_env(node: NodeDef | None) -> dict[str, str]:
return node.resources.env if node is not None and node.resources else {}
def _changes_code(node: NodeDef, old: NodeDef | None) -> bool:
"""Whether saving *node* over *old* changes code the engine would run.
A function node's body has its own route, gated there. What the document
carries is which nodes run code at all, which shared source each runs, the
command in an exec node's settings, and the environment a node's worker
starts with — where `PYTHONPATH` alone is enough to import a file of
somebody's choosing.
"""
if _worker_env(old) != _worker_env(node):
return True
spec = NODE_TYPES.get(node.type)
if spec is None or not spec.runs_code:
return False
return (
old is None
or old.type != node.type
or old.source_ref != node.source_ref
or (not spec.has_source and old.params != node.params)
)
def _refuse_code_changes(definition: FlowDef, before: FlowDef | None) -> None:
"""Refuse a non-superuser a change to code this flow would run.
*before* is the working copy being replaced, None for a new flow. Wiring,
titles, a function node's settings and every other node stay anyone's.
"""
previous = {node.id: node for node in before.nodes} if before else {}
for node in definition.nodes:
if _changes_code(node, previous.get(node.id)):
raise HTTPException(
status_code=403,
detail=f"{CODE_IS_SUPERUSERS} (node '{node.id}')",
)
@router.put("/{name}", response_model=FlowDetail)
async def save_flow(
name: str,
definition: FlowDef,
controller: FlowControllerDep,
user: CurrentUser,
) -> Any:
"""Save unpublished changes to a flow.
This writes a draft: the running pipeline keeps the published version until
someone publishes. ``version`` is the one the editor last saw — a mismatch
means another client saved in between and answers 409 rather than throwing
their work away.
"""
if definition.name != name:
raise HTTPException(
status_code=400, detail="The flow name in the body must match the URL"
)
duplicates = {n.id for n in definition.nodes}
if len(duplicates) != len(definition.nodes):
raise HTTPException(status_code=400, detail="Node names must be unique")
_check_flavors(definition)
_check_origin(definition)
if not user.is_superuser:
before = _read_flow(controller, name) if controller.store.exists(name) else None
_refuse_code_changes(definition, before)
try:
stored = await run_in_threadpool(
controller.store.write_draft, definition, definition.version
)
except StaleVersion as exc:
raise HTTPException(
status_code=409,
detail={"message": str(exc), "current_version": exc.current},
)
return await run_in_threadpool(_detail, controller, stored)
@router.post("/{name}/publish", response_model=FlowDetail)
async def publish_flow(
name: str,
body: PublishRequest,
controller: FlowControllerDep,
user: CurrentUser,
) -> Any:
"""Deploy the unpublished changes: the engine picks them up from here."""
_read_flow(controller, name)
if not controller.store.has_draft(name):
raise HTTPException(
status_code=400, detail=f"Flow '{name}' has no unpublished changes"
)
try:
published = await run_in_threadpool(
controller.store.publish_flow, name, body.version
)
except StaleVersion as exc:
raise HTTPException(
status_code=409,
detail={"message": str(exc), "current_version": exc.current},
)
_audit("published", name, user)
await controller.reload_flow(name)
return await run_in_threadpool(_detail, controller, published)
@router.post("/{name}/discard-draft", response_model=FlowDetail)
async def discard_draft(name: str, controller: FlowControllerDep) -> Any:
"""Throw the unpublished changes away and go back to what is running."""
_read_flow(controller, name)
if not controller.store.has_draft(name):
raise HTTPException(
status_code=400, detail=f"Flow '{name}' has no unpublished changes"
)
if not controller.store.is_published(name):
raise HTTPException(
status_code=400,
detail=(
f"Flow '{name}' has never been published — delete it instead of "
"discarding it"
),
)
published = await run_in_threadpool(controller.store.discard_draft, name)
return await run_in_threadpool(_detail, controller, published)
@router.delete("/{name}", response_model=Message)
async def delete_flow(
name: str, controller: FlowControllerDep, user: CurrentUser, session: SessionDep
) -> Any:
"""Delete a flow and everything in it, except the record of what it ran."""
live = session.exec(
select(Run.id).where(
col(Run.flow) == name, col(Run.status).in_(("running", "queued"))
)
).first()
if live is not None:
raise HTTPException(
status_code=409,
detail=(
f"Flow '{name}' has a run in progress ({live}). Cancel it, or "
"wait for it to finish, before deleting the flow."
),
)
try:
await run_in_threadpool(controller.store.delete_flow, name)
except FlowNotFound:
raise HTTPException(status_code=404, detail=f"No flow named '{name}'")
_audit("deleted", name, user)
# Its files are gone; its values and queued work would otherwise linger.
# Its runs stay: a run record is a record of what ran, and surviving the
# flow it belonged to is the point of keeping one. `DELETE /runs?flow=`
# is what clears them, and with `history=true` its cascades too.
await run_in_threadpool(controller.forget_flow, name)
await controller.reload_flow(name)
return Message(message=f"Deleted flow '{name}'")
@router.post("/{name}/rename", response_model=FlowDetail)
async def rename_flow(
name: str,
body: RenameRequest,
controller: FlowControllerDep,
) -> Any:
"""Rename a flow, along with every reference to its messages."""
if not NAME_PATTERN.match(body.new_name):
raise HTTPException(
status_code=400,
detail=(
"Use lowercase letters, digits and underscores, starting with a letter"
),
)
try:
renamed = await run_in_threadpool(
controller.store.rename_flow, name, body.new_name
)
except FlowNotFound:
raise HTTPException(status_code=404, detail=f"No flow named '{name}'")
except FlowExists as exc:
raise HTTPException(status_code=409, detail=str(exc))
# The old name is nobody's namespace now; its values would sit there under a
# flow that no longer exists. They repopulate under the new name on the next
# run, so this is cleanup rather than a migration.
await run_in_threadpool(controller.forget_flow, name)
await controller.reload()
return await run_in_threadpool(_detail, controller, renamed)
@router.post("/{name}/duplicate", response_model=FlowDetail)
async def duplicate_flow(
name: str, controller: FlowControllerDep, user: CurrentUser
) -> Any:
"""Copy a flow into a new one that is only a draft, named after it.
Nothing to reload: a draft does not run. Its code nodes are new code in
the copy, so copying them is a superuser's, like writing them.
"""
if not user.is_superuser:
_refuse_code_changes(_read_flow(controller, name), None)
try:
copy = await run_in_threadpool(controller.store.duplicate_flow, name)
except FlowNotFound:
raise HTTPException(status_code=404, detail=f"No flow named '{name}'")
return await run_in_threadpool(_detail, controller, copy)
# -----------------------------------------------------------------------------
# Node source
# -----------------------------------------------------------------------------
@router.get("/{name}/nodes/{node_id}/source", response_model=NodeSource)
def read_node_source(
name: str,
node_id: str,
controller: FlowControllerDep,
) -> Any:
"""Read a node's Python source, including unpublished edits."""
definition = _read_flow(controller, name)
ref = _source_ref(definition, node_id)
if ref:
try:
return NodeSource(code=controller.store.read_lib_source(ref))
except LibNotFound:
raise HTTPException(status_code=404, detail=f"No shared node '{ref}'")
return NodeSource(
code=controller.store.read_node_source(name, node_id, draft=True),
missing=not controller.store.has_node_source(name, node_id, draft=True),
)
def _follow_header(
controller: FlowController, name: str, node_id: str, code: str, drop: bool
) -> FlowDef | None:
"""Bring a function node's ports and settings in line with its header.
The draft is what the editor is on, so that is what moves, and only for a
node with its own code: a shared one serves other flows with other ports,
and a synced flow's ports are declared in its repository. Returns the
draft when it changed.
"""
definition = controller.store.read_flow(name, draft=True)
node = next((n for n in definition.nodes if n.id == node_id), None)
spec = NODE_TYPES.get(node.type) if node is not None else None
if (
node is None
or spec is None
or not spec.free_params
or node.source_ref
or definition.origin
):
return None
followed = follow_header(node, code, drop)
if followed == node:
return None
definition.nodes = [followed if n.id == node_id else n for n in definition.nodes]
try:
return controller.store.write_draft(definition, definition.version)
except StaleVersion:
# Someone saved in between; the next save of this code tries again.
return None
@router.put(
"/{name}/nodes/{node_id}/source",
response_model=NodeSourceSaved,
dependencies=[Depends(get_code_author)],
)
async def save_node_source(
name: str,
node_id: str,
source: NodeSource,
controller: FlowControllerDep,
user: CurrentUser,
drop: bool = True,
) -> Any:
"""Save a node's source as an unpublished edit and report whether it loads.
The answer comes from compiling the code rather than from the running
pipeline: a draft is not deployed, and compiling is both faster and more
precise about what the author just typed.
A shared node writes to the library, so the fix reaches every flow using
it — and that one is live immediately rather than waiting for a publish,
because the copy is not any single flow's to hold back. Being live, it is
audited like a publish.
A node with its own code takes its input ports and settings from the
header of ``process`` (see `follow_header`), and the answer carries the
node when that changed it. ``drop=false`` is the editor's autosave, which
adds and renames from the header but keeps what it no longer names until
the editor is left.
"""
definition = _read_flow(controller, name)
ref = _source_ref(definition, node_id)
if ref:
changed = await run_in_threadpool(
controller.store.write_lib_source, ref, source.code
)
if changed:
_audit(f"edited shared node '{ref}'", name, user)
await controller.reload_lib_users(ref)
else:
await run_in_threadpool(
controller.store.write_node_source, name, node_id, source.code, True
)
followed = await run_in_threadpool(
_follow_header, controller, name, node_id, source.code, drop
)
node_def = next((n for n in definition.nodes if n.id == node_id), None)
worker = (
node_def.worker
if node_def is not None and node_def.worker_policy == "require"
else None
)
error = await run_in_threadpool(
controller.compile_check, name, node_id, source.code, worker
)
return NodeSourceSaved(
id=f"{name}.{node_id}",
status="error" if error else "active",
error=error,
node=next((n for n in followed.nodes if n.id == node_id), None)
if followed
else None,
version=followed.version if followed else None,
)
@router.post("/{name}/nodes/{node_id}/share", response_model=FlowDetail)
async def share_node(
name: str,
node_id: str,
body: ShareRequest,
controller: FlowControllerDep,
) -> Any:
"""Move this node's code into the library so other flows can run it too."""
_read_flow(controller, name)
if not NAME_PATTERN.match(body.lib_name):
raise HTTPException(
status_code=400,
detail=(
"Use lowercase letters, digits and underscores, starting with a letter"
),
)
try:
await run_in_threadpool(
controller.store.share_node, name, node_id, body.lib_name
)
except LibExists as exc:
raise HTTPException(status_code=409, detail=str(exc))
return await run_in_threadpool(_detail, controller, _read_flow(controller, name))
@router.post("/{name}/nodes/{node_id}/unshare", response_model=FlowDetail)
async def unshare_node(
name: str,
node_id: str,
controller: FlowControllerDep,
) -> Any:
"""Take a private copy of the shared code back into this flow."""
_read_flow(controller, name)
try:
await run_in_threadpool(controller.store.unshare_node, name, node_id)
except LibNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc))
return await run_in_threadpool(_detail, controller, _read_flow(controller, name))
# -----------------------------------------------------------------------------
# Running, stopped, paused
# -----------------------------------------------------------------------------
@router.post("/{name}/start", response_model=FlowDetail)
async def start_flow(
name: str, controller: FlowControllerDep, user: CurrentUser
) -> Any:
"""Let the engine run this flow again."""
_read_flow(controller, name)
await controller.set_enabled(name, True)
_audit("started", name, user)
return await run_in_threadpool(_detail, controller, _read_flow(controller, name))
@router.post("/{name}/stop", response_model=FlowDetail)
async def stop_flow(name: str, controller: FlowControllerDep, user: CurrentUser) -> Any:
"""Take this flow off the engine: no subscriptions, schedules or webhooks."""
_read_flow(controller, name)
await controller.set_enabled(name, False)
_audit("stopped", name, user)
return await run_in_threadpool(_detail, controller, _read_flow(controller, name))
@router.post("/{name}/pause", response_model=Message)
def pause_flow(name: str, controller: FlowControllerDep) -> Any:
"""Hold the flow's nodes so its messages can be stepped through."""
_read_flow(controller, name)
controller.pause_flow(name)
return Message(message=f"Paused flow '{name}'")
@router.post("/{name}/resume", response_model=Message)
async def resume_flow(name: str, controller: FlowControllerDep) -> Any:
"""Let the flow carry on, running whatever was held back."""
_read_flow(controller, name)
await run_in_threadpool(controller.resume_flow, name)
return Message(message=f"Resumed flow '{name}'")
@router.post("/{name}/step", response_model=Message)
async def step_flow(name: str, controller: FlowControllerDep) -> Any:
"""Run one message a pause is holding back, leaving the flow paused."""
_read_flow(controller, name)
node = await run_in_threadpool(controller.step_flow, name)
if node is None:
return Message(message=f"Nothing held back in flow '{name}'")
return Message(message=f"Stepped '{node}'")
# -----------------------------------------------------------------------------
# Validation and execution
# -----------------------------------------------------------------------------
@router.post("/{name}/validate", response_model=ValidationResult)
def validate_flow(name: str, controller: FlowControllerDep) -> Any:
"""Report what would keep this flow from running."""
_read_flow(controller, name)
if controller.store.has_draft(name):
return ValidationResult(issues=controller.preview(name).issues)
return ValidationResult(issues=controller.flow_issues(name))
@router.post("/{name}/run", response_model=FlowStatePublic)
async def run_flow(
name: str,
body: RunRequest,
request: Request,
controller: FlowControllerDep,
user: CurrentUser,
hidden: HiddenDep,
) -> Any:
"""Run every node of a flow once.
With unpublished changes this runs the draft, so the button matches what is
on the canvas. Nothing is deployed by running it.
A *batch* flow is submitted as a run instead, because that is what running
one means: it is what keeps the parameters, the series and the result, and
a button that quietly did something else would be a trap.
"""
definition = _read_flow(controller, name)
_require_enabled(controller, name)
if definition.mode == "batch":
service = getattr(request.app.state, "run_service", None)
if service is None:
raise HTTPException(status_code=503, detail="Runs are not available")
try:
await run_in_threadpool(
service.submit,
name,
params=body.inputs,
cause="api",
actor=user.email,
draft=controller.store.has_draft(name),
)
except RunRejected as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return _flow_state(controller, name, hidden)
inputs = {qualify(name, key): value for key, value in body.inputs.items()}
if controller.store.has_draft(name):
await run_in_threadpool(controller.run_preview, name, inputs)
else:
await run_in_threadpool(controller.run_flow, name, inputs)
return _flow_state(controller, name, hidden)
@router.post("/{name}/nodes/{node_id}/trigger", response_model=FlowStatePublic)
async def trigger_node(
name: str,
node_id: str,
body: TriggerRequest,
controller: FlowControllerDep,
hidden: HiddenDep,
) -> Any:
"""Feed values into a single node."""
_require_enabled(controller, name)
try:
error = await run_in_threadpool(
controller.trigger_node, f"{name}.{node_id}", body.values
)
except KeyError:
raise HTTPException(
status_code=404, detail=f"No node named '{node_id}' in flow '{name}'"
)
if error:
# The canvas already has the failure from the bus; the person who
# clicked gets to read it too, instead of a stack trace in the log.
raise HTTPException(status_code=400, detail=error)
return _flow_state(controller, name, hidden)
@router.post("/{name}/nodes/{node_id}/cancel", response_model=Message)
def cancel_node(name: str, node_id: str, controller: FlowControllerDep) -> Any:
"""Stop a node that is running right now, by killing the worker running it.
Idempotent on purpose: by the time a click reaches here the node may well
have finished, and that is the outcome that was asked for.
"""
pool = controller.workers
if pool is not None and pool.cancel(f"{name}.{node_id}"):
return Message(message=f"Stopped '{node_id}'")
return Message(message=f"'{node_id}' was not running")
@router.post("/{name}/nodes/{node_id}/acknowledge", response_model=Message)
def acknowledge_node_error(
name: str, node_id: str, controller: FlowControllerDep
) -> Any:
"""Dismiss what a node last failed with, so the canvas stops marking it.
A failure outlives the next good run on purpose — otherwise one that fired
an alert leaves no trace by the time anyone looks. Reading the traceback is
what says it has been seen.
"""
try:
controller.acknowledge_error(f"{name}.{node_id}")
except KeyError:
raise HTTPException(
status_code=404, detail=f"No node named '{node_id}' in flow '{name}'"
) from None
# Another browser holds the same marker and has no reason to refetch, so it
# would keep showing a failure that is gone until its next snapshot.
event_bus.publish(
{
"type": "node_error_acknowledged",
"node": f"{name}.{node_id}",
"ts": time.time(),
}
)
return Message(message=f"Cleared the failure on '{node_id}'")
# -----------------------------------------------------------------------------
# Training a wren node
# -----------------------------------------------------------------------------
def _node_def(controller: FlowController, name: str, node_id: str) -> NodeDef:
"""A node as the editor shows it: from the draft when there is one."""
node = next(
(n for n in _read_flow(controller, name).nodes if n.id == node_id), None
)
if node is None:
raise HTTPException(
status_code=404, detail=f"No node named '{node_id}' in flow '{name}'"
)
return node
@router.post("/{name}/nodes/{node_id}/train", response_model=Training, status_code=202)
async def train_node(
name: str,
node_id: str,
request: Request,
controller: FlowControllerDep,
user: CurrentUser,
) -> Any:
"""Train a new model for a wren node, as a run of wren's training flow.
It learns the node's targets, its ``examples`` and its ``tests``, as the
editor shows them. The node keeps running its model until the new one is
set as its ``model``.
"""
node = _node_def(controller, name, node_id)
if node.type != "wren":
raise HTTPException(status_code=400, detail=f"'{node_id}' is not a wren node")
try:
wren = WrenNode(
requires=[spec for spec in node.requires if spec.name],
provides=[spec for spec in node.provides if spec.name],
params=node.params,
)
except (ValueError, RuntimeError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
service = getattr(request.app.state, "run_service", None)
if service is None:
raise HTTPException(status_code=503, detail="Runs are not available")
try:
run = await run_in_threadpool(
service.submit,
TRAIN_FLOW,
params={"installation": orjson.dumps(wren.installation()).decode()},
cause="train",
actor=user.email,
no_cache=True,
for_node=f"{name}.{node_id}",
)
except FlowNotFound:
raise HTTPException(
status_code=409,
detail=(
f"wren's training flow '{TRAIN_FLOW}' is not registered. Run "
"`fluksio sync wren.flow` from an environment with wren[train]."
),
) from None
except RunRejected as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return Training(id=run.id, status=run.status)
@router.get("/{name}/nodes/{node_id}/train", response_model=Training | None)
def read_training(
name: str,
node_id: str,
request: Request,
controller: FlowControllerDep,
session: SessionDep,
) -> Any:
"""A wren node's latest training run, or null when it has none."""
_node_def(controller, name, node_id)
run = session.exec(
select(Run)
.where(col(Run.flow) == TRAIN_FLOW, col(Run.for_node) == f"{name}.{node_id}")
.order_by(col(Run.created_at).desc())
).first()
if run is None:
return None
training = Training(id=run.id, status=run.status, result=run.result)
if run.status == "error":
failed = session.exec(
select(RunNode.error).where(
col(RunNode.run_id) == run.id, col(RunNode.error) != ""
)
).first()
training.error = failed or run.status_reason
# The model, once the run made one the store still has.
pack = session.get(RunArtifact, {"run_id": run.id, "name": f"{TRAIN_FLOW}.pack"})
store = getattr(request.app.state, "artifact_store", None)
path = store.path(pack.digest) if pack is not None and store else None
if pack is not None and path is not None:
meta = pack_meta(path)
training.model = pack.digest
training.threshold = meta.get("threshold")
training.test_exact_match = meta.get("test_exact_match")
return training
@router.get("/{name}/state", response_model=FlowStatePublic)
def read_flow_state(name: str, controller: FlowControllerDep, hidden: HiddenDep) -> Any:
"""The last value seen on every message of this flow."""
_read_flow(controller, name)
return _flow_state(controller, name, hidden)
@router.get("/{name}/history/{message}", response_model=MessageHistory)
def read_message_history(
name: str,
message: str,
controller: FlowControllerDep,
hidden: HiddenDep,
) -> Any:
"""The recent values of one message, for plotting.
``message`` may be given bare or qualified; a message that never carried a
number or a flag comes back with an empty series.
"""
_read_flow(controller, name)
key = qualify(name, message)
if key in hidden:
raise HTTPException(status_code=403, detail=CAMERA_IS_SUPERUSERS)
return MessageHistory(
message=key,
numeric=as_number(controller.state.get(key)) is not None,
points=[
HistoryPoint(ts=ts, value=value)
for ts, value in controller.state.history(key)
],
)
# -----------------------------------------------------------------------------
# Live updates
# -----------------------------------------------------------------------------
def snapshot_payload(
controller: FlowController,
only: set[str] | None = None,
device: str = "",
uplink: str = "",
hidden: frozenset[str] = frozenset(),
) -> dict[str, Any]:
"""Everything a client needs to catch up, sent the moment it connects.
Also sent by the tunnel connector, which serves this websocket inline: the
two have to agree, so they build the message here rather than each their
own. ``emits`` is what the bus counted while nobody was listening — a
client that reconnects between two pages would otherwise start from zero.
``queued`` is the nodes waiting for a machine, for the same reason: the bus
announces a wait once, and a client that missed it would draw the node idle.
``only`` bounds it to a set of message names, which is what a wall panel
gets: the values its own dashboards draw, and none of the rest — no node
status, no logs, no shape of the graph. A panel renders none of that, and
a screen may be hanging somewhere nobody here can see.
A device's socket also says which of its ports have ``demand`` now, and
which ``uplink`` its frames take: ``binary`` on its own socket, ``http``
through the portal, whose tunnel carries text only.
``hidden`` are the camera messages a person may not see, left out.
"""
if only is not None:
# The same keys, emptied rather than dropped: a screen already hanging
# runs whatever bundle it was paired with, and the shape of this
# message is what that bundle reads.
payload: dict[str, Any] = {
"type": "snapshot",
"values": {k: v for k, v in controller.values().items() if k in only},
"nodes": [],
"issues": [],
"paused": [],
"logs": [],
"emits": {},
"queued": [],
}
if device:
payload["demand"] = demand.ports(device)
payload["uplink"] = uplink
return payload
values = controller.values()
return {
"type": "snapshot",
"values": (
{k: v for k, v in values.items() if k not in hidden} if hidden else values
),
"nodes": [s.model_dump() for s in controller.node_statuses()],
"issues": [i.model_dump() for i in controller.issues],
"paused": controller.paused_flows(),
"logs": list(event_bus.recent_logs),
"emits": dict(event_bus.emits),
"queued": controller.placer.snapshot()["waiting"] if controller.placer else [],
}
def demand_frame(ports: list[str]) -> str:
"""What a device is told when its ports with demand change."""
return orjson.dumps({"type": "demand", "ports": ports}).decode()
def device_of(token: str, app: Any) -> str:
"""The device this credential stands for, or "" for a person or none."""
try:
return str(decode_token(token, app=app).get("panel") or "")
except InvalidTokenError:
return ""
class Credential:
"""Whether a device socket's credential still stands, asked per frame.
Checked in full only once what it is checked against has moved: the
device files, which a device's save, deletion or a revert commits to the
flow store, and the nonces in ``devices.json``, which an unpair bumps.
Otherwise a frame costs a stat of that file.
"""
def __init__(self, token: str, app: Any, device_id: str) -> None:
self.token = token
self.app = app
self.device = device_id
self._seen: tuple[int, int] | None = None
def _key(self) -> tuple[int, int] | None:
devices: DeviceStore | None = getattr(self.app.state, "device_store", None)
if devices is None:
return None
try:
return devices.flows.revision, devices.nonce(self.device)
except (OSError, ValueError):
return None
async def revoked(self) -> bool:
key = self._key()
if key is not None and key == self._seen:
return False
if await run_in_threadpool(revoked, self.token, self.app):
return True
# The key read before the check, so a change meanwhile is checked.
self._seen = key
return False
def revoked(token: str, app: Any) -> bool:
"""Whether a device's credential has stopped standing since its handshake.
Unpaired or deleted. One that cannot be checked right now has not: a
device closed with 1008 checks whether it is still paired.
"""
try:
decode_token(token, app=app)
except InvalidTokenError:
return True
except DeviceUnavailable:
return False
return False
def panel_scope(token: str, app: Any) -> set[str] | None:
"""The messages this credential is bounded to, or None if it is a person's.
The socket is the one authenticated surface the route check cannot reach,
a handshake has no route to judge, so a device is bounded by what it is
sent instead of by what it asks for.
Its own camera's frames are left out unless a dashboard it shows draws
them: it sent them, and a camera's values come several a second.
"""
device_id = device_of(token, app)
if not device_id:
return None
store: DashboardStore | None = getattr(app.state, "dashboard_store", None)
devices: DeviceStore | None = getattr(app.state, "device_store", None)
if store is None or devices is None:
return set()
device = devices.find(device_id)
found = messages_for(device, store, devices)
if device is not None:
shown = {
name for defn in dashboards_for(device, store) for name in messages_of(defn)
}
found -= {
b.message
for b in devices.bound(device.id)
if b.direction == "in" and b.dtype == "image"
} - shown
return found
def event_for_panel(event: dict[str, Any], only: set[str]) -> bool:
"""Whether a panel's socket should carry this event.
The same bound as the snapshot above, applied to the stream that follows
it: a value the panel draws, and nothing else on the bus — save for a
dashboard being published, which is how a screen hears that the document
it is drawing, or the set of them it was given, has moved.
"""
if event.get("type") == "dashboard_changed":
return True
return event.get("type") == "message_value" and str(event.get("name") or "") in only
def carried(
event: dict[str, Any], only: set[str] | None, hidden: frozenset[str]
) -> bool:
"""Whether a socket carries this event on the bus.
A panel's what it draws (``event_for_panel``), a person's everything but
the values of the cameras ``hidden`` from them.
"""
if only is not None:
return event_for_panel(event, only)
return not (
hidden and event.get("type") == "message_value" and event.get("name") in hidden
)
# How many events one frame may carry. A ceiling rather than a target: the
# batch is whatever the bus happens to hold, and a client that has been away
# should not be handed the whole queue in one message.
MAX_FRAME_EVENTS = 64
#: How many message names one socket may ask for bytes on. A screen draws a
#: handful of tiles, and every live one keeps a camera sending, so a phone
#: scrolled past several cameras does not pull them all. The rest are refused.
MAX_MEDIA_NAMES = 8
def wanted_names(
frame: str, only: set[str] | None, hidden: frozenset[str] = frozenset()
) -> set[str] | None:
"""What this client is asking to be sent bytes for, or None if it said
something else.
A client asks by name — the tiles it is drawing — and is answered only for
the names a panel credential would have been allowed anyway, and none of
the ``hidden`` cameras. Bytes are the expensive thing on this socket, so
nothing is pushed until something says it is looking at it.
Past the cap the names it gave last are kept: a client lists the tile that
came on screen most recently last, so one scrolled back to keeps working.
"""
try:
payload = orjson.loads(frame)
except orjson.JSONDecodeError:
return None
if not isinstance(payload, dict) or payload.get("type") != "media":
return None
names = payload.get("names")
if not isinstance(names, list):
return set()
asked = {str(name) for name in names}
if len(asked) > MAX_MEDIA_NAMES:
kept = list(dict.fromkeys(str(name) for name in names))[-MAX_MEDIA_NAMES:]
logger.warning(
"A socket asked for live media on %d messages; only %d are served, "
"the last it named: %s",
len(asked),
MAX_MEDIA_NAMES,
", ".join(kept),
)
asked = set(kept)
asked -= hidden
return asked if only is None else asked & only
def media_frames(
events: list[dict[str, Any]], wanted: set[str], store: Any
) -> list[bytes]:
"""The bytes behind the media values in this batch, one frame each.
Referenced-then-fetched costs a round trip per frame, which is what keeps
the message plane at a glance rather than a view. Pushing the bytes down
the socket that already carries the event closes that, and only for the
frames a client said it was drawing.
Only what the ring holds, out of memory: the durable store is what a fetch
is for, and a checkpoint has no business being pushed at anyone. The
newest value per name wins, so a client that fell behind is not handed a
backlog of frames it would only draw over.
"""
if not wanted or store is None or getattr(store, "volatile", None) is None:
return []
newest: dict[str, dict[str, Any]] = {}
for event in events:
if event.get("type") != "message_value":
continue
name = str(event.get("name") or "")
if name not in wanted or not is_reference(event.get("value")):
continue
newest[name] = event
frames: list[bytes] = []
for name, event in newest.items():
value = event["value"]
digest = str(value.get("digest") or "")
payload = store.volatile.get(digest)
if payload is None:
continue
header = orjson.dumps(
{
"type": "media",
"name": name,
"digest": digest,
"media_type": str(value.get("media_type") or ""),
"ts": event.get("ts"),
}
)
# Length-prefixed, so one frame carries both halves and the reader
# never has to guess where the JSON stops.
frames.append(len(header).to_bytes(4, "big") + header + payload)
return frames
#: What a device may say a frame is: a media type, parameters allowed
#: (``audio/L16;rate=16000``).
_MEDIA_TYPE = re.compile(r"^[\w.+-]+/[\w.+-]+(;[\w.+-]+=[\w.+-]+)*$")
def parse_frame(data: bytes) -> tuple[dict[str, Any], bytes] | None:
"""Split a binary frame into its JSON header and the bytes after it.
The layout media frames go out in: four bytes of header length, big
endian, the header, then the bytes. None for anything else.
"""
if len(data) < 4:
return None
length = int.from_bytes(data[:4], "big")
if length == 0 or 4 + length > len(data):
return None
try:
header = orjson.loads(data[4 : 4 + length])
except orjson.JSONDecodeError:
return None
if not isinstance(header, dict):
return None
return header, data[4 + length :]
#: Why a device's frame was dropped, by the key it is logged under once per
#: socket. Fixed, so nothing the device chose reaches the log.
FRAME_REFUSALS = {
"not_a_frame": "not a frame",
"not_a_device": "only a device sends frames",
"no_engine": "the engine is not running",
"not_wired": "it names no message one of the device's ports publishes",
"not_media": "the port it names is not wired as an image or audio",
"media_type": "its media type does not match its port",
"too_large": "it is larger than MAX_FRAME_BYTES",
"not_scalar": "the port it names is not wired as a bool, int, float or str",
"undeclared": "no flow declares its message",
"refused": "its message refused the value",
}
def ingest_frame(app: Any, device: DeviceDef, data: bytes) -> str:
"""Store a frame a device sent on its socket and publish it.
Returns the key in ``FRAME_REFUSALS`` it was refused for, or "" once it
is published.
The same frame over HTTP is two requests, each authenticated: the upload,
then the publish. Here the socket was authenticated once, at its
handshake, and the frame is held and published in one step. Only a
message one of the device's own ``in`` ports is wired to, as an ``image``
or an ``audio`` chunk, and the value is credited to the device node holding that port, as an
HTTP publish is. The frame is named after that message.
"""
cap = settings.MAX_FRAME_BYTES
if cap and len(data) > cap:
return "too_large"
parsed = parse_frame(data)
if parsed is None or parsed[0].get("type") != "frame":
return "not_a_frame"
header, payload = parsed
name = str(header.get("name") or "")
devices: DeviceStore | None = getattr(app.state, "device_store", None)
store: Any = getattr(app.state, "artifact_store", None)
controller: FlowController | None = getattr(app.state, "flow_controller", None)
if devices is None or store is None or controller is None:
return "no_engine"
binding = _bound_in(devices, device, name)
if binding is None:
return "not_wired"
if binding.dtype not in (DType.IMAGE, DType.AUDIO):
return "not_media"
media_type = str(header.get("media_type") or "")
if not _MEDIA_TYPE.match(media_type) or not media_type.startswith(
MEDIA_FAMILIES[DType(binding.dtype)]
):
return "media_type"
reference = store.put([payload], name=name, media_type=media_type, volatile=True)
try:
controller.publish_message(name, reference, port_source(devices, device, name))
except KeyError:
return "undeclared"
except TypeError:
return "refused"
return ""
def _bound_in(devices: DeviceStore, device: DeviceDef, name: str) -> Binding | None:
"""The device's own ``in`` port wired to ``name``, if one is."""
return next(
(
b
for b in devices.bound(device.id)
if b.direction == "in" and b.message == name
),
None,
)
def published(frame: str) -> dict[str, Any] | None:
"""A value a device publishes on its socket, or None for anything else."""
try:
payload = orjson.loads(frame)
except orjson.JSONDecodeError:
return None
if isinstance(payload, dict) and payload.get("type") == "publish":
return payload
return None
def ingest_publish(app: Any, device: DeviceDef, payload: dict[str, Any]) -> str:
"""Publish a value a device sent on its socket, as ``POST /messages`` would.
Returns the key in ``FRAME_REFUSALS`` it was refused for, or "" once it
is published. Only a message one of the device's own ``in`` ports is wired
to as a scalar, credited to the device node holding that port. Taken in
turn with the device's binary frames, so a ``listening`` false lands after
the last chunk of what was heard.
"""
name = str(payload.get("name") or "")
devices: DeviceStore | None = getattr(app.state, "device_store", None)
controller: FlowController | None = getattr(app.state, "flow_controller", None)
if devices is None or controller is None:
return "no_engine"
binding = _bound_in(devices, device, name)
if binding is None:
return "not_wired"
if binding.dtype not in (DType.BOOL, DType.INT, DType.FLOAT, DType.STR):
return "not_scalar"
try:
controller.publish_message(
name, payload.get("value"), port_source(devices, device, name)
)
except KeyError:
return "undeclared"
except TypeError:
return "refused"
return ""
async def _receive(websocket: WebSocket) -> str | bytes:
"""The client's next frame, text or binary."""
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
raise WebSocketDisconnect(message.get("code", 1000), message.get("reason"))
data = message.get("bytes")
return data if data is not None else str(message.get("text") or "")
async def _send(websocket: WebSocket, events: list[dict[str, Any]]) -> None:
"""One event, or a batch of them under `events`.
Serialised once with orjson rather than per client with the stdlib, which
is what `send_json` does.
"""
payload = events[0] if len(events) == 1 else {"type": "batch", "events": events}
await websocket.send_text(orjson.dumps(payload).decode())
class SocketTicket(BaseModel):
ticket: str
expires_in: float
@router.post("/ws-ticket", response_model=SocketTicket)
def issue_socket_ticket(token: TokenDep) -> Any:
"""A one-time pass for opening `/flows/ws`, good for thirty seconds.
A browser cannot set a header on a websocket handshake, and a token in the
URL ends up in every access log on the way. The ticket goes there instead
and stands for the bearer token of this call, panel credentials included.
"""
return SocketTicket(ticket=issue_ticket(token), expires_in=TICKET_TTL_S)
@ws_router.websocket("/ws")
async def flow_events(websocket: WebSocket, ticket: str = "", token: str = "") -> None:
"""Stream values, node status and execution events as they happen.
A browser brings a ticket from `POST /flows/ws-ticket`; anything that can
set headers sends `Authorization: Bearer`. `?token=` is still read for one
release, for clients from before either.
A client says which media it is drawing (``{"type": "media", "names":
[...]}``) and is pushed those frames. A device also sends its camera's
frames here, as binary frames in the layout it is pushed them in, and
the values of its scalar ports as ``publish`` messages, and is told when
its on-demand ports have demand.
A live view's signalling rides here too (``fluksio.flow.live``): ``live``
messages on a person's socket, ``signal`` messages on a device's.
"""
token = socket_token(websocket.headers, ticket, token)
# On a thread: authenticating is a database round trip, and the snapshot
# below reads the whole of state. Neither belongs on the event loop, which
# every other socket and every request is sharing.
def _authenticate() -> Any:
with Session(engine) as session:
user = user_from_token(session, token, app=websocket.app)
only = panel_scope(token, websocket.app) if user is not None else None
# A device is online for as long as this socket is open.
device = device_of(token, websocket.app) if only is not None else ""
return user, only, device, _device_def(websocket.app, device)
try:
user, only, device, device_def = await run_in_threadpool(_authenticate)
except Exception as exc:
# Not a refusal: the database or the device store could not say. A
# device takes 1008, or a 403 before accepting, as its credential
# being gone and checks it, so this one is told to try again later.
logger.warning("Could not check a socket's credential: %s", exc)
await websocket.accept()
await websocket.close(code=1013)
return
if user is None:
await websocket.close(code=1008)
return
# Nothing until a client says it is drawing something: bytes are what this
# socket cannot afford to send speculatively.
wanted: set[str] = set()
# This socket's entry in the registry every socket's wants are kept in.
viewer = object()
# Each reason a frame was refused for, logged once per socket.
refused: set[str] = set()
# The cameras a person may not see. Looked at again with each batch, so
# a device switched to members, or back, takes effect on open sockets.
hidden = hidden_from(user, websocket.app, device=only is not None)
# A device's credential, checked again per frame once it may have moved.
credential = Credential(token, websocket.app, device) if device else None
await websocket.accept()
controller: FlowController | None = getattr(
websocket.app.state, "flow_controller", None
)
store = getattr(websocket.app.state, "artifact_store", None)
async def send_snapshot() -> list[str]:
"""Send the snapshot, and return the demand it told a device of."""
if controller is None:
return []
payload = await run_in_threadpool(
snapshot_payload, controller, only, device, "binary", hidden
)
await websocket.send_text(orjson.dumps(payload).decode())
return list(payload.get("demand", []))
async def catch_up() -> bool:
"""Rescope a panel and send the snapshot again, demand included.
False once a device whose credential stopped standing is closed with
1008, which sends it to check: otherwise its frames would still be
taken here.
"""
nonlocal only, device_def, wanted
if only is not None:
if device and await run_in_threadpool(revoked, token, websocket.app):
await websocket.close(code=1008)
return False
# The scope was resolved once, at the handshake. A panel pointed
# at another dashboard would otherwise fetch the new document and
# then draw tiles nothing ever updates. ``or set()`` because a
# panel that was deleted resolves to None, the same as a person's
# token — and that would widen this socket to everything on the
# bus.
only = panel_scope(token, websocket.app) or set()
device_def = _device_def(websocket.app, device)
wanted &= only
demand.want(viewer, wanted)
await send_snapshot()
return True
told = await send_snapshot()
async with event_bus.subscribe() as queue:
receiver = asyncio.create_task(_receive(websocket))
# Where a live view this socket is one end of reaches it. Its own
# outbox rather than the bus's queue, which drops when full.
endpoint = live_view.Endpoint()
if device:
presence.opened(device)
# Demand that moved between the snapshot and the subscription
# would otherwise wait for the next change.
ports = demand.ports(device)
if ports != told:
await websocket.send_text(demand_frame(ports))
live_view.sessions.attach(device, endpoint)
outbox = asyncio.create_task(endpoint.next())
try:
while True:
sender = asyncio.create_task(queue.get())
done, _ = await asyncio.wait(
{sender, receiver, outbox}, return_when=asyncio.FIRST_COMPLETED
)
if outbox in done:
# Signalling, for this socket alone.
if sender not in done:
sender.cancel()
text = outbox.result()
outbox = asyncio.create_task(endpoint.next())
await websocket.send_text(text)
if receiver not in done and sender not in done:
continue
if receiver in done:
if sender not in done:
# Nobody will ever read this one: the next turn makes
# its own. Left behind it stays parked on the queue's
# waiter list, takes an event the live reader wanted,
# and is reported as a task destroyed while pending.
sender.cancel()
# A frame from the client. Only a disconnect ends the
# stream — a keepalive, or anything else it decides to
# say, used to be read as the client going away and cost
# it every live update from then on.
failure = receiver.exception()
if failure is not None:
# The client is gone, and `receive` raises for good
# from here on. Reading it again would spin this loop
# at full tilt against a dead socket.
raise failure
frame = receiver.result()
if isinstance(frame, bytes):
# A camera's frame. Refused ones are dropped and the
# socket stays open: one bad frame is not a reason to
# take the device's live values away. A device that
# was unpaired meanwhile is closed instead.
if credential is not None and await credential.revoked():
await websocket.close(code=1008)
return
reason = (
await run_in_threadpool(
ingest_frame, websocket.app, device_def, frame
)
if device_def is not None
else "not_a_device"
)
if reason and reason not in refused:
refused.add(reason)
logger.warning(
"Dropped a frame from %s: %s",
device or "a person's socket",
FRAME_REFUSALS[reason],
)
else:
asked = wanted_names(frame, only, hidden)
if asked is not None:
wanted = asked
demand.want(viewer, wanted)
elif (
device_def is not None
and (value := published(frame)) is not None
):
if credential is not None and await credential.revoked():
await websocket.close(code=1008)
return
reason = await run_in_threadpool(
ingest_publish, websocket.app, device_def, value
)
if reason and reason not in refused:
refused.add(reason)
logger.warning(
"Dropped a value from %s: %s",
device,
FRAME_REFUSALS[reason],
)
elif (signal := live_view.parse(frame)) is not None:
live_view.sessions.heard(
signal,
endpoint,
person=str(user.id) if only is None else "",
device=device,
devices=getattr(
websocket.app.state, "device_store", None
),
superuser=user.is_superuser,
)
receiver = asyncio.create_task(_receive(websocket))
if sender not in done:
continue
# `sender` is done from here: the turns where it is not have
# gone round again above.
# Everything the bus has right now, not just the one event
# that woke this: a cascade puts a dozen in at once, and one
# frame carrying them costs one wakeup rather than a dozen.
batch = [sender.result()]
while len(batch) < MAX_FRAME_EVENTS:
try:
batch.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
# A panel this far behind lost events the bus dropped for it:
# its demand, a dashboard published or its own unpairing. It
# catches up once this batch is out, as after a dashboard.
resync = only is not None and event_bus.dropped(queue)
if only is None:
fresh = hidden_from(user, websocket.app)
if fresh != hidden:
# A camera was opened or closed to members: the
# snapshot gives back what may now be seen.
hidden = fresh
wanted -= hidden
demand.want(viewer, wanted)
resync = True
out: list[dict[str, Any]] = []
for event in batch:
kind = event.get("type")
if kind == "device_demand":
# For the device it names, and for nobody else.
if device and event.get("device") == device:
await websocket.send_text(
demand_frame(list(event.get("ports") or []))
)
continue
if only is not None and kind == "dashboard_changed":
if out:
await _send(websocket, out)
out = []
if not await catch_up():
return
if carried(event, only, hidden):
out.append(event)
if out:
# The bytes first: a tile that has the frame when the
# value lands draws it in one pass rather than two.
for frame in media_frames(out, wanted, store):
await websocket.send_bytes(frame)
await _send(websocket, out)
if resync and not await catch_up():
return
except (WebSocketDisconnect, RuntimeError):
# A peer that goes away mid-send takes the RuntimeError route
# ("websocket.send after websocket.close") rather than the clean
# disconnect. Either way the socket is gone and the loop is over.
pass
finally:
receiver.cancel()
outbox.cancel()
demand.drop(viewer)
live_view.sessions.detach(endpoint)
if device:
presence.closed(device)
def _device_def(app: Any, device_id: str) -> DeviceDef | None:
"""The device a socket stands for, as its frames are credited to it."""
devices: DeviceStore | None = getattr(app.state, "device_store", None)
if not device_id or devices is None:
return None
return devices.find(device_id)