From 6cac64a3f69a1f90d3b72a080a415accf47e379f Mon Sep 17 00:00:00 2001 From: retoor Date: Mon, 10 Aug 2026 00:23:20 +0200 Subject: [PATCH] Update --- README.md | 46 ++ devplacepy/database/moderation.py | 4 + devplacepy/database/schema.py | 45 ++ devplacepy/database/soft_delete.py | 2 + devplacepy/docs_api/groups/workspaces.py | 97 +++- devplacepy/models.py | 17 + devplacepy/project_files.py | 3 + devplacepy/routers/admin/workspaces.py | 47 +- devplacepy/routers/docs/pages.py | 6 + .../routers/projects/containers/workspace.py | 75 ++- devplacepy/routers/projects/index.py | 27 +- devplacepy/schemas/__init__.py | 1 + devplacepy/schemas/containers.py | 23 + devplacepy/schemas/listings.py | 3 + devplacepy/services/containers/CLAUDE.md | 154 +++++- devplacepy/services/containers/api.py | 46 +- .../files/vscode/branding/devplace-login.css | 79 +++ .../vscode/branding/favicon-dark-support.svg | 12 + .../files/vscode/branding/favicon.ico | Bin 0 -> 4310 bytes .../files/vscode/branding/favicon.svg | 4 + .../files/vscode/branding/pwa-icon-192.png | Bin 0 -> 4469 bytes .../files/vscode/branding/pwa-icon-512.png | Bin 0 -> 7684 bytes .../vscode/branding/pwa-icon-maskable-192.png | Bin 0 -> 3491 bytes .../vscode/branding/pwa-icon-maskable-512.png | Bin 0 -> 6224 bytes .../vscode/devplace-workspace/extension.js | 252 ++++++++++ .../media/devplace-icon.png | Bin 0 -> 3060 bytes .../vscode/devplace-workspace/package.json | 156 ++++++ .../themes/devplace-dark.json | 223 +++++++++ .../themes/devplace-light.json | 223 +++++++++ .../devplace-workspace/walkthrough/agent.md | 12 + .../devplace-workspace/walkthrough/files.md | 9 + .../devplace-workspace/walkthrough/limits.md | 10 + .../walkthrough/toolchains.md | 9 + .../devplace-workspace/walkthrough/tunnels.md | 11 + .../files/vscode/product.patch.json | 13 + .../services/containers/workspace/__init__.py | 4 +- .../services/containers/workspace/editor.py | 463 ++++++++++++++++++ .../containers/workspace/provision.py | 13 +- .../services/containers/workspace/quota.py | 34 +- .../services/containers/workspace_service.py | 58 ++- .../services/devii/actions/dispatcher.py | 12 + .../devii/actions/workspace_actions.py | 47 ++ .../services/devii/workspace/controller.py | 45 ++ devplacepy/static/css/workspace.css | 86 ++++ devplacepy/static/js/Application.js | 2 + devplacepy/static/js/EditorLauncher.js | 58 +++ devplacepy/static/js/WorkspaceManager.js | 2 +- devplacepy/templates/_editor_open.html | 10 + devplacepy/templates/admin_workspaces.html | 6 +- .../docs/getting-started-vibing.html | 5 + .../templates/docs/workspace-editor.html | 117 +++++ devplacepy/templates/project_detail.html | 10 +- devplacepy/templates/workspace.html | 134 ++++- events.md | 1 + ppy.Dockerfile | 33 ++ tests/api/projects/workspace.py | 186 ++++++- tests/e2e/projects/workspace.py | 109 ++++- tests/unit/database/schema.py | 47 ++ tests/unit/services/containers/__init__.py | 1 + .../{containers.py => containers/api.py} | 0 .../services/containers/workspace/__init__.py | 1 + .../services/containers/workspace/editor.py | 362 ++++++++++++++ .../services/containers/workspace/quota.py | 112 +++++ 63 files changed, 3499 insertions(+), 68 deletions(-) create mode 100644 devplacepy/services/containers/files/vscode/branding/devplace-login.css create mode 100644 devplacepy/services/containers/files/vscode/branding/favicon-dark-support.svg create mode 100644 devplacepy/services/containers/files/vscode/branding/favicon.ico create mode 100644 devplacepy/services/containers/files/vscode/branding/favicon.svg create mode 100644 devplacepy/services/containers/files/vscode/branding/pwa-icon-192.png create mode 100644 devplacepy/services/containers/files/vscode/branding/pwa-icon-512.png create mode 100644 devplacepy/services/containers/files/vscode/branding/pwa-icon-maskable-192.png create mode 100644 devplacepy/services/containers/files/vscode/branding/pwa-icon-maskable-512.png create mode 100644 devplacepy/services/containers/files/vscode/devplace-workspace/extension.js create mode 100644 devplacepy/services/containers/files/vscode/devplace-workspace/media/devplace-icon.png create mode 100644 devplacepy/services/containers/files/vscode/devplace-workspace/package.json create mode 100644 devplacepy/services/containers/files/vscode/devplace-workspace/themes/devplace-dark.json create mode 100644 devplacepy/services/containers/files/vscode/devplace-workspace/themes/devplace-light.json create mode 100644 devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/agent.md create mode 100644 devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/files.md create mode 100644 devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/limits.md create mode 100644 devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/toolchains.md create mode 100644 devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/tunnels.md create mode 100644 devplacepy/services/containers/files/vscode/product.patch.json create mode 100644 devplacepy/services/containers/workspace/editor.py create mode 100644 devplacepy/static/js/EditorLauncher.js create mode 100644 devplacepy/templates/_editor_open.html create mode 100644 devplacepy/templates/docs/workspace-editor.html create mode 100644 tests/unit/database/schema.py create mode 100644 tests/unit/services/containers/__init__.py rename tests/unit/services/{containers.py => containers/api.py} (100%) create mode 100644 tests/unit/services/containers/workspace/__init__.py create mode 100644 tests/unit/services/containers/workspace/editor.py create mode 100644 tests/unit/services/containers/workspace/quota.py diff --git a/README.md b/README.md index c1e9bf04..8917e5a2 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ devplacepy/ | `/reports` | Content reporting: `POST /reports/{target_type}/{target_uid}` files a report against any user-generated surface, `GET /reports/mine` lists the reports you filed and their outcome, `GET /reports/reasons` serves the reason registry so every client renders the same dialog | | `/admin/moderation` | Admin **Moderation** queue: reported content oldest-open-first with the response-window badge, one report per detail page with the offender's history, triage (`/status`) and decisions (`/decide`) | | `/workspaces/index` | Public index of every workspace published to the ingress proxy, with owner, project, maturity label and direct link | +| `/projects/{slug}/workspace` | A member's dev workspace for a project: open/start/stop/delete, quota and idle status, public tunnels, and the **Editor** card holding their editor preferences (`GET`/`POST /projects/{slug}/workspace/editor`) | | `/block` | Block/unblock a user: hides all of their posts, comments and messages from you everywhere except their own profile, and stops them notifying you. Also reachable directly from every content action bar | | `/mute` | Mute/unmute a user: stops them creating notifications for you while their content stays visible | | `/leaderboard` | Contributor ranking by total stars earned | @@ -444,6 +445,51 @@ and its full configuration are documented automatically - including future servi **Runtime data** (container workspaces and zip archives) lives in `DEVPLACE_DATA_DIR` (default `data/`), **outside the package and never served via `/static`**. The docker daemon must be able to bind-mount the data dir for `/app`. +### Dev Workspaces and the browser editor + +A **workspace** is a member-facing container running the DevPlace browser editor, layered on the +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. + +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 +built-in extension ships the **DevPlace Dark** and **DevPlace Light** themes (generated from the +site's own design tokens), a **Get started on DevPlace** walkthrough, a project status bar item and +five `DevPlace:` commands. Nothing in the interface identifies as code-server. + +**On boot** two terminals open: a focused **DevPlace Code** terminal already running `dpc`, the +coding agent baked into the image, and a plain login shell beside it with the Python, Rust, Nim and +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. + +**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 +real consequence (a project's own `.vscode/tasks.json` will run on folder open), it is documented to +members on `/docs/workspace-editor.html`, and an administrator can restore Restricted Mode site-wide +with the `workspace_editor_trust_all` setting. + +**Four sizes are configurable through one resolver.** Editor and terminal font size plus zoom, the +editor layout and terminal panel preset, whether the editor opens in a tab or a sized window, and the +container's CPU, memory and disk. The first three are the member's own preferences on their workspace +page (and over the API, and through Devii's `workspace_editor_get` / `workspace_editor_set`); the +container size is part of the administrator-set workspace quota. Each preference resolves instance +override, then the member's row, then the site setting, then the built-in default, and the page shows +which of those each value came from. + +**A member edit is never overwritten.** DevPlace seeds the editor's `settings.json` from the host +before each launch and records exactly what it wrote; on the next launch it updates only the keys +whose current value is still the one it wrote. A setting the member changed inside the editor is +theirs permanently, while a change to the site default still reaches everyone who has expressed no +preference. Preferences apply on the next workspace start, and the page says so and offers the +restart. + ### Async job framework and zip downloads `services/jobs/` is the standard way to run blocking work asynchronously and hand the caller a result URL. A shared `jobs` table is the queue (discriminated by `kind`); `queue.enqueue()` inserts a `pending` row from any worker, the lock-owning worker processes jobs in `JobService.run_once` (reap, recover orphans, refill up to a concurrency limit, prune expired), and status is polled from the database. Retention is built in: each job service deletes its own expired artifacts via a `cleanup` hook (default 7 days, admin-configurable). To add a kind, subclass `JobService`, set `kind`, and implement `process()` and `cleanup()`. diff --git a/devplacepy/database/moderation.py b/devplacepy/database/moderation.py index 0cf7cf90..7ee64859 100644 --- a/devplacepy/database/moderation.py +++ b/devplacepy/database/moderation.py @@ -65,6 +65,10 @@ UNREPORTABLE_TABLES: dict[str, str] = { "email_accounts": "private mailbox credentials", "instance_schedules": "child rows of a reportable workspace instance", "workspace_flags": "moderation records, not authored content", + "workspace_quota_rules": "administrator-set limits, not authored content", + "workspace_editor_prefs": ( + "private per-user editor configuration, never shown to another member" + ), "content_reports": "moderation records, readable only by the reporter and moderators", "moderation_actions": "moderation records, not authored content", "content_maturity": "moderation labels, not authored content", diff --git a/devplacepy/database/schema.py b/devplacepy/database/schema.py index d4f52a70..4f39b35c 100644 --- a/devplacepy/database/schema.py +++ b/devplacepy/database/schema.py @@ -121,6 +121,17 @@ def init_db(): _index(db, "comments", "idx_comments_user_uid", ["user_uid"]) _index(db, "comments", "idx_comments_created_at", ["created_at"]) _index(db, "votes", "idx_votes_target", ["target_uid", "target_type"]) + messages = get_table("messages") + for column, example in ( + ("uid", ""), + ("sender_uid", ""), + ("receiver_uid", ""), + ("content", ""), + ("read", False), + ("created_at", ""), + ): + if not messages.has_column(column): + messages.create_column_by_example(column, example) _index(db, "messages", "idx_messages_sender", ["sender_uid"]) _index(db, "messages", "idx_messages_receiver", ["receiver_uid"]) _index( @@ -588,6 +599,10 @@ def init_db(): ("flag_reason", ""), ("suspended_at", ""), ("suspended_by", ""), + ("boot_marker", ""), + ("workspace_cpu_millicores", 0), + ("workspace_memory_mb", 0), + ("workspace_disk_quota_mb", 0), ): if not instances.has_column(column): instances.create_column_by_example(column, example) @@ -628,12 +643,36 @@ def init_db(): ("egress_quota_mb", 0), ("idle_stop_minutes", 0), ("retention_days", 0), + ("cpu_millicores", 0), + ("memory_mb", 0), ("created_at", ""), ("updated_at", ""), ): if not quota_rules.has_column(column): quota_rules.create_column_by_example(column, example) + editor_prefs = get_table("workspace_editor_prefs") + for column, example in ( + ("uid", ""), + ("owner_kind", ""), + ("owner_id", ""), + ("font_size", 0), + ("terminal_font_size", 0), + ("zoom_level", -99), + ("theme", ""), + ("layout", ""), + ("panel_preset", ""), + ("window_mode", ""), + ("window_width", 0), + ("window_height", 0), + ("boot_agent", ""), + ("boot_shell", -1), + ("created_at", ""), + ("updated_at", ""), + ): + if not editor_prefs.has_column(column): + editor_prefs.create_column_by_example(column, example) + flags = get_table("workspace_flags") for column, example in ( ("uid", ""), @@ -666,6 +705,12 @@ def init_db(): "idx_workspace_quota_owner", ["owner_kind", "owner_id"], ) + _index( + db, + "workspace_editor_prefs", + "idx_workspace_editor_prefs_owner", + ["owner_kind", "owner_id"], + ) _index(db, "workspace_flags", "idx_workspace_flags_open", ["status", "created_at"]) _index( db, diff --git a/devplacepy/database/soft_delete.py b/devplacepy/database/soft_delete.py index 828e4a41..6c132cd3 100644 --- a/devplacepy/database/soft_delete.py +++ b/devplacepy/database/soft_delete.py @@ -25,6 +25,8 @@ SOFT_DELETE_TABLES = [ "instance_schedules", "tunnels", "workspace_flags", + "workspace_quota_rules", + "workspace_editor_prefs", "backup_schedules", "devii_conversations", "devii_tasks", diff --git a/devplacepy/docs_api/groups/workspaces.py b/devplacepy/docs_api/groups/workspaces.py index 356cb085..0a95c8d7 100644 --- a/devplacepy/docs_api/groups/workspaces.py +++ b/devplacepy/docs_api/groups/workspaces.py @@ -8,10 +8,17 @@ GROUP = { "intro": """ # Dev Workspaces -A workspace is a browser VS Code environment attached to one of your projects. It runs your project -files, a terminal, and preinstalled Python, Rust, Nim and Swift toolchains. `sudo` and -`apt install` work with no extra setup; ports below 1024 cannot bind, so use a high port and publish -it through a tunnel. +A workspace is a browser editor attached to one of your projects. It runs your project files, a +terminal, and preinstalled Python, Rust, Nim and Swift toolchains. `sudo` and `apt install` work +with no extra setup; ports below 1024 cannot bind, so use a high port and publish it through a +tunnel. + +The editor opens with a **DevPlace Code** terminal already running the `dpc` coding agent and a +plain shell beside it, and it trusts every folder, so nothing opens in Restricted Mode. Its +appearance and boot behaviour are your own preferences, readable and writable through the two +`/workspace/editor` endpoints below and explained on +[the workspace editor page](/docs/workspace-editor.html). Editor preferences apply on the next +workspace start. A **tunnel** publishes one port from inside your container on a public HTTPS hostname of the form `-.tunnel.pravda.education`. **Tunnel URLs are public and unauthenticated** - anyone with @@ -101,6 +108,88 @@ arrives as a `workspace` notification and states exactly what happens next and w ], sample_response={"ok": True, "redirect": "/projects/my-project/workspace"}, ), + endpoint( + id="workspace-editor-get", + method="GET", + path="/projects/{slug}/workspace/editor", + title="Read editor profile", + summary=( + "The resolved DevPlace editor profile for this workspace: theme, layout, " + "panel preset, font sizes, zoom, boot terminals, how the editor opens, the " + "container size, and where each value comes from." + ), + auth="user", + params=[ + field("slug", "path", "string", True, "my-project", "Project slug or uid."), + ], + sample_response={ + "editor": { + "trust_all": True, + "theme": "devplace-dark", + "font_size": 14, + "terminal_font_size": 13, + "zoom_level": 0, + "layout": "standard", + "panel_preset": "tall", + "boot_agent": "dpc", + "boot_shell": True, + "window_mode": "tab", + "window_width": 1600, + "window_height": 1000, + "cpu_millicores": 2000, + "cpu_cores": 2.0, + "memory_mb": 2048, + "disk_quota_mb": 2048, + "sources": {"theme": "user", "font_size": "site"}, + }, + "restart_required": False, + }, + ), + endpoint( + id="workspace-editor-set", + method="POST", + path="/projects/{slug}/workspace/editor", + title="Set editor preferences", + summary=( + "Change your own editor preferences. Only the fields you send are " + "changed; within those, an empty string or zero means inherit the site " + "default, and `reset` drops every preference. Applies on the next " + "workspace start, and the response says whether a restart is needed." + ), + auth="user", + params=[ + field("slug", "path", "string", True, "my-project", "Project slug or uid."), + field("theme", "body", "string", False, "devplace-dark", + "devplace-dark, devplace-light or system."), + field("layout", "body", "string", False, "standard", + "standard, terminal-focus or zen."), + field("panel_preset", "body", "string", False, "tall", + "short, normal, tall or maximized."), + field("font_size", "body", "integer", False, "14", + "Editor font size in pixels. Zero inherits."), + field("terminal_font_size", "body", "integer", False, "13", + "Terminal font size in pixels. Zero inherits."), + field("zoom_level", "body", "integer", False, "0", + "Window zoom, -5 to 5. Send -99 to inherit."), + field("boot_agent", "body", "string", False, "dpc", + "dpc or none."), + field("boot_shell", "body", "integer", False, "1", + "1 opens a shell on boot, 0 skips it, -1 inherits."), + field("window_mode", "body", "string", False, "tab", + "tab, window or fullscreen."), + field("window_width", "body", "integer", False, "1600", + "Editor window width in pixels. Zero inherits."), + field("window_height", "body", "integer", False, "1000", + "Editor window height in pixels. Zero inherits."), + field("reset", "body", "boolean", False, "false", + "Drop every preference and fall back to the site defaults."), + ], + sample_response={ + "ok": True, + "redirect": "/projects/my-project/workspace", + "data": {"restart_required": True}, + }, + ), endpoint( id="workspace-tunnels-list", method="GET", diff --git a/devplacepy/models.py b/devplacepy/models.py index dec1bdf1..f1ce4687 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -959,6 +959,21 @@ class TunnelForm(BaseModel): container_port: int = Field(default=0, ge=0, le=65535) +class EditorPrefsForm(BaseModel): + font_size: int = Field(default=0, ge=0, le=48) + terminal_font_size: int = Field(default=0, ge=0, le=48) + zoom_level: int = Field(default=-99, ge=-99, le=5) + theme: str = Field(default="", max_length=32) + layout: str = Field(default="", max_length=32) + panel_preset: str = Field(default="", max_length=32) + window_mode: str = Field(default="", max_length=32) + window_width: int = Field(default=0, ge=0, le=7680) + window_height: int = Field(default=0, ge=0, le=4320) + boot_agent: str = Field(default="", max_length=32) + boot_shell: int = Field(default=-1, ge=-1, le=1) + reset: bool = False + + class WorkspaceQuotaForm(BaseModel): owner_id: str = Field(default="", max_length=36) label: str = Field(default="", max_length=64) @@ -968,6 +983,8 @@ class WorkspaceQuotaForm(BaseModel): egress_quota_mb: int = Field(default=0, ge=0) idle_stop_minutes: int = Field(default=0, ge=0) retention_days: int = Field(default=0, ge=0) + cpu_millicores: int = Field(default=0, ge=0, le=64000) + memory_mb: int = Field(default=0, ge=0, le=1048576) class WorkspaceFlagForm(BaseModel): diff --git a/devplacepy/project_files.py b/devplacepy/project_files.py index 0b195a4c..c2e963df 100644 --- a/devplacepy/project_files.py +++ b/devplacepy/project_files.py @@ -666,6 +666,9 @@ IMPORT_SKIP_NAMES = { SYNC_SKIP_NAMES = IMPORT_SKIP_NAMES | { ".devplace_boot.py", ".devplace_boot.sh", + ".devplace", + ".dpc", + "dpc.log", } IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024 diff --git a/devplacepy/routers/admin/workspaces.py b/devplacepy/routers/admin/workspaces.py index 40acc290..ef43d099 100644 --- a/devplacepy/routers/admin/workspaces.py +++ b/devplacepy/routers/admin/workspaces.py @@ -8,16 +8,24 @@ from fastapi.responses import JSONResponse from devplacepy.database import db, get_table, get_users_by_uids from devplacepy.dependencies import json_or_form from devplacepy.models import ( + EditorPrefsForm, WorkspaceFlagForm, WorkspaceQuotaForm, WorkspaceSuspendForm, ) from devplacepy.responses import action_result, json_error, respond +from devplacepy.routers.admin._shared import deny_senior, is_senior_admin from devplacepy.schemas import AdminWorkspacesOut from devplacepy.seo import base_seo_context from devplacepy.services.audit import record as audit from devplacepy.services.containers import store -from devplacepy.services.containers.workspace import flags, provision, quota, tunnels +from devplacepy.services.containers.workspace import ( + editor, + flags, + provision, + quota, + tunnels, +) from devplacepy.utils import create_notification, generate_uid, not_found, require_admin router = APIRouter() @@ -237,6 +245,43 @@ async def admin_flag_resolve(request: Request, flag_uid: str, status: str = "res return action_result(request, "/admin/workspaces") +@router.post("/workspaces/{uid}/editor") +async def admin_workspace_editor( + request: Request, + uid: str, + data: Annotated[EditorPrefsForm, Depends(json_or_form(EditorPrefsForm))], +): + admin = require_admin(request) + if not isinstance(admin, dict): + return admin + instance = _instance_or_404(uid) + owner_uid = instance.get("workspace_owner_uid", "") + owner = get_users_by_uids([owner_uid]).get(owner_uid) if owner_uid else None + if is_senior_admin(admin, owner): + return deny_senior( + request, + admin, + owner_uid, + owner, + "container.workspace.editor.update", + "/admin/workspaces", + ) + if not owner_uid: + return json_error(400, "this workspace has no owner") + if data.reset: + editor.reset_prefs(owner_uid, admin["uid"]) + else: + editor.save_prefs( + owner_uid, data.model_dump(exclude={"reset"}, exclude_unset=True) + ) + _audit(request, admin, "container.workspace.editor.update", instance) + return action_result( + request, + "/admin/workspaces", + data={"editor": editor.view(owner_uid, instance)}, + ) + + @router.post("/workspaces/quota") async def admin_workspace_quota( request: Request, diff --git a/devplacepy/routers/docs/pages.py b/devplacepy/routers/docs/pages.py index b9aed000..f87b51d2 100644 --- a/devplacepy/routers/docs/pages.py +++ b/devplacepy/routers/docs/pages.py @@ -58,6 +58,12 @@ DOCS_PAGES = [ "kind": "prose", "section": SECTION_GENERAL, }, + { + "slug": "workspace-editor", + "title": "The workspace editor", + "kind": "prose", + "section": SECTION_GENERAL, + }, { "slug": "feed", "title": "The feed", diff --git a/devplacepy/routers/projects/containers/workspace.py b/devplacepy/routers/projects/containers/workspace.py index 88c68ebb..35b86aa1 100644 --- a/devplacepy/routers/projects/containers/workspace.py +++ b/devplacepy/routers/projects/containers/workspace.py @@ -2,7 +2,7 @@ from typing import Annotated -from fastapi import APIRouter, Form, Request, WebSocket +from fastapi import APIRouter, Depends, Form, Request, WebSocket from starlette.responses import Response from devplacepy.content import ( @@ -10,13 +10,14 @@ from devplacepy.content import ( can_open_workspace, ) from devplacepy.database import get_table, resolve_by_slug -from devplacepy.models import TunnelForm +from devplacepy.dependencies import json_or_form +from devplacepy.models import EditorPrefsForm, TunnelForm from devplacepy.responses import action_result, json_error, respond from devplacepy.schemas import WorkspaceOut from devplacepy.services.audit import record as audit from devplacepy.services.containers import activity, api, forward, store from devplacepy.services.containers.api import ContainerError -from devplacepy.services.containers.workspace import provision, quota, tunnels +from devplacepy.services.containers.workspace import editor, provision, quota, tunnels from devplacepy.services.containers.workspace.provision import WorkspaceError from devplacepy.utils import not_found, require_user @@ -25,6 +26,12 @@ from ._shared import audit_instance, fail router = APIRouter() +def _restart_required(instance: dict, profile: editor.EditorProfile) -> bool: + if instance.get("status") != store.ST_RUNNING: + return False + return editor.restart_required(instance, profile) + + def _project_or_404(slug: str) -> dict: project = resolve_by_slug(get_table("projects"), slug) if not project: @@ -64,6 +71,7 @@ async def workspace_page(request: Request, slug: str): _guard(request, project, user, "container.workspace.open") instance = provision.find_for_project(project["uid"], user["uid"]) limits = quota.resolve(user["uid"]) + profile = editor.resolve(user["uid"], instance) context = { "project": project, "workspace": provision.view(instance) if instance else None, @@ -79,6 +87,10 @@ async def workspace_page(request: Request, slug: str): "editor_password": ( api.ensure_editor_password(instance) if instance else "" ), + "editor": editor.view(user["uid"], instance), + "restart_required": ( + _restart_required(instance, profile) if instance else False + ), "user": user, } return respond(request, "workspace.html", context, model=WorkspaceOut) @@ -151,6 +163,63 @@ async def workspace_delete(request: Request, slug: str): return action_result(request, f"/projects/{slug}/workspace") +@router.get("/{slug}/workspace/editor") +async def editor_prefs_read(request: Request, slug: str): + user = require_user(request) + if isinstance(user, Response): + return user + project = _project_or_404(slug) + instance = _workspace_or_404(project, user) + if not can_manage_workspace(instance, project, user): + return json_error(403, "Not allowed to manage this workspace") + profile = editor.resolve(user["uid"], instance) + return { + "editor": editor.view(user["uid"], instance), + "restart_required": _restart_required(instance, profile), + } + + +@router.post("/{slug}/workspace/editor") +async def editor_prefs_write( + request: Request, + slug: str, + data: Annotated[EditorPrefsForm, Depends(json_or_form(EditorPrefsForm))], +): + user = require_user(request) + if isinstance(user, Response): + return user + project = _project_or_404(slug) + instance = _workspace_or_404(project, user) + if not can_manage_workspace(instance, project, user): + return json_error(403, "Not allowed to manage this workspace") + owner_uid = instance.get("workspace_owner_uid") or user["uid"] + if data.reset: + editor.reset_prefs(owner_uid, user["uid"]) + summary = f"{user['username']} reset their workspace editor preferences" + else: + editor.save_prefs( + owner_uid, data.model_dump(exclude={"reset"}, exclude_unset=True) + ) + summary = f"{user['username']} updated their workspace editor preferences" + audit_instance( + request, + user, + "container.workspace.editor.update", + instance, + project, + summary=summary, + ) + profile = editor.resolve(owner_uid, instance) + return action_result( + request, + f"/projects/{slug}/workspace", + data={ + "editor": editor.view(owner_uid, instance), + "restart_required": _restart_required(instance, profile), + }, + ) + + @router.get("/{slug}/workspace/tunnels") async def tunnel_list(request: Request, slug: str): user = require_user(request) diff --git a/devplacepy/routers/projects/index.py b/devplacepy/routers/projects/index.py index 19197926..681c6e70 100644 --- a/devplacepy/routers/projects/index.py +++ b/devplacepy/routers/projects/index.py @@ -176,17 +176,24 @@ async def projects_page( model=ProjectsOut, ) -def _editor_url(project: dict, user: dict) -> str: +def _editor_launch(project: dict, user: dict) -> dict: from devplacepy.services.containers import store - from devplacepy.services.containers.workspace import provision + 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 "" + return blank if instance.get("status") != store.ST_RUNNING: - return "" + return blank slug = project["slug"] or project["uid"] - return f"/projects/{slug}/containers/instances/{instance['uid']}/code/" + profile = editor.resolve(user["uid"], instance) + return { + "url": f"/projects/{slug}/containers/instances/{instance['uid']}/code/", + "mode": profile.window_mode, + "width": profile.window_width, + "height": profile.window_height, + } @router.get("/{project_slug}", response_class=HTMLResponse) @@ -226,9 +233,12 @@ async def project_detail(request: Request, project_slug: str, before: str = None schemas=[website_schema(base), software_application_schema(project, base)], ) viewer_can_workspace = can_open_workspace(project, user) - workspace_editor_url = ( - _editor_url(project, user) if viewer_can_workspace else "" + editor_launch = ( + _editor_launch(project, user) + if viewer_can_workspace + else {"url": "", "mode": "tab", "width": 0, "height": 0} ) + workspace_editor_url = editor_launch["url"] parent = get_fork_parent(project["uid"]) forked_from = ( { @@ -276,6 +286,9 @@ async def project_detail(request: Request, project_slug: str, before: str = None "viewer_can_containers": can_view_project_containers(project, user), "viewer_can_workspace": viewer_can_workspace, "workspace_editor_url": workspace_editor_url, + "workspace_editor_mode": editor_launch["mode"], + "workspace_editor_width": editor_launch["width"], + "workspace_editor_height": editor_launch["height"], "forked_from": forked_from, "fork_count": count_forks(project["uid"]), "file_count": count_files(project["uid"]), diff --git a/devplacepy/schemas/__init__.py b/devplacepy/schemas/__init__.py index e2e2f258..4f84d164 100644 --- a/devplacepy/schemas/__init__.py +++ b/devplacepy/schemas/__init__.py @@ -74,6 +74,7 @@ from devplacepy.schemas.containers import ( AdminWorkspacesOut, BotFrameOut, ContainersOut, + EditorProfileOut, InstanceOut, ScheduleOut, TunnelOut, diff --git a/devplacepy/schemas/containers.py b/devplacepy/schemas/containers.py index 8666511f..8b4f4598 100644 --- a/devplacepy/schemas/containers.py +++ b/devplacepy/schemas/containers.py @@ -132,6 +132,26 @@ class WorkspaceFlagOut(_Out): created_at: str = "" +class EditorProfileOut(_Out): + trust_all: bool = True + theme: str = "" + font_size: int = 0 + terminal_font_size: int = 0 + zoom_level: int = 0 + layout: str = "" + panel_preset: str = "" + boot_agent: str = "" + boot_shell: bool = True + window_mode: str = "" + window_width: int = 0 + window_height: int = 0 + cpu_millicores: int = 0 + cpu_cores: float = 0.0 + memory_mb: int = 0 + disk_quota_mb: int = 0 + sources: dict = {} + + class WorkspaceViewOut(_Out): uid: str = "" name: str = "" @@ -153,6 +173,7 @@ class WorkspaceViewOut(_Out): max_tunnels: int = 0 tunnels: list[TunnelOut] = [] flags: list[WorkspaceFlagOut] = [] + editor: Optional[EditorProfileOut] = None class WorkspaceOut(_Out): @@ -164,6 +185,8 @@ class WorkspaceOut(_Out): max_workspaces: int = 0 editor_url: str = "" editor_password: str = "" + editor: Optional[EditorProfileOut] = None + restart_required: bool = False user: Optional[Any] = None diff --git a/devplacepy/schemas/listings.py b/devplacepy/schemas/listings.py index a795368a..ab77b31e 100644 --- a/devplacepy/schemas/listings.py +++ b/devplacepy/schemas/listings.py @@ -169,6 +169,9 @@ class ProjectDetailOut(_Out): viewer_can_containers: bool = False viewer_can_workspace: bool = False workspace_editor_url: Optional[str] = None + workspace_editor_mode: Optional[str] = None + workspace_editor_width: Optional[int] = None + workspace_editor_height: Optional[int] = None forked_from: Optional[dict] = None fork_count: int = 0 file_count: int = 0 diff --git a/devplacepy/services/containers/CLAUDE.md b/devplacepy/services/containers/CLAUDE.md index 8222e4b7..8d6fdc8e 100644 --- a/devplacepy/services/containers/CLAUDE.md +++ b/devplacepy/services/containers/CLAUDE.md @@ -112,6 +112,136 @@ An instance's shell is NOT inline - it is a floating `` (`st **Minimize/Normalize.** Geometry presets (`_presetGeometry(w,h)`, smallest-usable / comfortable), exposed both as titlebar buttons (`data-win="minimize|normalize"`) and menu items on every window. +## Editor profile and branding (`workspace/editor.py`, `files/vscode/`) + +The browser editor is a DevPlace product surface, not stock code-server. Three layers own it, and +the split is the design: the deterministic part is host-side and unit-testable without Docker, the +cosmetic part is an extension that fails soft. + +| Layer | Owns | Fails how | +|---|---|---| +| **Host** `workspace/editor.py` | Resolving the profile, seeding `settings.json`, building the argv, setting container CPU and memory | Deterministic, unit-tested against a temp state dir, no container needed | +| **Image** `ppy.Dockerfile` + `files/vscode/` | Branding assets, patched `product.json`, the bundled extension | Verified by the build smoke test; an image that cannot brand cannot build green | +| **Extension** `files/vscode/devplace-workspace/` | Boot terminals, panel layout, status bar, walkthrough, `DevPlace:` commands | Each stage in its own try/catch to a `DevPlace` output channel. A failure costs the terminals, never the editor | + +**One resolver, exactly like `quota.resolve`.** `editor.resolve(owner_uid, instance) -> EditorProfile` +is the only place a `workspace_editor_*` setting is read. Order is user preference row, then site +setting, then built-in default; the container size (`cpu_millicores`, `memory_mb`, `disk_quota_mb`) +comes from `quota.resolve` so all four "sizes" live on one object. `editor.view()` adds +`source_map()` so every surface can say where a value came from. Never read one of those settings at +a call site. + +**Inherit sentinels are explicit, never truthiness.** `workspace_editor_prefs` stores `""` / `0` for +"inherit", but zoom level `0` is a real value, so its sentinel is `-99` (`INHERIT_ZOOM`), and +`boot_shell` uses `-1` (`INHERIT_FLAG`) because `0` means off. `_inherits(key, row)` is the single +predicate; a bare `if value:` here would silently ignore a member who wants zoom 0 or no shell. + +**`merge_managed` is the contract that a member edit is never overwritten** and it is a pure +function, which is why it is exhaustively unit-tested. DevPlace writes a key only when it is absent +or still equal to the value DevPlace wrote last time, recorded in +`{state}/data/User/.devplace-managed.json`. So raising a site default reaches everyone who never +expressed a preference and nobody who did. Do not replace this with a plain merge or a full rewrite. + +**Seeding runs at launch, in `run_spec_for`**, alongside `ensure_editor_password` - the one point +every workspace launch passes through, so a workspace created before this feature is seeded on its +next boot. `stamp_boot_marker` writes a fresh `instances.boot_marker` there too; it reaches the +container as `DEVPLACE_CONTAINER_BOOT` and is what makes the extension's boot terminals idempotent +across browser reloads. Because seeding happens at launch, a preference change applies on the **next +start**: the workspace page compares the resolved profile against `{state}/devplace-editor.json` +(`editor.restart_required`) and shows a restart banner rather than pretending it applied. + +**Nothing DevPlace writes goes to `/app`.** `/app` round-trips into the member's project through +`sync_dir_bidirectional`, so a `.vscode/tasks.json` there would land in their repository. Every +artefact goes under `WORKSPACE_STATE_DIR`, which is a separate bind mount and never synced. This is +why boot terminals are an extension rather than a folder-open task. + +**The agent's own working files are in `SYNC_SKIP_NAMES` for the same reason.** `dpc` writes `.dpc/` +and `dpc.log` into its working directory, which is `/app`. That was harmless while `dpc` only ran +when a member typed it; now that it starts on every workspace boot, those artefacts would be +imported into every project on the next sync. `project_files.SYNC_SKIP_NAMES` therefore carries +`.dpc`, `dpc.log` and `.devplace` (the tunnel manifest directory, which was already being written +and already leaking) alongside the `.devplace_boot.*` entries. Any future in-container tool that +writes state next to the member's code needs the same entry. + +**The boot marker degrades to once-per-extension-host, never to "always".** `BootTerminals` is +guarded by `DEVPLACE_CONTAINER_BOOT`, but an instance created before that column existed injects an +empty value. The guard used to treat an empty marker as "not booted yet" and opened a fresh pair of +terminals on **every browser reload** - caught by driving one container with three consecutive +Playwright sessions and finding six terminal tabs. The fallback is now `host-${process.pid}` of the +extension host, which survives a browser reload and changes when the container restarts, which is +exactly the intended semantic. + +**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 - +`security.workspace.trust.enabled` is application-scoped and VS Code restricts which scopes an +extension may re-default - so never let it be the only layer. Turning the switch off restores +Restricted Mode with no code change and no image rebuild. It also enables +`task.allowAutomaticTasks`, so a project's own `runOn: folderOpen` task will run; that consequence is +documented to members on `/docs/workspace-editor.html` and must stay documented. + +**Panel height is a preset, not a pixel value, and that is a hard constraint.** VS Code stores part +sizes in the workbench grid inside `state.vscdb`, an undocumented and version-unstable internal +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 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 +live in the image. `--builtin-extensions-dir` is deliberately NOT used: it has a known upstream +defect where extensions loaded through it present as disabled. There is no build step, no npm and no +bundler - a VS Code extension is a directory with a `package.json` and an entry point, and it runs in +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. + +**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 +driving a real container with Playwright: the tab list must read exactly +`pravda@workspace` + `DevPlace Code`. + +**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" +copy, and a competing agent right beside `dpc`. `editor.FOREIGN_AI_SETTINGS` turns it off +(`chat.disableAIFeatures`, `chat.commandCenter.enabled`, `workbench.secondarySideBar.defaultVisibility`) +and `workbench.startupEditor` is `none` so a workspace opens straight onto the member's code with the +agent terminal ready, rather than onto a welcome page listing a "Get Started with VS Code" +walkthrough. Unknown keys are ignored by VS Code, so these stay safe across version bumps. The +DevPlace walkthrough is still contributed and reachable from Help and the command palette. + +**Branding is six things**, all baked in: the `--app-name` / `--welcome-text` / +`--disable-getting-started-override` flags in `editor.argv`; the favicon and PWA icon set generated +from `static/icon-512.png` into `files/vscode/branding/`; `devplace-login.css` appended to +code-server's `login.css`; `product.patch.json` merged key-wise into `product.json` (additive, so an +unnamed upstream key survives a bump); the `DevPlace Dark` / `DevPlace Light` themes generated from +`static/css/variables.css`; and the walkthrough, status bar item and five `DevPlace:` commands. The +login stylesheet is the **single sanctioned exception to the no-colour-literals rule** - code-server +serves it outside the application and cannot read `variables.css`, so the tokens are restated as +literals with a comment naming each one. Do not spread that exception anywhere else. + +**Theme token mapping** (`variables.css` -> VS Code), recorded here because a JSON theme cannot carry +a comment: `--bg-primary` -> `editor.background`; `--bg-secondary` -> `sideBar`/`activityBar`; +`--bg-card` -> `editorWidget`/`panel`; `--accent` -> `focusBorder`/`button.background`/`progressBar`; +`--accent-light` -> `list.activeSelectionBackground`; `--text-primary` -> `foreground`; +`--text-secondary` -> `descriptionForeground`; `--border` -> every `*.border`; +`--success`/`--warning`/`--danger`/`--info` -> the ANSI green/yellow/red/blue. `DevPlace Light` is a +derived light palette (DevPlace ships no light tokens); keep the two in step by construction. + +**Adding an editor setting** touches five places: `editor.DEFAULTS` + `SETTING_KEYS`, a `ConfigField` +on `WorkspaceService` (group `Editor`), the `workspace_editor_prefs` ensure block and +`editor.PREF_COLUMNS`, `EditorPrefsForm` + `EditorProfileOut`, and the `settings_for` map or the +extension. A `select` `ConfigField` MUST use `options=[{"value":..., "label":...}]` - plain strings +crash `docs_api.build_services_group`, which `docs_search` indexes, which 500s the docs search page. + + ## Pravda image (load-bearing, workspace ownership) The `ppy` image (`ppy.Dockerfile`, built by `make ppy`, context `devplacepy/services/containers/files`) is a `python:3.13-slim-bookworm` base with Playwright plus a broad set of common Python libraries preinstalled, plus CLI tools (`tmux`, `apache2-utils` for `ab`, `procps`/`htop`/`iftop`/`iotop`, `netcat-openbsd` for `nc`, `zip`/`unzip`, `fakeroot`, git/curl/wget/vim/ack). @@ -280,7 +410,7 @@ query, so a literal `#` in the path makes the reconstructed URL treat the query **Editor persistence.** code-server's user-data and extensions live in `config.WORKSPACE_STATE_DIR/`, bind-mounted at `WORKSPACE_STATE_MOUNT`, so extensions -survive container recreation. `api.editor_command` builds the argv; `run_spec_for` prefers it over +survive container recreation. `editor.argv` builds the argv; `run_spec_for` prefers it over the boot-script/boot-command chain when `is_workspace` and `editor_port` are set. **Activity and egress are the presence pattern.** `activity.py` keeps a per-worker monotonic dict and @@ -356,8 +486,9 @@ fields wired to nothing, which is why every tunnel sat at `pending` with no cert required field makes an anonymous request 422 instead of 401 and `tests/api/auth/matrix.py` fails. Give the field a default and validate it inside the handler after `require_user`. -**The editor opens in a new tab, directly.** The project detail page renders an inline **VS Code** -button in `.project-detail-actions` (`target="_blank"`) straight to the code-server proxy +**The editor opens through one shared partial.** The project detail page renders an inline **Editor** +button in `.project-detail-actions` via `templates/_editor_open.html` (`target="_blank"`, plus the +`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 @@ -382,9 +513,10 @@ page that links to it and the setting turned on. list, start/stop, suspend/unsuspend with a required reason, raise/resolve/dismiss flags, per-user quota rules. `base_seo_context` takes `breadcrumbs`/`schemas`, not `canonical`/`schema`. -**Devii** has 15 tools under `handler="workspace"`; five are in `CONFIRM_REQUIRED` and every one of +**Devii** has 17 tools under `handler="workspace"`; six are in `CONFIRM_REQUIRED` and every one of them declares a `confirm` param (schemas are `additionalProperties: false`, so a gated tool without it -loops forever). +loops forever). `workspace_editor_get` / `workspace_editor_set` cover the editor profile; +`workspace_editor_set` is gated because it changes how every workspace the member opens behaves. **Opening a workspace publishes its editor on a public tunnel, automatically.** `provision.ensure` does three things after creating the instance: `api.ensure_editor_password`, then @@ -399,7 +531,7 @@ password. This is a deliberate departure from the plane-A/plane-B split describe is now reachable publicly, which is only acceptable **because** `--auth password` is on - never reintroduce `--auth none` while the editor tunnel is auto-published. -**The editor is password-protected, per workspace.** `api.editor_command` runs code-server with +**The editor is password-protected, per workspace.** `editor.argv` runs code-server with `--auth password`, and `api.pravda_env` injects the secret as `PASSWORD` (the variable code-server reads). The secret is an 8-character **pronounceable** token from `api.generate_editor_password()`, built as four consonant-vowel pairs (`ronebamu`, `zipesodu`) so a user can read it once and retype it @@ -419,7 +551,7 @@ the instance, so putting it there would hand every user's editor password to any an unauthenticated public shell: `--auth none` was safe only while the editor was reachable solely through the session-authenticated proxy route, and a user can publish a tunnel to the editor port. -**code-server lives in the `ppy` image, and nothing else supplies it.** `api.editor_command` makes +**code-server lives in the `ppy` image, and nothing else supplies it.** `editor.argv` makes `code-server` the container's argv, so an image without it fails `docker run` with exit **127** (`executable file not found in $PATH`) and the reconciler records `crashed` / `launch_failed` with an empty `container_id` - the container process never existed. It is installed in `ppy.Dockerfile` in @@ -428,9 +560,11 @@ it with no elevation): version pinned in the single `ARG CODE_SERVER_VERSION`, a `dpkg --print-architecture`, release tarball unpacked to `/usr/local/lib/code-server` with a symlink at `/usr/local/bin/code-server`. `code-server --version` is in the build smoke-test loop and the symlink is in the executable-check list, so an image that cannot run the editor can never build -green. Bumping the version is a one-line `ARG` change plus `make ppy`. **`workspace_editor_version` -(a `ConfigField` on `WorkspaceService`) is currently read by nothing** - the version is the image's, -not a runtime setting; wire it up or drop it before relying on it. +green. Bumping the version is a one-line `ARG` change plus `make ppy`, and the branding smoke test +(below) re-verifies the four CLI flags, the media file names and the `product.json` keys, so a +version that breaks any of them cannot build green either. (`workspace_editor_version` was a +`ConfigField` read by nothing and has been removed: the version is a property of the shared image, +not a runtime setting, and a live control that changes nothing is a silent failure.) **A container stuck in `created` is recreated, never retried forever.** The reconciler's `desired=running` branch reaches `backend.start()` for `ps.state == "created"`. That call is wrapped: diff --git a/devplacepy/services/containers/api.py b/devplacepy/services/containers/api.py index 75a8c62a..3c85abeb 100644 --- a/devplacepy/services/containers/api.py +++ b/devplacepy/services/containers/api.py @@ -443,7 +443,7 @@ def pravda_env(instance: dict) -> dict: def workspace_env(instance: dict, base_url: str) -> dict: from devplacepy import database from devplacepy.database import get_setting - from devplacepy.services.containers.workspace import naming, quota + from devplacepy.services.containers.workspace import editor, naming, quota if not instance.get("is_workspace"): return {"DEVPLACE_WORKSPACE": ""} @@ -476,9 +476,12 @@ def workspace_env(instance: dict, base_url: str) -> dict: gallery = get_setting("workspace_extensions_gallery", "").strip() editor_port = int(instance.get("editor_port") or 0) + profile = editor.resolve(owner_uid, instance) env = { "DEVPLACE_WORKSPACE": "1", "DEVPLACE_WORKSPACE_UID": instance.get("uid") or "", + "DEVPLACE_CONTAINER_BOOT": instance.get("boot_marker") or "", + **editor.env_for(profile), "DEVPLACE_WORKSPACE_URL": workspace_url, "DEVPLACE_WORKSPACE_OWNER": owner_name, "DEVPLACE_WORKSPACE_OWNER_UID": owner_uid, @@ -546,27 +549,28 @@ def ensure_editor_password(instance: dict) -> str: return password -def editor_command(instance: dict) -> list[str]: - port = int(instance.get("editor_port") or EDITOR_DEFAULT_PORT) - return [ - "code-server", - "--bind-addr", - f"0.0.0.0:{port}", - "--auth", - "password", - "--disable-telemetry", - "--disable-update-check", - "--user-data-dir", - f"{WORKSPACE_STATE_MOUNT}/data", - "--extensions-dir", - f"{WORKSPACE_STATE_MOUNT}/extensions", - WORKSPACE_MOUNT, - ] +def stamp_boot_marker(instance: dict) -> str: + from devplacepy.utils import generate_uid + + marker = generate_uid() + store.update_instance(instance["uid"], {"boot_marker": marker}) + instance["boot_marker"] = marker + return marker def run_spec_for(instance: dict, image_tag: str) -> RunSpec: + from devplacepy.services.containers.workspace import editor + + profile = None + cpu_limit = instance.get("cpu_limit", "") + mem_limit = instance.get("mem_limit", "") if instance.get("is_workspace"): ensure_editor_password(instance) + stamp_boot_marker(instance) + profile = editor.resolve(instance.get("workspace_owner_uid", ""), instance) + editor.seed_state(instance, profile) + cpu_limit = profile.cpu_limit() or cpu_limit + mem_limit = profile.mem_limit() or mem_limit env = {**json.loads(instance.get("env_json") or "{}"), **pravda_env(instance)} ports = [ PortMapping(p["host"], p["container"], p.get("proto", "tcp")) @@ -584,8 +588,8 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec: ) language = (instance.get("boot_language") or "none").strip().lower() boot = (instance.get("boot_command") or "").strip() - if instance.get("is_workspace") and int(instance.get("editor_port") or 0): - command = editor_command(instance) + if profile and int(instance.get("editor_port") or 0): + command = editor.argv(instance, profile) elif language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip(): script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}" command = [BOOT_SCRIPT_RUNNERS[language], script_path] @@ -601,8 +605,8 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec: PROJECT_LABEL: instance["project_uid"], }, env=env, - cpu_limit=instance.get("cpu_limit", ""), - mem_limit=instance.get("mem_limit", ""), + cpu_limit=cpu_limit, + mem_limit=mem_limit, ports=ports, mounts=mounts, restart_policy=instance.get("restart_policy", "never"), diff --git a/devplacepy/services/containers/files/vscode/branding/devplace-login.css b/devplacepy/services/containers/files/vscode/branding/devplace-login.css new file mode 100644 index 00000000..679146f7 --- /dev/null +++ b/devplacepy/services/containers/files/vscode/branding/devplace-login.css @@ -0,0 +1,79 @@ +/* retoor */ +/* devplace-login-theme */ +/* DevPlace palette, restated as literals because code-server serves this file + outside the application and cannot read static/css/variables.css. + --bg-primary #080413 --bg-card #1a1030 --bg-input #140b26 + --accent #ff6b35 --accent-hover #ff7d4d + --text-primary #f4eefb --text-secondary #b8a8d0 + --border rgba(255,255,255,0.08) --radius 12px */ + +body { + background: linear-gradient(135deg, #080413 0%, #160a28 50%, #080413 100%); + color: #f4eefb; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.center-container { + background: transparent; +} + +.card-box { + background: #1a1030; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); +} + +.header .main { + color: #f4eefb; + font-weight: 700; +} + +.header .sub, +.content .links, +.content .links a { + color: #b8a8d0; +} + +.content .links a:hover { + color: #ff6b35; +} + +.field input, +.password-input { + background: #140b26; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + color: #f4eefb; +} + +.field input::placeholder { + color: #7a6a90; +} + +.field input:focus, +.password-input:focus { + border-color: #ff6b35; + outline: none; +} + +.field .submit, +.submit { + background: #ff6b35; + border: none; + border-radius: 12px; + color: #ffffff; + font-weight: 600; +} + +.field .submit:hover, +.submit:hover { + background: #ff7d4d; +} + +.error-display, +.error { + background: rgba(229, 57, 53, 0.16); + border-radius: 12px; + color: #ffb4b2; +} diff --git a/devplacepy/services/containers/files/vscode/branding/favicon-dark-support.svg b/devplacepy/services/containers/files/vscode/branding/favicon-dark-support.svg new file mode 100644 index 00000000..3a32c1b0 --- /dev/null +++ b/devplacepy/services/containers/files/vscode/branding/favicon-dark-support.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/devplacepy/services/containers/files/vscode/branding/favicon.ico b/devplacepy/services/containers/files/vscode/branding/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..21dce71dee9bec5fdc1f72c3da79e0990d2f8d78 GIT binary patch literal 4310 zcmai$cTm$yyT^Ya5SoD?p@^Xufgm7o0HqkEDkYIFAksv7mm)26kRno~g)UW!bR_gH z(mO~MkX}MpP%h`3cjmr-+?l&GJG;+5v(N18cfOxp0005R01O6PH8!9d1OSSE&)EKr z4Zr{ZB?16p;eTU#5&*Eg>L}E|u^t2fd?^4R9HXuTrC_?60nkUv@)}p;-xXnGS9yT- z@X`bTK+%unA82{ta^s{N#xO9&!C9}g?b1q5ScI578xw3q(JC_^c!KJGOGc8NlarUf zN{xzGU(d_}g>%sG^1-snIMB)sP3c*maG$pRVk5i~9YORj-z=~G*yYC?d27rVw@$qB z93=RzDEs{l`5di%THad#xP#oG`G>cx0HL(^1@oZs9h1_vazh#`P=P;n+wv)?^n&Dv zNkT!ZQd;Hp(`UkL;} zQc#yKeP|r;f5^yom1g@#Mwi#Gj{pG7@vn?Ki85h^+VF0_wb|1B<$ki^rmJsuwoJ#A~$+{GGBN$R}?!xhfSD#<#bwd+UbvU zGlE+st?NSHdXN4}%W5k=(aZOA*5&flkf0lV_uR5RW1tLtAq2!afoEG0G0j^+Y1ecL zXZk=~Wb6sOnrVUS9vh6XJYbr(1tCV)RF84o?1MbzND7QpWg&hqvZ^e#v~<&jMNgfa zL(ha($qWsUVwTR4+w7pB={+Zg{G8tFwODYLu7&*`t{UtEQqSsk& zLo7Zwq#_XMtrUr6>KSUwm%S)(W8^G>4KNU4(FblEnW3!z`lJ(qQOqNyV7X`}vB zGFR%p9o5gXZdum!yP` zaN3o^yA1ga*Ke@mw&eIbQc9ATQ0Aw7A5j=(BE96cX4yEb#m*=<8OHLi#3_ssmeB&L>&8G2|R z>aYd>QvCCj=mA?@`(_po<|fuEsG!1v!!nQV=YT?H+xdds{y^HA_ol60wNiFPR5s1DB|lS(b`Db z`7={y_hzPv5wf~R-8H_Qy0^Z<`jDQ{20Gm~lTC<^|9`CY zH&ccGv6e2;&rkp$p8JorlGQAk|FPD5vS)_X^O3QRTE#3bhY)1?yc4;qV7BHEi@j*-s0SbVgF9c&rY7^!GJ(cdE|hRTKKX9;F9g zm@sQ@)OSWEQTOQA75b~*f1Lp>60>61sM6R;QJ629*HkPk8{(3 z$ej@m675bp_Xom1=%*eRh+%b;)ZlMTop0_HVlwH;#KnwBwAhb568#)>upexIGGihW z=T@ifk9>l)%yh`FA)1y!7F?0z(6`kbRQBPL(mEz}R_vM;TI9bBgIA?97qNuTkt&DL zui5He01W}8IMOOLOXF`L*hKd+PapTVoe~(Um;7t$kIq16gtb40(wrRYSWCmvnY;L< z@p!Fa?Ki81q>SvK*INZ}`jjYa zv*{o~boAm*9AUSWUXU=wV!J`U=yt>Zr@W`jB}_<0|5h}e&B0KznoM)Q1ukuSeK#Ro^Fk>1N|7bG$mbdMEJ8+bR6_?^%wh?N_-x?76zjo_)N!nRA|{MgWtTYAX@C## z>?Y)t_(f^Z_TaU6=6{;fBCNa_gzad^XJS^n%v!??)N z!Uupp?*}tOo`$u5%<3106JAV5Wmn?*+>t?F z6w9UArksRx@pd+O|Cr5#4VE+*|EvTHHlyq5O-MmP7XLzP>_#Jz)&T|fPXTpr(f+|n z(G(M-#V}MXZ`ro3@XV(V>MC^NKCJl++SJfI`+}AcE57K_wNf73#UG}ZJf@08Asuq& z#yn3>B0~@ha~=7Q*;HyLv`I1j5vMM?_r!#lMmiR7t$ppQB;7y1P(4R?7^0=ywdFS1 zD~28=U=~L?Ic-aC7~)t@dJ7zYGS4XQ&F@gzbF1CbYbkteZ5XnWHxC$Wu+f}=Iy3X? zPdn|3n!Q4~VFNRj197x(Y~MH;N%(HMjNC3acW6|abIHzBCEv<&J7>eE#%LRS`Zr`Q z2wwM&pVDgH$zc2Otji&i!cUu9Y`>=~#!TvFLk<25_*I0Blr5{lBP*=(VvA)@LP)L! zNo}7Vh~w{*x!GRxv=I2dKS5Cv60GG^zW3Dw*jPn3twuI!y4{(ux4C^UF?8awn2(nM zn7iwBzGa?D3V!Y+1pmYVk0H+>SFm`N%i@IwK2GlmeLVlen(|*%`2%`X0J?T7%KFSWRE{;=BuX_(fFK<_Fk5&4nyE2v{VD&cBtVLfOiI)O}9pXm3_*91|J!{*#G^2p8?>Z4+)vk2@Sg+CxEljkkob>1#CAVCOZP;U;I)BZo91o(&C)T`x5&N6qJ)@$- zh+3w26-ccL5s^IPI%gkOn=|+KCC{(4U&&uazxVVQzJ9~Mrh98$nj?`96HQ_kY=y)_ zfYv#>Gjx%mgIk?rPXFM7y|96ZbUAMSIBrz>_adB!D3O1d?4g?=pcN=_G-LFFBv!AW zD}cUaWuhK&CAbMydVs_}*4*ELyg#inbIC1mZ#P5q~cPjyK0vkrmEa18RM zQFDs(kI6|-f<~GpwWe)tZzs4W4k*-|Q3u^1iz%9UF2=#BRbmX4WX%iSDy6D?w0`8` zSKl9%w=9M3kIPM$yoF}GfmP0&*Ga!C-kFHj`Z9BGvB|D%_OZ;~n5f{qr3>M8s)5>| z>K7R0?{{uYs0scMhCli*%Mpaxd<#dl3>DR@{lbX}afk1P5M>WX9nu|T*pd(4j#J(D z!MA=*oYdP|N1=8+I}E4ycbbb2&YauKlvL`VnA4nh-B^pfy0N%CmfEZr{X=4!(fv=G zPLn;2&cDmxQfkK}T-C{q57J9btmLc>g%ST$_eN60;QtT*mqtnS^^z(w7!;7v&jcPcBq7&87(V06dw;l zC-J_sB_e*NUk)xdZg_t)>-x;UO&6;y>34fIlwq?jdO=KIjZ~K@x_OS`1z~(lR9B>@ zzCtqSm&s;=GuOq6YLxYt#cx013Q82l{Pe!~k?*>f*1NVrG5G-3LMX#az6Gto2tP^shxq}fV)(h9pfu(>g_?LBAWYB2Fra)Nb}ohoY_h* zw8d>+($TnHp{wT4`!N4MOY7F(%wSCi<$S*TV~Ep!eZzsYPy}dusGE5l9A6k4(kSG@ z-Ts-BU)pO^4%gUx-Tv*$`pH(pIebdR@H$&`Tltv`UAvsi`&)0haE-Nn1O-a6rk(Rd zK1KE$zgT&Q=WQf+UEfxm90-GKx%%|ThwpC~z54>su)37Ajc1w%!=qKZ5qGxzbSLm! z3BRZ(xC_(A_%=^2eKy|La4fa#^{x_SPWn@4ciBsL4E+n(zd)EOkb(_SrX~2z1TpG= UiF^tEMdxm~cW>~{f4?jL1C6lKFaQ7m literal 0 HcmV?d00001 diff --git a/devplacepy/services/containers/files/vscode/branding/favicon.svg b/devplacepy/services/containers/files/vscode/branding/favicon.svg new file mode 100644 index 00000000..be25bd58 --- /dev/null +++ b/devplacepy/services/containers/files/vscode/branding/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/devplacepy/services/containers/files/vscode/branding/pwa-icon-192.png b/devplacepy/services/containers/files/vscode/branding/pwa-icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..4509c090e57496f9c791eaaceac0f04e55a5c8ff GIT binary patch literal 4469 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 literal 0 HcmV?d00001 diff --git a/devplacepy/services/containers/files/vscode/branding/pwa-icon-512.png b/devplacepy/services/containers/files/vscode/branding/pwa-icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..a793f76a48cef282ac47f0378b8b9704a528b54f GIT binary patch literal 7684 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 F{|D42kLUmZ literal 0 HcmV?d00001 diff --git a/devplacepy/services/containers/files/vscode/branding/pwa-icon-maskable-512.png b/devplacepy/services/containers/files/vscode/branding/pwa-icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..891cfb0aaec48978d82c25c5fcae20d0a3e6973e GIT binary patch literal 6224 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 E}`aOEniWmrNlZj*}r0CKdT z_N!H+*+5fsG!hGPG{o;Y@#_HMAE_p3DXZS?;45G&4-T6hvOva{0nFJZ32#hzuzi`XE9gcS-@{?+;fhPZTR+L8`>Fm*UBmE><9a zE$DvW{3j+~QNpVyFmD_=atFF3U%>n5Z21nER$={*3+bj|9ZqndK;w!t%yLM#63VWY zs8Z0q0{z&y3tIFf7sRu4I5X?GwAymU+pO;gti+rlq3n|La)$`k&^dIpII_0o12Yji zP5hO|NfjHrFW3!WJfREPE8hDfD}le8A!c57+7XJFgfAzo(J2QW0c26vN%MYuWi0cf^swQnT%H}29-2o~nkAYy02jAlfR z_5NyUd;=xXuAK>TfBK1z{f17@;k_zEVVfib;O;`prWw?ExCi#!A~JygE?wtaH__U< zW<38hRO18jm$A@LxrXL0Z80|{qkqOo*;ntXv*ijkB{7nde|pr+bE3saE*$KF+fj$3 zwkXY*<=|?cDZire6J$oKgONwr+dR>Bm<5+vmOM5E9To6a4Dkl(#){XD6GXpfy@Lgv zI;B!EH|%FUfUo#YY=ueRdKJ>ZE1W*}ZYLPLy@eWNd6vE~(;nh7?$eG?BU&wkJO4d|mvV!wO4 zP4{gmNqg6GoDmy##?RuFH^2|CVfYPw1gmf~fBSEIPUs1XJe zm_o~7CrZf{k4P8oLUW(0aYL^IkgYdPrgEBHr-oFB^XzLRW)4czdjVr4ro3D3{1zsJE$ypQeg;ECQ6s-JKrNo_c;53{ zxQ=rg{WkQ%NyrP_WWmUzXdj+i)QGDL>!pk~t3lJ8DT;fTc46u63*vf1$dw*my+0^+ z5AMV0D}F1vWNHj`?}8T7z@zG2CyMk0fHklBDg1F(DfefA5l%q=35CA=&}+Z8oH3nY zpho#XUjw+;!DLpqsP z>lM+*xwTqMVsMJWDa%!< zyI@07@FgBUESKE!Lc970McjQbpbyvg7FKqk7G%Me0@F0q`XU1DVvmp2Hu!7j({>G# zspl?_`3;zj;Rx_^X~^antGB}yIux@Vb~B&j<55wZ5SOTfz6rpjZ>Ri(7a-bRP&-Zu~pWQk%Kk&kkb2}!` z8shYjn8!^-d|qh!QJrk1IJ$c%6|ccYt}&wlzasv^}35mU*Z+ILQ5#TDCjgkq)wMy zhZLu2O#bGSg3Sp#(QNAXiJQ9IL-OMEN|N8h?7V-AITH%69@rSa4ZIVQp|Cr!*v7s< zHA-c5LOlf+8aN65iH&cyXq1#Q` + +const fs = require("fs"); +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 }; + +class Profile { + constructor() { + this.data = Object.assign( + { + theme: "devplace-dark", + layout: "standard", + panel_preset: "tall", + boot_agent: "dpc", + boot_shell: true, + trust_all: true, + }, + this.fromFile(), + this.fromEnv(), + ); + } + + fromFile() { + const path = process.env.DEVPLACE_EDITOR_PROFILE; + if (!path) return {}; + try { + const parsed = JSON.parse(fs.readFileSync(path, "utf8")); + return parsed && parsed.editor ? parsed.editor : {}; + } catch (error) { + return {}; + } + } + + fromEnv() { + const values = {}; + if (process.env.DEVPLACE_EDITOR_PANEL_PRESET) { + values.panel_preset = process.env.DEVPLACE_EDITOR_PANEL_PRESET; + } + if (process.env.DEVPLACE_EDITOR_BOOT_AGENT) { + values.boot_agent = process.env.DEVPLACE_EDITOR_BOOT_AGENT; + } + if (process.env.DEVPLACE_EDITOR_BOOT_SHELL) { + values.boot_shell = process.env.DEVPLACE_EDITOR_BOOT_SHELL === "1"; + } + return values; + } + + get bootMarker() { + return process.env.DEVPLACE_CONTAINER_BOOT || `host-${process.pid}`; + } + + get wantsAgent() { + return this.data.boot_agent === "dpc" && fs.existsSync(AGENT_PATH); + } + + get wantsShell() { + return Boolean(this.data.boot_shell); + } + + get panelPreset() { + return this.data.panel_preset || "tall"; + } +} + +class BootTerminals { + constructor(profile, memento) { + this.profile = profile; + this.memento = memento; + } + + alreadyBooted() { + return this.memento.get(BOOT_KEY) === this.profile.bootMarker; + } + + async open() { + if (this.alreadyBooted()) return false; + 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); + } + + createAgent() { + return vscode.window.createTerminal({ + name: AGENT_TERMINAL, + shellPath: AGENT_PATH, + iconPath: new vscode.ThemeIcon("rocket"), + isTransient: false, + }); + } + + createShell() { + return vscode.window.createTerminal({ + name: SHELL_TERMINAL, + shellPath: "/bin/bash", + shellArgs: ["-l"], + iconPath: new vscode.ThemeIcon("terminal-bash"), + isTransient: false, + }); + } +} + +class Layout { + constructor(profile) { + this.profile = profile; + } + + async apply(panelIsOpen) { + const preset = this.profile.panelPreset; + if (!panelIsOpen) return; + if (preset === "maximized") { + await vscode.commands.executeCommand("workbench.action.toggleMaximizedPanel"); + 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"); + } + } +} + +class Presence { + constructor(context) { + this.context = context; + this.item = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 100, + ); + } + + get projectTitle() { + return process.env.DEVPLACE_PROJECT_TITLE || "DevPlace"; + } + + register() { + this.item.text = `$(rocket) ${this.projectTitle}`; + this.item.tooltip = this.tooltip(); + this.item.command = "devplace.openProject"; + this.item.show(); + this.context.subscriptions.push(this.item); + this.registerCommands(); + } + + tooltip() { + const owner = process.env.DEVPLACE_WORKSPACE_OWNER || ""; + const name = process.env.DEVPLACE_TUNNEL_NAME || ""; + return `DevPlace workspace ${name}${owner ? ` for ${owner}` : ""}`; + } + + registerCommands() { + const commands = { + "devplace.runAgent": () => this.runAgent(), + "devplace.openProject": () => this.open(process.env.DEVPLACE_PROJECT_URL), + "devplace.openWorkspacePage": () => + this.open(process.env.DEVPLACE_WORKSPACE_URL), + "devplace.openDocs": () => this.openDocs(), + "devplace.showTunnels": () => this.showTunnels(), + }; + for (const [name, handler] of Object.entries(commands)) { + this.context.subscriptions.push( + vscode.commands.registerCommand(name, handler), + ); + } + } + + runAgent() { + const terminal = vscode.window.createTerminal({ + name: AGENT_TERMINAL, + shellPath: AGENT_PATH, + iconPath: new vscode.ThemeIcon("rocket"), + }); + terminal.show(true); + } + + openDocs() { + const base = process.env.DEVPLACE_BASE_URL || ""; + this.open(base ? `${base}/docs/workspace-editor.html` : ""); + } + + open(url) { + if (!url) { + vscode.window.showWarningMessage( + "DevPlace has not published a site URL for this workspace yet.", + ); + return; + } + vscode.env.openExternal(vscode.Uri.parse(url)); + } + + async showTunnels() { + const path = process.env.DEVPLACE_TUNNEL_MANIFEST; + const rows = this.readManifest(path); + if (!rows.length) { + vscode.window.showInformationMessage( + "This workspace has no public tunnels yet.", + ); + return; + } + const picked = await vscode.window.showQuickPick( + rows.map((row) => ({ + label: row.label || row.hostname, + description: row.url, + detail: `port ${row.container_port} - ${row.status}`, + url: row.url, + })), + { placeHolder: "Open a public tunnel" }, + ); + if (picked) this.open(picked.url); + } + + readManifest(path) { + if (!path) return []; + try { + const parsed = JSON.parse(fs.readFileSync(path, "utf8")); + return Array.isArray(parsed.tunnels) ? parsed.tunnels : []; + } catch (error) { + return []; + } + } +} + +async function stage(output, name, run) { + try { + return await run(); + } catch (error) { + output.appendLine(`${name} failed: ${error}`); + return undefined; + } +} + +async function activate(context) { + const output = vscode.window.createOutputChannel("DevPlace"); + context.subscriptions.push(output); + const profile = new Profile(); + + await stage(output, "presence", () => new Presence(context).register()); + const opened = await stage(output, "terminals", () => + new BootTerminals(profile, context.workspaceState).open(), + ); + await stage(output, "layout", () => new Layout(profile).apply(Boolean(opened))); +} + +function deactivate() {} + +module.exports = { activate, deactivate }; diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/media/devplace-icon.png b/devplacepy/services/containers/files/vscode/devplace-workspace/media/devplace-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..dce926876178fdcf5dbc7966e5ac8713f5e8972b GIT binary patch literal 3060 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 literal 0 HcmV?d00001 diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/package.json b/devplacepy/services/containers/files/vscode/devplace-workspace/package.json new file mode 100644 index 00000000..65ea820b --- /dev/null +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/package.json @@ -0,0 +1,156 @@ +{ + "name": "devplace-workspace", + "displayName": "DevPlace", + "description": "DevPlace workspace integration: the dpc coding agent, project links and DevPlace branding.", + "version": "1.0.0", + "publisher": "devplace", + "author": "retoor ", + "license": "SEE LICENSE IN https://pravda.education/docs/terms.html", + "engines": { + "vscode": "^1.80.0" + }, + "categories": [ + "Other", + "Themes" + ], + "icon": "media/devplace-icon.png", + "main": "./extension.js", + "activationEvents": [ + "onStartupFinished" + ], + "capabilities": { + "untrustedWorkspaces": { + "supported": true + }, + "virtualWorkspaces": true + }, + "contributes": { + "configurationDefaults": { + "security.workspace.trust.enabled": false, + "security.workspace.trust.startupPrompt": "never", + "security.workspace.trust.banner": "never", + "security.workspace.trust.emptyWindow": true, + "security.workspace.trust.untrustedFiles": "open", + "task.allowAutomaticTasks": "on", + "telemetry.telemetryLevel": "off", + "update.mode": "none", + "workbench.tips.enabled": false, + "extensions.autoCheckUpdates": false, + "workbench.colorTheme": "DevPlace Dark", + "terminal.integrated.defaultProfile.linux": "bash", + "workbench.startupEditor": "none", + "chat.disableAIFeatures": true, + "chat.commandCenter.enabled": false, + "workbench.secondarySideBar.defaultVisibility": "hidden" + }, + "themes": [ + { + "label": "DevPlace Dark", + "uiTheme": "vs-dark", + "path": "./themes/devplace-dark.json" + }, + { + "label": "DevPlace Light", + "uiTheme": "vs", + "path": "./themes/devplace-light.json" + } + ], + "commands": [ + { + "command": "devplace.runAgent", + "title": "Start DevPlace Code (dpc)", + "category": "DevPlace" + }, + { + "command": "devplace.openProject", + "title": "Open project on DevPlace", + "category": "DevPlace" + }, + { + "command": "devplace.openWorkspacePage", + "title": "Open workspace settings", + "category": "DevPlace" + }, + { + "command": "devplace.showTunnels", + "title": "Show public tunnels", + "category": "DevPlace" + }, + { + "command": "devplace.openDocs", + "title": "Open the DevPlace editor guide", + "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)" + } + ], + "walkthroughs": [ + { + "id": "devplace.getStarted", + "title": "Get started on DevPlace", + "description": "Your workspace, your agent, and how to publish what you build.", + "steps": [ + { + "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)", + "media": { + "markdown": "walkthrough/agent.md" + }, + "completionEvents": [ + "onCommand:devplace.runAgent" + ] + }, + { + "id": "files", + "title": "Your files are your project", + "description": "Everything under /app syncs back to your DevPlace project.", + "media": { + "markdown": "walkthrough/files.md" + }, + "completionEvents": [ + "onSettingChanged:files.autoSave" + ] + }, + { + "id": "tunnels", + "title": "Publish a port", + "description": "Serve on a high port and publish it on a public HTTPS address.\n[Show my tunnels](command:devplace.showTunnels)", + "media": { + "markdown": "walkthrough/tunnels.md" + }, + "completionEvents": [ + "onCommand:devplace.showTunnels" + ] + }, + { + "id": "toolchains", + "title": "Install anything", + "description": "sudo and apt install work here, with Python, Rust, Nim and Swift preinstalled.", + "media": { + "markdown": "walkthrough/toolchains.md" + }, + "completionEvents": [ + "onCommand:workbench.action.terminal.sendSequence" + ] + }, + { + "id": "limits", + "title": "Quotas and idle stop", + "description": "Your workspace has a size and stops when idle.\n[Open workspace settings](command:devplace.openWorkspacePage)", + "media": { + "markdown": "walkthrough/limits.md" + }, + "completionEvents": [ + "onCommand:devplace.openWorkspacePage" + ] + } + ] + } + ] + } +} diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/themes/devplace-dark.json b/devplacepy/services/containers/files/vscode/devplace-workspace/themes/devplace-dark.json new file mode 100644 index 00000000..2e87cdfa --- /dev/null +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/themes/devplace-dark.json @@ -0,0 +1,223 @@ +{ + "name": "DevPlace Dark", + "type": "dark", + "colors": { + "editor.background": "#080413", + "editor.foreground": "#f4eefb", + "editorLineNumber.foreground": "#7a6a90", + "editorLineNumber.activeForeground": "#ff6b35", + "editorCursor.foreground": "#ff6b35", + "editor.selectionBackground": "#ff6b352e", + "editor.selectionHighlightBackground": "#ff6b351f", + "editor.lineHighlightBackground": "#120821", + "editor.findMatchBackground": "#ff6b3559", + "editor.findMatchHighlightBackground": "#ff6b3530", + "editorIndentGuide.background1": "#ffffff14", + "editorIndentGuide.activeBackground1": "#ffffff24", + "editorWhitespace.foreground": "#ffffff14", + "editorRuler.foreground": "#ffffff14", + "editorWidget.background": "#1a1030", + "editorWidget.border": "#ffffff14", + "editorSuggestWidget.background": "#1a1030", + "editorSuggestWidget.selectedBackground": "#241640", + "editorHoverWidget.background": "#1a1030", + "editorGroup.border": "#ffffff14", + "editorGroupHeader.tabsBackground": "#120821", + "editorGroupHeader.noTabsBackground": "#120821", + "editorGutter.addedBackground": "#4caf50", + "editorGutter.modifiedBackground": "#ff9800", + "editorGutter.deletedBackground": "#e53935", + "editorError.foreground": "#e53935", + "editorWarning.foreground": "#ff9800", + "editorInfo.foreground": "#42a5f5", + "editorBracketMatch.background": "#ff6b3524", + "editorBracketMatch.border": "#ff6b35", + "foreground": "#f4eefb", + "descriptionForeground": "#b8a8d0", + "disabledForeground": "#7a6a90", + "errorForeground": "#e53935", + "focusBorder": "#ff6b35", + "selection.background": "#ff6b3559", + "widget.shadow": "#00000073", + "icon.foreground": "#b8a8d0", + "sash.hoverBorder": "#ff6b35", + "activityBar.background": "#120821", + "activityBar.foreground": "#f4eefb", + "activityBar.inactiveForeground": "#7a6a90", + "activityBar.border": "#ffffff14", + "activityBarBadge.background": "#ff6b35", + "activityBarBadge.foreground": "#ffffff", + "sideBar.background": "#120821", + "sideBar.foreground": "#b8a8d0", + "sideBar.border": "#ffffff14", + "sideBarTitle.foreground": "#f4eefb", + "sideBarSectionHeader.background": "#1a1030", + "sideBarSectionHeader.foreground": "#f4eefb", + "list.activeSelectionBackground": "#ff6b351f", + "list.activeSelectionForeground": "#f4eefb", + "list.inactiveSelectionBackground": "#241640", + "list.hoverBackground": "#241640", + "list.highlightForeground": "#ff6b35", + "list.errorForeground": "#e53935", + "list.warningForeground": "#ff9800", + "tree.indentGuidesStroke": "#ffffff14", + "statusBar.background": "#120821", + "statusBar.foreground": "#b8a8d0", + "statusBar.border": "#ffffff14", + "statusBar.noFolderBackground": "#120821", + "statusBar.debuggingBackground": "#ff6b35", + "statusBar.debuggingForeground": "#ffffff", + "statusBarItem.remoteBackground": "#ff6b35", + "statusBarItem.remoteForeground": "#ffffff", + "statusBarItem.hoverBackground": "#ffffff0d", + "titleBar.activeBackground": "#080413", + "titleBar.activeForeground": "#f4eefb", + "titleBar.inactiveBackground": "#080413", + "titleBar.inactiveForeground": "#7a6a90", + "titleBar.border": "#ffffff14", + "menu.background": "#1a1030", + "menu.foreground": "#f4eefb", + "menu.selectionBackground": "#241640", + "menubar.selectionBackground": "#241640", + "tab.activeBackground": "#080413", + "tab.activeForeground": "#f4eefb", + "tab.activeBorderTop": "#ff6b35", + "tab.inactiveBackground": "#120821", + "tab.inactiveForeground": "#7a6a90", + "tab.border": "#ffffff14", + "tab.hoverBackground": "#241640", + "panel.background": "#1a1030", + "panel.border": "#ffffff14", + "panelTitle.activeForeground": "#f4eefb", + "panelTitle.activeBorder": "#ff6b35", + "panelTitle.inactiveForeground": "#7a6a90", + "terminal.background": "#080413", + "terminal.foreground": "#f4eefb", + "terminal.selectionBackground": "#ff6b352e", + "terminalCursor.foreground": "#ff6b35", + "terminal.ansiBlack": "#120821", + "terminal.ansiRed": "#e53935", + "terminal.ansiGreen": "#4caf50", + "terminal.ansiYellow": "#ff9800", + "terminal.ansiBlue": "#42a5f5", + "terminal.ansiMagenta": "#ff4f8b", + "terminal.ansiCyan": "#00bcd4", + "terminal.ansiWhite": "#f4eefb", + "terminal.ansiBrightBlack": "#7a6a90", + "terminal.ansiBrightRed": "#ff5252", + "terminal.ansiBrightGreen": "#69d16d", + "terminal.ansiBrightYellow": "#ffab00", + "terminal.ansiBrightBlue": "#6fc0ff", + "terminal.ansiBrightMagenta": "#ff7dab", + "terminal.ansiBrightCyan": "#4dd8e8", + "terminal.ansiBrightWhite": "#ffffff", + "button.background": "#ff6b35", + "button.foreground": "#ffffff", + "button.hoverBackground": "#ff7d4d", + "button.secondaryBackground": "#241640", + "button.secondaryForeground": "#f4eefb", + "badge.background": "#ff6b35", + "badge.foreground": "#ffffff", + "progressBar.background": "#ff6b35", + "input.background": "#140b26", + "input.foreground": "#f4eefb", + "input.border": "#ffffff14", + "input.placeholderForeground": "#7a6a90", + "inputOption.activeBorder": "#ff6b35", + "inputValidation.errorBackground": "#e5393526", + "inputValidation.errorBorder": "#e53935", + "dropdown.background": "#140b26", + "dropdown.foreground": "#f4eefb", + "dropdown.border": "#ffffff14", + "checkbox.background": "#140b26", + "checkbox.border": "#ffffff14", + "scrollbarSlider.background": "#ffffff14", + "scrollbarSlider.hoverBackground": "#ffffff24", + "scrollbarSlider.activeBackground": "#ff6b3559", + "quickInput.background": "#1a1030", + "quickInputList.focusBackground": "#241640", + "notifications.background": "#1a1030", + "notifications.border": "#ffffff14", + "notificationCenterHeader.background": "#120821", + "peekView.border": "#ff6b35", + "peekViewEditor.background": "#120821", + "peekViewResult.background": "#1a1030", + "gitDecoration.modifiedResourceForeground": "#ff9800", + "gitDecoration.deletedResourceForeground": "#e53935", + "gitDecoration.untrackedResourceForeground": "#4caf50", + "gitDecoration.ignoredResourceForeground": "#7a6a90", + "gitDecoration.conflictingResourceForeground": "#ff4f8b", + "minimap.findMatchHighlight": "#ff6b35", + "welcomePage.background": "#080413", + "welcomePage.progress.foreground": "#ff6b35", + "welcomePage.tileBackground": "#1a1030", + "welcomePage.tileHoverBackground": "#241640", + "textLink.foreground": "#ff6b35", + "textLink.activeForeground": "#ff7d4d", + "textBlockQuote.background": "#120821", + "textCodeBlock.background": "#120821", + "textPreformat.foreground": "#ff4f8b" + }, + "tokenColors": [ + { + "scope": ["comment", "punctuation.definition.comment"], + "settings": { "foreground": "#7a6a90", "fontStyle": "italic" } + }, + { + "scope": ["string", "string.quoted", "meta.embedded.assembly"], + "settings": { "foreground": "#4caf50" } + }, + { + "scope": ["constant.numeric", "constant.language", "constant.character"], + "settings": { "foreground": "#ffab00" } + }, + { + "scope": ["keyword", "keyword.control", "storage", "storage.type"], + "settings": { "foreground": "#ff4f8b" } + }, + { + "scope": ["entity.name.function", "support.function", "meta.function-call"], + "settings": { "foreground": "#ff6b35" } + }, + { + "scope": ["entity.name.type", "entity.name.class", "support.class", "support.type"], + "settings": { "foreground": "#00bcd4" } + }, + { + "scope": ["variable", "variable.other", "meta.definition.variable"], + "settings": { "foreground": "#f4eefb" } + }, + { + "scope": ["variable.parameter", "variable.other.property"], + "settings": { "foreground": "#b8a8d0" } + }, + { + "scope": ["entity.name.tag", "punctuation.definition.tag"], + "settings": { "foreground": "#ff4f8b" } + }, + { + "scope": ["entity.other.attribute-name"], + "settings": { "foreground": "#42a5f5" } + }, + { + "scope": ["invalid", "invalid.illegal"], + "settings": { "foreground": "#e53935" } + }, + { + "scope": ["markup.heading", "entity.name.section"], + "settings": { "foreground": "#ff6b35", "fontStyle": "bold" } + }, + { + "scope": ["markup.bold"], + "settings": { "fontStyle": "bold" } + }, + { + "scope": ["markup.italic"], + "settings": { "fontStyle": "italic" } + }, + { + "scope": ["markup.inline.raw", "markup.fenced_code"], + "settings": { "foreground": "#00bcd4" } + } + ] +} diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/themes/devplace-light.json b/devplacepy/services/containers/files/vscode/devplace-workspace/themes/devplace-light.json new file mode 100644 index 00000000..c68c27e8 --- /dev/null +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/themes/devplace-light.json @@ -0,0 +1,223 @@ +{ + "name": "DevPlace Light", + "type": "light", + "colors": { + "editor.background": "#fdfbff", + "editor.foreground": "#1b1230", + "editorLineNumber.foreground": "#8c7fa4", + "editorLineNumber.activeForeground": "#d1481a", + "editorCursor.foreground": "#d1481a", + "editor.selectionBackground": "#ff6b3529", + "editor.selectionHighlightBackground": "#ff6b3517", + "editor.lineHighlightBackground": "#f2edfa", + "editor.findMatchBackground": "#ff6b354d", + "editor.findMatchHighlightBackground": "#ff6b3526", + "editorIndentGuide.background1": "#1b123014", + "editorIndentGuide.activeBackground1": "#1b123029", + "editorWhitespace.foreground": "#1b123014", + "editorRuler.foreground": "#1b123014", + "editorWidget.background": "#ffffff", + "editorWidget.border": "#1b123014", + "editorSuggestWidget.background": "#ffffff", + "editorSuggestWidget.selectedBackground": "#f2edfa", + "editorHoverWidget.background": "#ffffff", + "editorGroup.border": "#1b123014", + "editorGroupHeader.tabsBackground": "#f4f0fb", + "editorGroupHeader.noTabsBackground": "#f4f0fb", + "editorGutter.addedBackground": "#2f8b33", + "editorGutter.modifiedBackground": "#c77700", + "editorGutter.deletedBackground": "#c62828", + "editorError.foreground": "#c62828", + "editorWarning.foreground": "#c77700", + "editorInfo.foreground": "#1976d2", + "editorBracketMatch.background": "#ff6b3524", + "editorBracketMatch.border": "#d1481a", + "foreground": "#1b1230", + "descriptionForeground": "#5c4f74", + "disabledForeground": "#8c7fa4", + "errorForeground": "#c62828", + "focusBorder": "#d1481a", + "selection.background": "#ff6b354d", + "widget.shadow": "#1b123024", + "icon.foreground": "#5c4f74", + "sash.hoverBorder": "#d1481a", + "activityBar.background": "#f4f0fb", + "activityBar.foreground": "#1b1230", + "activityBar.inactiveForeground": "#8c7fa4", + "activityBar.border": "#1b123014", + "activityBarBadge.background": "#d1481a", + "activityBarBadge.foreground": "#ffffff", + "sideBar.background": "#f4f0fb", + "sideBar.foreground": "#5c4f74", + "sideBar.border": "#1b123014", + "sideBarTitle.foreground": "#1b1230", + "sideBarSectionHeader.background": "#ece5f7", + "sideBarSectionHeader.foreground": "#1b1230", + "list.activeSelectionBackground": "#ff6b351f", + "list.activeSelectionForeground": "#1b1230", + "list.inactiveSelectionBackground": "#ece5f7", + "list.hoverBackground": "#ece5f7", + "list.highlightForeground": "#d1481a", + "list.errorForeground": "#c62828", + "list.warningForeground": "#c77700", + "tree.indentGuidesStroke": "#1b123014", + "statusBar.background": "#f4f0fb", + "statusBar.foreground": "#5c4f74", + "statusBar.border": "#1b123014", + "statusBar.noFolderBackground": "#f4f0fb", + "statusBar.debuggingBackground": "#d1481a", + "statusBar.debuggingForeground": "#ffffff", + "statusBarItem.remoteBackground": "#d1481a", + "statusBarItem.remoteForeground": "#ffffff", + "statusBarItem.hoverBackground": "#1b12300d", + "titleBar.activeBackground": "#fdfbff", + "titleBar.activeForeground": "#1b1230", + "titleBar.inactiveBackground": "#fdfbff", + "titleBar.inactiveForeground": "#8c7fa4", + "titleBar.border": "#1b123014", + "menu.background": "#ffffff", + "menu.foreground": "#1b1230", + "menu.selectionBackground": "#ece5f7", + "menubar.selectionBackground": "#ece5f7", + "tab.activeBackground": "#fdfbff", + "tab.activeForeground": "#1b1230", + "tab.activeBorderTop": "#d1481a", + "tab.inactiveBackground": "#f4f0fb", + "tab.inactiveForeground": "#8c7fa4", + "tab.border": "#1b123014", + "tab.hoverBackground": "#ece5f7", + "panel.background": "#ffffff", + "panel.border": "#1b123014", + "panelTitle.activeForeground": "#1b1230", + "panelTitle.activeBorder": "#d1481a", + "panelTitle.inactiveForeground": "#8c7fa4", + "terminal.background": "#fdfbff", + "terminal.foreground": "#1b1230", + "terminal.selectionBackground": "#ff6b3529", + "terminalCursor.foreground": "#d1481a", + "terminal.ansiBlack": "#1b1230", + "terminal.ansiRed": "#c62828", + "terminal.ansiGreen": "#2f8b33", + "terminal.ansiYellow": "#c77700", + "terminal.ansiBlue": "#1976d2", + "terminal.ansiMagenta": "#c2185b", + "terminal.ansiCyan": "#00838f", + "terminal.ansiWhite": "#f4f0fb", + "terminal.ansiBrightBlack": "#5c4f74", + "terminal.ansiBrightRed": "#e53935", + "terminal.ansiBrightGreen": "#4caf50", + "terminal.ansiBrightYellow": "#ff9800", + "terminal.ansiBrightBlue": "#42a5f5", + "terminal.ansiBrightMagenta": "#ff4f8b", + "terminal.ansiBrightCyan": "#00bcd4", + "terminal.ansiBrightWhite": "#ffffff", + "button.background": "#d1481a", + "button.foreground": "#ffffff", + "button.hoverBackground": "#e2551f", + "button.secondaryBackground": "#ece5f7", + "button.secondaryForeground": "#1b1230", + "badge.background": "#d1481a", + "badge.foreground": "#ffffff", + "progressBar.background": "#d1481a", + "input.background": "#ffffff", + "input.foreground": "#1b1230", + "input.border": "#1b123014", + "input.placeholderForeground": "#8c7fa4", + "inputOption.activeBorder": "#d1481a", + "inputValidation.errorBackground": "#c6282826", + "inputValidation.errorBorder": "#c62828", + "dropdown.background": "#ffffff", + "dropdown.foreground": "#1b1230", + "dropdown.border": "#1b123014", + "checkbox.background": "#ffffff", + "checkbox.border": "#1b123014", + "scrollbarSlider.background": "#1b123014", + "scrollbarSlider.hoverBackground": "#1b123029", + "scrollbarSlider.activeBackground": "#ff6b354d", + "quickInput.background": "#ffffff", + "quickInputList.focusBackground": "#ece5f7", + "notifications.background": "#ffffff", + "notifications.border": "#1b123014", + "notificationCenterHeader.background": "#f4f0fb", + "peekView.border": "#d1481a", + "peekViewEditor.background": "#f4f0fb", + "peekViewResult.background": "#ffffff", + "gitDecoration.modifiedResourceForeground": "#c77700", + "gitDecoration.deletedResourceForeground": "#c62828", + "gitDecoration.untrackedResourceForeground": "#2f8b33", + "gitDecoration.ignoredResourceForeground": "#8c7fa4", + "gitDecoration.conflictingResourceForeground": "#c2185b", + "minimap.findMatchHighlight": "#d1481a", + "welcomePage.background": "#fdfbff", + "welcomePage.progress.foreground": "#d1481a", + "welcomePage.tileBackground": "#ffffff", + "welcomePage.tileHoverBackground": "#f2edfa", + "textLink.foreground": "#d1481a", + "textLink.activeForeground": "#e2551f", + "textBlockQuote.background": "#f4f0fb", + "textCodeBlock.background": "#f4f0fb", + "textPreformat.foreground": "#c2185b" + }, + "tokenColors": [ + { + "scope": ["comment", "punctuation.definition.comment"], + "settings": { "foreground": "#8c7fa4", "fontStyle": "italic" } + }, + { + "scope": ["string", "string.quoted", "meta.embedded.assembly"], + "settings": { "foreground": "#2f8b33" } + }, + { + "scope": ["constant.numeric", "constant.language", "constant.character"], + "settings": { "foreground": "#c77700" } + }, + { + "scope": ["keyword", "keyword.control", "storage", "storage.type"], + "settings": { "foreground": "#c2185b" } + }, + { + "scope": ["entity.name.function", "support.function", "meta.function-call"], + "settings": { "foreground": "#d1481a" } + }, + { + "scope": ["entity.name.type", "entity.name.class", "support.class", "support.type"], + "settings": { "foreground": "#00838f" } + }, + { + "scope": ["variable", "variable.other", "meta.definition.variable"], + "settings": { "foreground": "#1b1230" } + }, + { + "scope": ["variable.parameter", "variable.other.property"], + "settings": { "foreground": "#5c4f74" } + }, + { + "scope": ["entity.name.tag", "punctuation.definition.tag"], + "settings": { "foreground": "#c2185b" } + }, + { + "scope": ["entity.other.attribute-name"], + "settings": { "foreground": "#1976d2" } + }, + { + "scope": ["invalid", "invalid.illegal"], + "settings": { "foreground": "#c62828" } + }, + { + "scope": ["markup.heading", "entity.name.section"], + "settings": { "foreground": "#d1481a", "fontStyle": "bold" } + }, + { + "scope": ["markup.bold"], + "settings": { "fontStyle": "bold" } + }, + { + "scope": ["markup.italic"], + "settings": { "fontStyle": "italic" } + }, + { + "scope": ["markup.inline.raw", "markup.fenced_code"], + "settings": { "foreground": "#00838f" } + } + ] +} diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/agent.md b/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/agent.md new file mode 100644 index 00000000..effbe398 --- /dev/null +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/agent.md @@ -0,0 +1,12 @@ +# DevPlace Code + +`dpc` is the coding agent that ships with every DevPlace workspace. It is already running in the +**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. + +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**. diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/files.md b/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/files.md new file mode 100644 index 00000000..4265506d --- /dev/null +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/files.md @@ -0,0 +1,9 @@ +# Your files are your project + +The folder open in this editor is `/app`, and it is your DevPlace project. + +Files sync both ways on a short cycle: what you write here appears in the project file browser on +DevPlace, and what you change on DevPlace appears here. Whichever side is newer wins, and nothing is +ever deleted by the sync. + +A read-only project exports to the workspace but never imports back. diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/limits.md b/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/limits.md new file mode 100644 index 00000000..ec4dfc98 --- /dev/null +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/limits.md @@ -0,0 +1,10 @@ +# Quotas and idle stop + +Your workspace has a size: CPU, memory and disk, all set by your DevPlace quota. Egress and the +number of tunnels are bounded too. + +It stops on its own after a period with no activity, and is removed after a longer period of being +stopped. You are warned before each step, and a warning always says exactly what happens and when. + +Everything on this page is on your DevPlace workspace page, together with the editor preferences +that control this window. diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/toolchains.md b/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/toolchains.md new file mode 100644 index 00000000..969badc5 --- /dev/null +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/toolchains.md @@ -0,0 +1,9 @@ +# Install anything + +`sudo` and `apt install` work here with no extra setup, and nothing you install can break the +workspace for anyone else. + +Preinstalled: Python with a broad library set and Playwright, Rust, Nim, Swift, plus `git`, `tmux`, +`vim`, `curl`, `htop` and the usual command line tools. + +Run `apt update` once before your first `apt install`. diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/tunnels.md b/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/tunnels.md new file mode 100644 index 00000000..d8e3f905 --- /dev/null +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/walkthrough/tunnels.md @@ -0,0 +1,11 @@ +# Publish a port + +Run your server on a high port, then publish that port from the workspace page. DevPlace gives it a +public HTTPS address of the form `-.tunnel.pravda.education`. + +**A tunnel is public and unauthenticated.** Anyone with the link reaches whatever you are serving. + +Your live addresses are always listed in `/app/.devplace/tunnels.json`, and +**DevPlace: Show public tunnels** opens any of them. + +Ports below 1024 cannot bind in a workspace. Use a high port. diff --git a/devplacepy/services/containers/files/vscode/product.patch.json b/devplacepy/services/containers/files/vscode/product.patch.json new file mode 100644 index 00000000..0347e5d5 --- /dev/null +++ b/devplacepy/services/containers/files/vscode/product.patch.json @@ -0,0 +1,13 @@ +{ + "nameShort": "DevPlace", + "nameLong": "DevPlace Workspace", + "applicationName": "devplace", + "dataFolderName": ".devplace-editor", + "reportIssueUrl": "https://pravda.education/issues", + "documentationUrl": "https://pravda.education/docs/workspace-editor.html", + "licenseUrl": "https://pravda.education/docs/terms.html", + "privacyStatementUrl": "https://pravda.education/docs/privacy.html", + "twitterUrl": "", + "requestFeatureUrl": "https://pravda.education/issues", + "licenseName": "DevPlace Terms of Service" +} diff --git a/devplacepy/services/containers/workspace/__init__.py b/devplacepy/services/containers/workspace/__init__.py index 3567994d..f8dcdf81 100644 --- a/devplacepy/services/containers/workspace/__init__.py +++ b/devplacepy/services/containers/workspace/__init__.py @@ -1,5 +1,5 @@ # retoor -from . import flags, naming, provision, quota, tunnels +from . import editor, flags, naming, provision, quota, tunnels -__all__ = ["flags", "naming", "provision", "quota", "tunnels"] +__all__ = ["editor", "flags", "naming", "provision", "quota", "tunnels"] diff --git a/devplacepy/services/containers/workspace/editor.py b/devplacepy/services/containers/workspace/editor.py new file mode 100644 index 00000000..15611015 --- /dev/null +++ b/devplacepy/services/containers/workspace/editor.py @@ -0,0 +1,463 @@ +# retoor + +from __future__ import annotations + +import json +import logging +from dataclasses import asdict, dataclass +from pathlib import Path + +from devplacepy import config +from devplacepy.database import get_int_setting, get_setting, get_table +from devplacepy.services.containers import store +from devplacepy.services.containers.backend.base import ( + WORKSPACE_MOUNT, + WORKSPACE_STATE_MOUNT, +) + +from . import quota + +logger = logging.getLogger(__name__) + +PREFS_TABLE = "workspace_editor_prefs" + +APP_NAME = "DevPlace" +WELCOME_TEXT = "Sign in to your DevPlace workspace" +EDITOR_DEFAULT_PORT = 8443 +PROFILE_FILE = "devplace-editor.json" +MANAGED_FILE = ".devplace-managed.json" + +INHERIT_TEXT = "" +INHERIT_INT = 0 +INHERIT_ZOOM = -99 +INHERIT_FLAG = -1 + +THEME_CHOICES = ("devplace-dark", "devplace-light", "system") +LAYOUT_CHOICES = ("standard", "terminal-focus", "zen") +PANEL_CHOICES = ("short", "normal", "tall", "maximized") +WINDOW_CHOICES = ("tab", "window", "fullscreen") +AGENT_CHOICES = ("dpc", "none") + +CHOICES = { + "theme": THEME_CHOICES, + "layout": LAYOUT_CHOICES, + "panel_preset": PANEL_CHOICES, + "window_mode": WINDOW_CHOICES, + "boot_agent": AGENT_CHOICES, +} + +SENTINELS = { + "zoom_level": INHERIT_ZOOM, + "boot_shell": INHERIT_FLAG, +} + +THEME_LABELS = { + "devplace-dark": "DevPlace Dark", + "devplace-light": "DevPlace Light", +} + +LAYOUTS = { + "standard": { + "workbench.activityBar.location": "default", + "workbench.sideBar.location": "left", + "workbench.panel.defaultLocation": "bottom", + "editor.minimap.enabled": True, + "breadcrumbs.enabled": True, + }, + "terminal-focus": { + "workbench.activityBar.location": "top", + "workbench.sideBar.location": "left", + "workbench.panel.defaultLocation": "bottom", + "editor.minimap.enabled": False, + "breadcrumbs.enabled": True, + }, + "zen": { + "workbench.activityBar.location": "hidden", + "workbench.sideBar.location": "left", + "workbench.panel.defaultLocation": "bottom", + "editor.minimap.enabled": False, + "breadcrumbs.enabled": False, + }, +} + +FOREIGN_AI_SETTINGS = { + "chat.disableAIFeatures": True, + "chat.commandCenter.enabled": False, + "workbench.secondarySideBar.defaultVisibility": "hidden", +} + +TRUST_SETTINGS = { + "security.workspace.trust.enabled": False, + "security.workspace.trust.startupPrompt": "never", + "security.workspace.trust.banner": "never", + "security.workspace.trust.emptyWindow": True, + "security.workspace.trust.untrustedFiles": "open", + "task.allowAutomaticTasks": "on", +} + +BOUNDS = { + "font_size": (8, 48), + "terminal_font_size": (8, 48), + "zoom_level": (-5, 5), + "window_width": (640, 7680), + "window_height": (480, 4320), +} + +SETTING_KEYS = { + "trust_all": "workspace_editor_trust_all", + "theme": "workspace_editor_theme", + "font_size": "workspace_editor_font_size", + "terminal_font_size": "workspace_editor_terminal_font_size", + "zoom_level": "workspace_editor_zoom_level", + "layout": "workspace_editor_layout", + "panel_preset": "workspace_editor_panel_preset", + "boot_agent": "workspace_editor_boot_agent", + "boot_shell": "workspace_editor_boot_shell", + "window_mode": "workspace_editor_window_mode", + "window_width": "workspace_editor_window_width", + "window_height": "workspace_editor_window_height", +} + +DEFAULTS = { + "trust_all": True, + "theme": "devplace-dark", + "font_size": 14, + "terminal_font_size": 13, + "zoom_level": 0, + "layout": "standard", + "panel_preset": "tall", + "boot_agent": "dpc", + "boot_shell": True, + "window_mode": "tab", + "window_width": 1600, + "window_height": 1000, +} + +PREF_COLUMNS = ( + "font_size", + "terminal_font_size", + "zoom_level", + "theme", + "layout", + "panel_preset", + "window_mode", + "window_width", + "window_height", + "boot_agent", + "boot_shell", +) + +OPTIONAL_FLAGS = ( + "--app-name", + "--welcome-text", + "--disable-getting-started-override", + "--disable-workspace-trust", +) + +SOURCE_SITE = "site" +SOURCE_USER = "user" + + +@dataclass(frozen=True) +class EditorProfile: + trust_all: bool + theme: str + font_size: int + terminal_font_size: int + zoom_level: int + layout: str + panel_preset: str + boot_agent: str + boot_shell: bool + window_mode: str + window_width: int + window_height: int + cpu_millicores: int + memory_mb: int + disk_quota_mb: int + + def as_dict(self) -> dict: + return asdict(self) + + def cpu_cores(self) -> float: + return round(self.cpu_millicores / 1000, 3) + + def cpu_limit(self) -> str: + return quota.format_cpu(self.cpu_millicores) + + def mem_limit(self) -> str: + return quota.format_memory(self.memory_mb) + + +def _clamp(key: str, value: int) -> int: + bounds = BOUNDS.get(key) + if not bounds: + return value + low, high = bounds + return max(low, min(high, value)) + + +def _choice(value: str, choices: tuple[str, ...], fallback: str) -> str: + cleaned = (value or "").strip().lower() + return cleaned if cleaned in choices else fallback + + +def prefs_for(owner_uid: str) -> dict | None: + if not owner_uid: + return None + return get_table(PREFS_TABLE).find_one( + owner_kind="user", owner_id=owner_uid, deleted_at=None + ) + + +def _inherits(key: str, row: dict | None) -> bool: + if not row: + return True + value = row.get(key) + if value is None: + return True + if key in SENTINELS: + return int(value) == SENTINELS[key] + if isinstance(DEFAULTS[key], str): + return not str(value).strip() + return int(value) == INHERIT_INT + + +def _site_value(key: str): + default = DEFAULTS[key] + setting = SETTING_KEYS[key] + if isinstance(default, bool): + return get_setting(setting, "1" if default else "0") == "1" + if isinstance(default, int): + return get_int_setting(setting, default) + return get_setting(setting, default) + + +def _normalize(key: str, value): + default = DEFAULTS[key] + if key in CHOICES: + return _choice(value, CHOICES[key], default) + if isinstance(default, bool): + if isinstance(value, bool): + return value + try: + return bool(int(value)) + except (TypeError, ValueError): + return bool(value) + if isinstance(default, int): + try: + return _clamp(key, int(value)) + except (TypeError, ValueError): + return default + return str(value) + + +def source_map(owner_uid: str = "") -> dict[str, str]: + row = prefs_for(owner_uid) + sources = {key: SOURCE_SITE for key in SETTING_KEYS} + for key in PREF_COLUMNS: + if not _inherits(key, row): + sources[key] = SOURCE_USER + return sources + + +def resolve(owner_uid: str = "", instance: dict | None = None) -> EditorProfile: + row = prefs_for(owner_uid) + values = {} + for key in SETTING_KEYS: + value = _site_value(key) + if key in PREF_COLUMNS and not _inherits(key, row): + value = row.get(key) + values[key] = _normalize(key, value) + limits = quota.resolve(owner_uid, instance) + return EditorProfile( + cpu_millicores=limits.cpu_millicores, + memory_mb=limits.memory_mb, + disk_quota_mb=limits.disk_quota_mb, + **values, + ) + + +def settings_for(profile: EditorProfile) -> dict: + settings = { + "editor.fontSize": profile.font_size, + "terminal.integrated.fontSize": profile.terminal_font_size, + "window.zoomLevel": profile.zoom_level, + "telemetry.telemetryLevel": "off", + "update.mode": "none", + "workbench.tips.enabled": False, + "workbench.startupEditor": "none", + "extensions.autoCheckUpdates": False, + **FOREIGN_AI_SETTINGS, + "terminal.integrated.defaultProfile.linux": "bash", + "terminal.integrated.profiles.linux": { + "bash": {"path": "/bin/bash", "args": ["-l"], "icon": "terminal-bash"}, + "DevPlace Code": {"path": "/usr/bin/dpc", "icon": "rocket"}, + }, + } + settings.update(LAYOUTS[profile.layout]) + if profile.theme in THEME_LABELS: + settings["workbench.colorTheme"] = THEME_LABELS[profile.theme] + if profile.trust_all: + settings.update(TRUST_SETTINGS) + return settings + + +def merge_managed(current: dict, managed: dict, desired: dict) -> tuple[dict, dict]: + merged = dict(current) + for key, value in desired.items(): + if key not in merged or merged[key] == managed.get(key): + merged[key] = value + return merged, dict(desired) + + +def _read_json(path: Path) -> dict: + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def state_dir(instance: dict) -> Path: + return config.WORKSPACE_STATE_DIR / instance["uid"] + + +def profile_payload(instance: dict, profile: EditorProfile) -> dict: + return { + "app_name": APP_NAME, + "workspace_uid": instance.get("uid", ""), + "boot_marker": instance.get("boot_marker", ""), + "editor": profile.as_dict(), + } + + +def seed_state(instance: dict, profile: EditorProfile) -> bool: + root = state_dir(instance) + user_dir = root / "data" / "User" + try: + user_dir.mkdir(parents=True, exist_ok=True) + settings_path = user_dir / "settings.json" + managed_path = user_dir / MANAGED_FILE + settings, managed = merge_managed( + _read_json(settings_path), + _read_json(managed_path), + settings_for(profile), + ) + settings_path.write_text(json.dumps(settings, indent=2, sort_keys=True)) + managed_path.write_text(json.dumps(managed, indent=2, sort_keys=True)) + (root / PROFILE_FILE).write_text( + json.dumps(profile_payload(instance, profile), indent=2, sort_keys=True) + ) + except OSError as error: + logger.warning("workspace editor seed failed for %s: %s", instance.get("uid"), error) + return False + return True + + +def argv(instance: dict, profile: EditorProfile) -> list[str]: + port = int(instance.get("editor_port") or EDITOR_DEFAULT_PORT) + command = [ + "code-server", + "--bind-addr", + f"0.0.0.0:{port}", + "--auth", + "password", + "--app-name", + APP_NAME, + "--welcome-text", + WELCOME_TEXT, + "--disable-telemetry", + "--disable-update-check", + "--disable-getting-started-override", + ] + if profile.trust_all: + command.append("--disable-workspace-trust") + command += [ + "--user-data-dir", + f"{WORKSPACE_STATE_MOUNT}/data", + "--extensions-dir", + f"{WORKSPACE_STATE_MOUNT}/extensions", + WORKSPACE_MOUNT, + ] + return command + + +def env_for(profile: EditorProfile) -> dict: + return { + "DEVPLACE_EDITOR_APP_NAME": APP_NAME, + "DEVPLACE_EDITOR_PROFILE": f"{WORKSPACE_STATE_MOUNT}/{PROFILE_FILE}", + "DEVPLACE_EDITOR_THEME": profile.theme, + "DEVPLACE_EDITOR_FONT_SIZE": str(profile.font_size), + "DEVPLACE_EDITOR_TERMINAL_FONT_SIZE": str(profile.terminal_font_size), + "DEVPLACE_EDITOR_ZOOM_LEVEL": str(profile.zoom_level), + "DEVPLACE_EDITOR_LAYOUT": profile.layout, + "DEVPLACE_EDITOR_PANEL_PRESET": profile.panel_preset, + "DEVPLACE_EDITOR_BOOT_AGENT": profile.boot_agent, + "DEVPLACE_EDITOR_BOOT_SHELL": "1" if profile.boot_shell else "0", + "DEVPLACE_EDITOR_TRUST_ALL": "1" if profile.trust_all else "0", + } + + +def booted_profile(instance: dict) -> dict: + return _read_json(state_dir(instance) / PROFILE_FILE).get("editor") or {} + + +def restart_required(instance: dict, profile: EditorProfile) -> bool: + booted = booted_profile(instance) + if not booted: + return False + return booted != profile.as_dict() + + +def save_prefs(owner_uid: str, payload: dict) -> dict: + from devplacepy.utils import generate_uid + + table = get_table(PREFS_TABLE) + row = prefs_for(owner_uid) + values = {key: payload[key] for key in PREF_COLUMNS if key in payload} + stamp = store.now() + if row: + table.update({"uid": row["uid"], "updated_at": stamp, **values}, ["uid"]) + return table.find_one(uid=row["uid"]) + uid = generate_uid() + table.insert( + { + "uid": uid, + "owner_kind": "user", + "owner_id": owner_uid, + "created_at": stamp, + "updated_at": stamp, + "deleted_at": None, + "deleted_by": None, + **{key: _blank(key) for key in PREF_COLUMNS}, + **values, + } + ) + return table.find_one(uid=uid) + + +def reset_prefs(owner_uid: str, actor_uid: str) -> bool: + row = prefs_for(owner_uid) + if not row: + return False + get_table(PREFS_TABLE).update( + {"uid": row["uid"], "deleted_at": store.now(), "deleted_by": actor_uid}, ["uid"] + ) + return True + + +def _blank(key: str): + if key in SENTINELS: + return SENTINELS[key] + if isinstance(DEFAULTS[key], str): + return INHERIT_TEXT + return INHERIT_INT + + +def view(owner_uid: str = "", instance: dict | None = None) -> dict: + profile = resolve(owner_uid, instance) + payload = profile.as_dict() + payload["cpu_cores"] = profile.cpu_cores() + payload["sources"] = source_map(owner_uid) + return payload diff --git a/devplacepy/services/containers/workspace/provision.py b/devplacepy/services/containers/workspace/provision.py index 10de5800..63be3be6 100644 --- a/devplacepy/services/containers/workspace/provision.py +++ b/devplacepy/services/containers/workspace/provision.py @@ -6,11 +6,10 @@ import asyncio import json from pathlib import Path -from devplacepy import config from devplacepy.database import get_table from devplacepy.services.containers import api, store -from . import flags, naming, quota, tunnels +from . import editor, flags, naming, quota, tunnels MANIFEST_DIRECTORY = ".devplace" MANIFEST_NAME = "tunnels.json" @@ -193,12 +192,9 @@ def write_manifest(instance: dict) -> None: return -def state_dir(instance: dict) -> Path: - return config.WORKSPACE_STATE_DIR / instance["uid"] - - -def view(instance: dict, viewer_is_admin: bool = False) -> dict: - limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance) +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) return { @@ -226,4 +222,5 @@ def view(instance: dict, viewer_is_admin: bool = False) -> dict: "max_tunnels": limits.max_tunnels, "tunnels": tunnels.list_for_instance(instance["uid"]), "flags": flags.list_flags(instance_uid=instance["uid"]), + "editor": editor.view(owner_uid, instance), } diff --git a/devplacepy/services/containers/workspace/quota.py b/devplacepy/services/containers/workspace/quota.py index ed6df13f..1b8f7f84 100644 --- a/devplacepy/services/containers/workspace/quota.py +++ b/devplacepy/services/containers/workspace/quota.py @@ -18,6 +18,8 @@ DEFAULTS: dict[str, int] = { "retention_days": 14, "purge_after_days": 7, "disk_warn_percent": 80, + "cpu_millicores": 2000, + "memory_mb": 2048, } SETTING_KEYS: dict[str, str] = { @@ -30,6 +32,8 @@ SETTING_KEYS: dict[str, str] = { "retention_days": "workspace_retention_days", "purge_after_days": "workspace_purge_after_days", "disk_warn_percent": "workspace_disk_warn_percent", + "cpu_millicores": "workspace_cpu_millicores", + "memory_mb": "workspace_memory_mb", } RULE_COLUMNS = ( @@ -39,8 +43,28 @@ RULE_COLUMNS = ( "egress_quota_mb", "idle_stop_minutes", "retention_days", + "cpu_millicores", + "memory_mb", ) +INSTANCE_OVERRIDE_COLUMNS = ( + "cpu_millicores", + "memory_mb", + "disk_quota_mb", +) + + +def format_cpu(millicores: int) -> str: + if millicores <= 0: + return "" + return f"{millicores / 1000:.3f}".rstrip("0").rstrip(".") + + +def format_memory(megabytes: int) -> str: + if megabytes <= 0: + return "" + return f"{megabytes}m" + @dataclass(frozen=True) class Limits: @@ -53,6 +77,8 @@ class Limits: retention_days: int purge_after_days: int disk_warn_percent: int + cpu_millicores: int + memory_mb: int def disk_quota_bytes(self) -> int: return self.disk_quota_mb * 1024 * 1024 @@ -60,6 +86,12 @@ class Limits: def egress_quota_bytes(self) -> int: return self.egress_quota_mb * 1024 * 1024 + def cpu_limit(self) -> str: + return format_cpu(self.cpu_millicores) + + def mem_limit(self) -> str: + return format_memory(self.memory_mb) + def _global_value(key: str) -> int: return get_int_setting(SETTING_KEYS[key], DEFAULTS[key]) @@ -81,7 +113,7 @@ def resolve(user_uid: str = "", instance: dict | None = None) -> Limits: override = rule.get(key) if override: value = int(override) - if instance: + if instance and key in INSTANCE_OVERRIDE_COLUMNS: instance_override = instance.get(f"workspace_{key}") if instance_override: value = int(instance_override) diff --git a/devplacepy/services/containers/workspace_service.py b/devplacepy/services/containers/workspace_service.py index 84598d42..085394b1 100644 --- a/devplacepy/services/containers/workspace_service.py +++ b/devplacepy/services/containers/workspace_service.py @@ -77,9 +77,6 @@ class WorkspaceService(BaseService): config_fields = [ ConfigField("workspace_enabled", "Enabled", type="bool", default="0", group="General", help="Master switch for the workspace feature."), - ConfigField("workspace_editor_version", "Editor version", default="", - group="General", - help="code-server release. Empty means the image default."), ConfigField("workspace_extensions_gallery", "Extensions gallery", type="text", default="", group="General", help="EXTENSIONS_GALLERY JSON. Empty means the default."), @@ -112,6 +109,61 @@ class WorkspaceService(BaseService): default="10240", minimum=0, group="Quotas"), ConfigField("workspace_disk_warn_percent", "Disk warn percent", type="int", default="80", minimum=1, maximum=100, group="Quotas"), + ConfigField("workspace_cpu_millicores", "CPU per workspace (millicores)", + type="int", default="2000", minimum=250, group="Quotas", + help="1000 millicores is one core. Applied as the docker --cpus limit."), + ConfigField("workspace_memory_mb", "Memory per workspace (MB)", type="int", + default="2048", minimum=256, group="Quotas", + help="Applied as the docker --memory limit."), + ConfigField("workspace_editor_trust_all", "Trust every workspace", type="bool", + default="1", group="Editor", + help="Disables VS Code Restricted Mode. Turning this off restores " + "the workspace trust prompt and blocks automatic tasks."), + ConfigField("workspace_editor_theme", "Editor theme", type="select", + default="devplace-dark", + options=[{"value": "devplace-dark", "label": "DevPlace Dark"}, + {"value": "devplace-light", "label": "DevPlace Light"}, + {"value": "system", "label": "Leave to the member"}], + group="Editor"), + ConfigField("workspace_editor_font_size", "Editor font size", type="int", + default="14", minimum=8, maximum=48, group="Editor"), + ConfigField("workspace_editor_terminal_font_size", "Terminal font size", + type="int", default="13", minimum=8, maximum=48, group="Editor"), + ConfigField("workspace_editor_zoom_level", "Zoom level", type="int", + default="0", minimum=-5, maximum=5, group="Editor", + help="VS Code window zoom. Each step is about 20 percent."), + ConfigField("workspace_editor_layout", "Editor layout", type="select", + default="standard", + options=[{"value": "standard", "label": "Standard"}, + {"value": "terminal-focus", "label": "Terminal focus"}, + {"value": "zen", "label": "Zen"}], + group="Editor"), + ConfigField("workspace_editor_panel_preset", "Terminal panel size", + type="select", default="tall", + options=[{"value": "short", "label": "Short"}, + {"value": "normal", "label": "Normal"}, + {"value": "tall", "label": "Tall"}, + {"value": "maximized", "label": "Maximized"}], + group="Editor"), + ConfigField("workspace_editor_boot_agent", "Agent on boot", type="select", + default="dpc", + options=[{"value": "dpc", "label": "DevPlace Code (dpc)"}, + {"value": "none", "label": "None"}], + group="Editor"), + ConfigField("workspace_editor_boot_shell", "Shell on boot", type="bool", + default="1", group="Editor", + help="Opens a plain login shell beside the agent terminal."), + ConfigField("workspace_editor_window_mode", "Open editor in", type="select", + default="tab", + options=[{"value": "tab", "label": "A new tab"}, + {"value": "window", "label": "A sized window"}, + {"value": "fullscreen", "label": "A fullscreen window"}], + group="Editor"), + ConfigField("workspace_editor_window_width", "Editor window width", type="int", + default="1600", minimum=640, maximum=7680, group="Editor"), + ConfigField("workspace_editor_window_height", "Editor window height", + type="int", default="1000", minimum=480, maximum=4320, + group="Editor"), ConfigField("workspace_idle_warn_minutes", "Idle warn (minutes)", type="int", default="45", minimum=1, group="Lifecycle"), ConfigField("workspace_idle_stop_minutes", "Idle stop (minutes)", type="int", diff --git a/devplacepy/services/devii/actions/dispatcher.py b/devplacepy/services/devii/actions/dispatcher.py index 0ee11dbb..54ec706c 100644 --- a/devplacepy/services/devii/actions/dispatcher.py +++ b/devplacepy/services/devii/actions/dispatcher.py @@ -41,6 +41,7 @@ CONFIRM_REQUIRED = { "tunnel_delete", "workspace_flag_resolve", "workspace_suspend", + "workspace_editor_set", "project_set_private", "project_set_readonly", "customize_set_css", @@ -171,6 +172,17 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError | "Deleting customizations cannot be undone. Ask the user to confirm, then call again with " "confirm=true." ) + if name == "workspace_editor_set": + if arguments.get("reset"): + return ToolInputError( + "Resetting drops every editor preference and returns the workspace to the " + "site defaults. Ask the user to confirm, then call again with confirm=true." + ) + return ToolInputError( + "Editor preferences change how every workspace this member opens looks and " + "behaves, and they apply on the next workspace start. Show the user the exact " + "values you are about to set, then call again with confirm=true." + ) if name == "notification_reset": return ToolInputError( "Resetting clears every notification preference and restores the platform defaults; it " diff --git a/devplacepy/services/devii/actions/workspace_actions.py b/devplacepy/services/devii/actions/workspace_actions.py index 030aa7fd..7c3e04f1 100644 --- a/devplacepy/services/devii/actions/workspace_actions.py +++ b/devplacepy/services/devii/actions/workspace_actions.py @@ -140,6 +140,53 @@ WORKSPACE_ACTIONS: tuple[Action, ...] = ( arg("egress_quota_mb", "Egress quota in MB.", kind="integer"), arg("idle_stop_minutes", "Idle stop window in minutes.", kind="integer"), arg("retention_days", "Retention in days.", kind="integer"), + arg("cpu_millicores", "CPU limit in millicores. 1000 is one core.", kind="integer"), + arg("memory_mb", "Memory limit in MB.", kind="integer"), + ), + ), + Action( + name="workspace_editor_get", + method="LOCAL", + path="", + handler="workspace", + requires_auth=True, + read_only=True, + summary=( + "Read the resolved DevPlace editor profile for a workspace: theme, layout, " + "font sizes, zoom, boot terminals, how the editor opens, the container size, " + "and where each value comes from." + ), + params=( + SLUG, + arg("username", "Administrators only. Read another member's profile."), + ), + ), + Action( + name="workspace_editor_set", + method="LOCAL", + path="", + handler="workspace", + requires_auth=True, + summary=( + "Change the caller's DevPlace editor preferences. Omitted fields are left " + "alone; an empty string or zero means inherit the site default. Applies on " + "the next workspace start." + ), + params=( + SLUG, + arg("theme", "devplace-dark, devplace-light or system."), + arg("layout", "standard, terminal-focus or zen."), + arg("panel_preset", "short, normal, tall or maximized."), + arg("font_size", "Editor font size in pixels.", kind="integer"), + arg("terminal_font_size", "Terminal font size in pixels.", kind="integer"), + arg("zoom_level", "Window zoom level, -5 to 5.", kind="integer"), + arg("boot_agent", "dpc to open the agent terminal on boot, none to skip it."), + arg("boot_shell", "1 to open a plain shell on boot, 0 to skip it.", kind="integer"), + arg("window_mode", "tab, window or fullscreen."), + arg("window_width", "Editor window width in pixels.", kind="integer"), + arg("window_height", "Editor window height in pixels.", kind="integer"), + arg("reset", "Drop every preference and fall back to the site defaults.", kind="boolean"), + CONFIRM, ), ), Action( diff --git a/devplacepy/services/devii/workspace/controller.py b/devplacepy/services/devii/workspace/controller.py index 3a1b79aa..4f8b9f46 100644 --- a/devplacepy/services/devii/workspace/controller.py +++ b/devplacepy/services/devii/workspace/controller.py @@ -8,6 +8,7 @@ from typing import Any from devplacepy.database import get_table, resolve_by_slug from devplacepy.services.containers import store from devplacepy.services.containers.workspace import ( + editor, flags, provision, quota, @@ -139,6 +140,50 @@ class WorkspaceController: provision.write_manifest(instance) return {"ok": True, "deleted": uid} + def _editor_target(self, args: dict) -> str: + username = args.get("username", "") + if not username: + return self.owner_id + if not self.admin: + raise WorkspaceError( + "only administrators may read another user's editor profile" + ) + other = self._user_by_name(username) + if not other: + raise WorkspaceError(f"user not found: {username}") + return other["uid"] + + def _workspace_editor_get(self, args: dict) -> Any: + target = self._editor_target(args) + instance = provision.find_for_project( + self._project_uid(args.get("project_slug", "")), target + ) + return editor.view(target, instance) + + def _workspace_editor_set(self, args: dict) -> Any: + if not self.owner_id: + raise WorkspaceError("sign in to change editor preferences") + if args.get("reset"): + editor.reset_prefs(self.owner_id, self.owner_id) + return {"ok": True, "reset": True, "editor": editor.view(self.owner_id)} + payload = { + key: args[key] for key in editor.PREF_COLUMNS if args.get(key) is not None + } + if not payload: + raise WorkspaceError("no editor preference was supplied") + editor.save_prefs(self.owner_id, payload) + return { + "ok": True, + "editor": editor.view(self.owner_id), + "applies": "on the next workspace start", + } + + def _project_uid(self, slug: str) -> str: + project = self._project(slug) + if not project: + raise WorkspaceError(f"project not found: {slug}") + return project["uid"] + def _workspace_quota_get(self, args: dict) -> Any: target = self.owner_id username = args.get("username", "") diff --git a/devplacepy/static/css/workspace.css b/devplacepy/static/css/workspace.css index 25693942..7e25a82f 100644 --- a/devplacepy/static/css/workspace.css +++ b/devplacepy/static/css/workspace.css @@ -111,6 +111,92 @@ border-left: 3px solid var(--text-secondary); } +.workspace-editor-heading { + margin: var(--space-lg) 0 var(--space-xs); + color: var(--text-primary); +} + +.workspace-editor-restart { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + margin-bottom: var(--space-md); + border-left: 3px solid var(--warning); + border-radius: var(--radius); + background: var(--overlay-light); + color: var(--text-primary); +} + +.workspace-editor-summary { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: var(--space-sm); + list-style: none; + margin: 0 0 var(--space-md); + padding: 0; +} + +.workspace-editor-summary li { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--space-sm); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-secondary); +} + +.workspace-editor-summary span { + color: var(--text-secondary); + font-size: 0.85rem; +} + +.workspace-editor-summary strong { + color: var(--text-primary); + text-transform: capitalize; +} + +.workspace-editor-summary em { + color: var(--text-muted); + font-size: 0.75rem; + font-style: normal; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.workspace-editor-form { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: var(--space-sm); +} + +.workspace-editor-form label { + display: flex; + flex-direction: column; + gap: var(--space-xs); + color: var(--text-secondary); + font-size: 0.9rem; +} + +.workspace-editor-actions { + grid-column: 1 / -1; + display: flex; + gap: var(--space-sm); +} + +.workspace-editor-reset { + margin-top: var(--space-sm); +} + +@media (max-width: 768px) { + .workspace-editor-summary, + .workspace-editor-form { + grid-template-columns: 1fr; + } +} + @media (max-width: 768px) { .workspace-meters { flex-direction: column; diff --git a/devplacepy/static/js/Application.js b/devplacepy/static/js/Application.js index 6eb5562b..228382b6 100644 --- a/devplacepy/static/js/Application.js +++ b/devplacepy/static/js/Application.js @@ -42,6 +42,7 @@ import { LiveNotifications } from "./LiveNotifications.js"; import { PresenceManager } from "./PresenceManager.js"; import { OnlineUsers } from "./OnlineUsers.js"; import { LocalTime } from "./LocalTime.js"; +import { EditorLauncher } from "./EditorLauncher.js"; import { ScrollMemory } from "./ScrollMemory.js"; import { GameFarm } from "./GameFarm.js"; import { Accessibility } from "./Accessibility.js"; @@ -112,6 +113,7 @@ class Application { this.onlineUsers = new OnlineUsers(this.pubsub); this.localTime = new LocalTime(); this.scrollMemory = new ScrollMemory(); + this.editorLauncher = new EditorLauncher(); this.gameFarm = new GameFarm(); this.overflowTabs = new OverflowTabs(); } diff --git a/devplacepy/static/js/EditorLauncher.js b/devplacepy/static/js/EditorLauncher.js new file mode 100644 index 00000000..a6556b93 --- /dev/null +++ b/devplacepy/static/js/EditorLauncher.js @@ -0,0 +1,58 @@ +// retoor + +export class EditorLauncher { + constructor() { + document.addEventListener("click", (event) => this.onClick(event)); + } + + onClick(event) { + const trigger = event.target.closest("[data-editor-open]"); + if (!trigger) return; + const mode = trigger.dataset.editorMode || "tab"; + if (mode === "tab") return; + const opened = window.open( + trigger.href, + trigger.dataset.editorName || "devplace-editor", + this.features(mode, trigger), + ); + if (!opened) return; + event.preventDefault(); + opened.focus(); + } + + features(mode, trigger) { + const size = this.size(mode, trigger); + const left = Math.max(0, Math.round((window.screen.availWidth - size.width) / 2)); + const top = Math.max(0, Math.round((window.screen.availHeight - size.height) / 2)); + return [ + `width=${size.width}`, + `height=${size.height}`, + `left=${left}`, + `top=${top}`, + "noopener", + "resizable=yes", + "scrollbars=yes", + ].join(","); + } + + size(mode, trigger) { + if (mode === "fullscreen") { + return { + width: window.screen.availWidth, + height: window.screen.availHeight, + }; + } + return { + width: this.clamp(trigger.dataset.editorWidth, 640, window.screen.availWidth), + height: this.clamp(trigger.dataset.editorHeight, 480, window.screen.availHeight), + }; + } + + clamp(raw, minimum, maximum) { + const value = parseInt(raw, 10); + if (!Number.isFinite(value)) return maximum; + return Math.max(minimum, Math.min(maximum, value)); + } +} + +export default EditorLauncher; diff --git a/devplacepy/static/js/WorkspaceManager.js b/devplacepy/static/js/WorkspaceManager.js index 2ebea533..01a68cfd 100644 --- a/devplacepy/static/js/WorkspaceManager.js +++ b/devplacepy/static/js/WorkspaceManager.js @@ -20,7 +20,7 @@ export class WorkspaceManager { bind() { this.root.addEventListener("submit", (event) => { const form = event.target.closest("form"); - if (!form || form.dataset.confirm) return; + if (!form || form.dataset.confirm || form.dataset.native !== undefined) return; event.preventDefault(); this.send(form); }); diff --git a/devplacepy/templates/_editor_open.html b/devplacepy/templates/_editor_open.html new file mode 100644 index 00000000..a86db4ee --- /dev/null +++ b/devplacepy/templates/_editor_open.html @@ -0,0 +1,10 @@ +{# retoor #} + + {%- if _icon %}{{ _icon }} {{ _label or 'Editor' }} + {%- else %}{{ _label or 'Open editor' }}{% endif -%} + diff --git a/devplacepy/templates/admin_workspaces.html b/devplacepy/templates/admin_workspaces.html index cff15d18..c098414c 100644 --- a/devplacepy/templates/admin_workspaces.html +++ b/devplacepy/templates/admin_workspaces.html @@ -21,6 +21,8 @@ Status Disk Egress + Size + Editor Tunnels Flags Actions @@ -41,6 +43,8 @@ {{ ws.disk_percent }}% of {{ ws.disk_quota_mb }} MB {{ ws.egress_percent }}% of {{ ws.egress_quota_mb }} MB + {{ ws.editor.cpu_cores }} CPU / {{ ws.editor.memory_mb }} MB + {{ ws.editor.theme }} / {{ ws.editor.layout }} {{ ws.tunnels|length }} {{ ws.flags|length }} @@ -73,7 +77,7 @@ {% else %} - No workspaces yet. + No workspaces yet. {% endfor %} diff --git a/devplacepy/templates/docs/getting-started-vibing.html b/devplacepy/templates/docs/getting-started-vibing.html index ff856518..98db3792 100644 --- a/devplacepy/templates/docs/getting-started-vibing.html +++ b/devplacepy/templates/docs/getting-started-vibing.html @@ -105,6 +105,11 @@ to the platform AI gateway, so **all of its AI usage is metered through your own account**. There is no separate key to manage and nothing to configure: it is plug and play. +In a **workspace** you do not even have to start it. The DevPlace editor opens a +**DevPlace Code** terminal running `dpc` for you the moment the workspace boots, with +a plain shell beside it. See [The workspace editor](/docs/workspace-editor.html) for +the boot terminals, the trust policy, and every size you can change. + ## Container environment keys Every container is launched with these variables already set. Scripts and agents diff --git a/devplacepy/templates/docs/workspace-editor.html b/devplacepy/templates/docs/workspace-editor.html new file mode 100644 index 00000000..784b3849 --- /dev/null +++ b/devplacepy/templates/docs/workspace-editor.html @@ -0,0 +1,117 @@ +
+# The workspace editor + +Every DevPlace workspace opens a full editor in your browser. It is branded DevPlace, +it starts a coding agent for you, and it is configured from your DevPlace account +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. + +## What opens on boot + +When your workspace starts, two terminals open at the bottom of the window: + +- **DevPlace Code** runs [`dpc`](/docs/getting-started-vibing.html), the coding agent + that ships in every workspace. It has focus, so you can type a request straight + away. Every token it spends is metered against your own DevPlace account. +- **pravda@workspace** is an ordinary login shell, so the Python, Rust, Nim and Swift + toolchains are all on your `PATH`. + +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. + +You can turn either of them off. See **Your preferences** below. + +## Every workspace is trusted + +VS Code normally opens an unfamiliar folder in **Restricted Mode**, which disables +tasks, debugging and most extensions until you click to trust it. DevPlace turns +that off: your workspace is yours, so it is trusted from the first second and +nothing prompts you. + +**This has a real consequence, and you should know it.** Automatic tasks are enabled +too, so if a project you open contains a `.vscode/tasks.json` with a +`"runOn": "folderOpen"` task, that task runs when the folder opens. If you are about +to open code you did not write and do not trust, read that file first. + +An administrator can restore Restricted Mode for the whole site from the workspace +service settings. + +## Size + +Four separate things have a size, and they are set in two different places. + +| What | Set by | Where | +|---|---|---| +| Editor font size, terminal font size, zoom | You | Your workspace page | +| Editor layout and terminal panel size | You | Your workspace page | +| How the editor opens (tab or sized window) | You | Your workspace page | +| CPU, memory and disk | An administrator | Your workspace quota | + +Your own preferences follow you into every workspace you open. The container size is +part of your quota and is shown on the same page so you always know what you have. + +## Your preferences + +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. +- **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 + width and height for the sized case. + +Every field has a **Site default** option. Choosing it removes your preference and +lets the administrator's value apply again, including any future change to it. +**Reset to site defaults** does that for all of them at once. + +Over the API and through Devii the same rule applies field by field: only the fields +you send are changed, and a field you send as empty or zero goes back to inheriting. + +### They apply on the next start + +Editor settings are read when the workspace container boots. After you save, the page +tells you if a restart is needed and gives you the buttons to do it. + +### DevPlace never overwrites a setting you changed yourself + +If you change something inside the editor, that value is yours from then on. DevPlace +only writes a setting it wrote itself last time, so a change to the site default +reaches everyone who has not expressed an opinion and no one who has. + +## Doing it from Devii + +Devii can read and change these for you: + +- *"what is my workspace editor set to"* runs `workspace_editor_get`. +- *"make my workspace editor font 18 and use the light theme"* runs + `workspace_editor_set`. It will show you the exact values and ask before saving. + +## Commands inside the editor + +Press `F1` and type `DevPlace` for the full list: + +| Command | What it does | +|---|---| +| **DevPlace: Start DevPlace Code** | Opens another `dpc` terminal | +| **DevPlace: Open project on DevPlace** | Your project page | +| **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 | + +## Related + +- [Get started with vibing](/docs/getting-started-vibing.html) - the container + runtime, the agents, and publishing what you build. +- The **Dev Workspaces** API group for the same settings over HTTP. +
diff --git a/devplacepy/templates/project_detail.html b/devplacepy/templates/project_detail.html index 17767462..1f0ea927 100644 --- a/devplacepy/templates/project_detail.html +++ b/devplacepy/templates/project_detail.html @@ -71,7 +71,15 @@
📁 Files ({{ file_count }} files) {% if workspace_editor_url %} - 💻 VS Code + {% set _url = workspace_editor_url %} + {% set _uid = project['uid'] %} + {% set _class = "project-star-btn" %} + {% set _icon = "💻"|safe %} + {% set _mode = workspace_editor_mode %} + {% set _width = workspace_editor_width %} + {% set _height = workspace_editor_height %} + {% set _label = "Editor" %} + {% include "_editor_open.html" %} {% endif %} {% if user %} diff --git a/devplacepy/templates/workspace.html b/devplacepy/templates/workspace.html index d3e40620..34292279 100644 --- a/devplacepy/templates/workspace.html +++ b/devplacepy/templates/workspace.html @@ -8,6 +8,7 @@ {% block content %}

Workspace: {{ project.title }}

@@ -60,7 +61,14 @@

{% if workspace.status == "running" %} - Open editor + {% set _url = editor_url %} + {% set _uid = workspace.uid %} + {% set _class = "btn btn-primary" %} + {% set _mode = editor.window_mode %} + {% set _width = editor.window_width %} + {% set _height = editor.window_height %} + {% set _label = "Open editor" %} + {% include "_editor_open.html" %}
@@ -79,6 +87,128 @@
+
+

Editor

+

+ Your workspace opens a branded DevPlace editor with the + dpc coding agent already running. These preferences are yours and + follow you into every workspace you open. +

+ + {% if restart_required %} +
+ Your editor settings changed. Restart the workspace to apply them. +
+ +
+
+ +
+
+ {% endif %} + +
    +
  • Theme{{ editor.theme }}{{ editor.sources.theme }}
  • +
  • Layout{{ editor.layout }}{{ editor.sources.layout }}
  • +
  • Panel{{ editor.panel_preset }}{{ editor.sources.panel_preset }}
  • +
  • Editor font{{ editor.font_size }} px{{ editor.sources.font_size }}
  • +
  • Terminal font{{ editor.terminal_font_size }} px{{ editor.sources.terminal_font_size }}
  • +
  • Zoom{{ editor.zoom_level }}{{ editor.sources.zoom_level }}
  • +
  • Agent on boot{{ editor.boot_agent }}{{ editor.sources.boot_agent }}
  • +
  • Shell on boot{{ "yes" if editor.boot_shell else "no" }}{{ editor.sources.boot_shell }}
  • +
  • Opens in{{ editor.window_mode }}{{ editor.sources.window_mode }}
  • +
  • Trusts every folder{{ "yes" if editor.trust_all else "no" }}site
  • +
+ +

Container size

+

Set by an administrator through your workspace quota.

+
    +
  • CPU{{ editor.cpu_cores }} coresquota
  • +
  • Memory{{ editor.memory_mb }} MBquota
  • +
  • Disk{{ editor.disk_quota_mb }} MBquota
  • +
+ +
+ + + + + + + + + + + +
+ +
+
+ +
+ + +
+
+

Public tunnels

@@ -112,6 +242,8 @@

Inside the container

    +
  • A DevPlace Code terminal running dpc opens for you on boot.
  • +
  • Every folder is trusted, so nothing opens in Restricted Mode and project tasks run.
  • sudo and apt install work with no extra setup.
  • Python, Rust, Nim and Swift toolchains are preinstalled.
  • Ports below 1024 cannot bind. Use a high port and a tunnel.
  • diff --git a/events.md b/events.md index a94ec736..a9b30d3e 100644 --- a/events.md +++ b/events.md @@ -213,6 +213,7 @@ Every state-changing action in DevPlace records one append-only row through `dev | `container.tunnel.suspend` | `services/containers/workspace_service.py` | | `container.workspace.create` | `routers/projects/containers/workspace.py` | | `container.workspace.delete` | `routers/projects/containers/workspace.py` | +| `container.workspace.editor.update` | `routers/projects/containers/workspace.py`, `routers/admin/workspaces.py` | | `container.workspace.flag.dismiss` | `routers/admin/workspaces.py` | | `container.workspace.flag.raise` | `services/containers/workspace_service.py` | | `container.workspace.flag.resolve` | `routers/admin/workspaces.py` | diff --git a/ppy.Dockerfile b/ppy.Dockerfile index bf491430..3bf86d0b 100644 --- a/ppy.Dockerfile +++ b/ppy.Dockerfile @@ -87,6 +87,20 @@ RUN set -eu; \ tar -xzf /tmp/code-server.tar.gz -C /usr/local/lib/code-server --strip-components=1; \ rm -f /tmp/code-server.tar.gz; \ ln -sf /usr/local/lib/code-server/bin/code-server /usr/local/bin/code-server +COPY vscode/devplace-workspace /usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace +COPY vscode/branding/favicon.ico /usr/local/lib/code-server/src/browser/media/favicon.ico +COPY vscode/branding/favicon.svg /usr/local/lib/code-server/src/browser/media/favicon.svg +COPY vscode/branding/favicon-dark-support.svg /usr/local/lib/code-server/src/browser/media/favicon-dark-support.svg +COPY vscode/branding/pwa-icon-192.png /usr/local/lib/code-server/src/browser/media/pwa-icon-192.png +COPY vscode/branding/pwa-icon-512.png /usr/local/lib/code-server/src/browser/media/pwa-icon-512.png +COPY vscode/branding/pwa-icon-maskable-192.png /usr/local/lib/code-server/src/browser/media/pwa-icon-maskable-192.png +COPY vscode/branding/pwa-icon-maskable-512.png /usr/local/lib/code-server/src/browser/media/pwa-icon-maskable-512.png +COPY vscode/branding/devplace-login.css /tmp/devplace-login.css +COPY vscode/product.patch.json /tmp/product.patch.json +RUN set -eu; \ + cat /tmp/devplace-login.css >> /usr/local/lib/code-server/src/browser/pages/login.css; \ + python3 -c "import json,pathlib; p=pathlib.Path('/usr/local/lib/code-server/lib/vscode/product.json'); d=json.loads(p.read_text()); d.update(json.loads(pathlib.Path('/tmp/product.patch.json').read_text())); p.write_text(json.dumps(d, indent=2))"; \ + rm -f /tmp/devplace-login.css /tmp/product.patch.json COPY sudo /usr/local/bin/sudo COPY aptroot /usr/local/bin/aptroot COPY pagent /usr/bin/pagent.py @@ -142,4 +156,23 @@ RUN set -eu; \ done; \ [ -f /home/pravda/.vimrc ] || { echo "missing /home/pravda/.vimrc"; exit 1; } +RUN set -eu; \ + ext=/usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace; \ + [ -f "$ext/package.json" ] || { echo "missing the DevPlace extension"; exit 1; }; \ + [ -f "$ext/extension.js" ] || { echo "missing the DevPlace extension entry point"; exit 1; }; \ + for theme in devplace-dark devplace-light; do \ + python3 -c "import json,sys; json.load(open('$ext/themes/$theme.json'))" \ + || { echo "invalid theme: $theme"; exit 1; }; \ + done; \ + python3 -c "import json; d=json.load(open('$ext/package.json')); assert d['contributes']['configurationDefaults']['security.workspace.trust.enabled'] is False, 'trust default lost'"; \ + [ -f /usr/local/lib/code-server/src/browser/media/favicon.svg ] || { echo "missing the DevPlace favicon"; exit 1; }; \ + python3 -c "import json; d=json.load(open('/usr/local/lib/code-server/lib/vscode/product.json')); assert d['nameShort']=='DevPlace', d['nameShort']; assert d['nameLong']=='DevPlace Workspace', d['nameLong']"; \ + grep -q 'devplace-login-theme' /usr/local/lib/code-server/src/browser/pages/login.css \ + || { echo "the DevPlace login stylesheet was not applied"; exit 1; }; \ + for flag in --app-name --disable-workspace-trust --disable-getting-started-override --welcome-text; do \ + code-server --help 2>&1 | grep -q -- "$flag" \ + || { echo "code-server no longer supports $flag"; exit 1; }; \ + done; \ + echo "DevPlace branding verified" + CMD ["sleep", "infinity"] diff --git a/tests/api/projects/workspace.py b/tests/api/projects/workspace.py index 7a9ee338..18b6fba2 100644 --- a/tests/api/projects/workspace.py +++ b/tests/api/projects/workspace.py @@ -8,6 +8,7 @@ from devplacepy.content import can_manage_workspace, can_open_workspace from devplacepy.database import get_table, init_db, set_setting from devplacepy.services.containers import activity, api, store from devplacepy.services.containers.workspace import ( + editor, flags, naming, provision, @@ -28,7 +29,13 @@ def _workspace_db(): init_db() set_setting("workspace_enabled", "1") yield - for table in ("instances", "tunnels", "workspace_flags", "workspace_quota_rules"): + for table in ( + "instances", + "tunnels", + "workspace_flags", + "workspace_quota_rules", + "workspace_editor_prefs", + ): get_table(table).delete() @@ -391,7 +398,8 @@ def test_editor_runs_with_password_auth_and_an_eight_char_secret(): assert len(password) == api.EDITOR_PASSWORD_LENGTH == 8 assert password.isalnum() - command = api.editor_command(store.get_instance(instance["uid"])) + row = store.get_instance(instance["uid"]) + command = editor.argv(row, editor.resolve(OWNER, row)) assert "--auth" in command assert command[command.index("--auth") + 1] == "password" assert "none" not in command @@ -806,3 +814,177 @@ def test_no_certificate_is_ordered_when_molohttp_is_unconfigured(monkeypatch): row = tunnels.list_for_instance(instance["uid"])[0] assert row["status"] == tunnels.STATUS_PENDING assert [r["uid"] for r in tunnels.awaiting_certificate()] == [row["uid"]] + + +def _editor_workspace(owner: dict, title: str): + project = _http_project(owner["uid"], title) + instance = store.create_instance( + { + "project_uid": project["uid"], + "name": "ws-editor-http", + "status": "running", + "desired_state": "running", + "is_workspace": 1, + "workspace_owner_uid": owner["uid"], + "tunnel_name": naming.generate(), + "ports_json": '[{"host": 20911, "container": 8080, "proto": "tcp"}]', + } + ) + return project, instance + + +def _get(path: str, key: str): + import requests + + return requests.get( + f"{BASE_URL}{path}", + headers={"Accept": "application/json", "X-API-KEY": key}, + timeout=HTTP_TIMEOUT, + ) + + +def test_editor_profile_reads_back_over_http(app_server, seeded_db): + owner = _seeded_user("bob_test") + project, _ = _editor_workspace(owner, "WS Editor Read") + slug = project["slug"] + assert _await_workspaces_enabled(slug, owner["api_key"]) + + body = _get(f"/projects/{slug}/workspace/editor", owner["api_key"]).json() + assert body["editor"]["theme"] == editor.DEFAULTS["theme"] + assert body["editor"]["sources"]["theme"] == editor.SOURCE_SITE + assert body["editor"]["cpu_millicores"] == quota.resolve(owner["uid"]).cpu_millicores + assert body["restart_required"] is False + + +def test_editor_preferences_save_and_reset_over_http(app_server, seeded_db): + owner = _seeded_user("bob_test") + project, _ = _editor_workspace(owner, "WS Editor Write") + slug = project["slug"] + assert _await_workspaces_enabled(slug, owner["api_key"]) + try: + saved = _post( + f"/projects/{slug}/workspace/editor", + owner["api_key"], + data={"theme": "devplace-light", "font_size": "21"}, + ) + assert saved.status_code == 200, saved.text + profile = saved.json()["data"]["editor"] + assert profile["theme"] == "devplace-light" + assert profile["font_size"] == 21 + assert profile["sources"]["font_size"] == editor.SOURCE_USER + + reset = _post( + f"/projects/{slug}/workspace/editor", + owner["api_key"], + data={"reset": "true"}, + ) + assert reset.status_code == 200, reset.text + after = reset.json()["data"]["editor"] + assert after["font_size"] == editor.DEFAULTS["font_size"] + assert after["sources"]["font_size"] == editor.SOURCE_SITE + finally: + get_table("workspace_editor_prefs").delete(owner_id=owner["uid"]) + + +def test_an_out_of_range_editor_value_is_refused_not_clamped(app_server, seeded_db): + owner = _seeded_user("bob_test") + project, _ = _editor_workspace(owner, "WS Editor Range") + slug = project["slug"] + assert _await_workspaces_enabled(slug, owner["api_key"]) + + refused = _post( + f"/projects/{slug}/workspace/editor", + owner["api_key"], + data={"font_size": "500"}, + ) + assert refused.status_code == 422, refused.text + + +def test_editor_preferences_are_refused_to_a_non_owner(app_server, seeded_db): + owner = _seeded_user("bob_test") + intruder = _fresh_member() + project, _ = _editor_workspace(owner, "WS Editor Foreign") + slug = project["slug"] + + denied = _get(f"/projects/{slug}/workspace/editor", intruder["api_key"]) + assert denied.status_code in (403, 404), denied.text + + +def test_editor_preferences_reject_a_guest_with_401_not_422(app_server, seeded_db): + import requests + + owner = _seeded_user("bob_test") + project, _ = _editor_workspace(owner, "WS Editor Guest") + guest = requests.post( + f"{BASE_URL}/projects/{project['slug']}/workspace/editor", + headers={"Accept": "application/json"}, + data={}, + timeout=HTTP_TIMEOUT, + ) + assert guest.status_code == 401, guest.status_code + + +def test_a_saved_preference_asks_for_a_restart_while_running(app_server, seeded_db): + owner = _seeded_user("bob_test") + project, instance = _editor_workspace(owner, "WS Editor Restart") + slug = project["slug"] + assert _await_workspaces_enabled(slug, owner["api_key"]) + try: + editor.seed_state(instance, editor.resolve(owner["uid"], instance)) + body = _post( + f"/projects/{slug}/workspace/editor", + owner["api_key"], + data={"font_size": "29"}, + ).json() + assert body["data"]["restart_required"] is True + finally: + get_table("workspace_editor_prefs").delete(owner_id=owner["uid"]) + + +def test_the_run_spec_applies_the_quota_cpu_and_memory_to_a_workspace(): + from devplacepy.services.containers import api + + instance = _instance(editor_port=api.EDITOR_DEFAULT_PORT) + limits = quota.resolve(OWNER, store.get_instance(instance["uid"])) + spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest") + assert spec.cpu_limit == limits.cpu_limit() + assert spec.mem_limit == limits.mem_limit() + assert spec.command[0] == "code-server" + assert "--app-name" in spec.command + assert "--disable-workspace-trust" in spec.command + + +def test_the_run_spec_seeds_the_editor_state_and_stamps_a_boot_marker(monkeypatch, tmp_path): + from devplacepy import config + from devplacepy.services.containers import api + + monkeypatch.setattr(config, "WORKSPACE_STATE_DIR", tmp_path / "state") + instance = _instance() + spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest") + + marker = store.get_instance(instance["uid"])["boot_marker"] + assert marker + assert spec.env["DEVPLACE_CONTAINER_BOOT"] == marker + assert spec.env["DEVPLACE_EDITOR_APP_NAME"] == editor.APP_NAME + seeded = tmp_path / "state" / instance["uid"] / "data" / "User" / "settings.json" + assert seeded.exists() + + +def test_a_non_workspace_instance_keeps_its_own_limits(): + from devplacepy.services.containers import api + + instance = _instance(is_workspace=0, cpu_limit="0.5", mem_limit="256m") + spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest") + assert spec.cpu_limit == "0.5" + assert spec.mem_limit == "256m" + assert not spec.env.get("DEVPLACE_EDITOR_APP_NAME") + + +def test_a_workspace_without_an_editor_port_still_gets_its_size(): + from devplacepy.services.containers import api + + instance = _instance() + limits = quota.resolve(OWNER, store.get_instance(instance["uid"])) + spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest") + assert spec.cpu_limit == limits.cpu_limit() + assert spec.command == ["sleep", "infinity"] diff --git a/tests/e2e/projects/workspace.py b/tests/e2e/projects/workspace.py index ef77baf2..0e963c74 100644 --- a/tests/e2e/projects/workspace.py +++ b/tests/e2e/projects/workspace.py @@ -32,6 +32,7 @@ def _workspaces_on(): get_table("tunnels").delete(instance_uid=uid) get_table("workspace_flags").delete(instance_uid=uid) instances.delete(uid=uid) + get_table("workspace_editor_prefs").delete() def _row_for(user: dict) -> dict: @@ -111,7 +112,7 @@ def test_project_page_shows_a_direct_vscode_button_for_a_running_workspace(alice page.goto( f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded" ) - button = page.locator(".project-detail-actions a:has-text('VS Code')") + button = page.locator(".project-detail-actions a[data-editor-open]") button.wait_for(state="visible") expect(button).to_have_attribute("target", "_blank") expect(button).to_have_attribute( @@ -129,7 +130,7 @@ def test_direct_vscode_button_is_absent_while_the_workspace_is_stopped(alice): 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:has-text('VS Code')").count() + assert not page.locator(".project-detail-actions a[data-editor-open]").count() def _await_workspace_flag(slug: str, key: str, expected: bool) -> bool: @@ -310,3 +311,107 @@ def test_workspaces_sidebar_link_is_present_for_admin(alice): link = page.locator(".sidebar-link:has-text('Workspaces')") link.wait_for(state="visible") expect(link).to_have_class(re.compile("active")) + + +def test_the_editor_card_shows_the_resolved_profile_and_its_sources(alice): + page, user = alice + row = _row_for(user) + project = _project_for(row["uid"], "WS Editor Card") + _workspace_for(project, row["uid"]) + page.goto( + f"{BASE_URL}/projects/{project['slug']}/workspace", + wait_until="domcontentloaded", + ) + card = page.locator(".workspace-editor") + card.wait_for(state="visible") + expect(card.locator(".workspace-editor-summary").first).to_contain_text("devplace-dark") + expect(card.locator(".workspace-editor-resources")).to_contain_text("cores") + expect(card.locator(".workspace-editor-form select[name='theme']")).to_be_visible() + + +def test_saving_an_editor_preference_changes_its_source_to_the_member(alice): + page, user = alice + row = _row_for(user) + project = _project_for(row["uid"], "WS Editor Save") + _workspace_for(project, row["uid"]) + try: + page.goto( + f"{BASE_URL}/projects/{project['slug']}/workspace", + wait_until="domcontentloaded", + ) + page.locator(".workspace-editor-form").wait_for(state="visible") + page.select_option(".workspace-editor-form select[name='theme']", "devplace-light") + page.locator(".workspace-editor-actions button[type='submit']").click() + page.wait_for_url( + f"{BASE_URL}/projects/{project['slug']}/workspace", + wait_until="domcontentloaded", + ) + summary = page.locator(".workspace-editor-summary").first + summary.wait_for(state="visible") + expect(summary).to_contain_text("devplace-light") + expect(summary).to_contain_text("user") + finally: + get_table("workspace_editor_prefs").delete(owner_id=row["uid"]) + + +def test_resetting_editor_preferences_restores_the_site_default(alice): + page, user = alice + row = _row_for(user) + project = _project_for(row["uid"], "WS Editor Reset") + _workspace_for(project, row["uid"]) + try: + page.goto( + f"{BASE_URL}/projects/{project['slug']}/workspace", + wait_until="domcontentloaded", + ) + page.locator(".workspace-editor-form").wait_for(state="visible") + page.select_option(".workspace-editor-form select[name='theme']", "devplace-light") + page.locator(".workspace-editor-actions button[type='submit']").click() + page.wait_for_url( + f"{BASE_URL}/projects/{project['slug']}/workspace", + wait_until="domcontentloaded", + ) + page.locator(".workspace-editor-reset button").click() + page.wait_for_url( + f"{BASE_URL}/projects/{project['slug']}/workspace", + wait_until="domcontentloaded", + ) + summary = page.locator(".workspace-editor-summary").first + summary.wait_for(state="visible") + expect(summary).to_contain_text("devplace-dark") + finally: + get_table("workspace_editor_prefs").delete(owner_id=row["uid"]) + + +def test_the_editor_launch_control_carries_the_window_profile(alice): + page, user = alice + row = _row_for(user) + project = _project_for(row["uid"], "WS Editor Launch") + instance = _workspace_for(project, row["uid"]) + page.goto( + f"{BASE_URL}/projects/{project['slug']}/workspace", + wait_until="domcontentloaded", + ) + launch = page.locator(".workspace-actions a[data-editor-open]") + launch.wait_for(state="visible") + expect(launch).to_have_attribute("data-editor-mode", "tab") + expect(launch).to_have_attribute("target", "_blank") + expect(launch).to_have_attribute( + "href", + f"/projects/{project['slug']}/containers/instances/{instance['uid']}/code/", + ) + + +def test_the_workspace_help_states_that_every_folder_is_trusted(alice): + page, user = alice + row = _row_for(user) + project = _project_for(row["uid"], "WS Editor Trust") + _workspace_for(project, row["uid"]) + page.goto( + f"{BASE_URL}/projects/{project['slug']}/workspace", + wait_until="domcontentloaded", + ) + help_card = page.locator(".workspace-help") + help_card.wait_for(state="visible") + expect(help_card).to_contain_text("Restricted Mode") + expect(help_card).to_contain_text("dpc") diff --git a/tests/unit/database/schema.py b/tests/unit/database/schema.py new file mode 100644 index 00000000..745f784c --- /dev/null +++ b/tests/unit/database/schema.py @@ -0,0 +1,47 @@ +# retoor + +from devplacepy.database import get_table, init_db + +FILTERED_COLUMNS = { + "messages": ("uid", "sender_uid", "receiver_uid", "content", "read", "created_at"), + "workspace_quota_rules": ("uid", "owner_kind", "owner_id", "deleted_at"), + "workspace_editor_prefs": ("uid", "owner_kind", "owner_id", "deleted_at"), +} + + +def test_init_db_ensures_every_filtered_column(local_db): + for table, columns in FILTERED_COLUMNS.items(): + target = get_table(table) + missing = [column for column in columns if not target.has_column(column)] + assert not missing, f"{table} is missing {missing}" + + +def test_a_partial_insert_cannot_reduce_the_messages_schema(local_db): + table = get_table("messages") + table.insert( + { + "uid": "schema-msg-1", + "sender_uid": "schema-user-1", + "receiver_uid": "schema-user-2", + "content": "partial row, no created_at", + } + ) + try: + init_db() + assert get_table("messages").has_column("created_at") + rows = list( + local_db.query( + "SELECT uid FROM messages WHERE created_at IS NULL AND uid = :u", + u="schema-msg-1", + ) + ) + assert len(rows) == 1 + finally: + get_table("messages").delete(uid="schema-msg-1") + + +def test_the_workspace_rule_tables_filter_on_a_column_that_exists(local_db): + from devplacepy.services.containers.workspace import editor, quota + + assert quota._rule_for("user", "nobody-at-all") is None + assert editor.prefs_for("nobody-at-all") is None diff --git a/tests/unit/services/containers/__init__.py b/tests/unit/services/containers/__init__.py new file mode 100644 index 00000000..95ee7c3e --- /dev/null +++ b/tests/unit/services/containers/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/unit/services/containers.py b/tests/unit/services/containers/api.py similarity index 100% rename from tests/unit/services/containers.py rename to tests/unit/services/containers/api.py diff --git a/tests/unit/services/containers/workspace/__init__.py b/tests/unit/services/containers/workspace/__init__.py new file mode 100644 index 00000000..95ee7c3e --- /dev/null +++ b/tests/unit/services/containers/workspace/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/unit/services/containers/workspace/editor.py b/tests/unit/services/containers/workspace/editor.py new file mode 100644 index 00000000..4d8cdb9e --- /dev/null +++ b/tests/unit/services/containers/workspace/editor.py @@ -0,0 +1,362 @@ +# retoor + +import json + +import pytest + +from devplacepy import config +from devplacepy.database import get_table, init_db, set_setting +from devplacepy.services.containers.backend.base import ( + WORKSPACE_MOUNT, + WORKSPACE_STATE_MOUNT, +) +from devplacepy.services.containers.workspace import editor, quota + +OWNER = "editor-owner" +OTHER = "editor-other" + + +@pytest.fixture(autouse=True) +def _editor_db(tmp_path, monkeypatch): + init_db() + monkeypatch.setattr(config, "WORKSPACE_STATE_DIR", tmp_path / "state") + yield + get_table(editor.PREFS_TABLE).delete() + get_table(quota.RULES_TABLE).delete() + for key, default in editor.DEFAULTS.items(): + setting = editor.SETTING_KEYS[key] + if isinstance(default, bool): + set_setting(setting, "1" if default else "0") + else: + set_setting(setting, str(default)) + + +def _instance(uid: str = "ws-editor") -> dict: + return {"uid": uid, "editor_port": editor.EDITOR_DEFAULT_PORT, "is_workspace": 1} + + +def _prefs(**values) -> dict: + return editor.save_prefs(OWNER, values) + + +def test_resolve_uses_site_defaults(): + profile = editor.resolve(OWNER) + assert profile.theme == editor.DEFAULTS["theme"] + assert profile.font_size == editor.DEFAULTS["font_size"] + assert profile.zoom_level == editor.DEFAULTS["zoom_level"] + assert profile.boot_agent == editor.DEFAULTS["boot_agent"] + assert profile.trust_all is True + + +def test_resolve_reads_a_changed_site_setting(): + set_setting("workspace_editor_font_size", "22") + assert editor.resolve(OWNER).font_size == 22 + + +def test_resolve_prefers_the_user_row(): + _prefs(font_size=20, theme="devplace-light") + profile = editor.resolve(OWNER) + assert profile.font_size == 20 + assert profile.theme == "devplace-light" + + +def test_a_user_row_does_not_leak_to_another_user(): + _prefs(font_size=20) + assert editor.resolve(OTHER).font_size == editor.DEFAULTS["font_size"] + + +def test_zero_and_empty_inherit_but_zoom_zero_does_not(): + set_setting("workspace_editor_font_size", "22") + set_setting("workspace_editor_zoom_level", "3") + _prefs(font_size=0, theme="", zoom_level=0) + profile = editor.resolve(OWNER) + assert profile.font_size == 22 + assert profile.theme == editor.DEFAULTS["theme"] + assert profile.zoom_level == 0 + + +def test_the_zoom_sentinel_inherits(): + set_setting("workspace_editor_zoom_level", "3") + _prefs(zoom_level=editor.INHERIT_ZOOM) + assert editor.resolve(OWNER).zoom_level == 3 + + +def test_the_boot_shell_sentinel_inherits_but_zero_does_not(): + _prefs(boot_shell=editor.INHERIT_FLAG) + assert editor.resolve(OWNER).boot_shell is True + _prefs(boot_shell=0) + assert editor.resolve(OWNER).boot_shell is False + + +def test_out_of_range_stored_values_are_clamped(): + _prefs(font_size=999, zoom_level=99, window_width=1) + profile = editor.resolve(OWNER) + assert profile.font_size == editor.BOUNDS["font_size"][1] + assert profile.zoom_level == editor.BOUNDS["zoom_level"][1] + assert profile.window_width == editor.BOUNDS["window_width"][0] + + +def test_an_unknown_choice_falls_back_to_the_default(): + _prefs(theme="hot-pink", layout="chaos", window_mode="teleport") + profile = editor.resolve(OWNER) + assert profile.theme == editor.DEFAULTS["theme"] + assert profile.layout == editor.DEFAULTS["layout"] + assert profile.window_mode == editor.DEFAULTS["window_mode"] + + +def test_the_container_size_comes_from_the_quota_resolver(): + profile = editor.resolve(OWNER, {"workspace_cpu_millicores": 3000}) + assert profile.cpu_millicores == 3000 + assert profile.cpu_cores() == 3.0 + assert profile.cpu_limit() == "3" + assert profile.mem_limit() == quota.format_memory(profile.memory_mb) + + +def test_source_map_reports_where_each_value_came_from(): + _prefs(font_size=20) + sources = editor.source_map(OWNER) + assert sources["font_size"] == editor.SOURCE_USER + assert sources["theme"] == editor.SOURCE_SITE + assert sources["trust_all"] == editor.SOURCE_SITE + + +def test_reset_removes_the_row_and_restores_the_defaults(): + _prefs(font_size=20) + assert editor.reset_prefs(OWNER, OWNER) is True + assert editor.resolve(OWNER).font_size == editor.DEFAULTS["font_size"] + assert editor.source_map(OWNER)["font_size"] == editor.SOURCE_SITE + + +def test_reset_on_a_user_with_no_row_is_a_no_op(): + assert editor.reset_prefs(OWNER, OWNER) is False + + +def test_saving_twice_updates_one_row(): + _prefs(font_size=20) + _prefs(font_size=21) + rows = list(get_table(editor.PREFS_TABLE).find(owner_id=OWNER, deleted_at=None)) + assert len(rows) == 1 + assert rows[0]["font_size"] == 21 + + +def test_merge_managed_seeds_an_absent_key(): + merged, managed = editor.merge_managed({}, {}, {"editor.fontSize": 14}) + assert merged["editor.fontSize"] == 14 + assert managed == {"editor.fontSize": 14} + + +def test_merge_managed_updates_a_value_we_still_own(): + merged, _ = editor.merge_managed( + {"editor.fontSize": 14}, {"editor.fontSize": 14}, {"editor.fontSize": 18} + ) + assert merged["editor.fontSize"] == 18 + + +def test_merge_managed_respects_a_member_edit_forever(): + current = {"editor.fontSize": 30} + managed = {"editor.fontSize": 14} + for desired in (16, 18, 20): + current, managed = editor.merge_managed( + current, managed, {"editor.fontSize": desired} + ) + assert current["editor.fontSize"] == 30 + + +def test_merge_managed_keeps_unrelated_member_keys(): + merged, _ = editor.merge_managed( + {"files.autoSave": "on"}, {}, {"editor.fontSize": 14} + ) + assert merged["files.autoSave"] == "on" + + +def test_merge_managed_is_idempotent(): + desired = editor.settings_for(editor.resolve(OWNER)) + first, managed = editor.merge_managed({}, {}, desired) + second, _ = editor.merge_managed(first, managed, desired) + assert first == second + + +def test_settings_disable_workspace_trust_when_trust_all(): + settings = editor.settings_for(editor.resolve(OWNER)) + assert settings["security.workspace.trust.enabled"] is False + assert settings["security.workspace.trust.startupPrompt"] == "never" + assert settings["task.allowAutomaticTasks"] == "on" + + +def test_settings_leave_trust_alone_when_the_kill_switch_is_off(): + set_setting("workspace_editor_trust_all", "0") + settings = editor.settings_for(editor.resolve(OWNER)) + assert "security.workspace.trust.enabled" not in settings + + +def test_settings_carry_the_devplace_terminal_profile(): + settings = editor.settings_for(editor.resolve(OWNER)) + profiles = settings["terminal.integrated.profiles.linux"] + assert profiles["DevPlace Code"]["path"] == "/usr/bin/dpc" + assert settings["terminal.integrated.defaultProfile.linux"] == "bash" + + +def test_settings_apply_the_layout_preset(): + _prefs(layout="zen") + settings = editor.settings_for(editor.resolve(OWNER)) + assert settings["workbench.activityBar.location"] == "hidden" + assert settings["editor.minimap.enabled"] is False + + +def test_the_system_theme_is_left_to_the_member(): + _prefs(theme="system") + assert "workbench.colorTheme" not in editor.settings_for(editor.resolve(OWNER)) + + +def test_seed_state_writes_the_whole_tree(): + instance = _instance() + profile = editor.resolve(OWNER) + assert editor.seed_state(instance, profile) is True + root = editor.state_dir(instance) + settings = json.loads((root / "data" / "User" / "settings.json").read_text()) + managed = json.loads((root / "data" / "User" / editor.MANAGED_FILE).read_text()) + payload = json.loads((root / editor.PROFILE_FILE).read_text()) + assert settings["editor.fontSize"] == profile.font_size + assert managed["editor.fontSize"] == profile.font_size + assert payload["editor"]["theme"] == profile.theme + assert payload["app_name"] == editor.APP_NAME + + +def test_seed_state_never_clobbers_a_member_edit(): + instance = _instance() + editor.seed_state(instance, editor.resolve(OWNER)) + settings_path = editor.state_dir(instance) / "data" / "User" / "settings.json" + stored = json.loads(settings_path.read_text()) + stored["editor.fontSize"] = 30 + stored["files.autoSave"] = "on" + settings_path.write_text(json.dumps(stored)) + + set_setting("workspace_editor_font_size", "18") + set_setting("workspace_editor_terminal_font_size", "20") + editor.seed_state(instance, editor.resolve(OWNER)) + + written = json.loads(settings_path.read_text()) + assert written["editor.fontSize"] == 30 + assert written["files.autoSave"] == "on" + assert written["terminal.integrated.fontSize"] == 20 + + +def test_seed_state_replaces_an_unparseable_settings_file(): + instance = _instance() + user_dir = editor.state_dir(instance) / "data" / "User" + user_dir.mkdir(parents=True, exist_ok=True) + (user_dir / "settings.json").write_text("{not json at all") + assert editor.seed_state(instance, editor.resolve(OWNER)) is True + assert json.loads((user_dir / "settings.json").read_text())["editor.fontSize"] + + +def test_seed_state_fails_soft_on_an_unwritable_directory(monkeypatch): + def explode(*args, **kwargs): + raise OSError("read-only file system") + + monkeypatch.setattr("pathlib.Path.mkdir", explode) + assert editor.seed_state(_instance(), editor.resolve(OWNER)) is False + + +def test_argv_carries_the_devplace_brand(): + instance = _instance() + command = editor.argv(instance, editor.resolve(OWNER)) + assert command[0] == "code-server" + assert command[command.index("--app-name") + 1] == editor.APP_NAME + assert "--disable-getting-started-override" in command + assert "--disable-telemetry" in command + + +def test_argv_disables_workspace_trust_when_trust_all(): + command = editor.argv(_instance(), editor.resolve(OWNER)) + assert "--disable-workspace-trust" in command + + +def test_argv_keeps_workspace_trust_when_the_kill_switch_is_off(): + set_setting("workspace_editor_trust_all", "0") + command = editor.argv(_instance(), editor.resolve(OWNER)) + assert "--disable-workspace-trust" not in command + + +def test_argv_keeps_password_auth(): + command = editor.argv(_instance(), editor.resolve(OWNER)) + assert command[command.index("--auth") + 1] == "password" + assert "none" not in command + + +def test_argv_paths_sit_under_the_state_mount(): + command = editor.argv(_instance(), editor.resolve(OWNER)) + assert command[command.index("--user-data-dir") + 1].startswith( + WORKSPACE_STATE_MOUNT + ) + assert command[command.index("--extensions-dir") + 1].startswith( + WORKSPACE_STATE_MOUNT + ) + assert command[-1] == WORKSPACE_MOUNT + + +def test_argv_binds_the_instance_editor_port(): + instance = _instance() + instance["editor_port"] = 9443 + command = editor.argv(instance, editor.resolve(OWNER)) + assert command[command.index("--bind-addr") + 1] == "0.0.0.0:9443" + + +def test_every_optional_flag_is_declared(): + command = editor.argv(_instance(), editor.resolve(OWNER)) + for flag in editor.OPTIONAL_FLAGS: + assert flag in command + + +def test_env_for_exports_the_profile_to_the_container(): + env = editor.env_for(editor.resolve(OWNER)) + assert env["DEVPLACE_EDITOR_APP_NAME"] == editor.APP_NAME + assert env["DEVPLACE_EDITOR_PROFILE"].endswith(editor.PROFILE_FILE) + assert env["DEVPLACE_EDITOR_BOOT_AGENT"] == editor.DEFAULTS["boot_agent"] + assert env["DEVPLACE_EDITOR_TRUST_ALL"] == "1" + assert all(isinstance(value, str) for value in env.values()) + + +def test_restart_is_not_required_before_the_first_boot(): + instance = _instance() + assert editor.restart_required(instance, editor.resolve(OWNER)) is False + + +def test_restart_is_not_required_when_nothing_changed(): + instance = _instance() + profile = editor.resolve(OWNER) + editor.seed_state(instance, profile) + assert editor.restart_required(instance, profile) is False + + +def test_restart_is_required_after_a_preference_change(): + instance = _instance() + editor.seed_state(instance, editor.resolve(OWNER)) + _prefs(font_size=27) + assert editor.restart_required(instance, editor.resolve(OWNER)) is True + + +def test_view_carries_the_profile_and_its_sources(): + _prefs(theme="devplace-light") + payload = editor.view(OWNER) + assert payload["theme"] == "devplace-light" + assert payload["sources"]["theme"] == editor.SOURCE_USER + assert payload["cpu_cores"] == editor.resolve(OWNER).cpu_cores() + + +def test_settings_suppress_a_foreign_ai_assistant(): + settings = editor.settings_for(editor.resolve(OWNER)) + assert settings["chat.disableAIFeatures"] is True + assert settings["workbench.secondarySideBar.defaultVisibility"] == "hidden" + + +def test_settings_open_straight_into_the_workspace(): + assert editor.settings_for(editor.resolve(OWNER))["workbench.startupEditor"] == "none" + + +def test_the_agent_working_files_never_sync_into_the_project(): + from devplacepy.project_files import SYNC_SKIP_NAMES + + assert ".dpc" in SYNC_SKIP_NAMES + assert "dpc.log" in SYNC_SKIP_NAMES + assert ".devplace" in SYNC_SKIP_NAMES diff --git a/tests/unit/services/containers/workspace/quota.py b/tests/unit/services/containers/workspace/quota.py new file mode 100644 index 00000000..1924042d --- /dev/null +++ b/tests/unit/services/containers/workspace/quota.py @@ -0,0 +1,112 @@ +# retoor + +import pytest + +from devplacepy.database import get_table, init_db, set_setting +from devplacepy.services.containers.workspace import quota +from devplacepy.utils import generate_uid + +OWNER = "quota-owner" + + +@pytest.fixture(autouse=True) +def _quota_db(): + init_db() + yield + get_table(quota.RULES_TABLE).delete() + for key in quota.SETTING_KEYS.values(): + set_setting(key, str(quota.DEFAULTS[_key_for(key)])) + + +def _key_for(setting: str) -> str: + for key, value in quota.SETTING_KEYS.items(): + if value == setting: + return key + raise KeyError(setting) + + +def _rule(**values) -> dict: + row = { + "uid": generate_uid(), + "owner_kind": "user", + "owner_id": OWNER, + "label": "test", + "created_at": "", + "updated_at": "", + "deleted_at": None, + "deleted_by": None, + **{column: 0 for column in quota.RULE_COLUMNS}, + } + row.update(values) + get_table(quota.RULES_TABLE).insert(row) + return row + + +def test_format_cpu_renders_docker_values(): + assert quota.format_cpu(2000) == "2" + assert quota.format_cpu(1500) == "1.5" + assert quota.format_cpu(250) == "0.25" + assert quota.format_cpu(0) == "" + assert quota.format_cpu(-1) == "" + + +def test_format_memory_renders_docker_values(): + assert quota.format_memory(2048) == "2048m" + assert quota.format_memory(0) == "" + + +def test_limits_expose_the_docker_strings(): + limits = quota.resolve() + assert limits.cpu_limit() == quota.format_cpu(limits.cpu_millicores) + assert limits.mem_limit() == quota.format_memory(limits.memory_mb) + + +def test_defaults_resolve_without_a_rule(): + limits = quota.resolve(OWNER) + assert limits.cpu_millicores == quota.DEFAULTS["cpu_millicores"] + assert limits.memory_mb == quota.DEFAULTS["memory_mb"] + + +def test_a_user_rule_resolves(): + _rule(cpu_millicores=4000, memory_mb=8192, disk_quota_mb=512) + limits = quota.resolve(OWNER) + assert limits.cpu_millicores == 4000 + assert limits.memory_mb == 8192 + assert limits.disk_quota_mb == 512 + + +def test_a_user_rule_does_not_leak_to_another_user(): + _rule(cpu_millicores=4000) + assert quota.resolve("someone-else").cpu_millicores == ( + quota.DEFAULTS["cpu_millicores"] + ) + + +def test_a_soft_deleted_rule_is_ignored(): + row = _rule(cpu_millicores=4000) + get_table(quota.RULES_TABLE).update( + {"uid": row["uid"], "deleted_at": "2026-01-01T00:00:00+00:00"}, ["uid"] + ) + assert quota.resolve(OWNER).cpu_millicores == quota.DEFAULTS["cpu_millicores"] + + +def test_an_instance_override_beats_the_rule(): + _rule(cpu_millicores=4000, memory_mb=8192) + limits = quota.resolve( + OWNER, {"workspace_cpu_millicores": 1000, "workspace_memory_mb": 512} + ) + assert limits.cpu_millicores == 1000 + assert limits.memory_mb == 512 + + +def test_only_declared_instance_overrides_are_read(): + limits = quota.resolve(OWNER, {"workspace_max_tunnels": 99}) + assert limits.max_tunnels == quota.DEFAULTS["max_tunnels"] + assert "max_tunnels" not in quota.INSTANCE_OVERRIDE_COLUMNS + + +def test_the_idle_warning_stays_below_the_idle_stop(): + set_setting("workspace_idle_stop_minutes", "10") + set_setting("workspace_idle_warn_minutes", "30") + limits = quota.resolve() + assert limits.idle_warn_minutes < limits.idle_stop_minutes