feat: add remote workspaces and verified deliverables
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated
|
||||
from urllib.parse import quote
|
||||
|
||||
import uvicorn
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from agent_platform.auth import UserIdentity, decode_openwebui_identity, verify_service_bearer
|
||||
from agent_platform.config import Settings, get_settings
|
||||
@@ -19,6 +21,7 @@ from agent_platform.gateway.schemas import (
|
||||
SearchFilesRequest,
|
||||
StartProcessRequest,
|
||||
ToolResult,
|
||||
WorkspaceListing,
|
||||
WorkspaceStatus,
|
||||
WriteFileRequest,
|
||||
)
|
||||
@@ -70,6 +73,11 @@ def create_app(
|
||||
async with mutation_locks_guard:
|
||||
return mutation_locks.setdefault(user_id, asyncio.Lock())
|
||||
|
||||
def attachment_header(filename: str) -> str:
|
||||
fallback = "".join(character for character in filename if character.isascii() and character.isalnum())
|
||||
fallback = fallback[:80] or "download"
|
||||
return f"attachment; filename={fallback}; filename*=UTF-8''{quote(filename)}"
|
||||
|
||||
@app.get("/health", include_in_schema=False)
|
||||
async def health() -> dict:
|
||||
return {"status": "ok", "provider": settings.execution_provider}
|
||||
@@ -85,6 +93,52 @@ def create_app(
|
||||
except ValueError as exc:
|
||||
raise translate_value_error(exc) from exc
|
||||
|
||||
@app.get("/v1/files", response_model=WorkspaceListing, operation_id="browse_workspace_files")
|
||||
async def browse_workspace_files(
|
||||
identity: Identity,
|
||||
path: Annotated[str, Query(max_length=4096)] = ".",
|
||||
) -> WorkspaceListing:
|
||||
try:
|
||||
return await executor().browse_files(identity.user_id, path)
|
||||
except ValueError as exc:
|
||||
raise translate_value_error(exc) from exc
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/v1/files/download", operation_id="download_workspace_file")
|
||||
async def download_workspace_file(
|
||||
identity: Identity,
|
||||
path: Annotated[str, Query(max_length=4096)],
|
||||
) -> Response:
|
||||
try:
|
||||
download = await executor().download_file(identity.user_id, path)
|
||||
except ValueError as exc:
|
||||
raise translate_value_error(exc) from exc
|
||||
except (FileNotFoundError, IsADirectoryError) as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return Response(
|
||||
content=download.content or b"",
|
||||
media_type=download.media_type,
|
||||
headers={"Content-Disposition": attachment_header(download.filename)},
|
||||
)
|
||||
|
||||
@app.get("/v1/files/archive", operation_id="archive_workspace_files")
|
||||
async def archive_workspace_files(
|
||||
identity: Identity,
|
||||
path: Annotated[str, Query(max_length=4096)] = ".",
|
||||
) -> StreamingResponse:
|
||||
try:
|
||||
download = await executor().archive_files(identity.user_id, path)
|
||||
except ValueError as exc:
|
||||
raise translate_value_error(exc) from exc
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return StreamingResponse(
|
||||
download.chunks or iter(()),
|
||||
media_type=download.media_type,
|
||||
headers={"Content-Disposition": attachment_header(download.filename)},
|
||||
)
|
||||
|
||||
@app.post("/v1/tools/read_file", response_model=ToolResult, operation_id="read_file")
|
||||
async def read_file(body: ReadFileRequest, identity: Identity) -> ToolResult:
|
||||
try:
|
||||
|
||||
@@ -4,9 +4,12 @@ import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
import shlex
|
||||
import tarfile
|
||||
import uuid
|
||||
import zlib
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Protocol
|
||||
@@ -15,7 +18,7 @@ from docker.errors import ImageNotFound, NotFound
|
||||
|
||||
import docker
|
||||
from agent_platform.config import Settings
|
||||
from agent_platform.gateway.schemas import ToolResult, WorkspaceStatus
|
||||
from agent_platform.gateway.schemas import ToolResult, WorkspaceListing, WorkspaceStatus
|
||||
|
||||
|
||||
def normalize_workspace_path(raw_path: str) -> PurePosixPath:
|
||||
@@ -46,6 +49,14 @@ class WorkspaceRef:
|
||||
network_name: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkspaceDownload:
|
||||
filename: str
|
||||
media_type: str
|
||||
content: bytes | None = None
|
||||
chunks: Iterator[bytes] | None = None
|
||||
|
||||
|
||||
def workspace_ref(user_id: str) -> WorkspaceRef:
|
||||
digest = hashlib.sha256(user_id.encode("utf-8")).hexdigest()[:20]
|
||||
return WorkspaceRef(
|
||||
@@ -86,6 +97,12 @@ class ExecutionProvider(Protocol):
|
||||
|
||||
async def cancel_process(self, user_id: str, process_id: str) -> ToolResult: ...
|
||||
|
||||
async def browse_files(self, user_id: str, path: str) -> WorkspaceListing: ...
|
||||
|
||||
async def download_file(self, user_id: str, path: str) -> WorkspaceDownload: ...
|
||||
|
||||
async def archive_files(self, user_id: str, path: str) -> WorkspaceDownload: ...
|
||||
|
||||
|
||||
class DockerExecutionProvider:
|
||||
provider_name = "local-docker"
|
||||
@@ -228,6 +245,95 @@ class DockerExecutionProvider:
|
||||
["python3", "-c", script, absolute, str(max_depth), str(limit)],
|
||||
)
|
||||
|
||||
async def browse_files(self, user_id: str, path: str) -> WorkspaceListing:
|
||||
relative = normalize_workspace_path(path)
|
||||
absolute = absolute_workspace_path(path)
|
||||
script = (
|
||||
"import json,os,pathlib,sys\n"
|
||||
"root=pathlib.Path(sys.argv[1])\n"
|
||||
"if not root.is_dir(): raise SystemExit(f'Not a directory: {root}')\n"
|
||||
"items=[]\n"
|
||||
"for item in root.iterdir():\n"
|
||||
" if item.name=='.agent': continue\n"
|
||||
" try: info=item.lstat()\n"
|
||||
" except OSError: continue\n"
|
||||
" kind='symlink' if item.is_symlink() else ('directory' if item.is_dir() else 'file')\n"
|
||||
" rel=item.relative_to('/workspace').as_posix()\n"
|
||||
" items.append({'name':item.name,'path':rel,'kind':kind,'size':info.st_size,"
|
||||
"'modified_at':int(info.st_mtime)})\n"
|
||||
"items.sort(key=lambda value:(value['kind']!='directory',value['name'].casefold()))\n"
|
||||
"print(json.dumps(items,ensure_ascii=False))\n"
|
||||
)
|
||||
raw = await self._exec_argv(user_id, ["python3", "-c", script, absolute])
|
||||
if not raw.ok:
|
||||
raise FileNotFoundError(raw.output.strip() or f"Directory not found: {relative}")
|
||||
try:
|
||||
entries = json.loads(raw.output)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError("Workspace returned an invalid directory listing") from exc
|
||||
return WorkspaceListing(path="." if relative == PurePosixPath(".") else str(relative), entries=entries)
|
||||
|
||||
async def download_file(self, user_id: str, path: str) -> WorkspaceDownload:
|
||||
relative = normalize_workspace_path(path)
|
||||
if relative == PurePosixPath("."):
|
||||
raise ValueError("A file path is required")
|
||||
container = await self._container(user_id)
|
||||
absolute = absolute_workspace_path(path)
|
||||
|
||||
def collect() -> WorkspaceDownload:
|
||||
chunks, metadata = container.get_archive(absolute)
|
||||
size = int(metadata.get("size", 0))
|
||||
if size > self.settings.workspace_download_max_bytes:
|
||||
raise ValueError(
|
||||
f"File exceeds the {self.settings.workspace_download_max_bytes // (1024 * 1024)} MB download limit"
|
||||
)
|
||||
archive = io.BytesIO()
|
||||
archive_limit = self.settings.workspace_download_max_bytes + 2 * 1024 * 1024
|
||||
for chunk in chunks:
|
||||
archive.write(chunk)
|
||||
if archive.tell() > archive_limit:
|
||||
raise ValueError("File archive exceeded the download limit")
|
||||
archive.seek(0)
|
||||
with tarfile.open(fileobj=archive, mode="r:*") as tar:
|
||||
members = tar.getmembers()
|
||||
member = members[0] if members else None
|
||||
if member is None:
|
||||
raise FileNotFoundError(f"File not found: {relative}")
|
||||
if not member.isfile():
|
||||
raise IsADirectoryError(f"Not a regular file: {relative}")
|
||||
source = tar.extractfile(member)
|
||||
if source is None:
|
||||
raise FileNotFoundError(f"File not found: {relative}")
|
||||
content = source.read(self.settings.workspace_download_max_bytes + 1)
|
||||
if len(content) > self.settings.workspace_download_max_bytes:
|
||||
raise ValueError("File exceeds the download limit")
|
||||
media_type = mimetypes.guess_type(relative.name)[0] or "application/octet-stream"
|
||||
return WorkspaceDownload(filename=relative.name, media_type=media_type, content=content)
|
||||
|
||||
return await asyncio.to_thread(collect)
|
||||
|
||||
async def archive_files(self, user_id: str, path: str) -> WorkspaceDownload:
|
||||
relative = normalize_workspace_path(path)
|
||||
container = await self._container(user_id)
|
||||
chunks, _ = await asyncio.to_thread(container.get_archive, absolute_workspace_path(path))
|
||||
|
||||
def compressed_chunks() -> Iterator[bytes]:
|
||||
compressor = zlib.compressobj(level=6, method=zlib.DEFLATED, wbits=31)
|
||||
for chunk in chunks:
|
||||
compressed = compressor.compress(chunk)
|
||||
if compressed:
|
||||
yield compressed
|
||||
final = compressor.flush()
|
||||
if final:
|
||||
yield final
|
||||
|
||||
basename = "workspace" if relative == PurePosixPath(".") else relative.name
|
||||
return WorkspaceDownload(
|
||||
filename=f"{basename}.tar.gz",
|
||||
media_type="application/gzip",
|
||||
chunks=compressed_chunks(),
|
||||
)
|
||||
|
||||
async def read_file(self, user_id: str, path: str, start_line: int, max_lines: int) -> ToolResult:
|
||||
absolute = absolute_workspace_path(path)
|
||||
script = (
|
||||
|
||||
@@ -73,6 +73,19 @@ class WorkspaceStatus(BaseModel):
|
||||
state: str
|
||||
|
||||
|
||||
class WorkspaceEntry(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
kind: Literal["file", "directory", "symlink"]
|
||||
size: int = Field(ge=0)
|
||||
modified_at: int = Field(ge=0)
|
||||
|
||||
|
||||
class WorkspaceListing(BaseModel):
|
||||
path: str
|
||||
entries: list[WorkspaceEntry]
|
||||
|
||||
|
||||
class PlanItem(BaseModel):
|
||||
step: str = Field(min_length=1, max_length=1000)
|
||||
status: Literal["pending", "in_progress", "completed"]
|
||||
|
||||
Reference in New Issue
Block a user