Service node: a server kept running on a worker (openpanel V2)

- `service` node type: command, port, worker label, health path, env and
  weights (an artifact digest the worker fetches as {weights}). Its flow
  starts it; it restarts with backoff, reads degraded while starting or
  without a worker and down after three starts that never got healthy, and
  publishes http://<worker>:<port> once /health answers 200, "" when it ends.
- Worker protocol 3: a `serve` call, every call in its own process group,
  killed when the engine ends the call, the socket drops or the agent gets
  SIGTERM. `--service-host` overrides the address the engine sees.
- Services and compiles take no `--parallel` slot, and a flow's detail is
  built off the event loop, so a save no longer stalls the engine.
This commit is contained in:
Melvin Strobl
2026-10-19 18:43:04 +02:00
parent 80e0201612
commit 85dad39973
16 changed files with 757 additions and 34 deletions
+14 -9
View File
@@ -220,6 +220,11 @@ def _endpoints(controller: FlowController, definition: FlowDef) -> list[Endpoint
def _detail(controller: FlowController, definition: FlowDef) -> FlowDetail:
"""The flow and how its nodes are doing. Blocking: call it off the loop.
A draft is built to report on it, which asks a worker to compile its
nodes, and that waits on the socket the loop serves.
"""
name = definition.name
running = {
"enabled": controller.is_enabled(name),
@@ -534,7 +539,7 @@ async def save_flow(
status_code=409,
detail={"message": str(exc), "current_version": exc.current},
)
return _detail(controller, stored)
return await run_in_threadpool(_detail, controller, stored)
@router.post("/{name}/publish", response_model=FlowDetail)
@@ -561,7 +566,7 @@ async def publish_flow(
)
_audit("published", name, user)
await controller.reload_flow(name)
return _detail(controller, published)
return await run_in_threadpool(_detail, controller, published)
@router.post("/{name}/discard-draft", response_model=FlowDetail)
@@ -581,7 +586,7 @@ async def discard_draft(name: str, controller: FlowControllerDep) -> Any:
),
)
published = await run_in_threadpool(controller.store.discard_draft, name)
return _detail(controller, published)
return await run_in_threadpool(_detail, controller, published)
@router.delete("/{name}", response_model=Message)
@@ -644,7 +649,7 @@ async def rename_flow(
# run, so this is cleanup rather than a migration.
await run_in_threadpool(controller.forget_flow, name)
await controller.reload()
return _detail(controller, renamed)
return await run_in_threadpool(_detail, controller, renamed)
@router.post("/{name}/duplicate", response_model=FlowDetail)
@@ -662,7 +667,7 @@ async def duplicate_flow(
copy = await run_in_threadpool(controller.store.duplicate_flow, name)
except FlowNotFound:
raise HTTPException(status_code=404, detail=f"No flow named '{name}'")
return _detail(controller, copy)
return await run_in_threadpool(_detail, controller, copy)
# -----------------------------------------------------------------------------
@@ -810,7 +815,7 @@ async def share_node(
)
except LibExists as exc:
raise HTTPException(status_code=409, detail=str(exc))
return _detail(controller, _read_flow(controller, name))
return await run_in_threadpool(_detail, controller, _read_flow(controller, name))
@router.post("/{name}/nodes/{node_id}/unshare", response_model=FlowDetail)
@@ -825,7 +830,7 @@ async def unshare_node(
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))
return await run_in_threadpool(_detail, controller, _read_flow(controller, name))
# -----------------------------------------------------------------------------
@@ -841,7 +846,7 @@ async def start_flow(
_read_flow(controller, name)
await controller.set_enabled(name, True)
_audit("started", name, user)
return _detail(controller, _read_flow(controller, name))
return await run_in_threadpool(_detail, controller, _read_flow(controller, name))
@router.post("/{name}/stop", response_model=FlowDetail)
@@ -850,7 +855,7 @@ async def stop_flow(name: str, controller: FlowControllerDep, user: CurrentUser)
_read_flow(controller, name)
await controller.set_enabled(name, False)
_audit("stopped", name, user)
return _detail(controller, _read_flow(controller, name))
return await run_in_threadpool(_detail, controller, _read_flow(controller, name))
@router.post("/{name}/pause", response_model=Message)
+4
View File
@@ -337,6 +337,10 @@ async def _attached_session(websocket: WebSocket, claims: dict[str, Any]) -> Non
"version": hello.get("version"),
},
inventory=WorkerInventory.from_hello(hello.get("inventory")),
host=str(
hello.get("service_host")
or (websocket.client.host if websocket.client else "")
),
)
hub.attach(worker)
_behind.pop(name, None)