"""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 cap 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: its cap, the ceiling above it, and what is live. 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 cap. Stores it, then makes the live lane obey it. ONE field, since 2026-09-23. It used to take `slots`, `slots_cap`, `enabled` and `autoscale`; how many workers are running is now a measurement the sizing pass owns, and `enabled` is `cap > 0`. Two failure kinds, deliberately different statuses: * **400** — the value is not allowed (negative, or above what this container can hold). 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. Not an error: the sizing pass carries it within a minute, 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") if "slots_cap" not in body: return _bad("invalid_body", detail="give slots_cap") value = body["slots_cap"] # Rejected rather than coerced: `True` is an int in Python, and silently # reading it as a cap of 1 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="slots_cap must be an integer") async with get_session() as session: try: result = await set_lane(session, lane, slots_cap=value) except LaneUpdateRefused as exc: return _bad("refused", detail=str(exc)) return jsonify(result)