From 30729dce05a809273c7d8a3e2a9e06e6dc07686b Mon Sep 17 00:00:00 2001 From: Jiayi Chen Date: Mon, 23 Mar 2026 00:28:38 +0100 Subject: [PATCH 001/172] fix(consultant): docker socket access and container name fixes - entrypoint.sh: add clawith user to docker socket group for container mgmt - agent_seeder.py: support SKIP_DEFAULT_AGENTS env var - nginx.conf: fix proxy_pass to use consultant_backend hostname --- backend/app/services/agent_seeder.py | 4 ++++ backend/entrypoint.sh | 8 ++++++++ frontend/nginx.conf | 6 +++--- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/backend/app/services/agent_seeder.py b/backend/app/services/agent_seeder.py index 41df9d2c0..a25d0941b 100644 --- a/backend/app/services/agent_seeder.py +++ b/backend/app/services/agent_seeder.py @@ -93,6 +93,10 @@ async def seed_default_agents(): """Create Morty & Meeseeks if they don't already exist.""" + import os + if os.environ.get("SKIP_DEFAULT_AGENTS", "").lower() in ("true", "1", "yes"): + logger.info("[AgentSeeder] SKIP_DEFAULT_AGENTS is set, skipping default agents") + return async with async_session() as db: # Check if already seeded (presence of either agent by name) existing = await db.execute( diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 78acdfea0..e136b95a2 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -13,6 +13,14 @@ if [ "$(id -u)" = '0' ]; then echo "[entrypoint] Detected root user, fixing permissions..." # Ensure directories exist and are owned by clawith chown -R clawith:clawith ${AGENT_DATA_DIR} + + # Add clawith to docker group (GID from mounted socket) for container management + DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || true) + if [ -n "$DOCKER_SOCK_GID" ] && [ "$DOCKER_SOCK_GID" != "0" ]; then + echo "[entrypoint] Adding clawith to docker socket group (GID=$DOCKER_SOCK_GID)..." + groupadd -g "$DOCKER_SOCK_GID" -o docker 2>/dev/null || true + usermod -aG docker clawith 2>/dev/null || true + fi echo "[entrypoint] Dropping privileges to 'clawith' and re-executing..." exec gosu clawith /bin/bash "$0" "$@" diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 67b23eaea..081612e21 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -22,7 +22,7 @@ server { # API proxy location /api/ { - proxy_pass http://backend:8000; + proxy_pass http://consultant_backend:8000; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; @@ -33,7 +33,7 @@ server { # Public pages proxy (no auth, served by backend) location /p/ { - proxy_pass http://backend:8000; + proxy_pass http://consultant_backend:8000; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; @@ -41,7 +41,7 @@ server { # WebSocket proxy location /ws/ { - proxy_pass http://backend:8000; + proxy_pass http://consultant_backend:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; From ec8e13438644686f8fa8733e2ad9a85912d6260b Mon Sep 17 00:00:00 2001 From: Jiayi Chen Date: Mon, 23 Mar 2026 00:28:46 +0100 Subject: [PATCH 002/172] chore: add runtime data and infrastructure files to gitignore --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index 009e7d07e..378da0757 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,12 @@ _agents/ # Internal docs docs/ + +# Runtime data (postgres, redis, agent workspaces) +data/ + +# Infrastructure config (not code) +cloudfront-config.json + +# Unnecessary extra requirements file (deps in pyproject.toml) +backend/requirements.txt From a23acde513452f570b3a5d878650363f391feada Mon Sep 17 00:00:00 2001 From: Jiayi Chen Date: Mon, 23 Mar 2026 00:28:55 +0100 Subject: [PATCH 003/172] feat(workspace): add WorkspaceProject and WorkspaceBugReport models SQLAlchemy 2.0 models for tracking workspace deployments (static + container) and user/health-check bug reports with auto-fix circuit breaker fields. Also adds WORKSPACE_STATIC_DIR, WORKSPACE_CONF_DIR, WORKSPACE_GATEWAY_CONTAINER config settings. --- backend/app/config.py | 3 ++ backend/app/models/workspace.py | 94 +++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 backend/app/models/workspace.py diff --git a/backend/app/config.py b/backend/app/config.py index 701e2551a..ea4755755 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -65,6 +65,9 @@ class Settings(BaseSettings): # File Storage AGENT_DATA_DIR: str = _default_agent_data_dir() + WORKSPACE_STATIC_DIR: str = "/srv/workspace" + WORKSPACE_CONF_DIR: str = "/etc/nginx/workspace.d" + WORKSPACE_GATEWAY_CONTAINER: str = "workspace_gateway" AGENT_TEMPLATE_DIR: str = "/app/agent_template" # Docker (for Agent containers) diff --git a/backend/app/models/workspace.py b/backend/app/models/workspace.py new file mode 100644 index 000000000..838d3c4d5 --- /dev/null +++ b/backend/app/models/workspace.py @@ -0,0 +1,94 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import JSON, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class WorkspaceProject(Base): + __tablename__ = "workspace_projects" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + slug: Mapped[str] = mapped_column( + String(50), unique=True, index=True, nullable=False + ) + name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=True) + requested_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True + ) + requested_by_human: Mapped[str | None] = mapped_column(String(200), nullable=True) + built_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True + ) + deploy_type: Mapped[str | None] = mapped_column( + Enum("static", "container", name="deploy_type_enum", create_constraint=False), + nullable=True, + ) + status: Mapped[str] = mapped_column( + Enum( + "requested", "building", "awaiting_approval", "deployed", + "failed", "rejected", "stopped", "undeployed", + name="workspace_status_enum", create_constraint=False, + ), + nullable=False, + default="requested", + ) + container_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + container_image: Mapped[str | None] = mapped_column(String(300), nullable=True) + container_port: Mapped[int | None] = mapped_column(Integer, nullable=True) + health_endpoint: Mapped[str | None] = mapped_column(String(200), nullable=True) + resource_limits: Mapped[dict | None] = mapped_column(JSON, nullable=True) + auto_fix_attempts: Mapped[int] = mapped_column(Integer, default=0) + auto_fix_window_start: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + bug_reports: Mapped[list["WorkspaceBugReport"]] = relationship( + back_populates="project", cascade="all, delete-orphan" + ) + + +class WorkspaceBugReport(Base): + __tablename__ = "workspace_bug_reports" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + project_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workspace_projects.id", ondelete="CASCADE"), + nullable=False, + ) + source: Mapped[str] = mapped_column( + Enum("health_check", "user_report", name="bug_source_enum", create_constraint=False), + nullable=False, + ) + description: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column( + Enum( + "open", "investigating", "fixed", "escalated", + name="bug_status_enum", create_constraint=False, + ), + nullable=False, + default="open", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + project: Mapped["WorkspaceProject"] = relationship(back_populates="bug_reports") From 957aa28dde120fceb66f0bea8a5f4089789c4075 Mon Sep 17 00:00:00 2001 From: Jiayi Chen Date: Mon, 23 Mar 2026 00:29:04 +0100 Subject: [PATCH 004/172] feat(workspace): add deployment tools, index generator, and health checks - workspace_tools.py: slug validation, deploy_static, request_container_deploy, approve/reject pipeline, undeploy, bug report tools, gateway reload - workspace_index.py: auto-generated HTML index page at /workspace/ - workspace_health.py: periodic health checks with parallel execution and circuit breaker (max 3 auto-fixes per 24h window) --- backend/app/services/workspace_health.py | 147 +++++ backend/app/services/workspace_index.py | 164 ++++++ backend/app/services/workspace_tools.py | 660 +++++++++++++++++++++++ 3 files changed, 971 insertions(+) create mode 100644 backend/app/services/workspace_health.py create mode 100644 backend/app/services/workspace_index.py create mode 100644 backend/app/services/workspace_tools.py diff --git a/backend/app/services/workspace_health.py b/backend/app/services/workspace_health.py new file mode 100644 index 000000000..6e868422c --- /dev/null +++ b/backend/app/services/workspace_health.py @@ -0,0 +1,147 @@ +"""Periodic health checks for deployed workspace projects.""" + +import asyncio +import logging +from datetime import datetime, timedelta, timezone + +import httpx +from sqlalchemy import select, update + +from app.config import get_settings +from app.database import async_session +from app.models.workspace import WorkspaceBugReport, WorkspaceProject + +logger = logging.getLogger(__name__) + +settings = get_settings() + +CHECK_INTERVAL = 300 # 5 minutes +RETRY_DELAY = 30 # seconds +GRACE_PERIOD = 30 # seconds after deploy before first check +MAX_AUTO_FIX = 3 +AUTO_FIX_WINDOW = timedelta(hours=24) + + +async def run_health_checks(): + """Background loop that checks workspace project health every 5 minutes.""" + logger.info("Workspace health check task started") + while True: + try: + await _check_all_projects() + except Exception: + logger.exception("Health check cycle failed") + await asyncio.sleep(CHECK_INTERVAL) + + +async def _check_all_projects(): + """Check all deployed projects.""" + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.status == "deployed") + ) + projects = result.scalars().all() + + now = datetime.now(timezone.utc) + eligible = [ + p for p in projects + if not p.updated_at or (now - p.updated_at.replace(tzinfo=timezone.utc)).total_seconds() >= GRACE_PERIOD + ] + + # Check all projects in parallel + results = await asyncio.gather( + *[_check_project(p) for p in eligible], + return_exceptions=True, + ) + + # Retry failed ones (in parallel), then create bug reports + failed = [p for p, r in zip(eligible, results) if r is not True] + if failed: + await asyncio.sleep(RETRY_DELAY) + retry_results = await asyncio.gather( + *[_check_project(p) for p in failed], + return_exceptions=True, + ) + for p, r in zip(failed, retry_results): + if r is not True: + await _create_health_bug_report(p) + + +async def _check_project(project: WorkspaceProject) -> bool: + """Check a single project's health. Returns True if healthy.""" + if project.deploy_type == "static": + url = f"http://{settings.WORKSPACE_GATEWAY_CONTAINER}/workspace/{project.slug}/" + method = "HEAD" + else: + endpoint = project.health_endpoint or f"/workspace/{project.slug}/health" + url = f"http://{settings.WORKSPACE_GATEWAY_CONTAINER}{endpoint}" + method = "GET" + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + if method == "HEAD": + resp = await client.head(url) + else: + resp = await client.get(url) + return resp.status_code == 200 + except Exception as e: + logger.warning("Health check failed for %s: %s", project.slug, e) + return False + + +async def _create_health_bug_report(project: WorkspaceProject): + """Create a bug report for a failed health check, respecting circuit breaker.""" + now = datetime.now(timezone.utc) + + async with async_session() as db: + # Re-fetch to get latest state + proj = await db.get(WorkspaceProject, project.id) + if not proj or proj.status != "deployed": + return + + # Check circuit breaker + if proj.auto_fix_window_start: + window_start = proj.auto_fix_window_start.replace(tzinfo=timezone.utc) + if now - window_start < AUTO_FIX_WINDOW: + if proj.auto_fix_attempts >= MAX_AUTO_FIX: + # Exceeded max attempts — escalate + proj.status = "failed" + report = WorkspaceBugReport( + project_id=proj.id, + source="health_check", + description=f"Health check failed. Auto-fix limit ({MAX_AUTO_FIX}) exceeded in 24h window. Manual intervention required.", + status="escalated", + ) + db.add(report) + await db.commit() + logger.warning("Project '%s' escalated — auto-fix limit exceeded", proj.slug) + return + else: + # Window expired, reset + proj.auto_fix_attempts = 0 + proj.auto_fix_window_start = now + + if not proj.auto_fix_window_start: + proj.auto_fix_window_start = now + + proj.auto_fix_attempts += 1 + + # Check if there's already an open health_check report for this project + existing = await db.execute( + select(WorkspaceBugReport).where( + WorkspaceBugReport.project_id == proj.id, + WorkspaceBugReport.source == "health_check", + WorkspaceBugReport.status.in_(["open", "investigating"]), + ) + ) + if existing.scalar_one_or_none(): + await db.commit() # save the attempt counter + return # don't duplicate + + report = WorkspaceBugReport( + project_id=proj.id, + source="health_check", + description=f"Health check failed for /workspace/{proj.slug}/. The site may be down or returning errors.", + ) + db.add(report) + await db.commit() + logger.info("Health check bug report created for '%s' (attempt %d/%d)", proj.slug, proj.auto_fix_attempts, MAX_AUTO_FIX) diff --git a/backend/app/services/workspace_index.py b/backend/app/services/workspace_index.py new file mode 100644 index 000000000..45569f355 --- /dev/null +++ b/backend/app/services/workspace_index.py @@ -0,0 +1,164 @@ +"""Generates the workspace index page at /workspace/.""" + +import html +import logging +from pathlib import Path + +from sqlalchemy import select + +from app.config import get_settings +from app.database import async_session +from app.models.workspace import WorkspaceProject + +logger = logging.getLogger(__name__) + +settings = get_settings() + +STATUS_COLORS = { + "deployed": "#22c55e", + "building": "#f59e0b", + "awaiting_approval": "#3b82f6", + "failed": "#ef4444", + "stopped": "#6b7280", +} + + +async def regenerate_index() -> None: + """Regenerate the workspace index HTML. Non-blocking — logs errors but does not raise.""" + try: + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject) + .where(WorkspaceProject.status == "deployed") + .order_by(WorkspaceProject.created_at.desc()) + ) + projects = result.scalars().all() + + index_dir = Path(settings.WORKSPACE_STATIC_DIR) / "_index" + index_dir.mkdir(parents=True, exist_ok=True) + + project_cards = "" + for p in projects: + badge_color = STATUS_COLORS.get(p.status, "#6b7280") + project_cards += f""" +
+
+ {html.escape(p.name)} + {p.status} +
+

{html.escape(p.description or "No description")}

+ +
""" + + empty_msg = '

No projects deployed yet.

' if not projects else "" + + page_html = f""" + + + + + Workspace — NLearn Consultant Company + + + +

NLearn Consultant Company — Workspace

+

Projects built by our AI team

+
+ {empty_msg} + {project_cards} + + + + + + +""" + + (index_dir / "index.html").write_text(page_html, encoding="utf-8") + logger.info("Workspace index page regenerated with %d projects", len(projects)) + + except Exception: + logger.exception("Failed to regenerate workspace index page") + + + # Note: uses stdlib html.escape() imported at top of file diff --git a/backend/app/services/workspace_tools.py b/backend/app/services/workspace_tools.py new file mode 100644 index 000000000..3b7ee7ef0 --- /dev/null +++ b/backend/app/services/workspace_tools.py @@ -0,0 +1,660 @@ +"""Workspace deployment tools for the Software Engineer agent.""" + +import re +import uuid +import logging +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.database import async_session +from app.models.workspace import WorkspaceBugReport, WorkspaceProject + +logger = logging.getLogger(__name__) + +SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$") +RESERVED_SLUGS = {"_index", "api", "static", "health"} + +settings = get_settings() + + +def validate_slug(slug: str) -> str | None: + """Validate a workspace slug. Returns error message or None if valid.""" + if not slug: + return "Slug cannot be empty." + if slug in RESERVED_SLUGS: + return f"Slug '{slug}' is reserved. Choose a different name." + if not SLUG_PATTERN.match(slug): + return ( + f"Invalid slug '{slug}'. Must be 2-50 characters, " + "lowercase letters, numbers, and hyphens only. " + "Cannot start or end with a hyphen." + ) + return None + + +async def check_slug_available(slug: str) -> str | None: + """Check if slug is available in DB. Returns error message or None.""" + async with async_session() as db: + existing = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + if existing.scalar_one_or_none(): + return f"Slug '{slug}' is already in use." + return None + + +import asyncio +import html as html_mod +import json +import shutil + +from sqlalchemy.exc import IntegrityError + +from app.services.workspace_index import regenerate_index + + +def _get_docker_client(): + """Get a Docker client connected via the mounted socket. Lazy import to avoid import-time failure.""" + import docker + return docker.DockerClient(base_url="unix:///var/run/docker.sock") + + +async def tool_request_build( + agent_id: uuid.UUID, arguments: dict +) -> str: + """Create a build request for the SE agent.""" + slug = arguments.get("slug", "").strip() + name = arguments.get("name", "").strip() + description = arguments.get("description", "").strip() + + if not name or not description: + return "Error: 'name' and 'description' are required." + + slug_error = validate_slug(slug) + if slug_error: + return f"Error: {slug_error}" + + avail_error = await check_slug_available(slug) + if avail_error: + return f"Error: {avail_error}" + + try: + async with async_session() as db: + project = WorkspaceProject( + slug=slug, + name=name, + description=description, + requested_by=agent_id, + status="requested", + ) + db.add(project) + await db.commit() + except IntegrityError: + return f"Error: Slug '{slug}' is already in use." + + return ( + f"Build request created!\n" + f"- Slug: {slug}\n" + f"- Name: {name}\n" + f"- Description: {description}\n" + f"The Software Engineer agent will pick this up." + ) + + +async def tool_request_build_human( + arguments: dict, +) -> str: + """Create a build request from a human (no agent_id).""" + slug = arguments.get("slug", "").strip() + name = arguments.get("name", "").strip() + description = arguments.get("description", "").strip() + requester = arguments.get("requester", "").strip() or "Frank" + + if not name or not description: + return "Error: 'name' and 'description' are required." + + slug_error = validate_slug(slug) + if slug_error: + return f"Error: {slug_error}" + + avail_error = await check_slug_available(slug) + if avail_error: + return f"Error: {avail_error}" + + try: + async with async_session() as db: + project = WorkspaceProject( + slug=slug, + name=name, + description=description, + requested_by_human=requester, + status="requested", + ) + db.add(project) + await db.commit() + except IntegrityError: + return f"Error: Slug '{slug}' is already in use." + + return f"Build request '{name}' created for slug '{slug}'." + + +async def tool_list_build_requests() -> str: + """List pending build requests.""" + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject) + .where(WorkspaceProject.status == "requested") + .order_by(WorkspaceProject.created_at.asc()) + ) + projects = result.scalars().all() + + if not projects: + return "No pending build requests." + + lines = ["Pending build requests:\n"] + for p in projects: + requester = p.requested_by_human or f"Agent {p.requested_by}" + lines.append( + f"- [{p.slug}] {p.name}\n" + f" Requested by: {requester}\n" + f" Description: {p.description}\n" + ) + return "\n".join(lines) + + +async def tool_deploy_static( + agent_id: uuid.UUID, ws: Path, arguments: dict +) -> str: + """Deploy static files from agent workspace to /srv/workspace/{slug}/.""" + slug = arguments.get("slug", "").strip() + source_dir = arguments.get("source_dir", "").strip() + + if not slug or not source_dir: + return "Error: 'slug' and 'source_dir' are required." + + slug_error = validate_slug(slug) + if slug_error: + return f"Error: {slug_error}" + + # Resolve source path within agent workspace + source_path = (ws / source_dir).resolve() + if not str(source_path).startswith(str(ws.resolve())): + return "Error: source_dir must be within your workspace." + if not source_path.is_dir(): + return f"Error: Directory '{source_dir}' not found in your workspace." + if not (source_path / "index.html").exists(): + return f"Error: No index.html found in '{source_dir}'. Static sites must have an index.html." + + # Check/create project record + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + project = result.scalar_one_or_none() + + if project and project.deploy_type == "container" and project.status == "deployed": + return f"Error: Slug '{slug}' is used by a running container deployment. Undeploy it first." + + if not project: + # Auto-create project record for direct deploys + project = WorkspaceProject( + slug=slug, + name=slug.replace("-", " ").title(), + description="Deployed via deploy_static", + built_by=agent_id, + deploy_type="static", + status="building", + ) + db.add(project) + await db.flush() + else: + project.deploy_type = "static" + project.built_by = agent_id + project.status = "building" + + await db.commit() + project_id = project.id + + # Copy files to workspace static dir + dest_path = Path(settings.WORKSPACE_STATIC_DIR) / slug + try: + if dest_path.exists(): + shutil.rmtree(dest_path) + shutil.copytree(source_path, dest_path) + except Exception as e: + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.id == project_id) + ) + project = result.scalar_one() + project.status = "failed" + await db.commit() + return f"Error copying files: {e}" + + # Update status + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.id == project_id) + ) + project = result.scalar_one() + project.status = "deployed" + await db.commit() + + # Regenerate index (non-blocking) + try: + await regenerate_index() + except Exception: + logger.exception("Index regeneration failed after deploy_static") + + return ( + f"Static site deployed successfully!\n" + f"- URL: /workspace/{slug}/\n" + f"- Files copied from: {source_dir}\n" + f"The site is now live." + ) + + +async def tool_request_container_deploy( + agent_id: uuid.UUID, ws: Path, arguments: dict +) -> str: + """Submit a container deployment for approval.""" + slug = arguments.get("slug", "").strip() + dockerfile_path = arguments.get("dockerfile_path", "").strip() + port = arguments.get("port") + name = arguments.get("name", "").strip() + description = arguments.get("description", "").strip() + resource_suggestion = arguments.get("resource_limits_suggestion", {}) + + if not all([slug, dockerfile_path, port, name, description]): + return "Error: slug, dockerfile_path, port, name, and description are all required." + + slug_error = validate_slug(slug) + if slug_error: + return f"Error: {slug_error}" + + # Verify Dockerfile exists + dockerfile_full = (ws / dockerfile_path).resolve() + if not str(dockerfile_full).startswith(str(ws.resolve())): + return "Error: dockerfile_path must be within your workspace." + if not dockerfile_full.exists(): + return f"Error: Dockerfile not found at '{dockerfile_path}'." + + # Check/create project record + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + project = result.scalar_one_or_none() + + if project and project.status == "deployed": + return f"Error: Slug '{slug}' is already deployed. Undeploy it first." + if project and project.status == "awaiting_approval": + return f"Error: Slug '{slug}' already has a pending approval request." + + if not project: + project = WorkspaceProject( + slug=slug, + name=name, + description=description, + built_by=agent_id, + deploy_type="container", + status="awaiting_approval", + container_port=port, + resource_limits=resource_suggestion if resource_suggestion else None, + ) + db.add(project) + else: + project.name = name + project.description = description + project.built_by = agent_id + project.deploy_type = "container" + project.status = "awaiting_approval" + project.container_port = port + project.resource_limits = resource_suggestion if resource_suggestion else None + + await db.commit() + + return ( + f"Container deployment request submitted for approval.\n" + f"- Slug: {slug}\n" + f"- Name: {name}\n" + f"- Dockerfile: {dockerfile_path}\n" + f"- Port: {port}\n" + f"- Suggested limits: {json.dumps(resource_suggestion) if resource_suggestion else 'none'}\n" + f"Frank will review and approve this deployment." + ) + + +async def tool_list_workspace_projects() -> str: + """List all workspace projects.""" + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).order_by(WorkspaceProject.created_at.desc()) + ) + projects = result.scalars().all() + + if not projects: + return "No workspace projects." + + lines = ["Workspace projects:\n"] + for p in projects: + url = f"/workspace/{p.slug}/" if p.status == "deployed" else "(not live)" + lines.append( + f"- [{p.slug}] {p.name} — {p.status} ({p.deploy_type})\n" + f" URL: {url}\n" + ) + return "\n".join(lines) + + +async def tool_undeploy_project(arguments: dict) -> str: + """Remove a deployed workspace project.""" + slug = arguments.get("slug", "").strip() + if not slug: + return "Error: 'slug' is required." + + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + project = result.scalar_one_or_none() + if not project: + return f"Error: Project '{slug}' not found." + + if project.deploy_type == "static": + # Delete static files + dest = Path(settings.WORKSPACE_STATIC_DIR) / slug + if dest.exists(): + shutil.rmtree(dest) + + elif project.deploy_type == "container" and project.container_id: + # Stop and remove container + try: + client = _get_docker_client() + try: + container = client.containers.get(project.container_id) + container.stop(timeout=10) + container.remove() + except Exception: + pass # container already gone or not found + client.close() + except Exception as e: + logger.exception("Failed to remove container for %s", slug) + return f"Warning: Container removal failed ({e}), but project will be marked as undeployed." + + # Remove nginx conf + conf_path = Path(settings.WORKSPACE_CONF_DIR) / f"{slug}.conf" + if conf_path.exists(): + conf_path.unlink() + + # Reload gateway nginx + await _reload_gateway() + + project.status = "undeployed" + await db.commit() + + # Regenerate index + try: + await regenerate_index() + except Exception: + logger.exception("Index regeneration failed after undeploy") + + return f"Project '{slug}' has been undeployed." + + +async def tool_get_bug_reports(arguments: dict) -> str: + """List bug reports, optionally filtered by status.""" + from sqlalchemy.orm import selectinload + + status_filter = arguments.get("status_filter", "open").strip() + + async with async_session() as db: + query = ( + select(WorkspaceBugReport) + .options(selectinload(WorkspaceBugReport.project)) + .join(WorkspaceProject) + ) + if status_filter != "all": + query = query.where(WorkspaceBugReport.status == status_filter) + query = query.order_by(WorkspaceBugReport.created_at.desc()) + result = await db.execute(query) + reports = result.scalars().all() + + if not reports: + return f"No bug reports with status '{status_filter}'." + + lines = [f"Bug reports ({status_filter}):\n"] + for r in reports: + proj_slug = r.project.slug if r.project else "unknown" + lines.append( + f"- [{r.id}] Project: {proj_slug}\n" + f" Source: {r.source} | Status: {r.status}\n" + f" Description: {r.description[:200]}\n" + f" Created: {r.created_at}\n" + ) + return "\n".join(lines) + + +async def tool_resolve_bug(arguments: dict) -> str: + """Mark a bug report as fixed.""" + bug_id = arguments.get("bug_report_id", "").strip() + if not bug_id: + return "Error: 'bug_report_id' is required." + + try: + bug_uuid = uuid.UUID(bug_id) + except ValueError: + return f"Error: Invalid UUID '{bug_id}'." + + async with async_session() as db: + report = await db.get(WorkspaceBugReport, bug_uuid) + if not report: + return f"Error: Bug report '{bug_id}' not found." + report.status = "fixed" + await db.commit() + + return f"Bug report {bug_id} marked as fixed." + + +async def tool_report_workspace_bug( + agent_id: uuid.UUID, arguments: dict +) -> str: + """Report a bug on a workspace project (agent-initiated).""" + slug = arguments.get("slug", "").strip() + description = arguments.get("description", "").strip() + + if not slug or not description: + return "Error: 'slug' and 'description' are required." + + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where( + WorkspaceProject.slug == slug, + WorkspaceProject.status == "deployed", + ) + ) + project = result.scalar_one_or_none() + if not project: + return f"Error: No deployed project found with slug '{slug}'." + + report = WorkspaceBugReport( + project_id=project.id, + source="user_report", + description=description[:2000], + ) + db.add(report) + await db.commit() + + return f"Bug report created for project '{slug}'. The Software Engineer agent will investigate." + + +async def approve_container_deploy(slug: str, resource_limits: dict | None = None) -> dict: + """Approve and execute a container deployment. Returns status dict.""" + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + project = result.scalar_one_or_none() + if not project: + return {"ok": False, "error": f"Project '{slug}' not found."} + if project.status != "awaiting_approval": + return {"ok": False, "error": f"Project '{slug}' is not awaiting approval (status: {project.status})."} + + # Override resource limits if provided + if resource_limits: + project.resource_limits = resource_limits + + project.status = "building" + agent_id = project.built_by + port = project.container_port + limits = project.resource_limits or {} + await db.commit() + + # Find the Dockerfile in the agent's workspace + agent_ws = Path(settings.AGENT_DATA_DIR) / str(agent_id) + # Search for Dockerfile in workspace subdirectories + dockerfile_candidates = list(agent_ws.glob(f"workspace/{slug}/**/Dockerfile")) + \ + list(agent_ws.glob(f"workspace/**/Dockerfile")) + if not dockerfile_candidates: + # Also check for any Dockerfile in the workspace + dockerfile_candidates = list(agent_ws.glob("workspace/**/Dockerfile")) + + if not dockerfile_candidates: + async with async_session() as db: + proj = await db.get(WorkspaceProject, (await db.execute( + select(WorkspaceProject.id).where(WorkspaceProject.slug == slug) + )).scalar_one()) + proj.status = "failed" + await db.commit() + return {"ok": False, "error": "No Dockerfile found in agent workspace."} + + dockerfile_path = dockerfile_candidates[0] + build_context = dockerfile_path.parent + + # Build and start container + try: + client = _get_docker_client() + container_name = f"ws-{slug}" + image_tag = f"ws-{slug}:latest" + + # Build the image + logger.info("Building Docker image '%s' from %s", image_tag, build_context) + image, build_logs = client.images.build( + path=str(build_context), + tag=image_tag, + rm=True, + ) + for log_line in build_logs: + if "stream" in log_line: + logger.debug("Build: %s", log_line["stream"].strip()) + + # Stop/remove existing container if any + try: + old = client.containers.get(container_name) + old.stop(timeout=10) + old.remove() + except Exception: + pass + + # Prepare container kwargs + container_kwargs = { + "image": image_tag, + "name": container_name, + "hostname": container_name, + "detach": True, + "restart_policy": {"Name": "unless-stopped"}, + "network": "workspace", + } + + # Apply resource limits + if limits.get("memory"): + container_kwargs["mem_limit"] = limits["memory"] + if limits.get("cpus"): + container_kwargs["nano_cpus"] = int(float(limits["cpus"]) * 1e9) + + # Start the container + logger.info("Starting container '%s'", container_name) + container = client.containers.run(**container_kwargs) + + client.close() + except Exception as e: + logger.exception("Failed to build/start container for '%s'", slug) + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + proj = result.scalar_one() + proj.status = "failed" + await db.commit() + return {"ok": False, "error": f"Docker build/start failed: {e}"} + + # Write nginx conf snippet + conf_content = f"""location /workspace/{slug}/ {{ + proxy_pass http://{container_name}:{port}/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +}} +""" + conf_path = Path(settings.WORKSPACE_CONF_DIR) / f"{slug}.conf" + conf_path.write_text(conf_content, encoding="utf-8") + logger.info("Wrote nginx conf for '%s'", slug) + + # Reload gateway + await _reload_gateway() + + # Update project record + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + proj = result.scalar_one() + proj.status = "deployed" + proj.container_id = container.id + proj.container_image = image_tag + await db.commit() + + # Regenerate index + try: + from app.services.workspace_index import regenerate_index + await regenerate_index() + except Exception: + logger.exception("Index regeneration failed after container deploy") + + return { + "ok": True, + "slug": slug, + "container_name": container_name, + "url": f"/workspace/{slug}/", + } + + +async def reject_container_deploy(slug: str) -> dict: + """Reject a container deployment request.""" + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + project = result.scalar_one_or_none() + if not project: + return {"ok": False, "error": f"Project '{slug}' not found."} + if project.status != "awaiting_approval": + return {"ok": False, "error": f"Project '{slug}' is not awaiting approval (status: {project.status})."} + project.status = "rejected" + await db.commit() + return {"ok": True, "slug": slug, "status": "rejected"} + + +async def _reload_gateway() -> None: + """Reload the workspace gateway nginx config.""" + try: + client = _get_docker_client() + container = client.containers.get(settings.WORKSPACE_GATEWAY_CONTAINER) + container.exec_run("nginx -s reload") + client.close() + logger.info("Workspace gateway nginx reloaded") + except Exception: + logger.exception("Failed to reload workspace gateway nginx") From adc7c8cd880fe7c1dbeaa51de33845b203090ad1 Mon Sep 17 00:00:00 2001 From: Jiayi Chen Date: Mon, 23 Mar 2026 00:29:10 +0100 Subject: [PATCH 005/172] feat(workspace): add public bug report and container approve/reject API - POST /api/workspace/projects/{slug}/report-bug (public, rate-limited, honeypot) - POST /api/workspace/projects/{slug}/approve (builds Docker image, starts container) - POST /api/workspace/projects/{slug}/reject - GET /api/workspace/projects (list all projects) --- backend/app/api/workspace.py | 133 +++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 backend/app/api/workspace.py diff --git a/backend/app/api/workspace.py b/backend/app/api/workspace.py new file mode 100644 index 000000000..0943ee3ac --- /dev/null +++ b/backend/app/api/workspace.py @@ -0,0 +1,133 @@ +"""Public workspace API routes (bug reports, project listing).""" + +import logging +import time +from collections import defaultdict + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.workspace import WorkspaceBugReport, WorkspaceProject +from app.services.workspace_tools import approve_container_deploy, reject_container_deploy + +logger = logging.getLogger(__name__) + +# Public router — no auth required, registered without API prefix +public_router = APIRouter(tags=["workspace-public"]) + +# Authenticated router — under /api/workspace +router = APIRouter(prefix="/workspace", tags=["workspace"]) + +# Simple in-memory rate limiter: {ip: [timestamps]} +_rate_limit: dict[str, list[float]] = defaultdict(list) +RATE_LIMIT_MAX = 5 +RATE_LIMIT_WINDOW = 3600 # 1 hour + + +class BugReportRequest(BaseModel): + description: str + website: str = "" # honeypot field + + +def _check_rate_limit(ip: str) -> bool: + """Return True if request is within rate limit.""" + now = time.time() + _rate_limit[ip] = [t for t in _rate_limit[ip] if now - t < RATE_LIMIT_WINDOW] + if len(_rate_limit[ip]) >= RATE_LIMIT_MAX: + return False + _rate_limit[ip].append(now) + return True + + +@public_router.post("/api/workspace/projects/{slug}/report-bug") +async def report_bug( + slug: str, + body: BugReportRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Public endpoint for reporting bugs on workspace projects.""" + # Honeypot check + if body.website: + # Bot detected — return 200 silently to not reveal the trap + return {"status": "ok"} + + # Rate limit + client_ip = request.client.host if request.client else "unknown" + if not _check_rate_limit(client_ip): + raise HTTPException(status_code=429, detail="Too many reports. Try again later.") + + # Find project + result = await db.execute( + select(WorkspaceProject).where( + WorkspaceProject.slug == slug, + WorkspaceProject.status == "deployed", + ) + ) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="Project not found.") + + # Create bug report + report = WorkspaceBugReport( + project_id=project.id, + source="user_report", + description=body.description[:2000], # cap length + ) + db.add(report) + await db.commit() + + logger.info("Bug report created for project '%s' from %s", slug, client_ip) + return {"status": "ok", "message": "Report submitted. Thank you!"} + + +class ApproveRequest(BaseModel): + memory: str = "" + cpus: str = "" + + +@router.post("/projects/{slug}/approve") +async def approve_deploy(slug: str, body: ApproveRequest | None = None): + """Approve a container deployment (authenticated, under /api/workspace).""" + limits = {} + if body: + if body.memory: + limits["memory"] = body.memory + if body.cpus: + limits["cpus"] = body.cpus + result = await approve_container_deploy(slug, limits if limits else None) + if not result["ok"]: + raise HTTPException(status_code=400, detail=result["error"]) + return result + + +@router.post("/projects/{slug}/reject") +async def reject_deploy(slug: str): + """Reject a container deployment (authenticated, under /api/workspace).""" + result = await reject_container_deploy(slug) + if not result["ok"]: + raise HTTPException(status_code=400, detail=result["error"]) + return result + + +@router.get("/projects") +async def list_projects(db: AsyncSession = Depends(get_db)): + """List all workspace projects.""" + result = await db.execute( + select(WorkspaceProject).order_by(WorkspaceProject.created_at.desc()) + ) + projects = result.scalars().all() + return [ + { + "slug": p.slug, + "name": p.name, + "status": p.status, + "deploy_type": p.deploy_type, + "container_port": p.container_port, + "url": f"/workspace/{p.slug}/" if p.status == "deployed" else None, + } + for p in projects + ] From d6adb911cf9e09ba40a160ea6e62f1ca9e294f7e Mon Sep 17 00:00:00 2001 From: Jiayi Chen Date: Mon, 23 Mar 2026 00:29:18 +0100 Subject: [PATCH 006/172] feat(workspace): register 9 workspace tools for agents Add tool definitions (AGENT_TOOLS), dispatch wiring (execute_tool), and DB seeding (BUILTIN_TOOLS) for: request_build, list_build_requests, deploy_static, request_container_deploy, list_workspace_projects, undeploy_project, get_bug_reports, resolve_bug, report_workspace_bug. --- backend/app/services/agent_tools.py | 199 ++++++++++++++++++++++++++ backend/app/services/tool_seeder.py | 208 ++++++++++++++++++++++++++++ 2 files changed, 407 insertions(+) diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index 47a17cfa2..b2f6bb6e9 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -26,6 +26,7 @@ from app.database import async_session from app.models.task import Task from app.config import get_settings +from app.services import workspace_tools _settings = get_settings() WORKSPACE_ROOT = Path(_settings.AGENT_DATA_DIR) @@ -878,6 +879,186 @@ }, }, }, + { + "type": "function", + "function": { + "name": "request_build", + "description": "Create a build request for the Software Engineer agent. Any agent or human can use this to request a new website, tool, or application to be built and deployed.", + "parameters": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "URL-friendly project identifier (lowercase, hyphens allowed, 2-50 chars). This becomes the URL path: /workspace/{slug}/", + }, + "name": { + "type": "string", + "description": "Human-readable project name", + }, + "description": { + "type": "string", + "description": "Detailed description of what to build, including requirements, target audience, and any design preferences", + }, + }, + "required": ["slug", "name", "description"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "list_build_requests", + "description": "List pending build requests (status=requested) for the Software Engineer agent to pick up.", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + }, + { + "type": "function", + "function": { + "name": "deploy_static", + "description": "Deploy a static website (HTML/CSS/JS) from your workspace to the public workspace. Goes live immediately without approval. The files will be served at /workspace/{slug}/.", + "parameters": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "Project slug (must match an existing build request or be a new unique slug)", + }, + "source_dir": { + "type": "string", + "description": "Directory in your workspace containing the built files (e.g., 'workspace/my-project'). Must contain at least an index.html.", + }, + }, + "required": ["slug", "source_dir"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "request_container_deploy", + "description": "Submit a container-based application for deployment. Requires Frank's approval before going live. The application will be built from a Dockerfile in your workspace.", + "parameters": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "Project slug for the URL path /workspace/{slug}/", + }, + "dockerfile_path": { + "type": "string", + "description": "Path to the Dockerfile in your workspace (e.g., 'workspace/my-app/Dockerfile')", + }, + "port": { + "type": "integer", + "description": "Port the application listens on inside the container", + }, + "name": { + "type": "string", + "description": "Human-readable project name", + }, + "description": { + "type": "string", + "description": "What this application does", + }, + "resource_limits_suggestion": { + "type": "object", + "description": "Suggested resource limits (optional). Frank will set final limits at approval.", + "properties": { + "memory": {"type": "string", "description": "e.g., '256m', '512m'"}, + "cpus": {"type": "string", "description": "e.g., '0.5', '1'"}, + }, + }, + }, + "required": ["slug", "dockerfile_path", "port", "name", "description"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "list_workspace_projects", + "description": "List all workspace projects with their current status, deploy type, and URLs.", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + }, + { + "type": "function", + "function": { + "name": "undeploy_project", + "description": "Remove a deployed workspace project. For static sites, deletes files. For containers, stops and removes the container.", + "parameters": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "The project slug to undeploy", + }, + }, + "required": ["slug"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_bug_reports", + "description": "List open bug reports for workspace projects. Use this to find issues that need fixing.", + "parameters": { + "type": "object", + "properties": { + "status_filter": { + "type": "string", + "description": "Filter by status: 'open', 'investigating', 'escalated', or 'all'. Default: 'open'", + }, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "resolve_bug", + "description": "Mark a bug report as fixed after you have redeployed the fix.", + "parameters": { + "type": "object", + "properties": { + "bug_report_id": { + "type": "string", + "description": "The UUID of the bug report to resolve", + }, + }, + "required": ["bug_report_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "report_workspace_bug", + "description": "Report a bug on a deployed workspace project. Creates a bug report for the Software Engineer agent to investigate and fix.", + "parameters": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "The project slug with the bug", + }, + "description": { + "type": "string", + "description": "Detailed description of the bug, including steps to reproduce if possible", + }, + }, + "required": ["slug", "description"], + }, + }, + }, ] @@ -1270,6 +1451,24 @@ async def execute_tool( result = await _publish_page(agent_id, user_id, ws, arguments) elif tool_name == "list_published_pages": result = await _list_published_pages(agent_id) + elif tool_name == "request_build": + result = await workspace_tools.tool_request_build(agent_id, arguments) + elif tool_name == "list_build_requests": + result = await workspace_tools.tool_list_build_requests() + elif tool_name == "deploy_static": + result = await workspace_tools.tool_deploy_static(agent_id, ws, arguments) + elif tool_name == "request_container_deploy": + result = await workspace_tools.tool_request_container_deploy(agent_id, ws, arguments) + elif tool_name == "list_workspace_projects": + result = await workspace_tools.tool_list_workspace_projects() + elif tool_name == "undeploy_project": + result = await workspace_tools.tool_undeploy_project(arguments) + elif tool_name == "get_bug_reports": + result = await workspace_tools.tool_get_bug_reports(arguments) + elif tool_name == "resolve_bug": + result = await workspace_tools.tool_resolve_bug(arguments) + elif tool_name == "report_workspace_bug": + result = await workspace_tools.tool_report_workspace_bug(agent_id, arguments) else: # Try MCP tool execution result = await _execute_mcp_tool(tool_name, arguments, agent_id=agent_id) diff --git a/backend/app/services/tool_seeder.py b/backend/app/services/tool_seeder.py index a8b0e2b64..38622745b 100644 --- a/backend/app/services/tool_seeder.py +++ b/backend/app/services/tool_seeder.py @@ -886,6 +886,214 @@ "config": {}, "config_schema": {}, }, + # --- Workspace: build requests and deployment --- + { + "name": "request_build", + "display_name": "Request Build", + "description": "Create a build request for the Software Engineer agent. Any agent or human can use this to request a new website, tool, or application to be built and deployed.", + "category": "workspace", + "icon": "🔨", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "URL-friendly project identifier (lowercase, hyphens allowed, 2-50 chars). This becomes the URL path: /workspace/{slug}/", + }, + "name": { + "type": "string", + "description": "Human-readable project name", + }, + "description": { + "type": "string", + "description": "Detailed description of what to build, including requirements, target audience, and any design preferences", + }, + }, + "required": ["slug", "name", "description"], + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "list_build_requests", + "display_name": "List Build Requests", + "description": "List pending build requests (status=requested) for the Software Engineer agent to pick up.", + "category": "workspace", + "icon": "📋", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": {}, + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "deploy_static", + "display_name": "Deploy Static Site", + "description": "Deploy a static website (HTML/CSS/JS) from your workspace to the public workspace. Goes live immediately without approval. The files will be served at /workspace/{slug}/.", + "category": "workspace", + "icon": "🚀", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "Project slug (must match an existing build request or be a new unique slug)", + }, + "source_dir": { + "type": "string", + "description": "Directory in your workspace containing the built files (e.g., 'workspace/my-project'). Must contain at least an index.html.", + }, + }, + "required": ["slug", "source_dir"], + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "request_container_deploy", + "display_name": "Request Container Deploy", + "description": "Submit a container-based application for deployment. Requires Frank's approval before going live. The application will be built from a Dockerfile in your workspace.", + "category": "workspace", + "icon": "📦", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "Project slug for the URL path /workspace/{slug}/", + }, + "dockerfile_path": { + "type": "string", + "description": "Path to the Dockerfile in your workspace (e.g., 'workspace/my-app/Dockerfile')", + }, + "port": { + "type": "integer", + "description": "Port the application listens on inside the container", + }, + "name": { + "type": "string", + "description": "Human-readable project name", + }, + "description": { + "type": "string", + "description": "What this application does", + }, + "resource_limits_suggestion": { + "type": "object", + "description": "Suggested resource limits (optional). Frank will set final limits at approval.", + "properties": { + "memory": {"type": "string", "description": "e.g., '256m', '512m'"}, + "cpus": {"type": "string", "description": "e.g., '0.5', '1'"}, + }, + }, + }, + "required": ["slug", "dockerfile_path", "port", "name", "description"], + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "list_workspace_projects", + "display_name": "List Workspace Projects", + "description": "List all workspace projects with their current status, deploy type, and URLs.", + "category": "workspace", + "icon": "📋", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": {}, + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "undeploy_project", + "display_name": "Undeploy Project", + "description": "Remove a deployed workspace project. For static sites, deletes files. For containers, stops and removes the container.", + "category": "workspace", + "icon": "🗑️", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "The project slug to undeploy", + }, + }, + "required": ["slug"], + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "get_bug_reports", + "display_name": "Get Bug Reports", + "description": "List open bug reports for workspace projects. Use this to find issues that need fixing.", + "category": "workspace", + "icon": "🐛", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": { + "status_filter": { + "type": "string", + "description": "Filter by status: 'open', 'investigating', 'escalated', or 'all'. Default: 'open'", + }, + }, + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "resolve_bug", + "display_name": "Resolve Bug", + "description": "Mark a bug report as fixed after you have redeployed the fix.", + "category": "workspace", + "icon": "✅", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": { + "bug_report_id": { + "type": "string", + "description": "The UUID of the bug report to resolve", + }, + }, + "required": ["bug_report_id"], + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "report_workspace_bug", + "display_name": "Report Workspace Bug", + "description": "Report a bug on a deployed workspace project. Creates a bug report for the Software Engineer agent to investigate and fix.", + "category": "workspace", + "icon": "🐞", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "The project slug with the bug", + }, + "description": { + "type": "string", + "description": "Detailed description of the bug, including steps to reproduce if possible", + }, + }, + "required": ["slug", "description"], + }, + "config": {}, + "config_schema": {}, + }, ] From 03f7086c0ac2ab11ea7a92993618fdab6558bc66 Mon Sep 17 00:00:00 2001 From: Jiayi Chen Date: Mon, 23 Mar 2026 00:29:25 +0100 Subject: [PATCH 007/172] feat(workspace): wire models, routes, and health checks into app startup Register workspace models for auto-creation, workspace API routers, and health check background task in the FastAPI lifespan. --- backend/app/main.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/app/main.py b/backend/app/main.py index 7c0b6bd92..0a3ad216c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -72,6 +72,7 @@ async def lifespan(app: FastAPI): from app.services.dingtalk_stream import dingtalk_stream_manager from app.services.wecom_stream import wecom_stream_manager from app.services.discord_gateway import discord_gateway_manager + from app.services.workspace_health import run_health_checks # ── Step 0: Ensure all DB tables exist (idempotent, safe to run on every startup) ── try: @@ -98,6 +99,7 @@ async def lifespan(app: FastAPI): import app.models.trigger # noqa import app.models.notification # noqa import app.models.gateway_message # noqa + import app.models.workspace # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # Add 'atlassian' to channel_type_enum if it doesn't exist yet (idempotent) @@ -208,6 +210,7 @@ def _bg_task_error(t): ("dingtalk_stream", dingtalk_stream_manager.start_all()), ("wecom_stream", wecom_stream_manager.start_all()), ("discord_gw", discord_gateway_manager.start_all()), + ("workspace_health", run_health_checks()), ]: task = asyncio.create_task(coro, name=name) task.add_done_callback(_bg_task_error) @@ -282,6 +285,7 @@ def _bg_task_error(t): from app.api.gateway import router as gateway_router from app.api.admin import router as admin_router from app.api.pages import router as pages_router, public_router as pages_public_router +from app.api.workspace import public_router as workspace_public_router, router as workspace_router app.include_router(auth_router, prefix=settings.API_PREFIX) app.include_router(agents_router, prefix=settings.API_PREFIX) @@ -319,6 +323,8 @@ def _bg_task_error(t): app.include_router(admin_router, prefix=settings.API_PREFIX) app.include_router(pages_router, prefix=settings.API_PREFIX) app.include_router(pages_public_router) # Public endpoint for /p/{short_id}, no API prefix +app.include_router(workspace_public_router) # Public endpoint, no API prefix +app.include_router(workspace_router, prefix=settings.API_PREFIX) @app.get("/api/health", response_model=HealthResponse, tags=["health"]) From 03a664fd147026c997cf03186c932dd71c23a483 Mon Sep 17 00:00:00 2001 From: Jiayi Chen Date: Mon, 23 Mar 2026 14:05:41 +0100 Subject: [PATCH 008/172] feat: mobile responsive design for Dashboard, Chat, Plaza (#1) * feat: add mobile responsive layout shell Add hamburger menu, slide-over sidebar, and full-width main content for viewports <=768px. Desktop layout unchanged. * fix: remove unused isMobile state, close sidebar on account settings click Address code review: simplify closeMobileMenu, add closeMobileMenu to account settings handler so sidebar dismisses on mobile. * feat: make Dashboard responsive for mobile Stats grid goes 2x2, agent table becomes card list, activity column hidden on narrow viewports. * feat: make Chat page responsive for mobile Wider bubbles, smaller avatars, compact input area, dvh-based height for mobile viewport. * feat: make Plaza responsive for mobile Two-column layout stacks vertically, remove avatar indent on narrow viewports, sidebar moves below feed. * fix: address final code review issues - Move display:grid from agent-row-header inline to CSS class so media query display:none works on mobile - Move plaza-sidebar width/position from inline to CSS class, remove !important from media query overrides --- frontend/src/index.css | 219 +++++++++++++++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 22 ++-- frontend/src/pages/Layout.tsx | 52 +++++++- frontend/src/pages/Plaza.tsx | 28 ++-- 4 files changed, 288 insertions(+), 33 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index ed7a26470..f0db98cdf 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1606,3 +1606,222 @@ select:focus { transform: scale(1); } } + +/* ─── Mobile Responsive ─────────────────────────────── */ + +/* Hamburger menu button — hidden on desktop */ +.mobile-menu-btn { + display: none; + position: fixed; + top: 12px; + left: 12px; + z-index: 20; + width: 36px; + height: 36px; + border-radius: var(--radius-md); + background: var(--bg-secondary); + border: 1px solid var(--border-subtle); + color: var(--text-primary); + align-items: center; + justify-content: center; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +/* Sidebar backdrop — hidden on desktop */ +.sidebar-backdrop { + display: none; + position: fixed; + inset: 0; + z-index: 9; + background: rgba(0, 0, 0, 0.5); + -webkit-tap-highlight-color: transparent; +} + +.sidebar-backdrop.visible { + display: block; +} + +/* Mobile top bar with logo — hidden on desktop */ +.mobile-top-bar { + display: none; + align-items: center; + gap: 8px; + padding: 10px 16px 10px 56px; + height: 48px; + box-sizing: border-box; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-primary); + position: sticky; + top: 0; + z-index: 5; +} + +.mobile-top-bar img { + width: 20px; + height: 20px; +} + +.mobile-top-bar span { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.02em; +} + +/* Dashboard responsive classes — desktop baselines */ +.stats-grid { + grid-template-columns: repeat(4, 1fr); +} + +.agent-row-header { + display: grid; + grid-template-columns: 220px 1fr 150px 100px; +} + +.agent-row { + grid-template-columns: 220px 1fr 150px 100px; +} + +.activity-feed-item { + padding: 7px 12px; + font-size: 13px; +} + +/* Plaza responsive classes — desktop baselines */ +.post-content { + padding-left: 40px; +} + +.post-actions { + padding-left: 40px; +} + +.post-composer-footer { + padding-left: 42px; +} + +.post-comments { + padding-left: 40px; +} + +.plaza-stats-grid-item { + padding: 16px 20px; +} + +.plaza-sidebar { + width: 260px; + position: sticky; +} + +@media (max-width: 768px) { + .mobile-menu-btn { + display: flex; + } + + .mobile-top-bar { + display: flex; + } + + .sidebar { + transform: translateX(-100%); + transition: transform 200ms ease; + z-index: 11; + } + + .sidebar.mobile-open { + transform: translateX(0); + } + + .main-content { + margin-left: 0; + width: 100%; + max-width: 100%; + padding: 0 16px 16px; + } + + .page-header { + padding-top: 8px; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .agent-row-header { + display: none; + } + + .agent-row { + grid-template-columns: 1fr; + gap: 4px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); + } + + .agent-row .agent-row-activity { + display: none; + } + + .agent-row .agent-row-meta { + display: flex; + gap: 12px; + align-items: center; + font-size: 12px; + color: var(--text-tertiary); + } + + .activity-feed-item { + padding: 5px 8px; + font-size: 12px; + } + + /* Chat */ + .chat-container { + height: calc(100dvh - 120px); + } + + .chat-bubble { + max-width: 85%; + } + + .chat-avatar { + width: 24px; + height: 24px; + font-size: 11px; + } + + .chat-message { + gap: 8px; + } + + .chat-input-area { + padding: 8px 0; + gap: 8px; + } + + .page-header { + margin-bottom: 12px; + } + + /* Plaza */ + .plaza-layout { + flex-direction: column; + } + + .plaza-sidebar { + width: 100%; + position: static; + } + + .post-content, + .post-actions, + .post-composer-footer, + .post-comments { + padding-left: 0; + } + + .plaza-stats-grid-item { + padding: 12px 14px; + } +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 577ff9ff8..585fa5618 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -131,8 +131,8 @@ function StatsBar({ agents, allTasks }: { agents: Agent[]; allTasks: Task[] }) { ]; return ( -
navigate(`/agents/${agent.id}`)} + className="agent-row" style={{ display: 'grid', - gridTemplateColumns: '220px 1fr 150px 100px', alignItems: 'center', gap: '16px', padding: '12px 16px', borderRadius: 'var(--radius-md)', @@ -229,7 +229,7 @@ function AgentRow({ agent, tasks, recentActivity }: {
{/* Latest Activity / Tasks */} -
+
{latestActivity ? (
{/* Token Usage */} -
+
{formatTokens(usedTokens)} {maxTokens > 0 && / {formatTokens(maxTokens)}} @@ -290,7 +290,7 @@ function AgentRow({ agent, tasks, recentActivity }: {
{/* Last Active */} -
+
{timeAgo(agent.last_active_at, t)}
@@ -316,9 +316,9 @@ function ActivityFeed({ activities, agents }: { activities: any[]; agents: Agent {activities.map((act, i) => { const agent = agentMap.get(act.agent_id); return ( -
{/* Agent List Header */} -
{ + const mq = window.matchMedia('(max-width: 768px)'); + const handler = (e: MediaQueryListEvent | MediaQueryList) => { + if (!e.matches) setIsMobileMenuOpen(false); + }; + handler(mq); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + + const closeMobileMenu = () => setIsMobileMenuOpen(false); + // Use user's own tenant_id directly (no switching) const currentTenant = user?.tenant_id || ''; @@ -301,7 +316,24 @@ export default function Layout() { return (
-