Remote workers: a GPU box dials in and runs the nodes bound to it

The engine runs where the automations are and the GPU is somewhere else,
usually behind a different network — so the worker connects out and the engine
answers over the socket it was given. Nothing has to expose Redis, and the
same connection works through the tunnel the hosted access will use.

What travels is the protocol the local pool already speaks, so a node cannot
tell which kind of worker it is on. A node declares device: gpu and
device_policy, the label is resolved per call (a worker attaching later needs
no rebuild), and a run whose labels nothing carries waits in the queue saying
what it waits for rather than failing — submit from the couch, the GPU box
picks it up when it is switched on.

Two things had to move with it. Compiling now happens on the machine that will
run the node: a node importing torch is correct on the GPU box and a missing
module on the engine, so checking it here failed nodes that were fine. And the
artifact endpoint accepts a worker's own credential, because storing a
checkpoint is exactly what that credential is for — and only that.

Verified against the real split: the training ran on this host (its checkpoint
names the machine and a numpy the engine does not have), streamed 40 metric
points back mid-run, and the evaluate node read the checkpoint on the engine.
Cancel kills the remote training; pulling the worker fails the run in six
seconds instead of waiting out its ten-minute timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
This commit is contained in:
2026-08-18 17:52:42 +02:00
co-authored by Claude Fable 5
parent d27704a2bc
commit eb2d098d7c
16 changed files with 1401 additions and 11 deletions
+2
View File
@@ -15,6 +15,7 @@ from app.api.routes import (
secrets, secrets,
users, users,
utils, utils,
workers,
) )
api_router = APIRouter() api_router = APIRouter()
@@ -31,6 +32,7 @@ api_router.include_router(modules.router)
api_router.include_router(observability.router) api_router.include_router(observability.router)
api_router.include_router(runs.router) api_router.include_router(runs.router)
api_router.include_router(artifacts.router) api_router.include_router(artifacts.router)
api_router.include_router(workers.router)
# Always mounted so the generated SDK stays the same shape; the endpoints # Always mounted so the generated SDK stays the same shape; the endpoints
# themselves refuse to work unless MCP is switched on. # themselves refuse to work unless MCP is switched on.
api_router.include_router(oauth.router) api_router.include_router(oauth.router)
+32 -2
View File
@@ -9,13 +9,43 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from jwt.exceptions import InvalidTokenError
from pydantic import BaseModel from pydantic import BaseModel
from sqlmodel import Session
from app.api.deps import get_current_user from app.api.deps import user_from_token
from app.core import security
from app.core.db import engine
from app.flow.artifacts import ArtifactStore from app.flow.artifacts import ArtifactStore
def artifact_caller(request: Request) -> str:
"""Who may move artifacts: a signed-in person, or an attached worker.
A worker's node stores its checkpoints through this endpoint, so its own
credential has to open it — and only it. The token is no use anywhere else
in the API, which is why this check is here rather than in the shared
dependency every other route uses.
"""
header = request.headers.get("Authorization", "")
token = header[7:] if header.lower().startswith("bearer ") else ""
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
claims = security.decode_worker_token(token)
except InvalidTokenError:
pass
else:
return f"worker:{claims.get('sub')}"
with Session(engine) as session:
user = user_from_token(session, token)
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return user.email
router = APIRouter( router = APIRouter(
prefix="/artifacts", tags=["artifacts"], dependencies=[Depends(get_current_user)] prefix="/artifacts", tags=["artifacts"], dependencies=[Depends(artifact_caller)]
) )
+7 -1
View File
@@ -491,8 +491,14 @@ async def save_node_source(
await run_in_threadpool( await run_in_threadpool(
controller.store.write_node_source, name, node_id, source.code, True 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( error = await run_in_threadpool(
controller.compile_check, name, node_id, source.code controller.compile_check, name, node_id, source.code, device
) )
return NodeStatusPublic( return NodeStatusPublic(
id=f"{name}.{node_id}", id=f"{name}.{node_id}",
+165
View File
@@ -0,0 +1,165 @@
"""Remote workers: how one attaches, and what is attached right now.
A worker dials in rather than being dialled: the GPU box and the engine are
usually on different networks, and only one of them can be reached. It presents
a token minted here, says what it can do, and then answers calls on the socket
it opened.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket
from fastapi.responses import PlainTextResponse
from jwt.exceptions import InvalidTokenError
from pydantic import BaseModel, Field
from app.api.deps import get_current_active_superuser, get_current_user
from app.core import security
from app.flow import worker_main
from app.flow.remote import PROTOCOL, RemoteWorker, RemoteWorkerHub
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/workers", tags=["workers"])
#: Long, because a worker is a machine somebody set up once and left running.
TOKEN_DAYS = 365
class WorkerInfo(BaseModel):
name: str
labels: list[str] = Field(default_factory=list)
max_parallel: int = 1
in_flight: int = 0
attached_at: float = 0.0
last_seen: float = 0.0
python: str = ""
venv_digest: str = ""
class TokenRequest(BaseModel):
name: str
class TokenIssued(BaseModel):
name: str
token: str
expires_days: int = TOKEN_DAYS
def _hub(app: Any) -> RemoteWorkerHub:
hub: RemoteWorkerHub | None = getattr(app.state, "worker_hub", None)
if hub is None:
raise HTTPException(status_code=503, detail="Remote workers are not available")
return hub
@router.get(
"", response_model=list[WorkerInfo], dependencies=[Depends(get_current_user)]
)
def read_workers(request: Request) -> Any:
"""What is attached, and how busy it is."""
return [
WorkerInfo(
name=worker.name,
labels=sorted(worker.labels),
max_parallel=worker.max_parallel,
in_flight=worker.in_flight,
attached_at=worker.attached_at,
last_seen=worker.last_seen,
python=str(worker.info.get("python") or ""),
venv_digest=str(worker.info.get("venv_digest") or ""),
)
for worker in _hub(request.app).workers()
]
@router.post(
"/tokens",
response_model=TokenIssued,
dependencies=[Depends(get_current_active_superuser)],
)
def issue_token(body: TokenRequest) -> Any:
"""Mint the credential a worker presents when it dials in.
Shown once. It is signed with the same keypair the agent tokens use, so
rotating that key revokes every worker along with them.
"""
token = security.create_worker_token(body.name, timedelta(days=TOKEN_DAYS))
return TokenIssued(name=body.name, token=token)
@router.get(
"/runtime",
response_class=PlainTextResponse,
dependencies=[Depends(get_current_user)],
)
def read_runtime() -> str:
"""The worker's own code, so a fresh host installs by fetching one file.
It is the same module the engine's local workers run — deliberately
standard library only, and with nothing of the app importable in it.
"""
return worker_main.__file__ and open(worker_main.__file__).read()
@router.websocket("/attach")
async def attach(websocket: WebSocket, token: str = "") -> None:
"""A worker's connection, for as long as it holds.
The token goes in the query string for the same reason the dashboard's
does: a websocket handshake carries no headers of its own.
"""
try:
claims = security.decode_worker_token(token)
except InvalidTokenError:
await websocket.close(code=1008)
return
await websocket.accept()
try:
hello = await asyncio.wait_for(websocket.receive_json(), timeout=30)
except (TimeoutError, asyncio.TimeoutError, ValueError):
await websocket.close(code=1002)
return
if hello.get("op") != "hello" or int(hello.get("protocol", 0)) != PROTOCOL:
await websocket.send_json(
{"op": "refused", "reason": f"this engine speaks protocol {PROTOCOL}"}
)
await websocket.close(code=1002)
return
# The token names the worker; what it calls itself is a suggestion, so two
# hosts cannot fight over one identity by claiming the same name.
name = str(claims.get("sub") or hello.get("name") or "worker")
hub = _hub(websocket.app)
worker = RemoteWorker(
name=name,
labels=[str(label) for label in (hello.get("labels") or [])],
send=websocket.send_json,
loop=asyncio.get_running_loop(),
max_parallel=max(1, int(hello.get("max_parallel") or 1)),
info={
"python": hello.get("python"),
"venv_digest": hello.get("venv_digest"),
},
)
hub.attach(worker)
await websocket.send_json({"op": "welcome", "protocol": PROTOCOL, "name": name})
try:
while True:
message = await websocket.receive_json()
worker.deliver(message)
except Exception:
# Any way this ends is the same thing: the socket is gone, and whatever
# was waiting on it has to be told rather than left hanging.
logger.info("Worker '%s' disconnected", name)
finally:
hub.detach(name)
+45
View File
@@ -29,6 +29,10 @@ ALGORITHM = "HS256"
OAUTH_ALGORITHM = "RS256" OAUTH_ALGORITHM = "RS256"
#: The one scope an MCP token carries. #: The one scope an MCP token carries.
MCP_SCOPE = "mcp" MCP_SCOPE = "mcp"
#: What a remote worker's credential says it is for. Its own audience, so an
#: agent's token cannot attach a worker and a worker's cannot call the API.
WORKER_SCOPE = "worker"
WORKER_AUDIENCE = "fluksio-worker"
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
@@ -124,6 +128,47 @@ def create_oauth_access_token(
) )
def create_worker_token(name: str, expires_delta: timedelta) -> str:
"""A credential a remote worker presents when it dials in.
Signed with the same keypair the agent tokens use, so the whole set can be
revoked by rotating one key, and told apart from them by its audience: a
worker's token grants no API access, and an agent's opens no worker
connection.
"""
now = datetime.now(timezone.utc)
payload = {
"sub": name,
"iss": settings.oauth_issuer,
"aud": WORKER_AUDIENCE,
"iat": now,
"exp": now + expires_delta,
"scope": WORKER_SCOPE,
}
return jwt.encode(
payload,
oauth_key().private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
),
algorithm=OAUTH_ALGORITHM,
headers={"kid": "fluksio-oauth"},
)
def decode_worker_token(token: str) -> dict[str, Any]:
"""Validate a worker credential. Raises ``InvalidTokenError`` if it does not."""
payload: dict[str, Any] = jwt.decode(
token,
oauth_key().public_key(),
algorithms=[OAUTH_ALGORITHM],
audience=WORKER_AUDIENCE,
issuer=settings.oauth_issuer,
)
return payload
def decode_oauth_token(token: str) -> dict[str, Any]: def decode_oauth_token(token: str) -> dict[str, Any]:
"""Validate an MCP token. Raises ``InvalidTokenError`` if it does not hold.""" """Validate an MCP token. Raises ``InvalidTokenError`` if it does not hold."""
payload: dict[str, Any] = jwt.decode( payload: dict[str, Any] = jwt.decode(
+49 -3
View File
@@ -42,6 +42,7 @@ from app.flow.nodes import (
TriggerNode, TriggerNode,
) )
from app.flow.pipeline import NodeOutcome, Pipeline, ValidationIssue, ValueSource from app.flow.pipeline import NodeOutcome, Pipeline, ValidationIssue, ValueSource
from app.flow.remote import RemoteWorkerHub
from app.flow.schemas import ( from app.flow.schemas import (
BrainEdge, BrainEdge,
BrainGraph, BrainGraph,
@@ -257,11 +258,14 @@ class FlowController:
execution: ExecutionService | None = None, execution: ExecutionService | None = None,
alerts: AlertManager | None = None, alerts: AlertManager | None = None,
workers: PythonWorkerPool | None = None, workers: PythonWorkerPool | None = None,
remote: RemoteWorkerHub | None = None,
) -> None: ) -> None:
self.store = store self.store = store
# Without a pool, python nodes are compiled and run in this process — # Without a pool, python nodes are compiled and run in this process —
# which is what the tests do, and what a bare `Pipeline` has always done. # which is what the tests do, and what a bare `Pipeline` has always done.
self.workers = workers self.workers = workers
# Workers on other hosts. A node without a device never touches it.
self.remote = remote
self.state = state if state is not None else MemoryState() self.state = state if state is not None else MemoryState()
self.events = events self.events = events
self.max_workers = max_workers self.max_workers = max_workers
@@ -469,20 +473,53 @@ class FlowController:
else: else:
# The code never runs here: it is loaded in a worker, and # The code never runs here: it is loaded in a worker, and
# the node calls that worker instead of a local function. # the node calls that worker instead of a local function.
# A node bound to a device is loaded on *that* machine —
# one importing torch is correct on the GPU box and a
# missing module here, so checking it here would fail a
# node that is fine.
remote_only = (
node_def.device
and node_def.device_policy == "require"
and self.remote is not None
)
if remote_only and self.remote is not None:
problem = self.remote.compile(
node_def.device or "", owner, local, code
)
else:
problem = self.workers.compile(owner, local, code) problem = self.workers.compile(owner, local, code)
if problem: if problem:
entry.status = NodeStatus.ERROR entry.status = NodeStatus.ERROR
entry.error = problem entry.error = problem
return entry return entry
timeout = node_def.timeout or settings.FLOW_NODE_TIMEOUT
function = self.workers.proxy( function = self.workers.proxy(
owner, owner,
local, local,
code, code,
node_id=node_id, node_id=node_id,
timeout=node_def.timeout or settings.FLOW_NODE_TIMEOUT, timeout=timeout,
run_id=run.run_id if run else "", run_id=run.run_id if run else "",
on_event=run.on_event if run else None, on_event=run.on_event if run else None,
) )
if node_def.device and self.remote is not None:
# A node with a device runs on a worker carrying that
# label. Which worker is decided per call, so one that
# attaches after this flow was built is used without
# anything being rebuilt.
function = self.remote.proxy(
node_def.device,
owner,
local,
code,
node_id=node_id,
timeout=timeout,
run_id=run.run_id if run else "",
on_event=run.on_event if run else None,
fallback=(
function if node_def.device_policy == "prefer" else None
),
)
node = Node( node = Node(
f=function, f=function,
requires=_bound(node_def.requires), requires=_bound(node_def.requires),
@@ -587,8 +624,17 @@ class FlowController:
issues=[issue for issue in issues if not issue.flow or issue.flow == name], issues=[issue for issue in issues if not issue.flow or issue.flow == name],
) )
def compile_check(self, flow: str, node_id: str, code: str) -> str | None: def compile_check(
"""Does this source load? Returns what to show the author, or None.""" self, flow: str, node_id: str, code: str, device: str | None = None
) -> str | None:
"""Does this source load? Returns what to show the author, or None.
Loaded on the machine that will run it: a node bound to a device is
checked against that worker's packages, because a missing import here
says nothing about whether it is missing there.
"""
if device and self.remote is not None:
return self.remote.compile(device, flow, node_id, code)
if self.workers is not None: if self.workers is not None:
return self.workers.compile(flow, node_id, code) return self.workers.compile(flow, node_id, code)
try: try:
+356
View File
@@ -0,0 +1,356 @@
"""Workers on other hosts, reached over a socket they opened themselves.
The engine runs where the automations are; a GPU sits somewhere else. Those
two are usually not on the same network, and the one that can be dialled is
the engine — so a worker connects *out* to it and the engine answers over the
connection it was given. That also means nothing has to expose Redis, which is
the thing a remote worker must never be handed.
What travels is the protocol the local worker pool already speaks: one JSON
object per line becomes one JSON frame, the node's source rides along with
every call so no code has to be distributed, and the reports a node makes
while it runs arrive the same way they do over a pipe. A node cannot tell
which kind of worker it is running on, which is the point — the same flow runs
in both places.
The awkward part is that the socket lives on the event loop and a node
executes on a worker thread. A call therefore hands its frame to the loop and
blocks on a queue of its own until the loop puts the answer there.
"""
from __future__ import annotations
import asyncio
import logging
import queue
import sys
import threading
import time
from collections.abc import Callable
from typing import Any
from app.flow.workers import NodeTimeout, RemoteError, _remote_class
logger = logging.getLogger(__name__)
#: How long the loop is given to accept a frame we are handing it.
SEND_TIMEOUT_S = 30.0
#: A worker that has said nothing for this long is treated as gone. It sends a
#: heartbeat while it is executing, so this only ever catches a dead socket.
SILENCE_S = 90.0
#: Protocol version this engine speaks. A worker announcing anything else is
#: refused rather than half-understood.
PROTOCOL = 1
class NoWorker(RemoteError):
"""Nothing is attached that carries the label this node asked for."""
class RemoteWorker:
"""One attached worker, and the calls it has in flight."""
def __init__(
self,
name: str,
labels: list[str],
send: Callable[[dict[str, Any]], Any],
loop: asyncio.AbstractEventLoop,
max_parallel: int = 1,
info: dict[str, Any] | None = None,
) -> None:
self.name = name
self.labels = set(labels)
self.info = info or {}
self.attached_at = time.time()
self.last_seen = time.time()
self._send = send
self._loop = loop
self._slots = threading.Semaphore(max_parallel)
self.max_parallel = max_parallel
self._pending: dict[str, queue.Queue[dict[str, Any] | None]] = {}
self._lock = threading.Lock()
self._gone = False
# -------------------------------------------------------------------------
# From the socket's side, on the event loop
# -------------------------------------------------------------------------
def deliver(self, message: dict[str, Any]) -> None:
"""Hand a frame to whichever call is waiting for it."""
self.last_seen = time.time()
call_id = str(message.get("call_id") or "")
with self._lock:
inbox = self._pending.get(call_id)
if inbox is not None:
inbox.put(message)
def detach(self) -> None:
"""The socket closed: wake everything still waiting on it."""
self._gone = True
with self._lock:
inboxes = list(self._pending.values())
for inbox in inboxes:
# None is "no more answers are coming", which the caller turns into
# a failed node rather than a wait that never ends.
inbox.put(None)
# -------------------------------------------------------------------------
# From a node's side, on a worker thread
# -------------------------------------------------------------------------
def request(
self,
payload: dict[str, Any],
timeout: float,
on_event: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
if self._gone:
raise RemoteError(f"worker '{self.name}' is no longer attached")
call_id = str(payload["call_id"])
inbox: queue.Queue[dict[str, Any] | None] = queue.Queue()
# Blocking here is the backpressure, exactly as taking a slot is in the
# local pool.
self._slots.acquire()
with self._lock:
self._pending[call_id] = inbox
try:
future = asyncio.run_coroutine_threadsafe(self._send(payload), self._loop)
future.result(timeout=SEND_TIMEOUT_S)
while True:
try:
# Reset per frame: the deadline measures silence, so a node
# reporting its progress is never mistaken for a hung one.
message = inbox.get(timeout=timeout)
except queue.Empty:
self.cancel(call_id)
raise NodeTimeout(
f"'{self.name}' was silent for {timeout}s"
) from None
if message is None:
raise RemoteError(f"worker '{self.name}' went away mid-call")
kind = message.get("event")
if kind == "heartbeat":
continue
if kind:
if on_event is not None:
try:
on_event(message)
except Exception:
logger.exception("Could not record a worker event")
continue
return message
except Exception as exc:
if isinstance(exc, (NodeTimeout, RemoteError)):
raise
raise RemoteError(f"worker '{self.name}': {exc}") from exc
finally:
with self._lock:
self._pending.pop(call_id, None)
self._slots.release()
@property
def gone(self) -> bool:
return self._gone
@property
def in_flight(self) -> int:
"""Calls this worker has not answered yet."""
with self._lock:
return len(self._pending)
def calls_of(self, run_id: str) -> list[str]:
with self._lock:
return [call for call in self._pending if call.startswith(f"{run_id}:")]
def cancel(self, call_id: str) -> None:
"""Ask the worker to kill what it is running for this call."""
try:
asyncio.run_coroutine_threadsafe(
self._send({"op": "cancel", "call_id": call_id}), self._loop
).result(timeout=SEND_TIMEOUT_S)
except Exception:
logger.warning("Could not cancel '%s' on '%s'", call_id, self.name)
class RemoteWorkerHub:
"""Every attached worker, and which of them a node may run on."""
def __init__(self) -> None:
self._workers: dict[str, RemoteWorker] = {}
self._lock = threading.Lock()
# -------------------------------------------------------------------------
# Attachment
# -------------------------------------------------------------------------
def attach(self, worker: RemoteWorker) -> None:
with self._lock:
existing = self._workers.get(worker.name)
if existing is not None:
# A worker that reconnects after a network drop: the old socket
# is dead whether or not it has noticed yet.
existing.detach()
self._workers[worker.name] = worker
logger.info(
"Worker '%s' attached with labels %s", worker.name, sorted(worker.labels)
)
def detach(self, name: str) -> None:
with self._lock:
worker = self._workers.pop(name, None)
if worker is not None:
worker.detach()
logger.info("Worker '%s' detached", name)
def workers(self) -> list[RemoteWorker]:
with self._lock:
return list(self._workers.values())
def labels(self) -> set[str]:
"""Every label something attached right now carries."""
with self._lock:
return {
label for worker in self._workers.values() for label in worker.labels
}
def pick(self, label: str) -> RemoteWorker | None:
"""A worker carrying this label, least busy first.
Resolved per call rather than when the flow was built, so a worker that
attaches after a run was submitted picks the work up without anything
being rebuilt.
"""
with self._lock:
candidates = [
worker
for worker in self._workers.values()
if not worker.gone and (label == worker.name or label in worker.labels)
]
if not candidates:
return None
return min(candidates, key=lambda worker: worker.in_flight)
# -------------------------------------------------------------------------
# Running a node on one
# -------------------------------------------------------------------------
def run(
self,
label: str,
flow: str,
node: str,
source: str,
kwargs: dict[str, Any],
params: dict[str, Any] | None,
node_id: str,
timeout: float,
run_id: str = "",
on_event: Callable[[dict[str, Any]], None] | None = None,
) -> Any:
worker = self.pick(label)
if worker is None:
raise NoWorker(f"no worker labelled '{label}' is attached")
response = worker.request(
{
"op": "run",
"call_id": f"{run_id}:{node_id}" if run_id else node_id,
"flow": flow,
"node": node,
"source": source,
"kwargs": kwargs,
"params": params or {},
"run": {"id": run_id} if run_id else None,
"timeout": timeout,
},
timeout=timeout,
on_event=on_event,
)
logs = response.get("logs")
if logs:
sys.stdout.write(logs)
if response.get("ok"):
return response.get("result")
error = response.get("error") or {}
raise _remote_class(str(error.get("type") or "RemoteError"))(
str(error.get("message") or "the node failed"),
str(error.get("traceback") or ""),
)
def compile(
self, label: str, flow: str, node: str, source: str, timeout: float = 60.0
) -> str | None:
"""Load this source on the worker that will run it.
Which machine compiles matters: a node importing torch is fine on the
GPU box and a ``ModuleNotFoundError`` on the engine, so checking it
here would fail a node that is perfectly correct. When nothing is
attached there is nothing to check against, and ``None`` says so — the
node is not broken, it is waiting.
"""
worker = self.pick(label)
if worker is None:
return None
try:
response = worker.request(
{
"op": "compile",
"call_id": f"compile:{flow}.{node}",
"flow": flow,
"node": node,
"source": source,
},
timeout=timeout,
)
except RemoteError as exc:
return f"{type(exc).__name__}: {exc}"
if response.get("ok"):
return None
error = response.get("error") or {}
return str(error.get("short") or "The node could not be loaded.")
def proxy(
self,
label: str,
flow: str,
node: str,
source: str,
node_id: str,
timeout: float,
run_id: str = "",
on_event: Callable[[dict[str, Any]], None] | None = None,
fallback: Callable[..., Any] | None = None,
) -> Callable[..., Any]:
"""What a node with a device runs instead of its own function.
``fallback`` is the local pool's proxy, used when the node only prefers
the label rather than requiring it.
"""
def call(params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
if fallback is not None and self.pick(label) is None:
return fallback(params=params, **kwargs)
return self.run(
label,
flow,
node,
source,
kwargs,
params,
node_id,
timeout,
run_id=run_id,
on_event=on_event,
)
return call
def cancel_run(self, run_id: str) -> int:
"""Kill whatever this run has executing on any attached worker."""
stopped = 0
for worker in self.workers():
for call in worker.calls_of(run_id):
worker.cancel(call)
stopped += 1
return stopped
+52 -2
View File
@@ -75,6 +75,8 @@ METRIC_BATCH = 500
METRIC_FLUSH_S = 2.0 METRIC_FLUSH_S = 2.0
#: Progress is for whoever is watching, so it is throttled hard. #: Progress is for whoever is watching, so it is throttled hard.
PROGRESS_INTERVAL_S = 1.0 PROGRESS_INTERVAL_S = 1.0
#: How often a run waiting for a worker looks again.
WAIT_RETRY_S = 15.0
#: Where a run's state lives, so it can never collide with the engine's own. #: Where a run's state lives, so it can never collide with the engine's own.
RUN_NAMESPACE = "run" RUN_NAMESPACE = "run"
@@ -392,6 +394,8 @@ class RunService:
# Keyed by run, so a sweep cancelling one config leaves the others # Keyed by run, so a sweep cancelling one config leaves the others
# training. # training.
workers.cancel_run(run_id) workers.cancel_run(run_id)
if self.controller.remote is not None:
self.controller.remote.cancel_run(run_id)
return True return True
def _flow_of(self, run_id: str) -> str | None: def _flow_of(self, run_id: str) -> str | None:
@@ -421,6 +425,9 @@ class RunService:
failures = 0 failures = 0
while not self._stop.is_set(): while not self._stop.is_set():
try: try:
# Runs put back to wait for a worker come due here. The claim
# below blocks for a second, so this is about once a second.
self.queue.move_due(time.time())
items = self.queue.claim(CLAIM_COUNT, CLAIM_BLOCK_MS) items = self.queue.claim(CLAIM_COUNT, CLAIM_BLOCK_MS)
failures = 0 failures = 0
except Exception as exc: except Exception as exc:
@@ -429,16 +436,59 @@ class RunService:
self._stop.wait(min(30.0, 2.0**failures)) self._stop.wait(min(30.0, 2.0**failures))
continue continue
for item in items: for item in items:
if not item.run_id:
self.queue.ack(item)
continue
missing = self._missing_labels(item.run_id)
if missing:
# Left in the queue rather than failed: submitting a run
# before turning the GPU box on is a normal way to work, and
# the run says what it is waiting for while it waits.
self._waiting(item.run_id, missing)
self._defer(item)
continue
# Acknowledged before it runs: from here on the row is the # Acknowledged before it runs: from here on the row is the
# record, and a lease that stops moving is what says otherwise. # record, and a lease that stops moving is what says otherwise.
self.queue.ack(item) self.queue.ack(item)
if not item.run_id:
continue
try: try:
self._pool.submit(self._drive, item.run_id) self._pool.submit(self._drive, item.run_id)
except RuntimeError: except RuntimeError:
logger.warning("Run %s not started: shutting down", item.run_id) logger.warning("Run %s not started: shutting down", item.run_id)
def _missing_labels(self, run_id: str) -> list[str]:
"""Worker labels this run needs that nothing attached carries."""
with Session(db_engine) as session:
run = session.get(Run, run_id)
needed = list(run.labels) if run else []
if not needed:
return []
hub = self.controller.remote
available = hub.labels() | {w.name for w in hub.workers()} if hub else set()
# A node that only prefers its label runs locally instead, so it is not
# a reason to hold the run back; that is decided per node at call time.
return sorted(set(needed) - available)
def _waiting(self, run_id: str, missing: list[str]) -> None:
reason = f"Waiting for a worker labelled {', '.join(missing)}"
try:
with Session(db_engine) as session:
session.exec(
update(Run)
.where(col(Run.id) == run_id, col(Run.status) == "queued")
.values(status_reason=reason)
)
session.commit()
except Exception:
logger.exception("Could not record what run %s is waiting for", run_id)
def _defer(self, item: WorkItem) -> None:
"""Put an item back for later, and let go of this delivery."""
try:
self.queue.add_delayed(item, time.time() + WAIT_RETRY_S)
self.queue.ack(item)
except Exception:
logger.exception("Could not defer run %s", item.run_id)
def _keep_leases(self) -> None: def _keep_leases(self) -> None:
"""Say the local runs are alive, and clean up after engines that died.""" """Say the local runs are alive, and clean up after engines that died."""
last_sweep = 0.0 last_sweep = 0.0
+4
View File
@@ -26,6 +26,7 @@ from app.flow.nodes.http import close_shared_client
from app.flow.pipeline import ValueSource from app.flow.pipeline import ValueSource
from app.flow.plugins import load_plugins from app.flow.plugins import load_plugins
from app.flow.queue import MemoryWorkQueue, RedisWorkQueue, WorkQueue from app.flow.queue import MemoryWorkQueue, RedisWorkQueue, WorkQueue
from app.flow.remote import RemoteWorkerHub
from app.flow.runs import RUN_STATE_TTL, RunService from app.flow.runs import RUN_STATE_TTL, RunService
from app.flow.secrets import init_secrets from app.flow.secrets import init_secrets
from app.flow.state import MemoryState, RedisState, StateBackend from app.flow.state import MemoryState, RedisState, StateBackend
@@ -116,6 +117,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
) )
pool.start() pool.start()
app.state.worker_pool = pool app.state.worker_pool = pool
worker_hub = RemoteWorkerHub()
app.state.worker_hub = worker_hub
controller = FlowController( controller = FlowController(
store=store, store=store,
state=_state_backend(), state=_state_backend(),
@@ -125,6 +128,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
execution=execution, execution=execution,
alerts=alerts, alerts=alerts,
workers=pool, workers=pool,
remote=worker_hub,
) )
app.state.flow_controller = controller app.state.flow_controller = controller
# A "dashboard" alert channel puts its alert into the graph. Bound here # A "dashboard" alert channel puts its alert into the graph. Bound here
View File
+291
View File
@@ -0,0 +1,291 @@
"""The agent that runs Fluksio nodes on a machine the engine cannot reach.
Copy this file and ``worker_main.py`` onto the box with the GPU, point it at
the engine, and it dials in::
pip install websockets
python fluksio_worker.py --url wss://api.example.com/api/v1/workers/attach \\
--token "$FLUKSIO_WORKER_TOKEN" --labels gpu --python /opt/venv/bin/python
It connects *out*, so the engine needs no route back and nothing has to expose
Redis. What it then does is what the engine's own worker pool does: hold a few
subprocesses running ``worker_main.py``, hand each call to one, and pass back
everything that comes out — including the metrics a training loop reports
while it is still running.
Deliberately one file with one dependency. Nothing of the app is imported
here; a worker host installs Python, ``websockets``, and whatever the nodes
themselves need.
"""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import json
import logging
import os
import signal
import subprocess
import sys
from pathlib import Path
from typing import Any
try:
import websockets
except ImportError: # pragma: no cover - the one dependency, named plainly
print(
"This needs the 'websockets' package: pip install websockets", file=sys.stderr
)
raise SystemExit(1) from None
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fluksio-worker")
PROTOCOL = 1
#: Sent while a call is running, so the engine can tell working from wedged.
HEARTBEAT_S = 10.0
#: Reconnection backs off to this and no further.
MAX_BACKOFF_S = 30.0
WORKER_MAIN = Path(__file__).with_name("worker_main.py")
class Subprocess:
"""One user-code process and the framing of one call over its pipes."""
def __init__(self, python: str, env: dict[str, str]) -> None:
self.proc = subprocess.Popen(
[python, str(WORKER_MAIN)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
close_fds=True,
env=env,
)
self._buffer = bytearray()
def alive(self) -> bool:
return self.proc.poll() is None
def send(self, request: dict[str, Any]) -> None:
assert self.proc.stdin is not None
self.proc.stdin.write((json.dumps(request) + "\n").encode())
self.proc.stdin.flush()
def read_line(self) -> str:
"""One line, blocking. Empty when the process is gone.
Read at the file-descriptor level so a report arriving mid-call is
passed on the moment it is written rather than when a buffer fills.
"""
assert self.proc.stdout is not None
fd = self.proc.stdout.fileno()
while True:
end = self._buffer.find(b"\n")
if end >= 0:
line = bytes(self._buffer[: end + 1])
del self._buffer[: end + 1]
return line.decode(errors="replace")
try:
chunk = os.read(fd, 65536)
except OSError:
chunk = b""
if not chunk:
self._buffer.clear()
return ""
self._buffer += chunk
def kill(self) -> None:
with contextlib.suppress(OSError):
self.proc.send_signal(signal.SIGKILL)
with contextlib.suppress(subprocess.TimeoutExpired):
self.proc.wait(timeout=5)
class Agent:
"""Holds the connection, and one subprocess per call in flight."""
def __init__(self, args: argparse.Namespace) -> None:
self.args = args
self.env = dict(os.environ)
self.env["FLUKSIO_ARTIFACT_URL"] = args.artifact_url or _artifacts_from(
args.url
)
self.env["FLUKSIO_ARTIFACT_TOKEN"] = args.token
self.running: dict[str, Subprocess] = {}
async def serve_forever(self) -> None:
backoff = 1.0
while True:
try:
await self._session()
backoff = 1.0
except Exception as exc:
log.warning("disconnected: %s — retrying in %.0fs", exc, backoff)
await asyncio.sleep(backoff)
backoff = min(MAX_BACKOFF_S, backoff * 2)
async def _session(self) -> None:
url = f"{self.args.url}?token={self.args.token}"
async with websockets.connect(url, max_size=None, ping_interval=20) as socket:
await socket.send(
json.dumps(
{
"op": "hello",
"protocol": PROTOCOL,
"name": self.args.name,
"labels": self.args.labels,
"python": self.args.python,
"max_parallel": self.args.parallel,
"venv_digest": _venv_digest(self.args.python),
}
)
)
welcome = json.loads(await socket.recv())
if welcome.get("op") != "welcome":
raise RuntimeError(str(welcome.get("reason") or "refused"))
log.info(
"attached to %s as '%s' with labels %s",
self.args.url,
welcome.get("name"),
self.args.labels,
)
async for raw in socket:
message = json.loads(raw)
op = message.get("op")
if op in ("run", "compile"):
# Compiling is loading the source, which is the same trip
# through a subprocess a call is — and has to happen here
# rather than on the engine, because "does this import"
# is a question about *this* machine's packages.
log.info("%s %s", op, message.get("call_id"))
task = asyncio.create_task(self._run(socket, message))
# Without this a failure in the task is only noticed when
# it is garbage collected, which reads as a call that
# vanished.
task.add_done_callback(_report_failure)
elif op == "cancel":
self._cancel(str(message.get("call_id") or ""))
async def _run(self, socket: Any, request: dict[str, Any]) -> None:
"""Execute one call in a subprocess, streaming what it says back."""
call_id = str(request.get("call_id") or "")
loop = asyncio.get_running_loop()
worker = Subprocess(self.args.python, self.env)
self.running[call_id] = worker
async def beat() -> None:
while True:
await asyncio.sleep(HEARTBEAT_S)
with contextlib.suppress(Exception):
await socket.send(
json.dumps({"call_id": call_id, "event": "heartbeat"})
)
heartbeat = asyncio.create_task(beat())
try:
await loop.run_in_executor(None, worker.send, request)
while True:
line = await loop.run_in_executor(None, worker.read_line)
if not line:
await socket.send(
json.dumps(
{
"call_id": call_id,
"ok": False,
"error": {
"type": "NodeCancelled"
if worker.proc.returncode
else "RemoteError",
"message": "the node process stopped",
"short": "the node process stopped",
"traceback": "",
},
}
)
)
return
await socket.send(line.strip())
# Anything without an `event` is the answer; the call is over.
if not json.loads(line).get("event"):
return
finally:
heartbeat.cancel()
self.running.pop(call_id, None)
worker.kill()
def _cancel(self, call_id: str) -> None:
worker = self.running.get(call_id)
if worker is not None:
log.info("cancelling %s", call_id)
worker.kill()
def _report_failure(task: asyncio.Task[Any]) -> None:
if not task.cancelled() and task.exception() is not None:
log.exception("call failed", exc_info=task.exception())
def _artifacts_from(url: str) -> str:
"""The artifact endpoint beside the socket, so one URL configures both."""
base = url.replace("wss://", "https://").replace("ws://", "http://")
return base.rsplit("/workers/attach", 1)[0] + "/artifacts"
def _venv_digest(python: str) -> str:
"""What is installed here, so the engine can say when it has drifted."""
try:
listing = subprocess.run(
[python, "-m", "pip", "freeze"],
capture_output=True,
text=True,
timeout=60,
check=False,
).stdout
except (OSError, subprocess.SubprocessError):
return ""
import hashlib
return hashlib.sha256(listing.encode()).hexdigest()[:16]
def main() -> int:
parser = argparse.ArgumentParser(description="Run Fluksio nodes on this machine.")
parser.add_argument("--url", required=True, help="wss://…/api/v1/workers/attach")
parser.add_argument(
"--token",
default=os.environ.get("FLUKSIO_WORKER_TOKEN", ""),
help="issued by POST /api/v1/workers/tokens",
)
parser.add_argument("--name", default=os.uname().nodename)
parser.add_argument(
"--labels",
default="",
help="comma-separated, e.g. gpu,cuda12 — what a node's device matches",
)
parser.add_argument(
"--python",
default=sys.executable,
help="the interpreter node code runs on; point it at the venv with torch",
)
parser.add_argument("--parallel", type=int, default=1)
parser.add_argument("--artifact-url", default="")
args = parser.parse_args()
if not args.token:
parser.error("a token is required (--token or FLUKSIO_WORKER_TOKEN)")
args.labels = [part.strip() for part in args.labels.split(",") if part.strip()]
if not WORKER_MAIN.exists():
parser.error(f"{WORKER_MAIN} is missing — copy it beside this file")
agent = Agent(args)
try:
asyncio.run(agent.serve_forever())
except KeyboardInterrupt:
log.info("stopping")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+3
View File
@@ -92,6 +92,9 @@ ignore = [
# This one takes its own directory off sys.path before the rest of its imports # This one takes its own directory off sys.path before the rest of its imports
# run, which is the whole point of doing it there. # run, which is the whole point of doing it there.
"app/flow/worker_main.py" = ["E402"] "app/flow/worker_main.py" = ["E402"]
# A standalone script copied onto another machine: it has no logger configured
# before it tells you the one dependency it is missing.
"app/worker/fluksio_worker.py" = ["T201"]
[tool.ruff.lint.pyupgrade] [tool.ruff.lint.pyupgrade]
# Preserve types, even if a file imports `from __future__ import annotations`. # Preserve types, even if a file imports `from __future__ import annotations`.
+225
View File
@@ -0,0 +1,225 @@
"""A worker on another host, and the thread-to-loop bridge that reaches it.
The socket lives on an event loop; a node executes on a worker thread. These
run a real loop in a thread of its own, because that split is the whole
difficulty: what is checked is that a call handed across it comes back — with
its answer, with the reports it made on the way, or with a failure that says
what happened rather than a wait that never ends.
"""
import asyncio
import threading
from collections.abc import Iterator
import pytest
from app.flow.remote import NoWorker, RemoteWorker, RemoteWorkerHub
from app.flow.workers import NodeTimeout, RemoteError
@pytest.fixture
def loop() -> Iterator[asyncio.AbstractEventLoop]:
"""An event loop running in a thread, as the server's does."""
running = asyncio.new_event_loop()
thread = threading.Thread(target=running.run_forever, daemon=True)
thread.start()
yield running
running.call_soon_threadsafe(running.stop)
thread.join(timeout=5)
running.close()
class FakeSocket:
"""Stands in for the websocket: records frames, and says when one lands."""
def __init__(self) -> None:
self.sent: list[dict] = []
self.arrived = threading.Event()
async def send_json(self, payload: dict) -> None:
self.sent.append(payload)
self.arrived.set()
def attach(hub: RemoteWorkerHub, loop: asyncio.AbstractEventLoop, name: str = "gpu1"):
socket = FakeSocket()
worker = RemoteWorker(
name=name, labels=["gpu"], send=socket.send_json, loop=loop, max_parallel=2
)
hub.attach(worker)
return worker, socket
def call_in_thread(target) -> threading.Thread:
thread = threading.Thread(target=target, daemon=True)
thread.start()
return thread
def test_a_call_crosses_to_the_thread_and_the_answer_comes_back(loop):
hub = RemoteWorkerHub()
worker, socket = attach(hub, loop)
result: dict = {}
thread = call_in_thread(
lambda: result.update(
value=hub.run(
"gpu", "flow", "node", "src", {"x": 1}, {}, "flow.node", timeout=5
)
)
)
assert socket.arrived.wait(5)
assert socket.sent[0]["source"] == "src"
assert socket.sent[0]["kwargs"] == {"x": 1}
worker.deliver(
{"call_id": socket.sent[0]["call_id"], "ok": True, "result": {"out": 2}}
)
thread.join(timeout=5)
assert result["value"] == {"out": 2}
def test_reports_arrive_before_the_answer_and_a_heartbeat_is_not_one(loop):
hub = RemoteWorkerHub()
worker, socket = attach(hub, loop)
seen: list[dict] = []
result: dict = {}
thread = call_in_thread(
lambda: result.update(
value=hub.run(
"gpu",
"flow",
"node",
"src",
{},
{},
"flow.node",
timeout=5,
run_id="r1",
on_event=seen.append,
)
)
)
assert socket.arrived.wait(5)
call_id = socket.sent[0]["call_id"]
# The call names its run, which is how a metric finds the run that made it.
assert call_id == "r1:flow.node"
worker.deliver(
{"call_id": call_id, "event": "metric", "name": "loss", "value": 1.0}
)
worker.deliver({"call_id": call_id, "event": "heartbeat"})
worker.deliver({"call_id": call_id, "ok": True, "result": {"done": True}})
thread.join(timeout=5)
assert result["value"] == {"done": True}
# Liveness is not a measurement; only the metric is kept.
assert [event["event"] for event in seen] == ["metric"]
def test_a_failure_keeps_its_class_across_the_socket(loop):
hub = RemoteWorkerHub()
worker, socket = attach(hub, loop)
caught: list[Exception] = []
def call() -> None:
try:
hub.run("gpu", "flow", "node", "src", {}, {}, "flow.node", timeout=5)
except Exception as exc:
caught.append(exc)
thread = call_in_thread(call)
assert socket.arrived.wait(5)
worker.deliver(
{
"call_id": socket.sent[0]["call_id"],
"ok": False,
"error": {"type": "ValueError", "message": "bad input", "traceback": "tb"},
}
)
thread.join(timeout=5)
assert type(caught[0]).__name__ == "ValueError"
assert str(caught[0]) == "bad input"
def test_a_worker_that_goes_away_fails_the_call_rather_than_hanging(loop):
hub = RemoteWorkerHub()
worker, socket = attach(hub, loop)
caught: list[Exception] = []
def call() -> None:
try:
hub.run("gpu", "flow", "node", "src", {}, {}, "flow.node", timeout=30)
except Exception as exc:
caught.append(exc)
thread = call_in_thread(call)
assert socket.arrived.wait(5)
# Pulling the cable mid-training: the node fails, and does not wait out its
# thirty-second deadline to do it.
worker.detach()
thread.join(timeout=5)
assert not thread.is_alive()
assert isinstance(caught[0], RemoteError)
assert "went away" in str(caught[0])
def test_silence_past_the_deadline_is_a_timeout(loop):
hub = RemoteWorkerHub()
attach(hub, loop)
caught: list[Exception] = []
def call() -> None:
try:
hub.run("gpu", "flow", "node", "src", {}, {}, "flow.node", timeout=0.3)
except Exception as exc:
caught.append(exc)
call_in_thread(call).join(timeout=5)
assert isinstance(caught[0], NodeTimeout)
def test_a_label_nothing_carries_is_named_rather_than_waited_on(loop):
hub = RemoteWorkerHub()
attach(hub, loop)
with pytest.raises(NoWorker, match="tpu"):
hub.run("tpu", "flow", "node", "src", {}, {}, "flow.node", timeout=5)
# Compiling against a machine that is not attached is not a broken node —
# a node importing torch is correct there and missing here.
assert hub.compile("tpu", "flow", "node", "src") is None
def test_reattaching_replaces_the_old_socket(loop):
hub = RemoteWorkerHub()
first, _ = attach(hub, loop)
second, _ = attach(hub, loop)
assert first.gone
assert hub.pick("gpu") is second
assert [worker.name for worker in hub.workers()] == ["gpu1"]
def test_cancelling_a_run_reaches_only_that_run(loop):
hub = RemoteWorkerHub()
worker, socket = attach(hub, loop)
def call(run_id: str) -> None:
try:
hub.run(
"gpu", "flow", "node", "src", {}, {}, "flow.node", 30, run_id=run_id
)
except Exception:
pass
call_in_thread(lambda: call("run-a"))
assert socket.arrived.wait(5)
socket.arrived.clear()
call_in_thread(lambda: call("run-b"))
assert socket.arrived.wait(5)
assert hub.cancel_run("run-a") == 1
cancels = [frame for frame in socket.sent if frame.get("op") == "cancel"]
assert [frame["call_id"] for frame in cancels] == ["run-a:flow.node"]
+82
View File
@@ -2458,6 +2458,39 @@ export const TokenSchema = {
title: 'Token' title: 'Token'
} as const; } as const;
export const TokenIssuedSchema = {
properties: {
name: {
type: 'string',
title: 'Name'
},
token: {
type: 'string',
title: 'Token'
},
expires_days: {
type: 'integer',
title: 'Expires Days',
default: 365
}
},
type: 'object',
required: ['name', 'token'],
title: 'TokenIssued'
} as const;
export const TokenRequestSchema = {
properties: {
name: {
type: 'string',
title: 'Name'
}
},
type: 'object',
required: ['name'],
title: 'TokenRequest'
} as const;
export const TriggerRequestSchema = { export const TriggerRequestSchema = {
properties: { properties: {
values: { values: {
@@ -2879,6 +2912,55 @@ refresh_s, range_s}\`\`: it publishes \`\`{range_s, interval_s}\`\` to
a flow answers with on \`\`message\`\`.` a flow answers with on \`\`message\`\`.`
} as const; } as const;
export const WorkerInfoSchema = {
properties: {
name: {
type: 'string',
title: 'Name'
},
labels: {
items: {
type: 'string'
},
type: 'array',
title: 'Labels'
},
max_parallel: {
type: 'integer',
title: 'Max Parallel',
default: 1
},
in_flight: {
type: 'integer',
title: 'In Flight',
default: 0
},
attached_at: {
type: 'number',
title: 'Attached At',
default: 0
},
last_seen: {
type: 'number',
title: 'Last Seen',
default: 0
},
python: {
type: 'string',
title: 'Python',
default: ''
},
venv_digest: {
type: 'string',
title: 'Venv Digest',
default: ''
}
},
type: 'object',
required: ['name'],
title: 'WorkerInfo'
} as const;
export const app__api__routes__dashboards__PublishRequestSchema = { export const app__api__routes__dashboards__PublishRequestSchema = {
properties: { properties: {
version: { version: {
+55 -1
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise'; import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI'; import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request'; import { request as __request } from './core/request';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen'; import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
export class AlertsService { export class AlertsService {
/** /**
@@ -1842,3 +1842,57 @@ export class UtilsService {
}); });
} }
} }
export class WorkersService {
/**
* Read Workers
* What is attached, and how busy it is.
* @returns WorkerInfo Successful Response
* @throws ApiError
*/
public static readWorkers(): CancelablePromise<WorkersReadWorkersResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/workers'
});
}
/**
* Issue Token
* Mint the credential a worker presents when it dials in.
*
* Shown once. It is signed with the same keypair the agent tokens use, so
* rotating that key revokes every worker along with them.
* @param data The data for the request.
* @param data.requestBody
* @returns TokenIssued Successful Response
* @throws ApiError
*/
public static issueToken(data: WorkersIssueTokenData): CancelablePromise<WorkersIssueTokenResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/workers/tokens',
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Read Runtime
* The worker's own code, so a fresh host installs by fetching one file.
*
* It is the same module the engine's local workers run — deliberately
* standard library only, and with nothing of the app importable in it.
* @returns string Successful Response
* @throws ApiError
*/
public static readRuntime(): CancelablePromise<WorkersReadRuntimeResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/workers/runtime'
});
}
}
+31
View File
@@ -850,6 +850,16 @@ export type Token = {
token_type?: string; token_type?: string;
}; };
export type TokenIssued = {
name: string;
token: string;
expires_days?: number;
};
export type TokenRequest = {
name: string;
};
export type TriggerRequest = { export type TriggerRequest = {
values?: { values?: {
[key: string]: unknown; [key: string]: unknown;
@@ -955,6 +965,17 @@ export type WidgetDef = {
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown'; export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
export type WorkerInfo = {
name: string;
labels?: Array<(string)>;
max_parallel?: number;
in_flight?: number;
attached_at?: number;
last_seen?: number;
python?: string;
venv_digest?: string;
};
export type AlertsReadAlertsConfigResponse = (AlertsConfig); export type AlertsReadAlertsConfigResponse = (AlertsConfig);
export type AlertsSaveAlertsConfigData = { export type AlertsSaveAlertsConfigData = {
@@ -1446,3 +1467,13 @@ export type UtilsHealthCheckResponse = (boolean);
export type UtilsHealthResponse = ({ export type UtilsHealthResponse = ({
[key: string]: unknown; [key: string]: unknown;
}); });
export type WorkersReadWorkersResponse = (Array<WorkerInfo>);
export type WorkersIssueTokenData = {
requestBody: TokenRequest;
};
export type WorkersIssueTokenResponse = (TokenIssued);
export type WorkersReadRuntimeResponse = (string);