From 3d07a478c38f1519ca59a084deea9758c8ab8c44 Mon Sep 17 00:00:00 2001 From: retoor Date: Thu, 3 Sep 2026 08:47:43 +0200 Subject: [PATCH] Update --- README.md | 31 ++- devplacepy/database/schema.py | 43 +++- devplacepy/docs_api/groups/workspaces.py | 12 +- .../routers/projects/containers/workspace.py | 12 +- devplacepy/routers/projects/index.py | 5 +- devplacepy/schemas/containers.py | 4 + devplacepy/services/CLAUDE.md | 3 +- devplacepy/services/containers/CLAUDE.md | 137 +++++++++- devplacepy/services/containers/api.py | 8 + .../vscode/devplace-workspace/extension.js | 243 ++++++++++++++++-- .../devplace-workspace/media/welcome.css | 168 ++++++++++++ .../devplace-workspace/media/welcome.html | 132 ++++++++++ .../vscode/devplace-workspace/package.json | 16 +- .../devplace-workspace/walkthrough/agent.md | 5 +- devplacepy/services/containers/forward.py | 21 +- .../services/containers/workspace/editor.py | 2 +- .../containers/workspace/provision.py | 47 ++++ .../services/containers/workspace_service.py | 8 +- .../devii/actions/workspace_actions.py | 4 +- devplacepy/services/live_view_relay.py | 24 ++ devplacepy/static/css/workspace.css | 37 +++ devplacepy/static/js/WorkspaceManager.js | 89 +++++-- devplacepy/templates/_comment.html | 2 +- devplacepy/templates/_poll.html | 6 +- devplacepy/templates/_post_card.html | 4 +- devplacepy/templates/_post_votes.html | 2 +- devplacepy/templates/_quiz_card.html | 4 +- devplacepy/templates/_reaction_bar.html | 2 +- devplacepy/templates/_star_vote.html | 2 +- .../templates/docs/workspace-editor.html | 51 +++- devplacepy/templates/issues.html | 2 + devplacepy/templates/landing.html | 2 - devplacepy/templates/profile.html | 2 + devplacepy/templates/project_detail.html | 6 +- devplacepy/templates/workspace.html | 69 +++-- tests/api/containers.py | 56 ++++ tests/api/projects/workspace.py | 73 ++++++ tests/e2e/projects/workspace.py | 124 ++++++++- tests/unit/services/containers/forward.py | 45 ++++ .../containers/workspace/provision.py | 178 +++++++++++++ tests/unit/services/live_view_relay.py | 92 +++++++ 41 files changed, 1643 insertions(+), 130 deletions(-) create mode 100644 devplacepy/services/containers/files/vscode/devplace-workspace/media/welcome.css create mode 100644 devplacepy/services/containers/files/vscode/devplace-workspace/media/welcome.html create mode 100644 tests/unit/services/containers/forward.py create mode 100644 tests/unit/services/containers/workspace/provision.py create mode 100644 tests/unit/services/live_view_relay.py diff --git a/README.md b/README.md index 083b7ab..898c842 100644 --- a/README.md +++ b/README.md @@ -474,7 +474,17 @@ A **workspace** is a member-facing container running the DevPlace browser editor container runtime above. It is opened from a project's **Workspace** page and reached at `/projects/{slug}/workspace`; the editor itself is proxied at `/projects/{slug}/containers/instances/{uid}/code/`, and an **Editor** button appears on the project -page whenever the workspace is running. +page whenever the editor is actually reachable. + +**The workspace page reports the editor's real state, live.** A workspace has a *phase* derived on +the server from its desired state, its container status and a TCP probe of the editor port: +`stopped`, `starting`, `ready`, `stopping`, `crashed` or `suspended`. The page renders the control +that matches the phase, so pressing **Start** turns the button into a spinner reading *Starting the +editor* and the **Open editor** link appears only once code-server answers, never while the +container is still booting. While a workspace is in transition the page polls every two seconds +(twenty seconds otherwise) and also receives pushed updates on the owner's private pub/sub topic, so +the label changes on its own without a reload; the project page's **Editor** button follows the same +readiness rule. The phase, its label and `editor_ready` are part of the workspace JSON. The editor is `code-server`, rebranded as DevPlace end to end: the application name, the browser tab icon and PWA icons, the login page styling, and `product.json` all carry DevPlace, and a bundled @@ -487,10 +497,21 @@ coding agent baked into the image, and a plain login shell beside it with the Py Swift toolchains on `PATH`. Both are configurable, and `bash` stays the default profile for terminals the member opens later. -The workspace opens straight onto the member's files rather than a welcome page, and the editor's -own built-in chat assistant is suppressed so `dpc` is the only agent on offer and every token it -spends is ledgered against the member's DevPlace account. `dpc`'s own working files (`.dpc/`, -`dpc.log`) are in `SYNC_SKIP_NAMES`, so running an agent on every boot never pollutes the project. +**The first boot of a workspace opens the DevPlace welcome page** beside the terminals: a webview +introducing the workspace and DevPlace Code (its 900k token context, vision, parallel sub-agents, +deep research, safety gates and the daily credits it runs on), with example prompts and buttons that +focus the agent terminal, start the walkthrough, show the public tunnels and open the workspace +settings. It is shown once per workspace and can be reopened with **DevPlace: Show the welcome +page**. The editor's own built-in chat assistant and VS Code's own welcome page stay suppressed so +`dpc` is the only agent on offer and every token it spends is ledgered against the member's DevPlace +account. `dpc`'s own working files (`.dpc/`, `dpc.log`) are in `SYNC_SKIP_NAMES`, so running an +agent on every boot never pollutes the project. + +**The terminal panel gets about a third of the window by default.** Every preset is a fixed number +of steps up from the panel's minimum height (`normal`, the default, lands at roughly a third of a +typical window; `short` at a fifth, `tall` at about half) and `maximized` fills the editor area. +A preset is applied on the first boot of a workspace and again whenever it changes; a height the +member drags themselves is kept across restarts. **Every workspace is trusted.** VS Code Restricted Mode is disabled at the command line and in the seeded settings, so nothing prompts and automatic tasks run. This is a deliberate default with a diff --git a/devplacepy/database/schema.py b/devplacepy/database/schema.py index db78763..8c9db8c 100644 --- a/devplacepy/database/schema.py +++ b/devplacepy/database/schema.py @@ -549,7 +549,48 @@ def init_db(): ["owner_kind", "owner_id", "scope", "lang"], ) gateway_usage_ledger = get_table("gateway_usage_ledger") - for column, example in (("ttft_ms", 0.0), ("inter_token_ms", 0.0)): + for column, example in ( + ("created_at", ""), + ("owner_kind", ""), + ("owner_id", ""), + ("backend", ""), + ("endpoint", ""), + ("requested_model", ""), + ("model", ""), + ("status_code", 0), + ("success", 0), + ("error_category", ""), + ("upstream_latency_ms", 0.0), + ("gateway_overhead_ms", 0.0), + ("queue_wait_ms", 0.0), + ("connect_ms", 0.0), + ("total_latency_ms", 0.0), + ("prompt_tokens", 0), + ("completion_tokens", 0), + ("cache_hit_tokens", 0), + ("cache_miss_tokens", 0), + ("reasoning_tokens", 0), + ("total_tokens", 0), + ("tokens_per_second", 0.0), + ("context_window", 0), + ("context_utilization", 0.0), + ("cost_usd", 0.0), + ("input_cost_usd", 0.0), + ("output_cost_usd", 0.0), + ("native_cost", 0), + ("stream_requested", 0), + ("temperature", 0.0), + ("top_p", 0.0), + ("max_tokens", 0), + ("has_tools", 0), + ("retries_attempted", 0), + ("retry_succeeded", 0), + ("circuit_open", 0), + ("user_agent", ""), + ("app_reference", ""), + ("ttft_ms", 0.0), + ("inter_token_ms", 0.0), + ): if not gateway_usage_ledger.has_column(column): gateway_usage_ledger.create_column_by_example(column, example) _index(db, "gateway_usage_ledger", "idx_gw_usage_time", ["created_at"]) diff --git a/devplacepy/docs_api/groups/workspaces.py b/devplacepy/docs_api/groups/workspaces.py index b77e793..2a4545a 100644 --- a/devplacepy/docs_api/groups/workspaces.py +++ b/devplacepy/docs_api/groups/workspaces.py @@ -37,7 +37,10 @@ arrives as a `workspace` notification and states exactly what happens next and w title="Read workspace", summary=( "State, quota usage, idle countdown, tunnels and open moderation flags " - "for your workspace on this project." + "for your workspace on this project. The phase (stopped, starting, ready, " + "stopping, crashed, suspended) is derived from the desired state, the " + "container status and a live probe of the editor port, so editor_ready " + "is true only when the editor will actually open." ), auth="user", params=[ @@ -51,7 +54,12 @@ arrives as a `workspace` notification and states exactly what happens next and w "editor_url": "/projects/my-project/containers/instances/INSTANCE_UID/code/", "workspace": { "uid": "INSTANCE_UID", + "owner_uid": "USER_UID", "status": "running", + "desired_state": "running", + "phase": "ready", + "phase_label": "Ready", + "editor_ready": True, "suspended": False, "tunnel_name": "brave-otter", "primary_url": "https://brave-otter.tunnel.pravda.education", @@ -131,7 +139,7 @@ arrives as a `workspace` notification and states exactly what happens next and w "terminal_font_size": 13, "zoom_level": 0, "layout": "standard", - "panel_preset": "tall", + "panel_preset": "normal", "boot_agent": "dpc", "boot_shell": True, "window_mode": "tab", diff --git a/devplacepy/routers/projects/containers/workspace.py b/devplacepy/routers/projects/containers/workspace.py index 2ca833b..6b11791 100644 --- a/devplacepy/routers/projects/containers/workspace.py +++ b/devplacepy/routers/projects/containers/workspace.py @@ -311,12 +311,18 @@ async def editor_proxy(request: Request, slug: str, uid: str, path: str = ""): if denial is not None: return denial if instance.get("suspended_at"): - return Response("this workspace is suspended", status_code=403) + return Response( + "this workspace is suspended", status_code=403, media_type="text/plain" + ) if instance.get("status") != store.ST_RUNNING: - return Response("this workspace is not running", status_code=409) + return Response( + "this workspace is not running", status_code=409, media_type="text/plain" + ) host, port = provision.editor_target(instance) if not host or not port: - return Response("the editor has no reachable port", status_code=502) + return Response( + "the editor has no reachable port", status_code=502, media_type="text/plain" + ) activity.touch(instance["uid"]) prefix = f"/projects/{slug}/containers/instances/{uid}/code" return await forward.proxy_http(request, host, port, path, prefix=prefix) diff --git a/devplacepy/routers/projects/index.py b/devplacepy/routers/projects/index.py index 5f63709..6c5c7c0 100644 --- a/devplacepy/routers/projects/index.py +++ b/devplacepy/routers/projects/index.py @@ -188,14 +188,11 @@ async def projects_page( ) def _editor_launch(project: dict, user: dict) -> dict: - from devplacepy.services.containers import store from devplacepy.services.containers.workspace import editor, provision blank = {"url": "", "mode": "tab", "width": 0, "height": 0} instance = provision.find_for_project(project["uid"], user["uid"]) - if not instance or instance.get("suspended_at"): - return blank - if instance.get("status") != store.ST_RUNNING: + if not instance or not provision.editor_ready(instance): return blank slug = project["slug"] or project["uid"] profile = editor.resolve(user["uid"], instance) diff --git a/devplacepy/schemas/containers.py b/devplacepy/schemas/containers.py index 63d7f90..00a2456 100644 --- a/devplacepy/schemas/containers.py +++ b/devplacepy/schemas/containers.py @@ -155,8 +155,12 @@ class EditorProfileOut(_Out): class WorkspaceViewOut(_Out): uid: str = "" name: str = "" + owner_uid: str = "" status: str = "" desired_state: str = "" + phase: str = "" + phase_label: str = "" + editor_ready: bool = False suspended: bool = False flag_reason: Optional[str] = "" tunnel_name: Optional[str] = "" diff --git a/devplacepy/services/CLAUDE.md b/devplacepy/services/CLAUDE.md index 0c15e7c..d968539 100644 --- a/devplacepy/services/CLAUDE.md +++ b/devplacepy/services/CLAUDE.md @@ -162,8 +162,9 @@ Rules: a new hot read-path aggregate follows this exact pattern (module-level `T | `admin.services.{name}` | 5s | `{service}` | | `admin.ai-usage.{hours}` | 15s | `build_analytics(hours)` (hours parsed from the topic) | | `admin.backups` | 8s | `routers/admin/backups._dashboard(can_download=False)` (storage, backups, schedules, metrics) | +| `user.{owner_uid}.workspace.{uid}` | 3s | `{workspace, editor_url}` (`provision.view`, the same shape `GET /projects/{slug}/workspace` JSON carries; `None` unless the instance is a workspace owned by `owner_uid`) | -All these topics are admin-only by pub/sub policy (non-`public`, non-`user.{uid}` -> `privileged` required), matching the admin-only pages. Frontend monitors (`ContainerInstance`, `ContainerList`, `ContainerManager`, `BotMonitor`, `ServiceMonitor`, `AiUsageMonitor`, `BackupMonitor`) each `window.app.pubsub.subscribe(topic, render)` in their init and keep a **lengthened HTTP poll (15-30s) as initial-load + fallback** - the relay drives liveness at the cadence above. `AiUsageMonitor` re-subscribes (unsubscribe old, subscribe new) when the window-hours selector changes, since hours is in the topic. +All these topics except the last are admin-only by pub/sub policy (non-`public`, non-`user.{uid}` -> `privileged` required), matching the admin-only pages. The workspace topic is the one member-facing view: it sits in the owner's private `user.{uid}.*` namespace so the owner (and admins) can subscribe and nobody else can, and the compute callable re-checks `workspace_owner_uid` against the topic so a guessed uid never leaks another member's workspace. `WorkspaceManager` subscribes to it and keeps a 2s/20s HTTP poll as fallback (see `devplacepy/services/containers/CLAUDE.md`). Frontend monitors (`ContainerInstance`, `ContainerList`, `ContainerManager`, `BotMonitor`, `ServiceMonitor`, `AiUsageMonitor`, `BackupMonitor`) each `window.app.pubsub.subscribe(topic, render)` in their init and keep a **lengthened HTTP poll (15-30s) as initial-load + fallback** - the relay drives liveness at the cadence above. `AiUsageMonitor` re-subscribes (unsubscribe old, subscribe new) when the window-hours selector changes, since hours is in the topic. **Container topics never broadcast private-project instances**: `container.list` publishes only public-project rows with `partial: true` (`ContainerList.merge` updates by uid, never removes, so private rows from the authoritative HTTP poll survive), and `project.{slug}.containers` / `container.{uid}.detail` / `container.{uid}.logs` skip private-project targets entirely (owners fall back to their HTTP polls). diff --git a/devplacepy/services/containers/CLAUDE.md b/devplacepy/services/containers/CLAUDE.md index 62d0a78..b28c7ca 100644 --- a/devplacepy/services/containers/CLAUDE.md +++ b/devplacepy/services/containers/CLAUDE.md @@ -171,6 +171,23 @@ Playwright sessions and finding six terminal tabs. The fallback is now `host-${p extension host, which survives a browser reload and changes when the container restarts, which is exactly the intended semantic. +**The extension's markers live in a FILE on the state mount, never in a memento (load-bearing).** +The six-tab bug was only half fixed by the fallback above: every NEW browser session (a second tab, +a reopen from the project page, an incognito window, a Playwright context) makes code-server start +another extension host process while the previous one lingers for its 3h reconnection grace, and the +new host found no marker, booted again, and the tab list grew by one shell per session - measured on +the real image with the shipped extension (one, then two, then three `pravda@workspace` tabs across +three sessions, three `Starting extension host process` lines in the container log). Neither +`workspaceState` nor `globalState` can carry the marker: in code-server's web workbench both are +proxied to the BROWSER's own storage (there is no `state.vscdb` anywhere under the state dir - only +per-window `workspaceStorage/[-N]/vscode.lock` folders), so a memento is per browser profile, +and a member's second device or a fresh context has never heard of the boot. The `Memory` class +therefore keeps `devplace.bootMarker`, `devplace.panelPreset` and `devplace.welcomeShown` in +`{DEVPLACE_WORKSPACE_STATE_DIR}/devplace-extension.json` - the one store whose lifetime is the +workspace itself, read fresh on every `get` so concurrent hosts see each other's writes - and falls +back to `globalState` only when the env var is absent (a non-DevPlace launch). Verified: three +sessions, one shell, one welcome; a new container boot with a changed preset re-applies the layout. + **Trust is disabled at three layers** and gated by one kill switch, `workspace_editor_trust_all` (default on): the `--disable-workspace-trust` flag, the seeded `security.workspace.trust.*` settings, and the extension's `contributes.configurationDefaults`. The third is belt and braces only - @@ -186,6 +203,28 @@ SQLite database. Writing it from the host is rejected. The extension drives `workbench.action.toggleMaximizedPanel` / `increaseViewSize` instead, so the four presets are named honestly as presets in the UI. Do not "improve" this by writing `state.vscdb`. +**The resize commands act on the FOCUSED part, and only the `ViewSize` pair does what its name +says.** Measured on the real image with Playwright (1000px viewport, fresh state, terminal focused): +`increaseViewSize` x4 took the panel from VS Code's default third (333px) to 573px, `decreaseViewSize` +x24 drove it to its 77px floor, and `toggleMaximizedPanel` to 943px - while `increaseViewHeight` x4 +CRUSHED the panel to 93px and `decreaseViewHeight` x24 grew it to 873px. The first live build called +`increaseViewSize` right after `terminal.show(true)` (`preserveFocus`), so the focused part was the +Welcome editor and every "tall" boot shrank the terminal to its minimum: the production complaint +that the `dpc` terminal was unusably small. `Layout.apply(terminal)` therefore takes the boot +terminal `BootTerminals.open` returns and focuses it through its own handle +(`terminal.show(false)`) - NOT `workbench.action.terminal.focus`, which raced the still-resolving +boot terminals and spawned a stray default `bash` as the first tab on a fresh boot - then makes the +size deterministic from the one known baseline: `decreaseViewSize` x`PANEL_FLOOR_STEPS` to the floor, +then `increaseViewSize` x`PANEL_STEPS[preset]` (short 2, normal 4 = 317px, tall 7; the increment is +60px); `maximized` toggles instead. It always normalizes, even on a brand-new state dir where VS +Code's own default would already be a third: the panel size is persisted in the member's BROWSER, not +in the state dir, so the only way to repair a panel crushed by the old bug on a member's existing +browser profile is to normalize unconditionally (verified with one persistent Chromium profile: 93px +under the shipped extension, 317px on the next boot under this one). The preset is applied when the +recorded `devplace.panelPreset` marker differs from the profile (first boot under this extension, or +the member changed it on DevPlace) and never otherwise, so a height the member drags themselves +survives every restart. + **The extension is a built-in, copied to `/usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace`.** Built-ins are always enabled, cannot be uninstalled, need no install step and survive workspace recreation because they @@ -196,17 +235,37 @@ code-server's Node remote extension host, so `main` applies. **`extension.js` is CommonJS, and it is the one file in this repository that may be.** The VS Code extension host loads CommonJS; it is not frontend code and is never served to a browser. Every other -house rule applies unchanged. Four small classes (`Profile`, `BootTerminals`, `Layout`, `Presence`) -and an `activate` that runs each through `stage()`, which owns the try/catch and logs to a -`DevPlace` output channel. +house rule applies unchanged. Seven small classes (`Profile`, `Memory`, `BootTerminals`, `Layout`, +`Welcome`, `Presence`, `Tunnels`) and an `activate` that runs each through `stage()`, which owns the +try/catch and logs to a `DevPlace` output channel. + +**The welcome page is a webview, shown once per state dir.** `Welcome.openOnBoot` opens +`media/welcome.html` (placeholders `{{cspSource}}`, `{{nonce}}`, `{{styleUri}}`, `{{iconUri}}`, +`{{projectTitle}}`, `{{agentStarted}}`, the two dpc URLs) in a `WebviewPanel` with `preserveFocus`, +gated by the `devplace.welcomeShown` global-state memento, so a member sees it on the first boot of +a workspace and never again unless they run **DevPlace: Show the welcome page** (or the walkthrough +link). Its buttons `postMessage({action})` and the extension maps them through the +`WELCOME_COMMANDS` allow-list to commands - a webview never names a command directly. It opens +BEFORE `Layout` so the layout's `terminal.focus` leaves the member typing in `dpc`, not reading. The +prose is sourced from https://dpc.app.molodetz.nl/ (context size, vision, swarms, deep research, +safety gates, daily credits); when dpc's capabilities change, update `welcome.html`, the +walkthrough and `/docs/workspace-editor.html` together. `welcome.css` uses only `--vscode-*` +variables, which is what keeps it correct in both DevPlace themes. **The activation stages are awaited in order, and `Layout` never opens a panel of its own.** Firing them concurrently is what produced a stray third terminal in the first live build: `Layout` called `workbench.action.focusPanel` before `BootTerminals` had created anything, and VS Code answered by -spawning its own default `bash`. `Layout.apply(panelIsOpen)` therefore resizes only when the boot -terminals actually opened the panel, and `activate` awaits `terminals` before `layout`. Verified by +spawning its own default `bash`. `Layout.apply(terminal)` therefore resizes only when +`BootTerminals.open` actually created a terminal (it returns the one that got focus, `null` when the +boot was skipped), and `activate` awaits `terminals`, then `welcome`, then `layout` (presence and +the welcome commands are registered first, tunnels last). A skipped boot still calls +`BootTerminals.reveal`, which shows the newest existing terminal (or the first one VS Code revives, +waited for with `onDidOpenTerminal` up to `REVEAL_TIMEOUT_MS`) and never creates one, so a member +opening the same running workspace from a second browser lands on the panel with the terminals that +are already there instead of a closed panel or a duplicate agent. Verified by driving a real container with Playwright: the tab list must read exactly -`pravda@workspace` + `DevPlace Code`. +`pravda@workspace` + `DevPlace Code`, the editor area must hold exactly one `Welcome to DevPlace` +tab, and `document.activeElement` must be the terminal's xterm textarea. **A workspace suppresses the editor's own AI assistant.** Recent VS Code ships a chat panel in the secondary sidebar, which opened by default with Microsoft branding, "AI responses may be inaccurate" @@ -373,7 +432,11 @@ Every reconciler status mutation flows through `service._set_status(inst, change ## Testing without Docker -Use `FakeBackend` (its `image_exists` returns `True`) + `runtime.set_backend`, and monkeypatch `config.CONTAINER_WORKSPACES_DIR` to a tmp dir. See `tests/unit/services/containers.py` and `tests/api/containers.py` (argv, instance creation on `config.CONTAINER_IMAGE`, image-not-built guard, reconcile matrices, schedule firing, ingress validation + live HTTP proxy, HTTP admin gate). +Use `FakeBackend` (its `image_exists` returns `True`) + `runtime.set_backend`, and monkeypatch `config.CONTAINER_WORKSPACES_DIR` to a tmp dir. See `tests/unit/services/containers.py` and `tests/api/containers.py` (argv, instance creation on `config.CONTAINER_IMAGE`, image-not-built guard, reconcile matrices, schedule firing, ingress validation + live HTTP proxy, HTTP admin gate). Editor readiness in tests is a real listening socket: bind `127.0.0.1:0`, `listen()`, and give the instance `editor_port` 8443 with a `ports_json` mapping that container port to the socket's host port (`tests/e2e/projects/workspace.py` `editor_listener`, `tests/api/projects/workspace.py` `_editor_listener`), so `phase` is exercised against the same probe production uses rather than a mock. + +## Testing against the real image (and the one thing never to do) + +The editor extension, the proxied workbench and the panel layout can only be verified on the real `ppy` image. The harness that works: a scratch code-server container started by hand (`docker run --entrypoint /bin/sh ppy:latest -c 'exec code-server ...'` with the same argv `editor.argv` builds, **no `devplace.instance` label**, the state dir and `/app` bind-mounted from a scratch directory, and the extension directory bind-mounted read-only over `/usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace` so an edit is live on the next container start with no `make ppy`), plus a scratch DevPlace server (`DEVPLACE_DATABASE_URL`/`DEVPLACE_DATA_DIR` in the scratch dir, `DEVPLACE_DISABLE_SERVICES=1`, another port) seeded with an instance row whose `container_ip` is the scratch container's bridge IP, then Playwright against both. Simulate lifecycle transitions with `store.update_instance` from a script. **Never enable `ContainerService` against a scratch database on the host daemon**: its orphan sweep `rm -f`s every `devplace.instance`-labelled container its database does not know, which on this host is every production workspace. Measure VS Code layout by bounding box, never by assumption - see the `ViewSize`/`ViewHeight` table above for how wrong an assumption was. ## Vibe coding on-ramp (user-facing doc) @@ -447,6 +510,22 @@ while throttled, so a second call would double the request counter). **Request b buffered on purpose**: they are bounded by nginx `client_max_body_size`, and streaming them would force chunked encoding onto arbitrary upstream apps. +**`` is injected into the ROOT document only (`forward.is_root_document(path)`), never into a +nested HTML page.** A base tag exists for one case: the root document requested without a trailing +slash (`/p/slug`, `/code`), where relative URLs would otherwise resolve one level too high. Every +nested page already resolves its relative URLs against its own directory, so a base tag there is +not a no-op but a corruption. The concrete victim was VS Code's webview host page +(`.../static/out/vs/workbench/contrib/webview/browser/pre/index.html`, an iframe the Extensions +view and every extension detail page load): it registers `service-worker.js` and probes +`./fake.html` relative to its own directory, and with the injected base both went to +`/code/service-worker.js` and `/code/fake.html` (observed as 404s from the proxy), so the webview's +service worker could not register - the "Extensions view crashes" report. code-server's own pages +already carry a `` and were never touched, which is exactly why only the internal iframes +broke. Reproduce with the real image behind the scratch proxy: fetch the webview `index.html` +through `/code/...` and grep for `` that only defined the class, so the page had +no JavaScript at all and the Start button was a plain form post. Liveness is two-fold: an HTTP poll +of the page's own JSON at 2s while the phase is transitional and 20s otherwise (`Poller`, swapped +when the phase class changes), plus a pub/sub subscription to `user.{owner_uid}.workspace.{uid}`, +which the live view relay serves (`_workspace_detail`, 3s, owner-checked; the topic sits in the +owner's private namespace so `pubsub/policy.py` admits the owner and admins and nobody else) with +the same `{workspace, editor_url}` shape the page JSON carries. Only the start/stop forms +(`data-workspace-action`) go through the manager; tunnels, editor preferences and delete keep +their native page-reloading submit, which the e2e tests assert with `wait_for_url`. **Member entry point** is the project detail page's overflow menu (`project_detail.html`), gated by the `viewer_can_workspace` context flag (`can_open_workspace(project, user)`, set in diff --git a/devplacepy/services/containers/api.py b/devplacepy/services/containers/api.py index b59cf6b..d5e84ee 100644 --- a/devplacepy/services/containers/api.py +++ b/devplacepy/services/containers/api.py @@ -682,6 +682,14 @@ def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool: return False +def editor_reachable(instance: dict) -> bool: + port = int(instance.get("editor_port") or 0) + host, target_port = tunnel_target(instance, port) + if not host or not target_port: + return False + return _port_reachable(host, target_port) + + def _http_probe(host: str, port: int, timeout: float = 1.0) -> str: try: with stealth.stealth_sync_client(timeout=timeout) as client: diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/extension.js b/devplacepy/services/containers/files/vscode/devplace-workspace/extension.js index 51ad587..21d7ab2 100644 --- a/devplacepy/services/containers/files/vscode/devplace-workspace/extension.js +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/extension.js @@ -1,16 +1,36 @@ // retoor +const crypto = require("crypto"); const fs = require("fs"); const http = require("http"); const https = require("https"); +const path = require("path"); const vscode = require("vscode"); const AGENT_PATH = "/usr/bin/dpc"; const AGENT_TERMINAL = "DevPlace Code"; const SHELL_TERMINAL = "pravda@workspace"; const BOOT_KEY = "devplace.bootMarker"; -const PANEL_STEPS = { short: 0, normal: 2, tall: 5, maximized: 0 }; +const PRESET_KEY = "devplace.panelPreset"; +const WELCOME_KEY = "devplace.welcomeShown"; +const PANEL_STEPS = { short: 2, normal: 4, tall: 7, maximized: 0 }; +const PANEL_FLOOR_STEPS = 24; +const REVEAL_TIMEOUT_MS = 15000; const PUBLISH_TIMEOUT_MS = 20000; +const DPC_SITE_URL = "https://dpc.app.molodetz.nl/"; +const DPC_DOCS_URL = "https://dpc.app.molodetz.nl/docs"; +const MEMORY_FILE = "devplace-extension.json"; +const WELCOME_VIEW_TYPE = "devplace.welcome"; +const WELCOME_TITLE = "Welcome to DevPlace"; +const WELCOME_COMMANDS = { + agent: "devplace.runAgent", + terminal: "workbench.action.terminal.focus", + tunnels: "devplace.showTunnels", + settings: "devplace.openWorkspacePage", + project: "devplace.openProject", + docs: "devplace.openDocs", + walkthrough: "devplace.openWalkthrough", +}; class Profile { constructor() { @@ -18,7 +38,7 @@ class Profile { { theme: "devplace-dark", layout: "standard", - panel_preset: "tall", + panel_preset: "normal", boot_agent: "dpc", boot_shell: true, trust_all: true, @@ -66,7 +86,45 @@ class Profile { } get panelPreset() { - return this.data.panel_preset || "tall"; + return this.data.panel_preset || "normal"; + } + + get projectTitle() { + return process.env.DEVPLACE_PROJECT_TITLE || "your project"; + } +} + +class Memory { + constructor(fallback) { + this.fallback = fallback; + const directory = process.env.DEVPLACE_WORKSPACE_STATE_DIR || ""; + this.path = directory ? path.join(directory, MEMORY_FILE) : ""; + } + + read() { + if (!this.path) return null; + try { + const parsed = JSON.parse(fs.readFileSync(this.path, "utf8")); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + return error.code === "ENOENT" ? {} : null; + } + } + + get(key) { + const stored = this.read(); + if (stored === null) return this.fallback.get(key); + return stored[key]; + } + + async update(key, value) { + const stored = this.read(); + if (stored === null) { + await this.fallback.update(key, value); + return; + } + stored[key] = value; + fs.writeFileSync(this.path, JSON.stringify(stored, null, 2)); } } @@ -81,13 +139,36 @@ class BootTerminals { } async open() { - if (this.alreadyBooted()) return false; + if (this.alreadyBooted()) { + await this.reveal(); + return null; + } await this.memento.update(BOOT_KEY, this.profile.bootMarker); const shell = this.profile.wantsShell ? this.createShell() : null; const agent = this.profile.wantsAgent ? this.createAgent() : null; - if (agent) agent.show(true); - else if (shell) shell.show(true); - return Boolean(agent || shell); + const focused = agent || shell; + if (focused) focused.show(true); + return focused; + } + + async reveal() { + const existing = vscode.window.terminals; + if (existing.length) { + existing[existing.length - 1].show(true); + return; + } + await new Promise((resolve) => { + const timer = setTimeout(() => { + listener.dispose(); + resolve(); + }, REVEAL_TIMEOUT_MS); + const listener = vscode.window.onDidOpenTerminal((terminal) => { + clearTimeout(timer); + listener.dispose(); + terminal.show(true); + resolve(); + }); + }); } createAgent() { @@ -112,21 +193,142 @@ class BootTerminals { } class Layout { - constructor(profile) { + constructor(profile, memento) { this.profile = profile; + this.memento = memento; } - async apply(panelIsOpen) { + alreadyApplied() { + return this.memento.get(PRESET_KEY) === this.profile.panelPreset; + } + + async apply(terminal) { + if (!terminal || this.alreadyApplied()) return false; const preset = this.profile.panelPreset; - if (!panelIsOpen) return; + terminal.show(false); if (preset === "maximized") { await vscode.commands.executeCommand("workbench.action.toggleMaximizedPanel"); + } else { + const steps = PANEL_STEPS[preset] === undefined ? PANEL_STEPS.normal : PANEL_STEPS[preset]; + await this.resize("workbench.action.decreaseViewSize", PANEL_FLOOR_STEPS); + await this.resize("workbench.action.increaseViewSize", steps); + } + await this.memento.update(PRESET_KEY, preset); + return true; + } + + async resize(command, steps) { + for (let index = 0; index < steps; index += 1) { + await vscode.commands.executeCommand(command); + } + } +} + +class Welcome { + constructor(context, profile, output, memory) { + this.context = context; + this.profile = profile; + this.output = output; + this.memory = memory; + this.panel = null; + this.mediaUri = vscode.Uri.joinPath(context.extensionUri, "media"); + } + + alreadyShown() { + return this.memory.get(WELCOME_KEY) === true; + } + + register() { + this.context.subscriptions.push( + vscode.window.registerWebviewPanelSerializer(WELCOME_VIEW_TYPE, { + deserializeWebviewPanel: (panel) => this.adopt(panel), + }), + vscode.commands.registerCommand("devplace.showWelcome", () => this.open(false)), + vscode.commands.registerCommand("devplace.openWalkthrough", () => + vscode.commands.executeCommand( + "workbench.action.openWalkthrough", + "devplace.devplace-workspace#devplace.getStarted", + false, + ), + ), + ); + } + + async openOnBoot() { + if (this.alreadyShown()) return false; + await this.memory.update(WELCOME_KEY, true); + this.open(true); + return true; + } + + open(preserveFocus) { + if (this.panel) { + this.panel.reveal(undefined, preserveFocus); return; } - const steps = PANEL_STEPS[preset] === undefined ? 5 : PANEL_STEPS[preset]; - for (let index = 0; index < steps; index += 1) { - await vscode.commands.executeCommand("workbench.action.increaseViewSize"); - } + this.adopt( + vscode.window.createWebviewPanel( + WELCOME_VIEW_TYPE, + WELCOME_TITLE, + { viewColumn: vscode.ViewColumn.One, preserveFocus: Boolean(preserveFocus) }, + { enableScripts: true, localResourceRoots: [this.mediaUri] }, + ), + ); + } + + adopt(panel) { + this.panel = panel; + panel.iconPath = vscode.Uri.joinPath(this.mediaUri, "devplace-icon.png"); + panel.webview.options = { enableScripts: true, localResourceRoots: [this.mediaUri] }; + panel.webview.html = this.html(panel.webview); + panel.webview.onDidReceiveMessage( + (message) => this.onMessage(message), + null, + this.context.subscriptions, + ); + panel.onDidDispose( + () => { + if (this.panel === panel) this.panel = null; + }, + null, + this.context.subscriptions, + ); + } + + onMessage(message) { + const command = WELCOME_COMMANDS[message && message.action]; + if (!command) return; + vscode.commands.executeCommand(command).then(undefined, (error) => + this.output.appendLine(`welcome: ${command} failed: ${error}`), + ); + } + + html(webview) { + const template = fs.readFileSync( + path.join(this.mediaUri.fsPath, "welcome.html"), + "utf8", + ); + const values = { + cspSource: webview.cspSource, + nonce: crypto.randomBytes(16).toString("hex"), + styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.mediaUri, "welcome.css")).toString(), + iconUri: webview.asWebviewUri(vscode.Uri.joinPath(this.mediaUri, "devplace-icon.png")).toString(), + projectTitle: this.profile.projectTitle, + dpcSiteUrl: DPC_SITE_URL, + dpcDocsUrl: DPC_DOCS_URL, + agentStarted: this.profile.wantsAgent ? "already running" : "one command away", + }; + return template.replace(/\{\{(\w+)\}\}/g, (match, key) => + key in values ? this.escape(values[key]) : match, + ); + } + + escape(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); } } @@ -374,11 +576,18 @@ async function activate(context) { context.subscriptions.push(output); const profile = new Profile(); + const memory = new Memory(context.globalState); + const welcome = new Welcome(context, profile, output, memory); + await stage(output, "presence", () => new Presence(context).register()); - const opened = await stage(output, "terminals", () => - new BootTerminals(profile, context.workspaceState).open(), + await stage(output, "welcome-commands", () => welcome.register()); + const focused = await stage(output, "terminals", () => + new BootTerminals(profile, memory).open(), + ); + await stage(output, "welcome", () => welcome.openOnBoot()); + await stage(output, "layout", () => + new Layout(profile, memory).apply(focused || null), ); - await stage(output, "layout", () => new Layout(profile).apply(Boolean(opened))); await stage(output, "tunnels", () => new Tunnels(output).watch(context)); } diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/media/welcome.css b/devplacepy/services/containers/files/vscode/devplace-workspace/media/welcome.css new file mode 100644 index 0000000..8b51d63 --- /dev/null +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/media/welcome.css @@ -0,0 +1,168 @@ +/* retoor */ + +body { + margin: 0; + padding: 0; + color: var(--vscode-foreground); + background: var(--vscode-editor-background); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + line-height: 1.55; +} + +.welcome { + max-width: 960px; + margin: 0 auto; + padding: 32px 40px 48px; +} + +.welcome-hero { + display: flex; + gap: 24px; + align-items: flex-start; + padding-bottom: 28px; + border-bottom: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); +} + +.welcome-logo { + flex: 0 0 auto; + border-radius: 12px; +} + +.welcome-kicker { + margin: 0 0 4px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.75em; + color: var(--vscode-descriptionForeground); +} + +h1 { + margin: 0 0 12px; + font-size: 1.9em; + font-weight: 600; + line-height: 1.2; +} + +h2 { + margin: 0 0 12px; + font-size: 1.3em; + font-weight: 600; +} + +h3 { + margin: 0 0 6px; + font-size: 1em; + font-weight: 600; +} + +p { + margin: 0 0 12px; +} + +code { + font-family: var(--vscode-editor-font-family); + font-size: 0.92em; + padding: 1px 5px; + border-radius: 4px; + background: var(--vscode-textCodeBlock-background); +} + +a { + color: var(--vscode-textLink-foreground); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.welcome-lead { + font-size: 1.05em; +} + +.welcome-section { + padding: 28px 0; + border-bottom: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); +} + +.welcome-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; + margin: 16px 0; +} + +.welcome-card { + padding: 14px 16px; + border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); + border-radius: 8px; + background: var(--vscode-editorWidget-background); +} + +.welcome-card p { + margin: 0; + color: var(--vscode-descriptionForeground); +} + +.welcome-muted { + color: var(--vscode-descriptionForeground); +} + +.welcome-prompts { + margin: 0 0 16px; + padding-left: 20px; +} + +.welcome-prompts li { + margin: 6px 0; +} + +.welcome-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 8px; +} + +.welcome-button { + display: inline-flex; + align-items: center; + padding: 7px 14px; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 4px; + font: inherit; + cursor: pointer; + color: var(--vscode-button-secondaryForeground); + background: var(--vscode-button-secondaryBackground); + text-decoration: none; +} + +.welcome-button:hover { + background: var(--vscode-button-secondaryHoverBackground); + text-decoration: none; +} + +.welcome-button-primary { + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); +} + +.welcome-button-primary:hover { + background: var(--vscode-button-hoverBackground); +} + +.welcome-footer { + padding-top: 20px; + color: var(--vscode-descriptionForeground); +} + +@media (max-width: 640px) { + .welcome { + padding: 20px; + } + + .welcome-hero { + flex-direction: column; + } +} diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/media/welcome.html b/devplacepy/services/containers/files/vscode/devplace-workspace/media/welcome.html new file mode 100644 index 0000000..c48b848 --- /dev/null +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/media/welcome.html @@ -0,0 +1,132 @@ + + + + + + + + + Welcome to DevPlace + + +
+
+ +
+

DevPlace workspace

+

Welcome to your workspace for {{projectTitle}}

+

+ A full editor in your browser, your project files under /app, a login shell, + and DevPlace Code, the coding agent that builds with you. It is {{agentStarted}} in the + DevPlace Code terminal at the bottom of this window. +

+
+ + + About DevPlace Code +
+
+
+ +
+

Meet dpc, DevPlace Code

+

+ dpc is an autonomous software engineering agent for the terminal. Tell it what you + want in plain language; it reads and writes the files in /app, runs commands, + installs what it needs, and keeps going until the task is done. It is a single native binary + with your DevPlace credentials already configured, so there is nothing to set up. +

+
+
+

900k token context

+

Hold an entire codebase in one session. Context compaction and archival keep long sessions coherent.

+
+
+

Vision

+

Hand it screenshots, error captures and UI mockups. It designs from a picture and debugs from an error image in the same session as your code.

+
+
+

Agent swarms

+

Large tasks are split across parallel sub-agents, each with its own context and live output. Builds, tests and lints run side by side.

+
+
+

Deep research

+

Multi-step web research with cited sources when a task needs facts it does not have yet.

+
+
+

Safe by design

+

Read-before-write on every edit, verification gates after changes, and session resume so you continue exactly where you stopped.

+
+
+

Your account, your credits

+

Every token is metered against your own DevPlace account through the platform gateway, with a free daily allowance. Nothing leaves DevPlace.

+
+
+

+ More than a hundred tools: file operations, search, shell execution, web retrieval, syntax + validation, sub-agent delegation and scheduled prompts. It also reads the same + .claude/ workflows, agents and slash commands as Claude Code. + Read the dpc documentation. +

+
+ +
+

Try it now

+

Click into the DevPlace Code terminal and type a request. A few to start with:

+
    +
  • Explain this project and list what is missing before it can run.
  • +
  • Build a FastAPI app in app.py that serves a JSON health check at /, then start it on port 8000.
  • +
  • Read screenshot.png in /app and implement that layout in index.html.
  • +
  • Run the test suite, fix every failure, and show me the diff.
  • +
+
+ + +
+
+ +
+

Inside this workspace

+
+
+

Your files are your project

+

Everything under /app syncs back to your DevPlace project, in both directions, including deletions.

+
+
+

Install anything

+

sudo and apt install work with no setup. Python, Rust, Nim and Swift toolchains are preinstalled.

+
+
+

Publish a port

+

Serve on a high port and forward it in the Ports view. DevPlace publishes it on a public HTTPS address for you.

+
+
+

Every folder is trusted

+

Nothing opens in Restricted Mode, so tasks and extensions work from the first second.

+
+
+
+ + + + +
+
+ +
+ Reopen this page at any time with DevPlace: Show the welcome page from the command palette (F1). +
+
+ + + diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/package.json b/devplacepy/services/containers/files/vscode/devplace-workspace/package.json index b95ead2..0099b9a 100644 --- a/devplacepy/services/containers/files/vscode/devplace-workspace/package.json +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/package.json @@ -2,7 +2,7 @@ "name": "devplace-workspace", "displayName": "DevPlace", "description": "DevPlace workspace integration: the dpc coding agent, project links and DevPlace branding.", - "version": "1.0.0", + "version": "1.1.0", "publisher": "devplace", "author": "retoor ", "license": "SEE LICENSE IN https://pravda.education/docs/terms.html", @@ -83,12 +83,22 @@ "command": "devplace.openDocs", "title": "Open the DevPlace editor guide", "category": "DevPlace" + }, + { + "command": "devplace.showWelcome", + "title": "Show the welcome page", + "category": "DevPlace" + }, + { + "command": "devplace.openWalkthrough", + "title": "Open the Get started walkthrough", + "category": "DevPlace" } ], "viewsWelcome": [ { "view": "workbench.explorer.emptyView", - "contents": "This workspace holds your DevPlace project files.\n[Open project on DevPlace](command:devplace.openProject)\n[Start DevPlace Code](command:devplace.runAgent)" + "contents": "This workspace holds your DevPlace project files.\n[Open project on DevPlace](command:devplace.openProject)\n[Start DevPlace Code](command:devplace.runAgent)\n[Show the welcome page](command:devplace.showWelcome)" } ], "walkthroughs": [ @@ -100,7 +110,7 @@ { "id": "agent", "title": "Meet dpc, your coding agent", - "description": "A DevPlace Code terminal is already running. Ask it to build something.\n[Start another agent](command:devplace.runAgent)", + "description": "A DevPlace Code terminal is already running. Ask it to build something.\n[Start another agent](command:devplace.runAgent)\n[Show the welcome page](command:devplace.showWelcome)", "media": { "markdown": "walkthrough/agent.md" }, diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/agent.md b/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/agent.md index effbe39..f7ddbda 100644 --- a/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/agent.md +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/agent.md @@ -4,9 +4,12 @@ **DevPlace Code** terminal at the bottom of this window. Ask it for what you want in plain language. It reads and writes the files in `/app`, runs commands, -and installs what it needs. +and installs what it needs. It holds up to 900k tokens of context, reads screenshots and mockups, +splits large tasks across parallel sub-agents, and researches the web with cited sources. Every token it spends is metered against your own DevPlace account through the platform AI gateway. Nothing leaves DevPlace. Open another agent at any time from the terminal dropdown, or with **DevPlace: Start DevPlace Code**. +The welcome page that opened on your first boot has example prompts; reopen it with +**DevPlace: Show the welcome page**, or read more at [dpc.app.molodetz.nl](https://dpc.app.molodetz.nl/). diff --git a/devplacepy/services/containers/forward.py b/devplacepy/services/containers/forward.py index 0d17b63..8900eda 100644 --- a/devplacepy/services/containers/forward.py +++ b/devplacepy/services/containers/forward.py @@ -136,17 +136,19 @@ def upstream_url(scheme: str, authority: str, path: str, query: str) -> str: return url +def is_root_document(path: str) -> bool: + return not path.strip("/") + + def inject_base(body: bytes, prefix: str) -> bytes: lowered = body.lower() if b"'.encode() - head = lowered.find(b"", head) - if head != -1 - else lowered.find(b">", lowered.find(b"", opening) if opening != -1 else -1 if anchor == -1: return tag + body return body[: anchor + 1] + tag + body[anchor + 1 :] @@ -215,7 +217,12 @@ async def proxy_http( return Response(f"upstream error: {error}", status_code=502) content_type = upstream.headers.get("content-type", "") headers = response_headers(upstream, prefix) - if prefix and rewrite_html and "text/html" in content_type.lower(): + if ( + prefix + and rewrite_html + and is_root_document(path) + and "text/html" in content_type.lower() + ): body = await upstream.aread() await upstream.aclose() content = inject_base(body, prefix) diff --git a/devplacepy/services/containers/workspace/editor.py b/devplacepy/services/containers/workspace/editor.py index a4f1eba..d93ef9d 100644 --- a/devplacepy/services/containers/workspace/editor.py +++ b/devplacepy/services/containers/workspace/editor.py @@ -126,7 +126,7 @@ DEFAULTS = { "terminal_font_size": 13, "zoom_level": 0, "layout": "standard", - "panel_preset": "tall", + "panel_preset": "normal", "boot_agent": "dpc", "boot_shell": True, "window_mode": "tab", diff --git a/devplacepy/services/containers/workspace/provision.py b/devplacepy/services/containers/workspace/provision.py index 6e8a381..a5e8d7f 100644 --- a/devplacepy/services/containers/workspace/provision.py +++ b/devplacepy/services/containers/workspace/provision.py @@ -18,6 +18,24 @@ CERT_UNCONFIGURED = ( "molohttp base URL and credentials before this address serves HTTPS" ) +PHASE_SUSPENDED = "suspended" +PHASE_STARTING = "starting" +PHASE_READY = "ready" +PHASE_STOPPING = "stopping" +PHASE_STOPPED = "stopped" +PHASE_CRASHED = "crashed" + +PHASE_LABELS = { + PHASE_SUSPENDED: "Suspended", + PHASE_STARTING: "Starting", + PHASE_READY: "Ready", + PHASE_STOPPING: "Stopping", + PHASE_STOPPED: "Stopped", + PHASE_CRASHED: "Crashed", +} + +TRANSITIONAL_PHASES = (PHASE_STARTING, PHASE_STOPPING) + _pending_certificates: set[asyncio.Task] = set() @@ -223,16 +241,45 @@ def write_manifest(instance: dict) -> None: return +def editor_ready(instance: dict) -> bool: + if instance.get("suspended_at"): + return False + if instance.get("status") != store.ST_RUNNING: + return False + return api.editor_reachable(instance) + + +def phase(instance: dict, ready: bool) -> str: + if instance.get("suspended_at"): + return PHASE_SUSPENDED + status = instance.get("status") or "" + if instance.get("desired_state") == store.DESIRED_RUNNING: + if status == store.ST_RUNNING and ready: + return PHASE_READY + return PHASE_STARTING + if status in (store.ST_RUNNING, store.ST_PAUSED, store.ST_RESTARTING): + return PHASE_STOPPING + if status == store.ST_CRASHED: + return PHASE_CRASHED + return PHASE_STOPPED + + def view(instance: dict) -> dict: owner_uid = instance.get("workspace_owner_uid", "") limits = quota.resolve(owner_uid, instance) disk_used = int(instance.get("disk_bytes") or 0) egress_used = int(instance.get("egress_bytes") or 0) + ready = editor_ready(instance) + current = phase(instance, ready) return { "uid": instance.get("uid", ""), "name": instance.get("name", ""), + "owner_uid": owner_uid, "status": instance.get("status", ""), "desired_state": instance.get("desired_state", ""), + "phase": current, + "phase_label": PHASE_LABELS[current], + "editor_ready": ready, "suspended": bool(instance.get("suspended_at")), "flag_reason": instance.get("flag_reason", ""), "tunnel_name": instance.get("tunnel_name", ""), diff --git a/devplacepy/services/containers/workspace_service.py b/devplacepy/services/containers/workspace_service.py index 9c01a47..d40a09a 100644 --- a/devplacepy/services/containers/workspace_service.py +++ b/devplacepy/services/containers/workspace_service.py @@ -139,12 +139,14 @@ class WorkspaceService(BaseService): {"value": "zen", "label": "Zen"}], group="Editor"), ConfigField("workspace_editor_panel_preset", "Terminal panel size", - type="select", default="tall", + type="select", default="normal", options=[{"value": "short", "label": "Short"}, - {"value": "normal", "label": "Normal"}, + {"value": "normal", "label": "Normal (a third of the window)"}, {"value": "tall", "label": "Tall"}, {"value": "maximized", "label": "Maximized"}], - group="Editor"), + group="Editor", + help="Applied on the first boot of a workspace and again whenever " + "the preset changes; a size the member drags themselves is kept."), ConfigField("workspace_editor_boot_agent", "Agent on boot", type="select", default="dpc", options=[{"value": "dpc", "label": "DevPlace Code (dpc)"}, diff --git a/devplacepy/services/devii/actions/workspace_actions.py b/devplacepy/services/devii/actions/workspace_actions.py index 7c3e04f..56c8774 100644 --- a/devplacepy/services/devii/actions/workspace_actions.py +++ b/devplacepy/services/devii/actions/workspace_actions.py @@ -44,7 +44,9 @@ WORKSPACE_ACTIONS: tuple[Action, ...] = ( read_only=True, summary=( "Read a workspace's state, disk and egress usage against quota, idle " - "countdown, tunnels and any open moderation flags." + "countdown, tunnels and any open moderation flags. The phase (stopped, " + "starting, ready, stopping, crashed, suspended) and editor_ready come " + "from a live probe of the editor port, so ready means the editor opens." ), params=(SLUG,), ), diff --git a/devplacepy/services/live_view_relay.py b/devplacepy/services/live_view_relay.py index 43879bb..33ce75f 100644 --- a/devplacepy/services/live_view_relay.py +++ b/devplacepy/services/live_view_relay.py @@ -116,6 +116,25 @@ async def _backups(_match: re.Match) -> dict: return _dashboard(can_download=False) +async def _workspace_detail(match: re.Match) -> Optional[dict]: + from devplacepy.services.containers import store + from devplacepy.services.containers.workspace import provision + + inst = store.get_instance(match.group("uid")) + if not inst or not inst.get("is_workspace"): + return None + if inst.get("workspace_owner_uid") != match.group("owner"): + return None + project = _instance_project(inst) + slug = project.get("slug") or project.get("uid") or "" + return { + "workspace": provision.view(inst), + "editor_url": ( + f"/projects/{slug}/containers/instances/{inst['uid']}/code/" if slug else "" + ), + } + + VIEWS = [ (re.compile(r"^container\.list$"), _container_list, 4.0), (re.compile(rf"^project\.(?P{_SEGMENT})\.containers$"), _project_containers, 3.0), @@ -126,6 +145,11 @@ VIEWS = [ (re.compile(rf"^admin\.services\.(?P{_SEGMENT})$"), _service_detail, 5.0), (re.compile(r"^admin\.ai-usage\.(?P\d+)$"), _ai_usage, 15.0), (re.compile(r"^admin\.backups$"), _backups, 8.0), + ( + re.compile(rf"^user\.(?P{_SEGMENT})\.workspace\.(?P{_SEGMENT})$"), + _workspace_detail, + 3.0, + ), ] diff --git a/devplacepy/static/css/workspace.css b/devplacepy/static/css/workspace.css index 7e25a82..7985593 100644 --- a/devplacepy/static/css/workspace.css +++ b/devplacepy/static/css/workspace.css @@ -202,3 +202,40 @@ flex-direction: column; } } + +.workspace-state { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-sm); +} + +.workspace-badge-ready { + background: var(--success); + color: var(--bg-card); +} + +.workspace-badge-starting, +.workspace-badge-stopping { + background: var(--warning); + color: var(--bg-card); +} + +.workspace-badge-crashed, +.workspace-badge-suspended { + background: var(--danger); + color: var(--bg-card); +} + +.workspace-phase-view { + display: contents; +} + +.workspace-phase-view[hidden], +.workspace-phase-hint[hidden] { + display: none; +} + +.workspace-phase-hint { + margin: var(--space-xs) 0 0; +} diff --git a/devplacepy/static/js/WorkspaceManager.js b/devplacepy/static/js/WorkspaceManager.js index 01a68cf..5113d88 100644 --- a/devplacepy/static/js/WorkspaceManager.js +++ b/devplacepy/static/js/WorkspaceManager.js @@ -3,13 +3,22 @@ import { Http } from "./Http.js"; import { Poller } from "./Poller.js"; +const IDLE_INTERVAL_MS = 20000; +const TRANSITION_INTERVAL_MS = 2000; +const TRANSITIONAL_PHASES = ["starting", "stopping"]; + export class WorkspaceManager { constructor(root) { this.root = root; this.slug = root.dataset.slug; + this.uid = root.dataset.workspaceUid || ""; + this.ownerUid = root.dataset.ownerUid || ""; + this.phase = root.dataset.phase || ""; this.poller = null; + this.interval = 0; this.bind(); this.subscribe(); + this.poll(this.isTransitional(this.phase)); } static mount() { @@ -19,50 +28,94 @@ export class WorkspaceManager { bind() { this.root.addEventListener("submit", (event) => { - const form = event.target.closest("form"); - if (!form || form.dataset.confirm || form.dataset.native !== undefined) return; + const form = event.target.closest("form[data-workspace-action]"); + if (!form) return; event.preventDefault(); this.send(form); }); } async send(form) { + const button = form.querySelector("button[type='submit']"); + this.setBusy(button, true); try { - await Http.sendForm(form.action, this.params(form)); + await Http.sendForm(form.action, this.params(form), { silent: true }); await this.refresh(); } catch (error) { - window.app?.toast?.show(error.message || "Action failed", { type: "error" }); + this.toast(error.message || "Action failed", "error"); + } finally { + this.setBusy(button, false); } } + setBusy(button, busy) { + if (!button) return; + button.disabled = busy; + button.classList.toggle("is-loading", busy); + } + params(form) { const params = []; new FormData(form).forEach((value, key) => params.push([key, value])); return params; } + toast(message, type) { + if (window.app && window.app.toast) window.app.toast.show(message, { type: type }); + } + subscribe() { - const uid = this.root.dataset.workspaceUid; - if (uid && window.app?.pubsub) { - window.app.pubsub.subscribe(`workspace.${uid}.detail`, () => this.render()); - } - this.poller = new Poller(() => this.refresh(), 20000); - this.poller.start(); + const pubsub = window.app && window.app.pubsub; + if (!pubsub || !this.uid || !this.ownerUid) return; + pubsub.subscribe(`user.${this.ownerUid}.workspace.${this.uid}`, (data) => this.render(data)); + } + + isTransitional(phase) { + return TRANSITIONAL_PHASES.includes(phase); + } + + poll(fast) { + const interval = fast ? TRANSITION_INTERVAL_MS : IDLE_INTERVAL_MS; + if (this.poller && this.interval === interval) return; + if (this.poller) this.poller.stop(); + this.interval = interval; + this.poller = new Poller(() => this.refresh(), interval, { + immediate: false, + pauseHidden: true, + }); } async refresh() { - try { - const data = await Http.getJson(`/projects/${this.slug}/workspace`); - this.render(data); - } catch (error) { - return; - } + const data = await Http.getJson(`/projects/${this.slug}/workspace`); + this.render(data); } render(data) { if (!data || !data.workspace) return; - const state = this.root.querySelector("[data-workspace-status]"); - if (state) state.textContent = data.workspace.status || ""; + const workspace = data.workspace; + const badge = this.root.querySelector("[data-workspace-phase]"); + if (badge) { + badge.textContent = workspace.phase_label || workspace.phase || ""; + badge.className = `workspace-badge workspace-badge-${workspace.phase}`; + } + const status = this.root.querySelector("[data-workspace-status]"); + if (status) status.textContent = workspace.status || ""; + const launch = this.root.querySelector("[data-editor-open]"); + if (launch && data.editor_url) launch.href = data.editor_url; + this.applyPhase(workspace.phase); + if (workspace.phase === "ready" && this.phase === "starting") { + this.toast("Your workspace is ready", "success"); + } + this.phase = workspace.phase; + this.root.dataset.phase = workspace.phase; + this.poll(this.isTransitional(workspace.phase)); + } + + applyPhase(phase) { + for (const view of this.root.querySelectorAll("[data-phase-view]")) { + const phases = view.dataset.phaseView.split(/\s+/); + view.hidden = !phases.includes(phase); + } } } diff --git a/devplacepy/templates/_comment.html b/devplacepy/templates/_comment.html index afea242..df4282b 100644 --- a/devplacepy/templates/_comment.html +++ b/devplacepy/templates/_comment.html @@ -5,7 +5,7 @@ - {{ item.votes.up - item.votes.down }} + {% if user %}{{ item.votes.up - item.votes.down }}{% endif %}
diff --git a/devplacepy/templates/_poll.html b/devplacepy/templates/_poll.html index 9da0d8f..b67924e 100644 --- a/devplacepy/templates/_poll.html +++ b/devplacepy/templates/_poll.html @@ -4,15 +4,15 @@
{% for opt in _poll.options %} {% endfor %}
-
{{ _poll.total }} vote{% if _poll.total != 1 %}s{% endif %}{% if not user %} · Log in to vote{% endif %}
+
{% if user %}{{ _poll.total }} vote{% if _poll.total != 1 %}s{% endif %}{% else %}Log in to vote{% endif %}
{% endif %} diff --git a/devplacepy/templates/_post_card.html b/devplacepy/templates/_post_card.html index 87cf9a2..3870180 100644 --- a/devplacepy/templates/_post_card.html +++ b/devplacepy/templates/_post_card.html @@ -40,8 +40,8 @@
{% set _uid = item.post['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.post.get('stars', 0) %}{% include "_post_votes.html" %} - - {{ item.comment_count }} + + {% if user %} {{ item.comment_count }}{% endif %} {% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %}
- {{ item.comment_count }} + + {% if user %} {{ item.comment_count }}{% endif %} {% set _type = "quiz" %}{% set _uid = item.uid %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %} {% set _type = "quiz" %}{% set _uid = item.uid %}{% set _bookmarked = item.bookmarked %}{% include "_bookmark_button.html" %} diff --git a/devplacepy/templates/_reaction_bar.html b/devplacepy/templates/_reaction_bar.html index 12e951c..2335e17 100644 --- a/devplacepy/templates/_reaction_bar.html +++ b/devplacepy/templates/_reaction_bar.html @@ -7,7 +7,7 @@ {% for emoji in _emojis %} {% endfor %}
diff --git a/devplacepy/templates/_star_vote.html b/devplacepy/templates/_star_vote.html index e12bfad..ccb7f33 100644 --- a/devplacepy/templates/_star_vote.html +++ b/devplacepy/templates/_star_vote.html @@ -1,4 +1,4 @@ - +
diff --git a/devplacepy/templates/docs/workspace-editor.html b/devplacepy/templates/docs/workspace-editor.html index 98c81c9..5158fc8 100644 --- a/devplacepy/templates/docs/workspace-editor.html +++ b/devplacepy/templates/docs/workspace-editor.html @@ -6,7 +6,19 @@ it starts a coding agent for you, and it is configured from your DevPlace accoun rather than from inside the editor. Open one from a project's **Workspace** page, or with the **Editor** button on the -project itself once the workspace is running. +project itself once the editor is ready. + +## Starting a workspace + +The workspace page always shows the real state of your workspace: **Stopped**, +**Starting**, **Ready**, **Stopping**, **Crashed** or **Suspended**. Press **Start** and the +button turns into a spinner reading *Starting the editor* while the container boots and the +editor loads. The page watches the workspace for you, so the moment the editor answers the +spinner becomes **Open editor** on its own, without a reload. The same rule gates the +**Editor** button on the project page: it appears only when the editor will actually open. + +A start usually takes a few seconds. **Stop** stays available throughout, so a workspace that +is still starting can always be stopped again. ## What opens on boot @@ -22,12 +34,35 @@ New terminals you open later are plain shells. To start another agent, pick **DevPlace Code** from the terminal dropdown, or run the command **DevPlace: Start DevPlace Code**. -The workspace opens straight onto your files with the terminal ready, not onto a welcome -page, and the editor's own built-in chat assistant is switched off: `dpc` is the assistant -here, and it runs on your DevPlace account. The files `dpc` keeps for itself (`.dpc/` and -`dpc.log`) stay in the container and are never copied into your project. +The first time a workspace boots it also opens the **DevPlace welcome page** beside the +terminals. It introduces the workspace and DevPlace Code: what `dpc` can do (its 900k token +context, vision for screenshots and mockups, parallel sub-agents, deep research with cited +sources, and its safety gates), a few prompts to try, and buttons that focus the agent +terminal, start the tour, show your public tunnels and open your workspace settings. It is +shown once per workspace; reopen it any time with **DevPlace: Show the welcome page**, or +read more on the [DevPlace Code site](https://dpc.app.molodetz.nl/). -You can turn either of them off. See **Your preferences** below. +The editor's own built-in chat assistant and VS Code's own welcome page are switched off: +`dpc` is the assistant here, and it runs on your DevPlace account. The files `dpc` keeps for +itself (`.dpc/` and `dpc.log`) stay in the container and are never copied into your project. + +You can turn either terminal off. See **Your preferences** below. + +## The terminal panel + +`dpc` lives in the terminal panel at the bottom of the window, so its height matters. The +**Terminal panel** preference sets it: + +| Preset | Height | +|---|---| +| Short | A fifth of the window, roughly | +| Normal | A third of the window, the default | +| Tall | About half the window | +| Maximized | The whole editor area | + +The preset is applied the first time a workspace boots and again whenever you change it. +Between those moments the height is yours: drag the panel border and DevPlace keeps that +size across restarts. ## Every workspace is trusted @@ -65,7 +100,7 @@ The **Editor** card on your workspace page holds them all: - **Theme** - DevPlace Dark, DevPlace Light, or leave it to you (pick any theme from inside the editor and DevPlace will not touch it again). - **Layout** - Standard, Terminal focus, or Zen. -- **Terminal panel** - Short, Normal, Tall or Maximized. +- **Terminal panel** - Short, Normal, Tall or Maximized (see **The terminal panel** above). - **Editor font size**, **Terminal font size**, **Zoom level**. - **Agent on boot** and **Shell on boot**. - **Open editor in** - a new tab, a sized window, or a fullscreen window, with the @@ -108,6 +143,8 @@ Press `F1` and type `DevPlace` for the full list: | **DevPlace: Open workspace settings** | Your workspace page | | **DevPlace: Show public tunnels** | Pick one of your live public addresses | | **DevPlace: Open the DevPlace editor guide** | This page | +| **DevPlace: Show the welcome page** | The welcome page that opened on the first boot | +| **DevPlace: Open the Get started walkthrough** | The five-step tour of the workspace | ## Publishing a port from the editor diff --git a/devplacepy/templates/issues.html b/devplacepy/templates/issues.html index c7f0163..6acf94a 100644 --- a/devplacepy/templates/issues.html +++ b/devplacepy/templates/issues.html @@ -46,8 +46,10 @@ {{ issue.author_username }} · {{ local_dt(issue.created_at) }} + {% if user %} · {{ issue.comments_count }} comment{{ '' if issue.comments_count == 1 else 's' }} + {% endif %}
{% else %} diff --git a/devplacepy/templates/landing.html b/devplacepy/templates/landing.html index affa7ab..4ffb1e7 100644 --- a/devplacepy/templates/landing.html +++ b/devplacepy/templates/landing.html @@ -210,8 +210,6 @@ {% endif %}
{{ render_content(item.post['content'][:200] ~ ('...' if item.post['content']|length > 200 else ''), author_is_admin=is_admin(item.author)) }}
diff --git a/devplacepy/templates/profile.html b/devplacepy/templates/profile.html index 5467c2f..8206a65 100644 --- a/devplacepy/templates/profile.html +++ b/devplacepy/templates/profile.html @@ -603,7 +603,9 @@

{{ render_title(g['title'], author_is_admin=is_admin(profile_user)) }}

+ {% if user %} ☆ {{ g.get('stars', 0) }} + {% endif %}
📝 {{ language_name(g['language']) }} diff --git a/devplacepy/templates/project_detail.html b/devplacepy/templates/project_detail.html index a21fbb3..40ebd07 100644 --- a/devplacepy/templates/project_detail.html +++ b/devplacepy/templates/project_detail.html @@ -136,7 +136,7 @@ {% if gallery %} Screenshots {{ gallery | length }} {% endif %} - Comments {{ comment_count }} + Comments{% if user %} {{ comment_count }}{% endif %} Files {{ file_count }} @@ -214,9 +214,13 @@
+ {% if user %} {{ star_count }} stars + {% endif %} {{ devlog_count }} update{{ '' if devlog_count == 1 else 's' }} + {% if user %} {{ comment_count }} comment{{ '' if comment_count == 1 else 's' }} + {% endif %} {{ file_count }} file{{ '' if file_count == 1 else 's' }} {{ fork_count }} fork{{ '' if fork_count == 1 else 's' }}
diff --git a/devplacepy/templates/workspace.html b/devplacepy/templates/workspace.html index 28d83af..94f120b 100644 --- a/devplacepy/templates/workspace.html +++ b/devplacepy/templates/workspace.html @@ -9,6 +9,8 @@

Workspace: {{ project.title }}

@@ -45,7 +47,10 @@ {% endfor %}
-
{{ workspace.status }}
+
+ {{ workspace.phase_label }} + {{ workspace.status }} +
Disk @@ -69,24 +74,34 @@ Last active {{ dt_ago(workspace.last_active_at) }}. {% endif %}

-
- {% if workspace.status == "running" %} - {% set _url = editor_url %} - {% set _uid = workspace.uid %} - {% set _class = "btn btn-primary" %} - {% set _mode = editor.window_mode %} - {% set _width = editor.window_width %} - {% set _height = editor.window_height %} - {% set _label = "Open editor" %} - {% include "_editor_open.html" %} -
- -
- {% else %} -
- -
- {% endif %} +
+
+ {% set _url = editor_url %} + {% set _uid = workspace.uid %} + {% set _class = "btn btn-primary" %} + {% set _mode = editor.window_mode %} + {% set _width = editor.window_width %} + {% set _height = editor.window_height %} + {% set _label = "Open editor" %} + {% include "_editor_open.html" %} +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+ +
+
{% if editor_password %} {{ editor_password }} @@ -95,6 +110,9 @@
+

+ The container is booting and the editor is loading. This usually takes a few seconds; the button above changes on its own once the editor answers. +

@@ -108,11 +126,11 @@ {% if restart_required %}
Your editor settings changed. Restart the workspace to apply them. -
- + +
-
- + +
{% endif %} @@ -272,5 +290,8 @@ {% endblock %} {% block extra_js %} - + {% endblock %} diff --git a/tests/api/containers.py b/tests/api/containers.py index 3557e65..791658e 100644 --- a/tests/api/containers.py +++ b/tests/api/containers.py @@ -107,3 +107,59 @@ def test_http_ingress_proxy(app_server): httpd.shutdown() get_table("instances").delete(uid=uid) refresh_snapshot() + + +def test_http_ingress_proxy_injects_a_base_into_the_root_document_only(app_server): + import http.server + import socket + import socketserver + import threading + + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write( + b"t" + b"" + ) + + def log_message(self, *args): + pass + + httpd = socketserver.TCPServer(("127.0.0.1", port), Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + slug = f"base{port}" + uid = f"basetest-{port}" + get_table("instances").insert( + { + "uid": uid, + "name": "ingress-base", + "project_uid": "ingtest", + "status": "running", + "ingress_slug": slug, + "ingress_port": 8000, + "ports_json": f'[{{"host": {port}, "container": 8000, "proto": "tcp"}}]', + "deleted_at": None, + "deleted_by": None, + } + ) + refresh_snapshot() + try: + root = requests.get(f"{BASE_URL}/p/{slug}") + assert root.status_code == 200, root.text + assert f'' in root.text + nested = requests.get(f"{BASE_URL}/p/{slug}/static/webview/pre/index.html") + assert nested.status_code == 200, nested.text + assert " dict: + import requests + + return requests.get( + f"{BASE_URL}/projects/{slug}/workspace", + headers={"Accept": "application/json", "X-API-KEY": key}, + timeout=HTTP_TIMEOUT, + ).json() + + +def test_workspace_json_reports_the_phase_from_a_live_editor_probe(app_server, seeded_db): + owner = _seeded_user("bob_test") + project = _http_project(owner["uid"], "WS Phase Http") + sock = _editor_listener() + port = sock.getsockname()[1] + try: + _instance( + project_uid=project["uid"], + workspace_owner_uid=owner["uid"], + editor_port=editor.EDITOR_DEFAULT_PORT, + ports_json=( + f'[{{"host": {port}, "container": {editor.EDITOR_DEFAULT_PORT}, ' + '"proto": "tcp"}]' + ), + ) + body = _workspace_json(project["slug"], owner["api_key"]) + assert body["workspace"]["phase"] == provision.PHASE_READY + assert body["workspace"]["phase_label"] == "Ready" + assert body["workspace"]["editor_ready"] is True + assert body["workspace"]["owner_uid"] == owner["uid"] + assert body["editor_url"].endswith("/code/") + finally: + sock.close() + + body = _workspace_json(project["slug"], owner["api_key"]) + assert body["workspace"]["phase"] == provision.PHASE_STARTING + assert body["workspace"]["phase_label"] == "Starting" + assert body["workspace"]["editor_ready"] is False + assert body["workspace"]["status"] == "running" + + +def test_editor_proxy_refuses_a_stopped_workspace_as_plain_text(app_server, seeded_db): + import requests + + owner = _seeded_user("bob_test") + project = _http_project(owner["uid"], "WS Proxy Stopped") + instance = _instance( + project_uid=project["uid"], + workspace_owner_uid=owner["uid"], + status="stopped", + desired_state="stopped", + ) + + response = requests.get( + f"{BASE_URL}/projects/{project['slug']}/containers/instances/{instance['uid']}/code/", + headers={"X-API-KEY": owner["api_key"]}, + timeout=HTTP_TIMEOUT, + ) + + assert response.status_code == 409, response.text[:400] + assert response.headers["content-type"].startswith("text/plain") + assert "not running" in response.text diff --git a/tests/e2e/projects/workspace.py b/tests/e2e/projects/workspace.py index 0e963c7..bc2e95b 100644 --- a/tests/e2e/projects/workspace.py +++ b/tests/e2e/projects/workspace.py @@ -1,6 +1,7 @@ # retoor import re +import socket import time from uuid import uuid4 @@ -14,6 +15,9 @@ from devplacepy.services.containers.workspace import flags, naming from devplacepy.utils import make_combined_slug from tests.conftest import BASE_URL +EDITOR_CONTAINER_PORT = 8443 +TRANSITION_TIMEOUT_MS = 8000 + @pytest.fixture(autouse=True) def _workspaces_on(): @@ -62,7 +66,20 @@ def _project_for(owner_uid: str, title: str = "WS Project") -> dict: return row -def _workspace_for(project: dict, owner_uid: str, **overrides) -> dict: +@pytest.fixture(scope="module") +def editor_listener(): + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + sock.listen(16) + try: + yield sock.getsockname()[1] + finally: + sock.close() + + +def _workspace_for( + project: dict, owner_uid: str, editor_port: int = 0, **overrides +) -> dict: payload = { "project_uid": project["uid"], "name": "ws-e2e", @@ -73,10 +90,20 @@ def _workspace_for(project: dict, owner_uid: str, **overrides) -> dict: "tunnel_name": naming.generate(), "ports_json": '[{"host": 20777, "container": 8080, "proto": "tcp"}]', } + if editor_port: + payload["editor_port"] = EDITOR_CONTAINER_PORT + payload["ports_json"] = ( + f'[{{"host": {editor_port}, "container": {EDITOR_CONTAINER_PORT}, ' + '"proto": "tcp"}]' + ) payload.update(overrides) return store.create_instance(payload) +def _phase_view(page, phase: str): + return page.locator(f".workspace-phase-view[data-phase-view~='{phase}']") + + def test_project_page_offers_the_workspace_entry_point_to_the_owner(alice): page, user = alice project = _project_for(_row_for(user)["uid"], "WS Entry") @@ -104,11 +131,13 @@ def test_project_page_hides_the_workspace_entry_point_from_a_non_owner(bob): assert not page.locator(".context-menu-item:has-text('Workspace')").count() -def test_project_page_shows_a_direct_vscode_button_for_a_running_workspace(alice): +def test_project_page_shows_a_direct_vscode_button_for_a_running_workspace( + alice, editor_listener +): page, user = alice row = _row_for(user) project = _project_for(row["uid"], "WS Direct") - instance = _workspace_for(project, row["uid"]) + instance = _workspace_for(project, row["uid"], editor_port=editor_listener) page.goto( f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded" ) @@ -383,11 +412,11 @@ def test_resetting_editor_preferences_restores_the_site_default(alice): get_table("workspace_editor_prefs").delete(owner_id=row["uid"]) -def test_the_editor_launch_control_carries_the_window_profile(alice): +def test_the_editor_launch_control_carries_the_window_profile(alice, editor_listener): page, user = alice row = _row_for(user) project = _project_for(row["uid"], "WS Editor Launch") - instance = _workspace_for(project, row["uid"]) + instance = _workspace_for(project, row["uid"], editor_port=editor_listener) page.goto( f"{BASE_URL}/projects/{project['slug']}/workspace", wait_until="domcontentloaded", @@ -415,3 +444,88 @@ def test_the_workspace_help_states_that_every_folder_is_trusted(alice): help_card.wait_for(state="visible") expect(help_card).to_contain_text("Restricted Mode") expect(help_card).to_contain_text("dpc") + + +def test_direct_vscode_button_is_absent_while_the_editor_is_unreachable(alice): + page, user = alice + row = _row_for(user) + project = _project_for(row["uid"], "WS Booting") + _workspace_for(project, row["uid"]) + page.goto( + f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded" + ) + page.locator(".project-detail-actions").wait_for(state="visible") + assert not page.locator(".project-detail-actions a[data-editor-open]").count() + + +def test_a_running_workspace_whose_editor_is_not_listening_reads_starting(alice): + page, user = alice + row = _row_for(user) + project = _project_for(row["uid"], "WS Not Listening") + _workspace_for(project, row["uid"]) + page.goto( + f"{BASE_URL}/projects/{project['slug']}/workspace", + wait_until="domcontentloaded", + ) + expect(page.locator("[data-workspace-phase]")).to_have_text("Starting") + expect(_phase_view(page, "starting")).to_be_visible() + expect(_phase_view(page, "starting").locator(".btn.is-loading")).to_be_disabled() + expect(_phase_view(page, "ready")).to_be_hidden() + assert not page.locator(".workspace-actions a[data-editor-open]").is_visible() + + +def test_start_shows_progress_and_flips_to_open_editor_when_the_editor_answers( + alice, editor_listener +): + page, user = alice + row = _row_for(user) + project = _project_for(row["uid"], "WS Start Flow") + instance = _workspace_for( + project, + row["uid"], + editor_port=editor_listener, + status="stopped", + desired_state="stopped", + ) + page.goto( + f"{BASE_URL}/projects/{project['slug']}/workspace", + wait_until="domcontentloaded", + ) + expect(page.locator("[data-workspace-phase]")).to_have_text("Stopped") + start = _phase_view(page, "stopped").locator("button[type='submit']") + expect(start).to_be_visible() + start.click() + expect(_phase_view(page, "starting")).to_be_visible(timeout=TRANSITION_TIMEOUT_MS) + expect(_phase_view(page, "stopped")).to_be_hidden() + expect(page.locator("[data-workspace-phase]")).to_have_text("Starting") + assert store.get_instance(instance["uid"])["desired_state"] == "running" + + store.update_instance(instance["uid"], {"status": "running"}) + launch = page.locator(".workspace-actions a[data-editor-open]") + launch.wait_for(state="visible", timeout=TRANSITION_TIMEOUT_MS) + expect(page.locator("[data-workspace-phase]")).to_have_text("Ready") + expect(_phase_view(page, "starting")).to_be_hidden() + expect(launch).to_have_attribute( + "href", + f"/projects/{project['slug']}/containers/instances/{instance['uid']}/code/", + ) + + +def test_stop_shows_progress_until_the_container_is_down(alice, editor_listener): + page, user = alice + row = _row_for(user) + project = _project_for(row["uid"], "WS Stop Flow") + instance = _workspace_for(project, row["uid"], editor_port=editor_listener) + page.goto( + f"{BASE_URL}/projects/{project['slug']}/workspace", + wait_until="domcontentloaded", + ) + expect(page.locator("[data-workspace-phase]")).to_have_text("Ready") + _phase_view(page, "ready").locator("form[data-workspace-action] button").click() + expect(_phase_view(page, "stopping")).to_be_visible(timeout=TRANSITION_TIMEOUT_MS) + expect(page.locator("[data-workspace-phase]")).to_have_text("Stopping") + assert store.get_instance(instance["uid"])["desired_state"] == "stopped" + + store.update_instance(instance["uid"], {"status": "stopped"}) + expect(_phase_view(page, "stopped")).to_be_visible(timeout=TRANSITION_TIMEOUT_MS) + expect(page.locator("[data-workspace-phase]")).to_have_text("Stopped") diff --git a/tests/unit/services/containers/forward.py b/tests/unit/services/containers/forward.py new file mode 100644 index 0000000..6838ccd --- /dev/null +++ b/tests/unit/services/containers/forward.py @@ -0,0 +1,45 @@ +# retoor + +from devplacepy.services.containers import forward + +PREFIX = "/projects/demo/containers/instances/abc/code" + + +def test_the_root_document_is_the_empty_or_slash_path(): + assert forward.is_root_document("") is True + assert forward.is_root_document("/") is True + assert forward.is_root_document("//") is True + + +def test_a_nested_path_is_never_the_root_document(): + for path in ( + "index.html", + "login", + "stable-abc/static/out/vs/workbench/contrib/webview/browser/pre/index.html", + "nested/page.html", + "/nested/", + ): + assert forward.is_root_document(path) is False, path + + +def test_inject_base_adds_a_base_after_head(): + body = b"x" + injected = forward.inject_base(body, PREFIX) + assert injected.startswith(b"" + f''.encode()) + + +def test_inject_base_leaves_an_existing_base_alone(): + body = b'' + assert forward.inject_base(body, PREFIX) == body + + +def test_inject_base_prepends_when_there_is_no_head_or_html(): + body = b"

fragment

" + assert forward.inject_base(body, PREFIX) == f''.encode() + body + + +def test_rewrite_location_prefixes_only_absolute_paths(): + assert forward.rewrite_location("/login", PREFIX) == f"{PREFIX}/login" + assert forward.rewrite_location("./login", PREFIX) == "./login" + assert forward.rewrite_location("//cdn.example/x", PREFIX) == "//cdn.example/x" + assert forward.rewrite_location("/login", "") == "/login" diff --git a/tests/unit/services/containers/workspace/provision.py b/tests/unit/services/containers/workspace/provision.py new file mode 100644 index 0000000..f0f1521 --- /dev/null +++ b/tests/unit/services/containers/workspace/provision.py @@ -0,0 +1,178 @@ +# retoor + +import itertools +import socket + +import pytest + +from devplacepy.database import get_table, init_db +from devplacepy.services.containers import store +from devplacepy.services.containers.workspace import provision + +OWNER = "provision-owner" +STATUSES = ( + store.ST_CREATED, + store.ST_STARTING, + store.ST_RUNNING, + store.ST_STOPPED, + store.ST_PAUSED, + store.ST_CRASHED, + store.ST_RESTARTING, + store.ST_REMOVING, + store.ST_REMOVED, + "", +) +DESIRED = (store.DESIRED_RUNNING, store.DESIRED_STOPPED, store.DESIRED_PAUSED, "") + + +@pytest.fixture(autouse=True) +def _provision_db(): + init_db() + yield + get_table("instances").delete(workspace_owner_uid=OWNER) + + +def _instance(**overrides) -> dict: + row = { + "uid": "ws-phase", + "status": store.ST_RUNNING, + "desired_state": store.DESIRED_RUNNING, + "suspended_at": "", + "editor_port": 8443, + "ports_json": "[]", + "container_ip": "", + "workspace_owner_uid": OWNER, + } + row.update(overrides) + return row + + +@pytest.fixture +def listener(): + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + sock.listen(4) + try: + yield sock + finally: + sock.close() + + +@pytest.mark.parametrize( + "status, desired, ready, expected", + [ + (store.ST_RUNNING, store.DESIRED_RUNNING, True, provision.PHASE_READY), + (store.ST_RUNNING, store.DESIRED_RUNNING, False, provision.PHASE_STARTING), + (store.ST_CREATED, store.DESIRED_RUNNING, False, provision.PHASE_STARTING), + (store.ST_STOPPED, store.DESIRED_RUNNING, False, provision.PHASE_STARTING), + (store.ST_CRASHED, store.DESIRED_RUNNING, False, provision.PHASE_STARTING), + (store.ST_RUNNING, store.DESIRED_STOPPED, True, provision.PHASE_STOPPING), + (store.ST_PAUSED, store.DESIRED_STOPPED, False, provision.PHASE_STOPPING), + (store.ST_RESTARTING, store.DESIRED_STOPPED, False, provision.PHASE_STOPPING), + (store.ST_CRASHED, store.DESIRED_STOPPED, False, provision.PHASE_CRASHED), + (store.ST_STOPPED, store.DESIRED_STOPPED, False, provision.PHASE_STOPPED), + (store.ST_CREATED, store.DESIRED_STOPPED, False, provision.PHASE_STOPPED), + ("", "", False, provision.PHASE_STOPPED), + ], +) +def test_phase_follows_desired_state_status_and_the_editor_probe( + status, desired, ready, expected +): + row = _instance(status=status, desired_state=desired) + assert provision.phase(row, ready) == expected + + +def test_a_suspended_workspace_is_suspended_whatever_else_it_says(): + for status, desired, ready in itertools.product(STATUSES, DESIRED, (True, False)): + row = _instance( + status=status, desired_state=desired, suspended_at="2026-01-01T00:00:00" + ) + assert provision.phase(row, ready) == provision.PHASE_SUSPENDED + + +def test_ready_needs_a_running_container_that_wants_to_run(): + for status, desired in itertools.product(STATUSES, DESIRED): + row = _instance(status=status, desired_state=desired) + is_ready = provision.phase(row, True) == provision.PHASE_READY + assert is_ready == ( + status == store.ST_RUNNING and desired == store.DESIRED_RUNNING + ) + + +def test_every_phase_over_the_whole_input_domain_has_a_label(): + for status, desired, ready, suspended in itertools.product( + STATUSES, DESIRED, (True, False), ("", "2026-01-01T00:00:00") + ): + row = _instance(status=status, desired_state=desired, suspended_at=suspended) + assert provision.phase(row, ready) in provision.PHASE_LABELS + + +def test_transitional_phases_are_exactly_starting_and_stopping(): + assert set(provision.TRANSITIONAL_PHASES) == { + provision.PHASE_STARTING, + provision.PHASE_STOPPING, + } + + +def test_editor_ready_when_the_editor_port_accepts_connections(listener): + port = listener.getsockname()[1] + row = _instance(container_ip="127.0.0.1", editor_port=port) + assert provision.editor_ready(row) is True + + +def test_editor_ready_is_false_when_nothing_listens(): + probe = socket.socket() + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + probe.close() + row = _instance(container_ip="127.0.0.1", editor_port=port) + assert provision.editor_ready(row) is False + + +def test_editor_ready_is_false_without_a_reachable_target(listener): + row = _instance(container_ip="", ports_json="[]") + assert provision.editor_ready(row) is False + + +def test_editor_ready_is_false_unless_the_container_runs(listener): + port = listener.getsockname()[1] + for status in STATUSES: + if status == store.ST_RUNNING: + continue + row = _instance(container_ip="127.0.0.1", editor_port=port, status=status) + assert provision.editor_ready(row) is False + + +def test_editor_ready_is_false_while_suspended(listener): + port = listener.getsockname()[1] + row = _instance( + container_ip="127.0.0.1", editor_port=port, suspended_at="2026-01-01T00:00:00" + ) + assert provision.editor_ready(row) is False + + +def test_view_carries_the_phase_its_label_readiness_and_the_owner(listener): + port = listener.getsockname()[1] + instance = store.create_instance( + { + "project_uid": "provision-project", + "name": "ws-view", + "status": store.ST_RUNNING, + "desired_state": store.DESIRED_RUNNING, + "is_workspace": 1, + "workspace_owner_uid": OWNER, + "container_ip": "127.0.0.1", + "editor_port": port, + "ports_json": "[]", + } + ) + view = provision.view(instance) + assert view["phase"] == provision.PHASE_READY + assert view["phase_label"] == "Ready" + assert view["editor_ready"] is True + assert view["owner_uid"] == OWNER + + store.update_instance(instance["uid"], {"desired_state": store.DESIRED_STOPPED}) + view = provision.view(store.get_instance(instance["uid"])) + assert view["phase"] == provision.PHASE_STOPPING + assert view["editor_ready"] is True diff --git a/tests/unit/services/live_view_relay.py b/tests/unit/services/live_view_relay.py new file mode 100644 index 0000000..e2e70cb --- /dev/null +++ b/tests/unit/services/live_view_relay.py @@ -0,0 +1,92 @@ +# retoor + +import pytest + +from devplacepy.database import get_table, init_db +from devplacepy.services import live_view_relay +from devplacepy.services.containers import store +from devplacepy.services.containers.workspace import provision +from tests.conftest import run_async + +PROJECT_UID = "relay-ws-project" +OWNER = "relay-ws-owner" +OTHER = "relay-ws-other" + + +@pytest.fixture(autouse=True) +def _relay_db(): + init_db() + get_table("projects").insert( + { + "uid": PROJECT_UID, + "user_uid": OWNER, + "title": "Relay", + "description": "", + "slug": "relay-ws", + "project_type": "software", + "status": "In Development", + "platforms": "", + "is_private": 0, + "read_only": 0, + "stars": 0, + "created_at": "2026-01-01T00:00:00+00:00", + "deleted_at": None, + "deleted_by": None, + } + ) + yield + get_table("instances").delete(project_uid=PROJECT_UID) + get_table("projects").delete(uid=PROJECT_UID) + + +def _handler(topic: str): + for pattern, compute, interval in live_view_relay.VIEWS: + match = pattern.match(topic) + if match is not None: + return compute, match, interval + raise AssertionError(f"no live view for {topic}") + + +def _workspace(**overrides) -> dict: + row = { + "project_uid": PROJECT_UID, + "name": "ws-relay", + "status": store.ST_RUNNING, + "desired_state": store.DESIRED_RUNNING, + "is_workspace": 1, + "workspace_owner_uid": OWNER, + "ports_json": "[]", + } + row.update(overrides) + return store.create_instance(row) + + +def test_the_workspace_topic_lives_in_the_owner_namespace(): + instance = _workspace() + compute, match, interval = _handler(f"user.{OWNER}.workspace.{instance['uid']}") + assert compute is live_view_relay._workspace_detail + assert interval == 3.0 + payload = run_async(compute(match)) + assert payload["workspace"]["uid"] == instance["uid"] + assert payload["workspace"]["phase"] == provision.PHASE_STARTING + assert payload["workspace"]["editor_ready"] is False + assert payload["editor_url"] == ( + f"/projects/relay-ws/containers/instances/{instance['uid']}/code/" + ) + + +def test_the_workspace_topic_never_answers_another_owner(): + instance = _workspace() + compute, match, _interval = _handler(f"user.{OTHER}.workspace.{instance['uid']}") + assert run_async(compute(match)) is None + + +def test_the_workspace_topic_ignores_a_plain_container(): + instance = _workspace(is_workspace=0) + compute, match, _interval = _handler(f"user.{OWNER}.workspace.{instance['uid']}") + assert run_async(compute(match)) is None + + +def test_the_workspace_topic_ignores_an_unknown_instance(): + compute, match, _interval = _handler(f"user.{OWNER}.workspace.does-not-exist") + assert run_async(compute(match)) is None