Make dev workspaces serve a working browser IDE end to end

The workspace feature shipped its routes, agent tools and docs, but the
editor was never reachable: the project page had no entry point, the ppy
image had no code-server binary, no certificate was ever requested for a
tunnel, and both nginx and the proxy dropped what the editor needs.

- Add a VS Code button to the project action row and a Workspace item to
  the overflow menu, gated by can_open_workspace plus a running instance
  (viewer_can_workspace and workspace_editor_url on ProjectDetailOut).
- Install a pinned code-server in ppy.Dockerfile before USER pravda and
  assert it in the build smoke test, so an image that cannot run the
  editor no longer builds green.
- Run the editor with --auth password and a per workspace 8 character
  pronounceable secret, minted once at the ensure_editor_password choke
  point and injected as PASSWORD. Keep it off WorkspaceViewOut, which the
  admin listing shares.
- Publish the editor tunnel when a workspace is created and issue its
  certificate from a new WorkspaceService phase against the molohttp admin
  API, then notify the owner with the live URL and the password. Only
  pending rows are retried, so a broken host cannot burn the ACME failure
  rate limit. Renewal stays molohttp's job.
- Forward the original Host on proxied requests and the client cookie on
  proxied websockets, so code-server scopes its session cookie to the
  public hostname and authenticates the workbench socket.
- Recreate a container stuck in the created state instead of retrying
  docker start forever against an image it can no longer run.
- Return a JSON string from WorkspaceController.dispatch; raw dicts landed
  in a tool message and aborted the turn at the model endpoint.
- Let the nginx catch-all carry websocket upgrades, keeping upstream
  keepalive, so tunnelled apps and the editor both connect.
This commit is contained in:
2026-08-07 13:46:40 +02:00
parent 21f6ae0615
commit 192df12b1d
25 changed files with 716 additions and 26 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ This file documents the project detail page, the per-project virtual filesystem,
Each project card links to `/projects/{project_uid}` showing full project details, author info, platforms, star count, delete-for-owner, and (for the owner) Private/Read-only toggle buttons plus badges (see **Project visibility and read-only** below). The route is `GET /projects/{project_uid}` in `routers/projects/index.py` and 404s when the viewer cannot see a private project. The sitemap generator links to this URL (not the old `?user_uid=` query param). The detail page also links to the project filesystem at `/projects/{slug}/files`.
**Action row overflow.** The detail page has more actions than fit one line, so `project_detail.html` keeps the engagement actions inline (Files, Share, star vote, bookmark, reactions) and collapses the rest behind a single **More** button (`.project-actions-more`) that opens the shared `app.contextMenu`. The secondary actions (Containers, Download zip, Fork, the owner Edit/Private/Read-only/Delete controls) live as real elements inside a hidden `.project-actions-overflow` container, each tagged `data-menu-action` plus `data-menu-icon`/`data-menu-label`. `static/js/ProjectActionsMenu.js` builds the menu from those elements and each item's `onSelect` simply `.click()`s the real element, so all existing wiring is reused unchanged - `app.zipDownloader` (`data-zip-download`), `app.projectForker` (`data-fork-project`), `data-share`, the `data-modal` Edit trigger, and the delegated `data-confirm`/`data-confirm-danger` dialog on the owner forms. The open handler must `stopPropagation()` because `app.contextMenu`'s document-level close listener would otherwise dismiss it on the same click (every other caller opens it from a right-click `attach`, not a left-click). Reuse this pattern - a `More` trigger over `[data-menu-action]` real elements - for any future action row that overflows; do not duplicate controller logic into menu callbacks.
**Action row overflow.** The detail page has more actions than fit one line, so `project_detail.html` keeps the engagement actions inline (Files, Share, star vote, bookmark, reactions) and collapses the rest behind a single **More** button (`.project-actions-more`) that opens the shared `app.contextMenu`. The secondary actions (Workspace, Containers, Download zip, Fork, the owner Edit/Private/Read-only/Delete controls) live as real elements inside a hidden `.project-actions-overflow` container, each tagged `data-menu-action` plus `data-menu-icon`/`data-menu-label`. `static/js/ProjectActionsMenu.js` builds the menu from those elements and each item's `onSelect` simply `.click()`s the real element, so all existing wiring is reused unchanged - `app.zipDownloader` (`data-zip-download`), `app.projectForker` (`data-fork-project`), `data-share`, the `data-modal` Edit trigger, and the delegated `data-confirm`/`data-confirm-danger` dialog on the owner forms. The open handler must `stopPropagation()` because `app.contextMenu`'s document-level close listener would otherwise dismiss it on the same click (every other caller opens it from a right-click `attach`, not a left-click). Reuse this pattern - a `More` trigger over `[data-menu-action]` real elements - for any future action row that overflows; do not duplicate controller logic into menu callbacks.
**Owner editing.** Mirrors post editing exactly: an owner-only **Edit** menu item (`data-modal="edit-project-modal"`) opens the `modal()` macro's `edit-project-modal`, a plain `POST` form to `/projects/edit/{slug}` (route `edit_project` in `routers/projects/index.py`, body `ProjectEditForm`, owner-gated through the shared `content.edit_content_item` which returns 403 JSON / redirect for non-owners and stamps `updated_at`). The modal is the create modal pre-filled from the `project` row (title, description, type/status radios pre-checked, dates via `format_date()` back to DD/MM/YYYY). `is_private`/`read_only` are NOT edited here - they stay on their dedicated toggles. The platforms tag widget reuses the create modal's `platforms-input`/`platforms`/`platforms-tags` ids; `ProfileEditor.initPlatformTags` now **seeds existing tags** from the hidden `#platforms` value on load, so both the empty create form and the pre-filled edit form work from the same code. Devii tool `edit_project`; documented in `docs_api.py` (`projects-edit`).
@@ -14,7 +14,7 @@ from devplacepy.models import TunnelForm
from devplacepy.responses import action_result, json_error, respond
from devplacepy.schemas import WorkspaceOut
from devplacepy.services.audit import record as audit
from devplacepy.services.containers import activity, forward, store
from devplacepy.services.containers import activity, api, forward, store
from devplacepy.services.containers.workspace import provision, quota, tunnels
from devplacepy.services.containers.workspace.provision import WorkspaceError
from devplacepy.utils import not_found, require_user
@@ -75,6 +75,9 @@ async def workspace_page(request: Request, slug: str):
if instance
else ""
),
"editor_password": (
api.ensure_editor_password(instance) if instance else ""
),
"user": user,
}
return respond(request, "workspace.html", context, model=WorkspaceOut)
+20
View File
@@ -39,6 +39,7 @@ from devplacepy.content import (
is_owner,
can_view_project,
can_view_project_containers,
can_open_workspace,
get_project_devlog,
)
from devplacepy.utils import (
@@ -175,6 +176,19 @@ async def projects_page(
model=ProjectsOut,
)
def _editor_url(project: dict, user: dict) -> str:
from devplacepy.services.containers import store
from devplacepy.services.containers.workspace import provision
instance = provision.find_for_project(project["uid"], user["uid"])
if not instance or instance.get("suspended_at"):
return ""
if instance.get("status") != store.ST_RUNNING:
return ""
slug = project["slug"] or project["uid"]
return f"/projects/{slug}/containers/instances/{instance['uid']}/code/"
@router.get("/{project_slug}", response_class=HTMLResponse)
async def project_detail(request: Request, project_slug: str, before: str = None):
user = get_current_user(request)
@@ -211,6 +225,10 @@ async def project_detail(request: Request, project_slug: str, before: str = None
],
schemas=[website_schema(base), software_application_schema(project, base)],
)
viewer_can_workspace = can_open_workspace(project, user)
workspace_editor_url = (
_editor_url(project, user) if viewer_can_workspace else ""
)
parent = get_fork_parent(project["uid"])
forked_from = (
{
@@ -256,6 +274,8 @@ async def project_detail(request: Request, project_slug: str, before: str = None
"is_private": bool(project.get("is_private")),
"read_only": bool(project.get("read_only")),
"viewer_can_containers": can_view_project_containers(project, user),
"viewer_can_workspace": viewer_can_workspace,
"workspace_editor_url": workspace_editor_url,
"forked_from": forked_from,
"fork_count": count_forks(project["uid"]),
"file_count": count_files(project["uid"]),