A node's timeout now covers its body only: the pool loads the source into the
worker it picked, off the node's budget, so imports that outlast the timeout no
longer make a node impossible to run. Draft checks compile without caching, so
saving does not evict what a busy node is serving calls from. Requests carry an
id the worker echoes and the pool checks, a reply is encoded once, and the
remote-exception cache is bounded.
DELETE /flows/{name} answers 409 while the flow has a running or queued run,
which is what was letting run_node rows outlive their run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StpRc2C6au1WJ1EUU7fsfu
941 lines
34 KiB
Python
941 lines
34 KiB
Python
"""The flow API. Everything the editor can do is available here first."""
|
|
|
|
import asyncio
|
|
import time
|
|
from typing import Any
|
|
|
|
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 sqlalchemy import delete
|
|
from sqlmodel import Session, col, select
|
|
|
|
from fluksio.api.deps import (
|
|
CurrentUser,
|
|
FlowControllerDep,
|
|
SessionDep,
|
|
decode_token,
|
|
get_current_user,
|
|
user_from_token,
|
|
)
|
|
from fluksio.core.db import engine
|
|
from fluksio.flow.controller import FlowController
|
|
from fluksio.flow.dashboards import DashboardStore
|
|
from fluksio.flow.events import event_bus
|
|
from fluksio.flow.messages import flow_of, qualify
|
|
from fluksio.flow.panels import messages_for
|
|
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,
|
|
NodeSource,
|
|
NodeStatusPublic,
|
|
NodeTypeInfo,
|
|
)
|
|
from fluksio.flow.state import as_number
|
|
from fluksio.flow.store import (
|
|
FlowExists,
|
|
FlowNotFound,
|
|
LibExists,
|
|
LibNotFound,
|
|
StaleVersion,
|
|
)
|
|
from fluksio.models import Message, Run, RunArtifact, RunMetric, RunNode
|
|
|
|
router = APIRouter(
|
|
prefix="/flows", tags=["flows"], dependencies=[Depends(get_current_user)]
|
|
)
|
|
# The websocket authenticates from its query string, 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] = {}
|
|
|
|
|
|
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:
|
|
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 _forget_runs(session: Session, flow: str) -> None:
|
|
"""A deleted flow's runs, and everything hanging off them.
|
|
|
|
Here rather than in ``FlowController.forget_flow`` because renaming a flow
|
|
calls that too, and a rename must keep its experiment history.
|
|
|
|
Only the run tables: ``flow_run``, ``metric_minute`` and ``engine_event``
|
|
are the observability rollups, deliberately kept as a record of what ran
|
|
and already pruned at OBS_RETENTION_DAYS.
|
|
"""
|
|
# A subquery, not a materialised list of ids: a demo can hold thousands.
|
|
runs = select(col(Run.id)).where(col(Run.flow) == flow)
|
|
session.execute(delete(RunNode).where(col(RunNode.run_id).in_(runs)))
|
|
session.execute(delete(RunMetric).where(col(RunMetric.run_id).in_(runs)))
|
|
session.execute(delete(RunArtifact).where(col(RunArtifact.run_id).in_(runs)))
|
|
session.execute(delete(Run).where(col(Run.flow) == flow))
|
|
session.commit()
|
|
|
|
|
|
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) -> FlowStatePublic:
|
|
return FlowStatePublic(
|
|
values={
|
|
key: MessageValue(**value) for key, value in controller.values(name).items()
|
|
},
|
|
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,
|
|
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,
|
|
)
|
|
)
|
|
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) -> Any:
|
|
"""Remove a shared source, as long as no flow still runs it."""
|
|
used_by = controller.store.usages(lib_name)
|
|
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))
|
|
|
|
|
|
@router.put("/{name}", response_model=FlowDetail)
|
|
async def save_flow(
|
|
name: str,
|
|
definition: FlowDef,
|
|
controller: FlowControllerDep,
|
|
) -> 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")
|
|
|
|
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 _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()
|
|
return _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 _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."""
|
|
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.
|
|
await run_in_threadpool(controller.forget_flow, name)
|
|
await run_in_threadpool(_forget_runs, session, name)
|
|
await controller.reload()
|
|
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 _detail(controller, renamed)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# 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))
|
|
|
|
|
|
@router.put("/{name}/nodes/{node_id}/source", response_model=NodeStatusPublic)
|
|
async def save_node_source(
|
|
name: str,
|
|
node_id: str,
|
|
source: NodeSource,
|
|
controller: FlowControllerDep,
|
|
) -> 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.
|
|
"""
|
|
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:
|
|
await controller.reload()
|
|
else:
|
|
await run_in_threadpool(
|
|
controller.store.write_node_source, name, node_id, source.code, True
|
|
)
|
|
node_def = next((n for n in definition.nodes if n.id == node_id), None)
|
|
device = (
|
|
node_def.device
|
|
if node_def is not None and node_def.device_policy == "require"
|
|
else None
|
|
)
|
|
error = await run_in_threadpool(
|
|
controller.compile_check, name, node_id, source.code, device
|
|
)
|
|
return NodeStatusPublic(
|
|
id=f"{name}.{node_id}",
|
|
status="error" if error else "active",
|
|
error=error,
|
|
)
|
|
|
|
|
|
@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 _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 _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 _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 _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,
|
|
) -> 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)
|
|
|
|
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)
|
|
|
|
|
|
@router.post("/{name}/nodes/{node_id}/trigger", response_model=FlowStatePublic)
|
|
async def trigger_node(
|
|
name: str,
|
|
node_id: str,
|
|
body: TriggerRequest,
|
|
controller: FlowControllerDep,
|
|
) -> 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)
|
|
|
|
|
|
@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
|
|
return Message(message=f"Cleared the failure on '{node_id}'")
|
|
|
|
|
|
@router.get("/{name}/state", response_model=FlowStatePublic)
|
|
def read_flow_state(name: str, controller: FlowControllerDep) -> Any:
|
|
"""The last value seen on every message of this flow."""
|
|
_read_flow(controller, name)
|
|
return _flow_state(controller, name)
|
|
|
|
|
|
@router.get("/{name}/history/{message}", response_model=MessageHistory)
|
|
def read_message_history(
|
|
name: str,
|
|
message: str,
|
|
controller: FlowControllerDep,
|
|
) -> Any:
|
|
"""The recent values of one message, for plotting.
|
|
|
|
``message`` may be given bare or qualified; a message that never carried a
|
|
number comes back with an empty series.
|
|
"""
|
|
_read_flow(controller, name)
|
|
key = qualify(name, message)
|
|
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
|
|
) -> 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.
|
|
|
|
``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.
|
|
"""
|
|
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.
|
|
return {
|
|
"type": "snapshot",
|
|
"values": {k: v for k, v in controller.values().items() if k in only},
|
|
"nodes": [],
|
|
"issues": [],
|
|
"paused": [],
|
|
"logs": [],
|
|
"emits": {},
|
|
}
|
|
return {
|
|
"type": "snapshot",
|
|
"values": controller.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),
|
|
}
|
|
|
|
|
|
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 panel is bounded by what it is
|
|
sent instead of by what it asks for.
|
|
"""
|
|
try:
|
|
panel = str(decode_token(token).get("panel") or "")
|
|
except InvalidTokenError:
|
|
return None
|
|
if not panel:
|
|
return None
|
|
store: DashboardStore | None = getattr(app.state, "dashboard_store", None)
|
|
return messages_for(panel, store) if store is not None else set()
|
|
|
|
|
|
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
|
|
|
|
|
|
@ws_router.websocket("/ws")
|
|
async def flow_events(websocket: WebSocket, token: str = "") -> None:
|
|
"""Stream values, node status and execution events as they happen.
|
|
|
|
The token goes in the query string because browsers cannot set headers on
|
|
a websocket handshake.
|
|
"""
|
|
with Session(engine) as session:
|
|
user = user_from_token(session, token)
|
|
if user is None:
|
|
await websocket.close(code=1008)
|
|
return
|
|
only = panel_scope(token, websocket.app)
|
|
|
|
await websocket.accept()
|
|
|
|
controller: FlowController | None = getattr(
|
|
websocket.app.state, "flow_controller", None
|
|
)
|
|
if controller is not None:
|
|
await websocket.send_json(snapshot_payload(controller, only))
|
|
|
|
async with event_bus.subscribe() as queue:
|
|
receiver = asyncio.create_task(websocket.receive_text())
|
|
try:
|
|
while True:
|
|
sender = asyncio.create_task(queue.get())
|
|
done, _ = await asyncio.wait(
|
|
{sender, receiver}, return_when=asyncio.FIRST_COMPLETED
|
|
)
|
|
if receiver in done:
|
|
# The client went away.
|
|
sender.cancel()
|
|
break
|
|
event = sender.result()
|
|
if only is not None and event.get("type") == "dashboard_changed":
|
|
# 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()
|
|
if controller is not None:
|
|
await websocket.send_json(snapshot_payload(controller, only))
|
|
if only is not None and not event_for_panel(event, only):
|
|
continue
|
|
await websocket.send_json(event)
|
|
except WebSocketDisconnect:
|
|
pass
|
|
finally:
|
|
receiver.cancel()
|