@@ -474,7 +474,17 @@ A **workspace** is a member-facing container running the DevPlace browser editor
|
||||
container runtime above. It is opened from a project's **Workspace** page and reached at
|
||||
`/projects/{slug}/workspace`; the editor itself is proxied at
|
||||
`/projects/{slug}/containers/instances/{uid}/code/`, and an **Editor** button appears on the project
|
||||
page whenever the workspace is running.
|
||||
page whenever the editor is actually reachable.
|
||||
|
||||
**The workspace page reports the editor's real state, live.** A workspace has a *phase* derived on
|
||||
the server from its desired state, its container status and a TCP probe of the editor port:
|
||||
`stopped`, `starting`, `ready`, `stopping`, `crashed` or `suspended`. The page renders the control
|
||||
that matches the phase, so pressing **Start** turns the button into a spinner reading *Starting the
|
||||
editor* and the **Open editor** link appears only once code-server answers, never while the
|
||||
container is still booting. While a workspace is in transition the page polls every two seconds
|
||||
(twenty seconds otherwise) and also receives pushed updates on the owner's private pub/sub topic, so
|
||||
the label changes on its own without a reload; the project page's **Editor** button follows the same
|
||||
readiness rule. The phase, its label and `editor_ready` are part of the workspace JSON.
|
||||
|
||||
The editor is `code-server`, rebranded as DevPlace end to end: the application name, the browser tab
|
||||
icon and PWA icons, the login page styling, and `product.json` all carry DevPlace, and a bundled
|
||||
@@ -487,10 +497,21 @@ coding agent baked into the image, and a plain login shell beside it with the Py
|
||||
Swift toolchains on `PATH`. Both are configurable, and `bash` stays the default profile for
|
||||
terminals the member opens later.
|
||||
|
||||
The workspace opens straight onto the member's files rather than a welcome page, and the editor's
|
||||
own built-in chat assistant is suppressed so `dpc` is the only agent on offer and every token it
|
||||
spends is ledgered against the member's DevPlace account. `dpc`'s own working files (`.dpc/`,
|
||||
`dpc.log`) are in `SYNC_SKIP_NAMES`, so running an agent on every boot never pollutes the project.
|
||||
**The first boot of a workspace opens the DevPlace welcome page** beside the terminals: a webview
|
||||
introducing the workspace and DevPlace Code (its 900k token context, vision, parallel sub-agents,
|
||||
deep research, safety gates and the daily credits it runs on), with example prompts and buttons that
|
||||
focus the agent terminal, start the walkthrough, show the public tunnels and open the workspace
|
||||
settings. It is shown once per workspace and can be reopened with **DevPlace: Show the welcome
|
||||
page**. The editor's own built-in chat assistant and VS Code's own welcome page stay suppressed so
|
||||
`dpc` is the only agent on offer and every token it spends is ledgered against the member's DevPlace
|
||||
account. `dpc`'s own working files (`.dpc/`, `dpc.log`) are in `SYNC_SKIP_NAMES`, so running an
|
||||
agent on every boot never pollutes the project.
|
||||
|
||||
**The terminal panel gets about a third of the window by default.** Every preset is a fixed number
|
||||
of steps up from the panel's minimum height (`normal`, the default, lands at roughly a third of a
|
||||
typical window; `short` at a fifth, `tall` at about half) and `maximized` fills the editor area.
|
||||
A preset is applied on the first boot of a workspace and again whenever it changes; a height the
|
||||
member drags themselves is kept across restarts.
|
||||
|
||||
**Every workspace is trusted.** VS Code Restricted Mode is disabled at the command line and in the
|
||||
seeded settings, so nothing prompts and automatic tasks run. This is a deliberate default with a
|
||||
|
||||
@@ -549,7 +549,48 @@ def init_db():
|
||||
["owner_kind", "owner_id", "scope", "lang"],
|
||||
)
|
||||
gateway_usage_ledger = get_table("gateway_usage_ledger")
|
||||
for column, example in (("ttft_ms", 0.0), ("inter_token_ms", 0.0)):
|
||||
for column, example in (
|
||||
("created_at", ""),
|
||||
("owner_kind", ""),
|
||||
("owner_id", ""),
|
||||
("backend", ""),
|
||||
("endpoint", ""),
|
||||
("requested_model", ""),
|
||||
("model", ""),
|
||||
("status_code", 0),
|
||||
("success", 0),
|
||||
("error_category", ""),
|
||||
("upstream_latency_ms", 0.0),
|
||||
("gateway_overhead_ms", 0.0),
|
||||
("queue_wait_ms", 0.0),
|
||||
("connect_ms", 0.0),
|
||||
("total_latency_ms", 0.0),
|
||||
("prompt_tokens", 0),
|
||||
("completion_tokens", 0),
|
||||
("cache_hit_tokens", 0),
|
||||
("cache_miss_tokens", 0),
|
||||
("reasoning_tokens", 0),
|
||||
("total_tokens", 0),
|
||||
("tokens_per_second", 0.0),
|
||||
("context_window", 0),
|
||||
("context_utilization", 0.0),
|
||||
("cost_usd", 0.0),
|
||||
("input_cost_usd", 0.0),
|
||||
("output_cost_usd", 0.0),
|
||||
("native_cost", 0),
|
||||
("stream_requested", 0),
|
||||
("temperature", 0.0),
|
||||
("top_p", 0.0),
|
||||
("max_tokens", 0),
|
||||
("has_tools", 0),
|
||||
("retries_attempted", 0),
|
||||
("retry_succeeded", 0),
|
||||
("circuit_open", 0),
|
||||
("user_agent", ""),
|
||||
("app_reference", ""),
|
||||
("ttft_ms", 0.0),
|
||||
("inter_token_ms", 0.0),
|
||||
):
|
||||
if not gateway_usage_ledger.has_column(column):
|
||||
gateway_usage_ledger.create_column_by_example(column, example)
|
||||
_index(db, "gateway_usage_ledger", "idx_gw_usage_time", ["created_at"])
|
||||
|
||||
@@ -37,7 +37,10 @@ arrives as a `workspace` notification and states exactly what happens next and w
|
||||
title="Read workspace",
|
||||
summary=(
|
||||
"State, quota usage, idle countdown, tunnels and open moderation flags "
|
||||
"for your workspace on this project."
|
||||
"for your workspace on this project. The phase (stopped, starting, ready, "
|
||||
"stopping, crashed, suspended) is derived from the desired state, the "
|
||||
"container status and a live probe of the editor port, so editor_ready "
|
||||
"is true only when the editor will actually open."
|
||||
),
|
||||
auth="user",
|
||||
params=[
|
||||
@@ -51,7 +54,12 @@ arrives as a `workspace` notification and states exactly what happens next and w
|
||||
"editor_url": "/projects/my-project/containers/instances/INSTANCE_UID/code/",
|
||||
"workspace": {
|
||||
"uid": "INSTANCE_UID",
|
||||
"owner_uid": "USER_UID",
|
||||
"status": "running",
|
||||
"desired_state": "running",
|
||||
"phase": "ready",
|
||||
"phase_label": "Ready",
|
||||
"editor_ready": True,
|
||||
"suspended": False,
|
||||
"tunnel_name": "brave-otter",
|
||||
"primary_url": "https://brave-otter.tunnel.pravda.education",
|
||||
@@ -131,7 +139,7 @@ arrives as a `workspace` notification and states exactly what happens next and w
|
||||
"terminal_font_size": 13,
|
||||
"zoom_level": 0,
|
||||
"layout": "standard",
|
||||
"panel_preset": "tall",
|
||||
"panel_preset": "normal",
|
||||
"boot_agent": "dpc",
|
||||
"boot_shell": True,
|
||||
"window_mode": "tab",
|
||||
|
||||
@@ -311,12 +311,18 @@ async def editor_proxy(request: Request, slug: str, uid: str, path: str = ""):
|
||||
if denial is not None:
|
||||
return denial
|
||||
if instance.get("suspended_at"):
|
||||
return Response("this workspace is suspended", status_code=403)
|
||||
return Response(
|
||||
"this workspace is suspended", status_code=403, media_type="text/plain"
|
||||
)
|
||||
if instance.get("status") != store.ST_RUNNING:
|
||||
return Response("this workspace is not running", status_code=409)
|
||||
return Response(
|
||||
"this workspace is not running", status_code=409, media_type="text/plain"
|
||||
)
|
||||
host, port = provision.editor_target(instance)
|
||||
if not host or not port:
|
||||
return Response("the editor has no reachable port", status_code=502)
|
||||
return Response(
|
||||
"the editor has no reachable port", status_code=502, media_type="text/plain"
|
||||
)
|
||||
activity.touch(instance["uid"])
|
||||
prefix = f"/projects/{slug}/containers/instances/{uid}/code"
|
||||
return await forward.proxy_http(request, host, port, path, prefix=prefix)
|
||||
|
||||
@@ -188,14 +188,11 @@ async def projects_page(
|
||||
)
|
||||
|
||||
def _editor_launch(project: dict, user: dict) -> dict:
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.workspace import editor, provision
|
||||
|
||||
blank = {"url": "", "mode": "tab", "width": 0, "height": 0}
|
||||
instance = provision.find_for_project(project["uid"], user["uid"])
|
||||
if not instance or instance.get("suspended_at"):
|
||||
return blank
|
||||
if instance.get("status") != store.ST_RUNNING:
|
||||
if not instance or not provision.editor_ready(instance):
|
||||
return blank
|
||||
slug = project["slug"] or project["uid"]
|
||||
profile = editor.resolve(user["uid"], instance)
|
||||
|
||||
@@ -155,8 +155,12 @@ class EditorProfileOut(_Out):
|
||||
class WorkspaceViewOut(_Out):
|
||||
uid: str = ""
|
||||
name: str = ""
|
||||
owner_uid: str = ""
|
||||
status: str = ""
|
||||
desired_state: str = ""
|
||||
phase: str = ""
|
||||
phase_label: str = ""
|
||||
editor_ready: bool = False
|
||||
suspended: bool = False
|
||||
flag_reason: Optional[str] = ""
|
||||
tunnel_name: Optional[str] = ""
|
||||
|
||||
@@ -162,8 +162,9 @@ Rules: a new hot read-path aggregate follows this exact pattern (module-level `T
|
||||
| `admin.services.{name}` | 5s | `{service}` |
|
||||
| `admin.ai-usage.{hours}` | 15s | `build_analytics(hours)` (hours parsed from the topic) |
|
||||
| `admin.backups` | 8s | `routers/admin/backups._dashboard(can_download=False)` (storage, backups, schedules, metrics) |
|
||||
| `user.{owner_uid}.workspace.{uid}` | 3s | `{workspace, editor_url}` (`provision.view`, the same shape `GET /projects/{slug}/workspace` JSON carries; `None` unless the instance is a workspace owned by `owner_uid`) |
|
||||
|
||||
All these topics are admin-only by pub/sub policy (non-`public`, non-`user.{uid}` -> `privileged` required), matching the admin-only pages. Frontend monitors (`ContainerInstance`, `ContainerList`, `ContainerManager`, `BotMonitor`, `ServiceMonitor`, `AiUsageMonitor`, `BackupMonitor`) each `window.app.pubsub.subscribe(topic, render)` in their init and keep a **lengthened HTTP poll (15-30s) as initial-load + fallback** - the relay drives liveness at the cadence above. `AiUsageMonitor` re-subscribes (unsubscribe old, subscribe new) when the window-hours selector changes, since hours is in the topic.
|
||||
All these topics except the last are admin-only by pub/sub policy (non-`public`, non-`user.{uid}` -> `privileged` required), matching the admin-only pages. The workspace topic is the one member-facing view: it sits in the owner's private `user.{uid}.*` namespace so the owner (and admins) can subscribe and nobody else can, and the compute callable re-checks `workspace_owner_uid` against the topic so a guessed uid never leaks another member's workspace. `WorkspaceManager` subscribes to it and keeps a 2s/20s HTTP poll as fallback (see `devplacepy/services/containers/CLAUDE.md`). Frontend monitors (`ContainerInstance`, `ContainerList`, `ContainerManager`, `BotMonitor`, `ServiceMonitor`, `AiUsageMonitor`, `BackupMonitor`) each `window.app.pubsub.subscribe(topic, render)` in their init and keep a **lengthened HTTP poll (15-30s) as initial-load + fallback** - the relay drives liveness at the cadence above. `AiUsageMonitor` re-subscribes (unsubscribe old, subscribe new) when the window-hours selector changes, since hours is in the topic.
|
||||
|
||||
**Container topics never broadcast private-project instances**: `container.list` publishes only public-project rows with `partial: true` (`ContainerList.merge` updates by uid, never removes, so private rows from the authoritative HTTP poll survive), and `project.{slug}.containers` / `container.{uid}.detail` / `container.{uid}.logs` skip private-project targets entirely (owners fall back to their HTTP polls).
|
||||
|
||||
|
||||
@@ -171,6 +171,23 @@ Playwright sessions and finding six terminal tabs. The fallback is now `host-${p
|
||||
extension host, which survives a browser reload and changes when the container restarts, which is
|
||||
exactly the intended semantic.
|
||||
|
||||
**The extension's markers live in a FILE on the state mount, never in a memento (load-bearing).**
|
||||
The six-tab bug was only half fixed by the fallback above: every NEW browser session (a second tab,
|
||||
a reopen from the project page, an incognito window, a Playwright context) makes code-server start
|
||||
another extension host process while the previous one lingers for its 3h reconnection grace, and the
|
||||
new host found no marker, booted again, and the tab list grew by one shell per session - measured on
|
||||
the real image with the shipped extension (one, then two, then three `pravda@workspace` tabs across
|
||||
three sessions, three `Starting extension host process` lines in the container log). Neither
|
||||
`workspaceState` nor `globalState` can carry the marker: in code-server's web workbench both are
|
||||
proxied to the BROWSER's own storage (there is no `state.vscdb` anywhere under the state dir - only
|
||||
per-window `workspaceStorage/<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`
|
||||
(default on): the `--disable-workspace-trust` flag, the seeded `security.workspace.trust.*` settings,
|
||||
and the extension's `contributes.configurationDefaults`. The third is belt and braces only -
|
||||
@@ -186,6 +203,28 @@ SQLite database. Writing it from the host is rejected. The extension drives
|
||||
`workbench.action.toggleMaximizedPanel` / `increaseViewSize` instead, so the four presets are named
|
||||
honestly as presets in the UI. Do not "improve" this by writing `state.vscdb`.
|
||||
|
||||
**The resize commands act on the FOCUSED part, and only the `ViewSize` pair does what its name
|
||||
says.** Measured on the real image with Playwright (1000px viewport, fresh state, terminal focused):
|
||||
`increaseViewSize` x4 took the panel from VS Code's default third (333px) to 573px, `decreaseViewSize`
|
||||
x24 drove it to its 77px floor, and `toggleMaximizedPanel` to 943px - while `increaseViewHeight` x4
|
||||
CRUSHED the panel to 93px and `decreaseViewHeight` x24 grew it to 873px. The first live build called
|
||||
`increaseViewSize` right after `terminal.show(true)` (`preserveFocus`), so the focused part was the
|
||||
Welcome editor and every "tall" boot shrank the terminal to its minimum: the production complaint
|
||||
that the `dpc` terminal was unusably small. `Layout.apply(terminal)` therefore takes the boot
|
||||
terminal `BootTerminals.open` returns and focuses it through its own handle
|
||||
(`terminal.show(false)`) - NOT `workbench.action.terminal.focus`, which raced the still-resolving
|
||||
boot terminals and spawned a stray default `bash` as the first tab on a fresh boot - then makes the
|
||||
size deterministic from the one known baseline: `decreaseViewSize` x`PANEL_FLOOR_STEPS` to the floor,
|
||||
then `increaseViewSize` x`PANEL_STEPS[preset]` (short 2, normal 4 = 317px, tall 7; the increment is
|
||||
60px); `maximized` toggles instead. It always normalizes, even on a brand-new state dir where VS
|
||||
Code's own default would already be a third: the panel size is persisted in the member's BROWSER, not
|
||||
in the state dir, so the only way to repair a panel crushed by the old bug on a member's existing
|
||||
browser profile is to normalize unconditionally (verified with one persistent Chromium profile: 93px
|
||||
under the shipped extension, 317px on the next boot under this one). The preset is applied when the
|
||||
recorded `devplace.panelPreset` marker differs from the profile (first boot under this extension, or
|
||||
the member changed it on DevPlace) and never otherwise, so a height the member drags themselves
|
||||
survives every restart.
|
||||
|
||||
**The extension is a built-in, copied to
|
||||
`/usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace`.** Built-ins are always
|
||||
enabled, cannot be uninstalled, need no install step and survive workspace recreation because they
|
||||
@@ -196,17 +235,37 @@ code-server's Node remote extension host, so `main` applies.
|
||||
|
||||
**`extension.js` is CommonJS, and it is the one file in this repository that may be.** The VS Code
|
||||
extension host loads CommonJS; it is not frontend code and is never served to a browser. Every other
|
||||
house rule applies unchanged. Four small classes (`Profile`, `BootTerminals`, `Layout`, `Presence`)
|
||||
and an `activate` that runs each through `stage()`, which owns the try/catch and logs to a
|
||||
`DevPlace` output channel.
|
||||
house rule applies unchanged. Seven small classes (`Profile`, `Memory`, `BootTerminals`, `Layout`,
|
||||
`Welcome`, `Presence`, `Tunnels`) and an `activate` that runs each through `stage()`, which owns the
|
||||
try/catch and logs to a `DevPlace` output channel.
|
||||
|
||||
**The welcome page is a webview, shown once per state dir.** `Welcome.openOnBoot` opens
|
||||
`media/welcome.html` (placeholders `{{cspSource}}`, `{{nonce}}`, `{{styleUri}}`, `{{iconUri}}`,
|
||||
`{{projectTitle}}`, `{{agentStarted}}`, the two dpc URLs) in a `WebviewPanel` with `preserveFocus`,
|
||||
gated by the `devplace.welcomeShown` global-state memento, so a member sees it on the first boot of
|
||||
a workspace and never again unless they run **DevPlace: Show the welcome page** (or the walkthrough
|
||||
link). Its buttons `postMessage({action})` and the extension maps them through the
|
||||
`WELCOME_COMMANDS` allow-list to commands - a webview never names a command directly. It opens
|
||||
BEFORE `Layout` so the layout's `terminal.focus` leaves the member typing in `dpc`, not reading. The
|
||||
prose is sourced from https://dpc.app.molodetz.nl/ (context size, vision, swarms, deep research,
|
||||
safety gates, daily credits); when dpc's capabilities change, update `welcome.html`, the
|
||||
walkthrough and `/docs/workspace-editor.html` together. `welcome.css` uses only `--vscode-*`
|
||||
variables, which is what keeps it correct in both DevPlace themes.
|
||||
|
||||
**The activation stages are awaited in order, and `Layout` never opens a panel of its own.** Firing
|
||||
them concurrently is what produced a stray third terminal in the first live build: `Layout` called
|
||||
`workbench.action.focusPanel` before `BootTerminals` had created anything, and VS Code answered by
|
||||
spawning its own default `bash`. `Layout.apply(panelIsOpen)` therefore resizes only when the boot
|
||||
terminals actually opened the panel, and `activate` awaits `terminals` before `layout`. Verified by
|
||||
spawning its own default `bash`. `Layout.apply(terminal)` therefore resizes only when
|
||||
`BootTerminals.open` actually created a terminal (it returns the one that got focus, `null` when the
|
||||
boot was skipped), and `activate` awaits `terminals`, then `welcome`, then `layout` (presence and
|
||||
the welcome commands are registered first, tunnels last). A skipped boot still calls
|
||||
`BootTerminals.reveal`, which shows the newest existing terminal (or the first one VS Code revives,
|
||||
waited for with `onDidOpenTerminal` up to `REVEAL_TIMEOUT_MS`) and never creates one, so a member
|
||||
opening the same running workspace from a second browser lands on the panel with the terminals that
|
||||
are already there instead of a closed panel or a duplicate agent. Verified by
|
||||
driving a real container with Playwright: the tab list must read exactly
|
||||
`pravda@workspace` + `DevPlace Code`.
|
||||
`pravda@workspace` + `DevPlace Code`, the editor area must hold exactly one `Welcome to DevPlace`
|
||||
tab, and `document.activeElement` must be the terminal's xterm textarea.
|
||||
|
||||
**A workspace suppresses the editor's own AI assistant.** Recent VS Code ships a chat panel in the
|
||||
secondary sidebar, which opened by default with Microsoft branding, "AI responses may be inaccurate"
|
||||
@@ -373,7 +432,11 @@ Every reconciler status mutation flows through `service._set_status(inst, change
|
||||
|
||||
## Testing without Docker
|
||||
|
||||
Use `FakeBackend` (its `image_exists` returns `True`) + `runtime.set_backend`, and monkeypatch `config.CONTAINER_WORKSPACES_DIR` to a tmp dir. See `tests/unit/services/containers.py` and `tests/api/containers.py` (argv, instance creation on `config.CONTAINER_IMAGE`, image-not-built guard, reconcile matrices, schedule firing, ingress validation + live HTTP proxy, HTTP admin gate).
|
||||
Use `FakeBackend` (its `image_exists` returns `True`) + `runtime.set_backend`, and monkeypatch `config.CONTAINER_WORKSPACES_DIR` to a tmp dir. See `tests/unit/services/containers.py` and `tests/api/containers.py` (argv, instance creation on `config.CONTAINER_IMAGE`, image-not-built guard, reconcile matrices, schedule firing, ingress validation + live HTTP proxy, HTTP admin gate). Editor readiness in tests is a real listening socket: bind `127.0.0.1:0`, `listen()`, and give the instance `editor_port` 8443 with a `ports_json` mapping that container port to the socket's host port (`tests/e2e/projects/workspace.py` `editor_listener`, `tests/api/projects/workspace.py` `_editor_listener`), so `phase` is exercised against the same probe production uses rather than a mock.
|
||||
|
||||
## Testing against the real image (and the one thing never to do)
|
||||
|
||||
The editor extension, the proxied workbench and the panel layout can only be verified on the real `ppy` image. The harness that works: a scratch code-server container started by hand (`docker run --entrypoint /bin/sh ppy:latest -c 'exec code-server ...'` with the same argv `editor.argv` builds, **no `devplace.instance` label**, the state dir and `/app` bind-mounted from a scratch directory, and the extension directory bind-mounted read-only over `/usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace` so an edit is live on the next container start with no `make ppy`), plus a scratch DevPlace server (`DEVPLACE_DATABASE_URL`/`DEVPLACE_DATA_DIR` in the scratch dir, `DEVPLACE_DISABLE_SERVICES=1`, another port) seeded with an instance row whose `container_ip` is the scratch container's bridge IP, then Playwright against both. Simulate lifecycle transitions with `store.update_instance` from a script. **Never enable `ContainerService` against a scratch database on the host daemon**: its orphan sweep `rm -f`s every `devplace.instance`-labelled container its database does not know, which on this host is every production workspace. Measure VS Code layout by bounding box, never by assumption - see the `ViewSize`/`ViewHeight` table above for how wrong an assumption was.
|
||||
|
||||
## Vibe coding on-ramp (user-facing doc)
|
||||
|
||||
@@ -447,6 +510,22 @@ while throttled, so a second call would double the request counter). **Request b
|
||||
buffered on purpose**: they are bounded by nginx `client_max_body_size`, and streaming them would
|
||||
force chunked encoding onto arbitrary upstream apps.
|
||||
|
||||
**`<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
|
||||
`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
|
||||
@@ -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
|
||||
`/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
|
||||
`ProjectDetailOut`. It is emitted ONLY when the viewer passes `can_open_workspace` AND their
|
||||
workspace for that project exists, is not suspended, and is `store.ST_RUNNING` - the three states the
|
||||
`editor_proxy` route itself refuses (403 suspended, 409 not running, 502 no port), so the button can
|
||||
never open a dead editor. When there is no running 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.
|
||||
`ProjectDetailOut`. It is emitted ONLY when the viewer passes `can_open_workspace` AND
|
||||
`provision.editor_ready(instance)` holds: the workspace exists, is not suspended, is
|
||||
`store.ST_RUNNING`, AND the editor port answers a TCP connect (`api.editor_reachable`, the same
|
||||
`tunnel_target` the proxy dials) - the states the `editor_proxy` route itself refuses (403 suspended,
|
||||
409 not running, 502 no port) plus the boot window in which the container is up but code-server is
|
||||
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
|
||||
the `viewer_can_workspace` context flag (`can_open_workspace(project, user)`, set in
|
||||
|
||||
@@ -682,6 +682,14 @@ def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def editor_reachable(instance: dict) -> bool:
|
||||
port = int(instance.get("editor_port") or 0)
|
||||
host, target_port = tunnel_target(instance, port)
|
||||
if not host or not target_port:
|
||||
return False
|
||||
return _port_reachable(host, target_port)
|
||||
|
||||
|
||||
def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
|
||||
try:
|
||||
with stealth.stealth_sync_client(timeout=timeout) as client:
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
const vscode = require("vscode");
|
||||
|
||||
const AGENT_PATH = "/usr/bin/dpc";
|
||||
const AGENT_TERMINAL = "DevPlace Code";
|
||||
const SHELL_TERMINAL = "pravda@workspace";
|
||||
const BOOT_KEY = "devplace.bootMarker";
|
||||
const PANEL_STEPS = { short: 0, normal: 2, tall: 5, maximized: 0 };
|
||||
const PRESET_KEY = "devplace.panelPreset";
|
||||
const WELCOME_KEY = "devplace.welcomeShown";
|
||||
const PANEL_STEPS = { short: 2, normal: 4, tall: 7, maximized: 0 };
|
||||
const PANEL_FLOOR_STEPS = 24;
|
||||
const REVEAL_TIMEOUT_MS = 15000;
|
||||
const PUBLISH_TIMEOUT_MS = 20000;
|
||||
const DPC_SITE_URL = "https://dpc.app.molodetz.nl/";
|
||||
const DPC_DOCS_URL = "https://dpc.app.molodetz.nl/docs";
|
||||
const MEMORY_FILE = "devplace-extension.json";
|
||||
const WELCOME_VIEW_TYPE = "devplace.welcome";
|
||||
const WELCOME_TITLE = "Welcome to DevPlace";
|
||||
const WELCOME_COMMANDS = {
|
||||
agent: "devplace.runAgent",
|
||||
terminal: "workbench.action.terminal.focus",
|
||||
tunnels: "devplace.showTunnels",
|
||||
settings: "devplace.openWorkspacePage",
|
||||
project: "devplace.openProject",
|
||||
docs: "devplace.openDocs",
|
||||
walkthrough: "devplace.openWalkthrough",
|
||||
};
|
||||
|
||||
class Profile {
|
||||
constructor() {
|
||||
@@ -18,7 +38,7 @@ class Profile {
|
||||
{
|
||||
theme: "devplace-dark",
|
||||
layout: "standard",
|
||||
panel_preset: "tall",
|
||||
panel_preset: "normal",
|
||||
boot_agent: "dpc",
|
||||
boot_shell: true,
|
||||
trust_all: true,
|
||||
@@ -66,7 +86,45 @@ class Profile {
|
||||
}
|
||||
|
||||
get panelPreset() {
|
||||
return this.data.panel_preset || "tall";
|
||||
return this.data.panel_preset || "normal";
|
||||
}
|
||||
|
||||
get projectTitle() {
|
||||
return process.env.DEVPLACE_PROJECT_TITLE || "your project";
|
||||
}
|
||||
}
|
||||
|
||||
class Memory {
|
||||
constructor(fallback) {
|
||||
this.fallback = fallback;
|
||||
const directory = process.env.DEVPLACE_WORKSPACE_STATE_DIR || "";
|
||||
this.path = directory ? path.join(directory, MEMORY_FILE) : "";
|
||||
}
|
||||
|
||||
read() {
|
||||
if (!this.path) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(this.path, "utf8"));
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (error) {
|
||||
return error.code === "ENOENT" ? {} : null;
|
||||
}
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const stored = this.read();
|
||||
if (stored === null) return this.fallback.get(key);
|
||||
return stored[key];
|
||||
}
|
||||
|
||||
async update(key, value) {
|
||||
const stored = this.read();
|
||||
if (stored === null) {
|
||||
await this.fallback.update(key, value);
|
||||
return;
|
||||
}
|
||||
stored[key] = value;
|
||||
fs.writeFileSync(this.path, JSON.stringify(stored, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,13 +139,36 @@ class BootTerminals {
|
||||
}
|
||||
|
||||
async open() {
|
||||
if (this.alreadyBooted()) return false;
|
||||
if (this.alreadyBooted()) {
|
||||
await this.reveal();
|
||||
return null;
|
||||
}
|
||||
await this.memento.update(BOOT_KEY, this.profile.bootMarker);
|
||||
const shell = this.profile.wantsShell ? this.createShell() : null;
|
||||
const agent = this.profile.wantsAgent ? this.createAgent() : null;
|
||||
if (agent) agent.show(true);
|
||||
else if (shell) shell.show(true);
|
||||
return Boolean(agent || shell);
|
||||
const focused = agent || shell;
|
||||
if (focused) focused.show(true);
|
||||
return focused;
|
||||
}
|
||||
|
||||
async reveal() {
|
||||
const existing = vscode.window.terminals;
|
||||
if (existing.length) {
|
||||
existing[existing.length - 1].show(true);
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
listener.dispose();
|
||||
resolve();
|
||||
}, REVEAL_TIMEOUT_MS);
|
||||
const listener = vscode.window.onDidOpenTerminal((terminal) => {
|
||||
clearTimeout(timer);
|
||||
listener.dispose();
|
||||
terminal.show(true);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createAgent() {
|
||||
@@ -112,21 +193,142 @@ class BootTerminals {
|
||||
}
|
||||
|
||||
class Layout {
|
||||
constructor(profile) {
|
||||
constructor(profile, memento) {
|
||||
this.profile = profile;
|
||||
this.memento = memento;
|
||||
}
|
||||
|
||||
async apply(panelIsOpen) {
|
||||
alreadyApplied() {
|
||||
return this.memento.get(PRESET_KEY) === this.profile.panelPreset;
|
||||
}
|
||||
|
||||
async apply(terminal) {
|
||||
if (!terminal || this.alreadyApplied()) return false;
|
||||
const preset = this.profile.panelPreset;
|
||||
if (!panelIsOpen) return;
|
||||
terminal.show(false);
|
||||
if (preset === "maximized") {
|
||||
await vscode.commands.executeCommand("workbench.action.toggleMaximizedPanel");
|
||||
} else {
|
||||
const steps = PANEL_STEPS[preset] === undefined ? PANEL_STEPS.normal : PANEL_STEPS[preset];
|
||||
await this.resize("workbench.action.decreaseViewSize", PANEL_FLOOR_STEPS);
|
||||
await this.resize("workbench.action.increaseViewSize", steps);
|
||||
}
|
||||
await this.memento.update(PRESET_KEY, preset);
|
||||
return true;
|
||||
}
|
||||
|
||||
async resize(command, steps) {
|
||||
for (let index = 0; index < steps; index += 1) {
|
||||
await vscode.commands.executeCommand(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Welcome {
|
||||
constructor(context, profile, output, memory) {
|
||||
this.context = context;
|
||||
this.profile = profile;
|
||||
this.output = output;
|
||||
this.memory = memory;
|
||||
this.panel = null;
|
||||
this.mediaUri = vscode.Uri.joinPath(context.extensionUri, "media");
|
||||
}
|
||||
|
||||
alreadyShown() {
|
||||
return this.memory.get(WELCOME_KEY) === true;
|
||||
}
|
||||
|
||||
register() {
|
||||
this.context.subscriptions.push(
|
||||
vscode.window.registerWebviewPanelSerializer(WELCOME_VIEW_TYPE, {
|
||||
deserializeWebviewPanel: (panel) => this.adopt(panel),
|
||||
}),
|
||||
vscode.commands.registerCommand("devplace.showWelcome", () => this.open(false)),
|
||||
vscode.commands.registerCommand("devplace.openWalkthrough", () =>
|
||||
vscode.commands.executeCommand(
|
||||
"workbench.action.openWalkthrough",
|
||||
"devplace.devplace-workspace#devplace.getStarted",
|
||||
false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async openOnBoot() {
|
||||
if (this.alreadyShown()) return false;
|
||||
await this.memory.update(WELCOME_KEY, true);
|
||||
this.open(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
open(preserveFocus) {
|
||||
if (this.panel) {
|
||||
this.panel.reveal(undefined, preserveFocus);
|
||||
return;
|
||||
}
|
||||
const steps = PANEL_STEPS[preset] === undefined ? 5 : PANEL_STEPS[preset];
|
||||
for (let index = 0; index < steps; index += 1) {
|
||||
await vscode.commands.executeCommand("workbench.action.increaseViewSize");
|
||||
this.adopt(
|
||||
vscode.window.createWebviewPanel(
|
||||
WELCOME_VIEW_TYPE,
|
||||
WELCOME_TITLE,
|
||||
{ viewColumn: vscode.ViewColumn.One, preserveFocus: Boolean(preserveFocus) },
|
||||
{ enableScripts: true, localResourceRoots: [this.mediaUri] },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
adopt(panel) {
|
||||
this.panel = panel;
|
||||
panel.iconPath = vscode.Uri.joinPath(this.mediaUri, "devplace-icon.png");
|
||||
panel.webview.options = { enableScripts: true, localResourceRoots: [this.mediaUri] };
|
||||
panel.webview.html = this.html(panel.webview);
|
||||
panel.webview.onDidReceiveMessage(
|
||||
(message) => this.onMessage(message),
|
||||
null,
|
||||
this.context.subscriptions,
|
||||
);
|
||||
panel.onDidDispose(
|
||||
() => {
|
||||
if (this.panel === panel) this.panel = null;
|
||||
},
|
||||
null,
|
||||
this.context.subscriptions,
|
||||
);
|
||||
}
|
||||
|
||||
onMessage(message) {
|
||||
const command = WELCOME_COMMANDS[message && message.action];
|
||||
if (!command) return;
|
||||
vscode.commands.executeCommand(command).then(undefined, (error) =>
|
||||
this.output.appendLine(`welcome: ${command} failed: ${error}`),
|
||||
);
|
||||
}
|
||||
|
||||
html(webview) {
|
||||
const template = fs.readFileSync(
|
||||
path.join(this.mediaUri.fsPath, "welcome.html"),
|
||||
"utf8",
|
||||
);
|
||||
const values = {
|
||||
cspSource: webview.cspSource,
|
||||
nonce: crypto.randomBytes(16).toString("hex"),
|
||||
styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.mediaUri, "welcome.css")).toString(),
|
||||
iconUri: webview.asWebviewUri(vscode.Uri.joinPath(this.mediaUri, "devplace-icon.png")).toString(),
|
||||
projectTitle: this.profile.projectTitle,
|
||||
dpcSiteUrl: DPC_SITE_URL,
|
||||
dpcDocsUrl: DPC_DOCS_URL,
|
||||
agentStarted: this.profile.wantsAgent ? "already running" : "one command away",
|
||||
};
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) =>
|
||||
key in values ? this.escape(values[key]) : match,
|
||||
);
|
||||
}
|
||||
|
||||
escape(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,11 +576,18 @@ async function activate(context) {
|
||||
context.subscriptions.push(output);
|
||||
const profile = new Profile();
|
||||
|
||||
const memory = new Memory(context.globalState);
|
||||
const welcome = new Welcome(context, profile, output, memory);
|
||||
|
||||
await stage(output, "presence", () => new Presence(context).register());
|
||||
const opened = await stage(output, "terminals", () =>
|
||||
new BootTerminals(profile, context.workspaceState).open(),
|
||||
await stage(output, "welcome-commands", () => welcome.register());
|
||||
const focused = await stage(output, "terminals", () =>
|
||||
new BootTerminals(profile, memory).open(),
|
||||
);
|
||||
await stage(output, "welcome", () => welcome.openOnBoot());
|
||||
await stage(output, "layout", () =>
|
||||
new Layout(profile, memory).apply(focused || null),
|
||||
);
|
||||
await stage(output, "layout", () => new Layout(profile).apply(Boolean(opened)));
|
||||
await stage(output, "tunnels", () => new Tunnels(output).watch(context));
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
"displayName": "DevPlace",
|
||||
"description": "DevPlace workspace integration: the dpc coding agent, project links and DevPlace branding.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"publisher": "devplace",
|
||||
"author": "retoor <retoor@molodetz.nl>",
|
||||
"license": "SEE LICENSE IN https://pravda.education/docs/terms.html",
|
||||
@@ -83,12 +83,22 @@
|
||||
"command": "devplace.openDocs",
|
||||
"title": "Open the DevPlace editor guide",
|
||||
"category": "DevPlace"
|
||||
},
|
||||
{
|
||||
"command": "devplace.showWelcome",
|
||||
"title": "Show the welcome page",
|
||||
"category": "DevPlace"
|
||||
},
|
||||
{
|
||||
"command": "devplace.openWalkthrough",
|
||||
"title": "Open the Get started walkthrough",
|
||||
"category": "DevPlace"
|
||||
}
|
||||
],
|
||||
"viewsWelcome": [
|
||||
{
|
||||
"view": "workbench.explorer.emptyView",
|
||||
"contents": "This workspace holds your DevPlace project files.\n[Open project on DevPlace](command:devplace.openProject)\n[Start DevPlace Code](command:devplace.runAgent)"
|
||||
"contents": "This workspace holds your DevPlace project files.\n[Open project on DevPlace](command:devplace.openProject)\n[Start DevPlace Code](command:devplace.runAgent)\n[Show the welcome page](command:devplace.showWelcome)"
|
||||
}
|
||||
],
|
||||
"walkthroughs": [
|
||||
@@ -100,7 +110,7 @@
|
||||
{
|
||||
"id": "agent",
|
||||
"title": "Meet dpc, your coding agent",
|
||||
"description": "A DevPlace Code terminal is already running. Ask it to build something.\n[Start another agent](command:devplace.runAgent)",
|
||||
"description": "A DevPlace Code terminal is already running. Ask it to build something.\n[Start another agent](command:devplace.runAgent)\n[Show the welcome page](command:devplace.showWelcome)",
|
||||
"media": {
|
||||
"markdown": "walkthrough/agent.md"
|
||||
},
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
**DevPlace Code** terminal at the bottom of this window.
|
||||
|
||||
Ask it for what you want in plain language. It reads and writes the files in `/app`, runs commands,
|
||||
and installs what it needs.
|
||||
and installs what it needs. It holds up to 900k tokens of context, reads screenshots and mockups,
|
||||
splits large tasks across parallel sub-agents, and researches the web with cited sources.
|
||||
|
||||
Every token it spends is metered against your own DevPlace account through the platform AI gateway.
|
||||
Nothing leaves DevPlace.
|
||||
|
||||
Open another agent at any time from the terminal dropdown, or with **DevPlace: Start DevPlace Code**.
|
||||
The welcome page that opened on your first boot has example prompts; reopen it with
|
||||
**DevPlace: Show the welcome page**, or read more at [dpc.app.molodetz.nl](https://dpc.app.molodetz.nl/).
|
||||
|
||||
@@ -136,17 +136,19 @@ def upstream_url(scheme: str, authority: str, path: str, query: str) -> str:
|
||||
return url
|
||||
|
||||
|
||||
def is_root_document(path: str) -> bool:
|
||||
return not path.strip("/")
|
||||
|
||||
|
||||
def inject_base(body: bytes, prefix: str) -> bytes:
|
||||
lowered = body.lower()
|
||||
if b"<base" in lowered:
|
||||
return body
|
||||
tag = f'<base href="{prefix}/">'.encode()
|
||||
head = lowered.find(b"<head")
|
||||
anchor = (
|
||||
lowered.find(b">", head)
|
||||
if head != -1
|
||||
else lowered.find(b">", lowered.find(b"<html"))
|
||||
)
|
||||
opening = lowered.find(b"<head")
|
||||
if opening == -1:
|
||||
opening = lowered.find(b"<html")
|
||||
anchor = lowered.find(b">", opening) if opening != -1 else -1
|
||||
if anchor == -1:
|
||||
return tag + body
|
||||
return body[: anchor + 1] + tag + body[anchor + 1 :]
|
||||
@@ -215,7 +217,12 @@ async def proxy_http(
|
||||
return Response(f"upstream error: {error}", status_code=502)
|
||||
content_type = upstream.headers.get("content-type", "")
|
||||
headers = response_headers(upstream, prefix)
|
||||
if prefix and rewrite_html and "text/html" in content_type.lower():
|
||||
if (
|
||||
prefix
|
||||
and rewrite_html
|
||||
and is_root_document(path)
|
||||
and "text/html" in content_type.lower()
|
||||
):
|
||||
body = await upstream.aread()
|
||||
await upstream.aclose()
|
||||
content = inject_base(body, prefix)
|
||||
|
||||
@@ -126,7 +126,7 @@ DEFAULTS = {
|
||||
"terminal_font_size": 13,
|
||||
"zoom_level": 0,
|
||||
"layout": "standard",
|
||||
"panel_preset": "tall",
|
||||
"panel_preset": "normal",
|
||||
"boot_agent": "dpc",
|
||||
"boot_shell": True,
|
||||
"window_mode": "tab",
|
||||
|
||||
@@ -18,6 +18,24 @@ CERT_UNCONFIGURED = (
|
||||
"molohttp base URL and credentials before this address serves HTTPS"
|
||||
)
|
||||
|
||||
PHASE_SUSPENDED = "suspended"
|
||||
PHASE_STARTING = "starting"
|
||||
PHASE_READY = "ready"
|
||||
PHASE_STOPPING = "stopping"
|
||||
PHASE_STOPPED = "stopped"
|
||||
PHASE_CRASHED = "crashed"
|
||||
|
||||
PHASE_LABELS = {
|
||||
PHASE_SUSPENDED: "Suspended",
|
||||
PHASE_STARTING: "Starting",
|
||||
PHASE_READY: "Ready",
|
||||
PHASE_STOPPING: "Stopping",
|
||||
PHASE_STOPPED: "Stopped",
|
||||
PHASE_CRASHED: "Crashed",
|
||||
}
|
||||
|
||||
TRANSITIONAL_PHASES = (PHASE_STARTING, PHASE_STOPPING)
|
||||
|
||||
_pending_certificates: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
@@ -223,16 +241,45 @@ def write_manifest(instance: dict) -> None:
|
||||
return
|
||||
|
||||
|
||||
def editor_ready(instance: dict) -> bool:
|
||||
if instance.get("suspended_at"):
|
||||
return False
|
||||
if instance.get("status") != store.ST_RUNNING:
|
||||
return False
|
||||
return api.editor_reachable(instance)
|
||||
|
||||
|
||||
def phase(instance: dict, ready: bool) -> str:
|
||||
if instance.get("suspended_at"):
|
||||
return PHASE_SUSPENDED
|
||||
status = instance.get("status") or ""
|
||||
if instance.get("desired_state") == store.DESIRED_RUNNING:
|
||||
if status == store.ST_RUNNING and ready:
|
||||
return PHASE_READY
|
||||
return PHASE_STARTING
|
||||
if status in (store.ST_RUNNING, store.ST_PAUSED, store.ST_RESTARTING):
|
||||
return PHASE_STOPPING
|
||||
if status == store.ST_CRASHED:
|
||||
return PHASE_CRASHED
|
||||
return PHASE_STOPPED
|
||||
|
||||
|
||||
def view(instance: dict) -> dict:
|
||||
owner_uid = instance.get("workspace_owner_uid", "")
|
||||
limits = quota.resolve(owner_uid, instance)
|
||||
disk_used = int(instance.get("disk_bytes") or 0)
|
||||
egress_used = int(instance.get("egress_bytes") or 0)
|
||||
ready = editor_ready(instance)
|
||||
current = phase(instance, ready)
|
||||
return {
|
||||
"uid": instance.get("uid", ""),
|
||||
"name": instance.get("name", ""),
|
||||
"owner_uid": owner_uid,
|
||||
"status": instance.get("status", ""),
|
||||
"desired_state": instance.get("desired_state", ""),
|
||||
"phase": current,
|
||||
"phase_label": PHASE_LABELS[current],
|
||||
"editor_ready": ready,
|
||||
"suspended": bool(instance.get("suspended_at")),
|
||||
"flag_reason": instance.get("flag_reason", ""),
|
||||
"tunnel_name": instance.get("tunnel_name", ""),
|
||||
|
||||
@@ -139,12 +139,14 @@ class WorkspaceService(BaseService):
|
||||
{"value": "zen", "label": "Zen"}],
|
||||
group="Editor"),
|
||||
ConfigField("workspace_editor_panel_preset", "Terminal panel size",
|
||||
type="select", default="tall",
|
||||
type="select", default="normal",
|
||||
options=[{"value": "short", "label": "Short"},
|
||||
{"value": "normal", "label": "Normal"},
|
||||
{"value": "normal", "label": "Normal (a third of the window)"},
|
||||
{"value": "tall", "label": "Tall"},
|
||||
{"value": "maximized", "label": "Maximized"}],
|
||||
group="Editor"),
|
||||
group="Editor",
|
||||
help="Applied on the first boot of a workspace and again whenever "
|
||||
"the preset changes; a size the member drags themselves is kept."),
|
||||
ConfigField("workspace_editor_boot_agent", "Agent on boot", type="select",
|
||||
default="dpc",
|
||||
options=[{"value": "dpc", "label": "DevPlace Code (dpc)"},
|
||||
|
||||
@@ -44,7 +44,9 @@ WORKSPACE_ACTIONS: tuple[Action, ...] = (
|
||||
read_only=True,
|
||||
summary=(
|
||||
"Read a workspace's state, disk and egress usage against quota, idle "
|
||||
"countdown, tunnels and any open moderation flags."
|
||||
"countdown, tunnels and any open moderation flags. The phase (stopped, "
|
||||
"starting, ready, stopping, crashed, suspended) and editor_ready come "
|
||||
"from a live probe of the editor port, so ready means the editor opens."
|
||||
),
|
||||
params=(SLUG,),
|
||||
),
|
||||
|
||||
@@ -116,6 +116,25 @@ async def _backups(_match: re.Match) -> dict:
|
||||
return _dashboard(can_download=False)
|
||||
|
||||
|
||||
async def _workspace_detail(match: re.Match) -> Optional[dict]:
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.workspace import provision
|
||||
|
||||
inst = store.get_instance(match.group("uid"))
|
||||
if not inst or not inst.get("is_workspace"):
|
||||
return None
|
||||
if inst.get("workspace_owner_uid") != match.group("owner"):
|
||||
return None
|
||||
project = _instance_project(inst)
|
||||
slug = project.get("slug") or project.get("uid") or ""
|
||||
return {
|
||||
"workspace": provision.view(inst),
|
||||
"editor_url": (
|
||||
f"/projects/{slug}/containers/instances/{inst['uid']}/code/" if slug else ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
VIEWS = [
|
||||
(re.compile(r"^container\.list$"), _container_list, 4.0),
|
||||
(re.compile(rf"^project\.(?P<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(r"^admin\.ai-usage\.(?P<hours>\d+)$"), _ai_usage, 15.0),
|
||||
(re.compile(r"^admin\.backups$"), _backups, 8.0),
|
||||
(
|
||||
re.compile(rf"^user\.(?P<owner>{_SEGMENT})\.workspace\.(?P<uid>{_SEGMENT})$"),
|
||||
_workspace_detail,
|
||||
3.0,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -202,3 +202,40 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-state {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.workspace-badge-ready {
|
||||
background: var(--success);
|
||||
color: var(--bg-card);
|
||||
}
|
||||
|
||||
.workspace-badge-starting,
|
||||
.workspace-badge-stopping {
|
||||
background: var(--warning);
|
||||
color: var(--bg-card);
|
||||
}
|
||||
|
||||
.workspace-badge-crashed,
|
||||
.workspace-badge-suspended {
|
||||
background: var(--danger);
|
||||
color: var(--bg-card);
|
||||
}
|
||||
|
||||
.workspace-phase-view {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.workspace-phase-view[hidden],
|
||||
.workspace-phase-hint[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace-phase-hint {
|
||||
margin: var(--space-xs) 0 0;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,22 @@
|
||||
import { Http } from "./Http.js";
|
||||
import { Poller } from "./Poller.js";
|
||||
|
||||
const IDLE_INTERVAL_MS = 20000;
|
||||
const TRANSITION_INTERVAL_MS = 2000;
|
||||
const TRANSITIONAL_PHASES = ["starting", "stopping"];
|
||||
|
||||
export class WorkspaceManager {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.slug = root.dataset.slug;
|
||||
this.uid = root.dataset.workspaceUid || "";
|
||||
this.ownerUid = root.dataset.ownerUid || "";
|
||||
this.phase = root.dataset.phase || "";
|
||||
this.poller = null;
|
||||
this.interval = 0;
|
||||
this.bind();
|
||||
this.subscribe();
|
||||
this.poll(this.isTransitional(this.phase));
|
||||
}
|
||||
|
||||
static mount() {
|
||||
@@ -19,50 +28,94 @@ export class WorkspaceManager {
|
||||
|
||||
bind() {
|
||||
this.root.addEventListener("submit", (event) => {
|
||||
const form = event.target.closest("form");
|
||||
if (!form || form.dataset.confirm || form.dataset.native !== undefined) return;
|
||||
const form = event.target.closest("form[data-workspace-action]");
|
||||
if (!form) return;
|
||||
event.preventDefault();
|
||||
this.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
async send(form) {
|
||||
const button = form.querySelector("button[type='submit']");
|
||||
this.setBusy(button, true);
|
||||
try {
|
||||
await Http.sendForm(form.action, this.params(form));
|
||||
await Http.sendForm(form.action, this.params(form), { silent: true });
|
||||
await this.refresh();
|
||||
} catch (error) {
|
||||
window.app?.toast?.show(error.message || "Action failed", { type: "error" });
|
||||
this.toast(error.message || "Action failed", "error");
|
||||
} finally {
|
||||
this.setBusy(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
setBusy(button, busy) {
|
||||
if (!button) return;
|
||||
button.disabled = busy;
|
||||
button.classList.toggle("is-loading", busy);
|
||||
}
|
||||
|
||||
params(form) {
|
||||
const params = [];
|
||||
new FormData(form).forEach((value, key) => params.push([key, value]));
|
||||
return params;
|
||||
}
|
||||
|
||||
subscribe() {
|
||||
const uid = this.root.dataset.workspaceUid;
|
||||
if (uid && window.app?.pubsub) {
|
||||
window.app.pubsub.subscribe(`workspace.${uid}.detail`, () => this.render());
|
||||
toast(message, type) {
|
||||
if (window.app && window.app.toast) window.app.toast.show(message, { type: type });
|
||||
}
|
||||
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() {
|
||||
try {
|
||||
const data = await Http.getJson(`/projects/${this.slug}/workspace`);
|
||||
this.render(data);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
render(data) {
|
||||
if (!data || !data.workspace) return;
|
||||
const state = this.root.querySelector("[data-workspace-status]");
|
||||
if (state) state.textContent = data.workspace.status || "";
|
||||
const workspace = data.workspace;
|
||||
const badge = this.root.querySelector("[data-workspace-phase]");
|
||||
if (badge) {
|
||||
badge.textContent = workspace.phase_label || workspace.phase || "";
|
||||
badge.className = `workspace-badge workspace-badge-${workspace.phase}`;
|
||||
}
|
||||
const status = this.root.querySelector("[data-workspace-status]");
|
||||
if (status) status.textContent = workspace.status || "";
|
||||
const launch = this.root.querySelector("[data-editor-open]");
|
||||
if (launch && data.editor_url) launch.href = data.editor_url;
|
||||
this.applyPhase(workspace.phase);
|
||||
if (workspace.phase === "ready" && this.phase === "starting") {
|
||||
this.toast("Your workspace is ready", "success");
|
||||
}
|
||||
this.phase = workspace.phase;
|
||||
this.root.dataset.phase = workspace.phase;
|
||||
this.poll(this.isTransitional(workspace.phase));
|
||||
}
|
||||
|
||||
applyPhase(phase) {
|
||||
for (const view of this.root.querySelectorAll("[data-phase-view]")) {
|
||||
const phases = view.dataset.phaseView.split(/\s+/);
|
||||
view.hidden = !phases.includes(phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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>
|
||||
</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'] }}">
|
||||
<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>
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
<div class="poll-options" role="group" aria-label="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) }}>
|
||||
<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-meta">
|
||||
<span class="poll-option-check" aria-hidden="true"{% if _poll.my_choice != opt.uid %} hidden{% endif %}>✓</span>
|
||||
<span class="poll-option-pct">{{ opt.pct }}%</span>
|
||||
<span class="poll-option-pct">{% if user %}{{ opt.pct }}%{% endif %}</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="poll-total">{{ _poll.total }} vote{% if _poll.total != 1 %}s{% endif %}{% if not user %} · 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>
|
||||
{% endif %}
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
|
||||
<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" %}
|
||||
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn" aria-label="{{ item.comment_count }} comments">
|
||||
<span aria-hidden="true">💬</span> {{ item.comment_count }}
|
||||
<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">💬</span>{% if user %} {{ item.comment_count }}{% endif %}
|
||||
</a>
|
||||
{% 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">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
|
||||
<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" %}
|
||||
<a href="{{ item.url }}" class="post-action-btn" aria-label="{{ item.comment_count }} comments">
|
||||
<span aria-hidden="true">💬</span> {{ item.comment_count }}
|
||||
<a href="{{ item.url }}" class="post-action-btn" aria-label="{% if user %}{{ item.comment_count }} comments{% else %}Comments{% endif %}">
|
||||
<span aria-hidden="true">💬</span>{% if user %} {{ item.comment_count }}{% endif %}
|
||||
</a>
|
||||
{% 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" %}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{% 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) }}>
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<form method="POST" action="/votes/{{ _type }}/{{ _uid }}" class="inline-form"{% if _stop %} data-stop-propagation{% endif %}>
|
||||
<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>
|
||||
|
||||
@@ -6,7 +6,19 @@ it starts a coding agent for you, and it is configured from your DevPlace accoun
|
||||
rather than from inside the editor.
|
||||
|
||||
Open one from a project's **Workspace** page, or with the **Editor** button on the
|
||||
project itself once the workspace is running.
|
||||
project itself once the editor is ready.
|
||||
|
||||
## Starting a workspace
|
||||
|
||||
The workspace page always shows the real state of your workspace: **Stopped**,
|
||||
**Starting**, **Ready**, **Stopping**, **Crashed** or **Suspended**. Press **Start** and the
|
||||
button turns into a spinner reading *Starting the editor* while the container boots and the
|
||||
editor loads. The page watches the workspace for you, so the moment the editor answers the
|
||||
spinner becomes **Open editor** on its own, without a reload. The same rule gates the
|
||||
**Editor** button on the project page: it appears only when the editor will actually open.
|
||||
|
||||
A start usually takes a few seconds. **Stop** stays available throughout, so a workspace that
|
||||
is still starting can always be stopped again.
|
||||
|
||||
## What opens on boot
|
||||
|
||||
@@ -22,12 +34,35 @@ New terminals you open later are plain shells. To start another agent, pick
|
||||
**DevPlace Code** from the terminal dropdown, or run the command
|
||||
**DevPlace: Start DevPlace Code**.
|
||||
|
||||
The workspace opens straight onto your files with the terminal ready, not onto a welcome
|
||||
page, and the editor's own built-in chat assistant is switched off: `dpc` is the assistant
|
||||
here, and it runs on your DevPlace account. The files `dpc` keeps for itself (`.dpc/` and
|
||||
`dpc.log`) stay in the container and are never copied into your project.
|
||||
The first time a workspace boots it also opens the **DevPlace welcome page** beside the
|
||||
terminals. It introduces the workspace and DevPlace Code: what `dpc` can do (its 900k token
|
||||
context, vision for screenshots and mockups, parallel sub-agents, deep research with cited
|
||||
sources, and its safety gates), a few prompts to try, and buttons that focus the agent
|
||||
terminal, start the tour, show your public tunnels and open your workspace settings. It is
|
||||
shown once per workspace; reopen it any time with **DevPlace: Show the welcome page**, or
|
||||
read more on the [DevPlace Code site](https://dpc.app.molodetz.nl/).
|
||||
|
||||
You can turn either of them off. See **Your preferences** below.
|
||||
The editor's own built-in chat assistant and VS Code's own welcome page are switched off:
|
||||
`dpc` is the assistant here, and it runs on your DevPlace account. The files `dpc` keeps for
|
||||
itself (`.dpc/` and `dpc.log`) stay in the container and are never copied into your project.
|
||||
|
||||
You can turn either terminal off. See **Your preferences** below.
|
||||
|
||||
## The terminal panel
|
||||
|
||||
`dpc` lives in the terminal panel at the bottom of the window, so its height matters. The
|
||||
**Terminal panel** preference sets it:
|
||||
|
||||
| Preset | Height |
|
||||
|---|---|
|
||||
| Short | A fifth of the window, roughly |
|
||||
| Normal | A third of the window, the default |
|
||||
| Tall | About half the window |
|
||||
| Maximized | The whole editor area |
|
||||
|
||||
The preset is applied the first time a workspace boots and again whenever you change it.
|
||||
Between those moments the height is yours: drag the panel border and DevPlace keeps that
|
||||
size across restarts.
|
||||
|
||||
## Every workspace is trusted
|
||||
|
||||
@@ -65,7 +100,7 @@ The **Editor** card on your workspace page holds them all:
|
||||
- **Theme** - DevPlace Dark, DevPlace Light, or leave it to you (pick any theme from
|
||||
inside the editor and DevPlace will not touch it again).
|
||||
- **Layout** - Standard, Terminal focus, or Zen.
|
||||
- **Terminal panel** - Short, Normal, Tall or Maximized.
|
||||
- **Terminal panel** - Short, Normal, Tall or Maximized (see **The terminal panel** above).
|
||||
- **Editor font size**, **Terminal font size**, **Zoom level**.
|
||||
- **Agent on boot** and **Shell on boot**.
|
||||
- **Open editor in** - a new tab, a sized window, or a fullscreen window, with the
|
||||
@@ -108,6 +143,8 @@ Press `F1` and type `DevPlace` for the full list:
|
||||
| **DevPlace: Open workspace settings** | Your workspace page |
|
||||
| **DevPlace: Show public tunnels** | Pick one of your live public addresses |
|
||||
| **DevPlace: Open the DevPlace editor guide** | This page |
|
||||
| **DevPlace: Show the welcome page** | The welcome page that opened on the first boot |
|
||||
| **DevPlace: Open the Get started walkthrough** | The five-step tour of the workspace |
|
||||
|
||||
## Publishing a port from the editor
|
||||
|
||||
|
||||
@@ -46,8 +46,10 @@
|
||||
<span class="issue-author">{{ issue.author_username }}</span>
|
||||
<span class="issue-dot">·</span>
|
||||
<span class="issue-date">{{ local_dt(issue.created_at) }}</span>
|
||||
{% if user %}
|
||||
<span class="issue-dot">·</span>
|
||||
<span class="issue-comments">{{ issue.comments_count }} comment{{ '' if issue.comments_count == 1 else 's' }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
@@ -210,8 +210,6 @@
|
||||
{% 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-footer">
|
||||
<span class="landing-post-stat">+{{ item.stars }}</span>
|
||||
<span class="landing-post-stat">💬 {{ item.comment_count }}</span>
|
||||
<a href="/posts/{{ item.slug }}" class="landing-post-open">Open →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -603,7 +603,9 @@
|
||||
<a href="/gists/{{ g['slug'] or g['uid'] }}" class="gist-card">
|
||||
<div class="gist-card-header">
|
||||
<h3 class="gist-card-title">{{ render_title(g['title'], author_is_admin=is_admin(profile_user)) }}</h3>
|
||||
{% if user %}
|
||||
<span class="gist-card-star">☆ {{ g.get('stars', 0) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="gist-card-meta">
|
||||
<span class="gist-language-badge">📝 {{ language_name(g['language']) }}</span>
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
{% if gallery %}
|
||||
<a href="#screenshots" class="project-tab">Screenshots <span class="project-tab-count">{{ gallery | length }}</span></a>
|
||||
{% 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>
|
||||
</nav>
|
||||
|
||||
@@ -214,9 +214,13 @@
|
||||
<div class="project-sidebar-card">
|
||||
<h2 class="project-section-label">Stats</h2>
|
||||
<div class="project-stats">
|
||||
{% if user %}
|
||||
<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>
|
||||
{% 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>
|
||||
{% 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"><span class="project-stat-value">{{ fork_count }}</span> fork{{ '' if fork_count == 1 else 's' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<div class="workspace-page" data-workspace-root
|
||||
data-slug="{{ project.slug or project.uid }}"
|
||||
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 }}">
|
||||
|
||||
<h1 class="workspace-title">Workspace: {{ project.title }}</h1>
|
||||
@@ -45,7 +47,10 @@
|
||||
{% endfor %}
|
||||
|
||||
<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-meter">
|
||||
<span>Disk</span>
|
||||
@@ -69,8 +74,8 @@
|
||||
Last active {{ dt_ago(workspace.last_active_at) }}.
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="workspace-actions">
|
||||
{% if workspace.status == "running" %}
|
||||
<div class="workspace-actions" data-workspace-actions>
|
||||
<div class="workspace-phase-view" data-phase-view="ready" {{ "hidden" if workspace.phase != "ready" }}>
|
||||
{% set _url = editor_url %}
|
||||
{% set _uid = workspace.uid %}
|
||||
{% set _class = "btn btn-primary" %}
|
||||
@@ -79,14 +84,24 @@
|
||||
{% set _height = editor.window_height %}
|
||||
{% set _label = "Open editor" %}
|
||||
{% 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>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace">
|
||||
<button type="submit" class="btn btn-primary">Start</button>
|
||||
</div>
|
||||
<div class="workspace-phase-view" data-phase-view="starting" {{ "hidden" if workspace.phase != "starting" }}>
|
||||
<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>
|
||||
{% 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 %}
|
||||
<code class="secret-value" id="editor-password">{{ editor_password }}</code>
|
||||
<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>
|
||||
</form>
|
||||
</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 class="card workspace-editor" data-editor-card>
|
||||
@@ -108,11 +126,11 @@
|
||||
{% if restart_required %}
|
||||
<div class="workspace-editor-restart">
|
||||
<span>Your editor settings changed. Restart the workspace to apply them.</span>
|
||||
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/stop">
|
||||
<button type="submit" class="btn btn-sm">Stop</button>
|
||||
<form method="post" data-workspace-action action="/projects/{{ project.slug or project.uid }}/workspace/stop">
|
||||
<button type="submit" class="btn btn-sm"><span class="btn-spinner" aria-hidden="true"></span>Stop</button>
|
||||
</form>
|
||||
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Start</button>
|
||||
<form method="post" data-workspace-action action="/projects/{{ project.slug or project.uid }}/workspace">
|
||||
<button type="submit" class="btn btn-sm btn-primary"><span class="btn-spinner" aria-hidden="true"></span>Start</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -272,5 +290,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
@@ -107,3 +107,59 @@ def test_http_ingress_proxy(app_server):
|
||||
httpd.shutdown()
|
||||
get_table("instances").delete(uid=uid)
|
||||
refresh_snapshot()
|
||||
|
||||
|
||||
def test_http_ingress_proxy_injects_a_base_into_the_root_document_only(app_server):
|
||||
import http.server
|
||||
import socket
|
||||
import socketserver
|
||||
import threading
|
||||
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(
|
||||
b"<!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()
|
||||
|
||||
@@ -1082,3 +1082,76 @@ def test_republishing_a_live_tunnel_keeps_its_certificate():
|
||||
assert again["uid"] == row["uid"]
|
||||
assert again["status"] == tunnels.STATUS_ACTIVE
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -14,6 +15,9 @@ from devplacepy.services.containers.workspace import flags, naming
|
||||
from devplacepy.utils import make_combined_slug
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
EDITOR_CONTAINER_PORT = 8443
|
||||
TRANSITION_TIMEOUT_MS = 8000
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _workspaces_on():
|
||||
@@ -62,7 +66,20 @@ def _project_for(owner_uid: str, title: str = "WS Project") -> dict:
|
||||
return row
|
||||
|
||||
|
||||
def _workspace_for(project: dict, owner_uid: str, **overrides) -> dict:
|
||||
@pytest.fixture(scope="module")
|
||||
def editor_listener():
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(16)
|
||||
try:
|
||||
yield sock.getsockname()[1]
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def _workspace_for(
|
||||
project: dict, owner_uid: str, editor_port: int = 0, **overrides
|
||||
) -> dict:
|
||||
payload = {
|
||||
"project_uid": project["uid"],
|
||||
"name": "ws-e2e",
|
||||
@@ -73,10 +90,20 @@ def _workspace_for(project: dict, owner_uid: str, **overrides) -> dict:
|
||||
"tunnel_name": naming.generate(),
|
||||
"ports_json": '[{"host": 20777, "container": 8080, "proto": "tcp"}]',
|
||||
}
|
||||
if editor_port:
|
||||
payload["editor_port"] = EDITOR_CONTAINER_PORT
|
||||
payload["ports_json"] = (
|
||||
f'[{{"host": {editor_port}, "container": {EDITOR_CONTAINER_PORT}, '
|
||||
'"proto": "tcp"}]'
|
||||
)
|
||||
payload.update(overrides)
|
||||
return store.create_instance(payload)
|
||||
|
||||
|
||||
def _phase_view(page, phase: str):
|
||||
return page.locator(f".workspace-phase-view[data-phase-view~='{phase}']")
|
||||
|
||||
|
||||
def test_project_page_offers_the_workspace_entry_point_to_the_owner(alice):
|
||||
page, user = alice
|
||||
project = _project_for(_row_for(user)["uid"], "WS Entry")
|
||||
@@ -104,11 +131,13 @@ def test_project_page_hides_the_workspace_entry_point_from_a_non_owner(bob):
|
||||
assert not page.locator(".context-menu-item:has-text('Workspace')").count()
|
||||
|
||||
|
||||
def test_project_page_shows_a_direct_vscode_button_for_a_running_workspace(alice):
|
||||
def test_project_page_shows_a_direct_vscode_button_for_a_running_workspace(
|
||||
alice, editor_listener
|
||||
):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Direct")
|
||||
instance = _workspace_for(project, row["uid"])
|
||||
instance = _workspace_for(project, row["uid"], editor_port=editor_listener)
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
|
||||
)
|
||||
@@ -383,11 +412,11 @@ def test_resetting_editor_preferences_restores_the_site_default(alice):
|
||||
get_table("workspace_editor_prefs").delete(owner_id=row["uid"])
|
||||
|
||||
|
||||
def test_the_editor_launch_control_carries_the_window_profile(alice):
|
||||
def test_the_editor_launch_control_carries_the_window_profile(alice, editor_listener):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Editor Launch")
|
||||
instance = _workspace_for(project, row["uid"])
|
||||
instance = _workspace_for(project, row["uid"], editor_port=editor_listener)
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||
wait_until="domcontentloaded",
|
||||
@@ -415,3 +444,88 @@ def test_the_workspace_help_states_that_every_folder_is_trusted(alice):
|
||||
help_card.wait_for(state="visible")
|
||||
expect(help_card).to_contain_text("Restricted Mode")
|
||||
expect(help_card).to_contain_text("dpc")
|
||||
|
||||
|
||||
def test_direct_vscode_button_is_absent_while_the_editor_is_unreachable(alice):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Booting")
|
||||
_workspace_for(project, row["uid"])
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
|
||||
)
|
||||
page.locator(".project-detail-actions").wait_for(state="visible")
|
||||
assert not page.locator(".project-detail-actions a[data-editor-open]").count()
|
||||
|
||||
|
||||
def test_a_running_workspace_whose_editor_is_not_listening_reads_starting(alice):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Not Listening")
|
||||
_workspace_for(project, row["uid"])
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
expect(page.locator("[data-workspace-phase]")).to_have_text("Starting")
|
||||
expect(_phase_view(page, "starting")).to_be_visible()
|
||||
expect(_phase_view(page, "starting").locator(".btn.is-loading")).to_be_disabled()
|
||||
expect(_phase_view(page, "ready")).to_be_hidden()
|
||||
assert not page.locator(".workspace-actions a[data-editor-open]").is_visible()
|
||||
|
||||
|
||||
def test_start_shows_progress_and_flips_to_open_editor_when_the_editor_answers(
|
||||
alice, editor_listener
|
||||
):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Start Flow")
|
||||
instance = _workspace_for(
|
||||
project,
|
||||
row["uid"],
|
||||
editor_port=editor_listener,
|
||||
status="stopped",
|
||||
desired_state="stopped",
|
||||
)
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
expect(page.locator("[data-workspace-phase]")).to_have_text("Stopped")
|
||||
start = _phase_view(page, "stopped").locator("button[type='submit']")
|
||||
expect(start).to_be_visible()
|
||||
start.click()
|
||||
expect(_phase_view(page, "starting")).to_be_visible(timeout=TRANSITION_TIMEOUT_MS)
|
||||
expect(_phase_view(page, "stopped")).to_be_hidden()
|
||||
expect(page.locator("[data-workspace-phase]")).to_have_text("Starting")
|
||||
assert store.get_instance(instance["uid"])["desired_state"] == "running"
|
||||
|
||||
store.update_instance(instance["uid"], {"status": "running"})
|
||||
launch = page.locator(".workspace-actions a[data-editor-open]")
|
||||
launch.wait_for(state="visible", timeout=TRANSITION_TIMEOUT_MS)
|
||||
expect(page.locator("[data-workspace-phase]")).to_have_text("Ready")
|
||||
expect(_phase_view(page, "starting")).to_be_hidden()
|
||||
expect(launch).to_have_attribute(
|
||||
"href",
|
||||
f"/projects/{project['slug']}/containers/instances/{instance['uid']}/code/",
|
||||
)
|
||||
|
||||
|
||||
def test_stop_shows_progress_until_the_container_is_down(alice, editor_listener):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Stop Flow")
|
||||
instance = _workspace_for(project, row["uid"], editor_port=editor_listener)
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
expect(page.locator("[data-workspace-phase]")).to_have_text("Ready")
|
||||
_phase_view(page, "ready").locator("form[data-workspace-action] button").click()
|
||||
expect(_phase_view(page, "stopping")).to_be_visible(timeout=TRANSITION_TIMEOUT_MS)
|
||||
expect(page.locator("[data-workspace-phase]")).to_have_text("Stopping")
|
||||
assert store.get_instance(instance["uid"])["desired_state"] == "stopped"
|
||||
|
||||
store.update_instance(instance["uid"], {"status": "stopped"})
|
||||
expect(_phase_view(page, "stopped")).to_be_visible(timeout=TRANSITION_TIMEOUT_MS)
|
||||
expect(page.locator("[data-workspace-phase]")).to_have_text("Stopped")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user