46
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()`.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -25,6 +25,8 @@ SOFT_DELETE_TABLES = [
|
||||
"instance_schedules",
|
||||
"tunnels",
|
||||
"workspace_flags",
|
||||
"workspace_quota_rules",
|
||||
"workspace_editor_prefs",
|
||||
"backup_schedules",
|
||||
"devii_conversations",
|
||||
"devii_tasks",
|
||||
|
||||
@ -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
|
||||
`<port>-<name>.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",
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"]),
|
||||
|
||||
@ -74,6 +74,7 @@ from devplacepy.schemas.containers import (
|
||||
AdminWorkspacesOut,
|
||||
BotFrameOut,
|
||||
ContainersOut,
|
||||
EditorProfileOut,
|
||||
InstanceOut,
|
||||
ScheduleOut,
|
||||
TunnelOut,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -112,6 +112,136 @@ An instance's shell is NOT inline - it is a floating `<container-terminal>` (`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/<instance uid>`, 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:
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
/* 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;
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<style>
|
||||
.plate { fill: #080413; }
|
||||
.mark { stroke: #ff6b35; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
.plate { fill: #fdfbff; }
|
||||
.mark { stroke: #d1481a; }
|
||||
}
|
||||
</style>
|
||||
<rect class="plate" width="64" height="64" rx="14"/>
|
||||
<path class="mark" d="M18 16h13c11 0 18 6.5 18 16s-7 16-18 16H18z" fill="none" stroke-width="7" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 466 B |
BIN
devplacepy/services/containers/files/vscode/branding/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#080413"/>
|
||||
<path d="M18 16h13c11 0 18 6.5 18 16s-7 16-18 16H18z" fill="none" stroke="#ff6b35" stroke-width="7" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 275 B |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
@ -0,0 +1,252 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
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 };
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@ -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 <retoor@molodetz.nl>",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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**.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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`.
|
||||
@ -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 `<port>-<name>.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.
|
||||
@ -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"
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
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"]
|
||||
|
||||
463
devplacepy/services/containers/workspace/editor.py
Normal file
@ -0,0 +1,463 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
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
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 "
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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", "")
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
58
devplacepy/static/js/EditorLauncher.js
Normal file
@ -0,0 +1,58 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
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;
|
||||
@ -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);
|
||||
});
|
||||
|
||||
10
devplacepy/templates/_editor_open.html
Normal file
@ -0,0 +1,10 @@
|
||||
{# retoor <retoor@molodetz.nl> #}
|
||||
<a href="{{ _url }}" target="_blank" rel="noopener" class="{{ _class or 'btn btn-primary' }}"
|
||||
data-editor-open
|
||||
data-editor-mode="{{ _mode or 'tab' }}"
|
||||
data-editor-width="{{ _width or 1600 }}"
|
||||
data-editor-height="{{ _height or 1000 }}"
|
||||
data-editor-name="devplace-editor-{{ _uid }}">
|
||||
{%- if _icon %}<span class="icon">{{ _icon }}</span><span class="label"> {{ _label or 'Editor' }}</span>
|
||||
{%- else %}{{ _label or 'Open editor' }}{% endif -%}
|
||||
</a>
|
||||
@ -21,6 +21,8 @@
|
||||
<th>Status</th>
|
||||
<th>Disk</th>
|
||||
<th>Egress</th>
|
||||
<th>Size</th>
|
||||
<th>Editor</th>
|
||||
<th>Tunnels</th>
|
||||
<th>Flags</th>
|
||||
<th>Actions</th>
|
||||
@ -41,6 +43,8 @@
|
||||
</td>
|
||||
<td>{{ ws.disk_percent }}% of {{ ws.disk_quota_mb }} MB</td>
|
||||
<td>{{ ws.egress_percent }}% of {{ ws.egress_quota_mb }} MB</td>
|
||||
<td>{{ ws.editor.cpu_cores }} CPU / {{ ws.editor.memory_mb }} MB</td>
|
||||
<td>{{ ws.editor.theme }} / {{ ws.editor.layout }}</td>
|
||||
<td>{{ ws.tunnels|length }}</td>
|
||||
<td>{{ ws.flags|length }}</td>
|
||||
<td class="admin-actions">
|
||||
@ -73,7 +77,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9">No workspaces yet.</td></tr>
|
||||
<tr><td colspan="11">No workspaces yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -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
|
||||
|
||||
117
devplacepy/templates/docs/workspace-editor.html
Normal file
@ -0,0 +1,117 @@
|
||||
<div class="docs-content" data-render>
|
||||
# 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.
|
||||
</div>
|
||||
@ -71,7 +71,15 @@
|
||||
<div class="project-detail-actions">
|
||||
<a href="/projects/{{ project['slug'] or project['uid'] }}/files" class="project-star-btn"><span class="icon">📁</span><span class="label"> Files ({{ file_count }} files)</span></a>
|
||||
{% if workspace_editor_url %}
|
||||
<a href="{{ workspace_editor_url }}" target="_blank" rel="noopener" class="project-star-btn"><span class="icon">💻</span><span class="label"> VS Code</span></a>
|
||||
{% 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 %}
|
||||
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}"><span class="icon">🔗</span><span class="label"> Share</span></button>
|
||||
{% if user %}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
{% block content %}
|
||||
<div class="workspace-page" data-workspace-root
|
||||
data-slug="{{ project.slug or project.uid }}"
|
||||
data-workspace-uid="{{ workspace.uid if has_workspace else '' }}"
|
||||
data-has-workspace="{{ 1 if has_workspace else 0 }}">
|
||||
|
||||
<h1 class="workspace-title">Workspace: {{ project.title }}</h1>
|
||||
@ -60,7 +61,14 @@
|
||||
</p>
|
||||
<div class="workspace-actions">
|
||||
{% if workspace.status == "running" %}
|
||||
<a class="btn btn-primary" href="{{ editor_url }}" target="_blank" rel="noopener">Open editor</a>
|
||||
{% 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" %}
|
||||
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/stop">
|
||||
<button type="submit" class="btn">Stop</button>
|
||||
</form>
|
||||
@ -79,6 +87,128 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card workspace-editor" data-editor-card>
|
||||
<h2>Editor</h2>
|
||||
<p class="workspace-muted">
|
||||
Your workspace opens a branded DevPlace editor with the
|
||||
<code>dpc</code> coding agent already running. These preferences are yours and
|
||||
follow you into every workspace you open.
|
||||
</p>
|
||||
|
||||
{% if restart_required %}
|
||||
<div class="workspace-editor-restart">
|
||||
<span>Your editor settings changed. Restart the workspace to apply them.</span>
|
||||
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/stop">
|
||||
<button type="submit" class="btn btn-sm">Stop</button>
|
||||
</form>
|
||||
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Start</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<ul class="workspace-editor-summary">
|
||||
<li><span>Theme</span><strong>{{ editor.theme }}</strong><em>{{ editor.sources.theme }}</em></li>
|
||||
<li><span>Layout</span><strong>{{ editor.layout }}</strong><em>{{ editor.sources.layout }}</em></li>
|
||||
<li><span>Panel</span><strong>{{ editor.panel_preset }}</strong><em>{{ editor.sources.panel_preset }}</em></li>
|
||||
<li><span>Editor font</span><strong>{{ editor.font_size }} px</strong><em>{{ editor.sources.font_size }}</em></li>
|
||||
<li><span>Terminal font</span><strong>{{ editor.terminal_font_size }} px</strong><em>{{ editor.sources.terminal_font_size }}</em></li>
|
||||
<li><span>Zoom</span><strong>{{ editor.zoom_level }}</strong><em>{{ editor.sources.zoom_level }}</em></li>
|
||||
<li><span>Agent on boot</span><strong>{{ editor.boot_agent }}</strong><em>{{ editor.sources.boot_agent }}</em></li>
|
||||
<li><span>Shell on boot</span><strong>{{ "yes" if editor.boot_shell else "no" }}</strong><em>{{ editor.sources.boot_shell }}</em></li>
|
||||
<li><span>Opens in</span><strong>{{ editor.window_mode }}</strong><em>{{ editor.sources.window_mode }}</em></li>
|
||||
<li><span>Trusts every folder</span><strong>{{ "yes" if editor.trust_all else "no" }}</strong><em>site</em></li>
|
||||
</ul>
|
||||
|
||||
<h3 class="workspace-editor-heading">Container size</h3>
|
||||
<p class="workspace-muted">Set by an administrator through your workspace quota.</p>
|
||||
<ul class="workspace-editor-summary workspace-editor-resources">
|
||||
<li><span>CPU</span><strong>{{ editor.cpu_cores }} cores</strong><em>quota</em></li>
|
||||
<li><span>Memory</span><strong>{{ editor.memory_mb }} MB</strong><em>quota</em></li>
|
||||
<li><span>Disk</span><strong>{{ editor.disk_quota_mb }} MB</strong><em>quota</em></li>
|
||||
</ul>
|
||||
|
||||
<form class="workspace-editor-form" method="post" data-native
|
||||
action="/projects/{{ project.slug or project.uid }}/workspace/editor">
|
||||
<label>Theme
|
||||
<select name="theme">
|
||||
<option value="">Site default</option>
|
||||
<option value="devplace-dark" {{ "selected" if editor.sources.theme == "user" and editor.theme == "devplace-dark" }}>DevPlace Dark</option>
|
||||
<option value="devplace-light" {{ "selected" if editor.sources.theme == "user" and editor.theme == "devplace-light" }}>DevPlace Light</option>
|
||||
<option value="system" {{ "selected" if editor.sources.theme == "user" and editor.theme == "system" }}>Leave to me</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Layout
|
||||
<select name="layout">
|
||||
<option value="">Site default</option>
|
||||
<option value="standard" {{ "selected" if editor.sources.layout == "user" and editor.layout == "standard" }}>Standard</option>
|
||||
<option value="terminal-focus" {{ "selected" if editor.sources.layout == "user" and editor.layout == "terminal-focus" }}>Terminal focus</option>
|
||||
<option value="zen" {{ "selected" if editor.sources.layout == "user" and editor.layout == "zen" }}>Zen</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Terminal panel
|
||||
<select name="panel_preset">
|
||||
<option value="">Site default</option>
|
||||
<option value="short" {{ "selected" if editor.sources.panel_preset == "user" and editor.panel_preset == "short" }}>Short</option>
|
||||
<option value="normal" {{ "selected" if editor.sources.panel_preset == "user" and editor.panel_preset == "normal" }}>Normal</option>
|
||||
<option value="tall" {{ "selected" if editor.sources.panel_preset == "user" and editor.panel_preset == "tall" }}>Tall</option>
|
||||
<option value="maximized" {{ "selected" if editor.sources.panel_preset == "user" and editor.panel_preset == "maximized" }}>Maximized</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Editor font size
|
||||
<input type="number" name="font_size" min="0" max="48"
|
||||
value="{{ editor.font_size if editor.sources.font_size == 'user' else 0 }}">
|
||||
</label>
|
||||
<label>Terminal font size
|
||||
<input type="number" name="terminal_font_size" min="0" max="48"
|
||||
value="{{ editor.terminal_font_size if editor.sources.terminal_font_size == 'user' else 0 }}">
|
||||
</label>
|
||||
<label>Zoom level
|
||||
<input type="number" name="zoom_level" min="-99" max="5"
|
||||
value="{{ editor.zoom_level if editor.sources.zoom_level == 'user' else -99 }}">
|
||||
</label>
|
||||
<label>Agent on boot
|
||||
<select name="boot_agent">
|
||||
<option value="">Site default</option>
|
||||
<option value="dpc" {{ "selected" if editor.sources.boot_agent == "user" and editor.boot_agent == "dpc" }}>DevPlace Code (dpc)</option>
|
||||
<option value="none" {{ "selected" if editor.sources.boot_agent == "user" and editor.boot_agent == "none" }}>None</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Shell on boot
|
||||
<select name="boot_shell">
|
||||
<option value="-1">Site default</option>
|
||||
<option value="1" {{ "selected" if editor.sources.boot_shell == "user" and editor.boot_shell }}>Yes</option>
|
||||
<option value="0" {{ "selected" if editor.sources.boot_shell == "user" and not editor.boot_shell }}>No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Open editor in
|
||||
<select name="window_mode">
|
||||
<option value="">Site default</option>
|
||||
<option value="tab" {{ "selected" if editor.sources.window_mode == "user" and editor.window_mode == "tab" }}>A new tab</option>
|
||||
<option value="window" {{ "selected" if editor.sources.window_mode == "user" and editor.window_mode == "window" }}>A sized window</option>
|
||||
<option value="fullscreen" {{ "selected" if editor.sources.window_mode == "user" and editor.window_mode == "fullscreen" }}>A fullscreen window</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Window width
|
||||
<input type="number" name="window_width" min="0" max="7680"
|
||||
value="{{ editor.window_width if editor.sources.window_width == 'user' else 0 }}">
|
||||
</label>
|
||||
<label>Window height
|
||||
<input type="number" name="window_height" min="0" max="4320"
|
||||
value="{{ editor.window_height if editor.sources.window_height == 'user' else 0 }}">
|
||||
</label>
|
||||
<div class="workspace-editor-actions">
|
||||
<button type="submit" class="btn btn-primary">Save preferences</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form class="workspace-editor-reset" method="post" data-native
|
||||
action="/projects/{{ project.slug or project.uid }}/workspace/editor">
|
||||
<input type="hidden" name="reset" value="true">
|
||||
<button type="submit" class="btn btn-sm">Reset to site defaults</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card workspace-tunnels">
|
||||
<h2>Public tunnels</h2>
|
||||
<p class="workspace-muted">
|
||||
@ -112,6 +242,8 @@
|
||||
<div class="card workspace-help">
|
||||
<h2>Inside the container</h2>
|
||||
<ul>
|
||||
<li>A <strong>DevPlace Code</strong> terminal running <code>dpc</code> opens for you on boot.</li>
|
||||
<li>Every folder is trusted, so nothing opens in Restricted Mode and project tasks run.</li>
|
||||
<li><code>sudo</code> and <code>apt install</code> work with no extra setup.</li>
|
||||
<li>Python, Rust, Nim and Swift toolchains are preinstalled.</li>
|
||||
<li>Ports below 1024 cannot bind. Use a high port and a tunnel.</li>
|
||||
|
||||
@ -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` |
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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")
|
||||
|
||||
47
tests/unit/database/schema.py
Normal file
@ -0,0 +1,47 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
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
|
||||
1
tests/unit/services/containers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
1
tests/unit/services/containers/workspace/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
362
tests/unit/services/containers/workspace/editor.py
Normal file
@ -0,0 +1,362 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
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
|
||||
112
tests/unit/services/containers/workspace/quota.py
Normal file
@ -0,0 +1,112 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
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
|
||||