Writing node source, installing modules and any flow save that adds, retypes or repoints a code node (Function, Command), edits a Command node's settings or changes a node's resources.env now answer 403 for everyone but a superuser. MCP tools and the assistant reach the same routes, so they inherit the gate. Node types report runs_code. The editor shows code read-only with a note, disables a Command node's settings, leaves code nodes and reusable nodes out of the palette and paste, and the Modules page offers no install. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
114 lines
3.9 KiB
Python
114 lines
3.9 KiB
Python
"""The python packages node code may import.
|
|
|
|
A manifest in the flow store, a venv on the data volume, and one button that
|
|
brings the second in line with the first. Nothing here restarts the engine:
|
|
the worker pool retires its processes, and the next node call picks up the new
|
|
packages.
|
|
"""
|
|
|
|
import time
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.concurrency import run_in_threadpool
|
|
|
|
from fluksio.api.deps import (
|
|
CurrentUser,
|
|
FlowControllerDep,
|
|
WorkerPoolDep,
|
|
get_code_author,
|
|
get_current_user,
|
|
)
|
|
from fluksio.flow import modules
|
|
from fluksio.flow.events import event_bus
|
|
from fluksio.flow.schemas import ApplyRequest, ApplyResult, ModulesInfo
|
|
from fluksio.models import Message
|
|
|
|
router = APIRouter(
|
|
prefix="/modules", tags=["modules"], dependencies=[Depends(get_current_user)]
|
|
)
|
|
|
|
|
|
@router.get("/", response_model=ModulesInfo)
|
|
async def read_modules(controller: FlowControllerDep) -> Any:
|
|
"""What node code can import, and whether it matches the manifest."""
|
|
return await run_in_threadpool(modules.info, controller.store)
|
|
|
|
|
|
@router.post(
|
|
"/apply", response_model=ApplyResult, dependencies=[Depends(get_code_author)]
|
|
)
|
|
async def apply_modules(
|
|
body: ApplyRequest,
|
|
controller: FlowControllerDep,
|
|
pool: WorkerPoolDep,
|
|
user: CurrentUser,
|
|
) -> Any:
|
|
"""Install exactly these requirements, then hand them to the workers.
|
|
|
|
A superuser's: a package runs its own code on install and on import.
|
|
|
|
A manifest that does not resolve changes nothing: the venv is left as it
|
|
was and the stored manifest is only written once the install succeeded.
|
|
|
|
Only the flows already holding a node that would not load are rebuilt,
|
|
because those are the ones an install is called to fix. A flow that this
|
|
install *breaks* — a package taken back out from under it — is still green
|
|
and fails at call time with the node author's own import error, until
|
|
something rebuilds it.
|
|
"""
|
|
ok, output = await run_in_threadpool(modules.sync, body.requirements)
|
|
if not ok:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=output or "These requirements could not be installed",
|
|
)
|
|
await run_in_threadpool(controller.store.write_requirements, body.requirements)
|
|
event_bus.publish(
|
|
{
|
|
"type": "audit",
|
|
"action": "installed modules",
|
|
"flow": "",
|
|
"user": user.email,
|
|
"ts": time.time(),
|
|
}
|
|
)
|
|
# Retire the workers first, so the rebuild compiles every node against the
|
|
# packages that were just installed — a node that could not import one is
|
|
# the reason this was called, and it stays red until it is built again.
|
|
await _retire(controller, pool)
|
|
return ApplyResult(ok=True, output=output)
|
|
|
|
|
|
@router.post("/refresh", response_model=Message)
|
|
async def refresh_modules(
|
|
controller: FlowControllerDep,
|
|
pool: WorkerPoolDep,
|
|
user: CurrentUser,
|
|
) -> Any:
|
|
"""Retire the workers without installing anything.
|
|
|
|
A node that imports the caller's own package holds it in `sys.modules` for
|
|
as long as the process lives, so editing that package changes nothing a
|
|
running worker can see — recompiling the node would not help either, since
|
|
the import returns the module already there. Retiring the processes is the
|
|
whole of it, and `fluksio sync` asks for it after every upload.
|
|
"""
|
|
event_bus.publish(
|
|
{
|
|
"type": "audit",
|
|
"action": "refreshed workers",
|
|
"flow": "",
|
|
"user": user.email,
|
|
"ts": time.time(),
|
|
}
|
|
)
|
|
await _retire(controller, pool)
|
|
return Message(message="Workers retired; the next call imports afresh")
|
|
|
|
|
|
async def _retire(controller: Any, pool: Any) -> None:
|
|
"""Send the worker processes away and rebuild whatever was broken."""
|
|
pool.respawn_all()
|
|
await controller.reload_failed_flows()
|