Update
DevPlace CI / test (push) Failing after 1h28m53s

This commit is contained in:
2026-09-03 08:47:57 +02:00
parent 70ceb3cf81
commit 3d07a478c3
41 changed files with 1643 additions and 130 deletions
+26 -5
View File
@@ -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 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}/workspace`; the editor itself is proxied at
`/projects/{slug}/containers/instances/{uid}/code/`, and an **Editor** button appears on the project `/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 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 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 Swift toolchains on `PATH`. Both are configurable, and `bash` stays the default profile for
terminals the member opens later. terminals the member opens later.
The workspace opens straight onto the member's files rather than a welcome page, and the editor's **The first boot of a workspace opens the DevPlace welcome page** beside the terminals: a webview
own built-in chat assistant is suppressed so `dpc` is the only agent on offer and every token it introducing the workspace and DevPlace Code (its 900k token context, vision, parallel sub-agents,
spends is ledgered against the member's DevPlace account. `dpc`'s own working files (`.dpc/`, deep research, safety gates and the daily credits it runs on), with example prompts and buttons that
`dpc.log`) are in `SYNC_SKIP_NAMES`, so running an agent on every boot never pollutes the project. 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 **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 seeded settings, so nothing prompts and automatic tasks run. This is a deliberate default with a
+42 -1
View File
@@ -549,7 +549,48 @@ def init_db():
["owner_kind", "owner_id", "scope", "lang"], ["owner_kind", "owner_id", "scope", "lang"],
) )
gateway_usage_ledger = get_table("gateway_usage_ledger") 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): if not gateway_usage_ledger.has_column(column):
gateway_usage_ledger.create_column_by_example(column, example) gateway_usage_ledger.create_column_by_example(column, example)
_index(db, "gateway_usage_ledger", "idx_gw_usage_time", ["created_at"]) _index(db, "gateway_usage_ledger", "idx_gw_usage_time", ["created_at"])
+10 -2
View File
@@ -37,7 +37,10 @@ arrives as a `workspace` notification and states exactly what happens next and w
title="Read workspace", title="Read workspace",
summary=( summary=(
"State, quota usage, idle countdown, tunnels and open moderation flags " "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", auth="user",
params=[ 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/", "editor_url": "/projects/my-project/containers/instances/INSTANCE_UID/code/",
"workspace": { "workspace": {
"uid": "INSTANCE_UID", "uid": "INSTANCE_UID",
"owner_uid": "USER_UID",
"status": "running", "status": "running",
"desired_state": "running",
"phase": "ready",
"phase_label": "Ready",
"editor_ready": True,
"suspended": False, "suspended": False,
"tunnel_name": "brave-otter", "tunnel_name": "brave-otter",
"primary_url": "https://brave-otter.tunnel.pravda.education", "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, "terminal_font_size": 13,
"zoom_level": 0, "zoom_level": 0,
"layout": "standard", "layout": "standard",
"panel_preset": "tall", "panel_preset": "normal",
"boot_agent": "dpc", "boot_agent": "dpc",
"boot_shell": True, "boot_shell": True,
"window_mode": "tab", "window_mode": "tab",
@@ -311,12 +311,18 @@ async def editor_proxy(request: Request, slug: str, uid: str, path: str = ""):
if denial is not None: if denial is not None:
return denial return denial
if instance.get("suspended_at"): 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: 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) host, port = provision.editor_target(instance)
if not host or not port: 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"]) activity.touch(instance["uid"])
prefix = f"/projects/{slug}/containers/instances/{uid}/code" prefix = f"/projects/{slug}/containers/instances/{uid}/code"
return await forward.proxy_http(request, host, port, path, prefix=prefix) return await forward.proxy_http(request, host, port, path, prefix=prefix)
+1 -4
View File
@@ -188,14 +188,11 @@ async def projects_page(
) )
def _editor_launch(project: dict, user: dict) -> dict: def _editor_launch(project: dict, user: dict) -> dict:
from devplacepy.services.containers import store
from devplacepy.services.containers.workspace import editor, provision from devplacepy.services.containers.workspace import editor, provision
blank = {"url": "", "mode": "tab", "width": 0, "height": 0} blank = {"url": "", "mode": "tab", "width": 0, "height": 0}
instance = provision.find_for_project(project["uid"], user["uid"]) instance = provision.find_for_project(project["uid"], user["uid"])
if not instance or instance.get("suspended_at"): if not instance or not provision.editor_ready(instance):
return blank
if instance.get("status") != store.ST_RUNNING:
return blank return blank
slug = project["slug"] or project["uid"] slug = project["slug"] or project["uid"]
profile = editor.resolve(user["uid"], instance) profile = editor.resolve(user["uid"], instance)
+4
View File
@@ -155,8 +155,12 @@ class EditorProfileOut(_Out):
class WorkspaceViewOut(_Out): class WorkspaceViewOut(_Out):
uid: str = "" uid: str = ""
name: str = "" name: str = ""
owner_uid: str = ""
status: str = "" status: str = ""
desired_state: str = "" desired_state: str = ""
phase: str = ""
phase_label: str = ""
editor_ready: bool = False
suspended: bool = False suspended: bool = False
flag_reason: Optional[str] = "" flag_reason: Optional[str] = ""
tunnel_name: Optional[str] = "" tunnel_name: Optional[str] = ""
+2 -1
View File
@@ -162,8 +162,9 @@ Rules: a new hot read-path aggregate follows this exact pattern (module-level `T
| `admin.services.{name}` | 5s | `{service}` | | `admin.services.{name}` | 5s | `{service}` |
| `admin.ai-usage.{hours}` | 15s | `build_analytics(hours)` (hours parsed from the topic) | | `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) | | `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). **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).
+124 -13
View File
@@ -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 extension host, which survives a browser reload and changes when the container restarts, which is
exactly the intended semantic. 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/<hash>[-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` **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, (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 - 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 `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`. 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 **The extension is a built-in, copied to
`/usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace`.** Built-ins are always `/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 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.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 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`) house rule applies unchanged. Seven small classes (`Profile`, `Memory`, `BootTerminals`, `Layout`,
and an `activate` that runs each through `stage()`, which owns the try/catch and logs to a `Welcome`, `Presence`, `Tunnels`) and an `activate` that runs each through `stage()`, which owns the
`DevPlace` output channel. 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 **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 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 `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 spawning its own default `bash`. `Layout.apply(terminal)` therefore resizes only when
terminals actually opened the panel, and `activate` awaits `terminals` before `layout`. Verified by `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 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 **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" 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 ## 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) ## 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 buffered on purpose**: they are bounded by nginx `client_max_body_size`, and streaming them would
force chunked encoding onto arbitrary upstream apps. force chunked encoding onto arbitrary upstream apps.
**`<base>` 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 `<base>` 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 `<base`. The editor route's refusals (`403`/`409`/`502`) carry
`media_type="text/plain"`, because a bare `Response` with no content type makes a browser download
the error as a file instead of showing it.
**One keep-alive client, closed on shutdown.** `forward.client()` is a lazily-created module-level **One keep-alive client, closed on shutdown.** `forward.client()` is a lazily-created module-level
`httpx.AsyncClient` with `httpx.Limits` (the `ChromeStealthClient` pattern), closed by `httpx.AsyncClient` with `httpx.Limits` (the `ChromeStealthClient` pattern), closed by
`forward.close_client()` in the `main.py` lifespan. A client per request meant a fresh pool and TCP `forward.close_client()` in the `main.py` lifespan. A client per request meant a fresh pool and TCP
@@ -614,12 +693,44 @@ button in `.project-detail-actions` via `templates/_editor_open.html` (`target="
`data-editor-*` attributes `EditorLauncher` reads) straight to the code-server proxy `data-editor-*` attributes `EditorLauncher` reads) straight to the code-server proxy
`/projects/{slug}/containers/instances/{uid}/code/`, built by `_editor_url` in `/projects/{slug}/containers/instances/{uid}/code/`, built by `_editor_url` in
`routers/projects/index.py` and carried as `workspace_editor_url` on the context and `routers/projects/index.py` and carried as `workspace_editor_url` on the context and
`ProjectDetailOut`. It is emitted ONLY when the viewer passes `can_open_workspace` AND their `ProjectDetailOut`. It is emitted ONLY when the viewer passes `can_open_workspace` AND
workspace for that project exists, is not suspended, and is `store.ST_RUNNING` - the three states the `provision.editor_ready(instance)` holds: the workspace exists, is not suspended, is
`editor_proxy` route itself refuses (403 suspended, 409 not running, 502 no port), so the button can `store.ST_RUNNING`, AND the editor port answers a TCP connect (`api.editor_reachable`, the same
never open a dead editor. When there is no running workspace the button is absent and the Workspace `tunnel_target` the proxy dials) - the states the `editor_proxy` route itself refuses (403 suspended,
menu item below is the way in (create/start it there). The workspace page's own **Open editor** link 409 not running, 502 no port) plus the boot window in which the container is up but code-server is
opens in a new tab too; keep both in step. not yet listening, so the button can never open a dead editor. When there is no ready workspace the
button is absent and the Workspace menu item below is the way in (create/start it there). The
workspace page's own **Open editor** link opens in a new tab too; keep both in step.
**The workspace page renders a PHASE, and the phase is computed once, server-side.**
`provision.phase(instance, ready)` is a pure function of `suspended_at`, `desired_state`, `status`
and the editor probe: `suspended`; `desired=running` -> `ready` when `running` and reachable, else
`starting` (covers `created`, a stopped row just asked to start, and the running-but-not-listening
boot window); `desired=stopped` -> `stopping` while the container is still `running`/`paused`/
`restarting`, `crashed` when the exit was a crash, else `stopped`. `provision.view` adds `phase`,
`phase_label` (`PHASE_LABELS`) and `editor_ready` (all on `WorkspaceViewOut`, so Devii's
`workspace_status` and the admin console see them too) and reads the probe exactly once per view.
The probe is `socket.create_connection` with a 0.3s timeout: a listening or refusing port answers in
under a millisecond, so the page only pays the timeout on a dropped leg, which is a case the proxy
would 502 on anyway. `workspace.html` renders EVERY phase's controls inside
`.workspace-phase-view[data-phase-view="..."]` blocks (space-separated phases, `stopped crashed`
share the Start form) and shows one via the `hidden` attribute, so the no-JS page is correct and
`WorkspaceManager` only toggles `hidden`, the badge and the launch href - it never rebuilds markup.
`starting` shows a disabled `.btn.is-loading` spinner (the `btn-spinner` pattern from
`IssueReporter`) plus Stop; `ready` shows the `_editor_open.html` link plus Stop; `stopping` a
spinner; `stopped`/`crashed` the Start form (its button carries a spinner span the manager turns on
while the POST is in flight, which is what stops a member pressing Start twenty times).
`WorkspaceManager` MUST be mounted from the template's inline module
(`window.app.workspaceManager = WorkspaceManager.mount()`, the `containers_instance.html` pattern) -
it shipped once as a bare `<script type="module" src>` 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 **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 the `viewer_can_workspace` context flag (`can_open_workspace(project, user)`, set in
+8
View File
@@ -682,6 +682,14 @@ def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
return False 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: def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
try: try:
with stealth.stealth_sync_client(timeout=timeout) as client: with stealth.stealth_sync_client(timeout=timeout) as client:
@@ -1,16 +1,36 @@
// retoor <retoor@molodetz.nl> // retoor <retoor@molodetz.nl>
const crypto = require("crypto");
const fs = require("fs"); const fs = require("fs");
const http = require("http"); const http = require("http");
const https = require("https"); const https = require("https");
const path = require("path");
const vscode = require("vscode"); const vscode = require("vscode");
const AGENT_PATH = "/usr/bin/dpc"; const AGENT_PATH = "/usr/bin/dpc";
const AGENT_TERMINAL = "DevPlace Code"; const AGENT_TERMINAL = "DevPlace Code";
const SHELL_TERMINAL = "pravda@workspace"; const SHELL_TERMINAL = "pravda@workspace";
const BOOT_KEY = "devplace.bootMarker"; 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 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 { class Profile {
constructor() { constructor() {
@@ -18,7 +38,7 @@ class Profile {
{ {
theme: "devplace-dark", theme: "devplace-dark",
layout: "standard", layout: "standard",
panel_preset: "tall", panel_preset: "normal",
boot_agent: "dpc", boot_agent: "dpc",
boot_shell: true, boot_shell: true,
trust_all: true, trust_all: true,
@@ -66,7 +86,45 @@ class Profile {
} }
get panelPreset() { 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() { async open() {
if (this.alreadyBooted()) return false; if (this.alreadyBooted()) {
await this.reveal();
return null;
}
await this.memento.update(BOOT_KEY, this.profile.bootMarker); await this.memento.update(BOOT_KEY, this.profile.bootMarker);
const shell = this.profile.wantsShell ? this.createShell() : null; const shell = this.profile.wantsShell ? this.createShell() : null;
const agent = this.profile.wantsAgent ? this.createAgent() : null; const agent = this.profile.wantsAgent ? this.createAgent() : null;
if (agent) agent.show(true); const focused = agent || shell;
else if (shell) shell.show(true); if (focused) focused.show(true);
return Boolean(agent || shell); 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() { createAgent() {
@@ -112,21 +193,142 @@ class BootTerminals {
} }
class Layout { class Layout {
constructor(profile) { constructor(profile, memento) {
this.profile = profile; 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; const preset = this.profile.panelPreset;
if (!panelIsOpen) return; terminal.show(false);
if (preset === "maximized") { if (preset === "maximized") {
await vscode.commands.executeCommand("workbench.action.toggleMaximizedPanel"); 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; return;
} }
const steps = PANEL_STEPS[preset] === undefined ? 5 : PANEL_STEPS[preset]; this.adopt(
for (let index = 0; index < steps; index += 1) { vscode.window.createWebviewPanel(
await vscode.commands.executeCommand("workbench.action.increaseViewSize"); 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
} }
} }
@@ -374,11 +576,18 @@ async function activate(context) {
context.subscriptions.push(output); context.subscriptions.push(output);
const profile = new Profile(); 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()); await stage(output, "presence", () => new Presence(context).register());
const opened = await stage(output, "terminals", () => await stage(output, "welcome-commands", () => welcome.register());
new BootTerminals(profile, context.workspaceState).open(), 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)); await stage(output, "tunnels", () => new Tunnels(output).watch(context));
} }
@@ -0,0 +1,168 @@
/* retoor <retoor@molodetz.nl> */
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;
}
}
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<!-- retoor <retoor@molodetz.nl> -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src {{cspSource}} https: data:; style-src {{cspSource}}; script-src 'nonce-{{nonce}}';">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{styleUri}}">
<title>Welcome to DevPlace</title>
</head>
<body>
<main class="welcome">
<header class="welcome-hero">
<img class="welcome-logo" src="{{iconUri}}" alt="DevPlace" width="64" height="64">
<div>
<p class="welcome-kicker">DevPlace workspace</p>
<h1>Welcome to your workspace for {{projectTitle}}</h1>
<p class="welcome-lead">
A full editor in your browser, your project files under <code>/app</code>, a login shell,
and DevPlace Code, the coding agent that builds with you. It is {{agentStarted}} in the
<strong>DevPlace Code</strong> terminal at the bottom of this window.
</p>
<div class="welcome-actions">
<button class="welcome-button welcome-button-primary" data-action="terminal">Go to the agent terminal</button>
<button class="welcome-button" data-action="walkthrough">Take the tour</button>
<a class="welcome-button" href="{{dpcSiteUrl}}">About DevPlace Code</a>
</div>
</div>
</header>
<section class="welcome-section">
<h2>Meet dpc, DevPlace Code</h2>
<p>
<code>dpc</code> is an autonomous software engineering agent for the terminal. Tell it what you
want in plain language; it reads and writes the files in <code>/app</code>, 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.
</p>
<div class="welcome-grid">
<article class="welcome-card">
<h3>900k token context</h3>
<p>Hold an entire codebase in one session. Context compaction and archival keep long sessions coherent.</p>
</article>
<article class="welcome-card">
<h3>Vision</h3>
<p>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.</p>
</article>
<article class="welcome-card">
<h3>Agent swarms</h3>
<p>Large tasks are split across parallel sub-agents, each with its own context and live output. Builds, tests and lints run side by side.</p>
</article>
<article class="welcome-card">
<h3>Deep research</h3>
<p>Multi-step web research with cited sources when a task needs facts it does not have yet.</p>
</article>
<article class="welcome-card">
<h3>Safe by design</h3>
<p>Read-before-write on every edit, verification gates after changes, and session resume so you continue exactly where you stopped.</p>
</article>
<article class="welcome-card">
<h3>Your account, your credits</h3>
<p>Every token is metered against your own DevPlace account through the platform gateway, with a free daily allowance. Nothing leaves DevPlace.</p>
</article>
</div>
<p class="welcome-muted">
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
<code>.claude/</code> workflows, agents and slash commands as Claude Code.
<a href="{{dpcDocsUrl}}">Read the dpc documentation</a>.
</p>
</section>
<section class="welcome-section">
<h2>Try it now</h2>
<p>Click into the <strong>DevPlace Code</strong> terminal and type a request. A few to start with:</p>
<ul class="welcome-prompts">
<li><code>Explain this project and list what is missing before it can run.</code></li>
<li><code>Build a FastAPI app in app.py that serves a JSON health check at /, then start it on port 8000.</code></li>
<li><code>Read screenshot.png in /app and implement that layout in index.html.</code></li>
<li><code>Run the test suite, fix every failure, and show me the diff.</code></li>
</ul>
<div class="welcome-actions">
<button class="welcome-button welcome-button-primary" data-action="terminal">Focus the agent terminal</button>
<button class="welcome-button" data-action="agent">Start another agent</button>
</div>
</section>
<section class="welcome-section">
<h2>Inside this workspace</h2>
<div class="welcome-grid">
<article class="welcome-card">
<h3>Your files are your project</h3>
<p>Everything under <code>/app</code> syncs back to your DevPlace project, in both directions, including deletions.</p>
</article>
<article class="welcome-card">
<h3>Install anything</h3>
<p><code>sudo</code> and <code>apt install</code> work with no setup. Python, Rust, Nim and Swift toolchains are preinstalled.</p>
</article>
<article class="welcome-card">
<h3>Publish a port</h3>
<p>Serve on a high port and forward it in the <strong>Ports</strong> view. DevPlace publishes it on a public HTTPS address for you.</p>
</article>
<article class="welcome-card">
<h3>Every folder is trusted</h3>
<p>Nothing opens in Restricted Mode, so tasks and extensions work from the first second.</p>
</article>
</div>
<div class="welcome-actions">
<button class="welcome-button" data-action="tunnels">Show my public tunnels</button>
<button class="welcome-button" data-action="settings">Workspace settings</button>
<button class="welcome-button" data-action="project">Project on DevPlace</button>
<button class="welcome-button" data-action="docs">Editor guide</button>
</div>
</section>
<footer class="welcome-footer">
Reopen this page at any time with <strong>DevPlace: Show the welcome page</strong> from the command palette (F1).
</footer>
</main>
<script nonce="{{nonce}}">
(function () {
var vscode = acquireVsCodeApi();
document.addEventListener("click", function (event) {
var trigger = event.target.closest("[data-action]");
if (!trigger) return;
event.preventDefault();
vscode.postMessage({ action: trigger.getAttribute("data-action") });
});
})();
</script>
</body>
</html>
@@ -2,7 +2,7 @@
"name": "devplace-workspace", "name": "devplace-workspace",
"displayName": "DevPlace", "displayName": "DevPlace",
"description": "DevPlace workspace integration: the dpc coding agent, project links and DevPlace branding.", "description": "DevPlace workspace integration: the dpc coding agent, project links and DevPlace branding.",
"version": "1.0.0", "version": "1.1.0",
"publisher": "devplace", "publisher": "devplace",
"author": "retoor <retoor@molodetz.nl>", "author": "retoor <retoor@molodetz.nl>",
"license": "SEE LICENSE IN https://pravda.education/docs/terms.html", "license": "SEE LICENSE IN https://pravda.education/docs/terms.html",
@@ -83,12 +83,22 @@
"command": "devplace.openDocs", "command": "devplace.openDocs",
"title": "Open the DevPlace editor guide", "title": "Open the DevPlace editor guide",
"category": "DevPlace" "category": "DevPlace"
},
{
"command": "devplace.showWelcome",
"title": "Show the welcome page",
"category": "DevPlace"
},
{
"command": "devplace.openWalkthrough",
"title": "Open the Get started walkthrough",
"category": "DevPlace"
} }
], ],
"viewsWelcome": [ "viewsWelcome": [
{ {
"view": "workbench.explorer.emptyView", "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": [ "walkthroughs": [
@@ -100,7 +110,7 @@
{ {
"id": "agent", "id": "agent",
"title": "Meet dpc, your coding 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": { "media": {
"markdown": "walkthrough/agent.md" "markdown": "walkthrough/agent.md"
}, },
@@ -4,9 +4,12 @@
**DevPlace Code** terminal at the bottom of this window. **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, 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. Every token it spends is metered against your own DevPlace account through the platform AI gateway.
Nothing leaves DevPlace. Nothing leaves DevPlace.
Open another agent at any time from the terminal dropdown, or with **DevPlace: Start DevPlace Code**. 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/).
+14 -7
View File
@@ -136,17 +136,19 @@ def upstream_url(scheme: str, authority: str, path: str, query: str) -> str:
return url return url
def is_root_document(path: str) -> bool:
return not path.strip("/")
def inject_base(body: bytes, prefix: str) -> bytes: def inject_base(body: bytes, prefix: str) -> bytes:
lowered = body.lower() lowered = body.lower()
if b"<base" in lowered: if b"<base" in lowered:
return body return body
tag = f'<base href="{prefix}/">'.encode() tag = f'<base href="{prefix}/">'.encode()
head = lowered.find(b"<head") opening = lowered.find(b"<head")
anchor = ( if opening == -1:
lowered.find(b">", head) opening = lowered.find(b"<html")
if head != -1 anchor = lowered.find(b">", opening) if opening != -1 else -1
else lowered.find(b">", lowered.find(b"<html"))
)
if anchor == -1: if anchor == -1:
return tag + body return tag + body
return body[: anchor + 1] + tag + body[anchor + 1 :] 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) return Response(f"upstream error: {error}", status_code=502)
content_type = upstream.headers.get("content-type", "") content_type = upstream.headers.get("content-type", "")
headers = response_headers(upstream, prefix) 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() body = await upstream.aread()
await upstream.aclose() await upstream.aclose()
content = inject_base(body, prefix) content = inject_base(body, prefix)
@@ -126,7 +126,7 @@ DEFAULTS = {
"terminal_font_size": 13, "terminal_font_size": 13,
"zoom_level": 0, "zoom_level": 0,
"layout": "standard", "layout": "standard",
"panel_preset": "tall", "panel_preset": "normal",
"boot_agent": "dpc", "boot_agent": "dpc",
"boot_shell": True, "boot_shell": True,
"window_mode": "tab", "window_mode": "tab",
@@ -18,6 +18,24 @@ CERT_UNCONFIGURED = (
"molohttp base URL and credentials before this address serves HTTPS" "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() _pending_certificates: set[asyncio.Task] = set()
@@ -223,16 +241,45 @@ def write_manifest(instance: dict) -> None:
return 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: def view(instance: dict) -> dict:
owner_uid = instance.get("workspace_owner_uid", "") owner_uid = instance.get("workspace_owner_uid", "")
limits = quota.resolve(owner_uid, instance) limits = quota.resolve(owner_uid, instance)
disk_used = int(instance.get("disk_bytes") or 0) disk_used = int(instance.get("disk_bytes") or 0)
egress_used = int(instance.get("egress_bytes") or 0) egress_used = int(instance.get("egress_bytes") or 0)
ready = editor_ready(instance)
current = phase(instance, ready)
return { return {
"uid": instance.get("uid", ""), "uid": instance.get("uid", ""),
"name": instance.get("name", ""), "name": instance.get("name", ""),
"owner_uid": owner_uid,
"status": instance.get("status", ""), "status": instance.get("status", ""),
"desired_state": instance.get("desired_state", ""), "desired_state": instance.get("desired_state", ""),
"phase": current,
"phase_label": PHASE_LABELS[current],
"editor_ready": ready,
"suspended": bool(instance.get("suspended_at")), "suspended": bool(instance.get("suspended_at")),
"flag_reason": instance.get("flag_reason", ""), "flag_reason": instance.get("flag_reason", ""),
"tunnel_name": instance.get("tunnel_name", ""), "tunnel_name": instance.get("tunnel_name", ""),
@@ -139,12 +139,14 @@ class WorkspaceService(BaseService):
{"value": "zen", "label": "Zen"}], {"value": "zen", "label": "Zen"}],
group="Editor"), group="Editor"),
ConfigField("workspace_editor_panel_preset", "Terminal panel size", ConfigField("workspace_editor_panel_preset", "Terminal panel size",
type="select", default="tall", type="select", default="normal",
options=[{"value": "short", "label": "Short"}, options=[{"value": "short", "label": "Short"},
{"value": "normal", "label": "Normal"}, {"value": "normal", "label": "Normal (a third of the window)"},
{"value": "tall", "label": "Tall"}, {"value": "tall", "label": "Tall"},
{"value": "maximized", "label": "Maximized"}], {"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", ConfigField("workspace_editor_boot_agent", "Agent on boot", type="select",
default="dpc", default="dpc",
options=[{"value": "dpc", "label": "DevPlace Code (dpc)"}, options=[{"value": "dpc", "label": "DevPlace Code (dpc)"},
@@ -44,7 +44,9 @@ WORKSPACE_ACTIONS: tuple[Action, ...] = (
read_only=True, read_only=True,
summary=( summary=(
"Read a workspace's state, disk and egress usage against quota, idle " "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,), params=(SLUG,),
), ),
+24
View File
@@ -116,6 +116,25 @@ async def _backups(_match: re.Match) -> dict:
return _dashboard(can_download=False) 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 = [ VIEWS = [
(re.compile(r"^container\.list$"), _container_list, 4.0), (re.compile(r"^container\.list$"), _container_list, 4.0),
(re.compile(rf"^project\.(?P<slug>{_SEGMENT})\.containers$"), _project_containers, 3.0), (re.compile(rf"^project\.(?P<slug>{_SEGMENT})\.containers$"), _project_containers, 3.0),
@@ -126,6 +145,11 @@ VIEWS = [
(re.compile(rf"^admin\.services\.(?P<name>{_SEGMENT})$"), _service_detail, 5.0), (re.compile(rf"^admin\.services\.(?P<name>{_SEGMENT})$"), _service_detail, 5.0),
(re.compile(r"^admin\.ai-usage\.(?P<hours>\d+)$"), _ai_usage, 15.0), (re.compile(r"^admin\.ai-usage\.(?P<hours>\d+)$"), _ai_usage, 15.0),
(re.compile(r"^admin\.backups$"), _backups, 8.0), (re.compile(r"^admin\.backups$"), _backups, 8.0),
(
re.compile(rf"^user\.(?P<owner>{_SEGMENT})\.workspace\.(?P<uid>{_SEGMENT})$"),
_workspace_detail,
3.0,
),
] ]
+37
View File
@@ -202,3 +202,40 @@
flex-direction: column; 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;
}
+69 -16
View File
@@ -3,13 +3,22 @@
import { Http } from "./Http.js"; import { Http } from "./Http.js";
import { Poller } from "./Poller.js"; import { Poller } from "./Poller.js";
const IDLE_INTERVAL_MS = 20000;
const TRANSITION_INTERVAL_MS = 2000;
const TRANSITIONAL_PHASES = ["starting", "stopping"];
export class WorkspaceManager { export class WorkspaceManager {
constructor(root) { constructor(root) {
this.root = root; this.root = root;
this.slug = root.dataset.slug; this.slug = root.dataset.slug;
this.uid = root.dataset.workspaceUid || "";
this.ownerUid = root.dataset.ownerUid || "";
this.phase = root.dataset.phase || "";
this.poller = null; this.poller = null;
this.interval = 0;
this.bind(); this.bind();
this.subscribe(); this.subscribe();
this.poll(this.isTransitional(this.phase));
} }
static mount() { static mount() {
@@ -19,50 +28,94 @@ export class WorkspaceManager {
bind() { bind() {
this.root.addEventListener("submit", (event) => { this.root.addEventListener("submit", (event) => {
const form = event.target.closest("form"); const form = event.target.closest("form[data-workspace-action]");
if (!form || form.dataset.confirm || form.dataset.native !== undefined) return; if (!form) return;
event.preventDefault(); event.preventDefault();
this.send(form); this.send(form);
}); });
} }
async send(form) { async send(form) {
const button = form.querySelector("button[type='submit']");
this.setBusy(button, true);
try { try {
await Http.sendForm(form.action, this.params(form)); await Http.sendForm(form.action, this.params(form), { silent: true });
await this.refresh(); await this.refresh();
} catch (error) { } 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) { params(form) {
const params = []; const params = [];
new FormData(form).forEach((value, key) => params.push([key, value])); new FormData(form).forEach((value, key) => params.push([key, value]));
return params; return params;
} }
subscribe() { toast(message, type) {
const uid = this.root.dataset.workspaceUid; if (window.app && window.app.toast) window.app.toast.show(message, { type: type });
if (uid && window.app?.pubsub) {
window.app.pubsub.subscribe(`workspace.${uid}.detail`, () => this.render());
} }
this.poller = new Poller(() => this.refresh(), 20000);
this.poller.start(); subscribe() {
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() { async refresh() {
try {
const data = await Http.getJson(`/projects/${this.slug}/workspace`); const data = await Http.getJson(`/projects/${this.slug}/workspace`);
this.render(data); this.render(data);
} catch (error) {
return;
}
} }
render(data) { render(data) {
if (!data || !data.workspace) return; if (!data || !data.workspace) return;
const state = this.root.querySelector("[data-workspace-status]"); const workspace = data.workspace;
if (state) state.textContent = data.workspace.status || ""; 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);
}
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if item.my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button> <button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if item.my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button>
</form> </form>
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span> <span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{% if user %}{{ item.votes.up - item.votes.down }}{% endif %}</span>
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}"> <form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if item.my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>-</button> <button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if item.my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>-</button>
+3 -3
View File
@@ -4,15 +4,15 @@
<div class="poll-options" role="group" aria-label="Poll options"> <div class="poll-options" role="group" aria-label="Poll options">
{% for opt in _poll.options %} {% for opt in _poll.options %}
<button type="button" class="poll-option{% if _poll.my_choice == opt.uid %} chosen{% endif %}" data-option-uid="{{ opt.uid }}" aria-pressed="{% if _poll.my_choice == opt.uid %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}> <button type="button" class="poll-option{% if _poll.my_choice == opt.uid %} chosen{% endif %}" data-option-uid="{{ opt.uid }}" aria-pressed="{% if _poll.my_choice == opt.uid %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>
<span class="poll-option-bar" style="--poll-pct: {{ opt.pct }}%;"></span> <span class="poll-option-bar" style="--poll-pct: {% if user %}{{ opt.pct }}{% else %}0{% endif %}%;"></span>
<span class="poll-option-label">{{ render_title(opt.label) }}</span> <span class="poll-option-label">{{ render_title(opt.label) }}</span>
<span class="poll-option-meta"> <span class="poll-option-meta">
<span class="poll-option-check" aria-hidden="true"{% if _poll.my_choice != opt.uid %} hidden{% endif %}>&#x2713;</span> <span class="poll-option-check" aria-hidden="true"{% if _poll.my_choice != opt.uid %} hidden{% endif %}>&#x2713;</span>
<span class="poll-option-pct">{{ opt.pct }}%</span> <span class="poll-option-pct">{% if user %}{{ opt.pct }}%{% endif %}</span>
</span> </span>
</button> </button>
{% endfor %} {% endfor %}
</div> </div>
<div class="poll-total">{{ _poll.total }} vote{% if _poll.total != 1 %}s{% endif %}{% if not user %} &middot; Log in to vote{% endif %}</div> <div class="poll-total">{% if user %}{{ _poll.total }} vote{% if _poll.total != 1 %}s{% endif %}{% else %}Log in to vote{% endif %}</div>
</div> </div>
{% endif %} {% endif %}
+2 -2
View File
@@ -40,8 +40,8 @@
<div class="post-actions"> <div class="post-actions">
{% set _uid = item.post['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.post.get('stars', 0) %}{% include "_post_votes.html" %} {% set _uid = item.post['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.post.get('stars', 0) %}{% include "_post_votes.html" %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn" aria-label="{{ item.comment_count }} comments"> <a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn" aria-label="{% if user %}{{ item.comment_count }} comments{% else %}Comments{% endif %}">
<span aria-hidden="true">&#x1F4AC;</span> {{ item.comment_count }} <span aria-hidden="true">&#x1F4AC;</span>{% if user %} {{ item.comment_count }}{% endif %}
</a> </a>
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %} {% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn share"> <a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn share">
+1 -1
View File
@@ -3,7 +3,7 @@
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if _my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}></button> <button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if _my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}></button>
</form> </form>
<span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span> <span class="post-vote-count" data-vote-count="{{ _uid }}">{% if user %}{{ _count }}{% endif %}</span>
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form"> <form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button> <button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button>
+2 -2
View File
@@ -40,8 +40,8 @@
<div class="post-actions"> <div class="post-actions">
{% set _type = "quiz" %}{% set _uid = item.uid %}{% set _my_vote = item.my_vote %}{% set _count = item.stars %}{% set _btn_class = "post-action-btn" %}{% set _stop = true %}{% include "_star_vote.html" %} {% set _type = "quiz" %}{% set _uid = item.uid %}{% set _my_vote = item.my_vote %}{% set _count = item.stars %}{% set _btn_class = "post-action-btn" %}{% set _stop = true %}{% include "_star_vote.html" %}
<a href="{{ item.url }}" class="post-action-btn" aria-label="{{ item.comment_count }} comments"> <a href="{{ item.url }}" class="post-action-btn" aria-label="{% if user %}{{ item.comment_count }} comments{% else %}Comments{% endif %}">
<span aria-hidden="true">&#x1F4AC;</span> {{ item.comment_count }} <span aria-hidden="true">&#x1F4AC;</span>{% if user %} {{ item.comment_count }}{% endif %}
</a> </a>
{% set _type = "quiz" %}{% set _uid = item.uid %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %} {% 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" %} {% set _type = "quiz" %}{% set _uid = item.uid %}{% set _bookmarked = item.bookmarked %}{% include "_bookmark_button.html" %}
+1 -1
View File
@@ -7,7 +7,7 @@
{% for emoji in _emojis %} {% for emoji in _emojis %}
<button type="button" class="reaction-chip{% if emoji in _mine %} reacted{% endif %}" data-reaction-emoji="{{ emoji }}" aria-label="React with {{ emoji }}" aria-pressed="{% if emoji in _mine %}true{% else %}false{% endif %}"{% if not _counts.get(emoji) and emoji not in _mine %} hidden{% endif %}{{ guest_disabled(user) }}> <button type="button" class="reaction-chip{% if emoji in _mine %} reacted{% endif %}" data-reaction-emoji="{{ emoji }}" aria-label="React with {{ emoji }}" aria-pressed="{% if emoji in _mine %}true{% else %}false{% endif %}"{% if not _counts.get(emoji) and emoji not in _mine %} hidden{% endif %}{{ guest_disabled(user) }}>
<span class="reaction-emoji">{{ emoji }}</span> <span class="reaction-emoji">{{ emoji }}</span>
<span class="reaction-count">{{ _counts.get(emoji, 0) }}</span> <span class="reaction-count">{% if user %}{{ _counts.get(emoji, 0) }}{% endif %}</span>
</button> </button>
{% endfor %} {% endfor %}
</div> </div>
+1 -1
View File
@@ -1,4 +1,4 @@
<form method="POST" action="/votes/{{ _type }}/{{ _uid }}" class="inline-form"{% if _stop %} data-stop-propagation{% endif %}> <form method="POST" action="/votes/{{ _type }}/{{ _uid }}" class="inline-form"{% if _stop %} data-stop-propagation{% endif %}>
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="{{ _btn_class }} vote-star{% if _my_vote == 1 %} voted{% endif %}" aria-label="Star" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}><span class="vote-count-value" data-vote-count="{{ _uid }}">{{ _count }}</span></button> <button type="submit" class="{{ _btn_class }} vote-star{% if _my_vote == 1 %} voted{% endif %}" aria-label="Star" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}><span class="vote-count-value" data-vote-count="{{ _uid }}">{% if user %}{{ _count }}{% endif %}</span></button>
</form> </form>
@@ -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. rather than from inside the editor.
Open one from a project's **Workspace** page, or with the **Editor** button on the 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 ## 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 Code** from the terminal dropdown, or run the command
**DevPlace: Start DevPlace Code**. **DevPlace: Start DevPlace Code**.
The workspace opens straight onto your files with the terminal ready, not onto a welcome The first time a workspace boots it also opens the **DevPlace welcome page** beside the
page, and the editor's own built-in chat assistant is switched off: `dpc` is the assistant terminals. It introduces the workspace and DevPlace Code: what `dpc` can do (its 900k token
here, and it runs on your DevPlace account. The files `dpc` keeps for itself (`.dpc/` and context, vision for screenshots and mockups, parallel sub-agents, deep research with cited
`dpc.log`) stay in the container and are never copied into your project. 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 ## 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 - **Theme** - DevPlace Dark, DevPlace Light, or leave it to you (pick any theme from
inside the editor and DevPlace will not touch it again). inside the editor and DevPlace will not touch it again).
- **Layout** - Standard, Terminal focus, or Zen. - **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**. - **Editor font size**, **Terminal font size**, **Zoom level**.
- **Agent on boot** and **Shell on boot**. - **Agent on boot** and **Shell on boot**.
- **Open editor in** - a new tab, a sized window, or a fullscreen window, with the - **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: Open workspace settings** | Your workspace page |
| **DevPlace: Show public tunnels** | Pick one of your live public addresses | | **DevPlace: Show public tunnels** | Pick one of your live public addresses |
| **DevPlace: Open the DevPlace editor guide** | This page | | **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 ## Publishing a port from the editor
+2
View File
@@ -46,8 +46,10 @@
<span class="issue-author">{{ issue.author_username }}</span> <span class="issue-author">{{ issue.author_username }}</span>
<span class="issue-dot">&middot;</span> <span class="issue-dot">&middot;</span>
<span class="issue-date">{{ local_dt(issue.created_at) }}</span> <span class="issue-date">{{ local_dt(issue.created_at) }}</span>
{% if user %}
<span class="issue-dot">&middot;</span> <span class="issue-dot">&middot;</span>
<span class="issue-comments">{{ issue.comments_count }} comment{{ '' if issue.comments_count == 1 else 's' }}</span> <span class="issue-comments">{{ issue.comments_count }} comment{{ '' if issue.comments_count == 1 else 's' }}</span>
{% endif %}
</div> </div>
</div> </div>
{% else %} {% else %}
-2
View File
@@ -210,8 +210,6 @@
{% endif %} {% endif %}
<div class="landing-post-content rendered-content">{{ render_content(item.post['content'][:200] ~ ('...' if item.post['content']|length > 200 else ''), author_is_admin=is_admin(item.author)) }}</div> <div class="landing-post-content rendered-content">{{ render_content(item.post['content'][:200] ~ ('...' if item.post['content']|length > 200 else ''), author_is_admin=is_admin(item.author)) }}</div>
<div class="landing-post-footer"> <div class="landing-post-footer">
<span class="landing-post-stat">+{{ item.stars }}</span>
<span class="landing-post-stat">&#x1F4AC; {{ item.comment_count }}</span>
<a href="/posts/{{ item.slug }}" class="landing-post-open">Open &rarr;</a> <a href="/posts/{{ item.slug }}" class="landing-post-open">Open &rarr;</a>
</div> </div>
</article> </article>
+2
View File
@@ -603,7 +603,9 @@
<a href="/gists/{{ g['slug'] or g['uid'] }}" class="gist-card"> <a href="/gists/{{ g['slug'] or g['uid'] }}" class="gist-card">
<div class="gist-card-header"> <div class="gist-card-header">
<h3 class="gist-card-title">{{ render_title(g['title'], author_is_admin=is_admin(profile_user)) }}</h3> <h3 class="gist-card-title">{{ render_title(g['title'], author_is_admin=is_admin(profile_user)) }}</h3>
{% if user %}
<span class="gist-card-star">&#x2606; {{ g.get('stars', 0) }}</span> <span class="gist-card-star">&#x2606; {{ g.get('stars', 0) }}</span>
{% endif %}
</div> </div>
<div class="gist-card-meta"> <div class="gist-card-meta">
<span class="gist-language-badge">&#x1F4DD; {{ language_name(g['language']) }}</span> <span class="gist-language-badge">&#x1F4DD; {{ language_name(g['language']) }}</span>
+5 -1
View File
@@ -136,7 +136,7 @@
{% if gallery %} {% if gallery %}
<a href="#screenshots" class="project-tab">Screenshots <span class="project-tab-count">{{ gallery | length }}</span></a> <a href="#screenshots" class="project-tab">Screenshots <span class="project-tab-count">{{ gallery | length }}</span></a>
{% endif %} {% endif %}
<a href="#comments" class="project-tab">Comments <span class="project-tab-count">{{ comment_count }}</span></a> <a href="#comments" class="project-tab">Comments{% if user %} <span class="project-tab-count">{{ comment_count }}</span>{% endif %}</a>
<a href="{{ project_url }}/files" class="project-tab">Files <span class="project-tab-count">{{ file_count }}</span></a> <a href="{{ project_url }}/files" class="project-tab">Files <span class="project-tab-count">{{ file_count }}</span></a>
</nav> </nav>
@@ -214,9 +214,13 @@
<div class="project-sidebar-card"> <div class="project-sidebar-card">
<h2 class="project-section-label">Stats</h2> <h2 class="project-section-label">Stats</h2>
<div class="project-stats"> <div class="project-stats">
{% if user %}
<span class="project-stat"><span class="project-stat-value">{{ star_count }}</span> stars</span> <span class="project-stat"><span class="project-stat-value">{{ star_count }}</span> stars</span>
{% endif %}
<span class="project-stat"><a href="#devlog"><span class="project-stat-value">{{ devlog_count }}</span> update{{ '' if devlog_count == 1 else 's' }}</a></span> <span class="project-stat"><a href="#devlog"><span class="project-stat-value">{{ devlog_count }}</span> update{{ '' if devlog_count == 1 else 's' }}</a></span>
{% if user %}
<span class="project-stat"><a href="#comments"><span class="project-stat-value">{{ comment_count }}</span> comment{{ '' if comment_count == 1 else 's' }}</a></span> <span class="project-stat"><a href="#comments"><span class="project-stat-value">{{ comment_count }}</span> comment{{ '' if comment_count == 1 else 's' }}</a></span>
{% endif %}
<span class="project-stat"><a href="{{ project_url }}/files"><span class="project-stat-value">{{ file_count }}</span> file{{ '' if file_count == 1 else 's' }}</a></span> <span class="project-stat"><a href="{{ project_url }}/files"><span class="project-stat-value">{{ file_count }}</span> file{{ '' if file_count == 1 else 's' }}</a></span>
<span class="project-stat"><span class="project-stat-value">{{ fork_count }}</span> fork{{ '' if fork_count == 1 else 's' }}</span> <span class="project-stat"><span class="project-stat-value">{{ fork_count }}</span> fork{{ '' if fork_count == 1 else 's' }}</span>
</div> </div>
+34 -13
View File
@@ -9,6 +9,8 @@
<div class="workspace-page" data-workspace-root <div class="workspace-page" data-workspace-root
data-slug="{{ project.slug or project.uid }}" data-slug="{{ project.slug or project.uid }}"
data-workspace-uid="{{ workspace.uid if has_workspace else '' }}" data-workspace-uid="{{ workspace.uid if has_workspace else '' }}"
data-owner-uid="{{ workspace.owner_uid if has_workspace else '' }}"
data-phase="{{ workspace.phase if has_workspace else '' }}"
data-has-workspace="{{ 1 if has_workspace else 0 }}"> data-has-workspace="{{ 1 if has_workspace else 0 }}">
<h1 class="workspace-title">Workspace: {{ project.title }}</h1> <h1 class="workspace-title">Workspace: {{ project.title }}</h1>
@@ -45,7 +47,10 @@
{% endfor %} {% endfor %}
<div class="card workspace-summary"> <div class="card workspace-summary">
<div class="workspace-state" data-workspace-status>{{ workspace.status }}</div> <div class="workspace-state">
<span class="workspace-badge workspace-badge-{{ workspace.phase }}" data-workspace-phase>{{ workspace.phase_label }}</span>
<span class="workspace-muted" data-workspace-status>{{ workspace.status }}</span>
</div>
<div class="workspace-meters"> <div class="workspace-meters">
<div class="workspace-meter"> <div class="workspace-meter">
<span>Disk</span> <span>Disk</span>
@@ -69,8 +74,8 @@
Last active {{ dt_ago(workspace.last_active_at) }}. Last active {{ dt_ago(workspace.last_active_at) }}.
{% endif %} {% endif %}
</p> </p>
<div class="workspace-actions"> <div class="workspace-actions" data-workspace-actions>
{% if workspace.status == "running" %} <div class="workspace-phase-view" data-phase-view="ready" {{ "hidden" if workspace.phase != "ready" }}>
{% set _url = editor_url %} {% set _url = editor_url %}
{% set _uid = workspace.uid %} {% set _uid = workspace.uid %}
{% set _class = "btn btn-primary" %} {% set _class = "btn btn-primary" %}
@@ -79,14 +84,24 @@
{% set _height = editor.window_height %} {% set _height = editor.window_height %}
{% set _label = "Open editor" %} {% set _label = "Open editor" %}
{% include "_editor_open.html" %} {% include "_editor_open.html" %}
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/stop"> <form method="post" data-workspace-action action="/projects/{{ project.slug or project.uid }}/workspace/stop">
<button type="submit" class="btn">Stop</button> <button type="submit" class="btn">Stop</button>
</form> </form>
{% else %} </div>
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace"> <div class="workspace-phase-view" data-phase-view="starting" {{ "hidden" if workspace.phase != "starting" }}>
<button type="submit" class="btn btn-primary">Start</button> <button type="button" class="btn btn-primary is-loading" disabled><span class="btn-spinner" aria-hidden="true"></span>Starting the editor</button>
<form method="post" data-workspace-action action="/projects/{{ project.slug or project.uid }}/workspace/stop">
<button type="submit" class="btn">Stop</button>
</form> </form>
{% endif %} </div>
<div class="workspace-phase-view" data-phase-view="stopping" {{ "hidden" if workspace.phase != "stopping" }}>
<button type="button" class="btn is-loading" disabled><span class="btn-spinner" aria-hidden="true"></span>Stopping</button>
</div>
<div class="workspace-phase-view" data-phase-view="stopped crashed" {{ "hidden" if workspace.phase not in ("stopped", "crashed") }}>
<form method="post" data-workspace-action action="/projects/{{ project.slug or project.uid }}/workspace">
<button type="submit" class="btn btn-primary"><span class="btn-spinner" aria-hidden="true"></span>Start</button>
</form>
</div>
{% if editor_password %} {% if editor_password %}
<code class="secret-value" id="editor-password">{{ editor_password }}</code> <code class="secret-value" id="editor-password">{{ editor_password }}</code>
<button type="button" class="btn btn-sm" data-copy="editor-password">Copy password</button> <button type="button" class="btn btn-sm" data-copy="editor-password">Copy password</button>
@@ -95,6 +110,9 @@
<button type="submit" data-confirm="Delete this workspace?" data-confirm-danger class="btn btn-danger">Delete</button> <button type="submit" data-confirm="Delete this workspace?" data-confirm-danger class="btn btn-danger">Delete</button>
</form> </form>
</div> </div>
<p class="workspace-muted workspace-phase-hint" data-phase-view="starting" {{ "hidden" if workspace.phase != "starting" }}>
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.
</p>
</div> </div>
<div class="card workspace-editor" data-editor-card> <div class="card workspace-editor" data-editor-card>
@@ -108,11 +126,11 @@
{% if restart_required %} {% if restart_required %}
<div class="workspace-editor-restart"> <div class="workspace-editor-restart">
<span>Your editor settings changed. Restart the workspace to apply them.</span> <span>Your editor settings changed. Restart the workspace to apply them.</span>
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/stop"> <form method="post" data-workspace-action action="/projects/{{ project.slug or project.uid }}/workspace/stop">
<button type="submit" class="btn btn-sm">Stop</button> <button type="submit" class="btn btn-sm"><span class="btn-spinner" aria-hidden="true"></span>Stop</button>
</form> </form>
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace"> <form method="post" data-workspace-action action="/projects/{{ project.slug or project.uid }}/workspace">
<button type="submit" class="btn btn-sm btn-primary">Start</button> <button type="submit" class="btn btn-sm btn-primary"><span class="btn-spinner" aria-hidden="true"></span>Start</button>
</form> </form>
</div> </div>
{% endif %} {% endif %}
@@ -272,5 +290,8 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script type="module" src="{{ static_url('/static/js/WorkspaceManager.js') }}"></script> <script type="module">
import { WorkspaceManager } from "{{ static_url('/static/js/WorkspaceManager.js') }}";
window.app.workspaceManager = WorkspaceManager.mount();
</script>
{% endblock %} {% endblock %}
+56
View File
@@ -107,3 +107,59 @@ def test_http_ingress_proxy(app_server):
httpd.shutdown() httpd.shutdown()
get_table("instances").delete(uid=uid) get_table("instances").delete(uid=uid)
refresh_snapshot() 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"<!doctype html><html><head><title>t</title></head>"
b"<body><script src='app.js'></script></body></html>"
)
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'<base href="/p/{slug}/">' in root.text
nested = requests.get(f"{BASE_URL}/p/{slug}/static/webview/pre/index.html")
assert nested.status_code == 200, nested.text
assert "<base" not in nested.text
finally:
httpd.shutdown()
get_table("instances").delete(uid=uid)
refresh_snapshot()
+73
View File
@@ -1082,3 +1082,76 @@ def test_republishing_a_live_tunnel_keeps_its_certificate():
assert again["uid"] == row["uid"] assert again["uid"] == row["uid"]
assert again["status"] == tunnels.STATUS_ACTIVE assert again["status"] == tunnels.STATUS_ACTIVE
assert again["last_error"] == "" assert again["last_error"] == ""
def _editor_listener():
import socket
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
sock.listen(8)
return sock
def _workspace_json(slug: str, key: str) -> 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
+119 -5
View File
@@ -1,6 +1,7 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import re import re
import socket
import time import time
from uuid import uuid4 from uuid import uuid4
@@ -14,6 +15,9 @@ from devplacepy.services.containers.workspace import flags, naming
from devplacepy.utils import make_combined_slug from devplacepy.utils import make_combined_slug
from tests.conftest import BASE_URL from tests.conftest import BASE_URL
EDITOR_CONTAINER_PORT = 8443
TRANSITION_TIMEOUT_MS = 8000
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _workspaces_on(): def _workspaces_on():
@@ -62,7 +66,20 @@ def _project_for(owner_uid: str, title: str = "WS Project") -> dict:
return row 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 = { payload = {
"project_uid": project["uid"], "project_uid": project["uid"],
"name": "ws-e2e", "name": "ws-e2e",
@@ -73,10 +90,20 @@ def _workspace_for(project: dict, owner_uid: str, **overrides) -> dict:
"tunnel_name": naming.generate(), "tunnel_name": naming.generate(),
"ports_json": '[{"host": 20777, "container": 8080, "proto": "tcp"}]', "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) payload.update(overrides)
return store.create_instance(payload) 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): def test_project_page_offers_the_workspace_entry_point_to_the_owner(alice):
page, user = alice page, user = alice
project = _project_for(_row_for(user)["uid"], "WS Entry") 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() 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 page, user = alice
row = _row_for(user) row = _row_for(user)
project = _project_for(row["uid"], "WS Direct") 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( page.goto(
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded" 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"]) 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 page, user = alice
row = _row_for(user) row = _row_for(user)
project = _project_for(row["uid"], "WS Editor Launch") 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( page.goto(
f"{BASE_URL}/projects/{project['slug']}/workspace", f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded", 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") help_card.wait_for(state="visible")
expect(help_card).to_contain_text("Restricted Mode") expect(help_card).to_contain_text("Restricted Mode")
expect(help_card).to_contain_text("dpc") 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")
+45
View File
@@ -0,0 +1,45 @@
# retoor <retoor@molodetz.nl>
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"<!doctype html><html><head><meta charset=utf-8></head><body>x</body></html>"
injected = forward.inject_base(body, PREFIX)
assert injected.startswith(b"<!doctype html><html><head>" + f'<base href="{PREFIX}/">'.encode())
def test_inject_base_leaves_an_existing_base_alone():
body = b'<html><head><base href="./"></head><body></body></html>'
assert forward.inject_base(body, PREFIX) == body
def test_inject_base_prepends_when_there_is_no_head_or_html():
body = b"<p>fragment</p>"
assert forward.inject_base(body, PREFIX) == f'<base href="{PREFIX}/">'.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"
@@ -0,0 +1,178 @@
# retoor <retoor@molodetz.nl>
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
+92
View File
@@ -0,0 +1,92 @@
# retoor <retoor@molodetz.nl>
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