"""Worker lanes: what each is doing, and the dial that changes it. Milestone 422 step 2. The write half of a surface `api/system_activity.py` only reads. ## Why this is a separate blueprint `system_activity` says in its own first line that it is read-only, and it answers a different question: its `/workers` is keyed on celery HOSTNAME and reports which nodes answered. That stays as it is — the existing SystemActivityTab consumes it. This is keyed on LANE, joins the stored settings to the live pool, and accepts writes. Two endpoints answering "which celery processes exist" and "how much work is each lane allowed to do" are not the same endpoint, and folding the second into the first would make a read-only module a write one. """ from __future__ import annotations from datetime import UTC, datetime from quart import Blueprint, jsonify, request from ..extensions import get_session from ..services.worker_control import LaneUpdateRefused, lane_view, set_lane from ..services.worker_lanes import LANES_BY_NAME from ._responses import error_response as _bad workers_bp = Blueprint("workers", __name__, url_prefix="/api/system/workers") @workers_bp.route("", methods=["GET"]) async def list_lanes(): """Every lane: configured slots, the cap, the ceiling, and live state. Response: {lanes: [...], fetched_at: iso8601} Deliberately NOT cached, unlike system_activity's 2s/5s caches. This is the surface an operator watches while dragging a stepper, and a cached reply would show them the value from before their own change and read as the control having failed. """ async with get_session() as session: lanes = await lane_view(session) return jsonify({ "lanes": lanes, "fetched_at": datetime.now(UTC).isoformat(), }) @workers_bp.route("/", methods=["POST"]) async def update_lane(name: str): """Set a lane's slots, cap and/or enabled flag. Stores, then pushes live. Partial: only the keys present are changed, so the UI's stepper can send `{"slots": 3}` without restating the cap it did not touch. Two failure kinds, deliberately different statuses: * **400** — the value is not allowed (above the cap, above the ceiling, negative). Nothing was stored. The body carries `detail`, which is the sentence the UI shows; a refused control with no reason reads as a bug. * **200 with `applied: false`** — the value WAS stored but could not be pushed, because the lane is not currently answering. That is not an error: step 3's reconcile carries it when the lane comes back, and the UI should say "saved, not yet live" rather than "that didn't work". """ lane = LANES_BY_NAME.get(name) if lane is None: return _bad("unknown_lane", detail=name, known=sorted(LANES_BY_NAME)) body = await request.get_json() if not isinstance(body, dict): return _bad("invalid_body", detail="body must be a JSON object") fields: dict = {} for key in ("slots", "slots_cap"): if key in body: value = body[key] # Rejected rather than coerced: `True` is an int in Python, and # silently reading it as 1 slot would be a control that appears to # work and sets something nobody asked for. if not isinstance(value, int) or isinstance(value, bool): return _bad("invalid_body", detail=f"{key} must be an integer") fields[key] = value if "enabled" in body: if not isinstance(body["enabled"], bool): return _bad("invalid_body", detail="enabled must be a boolean") fields["enabled"] = body["enabled"] if not fields: return _bad( "invalid_body", detail="give at least one of slots, slots_cap, enabled", ) async with get_session() as session: try: result = await set_lane(session, lane, **fields) except LaneUpdateRefused as exc: return _bad("refused", detail=str(exc)) return jsonify(result)