fix(webui): improve automation management

This commit is contained in:
chengyongru
2026-06-14 18:24:59 +08:00
parent 6cf1f8e164
commit 747f0a08c7
15 changed files with 1376 additions and 91 deletions
+85 -1
View File
@@ -23,6 +23,7 @@ from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.command.builtin import builtin_command_palette
from nanobot.cron.types import CronSchedule
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
@@ -80,6 +81,7 @@ from nanobot.webui.transcript import build_webui_thread_response
from nanobot.webui.workspaces import WebUIWorkspaceController
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
if TYPE_CHECKING:
from nanobot.bus.queue import MessageBus
@@ -529,7 +531,7 @@ class GatewayHTTPHandler:
) -> Response | None:
if got == "/api/webui/automations":
return self._handle_webui_automations(request)
m = re.match(r"^/api/webui/automations/(enable|disable|delete|run)$", got)
m = re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", got)
if m:
return await self._handle_webui_automation_action(request, m.group(1))
return None
@@ -594,6 +596,21 @@ class GatewayHTTPHandler:
return _http_error(409, "automation is disabled")
task = asyncio.create_task(self.cron_service.run_job(job_id, force=False))
task.add_done_callback(self._log_automation_run_result)
elif action == "update":
values = _automation_values_from_request(request)
if values is None:
return _http_error(400, "invalid automation update payload")
parsed = _parse_automation_update(values)
if isinstance(parsed, str):
return _http_error(400, parsed)
try:
result = self.cron_service.update_job(job_id, **parsed)
except ValueError as exc:
return _http_error(400, str(exc))
if result == "not_found":
return _http_error(404, "automation not found")
if result == "protected":
return _http_error(403, "system automation is protected")
else:
return _http_error(404, "unknown automation action")
@@ -757,5 +774,72 @@ class GatewayHTTPHandler:
extra_headers=[("Cache-Control", cache)],
)
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
if not raw:
return {}
try:
values = json.loads(raw)
except Exception:
return None
return values if isinstance(values, dict) else None
def _parse_automation_update(values: dict[str, Any]) -> dict[str, Any] | str:
update: dict[str, Any] = {}
if "name" in values:
name = str(values.get("name") or "").strip()
if not name:
return "name cannot be empty"
update["name"] = name
if "message" in values:
message = str(values.get("message") or "").strip()
if not message:
return "message cannot be empty"
update["message"] = message
if "schedule" in values:
raw_schedule = values.get("schedule")
if not isinstance(raw_schedule, dict):
return "schedule must be an object"
parsed_schedule = _parse_automation_schedule(raw_schedule)
if isinstance(parsed_schedule, str):
return parsed_schedule
update["schedule"] = parsed_schedule
update["delete_after_run"] = parsed_schedule.kind == "at"
return update
def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str:
kind = str(values.get("kind") or "").strip()
if kind == "every":
every_ms = _positive_int(values.get("every_ms"))
if every_ms is None:
return "every schedule requires positive every_ms"
return CronSchedule(kind="every", every_ms=every_ms)
if kind == "cron":
expr = str(values.get("expr") or "").strip()
if not expr:
return "cron schedule requires expr"
tz = str(values.get("tz") or "").strip() or None
return CronSchedule(kind="cron", expr=expr, tz=tz)
if kind == "at":
at_ms = _positive_int(values.get("at_ms"))
if at_ms is None:
return "one-time schedule requires positive at_ms"
return CronSchedule(kind="at", at_ms=at_ms)
return "unknown schedule kind"
def _positive_int(value: Any) -> int | None:
if isinstance(value, bool):
return None
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _is_websocket_channel_session_key(key: str) -> bool:
return key.startswith("websocket:")