mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-07-10 01:38:37 +00:00
456 lines
15 KiB
Python
456 lines
15 KiB
Python
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
from pydantic import BaseModel
|
|
|
|
from app.model.enums import Status
|
|
from app.router_layer.hands_resolver import get_environment_hands
|
|
from app.service.task import get_task_lock_if_exists
|
|
from app.utils.space_overlay_client import overlay_sync_failure_count
|
|
from app.utils.workspace_paths import (
|
|
get_eigent_root,
|
|
runtime_owner_key,
|
|
sanitize_identity,
|
|
)
|
|
from app.utils.workspace_resolver import (
|
|
_same_workspace_path,
|
|
get_workspace_resolver,
|
|
)
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger("workspace_controller")
|
|
|
|
|
|
class WorkspaceBindRequest(BaseModel):
|
|
space_id: str | None = None
|
|
project_id: str | None = None
|
|
email: str
|
|
user_id: str | int | None = None
|
|
path: str
|
|
|
|
|
|
class WorkspaceReconcileRequest(BaseModel):
|
|
email: str
|
|
user_id: str | int | None = None
|
|
active_space_ids: list[str]
|
|
|
|
|
|
class WorkspaceScratchRequest(BaseModel):
|
|
space_id: str
|
|
email: str
|
|
user_id: str | int | None = None
|
|
|
|
|
|
class WorkspaceProjectRefreshRequest(BaseModel):
|
|
email: str
|
|
user_id: str | int | None = None
|
|
force: bool = False
|
|
server_refresh_confirmed: bool = False
|
|
|
|
|
|
def _manifest_from_request(request: Request) -> dict[str, Any]:
|
|
hands = getattr(request.state, "hands", None) or get_environment_hands()
|
|
get_manifest = getattr(hands, "get_capability_manifest", None)
|
|
if get_manifest is None:
|
|
return {}
|
|
try:
|
|
manifest = get_manifest()
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to read hands capability manifest", exc_info=True
|
|
)
|
|
return {}
|
|
return manifest if isinstance(manifest, dict) else {}
|
|
|
|
|
|
def _binding_enabled(manifest: dict[str, Any]) -> bool:
|
|
return manifest.get("deployment") == "local"
|
|
|
|
|
|
def _project_has_active_run(project_id: str) -> bool:
|
|
task_lock = get_task_lock_if_exists(project_id)
|
|
if task_lock is None:
|
|
return False
|
|
if task_lock.status != Status.done:
|
|
return True
|
|
return any(not task.done() for task in task_lock.background_tasks)
|
|
|
|
|
|
def _capability_payload(manifest: dict[str, Any]) -> dict[str, Any]:
|
|
binding_enabled = _binding_enabled(manifest)
|
|
return {
|
|
**manifest,
|
|
"binding_enabled": binding_enabled,
|
|
"binding_owner": "space",
|
|
"label": "Local Brain" if binding_enabled else "Cloud workspace",
|
|
"binding_persistence": "brain_local" if binding_enabled else "none",
|
|
}
|
|
|
|
|
|
def _status_for_reason(reason: str | None) -> int:
|
|
if reason in {
|
|
"filesystem_capability_denied",
|
|
"home_root_forbidden",
|
|
"home_resolve_failed",
|
|
"workspace_scope_denied",
|
|
}:
|
|
return 403
|
|
if reason and reason.startswith("sensitive_path:"):
|
|
return 403
|
|
return 400
|
|
|
|
|
|
def _validate_bind_path(request: Request, path: str) -> Path:
|
|
hands = getattr(request.state, "hands", None) or get_environment_hands()
|
|
validator = getattr(hands, "validate_workspace_binding_path", None)
|
|
if validator is not None:
|
|
ok, reason = validator(path)
|
|
if not ok:
|
|
raise HTTPException(
|
|
status_code=_status_for_reason(reason),
|
|
detail={
|
|
"code": "invalid_workspace_path",
|
|
"reason": reason or "path_not_allowed",
|
|
},
|
|
)
|
|
else:
|
|
can_access = getattr(hands, "can_access_filesystem", None)
|
|
if can_access is None or not can_access(path):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail={
|
|
"code": "invalid_workspace_path",
|
|
"reason": "filesystem_capability_denied",
|
|
},
|
|
)
|
|
candidate = Path(path).expanduser()
|
|
if not candidate.exists() or not candidate.is_dir():
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"code": "invalid_workspace_path",
|
|
"reason": "path_not_directory",
|
|
},
|
|
)
|
|
try:
|
|
return Path(path).expanduser().resolve()
|
|
except (OSError, RuntimeError) as exc:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"code": "invalid_workspace_path",
|
|
"reason": "path_resolve_failed",
|
|
},
|
|
) from exc
|
|
|
|
|
|
def _effective_space_id(payload: WorkspaceBindRequest) -> str:
|
|
space_id = payload.space_id or payload.project_id
|
|
if not space_id:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail={
|
|
"code": "space_id_required",
|
|
"message": "space_id is required for workspace binding.",
|
|
},
|
|
)
|
|
if not payload.space_id and payload.project_id:
|
|
logger.warning(
|
|
"Workspace bind received project_id without space_id; treating it as a legacy Space binding key",
|
|
extra={"project_id": payload.project_id},
|
|
)
|
|
return space_id
|
|
|
|
|
|
def _scratch_space_root(
|
|
email: str, space_id: str, user_id: str | int | None = None
|
|
) -> Path:
|
|
safe_space_id = sanitize_identity(space_id).removeprefix("space_")
|
|
return (
|
|
get_eigent_root()
|
|
/ runtime_owner_key(email, user_id)
|
|
/ f"space_{safe_space_id or 'scratch'}"
|
|
)
|
|
|
|
|
|
@router.get("/workspace/capabilities")
|
|
async def workspace_capabilities(request: Request) -> dict[str, Any]:
|
|
return _capability_payload(_manifest_from_request(request))
|
|
|
|
|
|
@router.get("/workspace/diagnostics")
|
|
async def workspace_diagnostics() -> dict[str, Any]:
|
|
return {
|
|
"overlay_sync_failures": overlay_sync_failure_count(),
|
|
}
|
|
|
|
|
|
@router.get("/workspace/current")
|
|
async def workspace_current(
|
|
space_id: str = Query(..., description="Space ID"),
|
|
email: str = Query(..., description="User email"),
|
|
user_id: str | None = Query(None, description="Canonical user ID"),
|
|
) -> dict[str, Any]:
|
|
# TODO(brain-auth): Phase B should derive canonical user_id from
|
|
# request.state.brain_auth and treat this email only as a legacy/display key.
|
|
resolver = get_workspace_resolver()
|
|
binding = resolver.store.get_binding(email, space_id, user_id)
|
|
binding_active = (
|
|
binding is not None
|
|
and Path(binding.workspace_root).expanduser().is_dir()
|
|
)
|
|
return {
|
|
"space_id": space_id,
|
|
"email": email,
|
|
"user_id": user_id,
|
|
"bound": binding_active,
|
|
"workspace_root": None if binding is None else binding.workspace_root,
|
|
"binding": None if binding is None else binding.__dict__.copy(),
|
|
}
|
|
|
|
|
|
@router.post("/workspace/scratch")
|
|
async def workspace_scratch(
|
|
payload: WorkspaceScratchRequest, request: Request
|
|
) -> dict[str, Any]:
|
|
# Creates a Brain-owned local folder for a blank Space. This keeps
|
|
# frontend/backend separation intact: renderers ask Brain for a workspace
|
|
# root instead of relying on Electron-only filesystem IPC.
|
|
manifest = _manifest_from_request(request)
|
|
if not _binding_enabled(manifest):
|
|
raise HTTPException(
|
|
status_code=412,
|
|
detail={
|
|
"code": "workspace_binding_disabled",
|
|
"capabilities": _capability_payload(manifest),
|
|
},
|
|
)
|
|
|
|
resolver = get_workspace_resolver()
|
|
existing = resolver.store.get_binding(
|
|
payload.email, payload.space_id, payload.user_id
|
|
)
|
|
if existing is not None:
|
|
existing_path = Path(existing.workspace_root).expanduser()
|
|
existing_path.mkdir(parents=True, exist_ok=True)
|
|
return {
|
|
"space_id": payload.space_id,
|
|
"email": payload.email,
|
|
"user_id": payload.user_id,
|
|
"bound": existing_path.is_dir(),
|
|
"workspace_root": existing.workspace_root,
|
|
"binding": existing.__dict__.copy(),
|
|
}
|
|
|
|
root = _scratch_space_root(
|
|
payload.email, payload.space_id, payload.user_id
|
|
).resolve()
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
binding = resolver.ensure_space_binding(
|
|
payload.email,
|
|
payload.space_id,
|
|
str(root),
|
|
user_id=payload.user_id,
|
|
)
|
|
return {
|
|
"space_id": payload.space_id,
|
|
"email": payload.email,
|
|
"user_id": payload.user_id,
|
|
"bound": True,
|
|
"workspace_root": binding.workspace_root,
|
|
"binding": binding.__dict__.copy(),
|
|
}
|
|
|
|
|
|
@router.post("/workspace/bind")
|
|
async def workspace_bind(
|
|
payload: WorkspaceBindRequest, request: Request
|
|
) -> dict[str, Any]:
|
|
# TODO(brain-auth): Phase B must cross-check payload.email against
|
|
# request.state.brain_auth.user_id before writing any binding mirror.
|
|
manifest = _manifest_from_request(request)
|
|
if not _binding_enabled(manifest):
|
|
raise HTTPException(
|
|
status_code=412,
|
|
detail={
|
|
"code": "workspace_binding_disabled",
|
|
"capabilities": _capability_payload(manifest),
|
|
},
|
|
)
|
|
|
|
space_id = _effective_space_id(payload)
|
|
resolver = get_workspace_resolver()
|
|
existing = resolver.store.get_binding(
|
|
payload.email, space_id, payload.user_id
|
|
)
|
|
if existing is not None:
|
|
if _same_workspace_path(existing.workspace_root, payload.path):
|
|
return {
|
|
"space_id": space_id,
|
|
"email": payload.email,
|
|
"user_id": payload.user_id,
|
|
"bound": Path(existing.workspace_root).expanduser().is_dir(),
|
|
"workspace_root": existing.workspace_root,
|
|
"binding": existing.__dict__.copy(),
|
|
}
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"code": "workspace_already_bound",
|
|
"message": (
|
|
"This Space already has a workspace folder. "
|
|
"Create a new Space to select another folder."
|
|
),
|
|
},
|
|
)
|
|
|
|
resolved = _validate_bind_path(request, payload.path)
|
|
|
|
conflict = next(
|
|
(
|
|
b
|
|
for b in resolver.store.list_bindings(
|
|
payload.email, payload.user_id
|
|
)
|
|
if b.space_id != space_id
|
|
and _same_workspace_path(b.workspace_root, str(resolved))
|
|
),
|
|
None,
|
|
)
|
|
if conflict is not None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"code": "folder_already_bound_to_other_space",
|
|
"other_space_id": conflict.space_id,
|
|
"workspace_root": conflict.workspace_root,
|
|
"message": (
|
|
"This folder is already used by another Space. "
|
|
"Open that Space to continue, or pick a different folder."
|
|
),
|
|
},
|
|
)
|
|
|
|
binding = resolver.ensure_space_binding(
|
|
payload.email,
|
|
space_id,
|
|
str(resolved),
|
|
user_id=payload.user_id,
|
|
)
|
|
return {
|
|
"space_id": space_id,
|
|
"email": payload.email,
|
|
"user_id": payload.user_id,
|
|
"bound": True,
|
|
"workspace_root": binding.workspace_root,
|
|
"binding": binding.__dict__.copy(),
|
|
}
|
|
|
|
|
|
@router.delete("/workspace/{space_id}")
|
|
async def workspace_unbind(
|
|
space_id: str,
|
|
email: str = Query(..., description="User email"),
|
|
user_id: str | None = Query(None, description="Canonical user ID"),
|
|
) -> dict[str, Any]:
|
|
# TODO(brain-auth): Phase B must derive the binding owner from
|
|
# request.state.brain_auth.user_id instead of trusting the email query.
|
|
resolver = get_workspace_resolver()
|
|
resolver.store.delete_binding(email, space_id, user_id)
|
|
return {
|
|
"space_id": space_id,
|
|
"email": email,
|
|
"user_id": user_id,
|
|
"bound": False,
|
|
"workspace_root": None,
|
|
"binding": None,
|
|
}
|
|
|
|
|
|
@router.post("/workspace/reconcile")
|
|
async def workspace_reconcile(
|
|
payload: WorkspaceReconcileRequest,
|
|
) -> dict[str, Any]:
|
|
# TODO(brain-auth): Phase B should enforce auth-context ownership here
|
|
# first; reconcile is destructive and must not trust payload.email.
|
|
resolver = get_workspace_resolver()
|
|
removed = resolver.store.reconcile_bindings(
|
|
payload.email,
|
|
set(payload.active_space_ids),
|
|
payload.user_id,
|
|
)
|
|
return {
|
|
"email": payload.email,
|
|
"user_id": payload.user_id,
|
|
"active_space_ids": payload.active_space_ids,
|
|
"removed_space_ids": [binding.space_id for binding in removed],
|
|
"removed_count": len(removed),
|
|
}
|
|
|
|
|
|
@router.post("/workspace/{space_id}/projects/{project_id}/refresh")
|
|
async def workspace_project_refresh(
|
|
space_id: str,
|
|
project_id: str,
|
|
payload: WorkspaceProjectRefreshRequest,
|
|
) -> dict[str, Any]:
|
|
# TODO(brain-auth): Phase B must derive the binding owner from
|
|
# request.state.brain_auth.user_id instead of trusting payload.email.
|
|
# TODO(cloud-brain): replace the client-provided boolean with a short-lived
|
|
# server-signed refresh token that Brain verifies before deleting workdirs.
|
|
if not payload.server_refresh_confirmed:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"code": "server_refresh_precondition_required",
|
|
"message": (
|
|
"Brain workdir refresh must be called only after the "
|
|
"control server has checked pending overlays."
|
|
),
|
|
},
|
|
)
|
|
if _project_has_active_run(project_id):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"code": "project_running",
|
|
"message": "Project workdir cannot be refreshed while a run is active.",
|
|
},
|
|
)
|
|
resolver = get_workspace_resolver()
|
|
try:
|
|
base_snapshot_id = resolver.refresh_project_workdir(
|
|
space_id=space_id,
|
|
project_id=project_id,
|
|
email=payload.email,
|
|
user_id=payload.user_id,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"code": "workspace_refresh_failed",
|
|
"message": str(exc),
|
|
},
|
|
) from exc
|
|
return {
|
|
"space_id": space_id,
|
|
"project_id": project_id,
|
|
"base_snapshot_id": base_snapshot_id,
|
|
}
|