from __future__ import annotations from server.status import status_bp from server.status.base import ( _active_task_counts, _close_terminal_for_key, _is_host_mode_request, ) import time import re import os import json import shutil import subprocess import sys import tempfile import plistlib from pathlib import Path from flask import Blueprint, jsonify, request, send_file, session from server.auth_helpers import api_login_required, resolve_admin_policy from server.context import with_terminal, attach_user_broadcast from server.state import ( PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE_TTL_SECONDS, PROJECT_MAX_STORAGE_MB, container_manager, user_manager, ) from config import AGENT_VERSION, TERMINAL_SANDBOX_MODE from modules.host_workspace_manager import ( create_host_workspace, delete_host_workspace, load_host_workspace_catalog, rename_host_workspace, resolve_host_workspace, set_default_host_workspace, ) from utils.host_workspace_debug import write_host_workspace_debug from server.utils_common import log_conn_diag import server.state as state def _build_host_workspaces_payload(catalog: dict, current_id: str) -> list[dict]: running_by_workspace = _active_task_counts("host") workspaces = [] for item in catalog.get("workspaces") or []: ws_id = item.get("workspace_id") workspaces.append({ "workspace_id": ws_id, "label": item.get("label"), "path": item.get("path"), "is_current": ws_id == current_id, "running_task_count": int(running_by_workspace.get(ws_id, 0)), }) return workspaces @status_bp.route('/api/host/workspaces') @api_login_required def list_host_workspaces(): if not _is_host_mode_request(): return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403 catalog, current = resolve_host_workspace(session.get("host_workspace_id")) current_id = current.get("workspace_id") session['host_workspace_id'] = current_id session['workspace_id'] = current_id workspaces = _build_host_workspaces_payload(catalog, current_id) return jsonify({ "success": True, "data": { "source_path": catalog.get("source_path"), "default_workspace_id": catalog.get("default_workspace_id"), "current_workspace_id": current_id, "workspaces": workspaces, } }) @status_bp.route('/api/host/workspaces/create', methods=['GET', 'POST']) @api_login_required def create_host_workspace_api(): if not _is_host_mode_request(): return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403 payload = request.get_json(silent=True) if request.method != "GET" else None workspace_path = ( request.args.get("path") or (payload or {}).get("path") or "" ).strip() label = ( request.args.get("label") or (payload or {}).get("label") or "" ).strip() set_default_raw = ( request.args.get("set_default") or (payload or {}).get("set_default") or "" ) set_default = str(set_default_raw).lower() in {"1", "true", "yes", "on"} if not workspace_path: return jsonify({"success": False, "error": "缺少 path"}), 400 try: result = create_host_workspace(workspace_path, label=label, set_default=set_default) except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 500 catalog = result.get("catalog") or {} workspaces = [] current_id = (session.get("host_workspace_id") or "").strip() for item in catalog.get("workspaces") or []: workspaces.append({ "workspace_id": item.get("workspace_id"), "label": item.get("label"), "path": item.get("path"), "is_current": item.get("workspace_id") == current_id, }) return jsonify({ "success": True, "data": { "created": bool(result.get("created")), "workspace": result.get("workspace") or {}, "source_path": catalog.get("source_path"), "default_workspace_id": catalog.get("default_workspace_id"), "current_workspace_id": current_id, "workspaces": workspaces, } }) @status_bp.route('/api/host/workspaces/delete', methods=['POST']) @api_login_required def delete_host_workspace_api(): if not _is_host_mode_request(): return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403 payload = request.get_json(silent=True) or {} workspace_id = (payload.get("workspace_id") or "").strip() if not workspace_id: return jsonify({"success": False, "error": "缺少 workspace_id"}), 400 if _active_task_counts("host").get(workspace_id): return jsonify({"success": False, "error": "该工作区有运行中的任务,暂不能删除"}), 409 try: _close_terminal_for_key(f"host::{workspace_id}") container_manager.release_container(f"host::{workspace_id}", reason="delete_host_workspace") result = delete_host_workspace(workspace_id) except ValueError as exc: return jsonify({"success": False, "error": str(exc)}), 400 except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 500 catalog = result.get("catalog") or load_host_workspace_catalog() current_id = (session.get("host_workspace_id") or "").strip() if current_id == workspace_id or not any( item.get("workspace_id") == current_id for item in (catalog.get("workspaces") or []) ): _, current = resolve_host_workspace(catalog.get("default_workspace_id")) current_id = current.get("workspace_id") or catalog.get("default_workspace_id") or "default" current_path = str(Path(current.get("path") or "").expanduser().resolve()) session["host_workspace_id"] = current_id session["workspace_id"] = current_id with state.HOST_ACTIVE_WORKSPACE_LOCK: state.HOST_ACTIVE_WORKSPACE_ID = current_id state.HOST_ACTIVE_WORKSPACE_PATH = current_path state.HOST_ACTIVE_WORKSPACE_VERSION += 1 return jsonify({ "success": True, "data": { "deleted_workspace_id": workspace_id, "source_path": catalog.get("source_path"), "default_workspace_id": catalog.get("default_workspace_id"), "current_workspace_id": current_id, "workspaces": _build_host_workspaces_payload(catalog, current_id), } }) @status_bp.route('/api/host/workspaces/rename', methods=['POST']) @api_login_required def rename_host_workspace_api(): if not _is_host_mode_request(): return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403 payload = request.get_json(silent=True) or {} workspace_id = (payload.get("workspace_id") or "").strip() label = (payload.get("label") or payload.get("name") or "").strip() if not workspace_id: return jsonify({"success": False, "error": "缺少 workspace_id"}), 400 if not label: return jsonify({"success": False, "error": "工作区名称不能为空"}), 400 try: result = rename_host_workspace(workspace_id, label) except ValueError as exc: return jsonify({"success": False, "error": str(exc)}), 400 except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 500 catalog = result.get("catalog") or load_host_workspace_catalog() current_id = (session.get("host_workspace_id") or "").strip() return jsonify({ "success": True, "data": { "workspace": result.get("workspace") or {}, "source_path": catalog.get("source_path"), "default_workspace_id": catalog.get("default_workspace_id"), "current_workspace_id": current_id, "workspaces": _build_host_workspaces_payload(catalog, current_id), } }) @status_bp.route('/api/host/workspaces/set-default', methods=['POST']) @api_login_required def set_default_host_workspace_api(): if not _is_host_mode_request(): return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403 payload = request.get_json(silent=True) or {} workspace_id = (payload.get("workspace_id") or "").strip() if not workspace_id: return jsonify({"success": False, "error": "缺少 workspace_id"}), 400 try: result = set_default_host_workspace(workspace_id) except ValueError as exc: return jsonify({"success": False, "error": str(exc)}), 400 except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 500 catalog = result.get("catalog") or load_host_workspace_catalog() current_id = (session.get("host_workspace_id") or "").strip() return jsonify({ "success": True, "data": { "default_workspace_id": catalog.get("default_workspace_id"), "current_workspace_id": current_id, "workspaces": _build_host_workspaces_payload(catalog, current_id), } }) @status_bp.route('/api/host/workspaces/select', methods=['GET', 'POST']) @api_login_required def select_host_workspace(): if not _is_host_mode_request(): return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403 payload = request.get_json(silent=True) if request.method != "GET" else None workspace_id = ( request.args.get("workspace_id") or (payload or {}).get("workspace_id") or "" ).strip() if not workspace_id: return jsonify({"success": False, "error": "缺少 workspace_id"}), 400 write_host_workspace_debug( "status.select_host_workspace.request", workspace_id=workspace_id, current_session_workspace_id=session.get("host_workspace_id"), ) catalog = load_host_workspace_catalog() current_id = (session.get("host_workspace_id") or "").strip() target = next( (item for item in (catalog.get("workspaces") or []) if item.get("workspace_id") == workspace_id), None, ) if not target: return jsonify({"success": False, "error": "workspace_id 不存在"}), 404 target_path = str(Path(target.get("path") or "").expanduser().resolve()) write_host_workspace_debug( "status.select_host_workspace.target", workspace_id=workspace_id, target_path=target_path, current_workspace_id=current_id, ) previous_workspace_id = session.get("workspace_id") with state.HOST_ACTIVE_WORKSPACE_LOCK: state.HOST_ACTIVE_WORKSPACE_ID = workspace_id state.HOST_ACTIVE_WORKSPACE_PATH = target_path state.HOST_ACTIVE_WORKSPACE_VERSION += 1 session['host_workspace_id'] = workspace_id session['workspace_id'] = workspace_id try: container_handle = state.container_manager.ensure_container( "host", target_path, container_key=f"host::{workspace_id}", preferred_mode="host", ) except RuntimeError as exc: if current_id: session['host_workspace_id'] = current_id if previous_workspace_id is not None: session['workspace_id'] = previous_workspace_id elif current_id: session['workspace_id'] = current_id write_host_workspace_debug( "status.select_host_workspace.ensure_container_failed", workspace_id=workspace_id, target_path=target_path, error=str(exc), ) return jsonify({"success": False, "error": str(exc)}), 503 host_terminal = state.user_terminals.get(f"host::{workspace_id}") if host_terminal: try: host_terminal.update_container_session(container_handle) attach_user_broadcast(host_terminal, "host") host_terminal.username = "host" host_terminal.user_role = "admin" write_host_workspace_debug( "status.select_host_workspace.updated_terminal", terminal_id=id(host_terminal), workspace_id=workspace_id, project_path=str(getattr(host_terminal, "project_path", "")), context_project_path=str(getattr(getattr(host_terminal, "context_manager", None), "project_path", "")), current_conversation_id=getattr(getattr(host_terminal, "context_manager", None), "current_conversation_id", None), ) except Exception: try: if getattr(host_terminal, "terminal_manager", None): host_terminal.terminal_manager.close_all() except Exception: pass state.user_terminals.pop(f"host::{workspace_id}", None) write_host_workspace_debug( "status.select_host_workspace.drop_terminal_after_error", workspace_id=workspace_id, ) write_host_workspace_debug( "status.select_host_workspace.success", workspace_id=workspace_id, target_path=target_path, ) return jsonify({ "success": True, "data": { "current_workspace_id": workspace_id, "project_path": target_path, "default_workspace_id": catalog.get("default_workspace_id"), "reloaded": current_id != workspace_id, } })