Compare commits

..

76 Commits

Author SHA1 Message Date
c4e0ac4266 Add devplacepy/static/uploads/ to .gitignore
Some checks failed
DevPlace CI / test (push) Failing after 19m12s
2026-06-09 16:11:13 +02:00
5aee5b725e Update 2026-06-09 16:06:02 +02:00
2230b9c5fe Update.. 2026-06-09 06:41:27 +02:00
314a911651 Zip filex 2026-06-09 01:32:57 +02:00
1b34ef80a3 Update, added CLAUDE.md 2026-06-09 00:30:25 +02:00
48a794753d Markdown 2026-06-08 22:56:59 +02:00
acccdd33f7 Major update. 2026-06-08 22:51:09 +02:00
e6698b11f9 Massive update, agent. 2026-06-08 17:38:33 +02:00
81f173a628 Update 2026-06-06 16:31:42 +02:00
a3fb6cb332 Update 2026-06-05 21:51:36 +02:00
98319b26da Update 2026-06-05 20:35:02 +02:00
858f225cbc Updte 2026-06-05 20:34:03 +02:00
5030b3345c Update 2026-06-05 20:33:35 +02:00
8aa6a894d4 iUpdate 2026-06-05 20:05:07 +02:00
27c0b1b8d0 Update 2026-06-05 19:32:46 +02:00
c8d066bb75 Updte 2026-06-05 19:22:29 +02:00
55b5b3f476 Update 2026-06-05 19:02:30 +02:00
4ecec85ed5 Update 2026-06-05 18:43:12 +02:00
94318bc2b9 Updatex` 2026-06-05 18:42:43 +02:00
d7458c26cd Update 2026-06-05 18:18:11 +02:00
9b232d0bd8 Update 2026-06-05 17:44:12 +02:00
1850a3896f Update 2026-06-05 10:14:40 +02:00
87fc320803 Update 2026-06-05 05:36:18 +02:00
c8ce439d01 Update 2026-06-05 04:43:06 +02:00
732a2a4517 Update 2026-06-02 23:17:51 +02:00
d74867bd9a iUpdate 2026-05-30 20:16:39 +02:00
421a1a5ff9 Updatex 2026-05-29 00:49:37 +02:00
8fe79c643f Update 2026-05-29 00:45:07 +02:00
1ba13acb39 Updatex 2026-05-27 22:03:12 +02:00
461181295f Update. 2026-05-27 21:07:02 +02:00
aaaf64635c Update 2026-05-27 21:06:18 +02:00
7a5e934adc Update 2026-05-25 16:16:53 +02:00
4ec347f21a Update 2026-05-23 10:54:45 +02:00
b6a8d1a56f Upate 2026-05-23 10:35:40 +02:00
c358326ad7 Update 2026-05-23 10:24:54 +02:00
0b233e5fd4 Upate 2026-05-23 10:16:56 +02:00
5d9ff4c5ba Upate 2026-05-23 10:08:26 +02:00
9c87983df5 Update 2026-05-23 10:03:55 +02:00
c9cd6f2473 Update 2026-05-23 10:03:27 +02:00
f173bb0a30 Update 2026-05-23 09:01:11 +02:00
3e17444a2d Update. 2026-05-23 08:45:53 +02:00
a096d6b108 iUpdate 2026-05-23 08:45:53 +02:00
ae1c00784a Done 2026-05-23 08:41:47 +02:00
de58080706 Refactor.. 2026-05-23 08:34:13 +02:00
dc2b520c02 Done 2026-05-23 06:55:11 +02:00
1f444182ac Update 2026-05-23 06:21:41 +02:00
5f273edc89 Update 2026-05-23 06:20:58 +02:00
662cc56b51 Updat 2026-05-23 05:57:21 +02:00
160bfe4b98 Update 2026-05-23 05:56:21 +02:00
3d262ed997 Update 2026-05-23 05:55:50 +02:00
8f94b4b2bf Update 2026-05-23 05:44:04 +02:00
0905a50f5f Update 2026-05-23 04:29:26 +02:00
c3b2008ac3 Update 2026-05-23 04:12:41 +02:00
7dc5600f1d Update 2026-05-23 03:21:55 +02:00
20ccb13441 Update 2026-05-23 01:50:31 +02:00
f19c32abf9 Update 2026-05-19 23:36:17 +02:00
8f853b9c39 Show error 2026-05-19 23:27:44 +02:00
d9567ed2b0 Spacing 2026-05-16 03:29:43 +02:00
1f9a576bc3 Repaired gists. 2026-05-16 03:10:55 +02:00
df5696744b Repaired chat 2026-05-16 03:02:10 +02:00
6168e84d9f Click on profile page and downvote button 2026-05-16 02:58:43 +02:00
3196d96ad1 Notification fix. 2026-05-16 02:49:53 +02:00
bad2db7765 Mention fix 2026-05-16 02:33:14 +02:00
fcb72cf0d4 Downvote button and click on post 2026-05-16 02:31:11 +02:00
540eca7d23 Burgr 2026-05-16 01:56:07 +02:00
3ed37f835e REsponsive. 2026-05-16 01:35:22 +02:00
d249a90240 Production setup. 2026-05-16 01:28:39 +02:00
ac483a646a No concurrent 2026-05-14 04:36:38 +02:00
264b1ab849 Updated.. 2026-05-14 04:25:52 +02:00
d3d2c36716 Fix 2026-05-14 04:12:19 +02:00
fc75db05d5 Update 2026-05-13 23:26:18 +02:00
39817025d9 Updated 2026-05-13 23:15:14 +02:00
729526f092 Update 2026-05-13 23:03:06 +02:00
b69612aaae Master 2026-05-13 22:53:42 +02:00
fef84d1b9b Progress 2026-05-13 22:48:39 +02:00
8b56300f6f Working in exception of upload 2026-05-13 21:17:57 +02:00
142 changed files with 11876 additions and 143 deletions

View File

@ -9,6 +9,21 @@ SECRET_KEY=change-me
# `make dev`). Set only to point at a different SQLite file.
# DEVPLACE_DATABASE_URL=sqlite:////app/devplace.db
# Persistent runtime data (zip archives, container workspaces). Lives OUTSIDE the
# package and is never served via /static. Defaults to <repo>/var. The docker
# daemon must be able to bind-mount this dir for container /app mounts; point it
# at a persistent volume in production.
# DEVPLACE_DATA_DIR=/var/lib/devplace
# Container Manager (admin-only, enabled via docker-compose.containers.yml).
# Host the /p/<slug> ingress proxy dials to reach a published container port.
# On the host: 127.0.0.1 (default). Containerized app reaching host ports:
# host.docker.internal.
# DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal
# GID of /var/run/docker.sock on the host (getent group docker | cut -d: -f3),
# so the UID-1000 app can use the socket.
# DOCKER_GID=999
# Public origin for absolute URLs (SEO, canonical links, push). Empty = derive
# from the request.
DEVPLACE_SITE_URL=

10
.gitignore vendored
View File

@ -19,12 +19,10 @@ devii_*.db-shm
devii_*.db-wal
devii.log
webdata/
devplacepy/static/uploads/attachments/
devplacepy/static/uploads/*.png
devplacepy/static/uploads/*.jpg
devplacepy/static/uploads/*.jpeg
devplacepy/static/uploads/*.gif
devplacepy/static/uploads/*.webp
# Uploaded/downloaded files - never track in git
devplacepy/static/uploads/
# Runtime data dir (container workspaces, zip artifacts) - never inside the package
var/
# coverage
.coverage

View File

@ -115,7 +115,40 @@ The standard for heavy, blocking work that must run off the request path and han
- **`JobService(BaseService)`** runs only in the lock owner. Each `run_once`: reap finished in-flight tasks -> recover orphaned `running` rows (a previous owner's, since there is only one processor) back to `pending` with bounded `retry_count` -> refill up to `max_concurrent` oldest `pending` jobs (uuid7 sorts FIFO) as `asyncio` tasks -> sweep rows past `expires_at` via the per-kind `cleanup()` hook. In-flight tasks span ticks (kept in an instance map); the loop returns immediately each tick, so keep the interval short (default 2s).
- **No atomic claim is needed** (single processor by construction) and **no separate reaper** exists (retention is built into every job service; default 7 days, admin-configurable; downloading extends `expires_at` via `touch_job`).
- **Add a kind:** subclass `JobService`, set `kind`, implement `async process(self, job) -> dict` (return the `result` dict incl. stat keys) and `cleanup(self, job)`; register in `main.py`; add enqueue endpoints that own authz, a status route, a download route, a Devii tool, and docs.
- **`ZipService`** (`zip_service.py`) materializes a project subtree with `project_files.export_to_dir` into `static/uploads/zip_staging/{uid}`, compresses it in a stdlib-only subprocess (`zip_worker.py`, prints stats JSON incl. crc32), writes `{crc32}.{slug}.zip` under `static/uploads/zips/`, and removes staging. Downloads are **capability URLs** (`/zips/{uid}/download`), scoped only by the unguessable uuid7 - matching publicly-viewable projects; the owner is stored for attribution, not access control. Frontend `app.zipDownloader` auto-wires `data-zip-download` elements and the files context menu.
- **`ZipService`** (`zip_service.py`) materializes a project subtree with `project_files.export_to_dir` into `config.DATA_DIR/zip_staging/{uid}`, compresses it in a stdlib-only subprocess (`zip_worker.py`, prints stats JSON incl. crc32), writes `{crc32}.{slug}.zip` under `config.DATA_DIR/zips/`, and removes staging. Artifacts live in `DATA_DIR` (`var/`, outside the package, not served by `/static`); the download is a `FileResponse` route, not the static mount. Downloads are **capability URLs** (`/zips/{uid}/download`), scoped only by the unguessable uuid7 - matching publicly-viewable projects; the owner is stored for attribution, not access control. Frontend `app.zipDownloader` auto-wires `data-zip-download` elements and the files context menu.
## Project fork (`services/jobs/fork_service.py`)
Forking copies a source project into a brand-new project owned by the forking user, off the request path via the **same async-job pattern as the zip flow**.
- **Enqueue:** `POST /projects/{slug}/fork` (`routers/projects.py`) - `require_user` then `can_view_project(source, user)` (any logged-in user may fork any project they can view: public projects and their own private ones). Body is `ForkForm{title}` (the destination project name). It enqueues a `fork` job (`{source_project_uid, title, forked_by_uid}`, owner `("user", uid)`) and returns `{uid, status_url}`. No work happens on the request path.
- **`ForkService`** (kind `fork`) `process`: looks up the source project row and the forking user row (raises -> job `failed` if either is missing), creates the destination project with `content.create_content_item("projects", "project", user, fields, ...)` (so slug/XP/logging match a normal create; metadata - `description`, `project_type`, `platforms`, `status`, dates, `is_private` - is copied from the source, `read_only` reset to 0), copies the whole virtual FS off-thread (`export_to_dir(source, "", staging)` -> `import_from_dir(new_uid, staging, user, skip_names=set())`, an **exact** copy including binaries, staging under `config.DATA_DIR/fork_staging/{uid}`), then records the relation with `database.record_fork(source_uid, new_uid, forked_by_uid)`. Result: `{project_uid, project_url, source_project_uid, item_count}`.
- **Rollback:** any failure after the project is created triggers `_rollback(new_uid)` (`project_files.delete_all_project_files` + `delete_fork_relations` + delete the `projects` row) before re-raising, so a failed fork leaves no orphan project.
- **Cleanup is a no-op on the project.** Unlike zip's disposable archive, a fork's artifact is a **permanent project** that must outlive the job. `cleanup()` only removes any leftover staging dir; the retention sweep deletes the job tracking row, never the forked project. `devplace forks prune|clear` likewise deletes job rows only.
- **Relation model:** the `project_forks` table (`uid`, `source_project_uid`, `forked_project_uid`, `forked_by_uid`, `created_at`) records direction explicitly (source -> forked), indexed on both project columns. Helpers in `database.py`: `record_fork`, `get_fork_parent(forked_uid)` (the source project row, for the "Forked from X" link on the project detail page), `count_forks(source_uid)`, and `delete_fork_relations(project_uid)` (called on project delete in `content.delete_content_item`, and in fork rollback).
- **Frontend:** `app.projectForker` (`static/js/ProjectForker.js`) wires `data-fork-project` (a Fork button on the project detail page, visible to any logged-in user): prompt for a name via `app.dialog.prompt` -> `Http.sendForm` POST -> poll `/forks/{uid}` -> on `done` redirect to `project_url`.
- **Status route** `GET /forks/{uid}` (`routers/forks.py`, `ForkJobOut`) exposes `project_uid`/`project_url`/`source_project_uid` only once `status == done`. Devii tools `fork_project`/`fork_status`; docs `projects-fork`/`forks-status`.
## Container manager (`services/containers/`)
Admin-only. Supervises container instances, all running one shared prebuilt image. Reference: admin docs `Services -> Container Manager` and the `containers` API group.
- **Backend seam.** `backend/base.py` `Backend` ABC; `DockerCliBackend` drives `docker` via `asyncio.create_subprocess_exec` (streaming logs through `_stream`), `FakeBackend` is the in-memory test double. `runtime.get_backend()`/`set_backend(b)` selects it - tests call `set_backend(FakeBackend())`. A future Kubernetes/remote backend implements the same ABC.
- **Security (load-bearing).** This needs the docker socket = host root. Everything is gated `require_admin`/`requires_admin=True`; `--privileged` is never passed; user input is never shell-interpolated (arg-list subprocesses only); resource limits via `--cpus`/`--memory`.
- **One shared image, no in-app builds (load-bearing).** Every instance runs `config.CONTAINER_IMAGE` (default `ppy:latest`, override `DEVPLACE_CONTAINER_IMAGE`), built ONCE from `ppy.Dockerfile` via `make ppy` (context `devplacepy/services/containers/files`). There are no per-project Dockerfiles, builds, `ContainerBuildService`, or build UI - all removed. `service._launch` calls `api.run_spec_for(inst, config.CONTAINER_IMAGE)`; `api.create_instance(project, *, name, ...)` fails fast with `ContainerError` if `backend.image_exists(CONTAINER_IMAGE)` is false ("run 'make ppy'"). `backend.image_exists(ref)` (`docker image inspect`) was added to the `Backend` ABC (`FakeBackend` returns True). Migrating off the old model: `devplace containers prune-builds` removes the legacy per-project images (`backend.remove_image`) and clears the `dockerfiles`/`dockerfile_versions`/`builds` tables; existing instances auto-repoint to `ppy` (their stored `build_uid` is ignored). **Default idle:** `run_spec_for` runs `["sleep", "infinity"]` when an instance has no `boot_command`, so a bare instance stays up instead of exiting (the image `CMD` is the same, but the explicit command makes it independent of the image).
- **Data model** (`store.py`, indexed in `init_db`): `instances`, `instance_events` (audit), `instance_metrics` (ring buffer, sweep keeps `METRICS_RING`), `instance_schedules`.
- **Reconciler** (`service.py`, `BaseService`): the model is desired-vs-actual, NOT a task per container. Each tick: `backend.ps(label=devplace.instance)` -> converge each instance to `desired_state`, apply restart policy, reap orphan containers (labeled but no DB row), fire due `instance_schedules` (reuse `devii/tasks/schedule.py` `cron_next`/`next_run`), sample `docker stats`. Only the lock owner reconciles; HTTP routes only flip `desired_state`/write events. `docker run --name <slug>` collision is the double-launch guard.
- **Workspace** = `/app`: `project_files.export_to_dir` materializes the project to `config.CONTAINER_WORKSPACES_DIR/<project>` once, bind-mounted RW; `project_files.import_from_dir` (the inverse) syncs it back. Both run via `asyncio.to_thread`. **All runtime data lives OUTSIDE the `devplacepy/` package and NOT under `/static`**: workspaces + zips in `config.DATA_DIR` (`var/`, configurable via `DEVPLACE_DATA_DIR`). Never put generated data under `STATIC_DIR` - it is served publicly AND watched by dev `--reload` (scoped to `--reload-dir devplacepy`). The docker daemon must be able to bind-mount `DATA_DIR` for `/app`.
- **Pravda image (load-bearing, workspace ownership).** The hotpatch that used to run per build is now baked into `ppy.Dockerfile` once. The final stage `COPY`s the **sudo superclone** (`files/sudo`) over `/usr/local/bin/sudo` (+ symlink `/usr/bin/sudo`; the real `sudo` package is not installed) and **`pagent`** (`files/pagent`, the stdlib AI agent; reads `PRAVDA_OPENAI_URL`+`PRAVDA_API_KEY`, falling back to its public endpoint + `DEEPSEEK_API_KEY`) to `/usr/bin/pagent.py` (and `files/.vimrc` to `/home/pravda/.vimrc`, whose `AiEditSelection` helper hits the same gateway as pagent via `PRAVDA_OPENAI_URL`/`PRAVDA_API_KEY` with a public fallback, not `api.openai.com`), evicts any pre-existing uid-1000 user, creates user **`pravda` at `1000:1000`**, hands pravda **ownership of the toolchain** (`chown -R pravda` over `/usr/local/lib`, `/usr/local/bin`, `/usr/lib/python3`, `/opt`, `/app`, `/home/pravda`; `~/.local/bin` on `PATH`), and ends on `USER pravda`. **Why uid 1000:** `/app` is bind-mounted from the host, and under DooD a container's UID maps 1:1 to the host. A container running as **root** wrote root-owned files into the workspace; the app process (host `retoor`, uid 1000) then hit `[Errno 13] Permission denied` in `export_to_dir` on the next instance create - a permanent per-project brick. Pinning the container UID to `1000` makes every write land as `retoor`, and pravda owning the global site-packages means runtime `pip install` just succeeds as pravda with no elevation. **The sudo superclone** (`files/sudo`, POSIX `sh`) is sudo-CLI-compatible but **never swaps user**: it parses the full flag interface (`-u/-g/-E/-H/-n/-S/-i/-s/--`, leading `VAR=value` env assignments, `-V/-l/-v/-k/-K` short-circuits) and `exec`s the command **as the current user (pravda)**, exporting `SUDO_USER`/`SUDO_UID`/`SUDO_GID`/`SUDO_COMMAND`. So even a blind `sudo apt ...` / `sudo -u root ...` runs as uid 1000 and **cannot create a root-owned file** - the residual is gone by construction. **Trade-off (intentional):** genuinely-root ops (binding a port < 1024) do NOT escalate - use a high port + `/p/<slug>` ingress, and add any system packages to `ppy.Dockerfile` (its `RUN apt-get` runs as root before `USER pravda`) then `make ppy`. Enforcement lives in the Dockerfile (no `--user` on `docker run`). The `export_to_dir` unlink-before-write fix remains as belt-and-suspenders (the app owns the workspace dir, so it may delete any stale file in it regardless of owner before rewriting it).
- **`PRAVDA_*` runtime env injection.** `api.run_spec_for` merges `api.pravda_env(instance)` over the instance's own `env_json` (PRAVDA keys win) so every running container gets six platform vars: `PRAVDA_BASE_URL` (the `site_url` setting via `seo.public_base_url()`), `PRAVDA_OPENAI_URL` (`{base}/openai/v1`, the OpenAI-compatible gateway base), `PRAVDA_API_KEY` (the **creating** user's `api_key`, resolved live), `PRAVDA_USER_UID` (the project **owner**'s uid), `PRAVDA_CONTAINER_NAME` (instance name), `PRAVDA_CONTAINER_UID` (instance uid), `PRAVDA_INGRESS_URL` (the instance's absolute public ingress URL `{base}/p/{ingress_slug}` when published and `site_url` is set, relative `/p/{slug}` if not, empty if the instance has no `ingress_slug`). They let code inside a container call back into the platform and the AI gateway authenticated as the user. **Injected at run (docker run `-e`), not baked into the image:** the image is shared by every instance, but name/uid/api-key are per-instance and base-url/key must stay fresh, so they are resolved each launch and never stored as secrets in the DB. Resolution needs two new instance columns set at `create_instance`: `created_by` (= `actor[1]` when the actor is a user) feeds `PRAVDA_API_KEY`, and `owner_uid` (= `project["user_uid"]`) feeds `PRAVDA_USER_UID`; instances created before this change have neither and degrade to an empty key/uid (base-url and container name/uid still resolve) until recreated. `PRAVDA_BASE_URL`/`PRAVDA_OPENAI_URL` are empty when `site_url` is unset, so set the admin `site_url` for container-to-platform calls to work (same requirement as ingress URLs).
- **Shared ops** in `api.py` back BOTH `routers/containers.py` and the Devii `ContainerController` (`services/devii/container/`, `handler="container"`, 7 instance tools). The reconciler service defaults disabled (needs docker).
- **UI is two surfaces over one API set, both discoverable** (the old `templates/containers.html` page had zero links to it - that bug is fixed). (1) Per-project manager: `templates/containers.html` + `static/js/ContainerManager.js` handles instance creation through an app **modal form** (the `_macros.html` `modal()` macro + `ModalManager` `.visible` toggle); its instance list links out to the shared detail page. Reached from the project detail page's admin-only **Containers** button (`is_admin(user)` gate) and now passes breadcrumbs so content clears the fixed nav. (2) Admin **Containers** section: `routers/containers_admin.py` (mounted `/admin/containers`, sidebar link in `admin_base.html`, `admin_section="containers"`) - `GET /admin/containers` lists every instance via `store.all_instances()` (decorated with project title/slug from one `projects` lookup) in an `.admin-table`; `GET /admin/containers/data` is the poll JSON; `GET /admin/containers/{uid}` renders `templates/containers_instance.html` + `static/js/ContainerInstance.js`, a dedicated detail page (lifecycle, poll logs/metrics, schedules add/delete, ingress, sync, interactive exec over a PTY WebSocket gated on the lock owner). **Key reuse rule:** `containers_admin.py` adds NO lifecycle/logs/exec endpoints - the instance carries its `project_uid`, so the detail route resolves the project and `ContainerInstance.js` targets the existing `/projects/{slug}/containers/instances/{uid}/...` routes. Add new instance operations to `routers/containers.py` only; the admin detail page picks them up for free. All container CSS (`static/css/containers.css`) uses app design tokens (`--bg-card`, `--text-primary`, `--success`/`--danger`/`--warning`, `--radius`) and the shared `.card` recipe.
- **Interactive shell = floating xterm.js window.** An instance's shell is NOT inline; it is a floating `<container-terminal>` (`static/js/components/ContainerTerminal.js`) over the existing exec WS (`/projects/{slug}/containers/instances/{uid}/exec/ws`, `pty.openpty()` + `docker exec -it`, admin + `service_manager.owns_lock()` gated). xterm + fit addon are vendored under `static/vendor/xterm/` and lazy-loaded. It extends the generic **`FloatingWindow`** base (`static/js/components/FloatingWindow.js` + `static/css/floating-window.css`) which owns the Devii-style chrome (drag, native `resize:both`, maximize/fullscreen, geometry persistence). **`app.windows`** (`WindowManager`, `components/WindowManager.js`) assigns z-index on `pointerdown` so the last-clicked window is on top; the Devii terminal registers with it too (a 4-line hook in `devii-terminal.js` - Devii itself was NOT refactored, to preserve its exact behavior). Open it two ways: the instance-page **Open terminal** button (`app.containerTerminals.open(slug, uid, name)`, `ContainerTerminalManager`) or Devii "attach &lt;container&gt;" -> the admin-only `open_terminal` **client** action (`client_actions.py`, `requires_admin=True`) -> `DeviiClient._openTerminal`. **Resize protocol:** the exec WS treats a brace-leading JSON frame `{"type":"resize","cols","rows"}` as a control message - it `TIOCSWINSZ`-es the host pty (`fcntl.ioctl`) **and** `proc.send_signal(SIGWINCH)` to the `docker exec -it` client so the resize forwards into the container tty (the SIGWINCH is required: with `start_new_session=True` the host slave is not the client's controlling terminal, so the kernel does not auto-deliver it; the pair shares its winsize, so the client reads the new size off its slave stdio and calls Docker's exec-resize API). Everything else is raw stdin, so keystrokes are untouched. Client-side the **fit addon** drives it and the window has a dedicated `.fw-resize` grip (xterm covers the native CSS resize corner, so the base `FloatingWindow` adds a manual bottom-right grip instead of relying on `resize:both`). **Persistence (tmux):** with `?session=<name>` (validated `^[A-Za-z0-9_-]{1,64}$`) the WS runs `tmux new-session -A -s <name>` (falls back to bash if tmux missing), so the shell/server survives WS death - `proc.kill` on disconnect kills the tmux *client*, the server daemon + session live on (proven: a detached `devplace` session keeps its process running across exec clients). The frontend keeps exactly ONE persistent terminal (the last opened, `ContainerTerminalManager`): it attaches to session `devplace`, **auto-reconnects** with capped backoff on unexpected WS close (not on code 1008), and is remembered per user in `localStorage` (`ct-persistent:<scope>`); `app.containerTerminals.restore()` (one call in `Application.js` after `window.app`) reopens it on the next page load. Opening another terminal calls `setPersistent(false)` on the previous one (its tmux session lingers in the container, but the frontend stops auto-reconnecting/remembering it); explicit window-close (`_onClose`) detaches + forgets (session still survives, reopen re-attaches). **Context menu:** every window wires `app.contextMenu.attach(this.win, () => this._contextItems())` (right-click + long-press). The base `FloatingWindow._contextItems` is Minimize/Normalize/Close; `ContainerTerminal` overrides it with Sync files / Restart / Terminate (`dp-dialog` confirm, then `delete` + close) / Minimize / Normalize / Project / Files / Close - the lifecycle items POST to the existing `instances/{uid}/{sync,restart,delete}` routes and Project/Files `window.location.assign` to `/projects/{slug}` and `/projects/{slug}/files`. Devii (not a `FloatingWindow`) carries its own copy of Minimize/Normalize/Close (no container/project actions, since it is bound to neither). **Minimize/Normalize** are `_presetGeometry(w,h)` size presets (smallest-usable / comfortable), available as both titlebar buttons (`data-win="minimize|normalize"`) and menu items. xterm renders ANSI natively (no `cleanTerm` stripping for the interactive shell; the one-shot exec box still strips, since `docker exec` without `-t` is non-TTY).
- **No wildcard verb route (load-bearing).** `routers/containers.py` registers every instance verb as a **literal** route - `start`/`stop`/`pause`/`resume`/`restart` (stacked `@router.post` decorators on one `instance_action` handler that reads the verb from `request.url.path`), plus `delete`/`exec`/`sync`/`schedules`. There is deliberately NO `POST /instances/{uid}/{action}` catch-all: a wildcard segment matches its literal siblings too (Starlette matches first-declared), so a catch-all silently swallowed `exec`/`sync`/`delete`/`schedules` as `{"error": "unknown action: exec"}` whenever it sat above them. Add any new instance verb as a literal route (and to the `instance_action` decorator stack if it just flips desired state) - never reintroduce a `{action}` wildcard.
- **Host ports are auto-allocated and globally unique.** `parse_ports` accepts a bare container port (`8899`, host side `0` = auto) or a pinned `host:container`. `api.assign_host_ports` (called in `create_instance`) resolves every `host=0` to the lowest free port in `[HOST_PORT_MIN=20001, 65535]` that is neither in `used_host_ports()` (the union of every instance's published host ports from `ports_json`) nor currently bound on the host (`_host_port_free` test-binds `0.0.0.0:port`). A pinned host port already published by another instance is rejected up front. This guarantees no two instances ever collide on a host port, which was the silent `docker run` "port is already allocated" failure. The resolved host port is what gets stored in `ports_json` and what the ingress proxy reads.
- **Ingress (`/p/<slug>`)** = `routers/proxy.py` (registered at `/p`). An instance opts in with `ingress_slug` + `ingress_port` (must be one of its mapped container ports; slug unique, validated in `api.validate_ingress`). `_resolve(slug)` -> `store.find_instance_by_ingress` -> the published host port; the route reverse-proxies HTTP (httpx) and WebSocket (`websockets` client) to `http://{config.CONTAINER_PROXY_HOST}:{host_port}/{path}`, stripping the `/p/<slug>` prefix. **Public** (no auth) but SSRF-safe (host/port come from the instance row, never user input). nginx needs a `/p/` location with WS upgrade + long timeouts (added to `nginx/nginx.conf.template`).
- **Production wiring** (the manager drives the host docker daemon): opt-in overlay `docker-compose.containers.yml` (socket mount, `INSTALL_DOCKER_CLI=true` build arg, `group_add` docker gid). `make docker-build`/`make docker-up` always apply this overlay and self-derive its inputs (`DOCKER_GID` from `stat -c '%g' /var/run/docker.sock`, `DEVPLACE_DATA_DIR=$(CURDIR)/var`), so it works with no `sudo`/`/srv`/`.env` edits; a plain `docker compose up -d` drops the overlay and re-breaks it. **DooD bind-mount gotcha**: `docker run -v <path>:/app` resolves `<path>` on the HOST, so `DEVPLACE_DATA_DIR` must sit at an identical host+container path (make uses `$(CURDIR)/var`; manual compose defaults to `/srv/devplace-data`); build contexts ship via the docker API tarball so the container temp dir is fine. Ingress reaches host ports via `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (containerized) or `127.0.0.1` (bare-metal). See README "Container Manager wiring".
- **Testing without Docker:** `FakeBackend` (its `image_exists` returns True) + `runtime.set_backend`, monkeypatch `config.CONTAINER_WORKSPACES_DIR` to tmp. See `tests/test_containers.py` (argv, instance creation on `config.CONTAINER_IMAGE`, image-not-built guard, reconcile matrices, schedule firing, ingress validation + live HTTP proxy, HTTP admin gate).
## CDN Libraries
@ -646,19 +679,32 @@ Post owners see an "Edit" button on the post detail page that opens `#edit-post-
## Project Detail Page
Each project card links to `/projects/{project_uid}` showing full project details, author info, platforms, star count, and delete-for-owner. The route is `GET /projects/{project_uid}` in `routers/projects.py`. The sitemap generator links to this URL (not the old `?user_uid=` query param). The detail page also links to the project filesystem at `/projects/{slug}/files`.
Each project card links to `/projects/{project_uid}` showing full project details, author info, platforms, star count, delete-for-owner, and (for the owner) Private/Read-only toggle buttons plus badges (see **Project visibility and read-only**). The route is `GET /projects/{project_uid}` in `routers/projects.py` and 404s when the viewer cannot see a private project. The sitemap generator links to this URL (not the old `?user_uid=` query param). The detail page also links to the project filesystem at `/projects/{slug}/files`.
## Project Filesystem
Each project carries a virtual filesystem so it can hold a whole software project. Logic lives in `devplacepy/project_files.py` (mirrors `attachments.py`); routes in `routers/project_files.py` (registered at prefix `/projects`, after `projects.router`). It is **public read, owner write** - reads use `get_current_user`, mutations use `require_user` + `is_owner(project, user)`.
Each project carries a virtual filesystem so it can hold a whole software project. Logic lives in `devplacepy/project_files.py` (mirrors `attachments.py`); routes in `routers/project_files.py` (registered at prefix `/projects`, after `projects.router`). It is **public read, owner write** - reads use `get_current_user`, mutations use `require_user` + `is_owner(project, user)`. Two project flags layer on top (see **Project visibility and read-only**): a private project's reads are restricted to owner/admin, and a read-only project refuses every mutation at the data layer.
- **Model.** One `project_files` table, each row a node keyed by a normalized POSIX `path` unique per project: `uid, project_uid, user_uid, path, name, parent_path, type` (`file`/`dir`), `content` (text in DB), `is_binary`, `stored_name`, `directory`, `mime_type`, `size`, timestamps. Indexes `(project_uid, path)` and `(project_uid, parent_path)` in `init_db`.
- **Text vs binary.** Text files store `content` in the DB (editable inline). Binary uploads write bytes to `static/uploads/project_files/<uid-hashed dir>/<stored_name>` (reusing `attachments._directory_for`/`_detect_mime`) and are served via the existing `/static/uploads` mount. `store_upload` decodes UTF-8 texty files into editable DB content; everything else is binary.
- **Path is the security control.** `normalize_path` rejects `..`, null bytes, control chars, and empty/over-long paths; a leading `/` is normalized to relative. The logical `path` is never used as a real FS path (blobs are uid-hashed), so traversal is structurally impossible. `write`/`upload`/`mkdir` create parent dirs recursively (`ensure_dirs`). Caps: `MAX_TEXT_CHARS` 400000, `MAX_FILES_PER_PROJECT` 5000.
- **Routes (all POST mutations, JSON via `respond`/`action_result`; file path travels in query/body `path`, not a path param):** `GET .../files` (page or `ProjectFilesOut`), `GET .../files/raw?path=`, `POST .../files/{write,upload,mkdir,move,delete}`. `move` rewrites a dir's descendants by path prefix; `delete` is recursive and unlinks blobs.
- **Line-range editing (large text files).** `GET .../files/lines?path=&start=&end=` returns `{path, start, end, total_lines, lines, content}`; `POST .../files/{replace-lines,insert-lines,delete-lines,append}` mutate a text file surgically without resending the whole thing. Helpers in `project_files.py` (`read_lines`, `replace_lines`, `insert_lines`, `delete_lines`, `append_lines`) work on a `(lines, trailing_newline)` model via `_split_lines`/`_join_lines`, are 1-indexed inclusive, enforce `MAX_TEXT_CHARS`, and reject binary/dir/missing paths. These are the preferred way to edit existing files; `write` replaces the whole file. They never create parents (the file must exist).
- **Cascade.** `content.py` `delete_content_item` calls `delete_all_project_files(uid)` when `target_type == "project"`.
- **Frontend.** `templates/project_files.html` (IDE layout: tree + CodeMirror editor / media preview, same CodeMirror vendor as gists), `static/js/ProjectFiles.js` (tree from the flat list, open/save/new/upload/rename/delete over JSON), `static/css/project_files.css`. The CodeMirror mode map is shared via `static/js/codemirrorModes.js` (used by `GistEditor.js` too).
- **Devii + API.** Seven `http` catalog actions (`project_list_files`/`read_file`/`write_file`/`upload_file`/`make_dir`/`move_file`/`delete_file`) and a `project-files` `docs_api.py` group. `project_write_file` is the key automation primitive (write any text file by path, parents auto-created). Because `PlatformClient` sends `Accept: application/json`, the same routes serve the agent and the API.
- **Devii + API.** `http` catalog actions cover the full filesystem: `project_list_files`/`read_file`/`read_lines`/`write_file`/`replace_lines`/`insert_lines`/`delete_lines`/`append_file`/`upload_file`/`make_dir`/`move_file`/`delete_file`, plus the `project-files` `docs_api.py` group. Because `PlatformClient` sends `Accept: application/json`, the same routes serve the agent and the API.
- **Overwrite-protection guard (agent only).** `Dispatcher._run_http` blocks `project_write_file` for a `(slug, path)` only when the file **already exists** and the agent has not read it this session - it must call `project_read_file` first. Existence is probed live via `_file_exists` (`GET /projects/{slug}/files/raw`, 200 = exists, 404 = new), so creating a brand-new file is never blocked. Reads of `project_read_file`/`project_read_lines` (and a successful write) record the path in the per-session `Dispatcher._read_files` set; the key is `normalize_path`d so read and write paths match. This enforces "look before you overwrite" and steers the agent to the surgical line tools for existing files; it does not affect the human UI or the public HTTP API (the guard lives in the Devii dispatcher, not the route). The system prompt's `WRITING AND EDITING FILES` rule mirrors this.
## Project visibility and read-only
Two owner-controlled flags on the `projects` row, both integer `0`/`1` (default `0`, set on the create insert so the columns always exist):
- **`is_private`.** When `1`, the project is visible only to its owner and to administrators. `content.can_view_project(project, user)` is the single predicate (`not is_private` OR `is_owner` OR `is_admin`). It gates every read surface: `project_detail` (404 otherwise), the project filesystem GET routes via `_load_viewable_project` (`/files`, `/files/raw`, `/files/lines`, `/files/zip`), `zip_project`, the `/projects` listing (`get_projects_list` adds a SQLAlchemy clause excluding others' private rows unless the viewer is admin), the profile projects list (`routers/profile.py` filters when the viewer is not owner/admin), and the sitemap (`seo._build_sitemap` skips `is_private`). Because Devii's `project_list_files`/`project_read_file`/`project_read_lines` are `requires_auth=False` GETs that hit those same routes, a guest/non-owner Devii session gets a 404 for free; a signed-in owner's Devii authenticates with the owner's key and sees it.
- **`read_only`.** When `1`, the project's files are **fully immutable** - every mutation is refused for everyone (owner included). Enforcement is a single data-layer guard `project_files._guard_writable(project_uid)` (raises `ProjectFileError("project is read-only; ...")`) called at the top of every write entrypoint: `write_text_file`, `store_upload`, `make_dir`, `move_node`, `delete_node`, `replace_lines`, `insert_lines`, `delete_lines`, `append_lines`, and `import_from_dir`. Placing it in `project_files.py` (not the route) covers ALL callers in one place: the HTTP routes, Devii (which goes through the routes), and container workspace **sync** (`services/containers/api.py sync_workspace` -> `import_from_dir`). `delete_all_project_files` is intentionally NOT guarded so deleting the whole project still cascades. `export_to_dir`/reads are never guarded. The owner unsets read-only to edit again.
**Toggle routes** (owner-only, `routers/projects.py`): `POST /projects/{slug}/private` and `POST /projects/{slug}/readonly`, each taking `ProjectFlagForm{value: bool}` and routed through `_set_project_flag`. The create form carries an `is_private` checkbox (`ProjectForm.is_private`); `project_detail.html` shows Private/Read-only badges and owner toggle buttons (**both** the private and read-only buttons carry `data-confirm` so `ModalManager.initConfirmations` gates them through `app.dialog`), and `project_files.html` hides the editing toolbar + shows a banner when read-only. Schemas: `is_private`/`read_only` on `ProjectOut` and `ProjectDetailOut`, `read_only`/`is_private` on `ProjectFilesOut`.
**Devii.** Actions `project_set_private` and `project_set_readonly` (catalog). **Both** are gated by an explicit-confirmation requirement: `dispatcher.confirmation_error(name, arguments)` (driven by the `CONFIRM_REQUIRED` set, which lists both action names) is checked in `Dispatcher.dispatch` BEFORE any HTTP call and returns a `ToolInputError` telling the agent to obtain user confirmation unless `confirm` is truthy (the message for `project_set_private` adapts to the `value` argument: making private vs. making public). Each action declares `confirm` as a required body param, and the `PROJECT PRIVACY AND READ-ONLY` system-prompt rule tells the agent to ask first and only then pass `confirm=true`. This mirrors the overwrite-protection guard pattern: a Devii-only gate that the human UI and HTTP API do not see.
## Bug Reports
@ -1002,8 +1048,14 @@ auto-opened on `/docs`. Opt-in (`default_enabled=False`).
the upstream model, retune from `CONTEXT_WINDOW_TOKENS`/`MAX_OUTPUT_TOKENS` - everything else derives.
- **Multi-worker.** Hubs are per-process, so `/devii/ws` is served **only** by the worker that
holds the background-service lock (`service_manager.owns_lock()`, set in `main.py` startup);
other workers `close(1013)` and the client (auto-reconnecting socket) lands on the owner. The
cap stays correct regardless because it reads the DB.
a non-owner worker `close(4013)` (an application code in the private 4000-4999 range, reliably
delivered as the browser `CloseEvent.code`). `DeviiSocket.js` recognises 4013 as "wrong worker,
not yet settled" and fast-retries in ~200ms **silently** (no "disconnected, reconnecting..."
notice), so with 2 prod workers the client converges on the owner in a fraction of a second
instead of bouncing every 1500ms. The visible "connected." notice is emitted on the first server
frame (`onReady`), never on raw socket open, so an accepted-then-4013-closed handshake on the
wrong worker is invisible. The disabled/no-service path keeps the standard `1013` (a real,
user-visible disconnect). The cap stays correct regardless because it reads the DB.
- **Client-side tool channel.** Beyond the avatar, Devii has a `client`/browser channel using the
same request/response pattern: `services/devii/client/ClientController` is bound on `attach`, and
`actions/client_actions.py` exposes `get_page_context`, `run_js`, `highlight_element`,
@ -1096,6 +1148,8 @@ auto-opened on `/docs`. Opt-in (`default_enabled=False`).
| `registration_open` | `"1"` | When `"0"`, signup GET shows a closed notice and POST is rejected (`auth.py`) |
| `maintenance_mode` | `"0"` | When `"1"`, non-admins get a 503 (`main.py` maintenance middleware) |
| `maintenance_message` | scheduled-maintenance text | Body shown on the maintenance 503 page |
| `customization_enabled` | `"1"` | When `"0"`, `custom_css_tag`/`custom_js_tag` inject nothing (feature off) |
| `customization_js_enabled` | `"1"` | When `"0"`, custom CSS still serves but custom JS is suppressed |
The seed block in `database.py` is guarded by `if "site_settings" in tables:` - on a brand-new DB the table does not exist yet (dataset creates tables lazily on first insert), so none of these rows are written until the table exists. Correct runtime behavior therefore relies on every consumer passing the production default to `get_setting`/`get_int_setting`, not on the seed.
@ -1252,9 +1306,7 @@ while feature_not_complete:
1. Plan: read existing code, design the change
2. Implement: write code (router → template → CSS → JS)
3. validate per language + clean import # must pass
4. falcon take + describe # visual check for UI changes
5. If visual fail: fix CSS/template → goto 3
6. Update AGENTS.md if needed
4. Update AGENTS.md if needed
```
Failures at any step block the workflow. Never skip a failed step.
@ -1340,3 +1392,25 @@ All SEO features are implemented across the following locations:
### CSS
- Reactions, bookmarks, polls and the saved page live in `static/css/engagement.css`, loaded globally in `base.html` (the controls appear across feed, detail pages and comments). Heatmap styles are in `profile.css`. Follow-list rows (`.follow-row`, `.follow-user`, `.follow-pagination`) are in `profile.css`.
## Per-user site customization (CSS/JS)
Users (and guests) inject their own CSS and JS, scoped to a **page type** or **globally**, configured conversationally through Devii. It is **per-owner only** - the code runs solely in that owner's own browser sessions (self-XSS, like a userscript), never in anyone else's view. There are **no HTTP routes**: persistence is owner-scoped from the trusted Devii session (the same pattern as `LessonStore`/`TaskStore`), and serving is a template-global injection.
- **Page type** = the matched route template (`request.scope["route"].path`, e.g. `/posts/{slug}`), resolved by `customization.page_type_for(request)`. One value covers all instances of a type (all posts), never a single post. It is also rendered as `data-page-type` on `<main class="page">` and surfaced to Devii via `DeviiClient._context().pageType`.
- **Owner** = `customization.owner_for(request)`: signed-in user -> `("user", uid)`, else the `devii_guest` cookie -> `("guest", cookie)` (session-scoped, prunable), else none. `DEVII_GUEST_COOKIE` lives in `constants.py` (shared by `routers/devii.py` and `customization.py`).
- **Storage** (`database.py`): table `user_customizations` (one row per `owner_kind`, `owner_id`, `scope`, `lang`; `enabled` flag; indexed `idx_user_cust_owner` / `idx_user_cust_scope`). Helpers `get_custom_overrides` (merges **global first, then page-type**, skips disabled; cached under the `"customizations"` cache-version name with `sync_local_cache`/`bump_cache_version`), `get_custom_override`, `list_custom_overrides`, `set_custom_override` (upsert + bump), `delete_custom_override` (+ bump).
- **Robustness against hostile CSS**: because users can override `body`, the app's own `position: fixed` chrome is layer-promoted with `will-change: transform` (grouped rules in `base.css` for `.topnav`/overlays/`.modal-overlay`/`.dialog-overlay`/`.context-menu`/`.dp-toast-host`/`.feed-fab`/`.attachment-lightbox`, and in `devii.css` for the devii launcher/window/highlight/toast). This stops a user `background-attachment: fixed` (or any fixed-background) from triggering the Chromium compositing bug that makes other `position: fixed` elements vanish or fail to repaint (it previously wiped the FAB and the create-post modal). It is a paint-only hardening (no layout change, since none of these have viewport-anchored fixed descendants). Any NEW always-on fixed UI element must be added to one of these groups.
- **Injection** (`customization.py` + `templating.py` globals + `base.html`): `custom_css_tag(request)` emits a `<style id="user-custom-css">` placed AFTER `extra_head` so user CSS overrides the design system (closing-tag breakout neutralized by `</` -> `<\/`, valid in CSS). `custom_js_tag(request)` emits the code as a JSON island `<script type="application/json" id="user-custom-js-src">` (with `<`/`>` `<`/`>`-escaped so `</script>` cannot break out) run by a tiny bootstrap via `new Function(JSON.parse(...))`, placed AFTER `Application.js`/`extra_js` so `app` exists. Both are wrapped in try/except that logs and returns empty Markup - **a customization bug must never break page render** (the one intended error suppression). Two `site_settings` kill-switches: `customization_enabled`, `customization_js_enabled`.
- **Devii** (`services/devii/customization/`, `handler="customization"`, all `requires_auth=False`): `customize_list`, `customize_get`, `customize_set_css`, `customize_set_js`, `customize_reset`. `CustomizationController(owner_kind, owner_id)` is built in `Dispatcher.__init__` (owner threaded from `DeviiSession` through the `Dispatcher(...)` call). The set/reset tools are in `CONFIRM_REQUIRED`; `confirmation_error` forces Devii to ask **page-type vs global** (set) or confirm deletion (reset) before `confirm=true`. The system-prompt CUSTOMIZATION section tells Devii to preview live with `run_js` (ids `devii-preview-css`/`devii-preview-js`), build on `customize_get` instead of overwriting, and `reload_page` after saving.
## Devii user-defined ("virtual") tools
Users invent new Devii tools in natural language; each is stored per-owner and added to Devii's live LLM tool list, and when called its handler **re-prompts Devii itself** (a self-eval sub-agent) with the stored prompt plus the user's `input`. Full CRUD is Devii-only.
- **Dynamic tool list (the crux).** The session tool list is normally frozen (`session.py` `self.tools = CATALOG.tool_schemas_for(...)`, handed by reference to `Agent`, `AgenticController.bind`, and read live by `react_loop` each `llm.complete`). `DeviiSession._refresh_tools()` runs at the top of every `_run_turn` (inside `self._lock`, before `agent.respond`) and rewrites that list **in place**: `self.tools[:] = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin) + self._virtual_tool_store.tool_schemas()`. Because every holder shares the same list object, a tool created/edited/deleted (and a mid-session login) takes effect on the **next** turn with no Agent rebuild.
- **Self-eval engine** (`agentic/controller.py`): `_delegate` was refactored onto `_spawn(prompt, tools, system_prompt)`, which runs `react_loop` with a fresh `messages`/`AgentState` under a **depth guard** (`agentic/state.py` `get/set/reset_eval_depth`, `MAX_EVAL_DEPTH=2`) so delegate/eval/virtual tools cannot self-call infinitely. `run_subagent(prompt)` (tools minus `delegate`) is the public engine; the `eval` agentic tool wraps it; `_delegate` keeps its richer JSON report.
- **Store** (`services/devii/virtual_tools/store.py`): `VirtualToolStore(db, owner_kind, owner_id)` over `devii_virtual_tools` (`uid, owner_kind, owner_id, name, description, prompt, input_description, enabled, created_at, updated_at`; indexes `idx_devii_vtools_owner` / `idx_devii_vtools_name`). `tool_schemas()` builds, for each **enabled** row, an LLM schema with a single free-form `input` string. Persistent for users, `memory_db()` for guests (built in `hub.get_or_create` from the shared `owned_db`).
- **Controller** (`virtual_tools/controller.py`): `VirtualToolController(store, evaluator, builtin_names)`, evaluator = `agentic.run_subagent`, `builtin_names = set(CATALOG.by_name())`. CRUD `tool_create/list/get/update/delete` (`handler="virtual_tool"`); `tool_create` validates name `^[a-zA-Z][a-zA-Z0-9_]{1,40}$`, rejects built-in collisions and duplicates, requires `description`+`prompt`. `has(name)` / `run(name, args)` compose `f"{prompt}\n\nUser input: {input}"` and call the evaluator.
- **Dispatch** (`actions/dispatcher.py`): constructed with `virtual_tools=...`; `_run` routes `handler="virtual_tool"` to CRUD; the **unknown-name branch** (`if action is None`) now resolves a virtual tool via `self._virtual_tools.has(name)` -> `run(name, args)` before erroring. So virtual tools live only in the store (not the static catalog) and are resolved on demand; the static `_actions` stays built-in only.
- Registered via `VIRTUAL_TOOL_ACTIONS` (`registry.py`); the `eval` tool via `AGENTIC_ACTIONS`. System-prompt **USER-DEFINED TOOLS (VIBE TOOLS)** section steers creation and warns against deep self-calls.

File diff suppressed because one or more lines are too long

View File

@ -3,9 +3,21 @@ FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Optional: the docker CLI so the (admin-only) container manager can drive the host
# docker daemon. Off by default; the container compose override turns it on.
ARG INSTALL_DOCKER_CLI=false
RUN if [ "$INSTALL_DOCKER_CLI" = "true" ]; then \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \
chmod a+r /etc/apt/keyrings/docker.asc && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list && \
apt-get update && apt-get install -y --no-install-recommends docker-ce-cli && \
rm -rf /var/lib/apt/lists/* ; \
fi
COPY pyproject.toml .
COPY devplacepy/ devplacepy/

View File

@ -13,7 +13,7 @@ install:
pip install -e .
dev:
uvicorn devplacepy.main:app --reload --host 0.0.0.0 --port 10500 --backlog 4096
uvicorn devplacepy.main:app --reload --reload-dir devplacepy --host 0.0.0.0 --port 10500 --backlog 4096
prod:
DEVPLACE_WEB_WORKERS=2 uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'
@ -78,22 +78,43 @@ clean:
rm -rf devplacepy.egg-info
rm -rf .venv
.PHONY: docker-build docker-up docker-down docker-logs docker-clean
# Container Manager works out of the box: the overlay installs the docker CLI in
# the image and mounts the host socket. DOCKER_GID is read straight from the
# socket so the UID-1000 app can use it; the data dir is the project's own var/
# at its real host path, so the DooD bind-mount (host == container path) holds
# with no /srv dir and no sudo.
COMPOSE := docker compose -f docker-compose.yml -f docker-compose.containers.yml
DEVPLACE_DATA_DIR ?= $(CURDIR)/var
DOCKER_GID ?= $(shell stat -c '%g' /var/run/docker.sock 2>/dev/null)
export DEVPLACE_DATA_DIR
export DOCKER_GID
docker-build:
docker compose build
.PHONY: docker-build docker-up docker-down docker-logs docker-clean docker-prep ppy
docker-up:
docker compose up -d
# Build the single shared container image every instance runs. Build once;
# rebuild only when ppy.Dockerfile, the sudo shim, or pagent change.
ppy:
docker build --network=host -f ppy.Dockerfile -t ppy:latest devplacepy/services/containers/files
docker-prep:
mkdir -p $(DEVPLACE_DATA_DIR)
docker-build: docker-prep
$(COMPOSE) build
docker-up: docker-prep
$(COMPOSE) up -d
docker-down:
docker compose down
$(COMPOSE) down
docker-logs:
docker compose logs -f
$(COMPOSE) logs -f
docker-clean:
docker compose down -v
$(COMPOSE) down -v
docker-bup: docker-build docker-up
deploy:
git checkout production

View File

@ -56,9 +56,13 @@ devplacepy/
| `/news` | Developer news listing, detail page with comments |
| `/posts` | Post detail, creation |
| `/comments` | Comment creation, deletion |
| `/projects` | Project listing, creation |
| `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing (public read, owner write) |
| `/projects` | Project listing, creation, and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files) |
| `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing, and line-range operations (`lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append`) for surgical edits to large text files (public read, owner write; all writes refused while the project is read-only) |
| `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` |
| `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL |
| `/projects/{slug}/containers` | Admin per-project container manager: Dockerfile CRUD with immutable versions, async image builds, and container instance creation. Reachable from the project page via the admin-only **Containers** button |
| `/admin/containers` | Admin **Containers** section: a list of every container instance across all projects (`/admin/containers`), each linking to a dedicated instance detail page (`/admin/containers/{uid}`) with lifecycle controls, live logs and metrics, an interactive terminal, schedules, ingress, and workspace sync |
| `/p/{slug}` | Public ingress proxy (HTTP + WebSocket) to a running container instance's published port, opt-in per instance via `ingress_slug` |
| `/profile` | Profile view, editing |
| `/messages` | Direct messaging |
| `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
@ -95,6 +99,8 @@ Member progression is driven by activity and peer recognition.
- **Emoji reactions** - a fixed palette of reactions on posts, comments, gists, and projects, separate from voting and carrying no ranking weight.
- **Polls** - a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member. A poll can be attached when the post is created or added later by editing a post that has none.
- **Bookmarks** - save posts, gists, projects, and news to a personal list at `/bookmarks/saved`.
- **Private projects** - an owner can mark a project private so it is visible only to them (and administrators) and excluded from listings, profiles, search, the sitemap, and zip access. Set at creation or toggled later from the project page.
- **Read-only projects** - an owner can mark a project read-only, making its entire virtual filesystem immutable: every write, edit, line-edit, move, delete, and upload is refused from all paths (the web UI, the HTTP API, the Devii agent, and container workspace sync) until read-only is turned off. Devii may toggle read-only only after the user explicitly confirms.
XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils.py`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
@ -103,6 +109,7 @@ XP awards are wired at the existing content-creation, vote, and follow hook poin
| Env var | Default | Purpose |
|---------|---------|---------|
| `DEVPLACE_DATABASE_URL` | `sqlite:///devplace.db` | Database connection string |
| `DEVPLACE_DATA_DIR` | `<repo>/var` | Persistent runtime data dir, outside the package and not served via `/static` (zip archives, container workspaces). Point at a volume in production |
| `SECRET_KEY` | hardcoded fallback | Session signing key |
| `DEVPLACE_VAPID_SUB` | `mailto:retoor@molodetz.nl` | Contact address in the VAPID JWT `sub` claim |
| `DEVPLACE_INTERNAL_BASE_URL` | `http://localhost:10500` | Base URL the platform's own services dial for the AI gateway |
@ -114,6 +121,7 @@ Operational behavior is tunable live from `/admin/settings` (stored in `site_set
| Setting | Default | Effect |
|---------|---------|--------|
| `site_url` | empty | Public origin for absolute links (SEO, container ingress `/p/<slug>`); empty derives from the request or `DEVPLACE_SITE_URL` |
| `rate_limit_per_minute` | `60` | Mutating requests allowed per IP per window |
| `rate_limit_window_seconds` | `60` | Rolling window for the request limit |
| `session_max_age_days` | `7` | Standard session cookie lifetime |
@ -121,6 +129,8 @@ Operational behavior is tunable live from `/admin/settings` (stored in `site_set
| `registration_open` | `1` | When `0`, new sign-ups are rejected |
| `maintenance_mode` | `0` | When `1`, non-admins see the maintenance page; admins retain access |
| `maintenance_message` | scheduled-maintenance text | Message shown during maintenance |
| `customization_enabled` | `1` | When `0`, no user CSS/JS customization is injected on any page |
| `customization_js_enabled` | `1` | When `0`, user custom CSS is still served but custom JavaScript is suppressed |
Numeric values are floored to safe minimums so an invalid entry cannot lock out writes or stall services. Consumers read via `get_setting`/`get_int_setting`, which fall back to these defaults when a row is absent.
@ -204,7 +214,16 @@ and its full configuration are documented automatically - including future servi
- **`NewsService`** - fetches news from `news.app.molodetz.nl/api`, grades with AI, stores articles >= threshold
- **`BotsService`** - Playwright fleet of AI personas that browse and interact with a DevPlace instance, with live cost/usage metrics (opt-in; install the `bots` extra)
- **`GatewayService`** - OpenAI-compatible LLM gateway at `/openai/v1/*`, forwarding to DeepSeek (default) with optional vision augmentation; the single point of truth for AI that every other service routes through (enabled by default)
- **`JobService` / `ZipService`** - generic async job framework (`services/jobs/`) for heavy, blocking work run off the request path; `ZipService` is the first consumer, building project zip archives in a subprocess
- **`JobService` / `ZipService` / `ForkService`** - generic async job framework (`services/jobs/`) for heavy, blocking work run off the request path; `ZipService` builds project zip archives in a subprocess, `ForkService` copies a project into a new project owned by the forking user
- **`ContainerService`** - the admin container manager (`services/containers/`): a reconciling supervisor for container instances, all running one shared prebuilt image
### Container manager (admin only)
`services/containers/` runs supervised container instances from the web UI, the HTTP API, and Devii. It drives the `docker` CLI via async subprocesses behind a pluggable `Backend` interface (a `DockerCliBackend` plus a `FakeBackend` for tests). **There is no in-app image building.** Every instance runs ONE shared prebuilt image, `ppy:latest` (override `DEVPLACE_CONTAINER_IMAGE`), built once with **`make ppy`** from `ppy.Dockerfile`: a Python + Playwright base with a broad set of common libraries preinstalled, the `pravda` (uid 1000) user, the sudo superclone, and `pagent` at `/usr/bin/pagent.py` all baked in. Creating an instance is then an instant `docker run` (it fails fast with a clear error if the `ppy` image has not been built yet). `ContainerService` reconciles desired instance state against `docker ps` each tick (containers are labeled `devplace.instance=<uid>`, so orphans are reaped and no state is lost), applies restart policies, fires cron/interval/one-time schedules, and samples metrics. The container's `/app` is bind-mounted to a persistent project workspace (materialized from the project files) and can be synced back; projects that need extra packages use runtime `pip install` (pravda owns the site-packages, no sudo needed) or add the library to `ppy.Dockerfile` and rerun `make ppy`. A running instance can be **published** with an `ingress_slug`, making its service reachable (HTTP and WebSocket) at `/p/<slug>` through DevPlace. The manager is reached two ways: the admin **Containers** sidebar entry (`/admin/containers`) lists every instance across all projects and opens a dedicated detail page per instance, and each project page carries an admin-only **Containers** button to its own instance manager.
**Security:** this requires mounting the Docker socket, which grants host root. Every run, exec, lifecycle, and schedule operation is administrator-only; `--privileged` is never used and all docker calls are argument-list subprocesses. The service is disabled by default; an admin enables **Containers** on `/admin/services`. CLI: `devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables).
**Runtime data** (container workspaces and zip archives) lives in `DEVPLACE_DATA_DIR` (default `var/`), **outside the package and never served via `/static`**. The docker daemon must be able to bind-mount the data dir for `/app`.
### Async job framework and zip downloads
@ -212,6 +231,8 @@ and its full configuration are documented automatically - including future servi
`ZipService` archives a project (or a file/folder subtree) into `{crc32}.{slug}.zip` using the standalone `zip_worker` subprocess, with a CRC32 over the output. The frontend `app.zipDownloader` (any `data-zip-download` element, plus the files context menu) enqueues, polls `/zips/{uid}`, and downloads from `/zips/{uid}/download`. The job uid is the capability token, so anyone with the link can download. CLI: `devplace zips prune` (delete expired) / `devplace zips clear` (delete all).
`ForkService` copies a project into a new project owned by the forking user. The **Fork** button on the project page (any signed-in user) prompts for a name; the job creates the destination project, duplicates the entire virtual filesystem, and records a directional `project_forks` relation so each fork shows a "Forked from X" link. The frontend `app.projectForker` enqueues, polls `/forks/{uid}`, and redirects to the new project once it is done; on failure the partially created project is rolled back. The forked project is permanent, so retention removes only the job tracking row. CLI: `devplace forks prune` / `devplace forks clear` (job rows only).
### Adding a service
Create a class extending `BaseService`, declare its `config_fields`, override `run_once()` (read parameters via `self.get_config()`), and register in `main.py`:
@ -404,6 +425,42 @@ devii --api-key <your DevPlace api_key> --base-url https://your-host
devii -p "List my unread notifications as a bullet list." # one-shot
```
### Site customization (per-user CSS/JS)
Each user can reshape the site to taste by injecting their own **CSS** (look) and
**JavaScript** (behaviour), scoped either to a single **page type** (every post page, every
project page) or **globally** (every page). Customizations apply only to that user's own
sessions; a signed-out guest gets customizations scoped to their `devii_guest` session cookie.
This is the user's own code running in their own browser, like a userscript, so it never affects
anyone else's view.
It is configured conversationally through Devii. Ask for a change and Devii previews it live in
your browser, then before saving it asks whether to apply it to the current page type or the
whole site, and only persists once you confirm. Overrides are stored in the `user_customizations`
table (one row per owner, scope, and language; cached with cross-worker invalidation) and injected
server-side: custom CSS is the last `<style>` in `<head>` so it overrides the design system, and
custom JavaScript runs after the application boots with access to the global `app`. Devii tools:
`customize_list`, `customize_get`, `customize_set_css`, `customize_set_js`, and `customize_reset`
(restores the default look and behaviour). Operators can disable the feature site-wide with the
`customization_enabled` / `customization_js_enabled` settings.
### User-defined tools (vibe new functionality)
Users can invent new Devii capabilities by describing them in natural language, with no code. Telling
Devii "when I say woeii, search the web for a cat picture matching my description and set it as my
page background" makes it call `tool_create` and store a virtual tool. The tool then appears in
Devii's own toolset, and on the next turn "woeii orange" calls it: the handler re-runs Devii itself
(a self-evaluating sub-agent) on the stored prompt plus the user's input, with full tool access, and
returns the result. Each tool takes a single free-form `input` string; the stored prompt directs what
to do with it.
Virtual tools are owner-scoped (persistent in the DB for signed-in users in `devii_virtual_tools`,
session-only for guests) and never run in anyone else's session. The full CRUD is exposed as Devii
tools: `tool_create`, `tool_list`, `tool_get`, `tool_update` (including enable/disable), and
`tool_delete`. A general `eval(prompt)` tool runs Devii on any prompt and returns the result; it is
the same engine that powers virtual tools. Self-evaluation depth is bounded so a tool cannot loop by
calling itself.
## Push notifications & PWA
Authenticated users can receive native web push notifications, and the site is an
@ -517,25 +574,42 @@ This requires prod and dev to be on the **same host/filesystem**; SQLite is a lo
```bash
cp .env.example .env # set SECRET_KEY; adjust PORT, DEVPLACE_SITE_URL
make docker-up # docker compose up -d (builds on first run)
make docker-build # build the image (installs the docker CLI)
make docker-up # start (creates ./var, mounts the socket)
```
Open `http://<host>:${PORT}` (default 10500). `make docker-logs` tails output; `make docker-down` stops.
`make docker-build` and `make docker-up` always apply the `docker-compose.containers.yml` overlay, so the admin-only Container Manager works with no extra setup. Mounting the docker socket grants the app **root on the host**; the feature itself stays disabled until an admin enables **Containers** on `/admin/services` (and the `ppy` image is built once with `make ppy`), but treat the whole deployment as trusted-admins-only. Always update through the make targets - a plain `docker compose up -d` drops the overlay and silently removes the socket and CLI.
### Updating
```bash
git pull
docker compose up -d # restart with new code (bind-mounted, no rebuild)
docker compose build && \
docker compose up -d # only when dependencies in pyproject.toml change
make docker-up # restart with new code (bind-mounted, no rebuild)
make docker-build && \
make docker-up # only when dependencies in pyproject.toml change
```
The `make deploy` target fast-forwards the `production` branch (`git checkout production && git merge master && git push origin production`); pull that branch on the server.
### Container Manager wiring (what the overlay does)
The Container Manager drives the host Docker daemon, so `make docker-build`/`make docker-up` apply `docker-compose.containers.yml` automatically. The make targets derive everything the overlay needs - `DOCKER_GID` from the socket and `DEVPLACE_DATA_DIR` as the project's own `./var` at its real host path - so no `sudo`, no `/srv` dir, and no manual `.env` editing are required. (Override `DEVPLACE_DATA_DIR` or `DOCKER_GID` on the make command line to point elsewhere.)
What the overlay (`docker-compose.containers.yml`) changes:
- **Docker CLI in the image** via the `INSTALL_DOCKER_CLI=true` build arg (the base image stays lean).
- **Docker socket** mounted into the app container. This grants the app **root on the host** - every build/run/exec is admin-only, `--privileged` is never used, and all docker calls are argument-list subprocesses, but treat the whole feature as trusted-admins-only.
- **Socket permissions:** the app runs as UID 1000, so the overlay adds the host `docker` group via `group_add`. `make` reads the gid straight from `/var/run/docker.sock` (`stat -c '%g'`), the exact group that owns the socket.
- **Data dir at a consistent path (critical).** When the app (in its container) runs `docker run -v <path>:/app`, the daemon resolves `<path>` against the **host**, not the app container. So the workspace/data dir must be mounted at the **same absolute path** on host and in the container - the make targets set `DEVPLACE_DATA_DIR` to the project's `./var` (an absolute host path) and mount it at that identical path on both sides. (Build contexts go through the docker API as a tarball, so they can stay in the container's temp dir - only the `/app` bind mount needs path consistency.)
- **Ingress reach:** published container ports live on the **host**, so the overlay sets `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (with `extra_hosts: host-gateway`) so the `/p/<slug>` proxy can reach them. On a bare-metal `make prod` deploy the app is already on the host, so the default `127.0.0.1` works and no overlay is needed (just install the docker CLI and run the services).
Then enable **Container builds** and **Containers** on `/admin/services`. Builds default to `--network=host` (configurable on the service) so pip can reach PyPI; set the build network to empty to use the docker default.
### nginx specifics
- **WebSockets:** `/devii/ws` (the Devii terminal) is proxied with `Upgrade`/`Connection` headers and a 1h read timeout.
- **WebSockets:** `/devii/ws` (the Devii terminal) and `/p/` (container ingress) are proxied with `Upgrade`/`Connection` headers and 1h timeouts; `/p/` also disables buffering for streaming.
- **Uploads:** `/static/uploads/` is served with `X-Content-Type-Options: nosniff` and a `Content-Disposition` that mirrors the app's `UploadStaticFiles` control: known-safe media (images, video, audio) is served `inline` so it embeds and plays in the page, while every other type is forced to `attachment` so it downloads instead of rendering (stored-XSS defense). SVG is deliberately excluded from the inline set.
- **Upload size:** `NGINX_MAX_BODY_SIZE` (default `50m`) must be **>= the admin-configurable `max_upload_size_mb`** (Admin -> Settings), or large uploads are rejected with HTTP 413 before reaching the app.
- **Micro-cache:** off by default; enable with `NGINX_CACHE_ENABLED=true`.
@ -565,8 +639,7 @@ Changes are promoted through automated DTAP streets: Development (`make dev`), T
2. Validate each touched language (Python compiles/imports, JS parses, CSS and HTML balance)
3. `make test` - run all tests (fail-fast)
4. Add Playwright tests in `tests/test_*.py` for new functionality
5. For visual features: `falcon take --output /tmp/verify.png && falcon describe /tmp/verify.png`
6. Update `AGENTS.md` and `README.md` if new conventions were introduced
5. Update `AGENTS.md` and `README.md` if new conventions were introduced
## License

View File

@ -172,6 +172,103 @@ def cmd_zips_clear(args):
print(f"Cleared {len(jobs)} zip job(s) and their archives")
def cmd_forks_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="fork", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
get_table("jobs").delete(uid=job["uid"])
removed += 1
print(f"Pruned {removed} expired fork job(s)")
def cmd_forks_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="fork")
for job in jobs:
get_table("jobs").delete(uid=job["uid"])
print(f"Cleared {len(jobs)} fork job(s)")
def cmd_containers_list(args):
from devplacepy.services.containers import store
instances = store.all_instances()
if not instances:
print("No container instances")
return
for inst in instances:
print(f"{inst['uid'][:8]} {inst.get('name', ''):24.24} {inst.get('status', ''):10} "
f"desired={inst.get('desired_state', '')} policy={inst.get('restart_policy', '')}")
def cmd_containers_reconcile(args):
import asyncio
from devplacepy.services.containers.service import ContainerService
asyncio.run(ContainerService().run_once())
print("Reconcile pass complete")
def cmd_containers_prune(args):
import asyncio
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.containers.service import ContainerService
async def run():
await ContainerService().run_once()
await get_backend().image_prune()
asyncio.run(run())
print("Reaped orphans and pruned dangling images")
def cmd_containers_prune_builds(args):
import asyncio
from devplacepy.database import db, get_table
from devplacepy.services.containers.runtime import get_backend
async def run():
backend = get_backend()
removed = 0
if "builds" in db.tables:
for build in list(get_table("builds").find()):
tag = build.get("image_tag")
if tag:
await backend.remove_image(tag)
removed += 1
for table in ("builds", "dockerfile_versions", "dockerfiles"):
if table in db.tables:
get_table(table).delete()
return removed
removed = asyncio.run(run())
print(f"Removed {removed} legacy per-project image(s) and cleared the dockerfiles/builds tables")
def cmd_containers_gc_workspaces(args):
import shutil
from pathlib import Path
from devplacepy import config
from devplacepy.services.containers import store
active = {inst["project_uid"] for inst in store.all_instances()}
base = Path(config.CONTAINER_WORKSPACES_DIR)
removed = 0
if base.is_dir():
for child in base.iterdir():
if child.is_dir() and child.name not in active:
shutil.rmtree(child, ignore_errors=True)
removed += 1
print(f"Removed {removed} unused workspace director{'y' if removed == 1 else 'ies'}")
def main():
parser = argparse.ArgumentParser(description="DevPlace admin CLI")
sub = parser.add_subparsers(title="commands", dest="command")
@ -227,6 +324,21 @@ def main():
zips_clear = zips_sub.add_parser("clear", help="Delete every zip archive and job row")
zips_clear.set_defaults(func=cmd_zips_clear)
forks = sub.add_parser("forks", help="Fork job management")
forks_sub = forks.add_subparsers(title="action", dest="action")
forks_prune = forks_sub.add_parser("prune", help="Delete expired completed fork job rows (forked projects persist)")
forks_prune.set_defaults(func=cmd_forks_prune)
forks_clear = forks_sub.add_parser("clear", help="Delete every fork job row (forked projects persist)")
forks_clear.set_defaults(func=cmd_forks_clear)
containers = sub.add_parser("containers", help="Container manager")
containers_sub = containers.add_subparsers(title="action", dest="action")
containers_sub.add_parser("list", help="List container instances").set_defaults(func=cmd_containers_list)
containers_sub.add_parser("reconcile", help="Run one reconcile pass").set_defaults(func=cmd_containers_reconcile)
containers_sub.add_parser("prune", help="Reap orphan containers and dangling images").set_defaults(func=cmd_containers_prune)
containers_sub.add_parser("prune-builds", help="Remove legacy per-project images and clear the dockerfiles/builds tables").set_defaults(func=cmd_containers_prune_builds)
containers_sub.add_parser("gc-workspaces", help="Remove workspace dirs with no instances").set_defaults(func=cmd_containers_gc_workspaces)
args = parser.parse_args()
if hasattr(args, "func"):
args.func(args)

View File

@ -22,6 +22,14 @@ INTERNAL_MODEL = "molodetz"
SERVICE_LOCK_FILE = BASE_DIR / "devplace-services.lock"
INIT_LOCK_FILE = BASE_DIR / "devplace-init.lock"
# Container data lives OUTSIDE the package (never served via /static, never watched by --reload).
DATA_DIR = Path(environ.get("DEVPLACE_DATA_DIR", str(BASE_DIR / "var")))
CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
# Every container instance runs this one prebuilt image (built once via `make ppy`).
CONTAINER_IMAGE = environ.get("DEVPLACE_CONTAINER_IMAGE", "ppy:latest")
# Host the /p/<slug> ingress proxy dials to reach a published container port.
CONTAINER_PROXY_HOST = environ.get("DEVPLACE_CONTAINER_PROXY_HOST", "127.0.0.1")
VAPID_PRIVATE_KEY_FILE = BASE_DIR / "notification-private.pem"
VAPID_PRIVATE_KEY_PKCS8_FILE = BASE_DIR / "notification-private.pkcs8.pem"
VAPID_PUBLIC_KEY_FILE = BASE_DIR / "notification-public.pem"

View File

@ -1,3 +1,5 @@
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"]
REACTION_EMOJI = ["\U0001F44D", "❤️", "\U0001F680", "\U0001F389", "\U0001F602", "\U0001F440", "\U0001F525", "\U0001F92F"]
DEVII_GUEST_COOKIE = "devii_guest"

View File

@ -29,6 +29,15 @@ def is_owner(item: dict | None, user: dict | None) -> bool:
return bool(item and user and item["user_uid"] == user["uid"])
def can_view_project(project: dict | None, user: dict | None) -> bool:
from devplacepy.utils import is_admin
if not project:
return False
if not project.get("is_private"):
return True
return is_owner(project, user) or is_admin(user)
def canonical_redirect(area: str, item: dict, requested: str) -> RedirectResponse | None:
canonical = item.get("slug") or item["uid"]
if requested == canonical:
@ -128,7 +137,9 @@ def delete_content_item(request, table_name: str, target_type: str, user: dict,
delete_engagement("comment", comment_uids)
if target_type == "project":
from devplacepy.project_files import delete_all_project_files
from devplacepy.database import delete_fork_relations
delete_all_project_files(item["uid"])
delete_fork_relations(item["uid"])
if inline_image_field:
delete_inline_image(item.get(inline_image_field))
table.delete(id=item["id"])

View File

@ -0,0 +1,77 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
import logging
from markupsafe import Markup
from starlette.requests import Request
from devplacepy.constants import DEVII_GUEST_COOKIE
from devplacepy.database import get_custom_overrides, get_setting
from devplacepy.utils import get_current_user
logger = logging.getLogger("customization")
EMPTY = Markup("")
def page_type_for(request: Request) -> str:
route = request.scope.get("route")
path = getattr(route, "path", None)
if path:
return path
return request.url.path
def owner_for(request: Request) -> tuple[str, str] | None:
user = get_current_user(request)
if user:
return "user", user["uid"]
guest = request.cookies.get(DEVII_GUEST_COOKIE)
if guest:
return "guest", guest
return None
def _overrides_for(request: Request) -> dict:
if get_setting("customization_enabled", "1") != "1":
return {"css": "", "js": ""}
owner = owner_for(request)
if owner is None:
return {"css": "", "js": ""}
return get_custom_overrides(owner[0], owner[1], page_type_for(request))
def custom_css_tag(request: Request) -> Markup:
try:
css = _overrides_for(request).get("css", "")
if not css.strip():
return EMPTY
safe = css.replace("</", "<\\/")
return Markup(f'<style id="user-custom-css">\n{safe}\n</style>')
except Exception as exc: # noqa: BLE001 - a customization bug must never break page render
logger.warning("custom_css_tag failed: %s", exc)
return EMPTY
def custom_js_tag(request: Request) -> Markup:
try:
if get_setting("customization_js_enabled", "1") != "1":
return EMPTY
code = _overrides_for(request).get("js", "")
if not code.strip():
return EMPTY
payload = json.dumps(code).replace("<", "\\u003c").replace(">", "\\u003e")
runner = (
f'<script type="application/json" id="user-custom-js-src">{payload}</script>'
'<script>(function(){try{'
'var src=document.getElementById("user-custom-js-src");'
'if(src){new Function(JSON.parse(src.textContent))();}'
'}catch(error){console.error("user custom js error",error);}})();</script>'
)
return Markup(runner)
except Exception as exc: # noqa: BLE001 - a customization bug must never break page render
logger.warning("custom_js_tag failed: %s", exc)
return EMPTY

View File

@ -115,6 +115,7 @@ def init_db():
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
_index(db, "sessions", "idx_sessions_token", ["session_token"])
_index(db, "projects", "idx_projects_user", ["user_uid"])
_index(db, "projects", "idx_projects_private", ["is_private"])
_index(db, "project_files", "idx_project_files_path", ["project_uid", "path"])
_index(db, "project_files", "idx_project_files_parent", ["project_uid", "parent_path"])
_index(db, "badges", "idx_badges_user", ["user_uid"])
@ -160,6 +161,10 @@ def init_db():
_index(db, "devii_tasks", "idx_devii_tasks_owner", ["owner_kind", "owner_id"])
_index(db, "devii_tasks", "idx_devii_tasks_due", ["enabled", "status", "next_run_at"])
_index(db, "devii_lessons", "idx_devii_lessons_owner", ["owner_kind", "owner_id"])
_index(db, "devii_virtual_tools", "idx_devii_vtools_owner", ["owner_kind", "owner_id"])
_index(db, "devii_virtual_tools", "idx_devii_vtools_name", ["owner_kind", "owner_id", "name"])
_index(db, "user_customizations", "idx_user_cust_owner", ["owner_kind", "owner_id"])
_index(db, "user_customizations", "idx_user_cust_scope", ["owner_kind", "owner_id", "scope", "lang"])
_index(db, "gateway_usage_ledger", "idx_gw_usage_time", ["created_at"])
_index(db, "gateway_usage_ledger", "idx_gw_usage_backend_time", ["backend", "created_at"])
_index(db, "gateway_usage_ledger", "idx_gw_usage_model_time", ["model", "created_at"])
@ -169,6 +174,15 @@ def init_db():
_index(db, "jobs", "idx_jobs_kind_status", ["kind", "status"])
_index(db, "jobs", "idx_jobs_owner", ["owner_kind", "owner_id"])
_index(db, "jobs", "idx_jobs_expires", ["expires_at"])
_index(db, "project_forks", "idx_project_forks_source", ["source_project_uid"])
_index(db, "project_forks", "idx_project_forks_forked", ["forked_project_uid"])
_index(db, "instances", "idx_instances_project", ["project_uid"])
_index(db, "instances", "idx_instances_state", ["desired_state", "status"])
_index(db, "instances", "idx_instances_container", ["container_id"])
_index(db, "instances", "idx_instances_ingress", ["ingress_slug"])
_index(db, "instance_events", "idx_instance_events_instance", ["instance_uid", "created_at"])
_index(db, "instance_metrics", "idx_instance_metrics_instance_ts", ["instance_uid", "ts"])
_index(db, "instance_schedules", "idx_instance_schedules_due", ["enabled", "next_run_at"])
if "news" in tables:
for article in db["news"].find(status=None):
@ -230,6 +244,8 @@ def init_db():
"registration_open": "1",
"maintenance_mode": "0",
"maintenance_message": "DevPlace is undergoing scheduled maintenance. Please check back shortly.",
"customization_enabled": "1",
"customization_js_enabled": "1",
}
for key, value in operational_defaults.items():
existing = db["site_settings"].find_one(key=key)
@ -660,6 +676,100 @@ def clear_settings_cache() -> None:
bump_cache_version("settings")
CUSTOMIZATION_GLOBAL_SCOPE = "global"
CUSTOMIZATION_LANGS = ("css", "js")
_customizations_cache = TTLCache(ttl=300)
def _customization_key(owner_kind: str, owner_id: str, page_type: str) -> str:
return f"{owner_kind}\x1f{owner_id}\x1f{page_type}"
def get_custom_overrides(owner_kind: str, owner_id: str, page_type: str) -> dict:
sync_local_cache("customizations", _customizations_cache)
key = _customization_key(owner_kind, owner_id, page_type)
cached = _customizations_cache.get(key)
if cached is not None:
return cached
result = {"css": "", "js": ""}
if "user_customizations" in db.tables:
scopes = (CUSTOMIZATION_GLOBAL_SCOPE, page_type)
rows = db["user_customizations"].find(
owner_kind=owner_kind, owner_id=owner_id, enabled=1,
)
pieces: dict[str, dict[str, str]] = {lang: {} for lang in CUSTOMIZATION_LANGS}
for row in rows:
lang = row.get("lang")
scope = row.get("scope")
if lang in pieces and scope in scopes:
pieces[lang][scope] = row.get("code") or ""
for lang in CUSTOMIZATION_LANGS:
ordered = [pieces[lang][scope] for scope in scopes if scope in pieces[lang]]
result[lang] = "\n".join(part for part in ordered if part.strip())
_customizations_cache.set(key, result)
return result
def get_custom_override(owner_kind: str, owner_id: str, scope: str, lang: str) -> dict | None:
if "user_customizations" not in db.tables:
return None
return db["user_customizations"].find_one(
owner_kind=owner_kind, owner_id=owner_id, scope=scope, lang=lang,
)
def list_custom_overrides(owner_kind: str, owner_id: str) -> list:
if "user_customizations" not in db.tables:
return []
return list(db["user_customizations"].find(owner_kind=owner_kind, owner_id=owner_id))
def set_custom_override(owner_kind: str, owner_id: str, scope: str, lang: str, code: str) -> dict:
from devplacepy.utils import generate_uid
table = get_table("user_customizations")
now = datetime.now(timezone.utc).isoformat()
existing = table.find_one(owner_kind=owner_kind, owner_id=owner_id, scope=scope, lang=lang)
if existing:
record = {
"id": existing["id"],
"code": code,
"enabled": 1,
"updated_at": now,
}
table.update(record, ["id"])
result = {**existing, **record}
else:
result = {
"uid": generate_uid(),
"owner_kind": owner_kind,
"owner_id": owner_id,
"scope": scope,
"lang": lang,
"code": code,
"enabled": 1,
"created_at": now,
"updated_at": now,
}
table.insert(result)
bump_cache_version("customizations")
return result
def delete_custom_override(owner_kind: str, owner_id: str, scope: str | None = None, lang: str | None = None) -> int:
if "user_customizations" not in db.tables:
return 0
criteria: dict = {"owner_kind": owner_kind, "owner_id": owner_id}
if scope is not None:
criteria["scope"] = scope
if lang is not None:
criteria["lang"] = lang
count = db["user_customizations"].count(**criteria)
db["user_customizations"].delete(**criteria)
bump_cache_version("customizations")
return int(count)
_stats_cache = TTLCache(ttl=30)
@ -970,6 +1080,40 @@ def resolve_by_slug(table, slug):
return entry
def record_fork(source_project_uid: str, forked_project_uid: str, forked_by_uid: str) -> None:
from devplacepy.utils import generate_uid
get_table("project_forks").insert({
"uid": generate_uid(),
"source_project_uid": source_project_uid,
"forked_project_uid": forked_project_uid,
"forked_by_uid": forked_by_uid,
"created_at": datetime.now(timezone.utc).isoformat(),
})
def get_fork_parent(forked_project_uid: str) -> dict | None:
if "project_forks" not in db.tables:
return None
relation = get_table("project_forks").find_one(forked_project_uid=forked_project_uid)
if not relation:
return None
return get_table("projects").find_one(uid=relation["source_project_uid"])
def count_forks(source_project_uid: str) -> int:
if "project_forks" not in db.tables:
return 0
return get_table("project_forks").count(source_project_uid=source_project_uid)
def delete_fork_relations(project_uid: str) -> None:
if "project_forks" not in db.tables:
return
forks = get_table("project_forks")
forks.delete(forked_project_uid=project_uid)
forks.delete(source_project_uid=project_uid)
def resolve_object_url(target_type: str, target_uid: str) -> str:
if target_type == "post":
post = resolve_by_slug(get_table("posts"), target_uid)

View File

@ -540,6 +540,33 @@ four ways to sign requests.
destructive=True,
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project.")],
),
endpoint(
id="projects-private",
method="POST",
path="/projects/{project_slug}/private",
title="Set project visibility",
summary="Mark a project you own private (only you and administrators can see it) or public. Send value=1 for private, value=0 for public.",
auth="user",
encoding="form",
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project."),
field("value", "form", "boolean", True, "1", "1 to make the project private, 0 to make it public."),
],
),
endpoint(
id="projects-readonly",
method="POST",
path="/projects/{project_slug}/readonly",
title="Set project read-only",
summary="Mark a project you own read-only so all of its files become immutable (no writes, edits, moves, deletes, or uploads succeed), or writable again. Send value=1 for read-only, value=0 for writable.",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project."),
field("value", "form", "boolean", True, "1", "1 to make the project read-only, 0 to make it writable."),
],
),
endpoint(
id="projects-zip",
method="POST",
@ -584,6 +611,41 @@ four ways to sign requests.
interactive=True,
params=[field("uid", "path", "string", True, "ZIP_JOB_UID", "Zip job uid of a finished job.")],
),
endpoint(
id="projects-fork",
method="POST",
path="/projects/{project_slug}/fork",
title="Queue a project fork",
summary="Start a background job that copies the whole project into a new project owned by you. Returns the job uid and status URL to poll.",
auth="user",
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project to fork."),
field("title", "form", "string", True, "My Fork", "Title for the new forked project."),
],
sample_response={"uid": "FORK_JOB_UID", "status_url": "/forks/FORK_JOB_UID"},
),
endpoint(
id="forks-status",
method="GET",
path="/forks/{uid}",
title="Fork job status",
summary="Poll a fork job. While pending or running project_url is null; once done it points at the new project.",
auth="public",
params=[field("uid", "path", "string", True, "FORK_JOB_UID", "Fork job uid returned when the job was queued.")],
sample_response={
"uid": "FORK_JOB_UID",
"kind": "fork",
"status": "done",
"preferred_name": "My Fork",
"project_uid": "NEW_PROJECT_UID",
"project_url": "/projects/new-project-slug",
"source_project_uid": "SOURCE_PROJECT_UID",
"error": None,
"item_count": 12,
"created_at": "2026-06-09T10:00:00+00:00",
"completed_at": "2026-06-09T10:00:05+00:00",
},
),
endpoint(
id="gists-list",
method="GET",
@ -1033,6 +1095,91 @@ sign requests.
notes=["Owner only; non-owners get `403`. Invalid paths return `400`."],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/main.py", "type": "file"}},
),
endpoint(
id="project-files-lines",
method="GET",
path="/projects/{project_slug}/files/lines",
title="Read a line range",
summary="Read a 1-indexed inclusive line range of a text file. Returns lines plus total_lines for targeting edits.",
auth="public",
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "query", "string", True, "src/main.py", "Relative file path."),
field("start", "query", "integer", False, "1", "First line, 1-indexed (default 1)."),
field("end", "query", "integer", False, "50", "Last line inclusive; omit or -1 for end of file."),
],
notes=["Text files only; binary, directory, or missing paths return `404`."],
sample_response={"path": "src/main.py", "start": 1, "end": 2, "total_lines": 2, "lines": ["import os", "print(os.getcwd())"], "content": "import os\nprint(os.getcwd())"},
),
endpoint(
id="project-files-replace-lines",
method="POST",
path="/projects/{project_slug}/files/replace-lines",
title="Replace a line range",
summary="Replace lines start..end (inclusive) with new content; empty content deletes the range. Leaves the rest of the file untouched.",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "form", "string", True, "src/main.py", "Relative file path."),
field("start", "form", "integer", True, "10", "First line to replace (1-indexed)."),
field("end", "form", "integer", True, "12", "Last line to replace (inclusive)."),
field("content", "form", "textarea", False, "new code", "Replacement text (empty deletes the range)."),
],
notes=["Owner only. The preferred way to edit a large file; avoids rewriting the whole file."],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/main.py", "type": "file"}},
),
endpoint(
id="project-files-insert-lines",
method="POST",
path="/projects/{project_slug}/files/insert-lines",
title="Insert lines",
summary="Insert content before a 1-indexed line. Use at=1 to prepend and at=total_lines+1 to append.",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "form", "string", True, "src/main.py", "Relative file path."),
field("at", "form", "integer", True, "1", "Insert before this 1-indexed line."),
field("content", "form", "textarea", True, "# header", "Text to insert."),
],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/main.py", "type": "file"}},
),
endpoint(
id="project-files-delete-lines",
method="POST",
path="/projects/{project_slug}/files/delete-lines",
title="Delete a line range",
summary="Delete lines start..end (inclusive) from a text file.",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "form", "string", True, "src/main.py", "Relative file path."),
field("start", "form", "integer", True, "5", "First line to delete (1-indexed)."),
field("end", "form", "integer", True, "7", "Last line to delete (inclusive)."),
],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/main.py", "type": "file"}},
),
endpoint(
id="project-files-append",
method="POST",
path="/projects/{project_slug}/files/append",
title="Append to a file",
summary="Append content as new lines at the end of a text file; grow a large file across calls without resending it.",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "form", "string", True, "log.txt", "Relative file path."),
field("content", "form", "textarea", True, "next chunk", "Text to append."),
],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "log.txt", "type": "file"}},
),
endpoint(
id="project-files-upload",
method="POST",
@ -1110,6 +1257,107 @@ sign requests.
),
],
},
{
"slug": "containers",
"title": "Container Manager",
"intro": """
# Container Manager
Build versioned Docker images for a project and run supervised container instances. Every endpoint is
**administrator only** (running arbitrary Dockerfiles with docker socket access is root-equivalent).
Mutations flip desired state; a single reconciler converges containers to it.
""",
"endpoints": [
endpoint(
id="containers-page", method="GET", path="/projects/{project_slug}/containers",
title="Container manager page", summary="The admin per-project container manager UI (Dockerfiles, builds, instance creation).",
auth="admin", interactive=True,
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid.")],
),
endpoint(
id="containers-admin-index", method="GET", path="/admin/containers",
title="Admin containers list", summary="The admin Containers section: every instance across all projects, each linking to its detail page.",
auth="admin", interactive=True,
),
endpoint(
id="containers-admin-data", method="GET", path="/admin/containers/data",
title="Admin containers list data", summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
auth="admin",
sample_response={"instances": [{"uid": "INSTANCE_UID", "name": "staging", "status": "running",
"project_slug": "PROJECT_SLUG", "project_title": "My Project",
"ingress_slug": "my-service", "restart_policy": "always"}]},
),
endpoint(
id="containers-admin-instance", method="GET", path="/admin/containers/{uid}",
title="Instance detail page", summary="The dedicated detail page for one instance (lifecycle, logs, metrics, terminal, schedules, ingress, sync).",
auth="admin", interactive=True,
params=[field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid.")],
),
endpoint(
id="containers-create-instance", method="POST",
path="/projects/{project_slug}/containers/instances",
title="Create an instance", summary="Create and (by default) start an instance; it runs the shared ppy image with the project workspace mounted at /app.",
auth="admin", encoding="form", destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("name", "form", "string", True, "staging", "Instance name."),
field("boot_command", "form", "string", False, "python app.py", "Optional boot command."),
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
field("ports", "form", "string", False, "80", "Port maps. Bare container port auto-assigns a unique host port above 20000; host:container pins one."),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
field("restart_policy", "form", "enum", False, "never", "Restart policy.",
["never", "always", "on-failure", "unless-stopped"]),
field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
field("ingress_port", "form", "integer", False, "8899", "Container port to publish (must be a mapped port)."),
],
),
endpoint(
id="containers-ingress",
method="GET",
path="/p/{slug}",
title="Container ingress proxy",
summary="Public reverse proxy (HTTP and WebSocket) to a running instance published via ingress_slug. The /p/<slug> prefix is stripped before forwarding.",
auth="public",
interactive=True,
params=[field("slug", "path", "string", True, "my-service", "The instance's ingress_slug.")],
),
endpoint(
id="containers-instance-action", method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/{action}",
title="Instance lifecycle", summary="start, stop, restart, pause, or resume an instance (flips desired state).",
auth="admin", destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("action", "path", "enum", True, "start", "Lifecycle action.",
["start", "stop", "restart", "pause", "resume"]),
],
),
endpoint(
id="containers-instance-logs", method="GET",
path="/projects/{project_slug}/containers/instances/{uid}/logs",
title="Instance logs", summary="Recent docker logs of a running instance.",
auth="admin",
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("tail", "query", "integer", False, "200", "Number of lines."),
],
sample_response={"logs": "..."},
),
endpoint(
id="containers-instance-sync", method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/sync",
title="Sync workspace", summary="Import the container /app workspace back into the project files.",
auth="admin", destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
],
),
],
},
{
"slug": "push",
"title": "Web Push",

View File

@ -17,13 +17,15 @@ from devplacepy.schemas import ValidationErrorOut
from fastapi.responses import JSONResponse
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.routers import auth, feed, posts, comments, projects, project_files, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads, push, leaderboard, reactions, bookmarks, polls, docs, openai_gateway, devii, zips
from devplacepy.routers import auth, feed, posts, comments, projects, project_files, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads, push, leaderboard, reactions, bookmarks, polls, docs, openai_gateway, devii, zips, forks, containers, containers_admin, proxy
from devplacepy.services.manager import service_manager
from devplacepy.services.news import NewsService
from devplacepy.services.bot import BotsService
from devplacepy.services.openai_gateway import GatewayService
from devplacepy.services.devii import DeviiService
from devplacepy.services.jobs.zip_service import ZipService
from devplacepy.services.jobs.fork_service import ForkService
from devplacepy.services.containers.service import ContainerService
logging.basicConfig(
level=logging.INFO,
@ -180,6 +182,10 @@ app.include_router(news.router, prefix="/news")
app.include_router(services_router.router, prefix="/admin/services")
app.include_router(uploads.router, prefix="/uploads")
app.include_router(zips.router, prefix="/zips")
app.include_router(forks.router, prefix="/forks")
app.include_router(containers.router, prefix="/projects")
app.include_router(containers_admin.router, prefix="/admin/containers")
app.include_router(proxy.router, prefix="/p")
@app.middleware("http")
@ -247,6 +253,8 @@ async def startup():
service_manager.register(GatewayService())
service_manager.register(DeviiService())
service_manager.register(ZipService())
service_manager.register(ForkService())
service_manager.register(ContainerService())
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
if acquire_service_lock():
service_manager.set_lock_owner(True)

View File

@ -1,5 +1,5 @@
from datetime import datetime
from typing import Literal
from typing import Literal, Optional
from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.constants import TOPICS, REACTION_EMOJI
@ -150,6 +150,7 @@ class ProjectForm(BaseModel):
project_type: Literal["game", "game_asset", "software", "mobile_app", "website"] = "software"
platforms: str = Field(default="", max_length=500)
status: str = Field(default="In Development", max_length=100)
is_private: bool = False
attachment_uids: list[str] = []
@field_validator("release_date", "demo_date", mode="before")
@ -158,6 +159,14 @@ class ProjectForm(BaseModel):
return normalize_european_date(value)
class ProjectFlagForm(BaseModel):
value: bool = False
class ForkForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
class ProjectFileWriteForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
content: str = Field(default="", max_length=400000)
@ -176,6 +185,58 @@ class ProjectFileDeleteForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
class ProjectFileReplaceLinesForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
start: int = Field(ge=1)
end: int = Field(ge=0)
content: str = Field(default="", max_length=400000)
class ProjectFileInsertLinesForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
at: int = Field(ge=1)
content: str = Field(default="", max_length=400000)
class ProjectFileDeleteLinesForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
start: int = Field(ge=1)
end: int = Field(ge=1)
class ProjectFileAppendForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
content: str = Field(default="", max_length=400000)
class ContainerInstanceForm(BaseModel):
name: str = Field(min_length=1, max_length=64)
boot_command: str = Field(default="", max_length=500)
env: str = Field(default="", max_length=10000)
cpu_limit: str = Field(default="", max_length=16)
mem_limit: str = Field(default="", max_length=16)
ports: str = Field(default="", max_length=500)
volumes: str = Field(default="", max_length=2000)
restart_policy: str = Field(default="never", max_length=20)
autostart: bool = True
ingress_slug: str = Field(default="", max_length=64)
ingress_port: Optional[int] = Field(default=None, ge=1, le=65535)
class ContainerExecForm(BaseModel):
command: str = Field(min_length=1, max_length=2000)
class ContainerScheduleForm(BaseModel):
action: str = Field(max_length=10)
kind: str = Field(max_length=10)
cron: str = Field(default="", max_length=120)
run_at: str = Field(default="", max_length=40)
delay_seconds: Optional[int] = Field(default=None, ge=1)
every_seconds: Optional[int] = Field(default=None, ge=1)
max_runs: Optional[int] = Field(default=None, ge=1)
class MessageForm(BaseModel):
content: str = Field(min_length=1, max_length=2000)
receiver_uid: str = Field(min_length=1)
@ -248,6 +309,7 @@ class AdminSettingsForm(BaseModel):
site_name: str = Field(default="", max_length=200)
site_description: str = Field(default="", max_length=500)
site_tagline: str = Field(default="", max_length=500)
site_url: str = Field(default="", max_length=300)
max_upload_size_mb: str = Field(default="", max_length=10)
allowed_file_types: str = Field(default="", max_length=1000)
max_attachments_per_resource: str = Field(default="", max_length=10)

View File

@ -1,6 +1,7 @@
# retoor <retoor@molodetz.nl>
import logging
import os
import shutil
from datetime import datetime, timezone
from pathlib import Path
@ -42,6 +43,18 @@ class ProjectFileError(ValueError):
pass
def is_readonly(project_uid: str) -> bool:
if "projects" not in db.tables:
return False
project = get_table("projects").find_one(uid=project_uid)
return bool(project and project.get("read_only"))
def _guard_writable(project_uid: str) -> None:
if is_readonly(project_uid):
raise ProjectFileError("project is read-only; files cannot be modified")
def normalize_path(raw) -> str:
if raw is None:
raise ProjectFileError("path is required")
@ -147,6 +160,7 @@ def ensure_dirs(project_uid: str, user: dict, dir_path: str) -> None:
def make_dir(project_uid: str, user: dict, raw_path: str) -> dict:
_guard_writable(project_uid)
path = normalize_path(raw_path)
existing = get_node(project_uid, path)
if existing is not None:
@ -158,6 +172,7 @@ def make_dir(project_uid: str, user: dict, raw_path: str) -> dict:
def write_text_file(project_uid: str, user: dict, raw_path: str, content: str) -> dict:
_guard_writable(project_uid)
path = normalize_path(raw_path)
content = content or ""
if len(content) > MAX_TEXT_CHARS:
@ -224,6 +239,7 @@ def _unlink_blob(node: dict) -> None:
def store_upload(project_uid: str, user: dict, dir_path: str, filename: str, data: bytes) -> dict:
_guard_writable(project_uid)
max_bytes = get_int_setting("max_upload_size_mb", 10) * 1024 * 1024
if len(data) > max_bytes:
raise ProjectFileError(f"file exceeds the {get_int_setting('max_upload_size_mb', 10)}MB limit")
@ -282,6 +298,7 @@ def _descendants(project_uid: str, path: str) -> list:
def move_node(project_uid: str, user: dict, raw_from: str, raw_to: str) -> dict:
_guard_writable(project_uid)
src_path = normalize_path(raw_from)
dst_path = normalize_path(raw_to)
if src_path == dst_path:
@ -312,6 +329,7 @@ def move_node(project_uid: str, user: dict, raw_from: str, raw_to: str) -> dict:
def delete_node(project_uid: str, raw_path: str) -> None:
_guard_writable(project_uid)
path = normalize_path(raw_path)
node = get_node(project_uid, path)
if node is None:
@ -357,6 +375,44 @@ def read_file(project_uid: str, raw_path: str) -> dict:
return payload
IMPORT_SKIP_NAMES = {
".git", "node_modules", "__pycache__", ".venv", "venv",
".mypy_cache", ".pytest_cache", ".DS_Store", ".idea", ".cache",
}
IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024
def import_from_dir(project_uid: str, src_dir, user: dict, *, skip_names=None) -> int:
_guard_writable(project_uid)
src = Path(src_dir).resolve()
if not src.is_dir():
raise ProjectFileError("source directory does not exist")
skip = set(skip_names) if skip_names is not None else set(IMPORT_SKIP_NAMES)
imported = 0
for root, dirs, files in os.walk(src):
dirs[:] = [d for d in sorted(dirs) if d not in skip]
for name in sorted(files):
if name in skip:
continue
full = Path(root) / name
if full.is_symlink() or not full.is_file():
continue
try:
if full.stat().st_size > IMPORT_MAX_FILE_BYTES:
continue
relative = full.relative_to(src).as_posix()
path = normalize_path(relative)
data = full.read_bytes()
except (OSError, ProjectFileError):
continue
try:
store_upload(project_uid, user, _parent_of(path), _name_of(path), data)
imported += 1
except ProjectFileError:
continue
return imported
def export_to_dir(project_uid: str, subpath: str, dest_dir) -> int:
dest = Path(dest_dir).resolve()
dest.mkdir(parents=True, exist_ok=True)
@ -377,6 +433,8 @@ def export_to_dir(project_uid: str, subpath: str, dest_dir) -> int:
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
if target.is_symlink() or target.is_file():
target.unlink()
if row.get("is_binary") and row.get("stored_name") and row.get("directory"):
shutil.copyfile(PROJECT_FILES_DIR / row["directory"] / row["stored_name"], target)
else:
@ -385,6 +443,119 @@ def export_to_dir(project_uid: str, subpath: str, dest_dir) -> int:
return written
def _load_text_node(project_uid: str, raw_path: str) -> tuple[dict, str]:
path = normalize_path(raw_path)
node = get_node(project_uid, path)
if node is None:
raise ProjectFileError(f"'{path}' does not exist")
if node["type"] != "file":
raise ProjectFileError(f"'{path}' is a directory, not a file")
if node.get("is_binary"):
raise ProjectFileError(f"'{path}' is a binary file and has no editable lines")
return node, node.get("content") or ""
def _split_lines(content: str) -> tuple[list, bool]:
if content == "":
return [], False
trailing = content.endswith("\n")
body = content[:-1] if trailing else content
return body.split("\n"), trailing
def _join_lines(lines: list, trailing: bool) -> str:
if not lines:
return ""
return "\n".join(lines) + ("\n" if trailing else "")
def _replacement_lines(content: str) -> list:
return _split_lines(content)[0] if content else []
def _save_text(project_uid: str, node: dict, new_content: str) -> dict:
if len(new_content) > MAX_TEXT_CHARS:
raise ProjectFileError(f"file content exceeds the {MAX_TEXT_CHARS}-character limit")
_table().update({
"uid": node["uid"],
"content": new_content,
"is_binary": 0,
"stored_name": None,
"directory": None,
"mime_type": "text/plain",
"size": len(new_content.encode("utf-8")),
"updated_at": _now(),
}, ["uid"])
return get_node(project_uid, node["path"])
def read_lines(project_uid: str, raw_path: str, start: int = 1, end=None) -> dict:
node, content = _load_text_node(project_uid, raw_path)
lines, _ = _split_lines(content)
total = len(lines)
start = max(1, int(start))
end = total if end is None else int(end)
if end < 0:
end = total
end = min(end, total)
selected = lines[start - 1:end] if start <= total and end >= start else []
return {
"path": node["path"],
"start": start,
"end": end if selected else start - 1,
"total_lines": total,
"lines": selected,
"content": "\n".join(selected),
}
def replace_lines(project_uid: str, raw_path: str, start: int, end: int, content: str) -> dict:
_guard_writable(project_uid)
node, original = _load_text_node(project_uid, raw_path)
lines, trailing = _split_lines(original)
total = len(lines)
start = max(1, int(start))
end = int(end)
if start > total:
raise ProjectFileError(f"start line {start} is past the end of the file ({total} lines)")
end = min(max(end, start - 1), total)
new_lines = lines[:start - 1] + _replacement_lines(content) + lines[end:]
return _save_text(project_uid, node, _join_lines(new_lines, trailing if new_lines else False))
def insert_lines(project_uid: str, raw_path: str, at: int, content: str) -> dict:
_guard_writable(project_uid)
node, original = _load_text_node(project_uid, raw_path)
lines, trailing = _split_lines(original)
total = len(lines)
at = max(1, int(at))
if at > total + 1:
at = total + 1
new_lines = lines[:at - 1] + _replacement_lines(content) + lines[at - 1:]
return _save_text(project_uid, node, _join_lines(new_lines, trailing if total else False))
def delete_lines(project_uid: str, raw_path: str, start: int, end: int) -> dict:
_guard_writable(project_uid)
node, original = _load_text_node(project_uid, raw_path)
lines, trailing = _split_lines(original)
total = len(lines)
start = max(1, int(start))
end = min(int(end), total)
if start > total or end < start:
raise ProjectFileError(f"no lines in range {start}-{end} (file has {total} lines)")
new_lines = lines[:start - 1] + lines[end:]
return _save_text(project_uid, node, _join_lines(new_lines, trailing if new_lines else False))
def append_lines(project_uid: str, raw_path: str, content: str) -> dict:
_guard_writable(project_uid)
node, original = _load_text_node(project_uid, raw_path)
lines, trailing = _split_lines(original)
new_lines = lines + _replacement_lines(content)
return _save_text(project_uid, node, _join_lines(new_lines, trailing if lines else False))
def delete_all_project_files(project_uid: str) -> None:
if "project_files" not in db.tables:
return

View File

@ -0,0 +1,297 @@
# retoor <retoor@molodetz.nl>
import asyncio
import fcntl
import json
import logging
import os
import pty
import re
import signal
import struct
import termios
from typing import Annotated
from fastapi import APIRouter, Form, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.models import (
ContainerExecForm,
ContainerInstanceForm,
ContainerScheduleForm,
)
from devplacepy.responses import action_result, json_error, respond
from devplacepy.schemas import ContainersOut
from devplacepy.seo import base_seo_context
from devplacepy.utils import _user_from_session, is_admin, not_found, require_admin
from devplacepy.services.manager import service_manager
from devplacepy.services.containers import api, store
from devplacepy.services.containers.api import ContainerError
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.devii.tasks.schedule import Schedule, from_iso
logger = logging.getLogger(__name__)
router = APIRouter()
def _project(project_slug: str) -> dict:
project = resolve_by_slug(get_table("projects"), project_slug)
if not project:
raise not_found("Project not found")
return project
def _slug(project: dict) -> str:
return project["slug"] or project["uid"]
def _fail(exc: ContainerError):
return json_error(400, str(exc))
def _instance(project: dict, uid: str) -> dict:
inst = store.get_instance(uid)
if not inst or inst["project_uid"] != project["uid"]:
raise not_found("Instance not found")
return inst
@router.get("/{project_slug}/containers", response_class=HTMLResponse)
async def containers_page(request: Request, project_slug: str):
user = require_admin(request)
project = _project(project_slug)
slug = _slug(project)
return respond(request, "containers.html", {
**base_seo_context(request, title=f"Containers - {project.get('title', 'Project')}",
description="Container manager", robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": project.get("title", "Project"), "url": f"/projects/{slug}"},
{"name": "Containers", "url": f"/projects/{slug}/containers"},
]),
"request": request, "user": user, "project": project,
"instances": store.list_instances(project["uid"]),
}, model=ContainersOut)
@router.get("/{project_slug}/containers/data")
async def containers_data(request: Request, project_slug: str):
require_admin(request)
project = _project(project_slug)
return JSONResponse({
"instances": store.list_instances(project["uid"]),
})
# ---------------- instances ----------------
@router.post("/{project_slug}/containers/instances")
async def create_instance(request: Request, project_slug: str, data: Annotated[ContainerInstanceForm, Form()]):
user = require_admin(request)
project = _project(project_slug)
try:
inst = await api.create_instance(
project, name=data.name, boot_command=data.boot_command, env=data.env,
cpu_limit=data.cpu_limit, mem_limit=data.mem_limit, ports=data.ports, volumes=data.volumes,
restart_policy=data.restart_policy, autostart=data.autostart,
ingress_slug=data.ingress_slug, ingress_port=data.ingress_port, actor=("user", user["uid"]))
except ContainerError as exc:
return _fail(exc)
return action_result(request, f"/projects/{_slug(project)}/containers", data={"instance": inst})
@router.get("/{project_slug}/containers/instances/{uid}")
async def instance_detail(request: Request, project_slug: str, uid: str):
require_admin(request)
project = _project(project_slug)
inst = _instance(project, uid)
return JSONResponse({
"instance": inst, "events": store.list_events(uid),
"schedules": store.list_schedules(uid), "stats": api.instance_stats(uid),
"runtime": api.instance_runtime(inst),
})
_ACTIONS = {
"start": store.DESIRED_RUNNING, "stop": store.DESIRED_STOPPED,
"pause": store.DESIRED_PAUSED, "resume": store.DESIRED_RUNNING,
}
@router.post("/{project_slug}/containers/instances/{uid}/delete")
async def delete_instance(request: Request, project_slug: str, uid: str):
user = require_admin(request)
project = _project(project_slug)
inst = _instance(project, uid)
api.mark_for_removal(inst, actor=("user", user["uid"]))
return action_result(request, f"/projects/{_slug(project)}/containers", data={"uid": uid})
@router.post("/{project_slug}/containers/instances/{uid}/exec")
async def instance_exec(request: Request, project_slug: str, uid: str, data: Annotated[ContainerExecForm, Form()]):
user = require_admin(request)
project = _project(project_slug)
inst = _instance(project, uid)
if not inst.get("container_id"):
return json_error(400, "instance is not running")
result = await get_backend().exec(inst["container_id"], ["/bin/sh", "-c", data.command])
store.record_event(inst, "exec", "user", user["uid"], {"command": data.command, "exit_code": result.exit_code})
return JSONResponse({"exit_code": result.exit_code, "output": result.output})
@router.get("/{project_slug}/containers/instances/{uid}/logs")
async def instance_logs(request: Request, project_slug: str, uid: str, tail: int = 200):
require_admin(request)
project = _project(project_slug)
inst = _instance(project, uid)
if not inst.get("container_id"):
return JSONResponse({"logs": ""})
lines: list = []
async def collect(line: str) -> None:
lines.append(line)
await get_backend().logs(inst["container_id"], follow=False, tail=max(1, min(tail, 2000)), on_log=collect)
return JSONResponse({"logs": "\n".join(lines)})
@router.get("/{project_slug}/containers/instances/{uid}/metrics")
async def instance_metrics(request: Request, project_slug: str, uid: str):
require_admin(request)
project = _project(project_slug)
inst = _instance(project, uid)
return JSONResponse({"metrics": store.recent_metrics(uid), "stats": api.instance_stats(uid)})
@router.post("/{project_slug}/containers/instances/{uid}/sync")
async def instance_sync(request: Request, project_slug: str, uid: str):
user = require_admin(request)
project = _project(project_slug)
inst = _instance(project, uid)
try:
count = await api.sync_workspace(inst, user)
except ContainerError as exc:
return _fail(exc)
return action_result(request, f"/projects/{_slug(project)}/containers", data={"imported": count})
@router.post("/{project_slug}/containers/instances/{uid}/schedules")
async def create_schedule(request: Request, project_slug: str, uid: str, data: Annotated[ContainerScheduleForm, Form()]):
require_admin(request)
project = _project(project_slug)
inst = _instance(project, uid)
try:
schedule = Schedule(
kind=data.kind, cron=data.cron or None,
run_at=from_iso(data.run_at) if data.run_at else None,
delay_seconds=data.delay_seconds, every_seconds=data.every_seconds, max_runs=data.max_runs)
sched = api.add_schedule(inst, data.action, schedule)
except (ContainerError, ValueError) as exc:
return json_error(400, str(exc))
return action_result(request, f"/projects/{_slug(project)}/containers", data={"schedule": sched})
@router.post("/{project_slug}/containers/instances/{uid}/schedules/{sid}/delete")
async def delete_schedule(request: Request, project_slug: str, uid: str, sid: str):
require_admin(request)
project = _project(project_slug)
_instance(project, uid)
store.delete_schedule(sid)
return action_result(request, f"/projects/{_slug(project)}/containers", data={"uid": sid})
@router.post("/{project_slug}/containers/instances/{uid}/start")
@router.post("/{project_slug}/containers/instances/{uid}/stop")
@router.post("/{project_slug}/containers/instances/{uid}/pause")
@router.post("/{project_slug}/containers/instances/{uid}/resume")
@router.post("/{project_slug}/containers/instances/{uid}/restart")
async def instance_action(request: Request, project_slug: str, uid: str):
user = require_admin(request)
project = _project(project_slug)
inst = _instance(project, uid)
actor = ("user", user["uid"])
action = request.url.path.rsplit("/", 1)[-1]
if action == "restart":
api.request_restart(inst, actor=actor)
else:
api.set_desired_state(inst, _ACTIONS[action], actor=actor)
return action_result(request, f"/projects/{_slug(project)}/containers", data={"uid": uid, "action": action})
@router.websocket("/{project_slug}/containers/instances/{uid}/exec/ws")
async def instance_exec_ws(websocket: WebSocket, project_slug: str, uid: str):
await websocket.accept()
user = _user_from_session(websocket)
if not user or not is_admin(user):
await websocket.close(code=1008)
return
if not service_manager.owns_lock():
await websocket.close(code=1013)
return
project = resolve_by_slug(get_table("projects"), project_slug)
inst = store.get_instance(uid)
if not project or not inst or inst["project_uid"] != project["uid"] or not inst.get("container_id"):
await websocket.close(code=1011)
return
master, slave = pty.openpty()
session = (websocket.query_params.get("session") or "").strip()
if session and re.match(r"^[A-Za-z0-9_-]{1,64}$", session):
shell_cmd = (f"if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s {session}; "
"elif command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi")
else:
shell_cmd = "if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi"
proc = await asyncio.create_subprocess_exec(
"docker", "exec", "-it", inst["container_id"], "/bin/sh", "-c", shell_cmd,
stdin=slave, stdout=slave, stderr=slave, start_new_session=True)
os.close(slave)
loop = asyncio.get_event_loop()
async def pump_out():
try:
while True:
data = await loop.run_in_executor(None, os.read, master, 4096)
if not data:
break
await websocket.send_text(data.decode("utf-8", "replace"))
except Exception:
pass
def set_winsize(rows: int, cols: int) -> None:
try:
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
except OSError:
pass
try:
proc.send_signal(signal.SIGWINCH)
except (ProcessLookupError, OSError, ValueError):
pass
reader = asyncio.create_task(pump_out())
try:
while True:
text = await websocket.receive_text()
if text[:1] == "{":
try:
control = json.loads(text)
except ValueError:
control = None
if isinstance(control, dict) and control.get("type") == "resize":
rows = int(control.get("rows") or 0)
cols = int(control.get("cols") or 0)
if rows > 0 and cols > 0:
await loop.run_in_executor(None, set_winsize, rows, cols)
continue
await loop.run_in_executor(None, os.write, master, text.encode("utf-8"))
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
reader.cancel()
try:
proc.kill()
except ProcessLookupError:
pass
os.close(master)

View File

@ -0,0 +1,105 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.database import db, get_table
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.containers import api, store
from devplacepy.templating import templates
from devplacepy.utils import not_found, require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
def _project_index(project_uids: set) -> dict:
if not project_uids or "projects" not in db.tables:
return {}
table = get_table("projects")
rows = table.find(table.table.columns.uid.in_(list(project_uids)))
return {row["uid"]: row for row in rows}
def _decorate(instances: list) -> list:
index = _project_index({inst["project_uid"] for inst in instances})
decorated = []
for inst in instances:
project = index.get(inst["project_uid"], {})
row = dict(inst)
row["project_title"] = project.get("title", "")
row["project_slug"] = project.get("slug") or project.get("uid") or ""
decorated.append(row)
decorated.sort(key=lambda r: r.get("created_at", ""), reverse=True)
return decorated
@router.get("", response_class=HTMLResponse)
async def containers_index(request: Request):
admin = require_admin(request)
instances = _decorate(store.all_instances())
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Containers - Admin",
description="Manage container instances across all projects.",
robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Containers", "url": "/admin/containers"},
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "containers_admin.html", {
**seo_ctx,
"request": request,
"user": admin,
"instances": instances,
"admin_section": "containers",
})
@router.get("/data")
async def containers_index_data(request: Request):
require_admin(request)
return JSONResponse({"instances": _decorate(store.all_instances())})
@router.get("/{uid}", response_class=HTMLResponse)
async def container_instance_page(request: Request, uid: str):
admin = require_admin(request)
inst = store.get_instance(uid)
if not inst:
raise not_found("Instance not found")
project = get_table("projects").find_one(uid=inst["project_uid"]) or {}
project_slug = project.get("slug") or project.get("uid") or ""
base = site_url(request)
seo_ctx = base_seo_context(
request,
title=f"{inst['name']} - Containers",
description=f"Container instance {inst['name']}.",
robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Containers", "url": "/admin/containers"},
{"name": inst["name"], "url": f"/admin/containers/{inst['uid']}"},
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "containers_instance.html", {
**seo_ctx,
"request": request,
"user": admin,
"instance": inst,
"project": project,
"project_slug": project_slug,
"events": store.list_events(inst["uid"]),
"schedules": store.list_schedules(inst["uid"]),
"stats": api.instance_stats(inst["uid"]),
"runtime": api.instance_runtime(inst),
"admin_section": "containers",
})

View File

@ -7,7 +7,9 @@ import uuid_utils
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, RedirectResponse
from devplacepy.constants import DEVII_GUEST_COOKIE
from devplacepy.database import get_int_setting
from devplacepy.seo import site_url
from devplacepy.services.manager import service_manager
from devplacepy.templating import templates
from devplacepy.utils import _user_from_api_key, _user_from_session, get_current_user, is_admin
@ -16,7 +18,7 @@ logger = logging.getLogger("devii.router")
router = APIRouter()
GUEST_COOKIE = "devii_guest"
GUEST_COOKIE = DEVII_GUEST_COOKIE
GUEST_COOKIE_MAX_AGE = 31536000
@ -74,6 +76,7 @@ async def devii_session(request: Request):
"username": user.get("username") if user else "guest",
"enabled": bool(svc and svc.is_enabled()),
"guestsEnabled": bool(svc and svc.guests_enabled()),
"baseUrl": site_url(request),
}
response = JSONResponse(payload)
if not user and not request.cookies.get(GUEST_COOKIE):
@ -152,7 +155,7 @@ async def devii_ws(websocket: WebSocket):
await websocket.close(code=1013)
return
if not service_manager.owns_lock():
await websocket.close(code=1013)
await websocket.close(code=4013)
return
owner_kind, owner_id, username, api_key, owner_is_admin = _resolve_ws_owner(websocket)
if owner_kind == "guest" and not svc.guests_enabled():
@ -183,6 +186,8 @@ async def devii_ws(websocket: WebSocket):
continue
session.spawn_turn(text)
elif kind == "reset":
await session.reset()
elif kind == "clear":
await session.reset_conversation()
elif kind == "visibility":
session.set_visibility(websocket, bool(data.get("visible", True)), bool(data.get("focused", False)))

View File

@ -13,6 +13,7 @@ router = APIRouter()
SECTION_GENERAL = "General"
SECTION_COMPONENTS = "Components"
SECTION_STYLES = "Styles"
SECTION_API = "API"
SECTION_ADMIN = "Administration"
SECTION_DEVII = "Devii internals"
@ -38,6 +39,12 @@ DOCS_PAGES = [
{"slug": "component-devii-terminal", "title": "devii-terminal", "kind": "prose", "section": SECTION_COMPONENTS},
{"slug": "component-devii-avatar", "title": "devii-avatar", "kind": "prose", "section": SECTION_COMPONENTS},
{"slug": "component-emoji-picker", "title": "emoji-picker", "kind": "prose", "section": SECTION_COMPONENTS},
# Styles - the design system: colors, layout, responsiveness, and hard structural rules (everyone)
{"slug": "styles", "title": "Design system overview", "kind": "prose", "section": SECTION_STYLES},
{"slug": "styles-colors", "title": "Colors", "kind": "prose", "section": SECTION_STYLES},
{"slug": "styles-layout", "title": "Layout", "kind": "prose", "section": SECTION_STYLES},
{"slug": "styles-responsiveness", "title": "Responsiveness", "kind": "prose", "section": SECTION_STYLES},
{"slug": "styles-consistency", "title": "Consistency rules", "kind": "prose", "section": SECTION_STYLES},
# API - developer reference (everyone); admin-only groups are routed to Administration below
{"slug": "authentication", "title": "Authentication", "kind": "prose", "section": SECTION_API},
*[{**page, "section": (SECTION_ADMIN if page.get("admin") else SECTION_API)} for page in api_doc_pages()],
@ -57,6 +64,7 @@ DOCS_PAGES = [
{"slug": "services-news", "title": "NewsService", "kind": "prose", "admin": True, "section": SECTION_SERVICES},
{"slug": "services-bots", "title": "BotsService", "kind": "prose", "admin": True, "section": SECTION_SERVICES},
{"slug": "services-zip", "title": "ZipService", "kind": "prose", "admin": True, "section": SECTION_SERVICES},
{"slug": "services-containers", "title": "Container Manager", "kind": "prose", "admin": True, "section": SECTION_SERVICES},
# Architecture - platform design, structure, and development process (admins only)
{"slug": "architecture", "title": "Architecture overview", "kind": "prose", "admin": True, "section": SECTION_ARCH},
{"slug": "architecture-backend", "title": "Backend and request pipeline", "kind": "prose", "admin": True, "section": SECTION_ARCH},

View File

@ -0,0 +1,39 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from devplacepy.services.jobs import queue
from devplacepy.schemas import ForkJobOut
from devplacepy.utils import not_found
logger = logging.getLogger(__name__)
router = APIRouter()
def _status_payload(job: dict) -> dict:
result = job.get("result", {})
done = job.get("status") == queue.DONE
return {
"uid": job.get("uid", ""),
"kind": job.get("kind", ""),
"status": job.get("status", ""),
"preferred_name": job.get("preferred_name"),
"project_uid": result.get("project_uid") if done else None,
"project_url": result.get("project_url") if done else None,
"source_project_uid": result.get("source_project_uid") if done else None,
"error": job.get("error") or None,
"item_count": int(job.get("item_count") or 0),
"created_at": job.get("created_at"),
"completed_at": job.get("completed_at") or None,
}
@router.get("/{uid}")
async def fork_status(request: Request, uid: str):
job = queue.get_job(uid)
if not job or job.get("kind") != "fork":
raise not_found("Job not found")
return JSONResponse(ForkJobOut.model_validate(_status_payload(job)).model_dump(mode="json"))

View File

@ -121,7 +121,10 @@ async def profile_page(request: Request, username: str, tab: str = "posts", page
item["poll"] = polls_map.get(uid)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
can_see_private = bool(current_user and (current_user["uid"] == profile_user["uid"] or current_user.get("role") == "Admin"))
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
if not can_see_private:
projects = [p for p in projects if not p.get("is_private")]
gists_raw = list(get_table("gists").find(user_uid=profile_user["uid"]))
gists = []
for g in gists_raw:

View File

@ -7,12 +7,16 @@ from fastapi import APIRouter, Request, Form
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.content import is_owner
from devplacepy.content import is_owner, can_view_project
from devplacepy.models import (
ProjectFileWriteForm,
ProjectFileMkdirForm,
ProjectFileMoveForm,
ProjectFileDeleteForm,
ProjectFileReplaceLinesForm,
ProjectFileInsertLinesForm,
ProjectFileDeleteLinesForm,
ProjectFileAppendForm,
)
from devplacepy.responses import respond, action_result, wants_json, json_error
from devplacepy.schemas import ProjectFilesOut
@ -33,6 +37,13 @@ def _load_project(project_slug: str) -> dict:
return project
def _load_viewable_project(request: Request, project_slug: str) -> dict:
project = _load_project(project_slug)
if not can_view_project(project, get_current_user(request)):
raise not_found("Project not found")
return project
def _files_url(project: dict) -> str:
return f"/projects/{project['slug'] or project['uid']}/files"
@ -52,7 +63,7 @@ def _fail(request: Request, project: dict, exc: ProjectFileError):
@router.get("/{project_slug}/files", response_class=HTMLResponse)
async def project_files_page(request: Request, project_slug: str):
user = get_current_user(request)
project = _load_project(project_slug)
project = _load_viewable_project(request, project_slug)
files = project_files.list_files(project["uid"])
seo_ctx = base_seo_context(
request,
@ -67,12 +78,14 @@ async def project_files_page(request: Request, project_slug: str):
"project": project,
"files": files,
"is_owner": is_owner(project, user),
"read_only": bool(project.get("read_only")),
"is_private": bool(project.get("is_private")),
}, model=ProjectFilesOut)
@router.get("/{project_slug}/files/raw")
async def project_file_raw(request: Request, project_slug: str, path: str):
project = _load_project(project_slug)
project = _load_viewable_project(request, project_slug)
try:
return JSONResponse(project_files.read_file(project["uid"], path))
except ProjectFileError as exc:
@ -81,7 +94,7 @@ async def project_file_raw(request: Request, project_slug: str, path: str):
@router.post("/{project_slug}/files/zip")
async def project_files_zip(request: Request, project_slug: str, path: str = ""):
project = _load_project(project_slug)
project = _load_viewable_project(request, project_slug)
user = get_current_user(request)
subpath = ""
name = project.get("title") or "project"
@ -102,6 +115,15 @@ async def project_files_zip(request: Request, project_slug: str, path: str = "")
return JSONResponse({"uid": uid, "status_url": f"/zips/{uid}"})
@router.get("/{project_slug}/files/lines")
async def project_file_lines(request: Request, project_slug: str, path: str, start: int = 1, end: int = -1):
project = _load_viewable_project(request, project_slug)
try:
return JSONResponse(project_files.read_lines(project["uid"], path, start, None if end < 0 else end))
except ProjectFileError as exc:
return json_error(404, str(exc))
@router.post("/{project_slug}/files/write")
async def project_file_write(request: Request, project_slug: str, data: Annotated[ProjectFileWriteForm, Form()]):
user = require_user(request)
@ -116,6 +138,51 @@ async def project_file_write(request: Request, project_slug: str, data: Annotate
return action_result(request, _files_url(project), data=project_files.node_to_dict(node))
def _edit(request: Request, project: dict, mutate) -> JSONResponse:
try:
node = mutate()
except ProjectFileError as exc:
return _fail(request, project, exc)
logger.info("project %s file edited: %s", project["uid"], node["path"])
return action_result(request, _files_url(project), data=project_files.node_to_dict(node))
@router.post("/{project_slug}/files/replace-lines")
async def project_file_replace_lines(request: Request, project_slug: str, data: Annotated[ProjectFileReplaceLinesForm, Form()]):
user = require_user(request)
project = _load_project(project_slug)
if not is_owner(project, user):
return _deny(request, project)
return _edit(request, project, lambda: project_files.replace_lines(project["uid"], data.path, data.start, data.end, data.content))
@router.post("/{project_slug}/files/insert-lines")
async def project_file_insert_lines(request: Request, project_slug: str, data: Annotated[ProjectFileInsertLinesForm, Form()]):
user = require_user(request)
project = _load_project(project_slug)
if not is_owner(project, user):
return _deny(request, project)
return _edit(request, project, lambda: project_files.insert_lines(project["uid"], data.path, data.at, data.content))
@router.post("/{project_slug}/files/delete-lines")
async def project_file_delete_lines(request: Request, project_slug: str, data: Annotated[ProjectFileDeleteLinesForm, Form()]):
user = require_user(request)
project = _load_project(project_slug)
if not is_owner(project, user):
return _deny(request, project)
return _edit(request, project, lambda: project_files.delete_lines(project["uid"], data.path, data.start, data.end))
@router.post("/{project_slug}/files/append")
async def project_file_append(request: Request, project_slug: str, data: Annotated[ProjectFileAppendForm, Form()]):
user = require_user(request)
project = _load_project(project_slug)
if not is_owner(project, user):
return _deny(request, project)
return _edit(request, project, lambda: project_files.append_lines(project["uid"], data.path, data.content))
@router.post("/{project_slug}/files/upload")
async def project_file_upload(request: Request, project_slug: str):
user = require_user(request)

View File

@ -2,13 +2,13 @@ import logging
from typing import Annotated
from sqlalchemy import or_
from fastapi import APIRouter, Request, Form
from devplacepy.models import ProjectForm
from devplacepy.models import ProjectForm, ProjectFlagForm, ForkForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes, paginate, resolve_by_slug
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes, paginate, resolve_by_slug, get_fork_parent, count_forks
from devplacepy.services.jobs import queue
from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context, canonical_redirect, first_image_url
from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context, canonical_redirect, first_image_url, is_owner, can_view_project
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, not_found, XP_PROJECT
from devplacepy.utils import get_current_user, require_user, not_found, is_admin, XP_PROJECT
from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema, list_page_seo, next_page_url
from devplacepy.responses import respond, action_result
from devplacepy.schemas import ProjectsOut, ProjectDetailOut
@ -29,9 +29,16 @@ def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = Non
filters["status"] = "Released"
clauses = []
if search and projects.exists:
like = f"%{search}%"
clauses.append(or_(projects.table.columns.title.ilike(like), projects.table.columns.description.ilike(like)))
if projects.exists:
columns = projects.table.columns
if search:
like = f"%{search}%"
clauses.append(or_(columns.title.ilike(like), columns.description.ilike(like)))
if not is_admin(viewer) and "is_private" in columns:
visible = or_(columns.is_private == None, columns.is_private == 0)
if viewer:
visible = or_(visible, columns.user_uid == viewer["uid"])
clauses.append(visible)
order = ["-stars", "-created_at"] if tab == "popular" else ["-created_at"]
total = projects.count(*clauses, **filters)
@ -92,6 +99,8 @@ async def project_detail(request: Request, project_slug: str):
if not detail:
raise not_found("Project not found")
project = detail["item"]
if not can_view_project(project, user):
raise not_found("Project not found")
redirect = canonical_redirect("projects", project, project_slug)
if redirect:
return redirect
@ -109,8 +118,18 @@ async def project_detail(request: Request, project_slug: str):
],
schemas=[website_schema(base), software_application_schema(project, base)],
)
parent = get_fork_parent(project["uid"])
forked_from = {
"uid": parent["uid"],
"slug": parent.get("slug") or parent["uid"],
"title": parent.get("title") or "project",
} if parent else None
return respond(request, "project_detail.html", detail_context(request, user, detail, "project", seo_ctx, {
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
"is_private": bool(project.get("is_private")),
"read_only": bool(project.get("read_only")),
"forked_from": forked_from,
"fork_count": count_forks(project["uid"]),
}), model=ProjectDetailOut)
@ -120,6 +139,8 @@ async def zip_project(request: Request, project_slug: str):
if not project:
raise not_found("Project not found")
user = get_current_user(request)
if not can_view_project(project, user):
raise not_found("Project not found")
owner_kind, owner_id = ("user", user["uid"]) if user else ("guest", request.client.host or "anon")
uid = queue.enqueue(
"zip",
@ -130,6 +151,24 @@ async def zip_project(request: Request, project_slug: str):
return JSONResponse({"uid": uid, "status_url": f"/zips/{uid}"})
@router.post("/{project_slug}/fork")
async def fork_project(request: Request, project_slug: str, data: Annotated[ForkForm, Form()]):
user = require_user(request)
source = resolve_by_slug(get_table("projects"), project_slug)
if not source:
raise not_found("Project not found")
if not can_view_project(source, user):
raise not_found("Project not found")
title = data.title.strip()
uid = queue.enqueue(
"fork",
{"source_project_uid": source["uid"], "title": title, "forked_by_uid": user["uid"]},
"user", user["uid"],
title,
)
return JSONResponse({"uid": uid, "status_url": f"/forks/{uid}"})
@router.post("/delete/{project_slug}")
async def delete_project(request: Request, project_slug: str):
user = require_user(request)
@ -150,6 +189,33 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Form()])
"project_type": data.project_type,
"platforms": data.platforms.strip(),
"status": data.status,
"is_private": 1 if data.is_private else 0,
"read_only": 0,
}, title, XP_PROJECT, "First Project", description, data.attachment_uids)
url = f"/projects/{project_slug}"
return action_result(request, url, data={"uid": uid, "slug": project_slug, "url": url})
def _set_project_flag(request: Request, project_slug: str, field: str, value: bool):
user = require_user(request)
projects = get_table("projects")
project = resolve_by_slug(projects, project_slug)
if not is_owner(project, user):
from devplacepy.responses import wants_json, json_error
if wants_json(request):
return json_error(403, "Not allowed")
return RedirectResponse(url=f"/projects/{project_slug}", status_code=302)
projects.update({"uid": project["uid"], field: 1 if value else 0}, ["uid"])
logger.info("project %s %s set to %s by %s", project["uid"], field, bool(value), user["username"])
url = f"/projects/{project['slug'] or project['uid']}"
return action_result(request, url, data={"uid": project["uid"], field: bool(value), "url": url})
@router.post("/{project_slug}/private")
async def set_project_private(request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Form()]):
return _set_project_flag(request, project_slug, "is_private", data.value)
@router.post("/{project_slug}/readonly")
async def set_project_readonly(request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Form()]):
return _set_project_flag(request, project_slug, "read_only", data.value)

157
devplacepy/routers/proxy.py Normal file
View File

@ -0,0 +1,157 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import logging
import httpx
import websockets
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import Response
from devplacepy import config
from devplacepy.services.containers import store
from devplacepy.utils import not_found
logger = logging.getLogger(__name__)
router = APIRouter()
HOP_HEADERS = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade", "host", "content-length",
"content-encoding",
}
METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
def _forward_headers(request: Request, prefix: str) -> dict:
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS}
headers["X-Forwarded-Prefix"] = prefix
headers["X-Script-Name"] = prefix
headers["X-Forwarded-Host"] = request.headers.get("host", request.url.hostname or "")
headers["X-Forwarded-Proto"] = request.headers.get("x-forwarded-proto", request.url.scheme)
headers["Accept-Encoding"] = "identity"
return headers
def _inject_base(body: bytes, prefix: str) -> bytes:
lowered = body.lower()
if b"<base" in lowered:
return body
tag = f'<base href="{prefix}/">'.encode()
head = lowered.find(b"<head")
anchor = lowered.find(b">", head) if head != -1 else lowered.find(b">", lowered.find(b"<html"))
if anchor == -1:
return tag + body
return body[: anchor + 1] + tag + body[anchor + 1 :]
def _rewrite_location(value: str, prefix: str) -> str:
if value.startswith("/") and not value.startswith("//"):
return prefix + value
return value
def _resolve(slug: str):
instance = store.find_instance_by_ingress(slug)
if not instance or instance.get("status") != store.ST_RUNNING:
return None, None
ports = json.loads(instance.get("ports_json") or "[]")
if not ports:
return instance, None
ingress_port = int(instance.get("ingress_port") or 0)
if ingress_port:
for port in ports:
if int(port["container"]) == ingress_port:
return instance, int(port["host"])
return instance, None
return instance, int(ports[0]["host"])
@router.api_route("/{slug}", methods=METHODS)
@router.api_route("/{slug}/{path:path}", methods=METHODS)
async def proxy_http(request: Request, slug: str, path: str = ""):
instance, port = _resolve(slug)
if instance is None:
raise not_found("No running container is published at this address")
if port is None:
return Response("the published container has no reachable port", status_code=502)
prefix = f"/p/{slug}"
url = f"http://{config.CONTAINER_PROXY_HOST}:{port}/{path}"
headers = _forward_headers(request, prefix)
body = await request.body()
try:
async with httpx.AsyncClient(timeout=60.0, follow_redirects=False) as client:
upstream = await client.request(
request.method, url, params=request.query_params, headers=headers, content=body)
except httpx.HTTPError as exc:
return Response(f"upstream error: {exc}", status_code=502)
out_headers = {k: v for k, v in upstream.headers.items()
if k.lower() not in HOP_HEADERS and k.lower() != "set-cookie"}
if "location" in out_headers:
out_headers["location"] = _rewrite_location(out_headers["location"], prefix)
content_type = upstream.headers.get("content-type", "")
content = upstream.content
if "text/html" in content_type.lower():
content = _inject_base(content, prefix)
response = Response(content=content, status_code=upstream.status_code,
headers=out_headers, media_type=content_type or None)
for cookie in upstream.headers.get_list("set-cookie"):
response.headers.append("set-cookie", cookie)
return response
@router.websocket("/{slug}")
@router.websocket("/{slug}/{path:path}")
async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
instance, port = _resolve(slug)
if instance is None or port is None:
await websocket.close(code=1011)
return
upstream_url = f"ws://{config.CONTAINER_PROXY_HOST}:{port}/{path}"
if websocket.url.query:
upstream_url += f"?{websocket.url.query}"
await websocket.accept()
try:
async with websockets.connect(upstream_url, open_timeout=10, max_size=None) as upstream:
await _pump(websocket, upstream)
except Exception as exc:
logger.debug("ws proxy %s failed: %s", slug, exc)
try:
await websocket.close(code=1011)
except Exception:
pass
async def _pump(client_ws: WebSocket, upstream) -> None:
async def client_to_upstream():
try:
while True:
message = await client_ws.receive()
if message["type"] == "websocket.disconnect":
break
if message.get("text") is not None:
await upstream.send(message["text"])
elif message.get("bytes") is not None:
await upstream.send(message["bytes"])
except Exception:
pass
finally:
await upstream.close()
async def upstream_to_client():
try:
async for message in upstream:
if isinstance(message, (bytes, bytearray)):
await client_ws.send_bytes(bytes(message))
else:
await client_ws.send_text(message)
except Exception:
pass
finally:
try:
await client_ws.close()
except Exception:
pass
await asyncio.gather(client_to_upstream(), upstream_to_client())

View File

@ -34,6 +34,41 @@ class ValidationErrorOut(_Out):
messages: list = []
class InstanceOut(_Out):
uid: str = ""
project_uid: str = ""
name: str = ""
slug: str = ""
container_id: Optional[str] = None
status: Optional[str] = None
desired_state: Optional[str] = None
restart_policy: Optional[str] = None
restart_count: int = 0
ingress_slug: Optional[str] = None
ingress_port: int = 0
boot_command: Optional[str] = None
cpu_limit: Optional[str] = None
mem_limit: Optional[str] = None
created_at: Optional[str] = None
started_at: Optional[str] = None
stopped_at: Optional[str] = None
class ScheduleOut(_Out):
uid: str = ""
instance_uid: str = ""
action: Optional[str] = None
enabled: int = 0
next_run_at: Optional[str] = None
last_run_at: Optional[str] = None
run_count: int = 0
class ContainersOut(_Out):
project: Optional[dict] = None
instances: list[InstanceOut] = []
class ZipJobOut(_Out):
uid: str = ""
kind: str = ""
@ -50,6 +85,20 @@ class ZipJobOut(_Out):
completed_at: Optional[str] = None
class ForkJobOut(_Out):
uid: str = ""
kind: str = ""
status: str = ""
preferred_name: Optional[str] = None
project_uid: Optional[str] = None
project_url: Optional[str] = None
source_project_uid: Optional[str] = None
error: Optional[str] = None
item_count: int = 0
created_at: Optional[str] = None
completed_at: Optional[str] = None
# ---------- shared resource projections ----------
class UserOut(_Out):
@ -146,6 +195,8 @@ class ProjectOut(_Out):
status: Optional[str] = None
platforms: Optional[Any] = None
stars: Optional[int] = None
is_private: Optional[bool] = None
read_only: Optional[bool] = None
release_date: Optional[str] = None
demo_date: Optional[str] = None
created_at: Optional[str] = None
@ -172,6 +223,8 @@ class ProjectFilesOut(_Out):
project: ProjectOut
files: list[ProjectFileOut] = []
is_owner: bool = False
read_only: bool = False
is_private: bool = False
class NewsOut(_Out):
@ -394,6 +447,10 @@ class ProjectDetailOut(_Out):
reactions: ReactionsOut = ReactionsOut()
bookmarked: bool = False
platforms: Optional[Any] = None
is_private: bool = False
read_only: bool = False
forked_from: Optional[dict] = None
fork_count: int = 0
class GistsOut(_Out):

View File

@ -27,8 +27,13 @@ def truncate(text, max_len=160):
return text + "..."
def public_base_url() -> str:
from devplacepy.database import get_setting
return (get_setting("site_url", "").strip() or SITE_URL or "").rstrip("/")
def site_url(request):
return SITE_URL or str(request.base_url).rstrip("/")
return public_base_url() or str(request.base_url).rstrip("/")
def website_schema(base_url):
@ -309,6 +314,8 @@ def _build_sitemap(base_url):
if "projects" in db.tables:
projects = _collect(get_table("projects"), SITEMAP_URL_LIMIT, "projects", order_by=["-created_at"])
for p in projects:
if p.get("is_private"):
continue
urlset.append(url_element(
f"{base_url}/projects/{p.get('slug') or p['uid']}",
lastmod=p.get("created_at", ""),

View File

@ -33,10 +33,14 @@ class BotBrowser:
self._page = await self._context.new_page()
async def close(self) -> None:
context, self._context = self._context, None
browser, self._browser = self._browser, None
playwright, self._playwright = self._playwright, None
self._page = None
for label, resource, shutdown in (
("context", self._context, lambda r: r.close()),
("browser", self._browser, lambda r: r.close()),
("playwright", self._playwright, lambda r: r.stop()),
("context", context, lambda r: r.close()),
("browser", browser, lambda r: r.close()),
("playwright", playwright, lambda r: r.stop()),
):
if resource is None:
continue
@ -44,10 +48,6 @@ class BotBrowser:
await shutdown(resource)
except Exception as e:
logger.debug("Browser %s close error: %s", label, e)
self._page = None
self._context = None
self._browser = None
self._playwright = None
@property
def page(self) -> Page:

View File

@ -135,8 +135,9 @@ class BotsService(BaseService):
await self._stop_fleet()
async def _stop_fleet(self) -> None:
for slot in list(self._fleet):
await self._stop_slot(slot)
slots = list(self._fleet)
if slots:
await asyncio.gather(*(self._stop_slot(slot) for slot in slots), return_exceptions=True)
async def _stop_slot(self, slot: int) -> None:
entry = self._fleet.pop(slot, None)
@ -150,8 +151,20 @@ class BotsService(BaseService):
pass
except Exception as e:
self.log(f"bot{slot} stop error: {e}")
await self._close_bot(slot, entry.get("bot"))
self.log(f"Stopped bot{slot}")
async def _close_bot(self, slot: int, bot) -> None:
browser = getattr(bot, "b", None)
if browser is None:
return
try:
await asyncio.wait_for(browser.close(), timeout=15)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
except Exception as e:
self.log(f"bot{slot} browser close error: {e}")
def collect_metrics(self) -> dict:
rows = []
total_cost = 0.0

View File

@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>

View File

@ -0,0 +1,331 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import re
import socket
from pathlib import Path
import httpx
from devplacepy import config, project_files
from devplacepy.services.containers import store
from devplacepy.services.containers.backend.base import Mount, PortMapping, RunSpec
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.devii.tasks.schedule import Schedule, next_run, now_utc, to_iso, from_iso
IMAGE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
INGRESS_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
MEM_RE = re.compile(r"^\d+(\.\d+)?[bkmgBKMG]?$")
CPU_RE = re.compile(r"^\d+(\.\d+)?$")
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
INSTANCE_LABEL = "devplace.instance"
PROJECT_LABEL = "devplace.project"
HOST_PORT_MIN = 20001
HOST_PORT_MAX = 65535
class ContainerError(ValueError):
pass
def _validate_limits(cpu_limit: str, mem_limit: str) -> None:
if cpu_limit and not CPU_RE.match(str(cpu_limit)):
raise ContainerError("cpu limit must be a number, e.g. 1 or 1.5")
if mem_limit and not MEM_RE.match(str(mem_limit)):
raise ContainerError("memory limit must look like 512m, 1g, or a byte count")
def parse_ports(value) -> list:
ports = []
if not value:
return ports
items = value if isinstance(value, list) else str(value).replace(",", "\n").splitlines()
for item in items:
item = str(item).strip()
if not item:
continue
proto = "tcp"
if "/" in item:
item, proto = item.split("/", 1)
if ":" in item:
host, container = item.split(":", 1)
else:
host, container = "0", item
if not host.isdigit() or not container.isdigit():
raise ContainerError(f"port '{item}' must be numeric host:container or a bare container port")
ports.append(PortMapping(int(host), int(container), proto.strip() or "tcp"))
return ports
def used_host_ports() -> set:
ports = set()
for instance in store.all_instances():
for mapping in json.loads(instance.get("ports_json") or "[]"):
host = int(mapping.get("host") or 0)
if host:
ports.add(host)
return ports
def _host_port_free(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind(("0.0.0.0", port))
return True
except OSError:
return False
def allocate_host_port(reserved: set) -> int:
for port in range(HOST_PORT_MIN, HOST_PORT_MAX + 1):
if port in reserved:
continue
if _host_port_free(port):
return port
raise ContainerError(f"no free host port available in range {HOST_PORT_MIN}-{HOST_PORT_MAX}")
def assign_host_ports(port_list: list) -> list:
reserved = used_host_ports()
assigned = []
for mapping in port_list:
host = mapping.host
if not host:
host = allocate_host_port(reserved)
elif host in reserved:
raise ContainerError(f"host port {host} is already published by another instance")
reserved.add(host)
assigned.append(PortMapping(host, mapping.container, mapping.proto))
return assigned
def parse_env(value) -> dict:
env = {}
if not value:
return env
if isinstance(value, dict):
items = value.items()
else:
items = (line.split("=", 1) for line in str(value).splitlines() if "=" in line)
for key, val in items:
key = str(key).strip()
if not ENV_KEY_RE.match(key):
raise ContainerError(f"invalid environment variable name: {key}")
env[key] = str(val)
return env
# ---------------- instances ----------------
def validate_ingress(slug: str, port, port_list) -> tuple:
slug = (slug or "").strip().lower()
if not slug:
return "", 0
if not INGRESS_SLUG_RE.match(slug):
raise ContainerError("ingress slug must be lowercase letters, digits, or '-' (max 63 chars)")
for other in store.all_instances():
if other.get("ingress_slug") == slug:
raise ContainerError(f"ingress slug '{slug}' is already in use")
ingress_port = int(port) if port else 0
container_ports = {p.container for p in port_list}
if ingress_port and ingress_port not in container_ports:
raise ContainerError(f"ingress_port {ingress_port} must be one of the container ports you mapped")
if not ingress_port and len(container_ports) != 1:
raise ContainerError("set ingress_port to choose which mapped container port to publish")
return slug, ingress_port
async def create_instance(project: dict, *, name: str,
boot_command: str = "", env="", cpu_limit: str = "", mem_limit: str = "",
ports="", volumes="", restart_policy: str = "never",
autostart: bool = True, ingress_slug: str = "", ingress_port=None,
actor=("system", "system")) -> dict:
if not await get_backend().image_exists(config.CONTAINER_IMAGE):
raise ContainerError(f"the '{config.CONTAINER_IMAGE}' image is not built - run 'make ppy'")
if restart_policy not in store.RESTART_POLICIES:
raise ContainerError(f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}")
_validate_limits(cpu_limit, mem_limit)
port_list = assign_host_ports(parse_ports(ports))
env_map = parse_env(env)
name = (name or "").strip()
if not name:
raise ContainerError("instance name is required")
ingress_slug, ingress_port = validate_ingress(ingress_slug, ingress_port, port_list)
workspace = Path(config.CONTAINER_WORKSPACES_DIR) / project["uid"]
await asyncio.to_thread(project_files.export_to_dir, project["uid"], "", str(workspace))
row = {
"project_uid": project["uid"],
"created_by": actor[1] if actor and actor[0] == "user" else "",
"owner_uid": project.get("user_uid", ""),
"name": name, "boot_command": boot_command or "",
"env_json": json.dumps(env_map),
"cpu_limit": str(cpu_limit or ""), "mem_limit": str(mem_limit or ""),
"ports_json": json.dumps([{"host": p.host, "container": p.container, "proto": p.proto} for p in port_list]),
"volumes_json": volumes if isinstance(volumes, str) else json.dumps(volumes or []),
"restart_policy": restart_policy,
"ingress_slug": ingress_slug, "ingress_port": ingress_port,
"desired_state": store.DESIRED_RUNNING if autostart else store.DESIRED_STOPPED,
"status": store.ST_CREATED,
"workspace_dir": str(workspace),
}
instance = store.create_instance(row)
store.record_event(instance, "created", actor[0], actor[1], {"image": config.CONTAINER_IMAGE})
return instance
def set_desired_state(instance: dict, desired: str, *, actor=("system", "system")) -> dict:
if desired not in (store.DESIRED_RUNNING, store.DESIRED_STOPPED, store.DESIRED_PAUSED):
raise ContainerError("desired state must be running, stopped, or paused")
store.update_instance(instance["uid"], {"desired_state": desired})
store.record_event(instance, f"desire_{desired}", actor[0], actor[1])
return store.get_instance(instance["uid"])
def request_restart(instance: dict, *, actor=("system", "system")) -> dict:
store.update_instance(instance["uid"], {"desired_state": store.DESIRED_RUNNING, "status": store.ST_RESTARTING})
store.record_event(instance, "restart", actor[0], actor[1])
return store.get_instance(instance["uid"])
def mark_for_removal(instance: dict, *, actor=("system", "system")) -> None:
store.update_instance(instance["uid"], {"desired_state": store.DESIRED_STOPPED, "status": store.ST_REMOVING})
store.record_event(instance, "remove", actor[0], actor[1])
def pravda_env(instance: dict) -> dict:
from devplacepy import database, seo
base_url = seo.public_base_url()
api_key = ""
for uid in (instance.get("created_by"), instance.get("owner_uid")):
if not uid:
continue
user = database.get_users_by_uids([uid]).get(uid)
if user and user.get("api_key"):
api_key = user["api_key"]
break
slug = instance.get("ingress_slug") or ""
ingress_url = (f"{base_url}/p/{slug}" if base_url else f"/p/{slug}") if slug else ""
return {
"PRAVDA_BASE_URL": base_url,
"PRAVDA_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
"PRAVDA_API_KEY": api_key,
"PRAVDA_USER_UID": instance.get("owner_uid") or "",
"PRAVDA_CONTAINER_NAME": instance.get("name") or "",
"PRAVDA_CONTAINER_UID": instance.get("uid") or "",
"PRAVDA_INGRESS_URL": ingress_url,
}
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
env = {**json.loads(instance.get("env_json") or "{}"), **pravda_env(instance)}
ports = [PortMapping(p["host"], p["container"], p.get("proto", "tcp"))
for p in json.loads(instance.get("ports_json") or "[]")]
mounts = [Mount(instance["workspace_dir"], "/app", "rw")]
for extra in json.loads(instance.get("volumes_json") or "[]"):
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
mounts.append(Mount(extra["host"], extra["container"], extra.get("mode", "rw")))
boot = (instance.get("boot_command") or "").strip()
command = ["/bin/sh", "-c", boot] if boot else ["sleep", "infinity"]
return RunSpec(
image=image_tag, name=instance["slug"],
labels={INSTANCE_LABEL: instance["uid"], PROJECT_LABEL: instance["project_uid"]},
env=env, cpu_limit=instance.get("cpu_limit", ""), mem_limit=instance.get("mem_limit", ""),
ports=ports, mounts=mounts, restart_policy=instance.get("restart_policy", "never"), command=command,
)
async def sync_workspace(instance: dict, user: dict) -> int:
workspace = instance.get("workspace_dir")
if not workspace:
raise ContainerError("instance has no workspace")
count = await asyncio.to_thread(project_files.import_from_dir, instance["project_uid"], workspace, user)
store.record_event(instance, "sync", "user", user["uid"], {"imported": count})
return count
def add_schedule(instance: dict, action: str, schedule: Schedule) -> dict:
if action not in ("start", "stop"):
raise ContainerError("schedule action must be start or stop")
first = schedule.first_run(now_utc())
return store.create_schedule(instance, action, schedule.columns(), to_iso(first))
# ---------------- aggregation ----------------
def _percentile(values: list, pct: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
index = min(len(ordered) - 1, int(round((pct / 100.0) * (len(ordered) - 1))))
return float(ordered[index])
def instance_stats(instance_uid: str) -> dict:
metrics = store.recent_metrics(instance_uid, limit=720)
cpu = [m.get("cpu_pct", 0) for m in metrics]
mem = [m.get("mem_bytes", 0) for m in metrics]
return {
"samples": len(metrics),
"cpu_avg": round(sum(cpu) / len(cpu), 2) if cpu else 0.0,
"cpu_p95": round(_percentile(cpu, 95), 2),
"mem_max": max(mem) if mem else 0,
"mem_avg": int(sum(mem) / len(mem)) if mem else 0,
}
def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
try:
with httpx.Client(timeout=timeout) as client:
response = client.get(f"http://{host}:{port}/")
return f"HTTP {response.status_code}"
except Exception as exc: # noqa: BLE001 - diagnostic, any failure is informative
return f"unreachable: {type(exc).__name__}"
def _ingress_host_port(instance: dict, port_maps: list) -> int:
ingress_port = int(instance.get("ingress_port") or 0)
if ingress_port:
for mapping in port_maps:
if int(mapping.get("container") or 0) == ingress_port:
return int(mapping.get("host") or 0)
return 0
return int(port_maps[0].get("host") or 0) if port_maps else 0
def instance_runtime(instance: dict) -> dict:
boot = (instance.get("boot_command") or "").strip()
port_maps = json.loads(instance.get("ports_json") or "[]")
host = config.CONTAINER_PROXY_HOST
ports = []
for mapping in port_maps:
host_port = int(mapping.get("host") or 0)
ports.append({
"container": int(mapping.get("container") or 0),
"host": host_port,
"proto": mapping.get("proto", "tcp"),
"reachable": _port_reachable(host, host_port) if host_port else False,
})
ingress_host_port = _ingress_host_port(instance, port_maps)
ingress_serving = _http_probe(host, ingress_host_port) if ingress_host_port else "no ingress port mapped"
return {
"command": boot or "image CMD (no boot_command set)",
"ports": ports,
"ingress_port": int(instance.get("ingress_port") or 0),
"ingress_serving": ingress_serving,
"status_ok_for_ingress": instance.get("status") == store.ST_RUNNING,
"restart_count": int(instance.get("restart_count") or 0),
"exit_code": instance.get("exit_code"),
"container_id": (instance.get("container_id") or "")[:12],
}

View File

@ -0,0 +1,23 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.containers.backend.base import (
Backend,
BuildResult,
ExecResult,
Mount,
PortMapping,
PsRow,
RunSpec,
StatsSample,
)
__all__ = [
"Backend",
"BuildResult",
"ExecResult",
"Mount",
"PortMapping",
"PsRow",
"RunSpec",
"StatsSample",
]

View File

@ -0,0 +1,124 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Awaitable, Callable, Optional
LogCallback = Callable[[str], Awaitable[None]]
@dataclass
class PortMapping:
host: int
container: int
proto: str = "tcp"
@dataclass
class Mount:
host: str
container: str
mode: str = "rw"
@dataclass
class RunSpec:
image: str
name: str
labels: dict = field(default_factory=dict)
env: dict = field(default_factory=dict)
cpu_limit: str = ""
mem_limit: str = ""
ports: list = field(default_factory=list)
mounts: list = field(default_factory=list)
restart_policy: str = "no"
command: list = field(default_factory=list)
@dataclass
class BuildResult:
success: bool
image_id: str = ""
error: str = ""
@dataclass
class ExecResult:
exit_code: int
output: str = ""
@dataclass
class StatsSample:
cpu_pct: float = 0.0
mem_bytes: int = 0
mem_pct: float = 0.0
net_rx: int = 0
net_tx: int = 0
blk_read: int = 0
blk_write: int = 0
@dataclass
class PsRow:
container_id: str
name: str
state: str
status: str
exit_code: Optional[int]
labels: dict = field(default_factory=dict)
class Backend(ABC):
@abstractmethod
async def build(self, *, context_dir: str, dockerfile_text: str, tags: list,
on_log: Optional[LogCallback] = None, network: str = "") -> BuildResult: ...
@abstractmethod
async def run(self, spec: RunSpec) -> str: ...
@abstractmethod
async def start(self, cid: str) -> None: ...
@abstractmethod
async def stop(self, cid: str, timeout: int = 10) -> None: ...
@abstractmethod
async def restart(self, cid: str) -> None: ...
@abstractmethod
async def pause(self, cid: str) -> None: ...
@abstractmethod
async def unpause(self, cid: str) -> None: ...
@abstractmethod
async def rm(self, cid: str, force: bool = False) -> None: ...
@abstractmethod
async def exec(self, cid: str, cmd: list, *, tty: bool = False,
on_log: Optional[LogCallback] = None) -> ExecResult: ...
@abstractmethod
async def logs(self, cid: str, *, follow: bool = False, tail: int = 200,
on_log: Optional[LogCallback] = None) -> None: ...
@abstractmethod
async def stats_once(self, cids: list) -> dict: ...
@abstractmethod
async def ps(self, *, label_filter: str = "devplace.instance") -> list: ...
@abstractmethod
async def inspect(self, cid: str) -> dict: ...
@abstractmethod
async def image_prune(self) -> None: ...
@abstractmethod
async def remove_image(self, ref: str, force: bool = False) -> None: ...
@abstractmethod
async def image_exists(self, ref: str) -> bool: ...

View File

@ -0,0 +1,280 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import asyncio
import json
import logging
import re
import tempfile
from pathlib import Path
from typing import Optional
from devplacepy.services.containers.backend.base import (
Backend,
BuildResult,
ExecResult,
LogCallback,
PsRow,
RunSpec,
StatsSample,
)
logger = logging.getLogger("containers.docker")
DOCKER = "docker"
DEFAULT_TIMEOUT = 600
BUILD_TIMEOUT = 3600
_EXIT_RE = re.compile(r"Exited \((\d+)\)")
_SIZE_RE = re.compile(r"([0-9.]+)\s*([kKmMgGtT]?i?)B")
def build_run_argv(spec: RunSpec) -> list:
argv = [DOCKER, "run", "-d", "--name", spec.name]
for key, value in spec.labels.items():
argv += ["--label", f"{key}={value}"]
for key, value in spec.env.items():
argv += ["-e", f"{key}={value}"]
if spec.cpu_limit:
argv += ["--cpus", str(spec.cpu_limit)]
if spec.mem_limit:
argv += ["--memory", str(spec.mem_limit)]
for port in spec.ports:
argv += ["-p", f"{port.host}:{port.container}/{port.proto}"]
for mount in spec.mounts:
argv += ["-v", f"{mount.host}:{mount.container}:{mount.mode}"]
if spec.restart_policy and spec.restart_policy != "never":
policy = "no" if spec.restart_policy in ("no", "never") else spec.restart_policy
argv += ["--restart", policy]
argv.append(spec.image)
argv += list(spec.command)
return argv
def build_image_argv(context_dir: str, dockerfile_path: str, tags: list, iidfile: str, network: str = "") -> list:
argv = [DOCKER, "build", "--label", "devplace.build=1", "--iidfile", iidfile, "-f", dockerfile_path]
if network:
argv += ["--network", network]
for tag in tags:
argv += ["-t", tag]
argv.append(context_dir)
return argv
def parse_size(text: str) -> int:
match = _SIZE_RE.search(text or "")
if not match:
return 0
value = float(match.group(1))
unit = match.group(2).lower().rstrip("i")
factor = {"": 1, "k": 1024, "m": 1024 ** 2, "g": 1024 ** 3, "t": 1024 ** 4}.get(unit, 1)
return int(value * factor)
def parse_stats_line(row: dict) -> StatsSample:
def _pair(text: str) -> tuple:
parts = (text or "").split("/")
left = parse_size(parts[0]) if parts else 0
right = parse_size(parts[1]) if len(parts) > 1 else 0
return left, right
rx, tx = _pair(row.get("NetIO", ""))
rd, wr = _pair(row.get("BlockIO", ""))
return StatsSample(
cpu_pct=float((row.get("CPUPerc", "0%") or "0%").rstrip("%") or 0),
mem_bytes=_pair(row.get("MemUsage", ""))[0],
mem_pct=float((row.get("MemPerc", "0%") or "0%").rstrip("%") or 0),
net_rx=rx, net_tx=tx, blk_read=rd, blk_write=wr,
)
class DockerCliBackend(Backend):
def __init__(self, binary: str = DOCKER, timeout: int = DEFAULT_TIMEOUT,
build_timeout: int = BUILD_TIMEOUT) -> None:
self._binary = binary
self._timeout = timeout
self._build_timeout = build_timeout
async def _run(self, argv: list, *, timeout: Optional[int] = None) -> tuple:
proc = await asyncio.create_subprocess_exec(
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout or self._timeout)
except asyncio.TimeoutError:
proc.kill()
raise RuntimeError(f"docker command timed out: {' '.join(argv[:3])}")
return proc.returncode, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
async def _stream(self, argv: list, on_log: Optional[LogCallback], *, timeout: Optional[int] = None) -> int:
proc = await asyncio.create_subprocess_exec(
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
async def pump():
assert proc.stdout is not None
async for raw in proc.stdout:
line = raw.decode("utf-8", "replace").rstrip("\n")
if on_log is not None:
await on_log(line)
return await proc.wait()
try:
return await asyncio.wait_for(pump(), timeout=timeout or self._timeout)
except asyncio.TimeoutError:
proc.kill()
raise RuntimeError(f"docker stream timed out: {' '.join(argv[:3])}")
async def _check(self, argv: list) -> str:
code, out, err = await self._run(argv)
if code != 0:
raise RuntimeError(f"{' '.join(argv[:2])} failed ({code}): {(err or out).strip()[:500]}")
return out.strip()
async def build(self, *, context_dir, dockerfile_text, tags, on_log=None, network="") -> BuildResult:
ctx = Path(context_dir)
dockerfile_path = ctx / "Dockerfile.devplace"
iid_path = await asyncio.to_thread(self._prepare_context, dockerfile_path, dockerfile_text)
argv = build_image_argv(str(ctx), str(dockerfile_path), tags, iid_path, network)
try:
code = await self._stream(argv, on_log, timeout=self._build_timeout)
if code != 0:
return BuildResult(success=False, error=f"docker build exited {code}")
image_id = (await asyncio.to_thread(Path(iid_path).read_text)).strip()
return BuildResult(success=True, image_id=image_id)
finally:
await asyncio.to_thread(self._cleanup_context, iid_path, dockerfile_path)
@staticmethod
def _prepare_context(dockerfile_path: Path, dockerfile_text: str) -> str:
dockerfile_path.write_text(dockerfile_text, encoding="utf-8")
iid = tempfile.NamedTemporaryFile(suffix=".iid", delete=False)
iid.close()
return iid.name
@staticmethod
def _cleanup_context(iid_path: str, dockerfile_path: Path) -> None:
Path(iid_path).unlink(missing_ok=True)
dockerfile_path.unlink(missing_ok=True)
async def run(self, spec: RunSpec) -> str:
return await self._check(build_run_argv(spec))
async def start(self, cid: str) -> None:
await self._check([self._binary, "start", cid])
async def stop(self, cid: str, timeout: int = 10) -> None:
await self._check([self._binary, "stop", "-t", str(timeout), cid])
async def restart(self, cid: str) -> None:
await self._check([self._binary, "restart", cid])
async def pause(self, cid: str) -> None:
await self._check([self._binary, "pause", cid])
async def unpause(self, cid: str) -> None:
await self._check([self._binary, "unpause", cid])
async def rm(self, cid: str, force: bool = False) -> None:
argv = [self._binary, "rm"]
if force:
argv.append("-f")
argv.append(cid)
await self._check(argv)
async def exec(self, cid, cmd, *, tty=False, on_log=None) -> ExecResult:
argv = [self._binary, "exec"]
if tty:
argv.append("-t")
argv.append(cid)
argv += list(cmd)
if on_log is not None:
code = await self._stream(argv, on_log)
return ExecResult(exit_code=code)
code, out, err = await self._run(argv)
return ExecResult(exit_code=code or 0, output=(out + err))
async def logs(self, cid, *, follow=False, tail=200, on_log=None) -> None:
argv = [self._binary, "logs", "--tail", str(tail)]
if follow:
argv.append("--follow")
argv.append(cid)
await self._stream(argv, on_log)
async def stats_once(self, cids: list) -> dict:
if not cids:
return {}
argv = [self._binary, "stats", "--no-stream", "--format", "{{json .}}", *cids]
code, out, _ = await self._run(argv, timeout=30)
samples = {}
if code != 0:
return samples
for line in out.splitlines():
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except ValueError:
continue
name = row.get("Name") or row.get("ID") or ""
samples[name] = parse_stats_line(row)
return samples
async def ps(self, *, label_filter: str = "devplace.instance") -> list:
argv = [self._binary, "ps", "-a", "--filter", f"label={label_filter}", "--format", "{{json .}}"]
code, out, _ = await self._run(argv, timeout=30)
rows = []
if code != 0:
return rows
for line in out.splitlines():
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except ValueError:
continue
labels = {}
for pair in (row.get("Labels", "") or "").split(","):
if "=" in pair:
key, value = pair.split("=", 1)
labels[key] = value
status = row.get("Status", "") or ""
match = _EXIT_RE.search(status)
rows.append(PsRow(
container_id=row.get("ID", ""),
name=row.get("Names", ""),
state=(row.get("State", "") or "").lower(),
status=status,
exit_code=int(match.group(1)) if match else None,
labels=labels,
))
return rows
async def inspect(self, cid: str) -> dict:
code, out, _ = await self._run([self._binary, "inspect", cid], timeout=30)
if code != 0:
return {}
try:
data = json.loads(out)
return data[0] if isinstance(data, list) and data else {}
except ValueError:
return {}
async def image_prune(self) -> None:
try:
await self._run([self._binary, "image", "prune", "-f", "--filter", "label=devplace.build"], timeout=120)
except RuntimeError as exc:
logger.warning("image prune failed: %s", exc)
async def remove_image(self, ref: str, force: bool = False) -> None:
argv = [self._binary, "rmi"]
if force:
argv.append("-f")
argv.append(ref)
code, _, err = await self._run(argv, timeout=60)
if code != 0 and "No such image" not in (err or ""):
logger.warning("rmi %s failed: %s", ref, (err or "").strip()[:200])
async def image_exists(self, ref: str) -> bool:
code, _, _ = await self._run([self._binary, "image", "inspect", ref], timeout=30)
return code == 0

View File

@ -0,0 +1,106 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from devplacepy.services.containers.backend.base import (
Backend,
BuildResult,
ExecResult,
PsRow,
RunSpec,
StatsSample,
)
class FakeBackend(Backend):
def __init__(self) -> None:
self.containers: dict = {}
self.built_images: list = []
self.removed: list = []
self.removed_images: list = []
self.pruned = 0
self._counter = 0
self.fail_build = False
async def build(self, *, context_dir, dockerfile_text, tags, on_log=None, network="") -> BuildResult:
if on_log is not None:
await on_log(f"FAKE build {tags} net={network}")
if self.fail_build:
return BuildResult(success=False, error="fake build failure")
self.built_images.append(list(tags))
return BuildResult(success=True, image_id=f"sha256:fake{len(self.built_images)}")
async def run(self, spec: RunSpec) -> str:
self._counter += 1
cid = f"fake{self._counter:012d}"
self.containers[cid] = {
"name": spec.name, "state": "running", "labels": dict(spec.labels),
"exit_code": None, "image": spec.image, "spec": spec,
}
return cid
def _set_state(self, cid: str, state: str, exit_code=None) -> None:
if cid in self.containers:
self.containers[cid]["state"] = state
if exit_code is not None:
self.containers[cid]["exit_code"] = exit_code
async def start(self, cid: str) -> None:
self._set_state(cid, "running")
async def stop(self, cid: str, timeout: int = 10) -> None:
self._set_state(cid, "exited", exit_code=0)
async def restart(self, cid: str) -> None:
self._set_state(cid, "running")
async def pause(self, cid: str) -> None:
self._set_state(cid, "paused")
async def unpause(self, cid: str) -> None:
self._set_state(cid, "running")
async def rm(self, cid: str, force: bool = False) -> None:
self.removed.append(cid)
self.containers.pop(cid, None)
async def exec(self, cid, cmd, *, tty=False, on_log=None) -> ExecResult:
line = f"FAKE exec {' '.join(cmd)}"
if on_log is not None:
await on_log(line)
return ExecResult(exit_code=0, output=line)
async def logs(self, cid, *, follow=False, tail=200, on_log=None) -> None:
if on_log is not None:
await on_log(f"FAKE logs for {cid}")
async def stats_once(self, cids: list) -> dict:
return {self.containers[cid]["name"]: StatsSample(cpu_pct=1.0, mem_bytes=1024)
for cid in cids if cid in self.containers}
async def ps(self, *, label_filter: str = "devplace.instance") -> list:
key, _, value = label_filter.partition("=")
rows = []
for cid, data in self.containers.items():
labels = data["labels"]
if key not in labels:
continue
if value and labels.get(key) != value:
continue
rows.append(PsRow(
container_id=cid, name=data["name"], state=data["state"],
status=data["state"], exit_code=data["exit_code"], labels=labels,
))
return rows
async def inspect(self, cid: str) -> dict:
return self.containers.get(cid, {})
async def image_prune(self) -> None:
self.pruned += 1
async def remove_image(self, ref: str, force: bool = False) -> None:
self.removed_images.append(ref)
async def image_exists(self, ref: str) -> bool:
return True

View File

@ -0,0 +1,245 @@
et nocompatible
filetype off
set rtp+=~/.vim/bundle/Vundle.vim
let g:ycm_auto_trigger = 1
filetype plugin indent on " required
set clipboard=unnamedplus
function! GitBranch()
let l:branch = system("bash -c 'uptime'")
return l:branch
endfunction
call plug#begin()
" List your plugins here
Plug 'prabirshrestha/asyncomplete.vim'
Plug 'prabirshrestha/asyncomplete-lsp.vim'
Plug 'tpope/vim-sensible'
Plug 'Exafunction/codeium.vim', { 'branch': 'main' }
Plug 'prabirshrestha/vim-lsp'
Plug 'mattn/vim-lsp-settings'
set statusline=\{…\}%3{codeium#GetStatusString()}\%h%m%r\ [%l,%c]\ %p%%
highlight StatusLine guifg=#ffffff guibg=#005f87
highlight StatusLineNC guifg=#aaaaaa guibg=#303030
highlight ErrorMsg ctermfg=Red ctermbg=NONE guifg=#FF0000 guibg=NONE
call plug#end()
call vundle#begin()
Plugin 'VundleVim/Vundle.vim'
Plugin 'ycm-core/YouCompleteMe'
call vundle#end()
set mouse=a
set backspace=indent,eol,start
syntax on
filetype plugin indent on
set showtabline=2
set enc=utf-8
set fenc=utf-8
set termencoding=utf-8
set autoindent
set smartindent
set tabstop=4
set shiftwidth=4
set expandtab
set number
set showmatch
set comments=sl:/*,mb:\ *,elx:\ */
let mapleader = ","
function! ShowInTerminal(content)
" Open a new terminal split
belowright split | terminal
" Wait for terminal to initialize
call feedkeys("i") " go into insert mode to send keys
call chansend(b:terminal_job_id, a:content . "\n")
endfunction
function! AIR()
let l:ai_prompt = input("Enter AI instructions: ")
let $AI_PROMPT = l:ai_prompt
let l:result = system('r ' . l:ai_prompt)
call ShowInTerminal(l:result)
endfunction
function! AIPromptSelection()
" Save cursor position and the unnamed register
let l:pos = getpos('.')
let l:save_reg = @"
let l:save_regtype = getregtype('"')
" Yank the visual selection into the unnamed register
normal! gvy
let l:selected_text = @"
" Restore the unnamed register
call setreg('"', l:save_reg, l:save_regtype)
" Prompt the user for AI instructions
let l:ai_prompt = input('Enter AI instructions: ')
let $AI_PROMPT = l:ai_prompt
" Call your external AI formatter (replace `c` with your command)
let l:formatted = system('c', l:selected_text)
" Handle errors or replace the selection with the AI response
if v:shell_error
echohl ErrorMsg | echom 'Formatting failed' | echohl None
else
" Delete the original selection
normal! gvd
" Insert the formatted text *before* the cursor (same spot)
put! =l:formatted
" Restore the original cursor position
call setpos('.', l:pos)
endif
" Restore the unnamed register again (good measure)
call setreg('"', l:save_reg, l:save_regtype)
endfunction
" Map the AI prompt function to a key combination
vnoremap <Leader>f :<C-u>call AIPromptSelection()<CR>
nnoremap <Leader>r :call AIR()<CR>
" Existing keymappings and other configurations...
inoremap <C-n> <ESC>:tabnext<CR>
inoremap <C-p> <ESC>:tabnext<CR>
nnoremap <C-n> :tabnext<CR>
nnoremap <C-p> :tabnext<CR>
nnoremap <Tab> :tabnext<CR>
nnoremap <C-Tab> :tabprevious<CR>
inoremap <C-t> <ESC>:terminal<CR><CR><C-w>r<CR><C-w>-<C-w>-<C-w>-<C-w>-<C-w>-<C-w>-<C-w>-
nnoremap <C-e> :tabnew<Space>
inoremap <C-e> <ESC>:tabnew<Space>
nnoremap e :tabnew<Space>
nnoremap q <ESC>:q<CR>
set undofile
set undodir=~/.vim/undo
" Existing commands and other configurations...
command! GPT py3file ~/bin/gpt
command! Refactor py3file /home/retoor/.vim/plugin/refactor.py
command! HelloVim py3file ~/.vim/plugin/hello.py | py3 say_hello()
if has("autocmd")
au BufReadPost * if line("'\"") > 0 && line("'\"") <= line("$") | exe "normal! g'\"" | endif
endif
execute pathogen#infect()
execute pathogen#helptags()
" Helper: get visually selected text
function! GetVisualSelection()
let [line_start, col_start] = [line("'<"), col("'<")]
let [line_end, col_end] = [line("'>"), col("'>")]
let lines = getline(line_start, line_end)
if len(lines) == 0
return ''
endif
" Trim col for first/last line if selection is characterwise
let lines[0] = lines[0][col_start - 1 :]
let lines[-1] = lines[-1][: col_end - (line_start == line_end ? 1 : 2)]
return join(lines, "\n")
endfunction
function! AiEditSelection()
" (1) Get user prompt
let l:instruction = input("AI instruction: ")
if empty(l:instruction)
echo "No instruction given, cancelled."
return
endif
" (2) Get visual selection
let l:origText = GetVisualSelection()
if empty(l:origText)
echo "No selection."
return
endif
" (3) Compose the API prompt
let l:prompt = l:instruction . "\n\nHere is the text:\n" . l:origText . "\n\nOutput only the transformed text. Do not include explanations, markdown, or code blocks."
" (4) Send to the same gateway pagent uses (PRAVDA_OPENAI_URL / PRAVDA_API_KEY)
let l:base = substitute($PRAVDA_OPENAI_URL, '/\+$', '', '')
if empty(l:base)
let l:url = 'https://openai.app.molodetz.nl/v1/chat/completions'
elseif l:base =~# '/chat/completions$'
let l:url = l:base
else
let l:url = l:base . '/chat/completions'
endif
let l:api_key = !empty($PRAVDA_API_KEY) ? $PRAVDA_API_KEY : $DEEPSEEK_API_KEY
let l:json = '{"model":"deepseek-chat","messages":[{"role":"user","content":'.json_encode(l:prompt).'}]}'
let l:cmd = 'curl -sS -X POST ' . shellescape(l:url) . ' -H "Authorization: Bearer ' . l:api_key . '" -H "Content-Type: application/json" -d ' . shellescape(l:json)
let l:reply = system(l:cmd)
" --- Extract output (simple JSON parsing) ---
let l:text = matchstr(l:reply, '"content":\s*"\zs\(.\{-}\)\ze"\s*}')
let l:text = substitute(l:text, '\\n', "\n", 'g')
let l:text = substitute(l:text, '\\"', '"', 'g')
" (5) Replace selected text
normal! gv
" Use c to change or d (delete and then 'put') depending on visual mode
normal! c
call feedkeys(l:text, 'n')
endfunction
" Visual mode mapping
xnoremap <silent> <Leader>a :<C-u>call AiEditSelection()<CR>
command! GreetPython py3 greet()
vnoremap <leader>gr :GPTRefactor<CR>
vnoremap <leader>gd :GPTRefactorDefault<CR>
if executable('pylsp')
" pip install python-lsp-server
au User lsp_setup call lsp#register_server({
\ 'name': 'pylsp',
\ 'cmd': {server_info->['pylsp']},
\ 'allowlist': ['python'],
\ })
endif
function! s:on_lsp_buffer_enabled() abort
setlocal omnifunc=lsp#complete
setlocal signcolumn=yes
if exists('+tagfunc') | setlocal tagfunc=lsp#tagfunc | endif
nmap <buffer> gd <plug>(lsp-definition)
nmap <buffer> gs <plug>(lsp-document-symbol-search)
nmap <buffer> gS <plug>(lsp-workspace-symbol-search)
nmap <buffer> gr <plug>(lsp-references)
nmap <buffer> gi <plug>(lsp-implementation)
nmap <buffer> gt <plug>(lsp-type-definition)
nmap <buffer> <leader>rn <plug>(lsp-rename)
nmap <buffer> [g <plug>(lsp-previous-diagnostic)
nmap <buffer> ]g <plug>(lsp-next-diagnostic)
nmap <buffer> K <plug>(lsp-hover)
nnoremap <buffer> <expr><c-f> lsp#scroll(+4)
nnoremap <buffer> <expr><c-d> lsp#scroll(-4)
let g:lsp_format_sync_timeout = 1000
autocmd! BufWritePre *.rs,*.go call execute('LspDocumentFormatSync')
" refer to doc to add more commands
endfunction
augroup lsp_install
au!
" call s:on_lsp_buffer_enabled only for languages that has the server registered.
autocmd User lsp_buffer_enabled call s:on_lsp_buffer_enabled()
augroup END
let g:lsp_document_highlight_enabled = 1
let g:lsp_diagnostics_enabled = 0 " disable diagnostics support

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
#!/bin/sh
# devplace sudo superclone: sudo-compatible CLI, no privilege change.
# It never swaps user; it executes the command as the current user (pravda),
# so nothing a container does can ever create a root-owned file on the host.
_dp_usage() {
cat <<'EOF'
usage: sudo -h | -K | -k | -V
usage: sudo -v [-ABkNnS] [-g group] [-h host] [-p prompt] [-u user]
usage: sudo -l [-ABkNnS] [-g group] [-h host] [-p prompt] [-U user] [-u user] [command]
usage: sudo [-ABbEHnPS] [-C num] [-g group] [-h host] [-p prompt] [-R dir] [-T timeout] [-u user] [VAR=value] [-i | -s] [command [arg ...]]
EOF
}
_dp_login=0
_dp_shell=0
while [ $# -gt 0 ]; do
case "$1" in
--) shift; break ;;
-V|--version) echo "Sudo version 1.9.15p5"; echo "Sudoers policy plugin version 1.9.15p5"; exit 0 ;;
--help) _dp_usage; exit 0 ;;
-K|-k|--remove-timestamp|--reset-timestamp) exit 0 ;;
-v|--validate) exit 0 ;;
-l|-ll|--list) echo "User $(id -un 2>/dev/null) may run any command"; exit 0 ;;
-i|--login) _dp_login=1; shift ;;
-s|--shell) _dp_shell=1; shift ;;
--user=*|--group=*|--prompt=*|--chdir=*|--chroot=*|--host=*|--role=*|--type=*|--other-user=*|--command-timeout=*|--close-from=*|--preserve-env=*) shift ;;
-A|--askpass|-b|--background|-B|--bell|-E|--preserve-env|-H|--set-home|-n|--non-interactive|-N|--no-update|-P|--preserve-groups|-S|--stdin) shift ;;
-u|--user|-g|--group|-p|--prompt|-C|--close-from|-D|--chdir|-R|--chroot|-T|--command-timeout|-U|--other-user|-r|--role|-t|--type|-c|--class|-h|--host)
if [ $# -ge 2 ]; then shift 2; else shift; fi ;;
-*) shift ;;
*) break ;;
esac
done
_dp_env=""
while [ $# -gt 0 ]; do
case "$1" in
[A-Za-z_]*=*) _dp_env="$_dp_env $1"; shift ;;
*) break ;;
esac
done
SUDO_USER="$(id -un 2>/dev/null)"; export SUDO_USER
SUDO_UID="$(id -u 2>/dev/null)"; export SUDO_UID
SUDO_GID="$(id -g 2>/dev/null)"; export SUDO_GID
SUDO_COMMAND="$*"; export SUDO_COMMAND
if [ "$_dp_login" -eq 1 ] || [ "$_dp_shell" -eq 1 ]; then
_dp_sh="${SHELL:-/bin/sh}"
if [ $# -gt 0 ]; then
[ -n "$_dp_env" ] && exec env $_dp_env "$_dp_sh" -c "$*"
exec "$_dp_sh" -c "$*"
fi
[ -n "$_dp_env" ] && exec env $_dp_env "$_dp_sh"
exec "$_dp_sh"
fi
if [ $# -eq 0 ]; then
_dp_usage >&2
exit 1
fi
[ -n "$_dp_env" ] && exec env $_dp_env "$@"
exec "$@"

View File

@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
import asyncio
NUMBERING_LOCK = asyncio.Lock()

View File

@ -0,0 +1,16 @@
# retoor <retoor@molodetz.nl>
_backend = None
def get_backend():
global _backend
if _backend is None:
from devplacepy.services.containers.backend.docker_cli import DockerCliBackend
_backend = DockerCliBackend()
return _backend
def set_backend(backend) -> None:
global _backend
_backend = backend

View File

@ -0,0 +1,213 @@
# retoor <retoor@molodetz.nl>
import json
from devplacepy import config
from devplacepy.services.base import BaseService, ConfigField
from devplacepy.services.containers import api, store
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.devii.tasks.schedule import next_run, now_utc, to_iso
AUTO_RESTART = ("always", "on-failure", "unless-stopped")
class ContainerService(BaseService):
default_enabled = False
min_interval = 1
title = "Containers"
description = (
"Reconciles desired container state against the docker daemon: launches, stops, restarts, "
"reaps orphans, fires schedules, and samples per-instance metrics. Requires access to the "
"docker socket."
)
config_fields = [
ConfigField("container_metrics_every", "Metrics sample every (ticks)", type="int",
default=1, minimum=1, maximum=60,
help="Sample docker stats once every N reconcile ticks.", group="Containers"),
]
def __init__(self):
super().__init__(name="containers", interval_seconds=5)
self._metric_tick = 0
async def run_once(self) -> None:
backend = get_backend()
try:
ps_rows = await backend.ps(label_filter=api.INSTANCE_LABEL)
except Exception as exc:
self.log(f"docker ps failed: {exc}")
return
by_uid = {}
for row in ps_rows:
uid = row.labels.get(api.INSTANCE_LABEL)
if uid:
by_uid[uid] = row
instances = store.all_instances()
known = {inst["uid"] for inst in instances}
for uid, row in by_uid.items():
if uid not in known:
try:
await backend.rm(row.container_id, force=True)
self.log(f"reaped orphan container {row.name}")
except Exception as exc:
self.log(f"orphan rm failed for {row.name}: {exc}")
for inst in instances:
try:
await self._reconcile(backend, inst, by_uid.get(inst["uid"]))
except Exception as exc:
self.log(f"reconcile {inst.get('name')} failed: {exc}")
await self._fire_schedules()
self._metric_tick += 1
if self._metric_tick % max(1, self.get_config().get("container_metrics_every", 1)) == 0:
await self._sample_metrics(backend, by_uid)
async def _reconcile(self, backend, inst, ps) -> None:
uid = inst["uid"]
desired = inst["desired_state"]
status = inst["status"]
if status == store.ST_REMOVING:
if ps is not None:
await backend.rm(ps.container_id, force=True)
store.delete_instance(uid)
self.log(f"removed instance {inst['name']}")
return
if desired == store.DESIRED_RUNNING:
if ps is None:
await self._launch(backend, inst)
elif ps.state == "running":
if status != store.ST_RUNNING:
store.update_instance(uid, {"status": store.ST_RUNNING, "container_id": ps.container_id,
"started_at": inst.get("started_at") or store.now()})
elif ps.state == "paused":
await backend.unpause(ps.container_id)
store.update_instance(uid, {"status": store.ST_RUNNING})
elif ps.state == "created":
await backend.start(ps.container_id)
store.update_instance(uid, {"status": store.ST_RUNNING})
else:
await self._handle_exit(backend, inst, ps)
elif desired == store.DESIRED_STOPPED:
if ps is not None and ps.state in ("running", "restarting", "paused"):
await backend.stop(ps.container_id)
if status != store.ST_STOPPED:
store.update_instance(uid, {"status": store.ST_STOPPED, "stopped_at": store.now()})
elif desired == store.DESIRED_PAUSED:
if ps is not None and ps.state == "running":
await backend.pause(ps.container_id)
store.update_instance(uid, {"status": store.ST_PAUSED})
async def _launch(self, backend, inst) -> None:
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
try:
cid = await backend.run(spec)
except Exception as exc:
store.update_instance(inst["uid"], {"status": store.ST_CRASHED})
store.record_event(inst, "launch_failed", "reconciler", "", {"reason": str(exc)})
self.log(f"launch {inst['name']} failed: {exc}")
return
store.update_instance(inst["uid"], {"container_id": cid, "status": store.ST_RUNNING, "started_at": store.now()})
store.record_event(inst, "start", "reconciler", "")
self.log(f"launched instance {inst['name']}")
async def _exit_logs(self, backend, container_id: str) -> str:
lines: list = []
async def collect(line: str) -> None:
lines.append(line)
try:
await backend.logs(container_id, follow=False, tail=20, on_log=collect)
except Exception:
return ""
return "\n".join(lines)[-2000:]
async def _handle_exit(self, backend, inst, ps) -> None:
uid = inst["uid"]
exit_code = ps.exit_code or 0
logs = await self._exit_logs(backend, ps.container_id)
policy = inst.get("restart_policy", "never")
if policy in AUTO_RESTART and not (policy == "on-failure" and exit_code == 0):
try:
await backend.start(ps.container_id)
store.update_instance(uid, {"status": store.ST_RUNNING,
"restart_count": int(inst.get("restart_count") or 0) + 1})
store.record_event(inst, "policy_restart", "reconciler", "",
{"exit_code": exit_code, "logs": logs})
return
except Exception as exc:
self.log(f"policy restart {inst['name']} failed: {exc}")
terminal = store.ST_CRASHED if exit_code != 0 else store.ST_STOPPED
store.update_instance(uid, {"status": terminal, "desired_state": store.DESIRED_STOPPED,
"exit_code": exit_code, "stopped_at": store.now()})
if terminal == store.ST_CRASHED:
store.record_event(inst, "crash", "reconciler", "", {"exit_code": exit_code, "logs": logs})
async def _fire_schedules(self) -> None:
now = now_utc()
now_iso = to_iso(now)
for sched in store.due_schedules(now_iso):
inst = store.get_instance(sched["instance_uid"])
if inst is None:
store.delete_schedule(sched["uid"])
continue
action = sched["action"]
desired = store.DESIRED_RUNNING if action == "start" else store.DESIRED_STOPPED
store.update_instance(inst["uid"], {"desired_state": desired})
store.record_event(inst, f"schedule_{action}", "scheduler", "")
cols = json.loads(sched.get("schedule_json") or "{}")
run_count = int(sched.get("run_count") or 0) + 1
upcoming = next_run(cols.get("kind"), cols.get("every_seconds"), cols.get("cron"), now)
changes = {"last_run_at": now_iso, "run_count": run_count}
max_runs = cols.get("max_runs")
if upcoming is None or (max_runs and run_count >= max_runs):
changes["enabled"] = 0
changes["next_run_at"] = ""
else:
changes["next_run_at"] = to_iso(upcoming)
store.update_schedule(sched["uid"], changes)
self.log(f"schedule fired: {action} {inst['name']}")
async def _sample_metrics(self, backend, by_uid) -> None:
running = {uid: row for uid, row in by_uid.items() if row.state == "running"}
if not running:
return
try:
samples = await backend.stats_once([row.container_id for row in running.values()])
except Exception as exc:
self.log(f"docker stats failed: {exc}")
return
name_to_uid = {row.name: uid for uid, row in running.items()}
for name, sample in samples.items():
uid = name_to_uid.get(name)
if uid:
store.insert_metric(uid, sample)
def collect_metrics(self) -> dict:
instances = store.all_instances()
counts = {}
for inst in instances:
counts[inst["status"]] = counts.get(inst["status"], 0) + 1
running = counts.get(store.ST_RUNNING, 0)
stats = [
{"label": "Instances", "value": len(instances)},
{"label": "Running", "value": running},
{"label": "Stopped", "value": counts.get(store.ST_STOPPED, 0)},
{"label": "Crashed", "value": counts.get(store.ST_CRASHED, 0)},
{"label": "Paused", "value": counts.get(store.ST_PAUSED, 0)},
]
rows = [[
inst.get("name", "")[:32], inst.get("status", ""), inst.get("desired_state", ""),
inst.get("restart_policy", ""), int(inst.get("restart_count") or 0),
] for inst in instances[:15]]
table = {"columns": ["Instance", "Status", "Desired", "Policy", "Restarts"], "rows": rows}
return {"stats": stats, "table": table}

View File

@ -0,0 +1,161 @@
# retoor <retoor@molodetz.nl>
import json
from datetime import datetime, timezone
from devplacepy.database import db, get_table
from devplacepy.utils import generate_uid, make_combined_slug
ST_CREATED = "created"
ST_STARTING = "starting"
ST_RUNNING = "running"
ST_STOPPED = "stopped"
ST_PAUSED = "paused"
ST_CRASHED = "crashed"
ST_RESTARTING = "restarting"
ST_REMOVING = "removing"
ST_REMOVED = "removed"
DESIRED_RUNNING = "running"
DESIRED_STOPPED = "stopped"
DESIRED_PAUSED = "paused"
RESTART_POLICIES = ("never", "always", "on-failure", "unless-stopped")
METRICS_RING = 720
def now() -> str:
return datetime.now(timezone.utc).isoformat()
def _exists(table: str) -> bool:
return table in db.tables
# ---------------- instances ----------------
def create_instance(row: dict) -> dict:
uid = generate_uid()
base = {
"uid": uid, "slug": make_combined_slug(row.get("name", "instance"), uid),
"container_id": "", "status": ST_CREATED, "exit_code": 0, "restart_count": 0,
"ingress_slug": "", "ingress_port": 0,
"started_at": "", "stopped_at": "", "created_at": now(), "updated_at": now(),
}
base.update(row)
base["uid"] = uid
get_table("instances").insert(base)
return get_instance(uid)
def get_instance(uid: str):
table = get_table("instances")
if not _exists("instances"):
return None
return table.find_one(uid=uid) or table.find_one(slug=uid)
def list_instances(project_uid: str = None) -> list:
if not _exists("instances"):
return []
filters = {}
if project_uid:
filters["project_uid"] = project_uid
return sorted(get_table("instances").find(**filters),
key=lambda r: r.get("created_at", ""), reverse=True)
def all_instances() -> list:
if not _exists("instances"):
return []
return list(get_table("instances").find())
def find_instance_by_ingress(slug: str):
if not slug or not _exists("instances"):
return None
matches = list(get_table("instances").find(ingress_slug=slug))
for instance in matches:
if instance.get("status") == ST_RUNNING:
return instance
return matches[0] if matches else None
def update_instance(uid: str, changes: dict) -> None:
get_table("instances").update({"uid": uid, "updated_at": now(), **changes}, ["uid"])
def delete_instance(uid: str) -> None:
get_table("instances").delete(uid=uid)
# ---------------- events / metrics / schedules ----------------
def record_event(instance: dict, event: str, actor_kind: str, actor_id: str, detail: dict = None) -> None:
get_table("instance_events").insert({
"uid": generate_uid(), "instance_uid": instance["uid"], "project_uid": instance.get("project_uid", ""),
"event": event, "actor_kind": actor_kind, "actor_id": actor_id or "",
"detail": json.dumps(detail or {}), "created_at": now(),
})
def list_events(instance_uid: str, limit: int = 100) -> list:
if not _exists("instance_events"):
return []
rows = sorted(get_table("instance_events").find(instance_uid=instance_uid),
key=lambda r: r.get("created_at", ""), reverse=True)
return rows[:limit]
def insert_metric(instance_uid: str, sample) -> None:
table = get_table("instance_metrics")
table.insert({
"uid": generate_uid(), "instance_uid": instance_uid, "ts": now(),
"cpu_pct": sample.cpu_pct, "mem_bytes": sample.mem_bytes, "mem_pct": sample.mem_pct,
"net_rx": sample.net_rx, "net_tx": sample.net_tx,
"blk_read": sample.blk_read, "blk_write": sample.blk_write,
})
rows = sorted(table.find(instance_uid=instance_uid), key=lambda r: r.get("ts", ""))
if len(rows) > METRICS_RING:
for old in rows[:len(rows) - METRICS_RING]:
table.delete(uid=old["uid"])
def recent_metrics(instance_uid: str, limit: int = 120) -> list:
if not _exists("instance_metrics"):
return []
rows = sorted(get_table("instance_metrics").find(instance_uid=instance_uid),
key=lambda r: r.get("ts", ""))
return rows[-limit:]
def create_schedule(instance: dict, action: str, schedule_columns: dict, next_run_at: str) -> dict:
uid = generate_uid()
get_table("instance_schedules").insert({
"uid": uid, "instance_uid": instance["uid"], "project_uid": instance.get("project_uid", ""),
"action": action, "schedule_json": json.dumps(schedule_columns), "enabled": 1,
"next_run_at": next_run_at, "last_run_at": "", "run_count": 0,
"created_at": now(), "updated_at": now(),
})
return get_table("instance_schedules").find_one(uid=uid)
def list_schedules(instance_uid: str) -> list:
if not _exists("instance_schedules"):
return []
return list(get_table("instance_schedules").find(instance_uid=instance_uid))
def due_schedules(now_iso: str) -> list:
if not _exists("instance_schedules"):
return []
return [r for r in get_table("instance_schedules").find(enabled=1)
if r.get("next_run_at") and r["next_run_at"] <= now_iso]
def update_schedule(uid: str, changes: dict) -> None:
get_table("instance_schedules").update({"uid": uid, "updated_at": now(), **changes}, ["uid"])
def delete_schedule(uid: str) -> None:
get_table("instance_schedules").delete(uid=uid)

View File

@ -205,6 +205,41 @@ ACTIONS: tuple[Action, ...] = (
summary="Delete a project",
params=(path("project_slug", "Exact project slug copied from a /projects/... link in a listing response; do not build it from the title."),),
),
Action(
name="project_set_private",
method="POST",
path="/projects/{project_slug}/private",
summary="Mark a project private (owner-only) or public",
description=(
"Set value=true to hide the project from everyone except its owner (and administrators), "
"or value=false to make it public again. This is a significant change: you MUST ask the "
"user for explicit confirmation BEFORE calling it, and only pass confirm=true once they "
"have agreed. Only the project owner may change this."
),
params=(
path("project_slug", "Project slug or uid."),
body("value", "true to make the project private, false to make it public.", required=True),
body("confirm", "Must be true, set only after the user has explicitly confirmed.", required=True),
),
),
Action(
name="project_set_readonly",
method="POST",
path="/projects/{project_slug}/readonly",
summary="Mark a project read-only (immutable files) or writable again",
description=(
"Set value=true to make every file in the project immutable - no writes, edits, line "
"edits, moves, deletes, or uploads succeed afterwards, from anyone including you - or "
"value=false to allow changes again. This is a significant change: you MUST ask the user "
"for explicit confirmation BEFORE calling it, and only pass confirm=true once they have "
"agreed. Only the project owner may change this."
),
params=(
path("project_slug", "Project slug or uid."),
body("value", "true to make the project read-only, false to make it writable.", required=True),
body("confirm", "Must be true, set only after the user has explicitly confirmed.", required=True),
),
),
Action(
name="project_list_files",
method="GET",
@ -230,14 +265,97 @@ ACTIONS: tuple[Action, ...] = (
name="project_write_file",
method="POST",
path="/projects/{project_slug}/files/write",
summary="Create or overwrite a text file in a project (parent directories are created automatically)",
description="The primary tool for building a project: write any text file by path. Missing parent directories are created recursively.",
summary="Create or overwrite a whole text file in a project (parent directories are created automatically)",
description=(
"Replaces the ENTIRE file with the content you send. Creating a new file needs no prior "
"read, but overwriting an existing one requires reading it first (project_read_file). "
"For an existing file prefer the surgical line tools (project_replace_lines, "
"project_insert_lines, project_delete_lines, project_append_file) instead of rewriting it, "
"and never batch several writes in one turn. Use this only to create a new file or fully "
"rewrite a small one. Missing parent directories are created recursively."
),
params=(
path("project_slug", "Project slug or uid."),
body("path", "Relative file path, e.g. src/app/main.py.", required=True),
body("content", "Full file content.", required=True),
),
),
Action(
name="project_read_lines",
method="GET",
path="/projects/{project_slug}/files/lines",
summary="Read a 1-indexed line range of a text file in a project",
description=(
"Returns {path, start, end, total_lines, lines, content} for the requested range. Use it "
"to inspect part of a large file before editing, and to learn total_lines so you can target "
"the right range with the line-edit tools."
),
params=(
path("project_slug", "Project slug or uid."),
query("path", "Relative file path inside the project.", required=True),
query("start", "First line to read (1-indexed, default 1)."),
query("end", "Last line to read (inclusive); omit for end of file."),
),
requires_auth=False,
),
Action(
name="project_replace_lines",
method="POST",
path="/projects/{project_slug}/files/replace-lines",
summary="Replace an inclusive 1-indexed line range of a text file with new content",
description=(
"Surgically rewrites lines start..end (inclusive) with content (which may be any number of "
"lines, or empty to delete the range). The preferred way to edit an existing file: it leaves "
"the rest of the file untouched and never overflows the model output limit."
),
params=(
path("project_slug", "Project slug or uid."),
body("path", "Relative file path.", required=True),
body("start", "First line to replace (1-indexed).", required=True),
body("end", "Last line to replace (inclusive).", required=True),
body("content", "Replacement text for those lines (empty deletes them)."),
),
),
Action(
name="project_insert_lines",
method="POST",
path="/projects/{project_slug}/files/insert-lines",
summary="Insert content before a 1-indexed line of a text file",
description=(
"Inserts content before line 'at' without touching existing lines. Use at=1 to prepend and "
"at=total_lines+1 to insert at the end."
),
params=(
path("project_slug", "Project slug or uid."),
body("path", "Relative file path.", required=True),
body("at", "Insert before this 1-indexed line (1 prepends, total+1 appends).", required=True),
body("content", "Text to insert.", required=True),
),
),
Action(
name="project_delete_lines",
method="POST",
path="/projects/{project_slug}/files/delete-lines",
summary="Delete an inclusive 1-indexed line range from a text file",
params=(
path("project_slug", "Project slug or uid."),
body("path", "Relative file path.", required=True),
body("start", "First line to delete (1-indexed).", required=True),
body("end", "Last line to delete (inclusive).", required=True),
),
),
Action(
name="project_append_file",
method="POST",
path="/projects/{project_slug}/files/append",
summary="Append content to the end of a text file in a project",
description="Adds content as new lines at the end of the file. Use it to grow a large file across turns without resending the whole thing.",
params=(
path("project_slug", "Project slug or uid."),
body("path", "Relative file path.", required=True),
body("content", "Text to append.", required=True),
),
),
Action(
name="project_upload_file",
method="POST",
@ -304,6 +422,33 @@ ACTIONS: tuple[Action, ...] = (
params=(path("uid", "Zip job uid returned by zip_project."),),
requires_auth=False,
),
Action(
name="fork_project",
method="POST",
path="/projects/{project_slug}/fork",
summary="Fork a project into a new project owned by the current user",
description=(
"Queues a background fork job and returns {uid, status_url}. Poll the status_url with "
"fork_status until status is 'done', then give the user the project_url of the new fork."
),
params=(
path("project_slug", "Slug or uid of the project to fork."),
body("title", "Title for the new forked project.", required=True),
),
requires_auth=True,
),
Action(
name="fork_status",
method="GET",
path="/forks/{uid}",
summary="Check a fork job and obtain the new project once finished",
description=(
"Returns the job status. When status is 'done', project_url points at the new forked "
"project; while 'pending' or 'running', poll again shortly."
),
params=(path("uid", "Fork job uid returned by fork_project."),),
requires_auth=False,
),
Action(
name="search_users",
method="GET",

View File

@ -17,6 +17,7 @@ CHUNK_ACTIONS: tuple[Action, ...] = (
),
handler="chunks",
requires_auth=False,
read_only=True,
params=(
Param(
name="chunk_id",

View File

@ -23,6 +23,7 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
description=CLIENT + " Use this to understand where the user is and what they are looking at before acting or guiding them.",
handler="client",
requires_auth=False,
read_only=True,
),
Action(
name="run_js",
@ -109,4 +110,22 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
handler="client",
requires_auth=False,
),
Action(
name="open_terminal",
method="LOCAL",
path="",
summary="Open a floating interactive terminal window attached to a running container instance",
description=(
CLIENT + " Opens a new xterm.js window in the user's browser connected to the container's "
"interactive shell (admin only). Resolve the instance first with container_list_instances, "
"then pass the project slug and the instance slug or uid."
),
handler="client",
requires_admin=True,
params=(
arg("project_slug", "Project slug or uid that owns the container.", required=True),
arg("instance", "Container instance slug or uid.", required=True),
arg("label", "Optional window title (defaults to the instance name)."),
),
),
)

View File

@ -0,0 +1,88 @@
# retoor <retoor@molodetz.nl>
from .spec import Action, Param
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
SLUG = arg("project_slug", "Project slug or uid that owns the container resources.", required=True)
CONTAINER_ACTIONS: tuple[Action, ...] = (
Action(
name="container_list_instances",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
summary="List a project's container instances and their status",
params=(SLUG,),
),
Action(
name="container_create_instance",
method="LOCAL", path="", handler="container", requires_admin=True,
summary="Create and start a container instance (runs the shared ppy image with the project files mounted at /app)",
params=(
SLUG,
arg("name", "Instance name.", required=True),
arg("boot_command", "Optional command to run on boot, e.g. 'python app.py'."),
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
arg("env", "Optional env vars as KEY=VALUE lines."),
arg("ports", "Port maps per line or comma separated. Use a bare container port (e.g. '8899') to auto-assign a unique host port above 20000, or 'host:container' to pin one."),
arg("cpu_limit", "Optional CPU limit, e.g. 1 or 1.5."),
arg("mem_limit", "Optional memory limit, e.g. 512m or 1g."),
arg("autostart", "Start immediately ('true' or 'false', default true)."),
arg("ingress_slug", "Optional public ingress slug; the service is then reachable at /p/<slug>."),
arg("ingress_port", "Container port to publish at /p/<slug> (must be one of the mapped ports).", kind="integer"),
),
),
Action(
name="container_instance_action",
method="LOCAL", path="", handler="container", requires_admin=True,
summary="Control an instance: start, stop, restart, pause, resume, delete, or sync",
description="sync imports the container /app workspace back into the project files.",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg("action", "start, stop, restart, pause, resume, delete, or sync.", required=True),
),
),
Action(
name="container_logs",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
summary="Read the recent logs of a running instance",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg("tail", "Number of log lines (default 200).", kind="integer"),
),
),
Action(
name="container_exec",
method="LOCAL", path="", handler="container", requires_admin=True,
summary="Run a one-shot command inside a running instance and return its output",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg("command", "Command to run, e.g. 'pip list'.", required=True),
),
),
Action(
name="container_stats",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
summary="Get aggregated resource and runtime statistics for an instance",
params=(SLUG, arg("instance", "Instance name, slug, or uid.", required=True)),
),
Action(
name="container_schedule",
method="LOCAL", path="", handler="container", requires_admin=True,
summary="Schedule a start or stop of an instance (cron, interval, or one-time)",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg("action", "start or stop.", required=True),
arg("kind", "once, interval, or cron.", required=True),
arg("cron", "Cron expression for kind=cron, e.g. '0 2 * * *'."),
arg("run_at", "ISO time for kind=once, e.g. 2026-06-15T02:00:00."),
arg("every_seconds", "Interval seconds for kind=interval.", kind="integer"),
),
),
)

View File

@ -18,6 +18,7 @@ COST_ACTIONS: tuple[Action, ...] = (
),
handler="cost",
requires_auth=False,
read_only=True,
),
Action(
name="cost_stats",
@ -33,5 +34,6 @@ COST_ACTIONS: tuple[Action, ...] = (
handler="cost",
requires_auth=True,
requires_admin=True,
read_only=True,
),
)

View File

@ -0,0 +1,100 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
SCOPE = (
"Either 'global' (applies to every page of the site for this user) or a page-type string "
"as returned by get_page_context.pageType (e.g. '/posts/{slug}'), which applies to every "
"page of that type."
)
CUSTOMIZATION_ACTIONS: tuple[Action, ...] = (
Action(
name="customize_list",
method="LOCAL",
path="",
summary="List the user's saved CSS/JS customizations (scopes, languages, sizes)",
description=(
"Returns every stored customization for the current user (or guest session), with its "
"scope, language, enabled flag and a short preview of the code."
),
handler="customization",
requires_auth=False,
read_only=True,
),
Action(
name="customize_get",
method="LOCAL",
path="",
summary="Read the full saved CSS or JS code for one scope",
description="Use this before editing so you build on existing customization rather than overwriting it.",
handler="customization",
requires_auth=False,
read_only=True,
params=(
arg("scope", SCOPE, required=True),
arg("lang", "Which code to read: 'css' or 'js'.", required=True),
),
),
Action(
name="customize_set_css",
method="LOCAL",
path="",
summary="Permanently save custom CSS for a page type or the whole site",
description=(
"Persists custom CSS for the current user. Before calling, ask the user whether it should "
"apply to the current page TYPE or to the whole site (global). The CSS is the final, full "
"stylesheet for that scope and replaces any previously saved CSS for it."
),
handler="customization",
requires_auth=False,
params=(
arg("scope", SCOPE, required=True),
arg("css", "The full CSS source to save for this scope.", required=True),
arg("confirm", "Must be true after the user has confirmed the scope.", required=True, kind="boolean"),
),
),
Action(
name="customize_set_js",
method="LOCAL",
path="",
summary="Permanently save custom JavaScript for a page type or the whole site",
description=(
"Persists custom JavaScript for the current user. Before calling, ask the user whether it "
"should apply to the current page TYPE or to the whole site (global). The code runs after "
"the application boots, with access to window, document and the global 'app'. It is the "
"final, full script for that scope and replaces any previously saved JS for it."
),
handler="customization",
requires_auth=False,
params=(
arg("scope", SCOPE, required=True),
arg("js", "The full JavaScript source to save for this scope.", required=True),
arg("confirm", "Must be true after the user has confirmed the scope.", required=True, kind="boolean"),
),
),
Action(
name="customize_reset",
method="LOCAL",
path="",
summary="Delete saved customizations, restoring the default look and behaviour",
description=(
"Removes saved customizations. Omit lang to remove both CSS and JS for the scope. Pass "
"scope='all' to remove every customization for this user. This cannot be undone."
),
handler="customization",
requires_auth=False,
params=(
arg("scope", SCOPE + " Use 'all' to remove every customization.", required=True),
arg("lang", "Optional: limit the reset to 'css' or 'js'. Omit to remove both."),
arg("confirm", "Must be true after the user has confirmed the deletion.", required=True, kind="boolean"),
),
),
)

View File

@ -30,9 +30,48 @@ from .spec import Action, Catalog
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
CONFIRM_REQUIRED = {"project_set_readonly", "project_set_private", "customize_set_css", "customize_set_js", "customize_reset"}
logger = logging.getLogger("devii.dispatch")
def _is_confirmed(arguments: dict[str, Any]) -> bool:
return str(arguments.get("confirm", "")).strip().lower() in ("true", "1", "yes", "on")
def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError | None:
if name not in CONFIRM_REQUIRED or _is_confirmed(arguments):
return None
if name in ("customize_set_css", "customize_set_js"):
scope = str(arguments.get("scope", "")).strip() or "(unspecified)"
return ToolInputError(
"Before saving, confirm the scope with the user: should this apply to THIS page type "
f"('{scope}') or to the whole site ('global')? Ask them, then call again with the chosen "
"scope and confirm=true."
)
if name == "customize_reset":
return ToolInputError(
"Deleting customizations cannot be undone. Ask the user to confirm, then call again with "
"confirm=true."
)
if name == "project_set_private":
making_private = str(arguments.get("value", "")).strip().lower() in ("true", "1", "yes", "on")
change = (
"hide the project from everyone except its owner and administrators"
if making_private
else "make the project public so everyone can see it and all its files"
)
return ToolInputError(
f"Changing a project's visibility will {change}. Ask the user to confirm this explicitly "
"first, then call again with confirm=true."
)
return ToolInputError(
"Setting a project read-only makes every file immutable and blocks all further "
"edits. Ask the user to confirm this explicitly first, then call again with "
"confirm=true."
)
class Dispatcher:
def __init__(
self,
@ -45,6 +84,9 @@ class Dispatcher:
browser: Any = None,
is_admin: bool = False,
quota_provider: Any = None,
owner_kind: str = "guest",
owner_id: str = "",
virtual_tools: Any = None,
) -> None:
self._actions = catalog.by_name()
self._client = client
@ -59,10 +101,41 @@ class Dispatcher:
self._cost = CostController(quota_provider=quota_provider)
self._chunks = ChunkController(settings)
self._rsearch = RsearchController(settings)
from ..container import ContainerController
self._container = ContainerController(client)
from ..customization import CustomizationController
self._customization = CustomizationController(owner_kind, owner_id)
self._virtual_tools = virtual_tools
self._read_files: set[tuple[str, str]] = set()
@staticmethod
def _file_key(arguments: dict[str, Any]) -> tuple[str, str] | None:
from devplacepy.project_files import normalize_path, ProjectFileError as _PFError
raw = arguments.get("path")
if not raw:
return None
try:
path = normalize_path(raw)
except _PFError:
return None
return str(arguments.get("project_slug", "")), path
def is_read_only(self, name: str) -> bool:
action = self._actions.get(name)
return bool(action and action.is_read_only)
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
action = self._actions.get(name)
if action is None:
if self._virtual_tools is not None and self._virtual_tools.has(name):
logger.info("Dispatch virtual tool %s args=%s", name, list(arguments))
try:
return await self._virtual_tools.run(name, arguments)
except DeviiError as exc:
return error_result(exc)
except Exception as exc: # noqa: BLE001 - surfaced to the model as data
logger.exception("Virtual tool %s crashed", name)
return unexpected_result(exc)
return error_result(ToolInputError(f"Unknown tool: {name}"))
logger.info("Dispatch %s args=%s", name, list(arguments))
@ -77,6 +150,9 @@ class Dispatcher:
"This information is restricted to administrators.",
tool=name,
)
guard = confirmation_error(name, arguments)
if guard is not None:
raise guard
resource_key = self._resource_key(action, arguments)
if resource_key:
cached = serve_resource(resource_key, self._settings.max_response_chars)
@ -136,6 +212,19 @@ class Dispatcher:
if action.handler == "rsearch":
return await self._rsearch.dispatch(action.name, arguments)
if action.handler == "container":
return await self._container.dispatch(action.name, arguments)
if action.handler == "customization":
return await self._customization.dispatch(action.name, arguments)
if action.handler == "virtual_tool":
if self._virtual_tools is None:
return error_result(
ToolInputError("User-defined tools are not available in this context.")
)
return await self._virtual_tools.dispatch(action.name, arguments)
if action.handler == "avatar":
if self._avatar is None:
return error_result(
@ -204,7 +293,30 @@ class Dispatcher:
return None
return None
async def _file_exists(self, arguments: dict[str, Any]) -> bool:
slug = str(arguments.get("project_slug", "")).strip()
path = str(arguments.get("path", "")).strip()
if not slug or not path:
return False
response = await self._client.call(
method="GET",
path=f"/projects/{quote(slug, safe='')}/files/raw",
params={"path": path},
headers={"X-Requested-With": "fetch"},
)
return response.status_code == 200
async def _run_http(self, action: Action, arguments: dict[str, Any]) -> str:
if action.name == "project_write_file":
key = self._file_key(arguments)
if key is not None and key not in self._read_files and await self._file_exists(arguments):
raise ToolInputError(
f"Read '{key[1]}' before overwriting it. It already exists; call "
"project_read_file first. For an existing file prefer the line tools "
"(project_replace_lines, project_insert_lines, project_delete_lines, "
"project_append_file); project_write_file replaces the entire file."
)
url_path, params, data, file_field = self._build_request(action, arguments)
headers = {"X-Requested-With": "fetch"} if action.ajax else None
@ -216,6 +328,10 @@ class Dispatcher:
file_field=file_field,
headers=headers,
)
if action.name in ("project_read_file", "project_read_lines", "project_write_file"):
key = self._file_key(arguments)
if key is not None:
self._read_files.add(key)
if action.method in MUTATING_METHODS:
record_mutation(action.name)
store = get_store()

View File

@ -17,6 +17,7 @@ DOCS_ACTIONS: tuple[Action, ...] = (
),
handler="docs",
requires_auth=False,
read_only=True,
params=(
Param(
name="query",

View File

@ -18,6 +18,7 @@ FETCH_ACTIONS: tuple[Action, ...] = (
),
handler="fetch",
requires_auth=False,
read_only=True,
params=(
Param(
name="url",

View File

@ -23,6 +23,7 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
),
handler="rsearch",
requires_auth=False,
read_only=True,
params=(
Param(name="query", location="body", description="The web search query.", required=True),
Param(name="count", location="body", description="Number of results (1-100, default 10).", type="integer"),
@ -43,6 +44,7 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
),
handler="rsearch",
requires_auth=False,
read_only=True,
params=(
Param(name="query", location="body", description="The question or prompt to answer.", required=True),
Param(name="content", location="body", description="Let the AI read full page content while answering.", type="boolean"),
@ -61,6 +63,7 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
),
handler="rsearch",
requires_auth=False,
read_only=True,
params=(
Param(name="prompt", location="body", description="The prompt to send.", required=True),
Param(name="json", location="body", description="Force a valid-JSON-only response.", type="boolean"),
@ -78,6 +81,7 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
),
handler="rsearch",
requires_auth=False,
read_only=True,
params=(
Param(name="url", location="body", description="Public URL of the image to describe.", required=True),
),

View File

@ -29,10 +29,15 @@ class Action:
requires_admin: bool = False
handler: Literal[
"http", "login", "logout", "status", "task", "agentic", "avatar", "client", "fetch",
"docs", "cost", "chunks", "rsearch"
"docs", "cost", "chunks", "rsearch", "container"
] = "http"
freeform_body: bool = False
ajax: bool = False
read_only: bool = False
@property
def is_read_only(self) -> bool:
return self.read_only or self.method.upper() == "GET"
def tool_schema(self) -> dict[str, Any]:
properties: dict[str, Any] = {}

View File

@ -70,6 +70,48 @@ SYSTEM_PROMPT = (
"trial-and-error, and never tell the user that a page or capability does not exist without "
"confirming against the docs. If a guess returns a 404, that means you guessed - search the docs "
"instead of concluding the feature is missing.\n\n"
"WRITING AND EDITING FILES\n"
"Creating a new file needs no prior read, but overwriting an existing one does: call "
"project_read_file first. project_write_file replaces the ENTIRE file and is rejected when the "
"path already exists and you have not read it this session, so use it only to create a new file "
"or fully rewrite a small one. To change an existing "
"file, prefer the surgical line tools - project_read_lines to inspect a range and learn total_lines, "
"then project_replace_lines, project_insert_lines, project_delete_lines, or project_append_file to "
"edit just the affected lines. These leave the rest of the file untouched and let you build or modify "
"very large files across turns without resending the whole thing. A single model completion has a "
"maximum output length: emit only ONE file write per turn and never batch several writes into one "
"response, or their combined content overflows the completion and the last file is truncated. If a "
"tool result has error 'tool_input_truncated', your output was cut off - resend that single write or "
"line edit on its own.\n\n"
"PROJECT PRIVACY AND READ-ONLY\n"
"A project owner can mark a project private (project_set_private) so only the owner and "
"administrators can see it, and read-only (project_set_readonly) so every file becomes immutable. "
"When a project is read-only, all file writes fail with 'project is read-only'; to edit such a "
"project you must first ask the owner for permission and set it writable again. Changing a "
"project's visibility or read-only state is significant, so you MUST obtain explicit user "
"confirmation before calling project_set_private or project_set_readonly (in either direction), "
"and only then pass confirm=true; never change visibility or lock a project on your own "
"initiative.\n\n"
"CONTAINERS (admin only)\n"
"When you are an administrator you can manage a project's containers with the container_* tools: "
"create and control instances (container_create_instance; container_instance_action: "
"start/stop/restart/pause/resume/delete/sync), read logs, run one-shot commands (container_exec), "
"inspect stats, and schedule starts/stops. There is no image building: every instance runs the "
"shared prebuilt 'ppy' image (Python + Playwright + common libraries) with the project's files "
"mounted at /app, so creating an instance is instant. If a project needs an extra Python package, "
"install it at runtime via container_exec ('pip install ...') - no sudo is needed. Instances run on "
"the host docker daemon, so confirm destructive actions and never expose backend or infrastructure "
"detail. These tools are unavailable to non-administrators. "
"When the user asks to attach, open, show, reopen, or get back a terminal/shell for a container, "
"ALWAYS call open_terminal (resolve the instance with container_list_instances first if you do not "
"already know its slug/uid). Call it EVERY time it is asked - even if you opened a terminal earlier "
"in this conversation - because the user may have closed the window; open_terminal reopens the "
"window or focuses the existing one, and (thanks to tmux) it re-attaches to the same running shell. "
"Never reply that the terminal is 'already open' without actually calling open_terminal. "
"A running instance can be published with an ingress slug; the container tools then return an "
"'ingress_url'. To reach a published service, use that 'ingress_url' (the configured public URL, "
"e.g. https://host/p/<slug>) - never localhost or 127.0.0.1, which web tools refuse. If ingress_url "
"is a relative /p/<slug>, the admin has not set the public Site URL in admin settings yet.\n\n"
"REMOTE WEB TOOLS (rsearch)\n"
"The rsearch_* tools (rsearch, rsearch_answer, rsearch_chat, rsearch_describe_image) reach an "
"EXTERNAL public web/AI service, not this platform. They are not platform-specific, so platform "
@ -78,6 +120,35 @@ SYSTEM_PROMPT = (
"information the platform cannot provide and the user wants it. Never use them to answer questions "
"about this DevPlace instance, its users, posts, settings, or metrics - those have dedicated "
"platform tools. When platform tools can serve the request, do not call rsearch.\n\n"
"CUSTOMIZATION (CSS AND JAVASCRIPT)\n"
"The user can permanently customize the LOOK (custom CSS) and BEHAVIOUR (custom JavaScript) of "
"the site through the customize_* tools. Every customization is scoped either to the current "
"page TYPE (it then applies to every page of that type, for example all post pages, never a "
"single specific post) or GLOBALLY (every page). It applies only to this user's own sessions; a "
"signed-out guest gets customizations for their current session. To learn the current page type, "
"call get_page_context and read its pageType field, and pass that exact value as the scope; pass "
"scope='global' for the whole site. "
"Iterate by PREVIEWING live first: inject a draft with run_js, replacing an element with id "
"'devii-preview-css' (a <style>) or 'devii-preview-js' (run the code), so the user sees it "
"immediately WITHOUT saving. Do not persist while experimenting. Before you SAVE "
"(customize_set_css / customize_set_js) you MUST ask the user whether it is for the current page "
"type or the whole site; only after they choose, call the tool with that scope and confirm=true. "
"Saved code is the full, final stylesheet or script for that scope and replaces what was there, "
"so call customize_get first and build on the existing code instead of dropping it. Saved JS runs "
"after the application boots, with access to window, document and the global 'app'. After saving, "
"call reload_page so the server-applied version is shown. List existing customizations with "
"customize_list, and remove them with customize_reset (confirm=true; scope='all' removes "
"everything) to restore the default look and behaviour.\n\n"
"USER-DEFINED TOOLS (VIBE TOOLS)\n"
"The user can invent new tools for you by describing them ('when I say X, do Y'). When they do, "
"call tool_create with a short trigger-oriented description (so you know when to use it), the "
"instruction prompt the tool should run, and an input_description for its single free-form input. "
"The new tool becomes callable on the next turn and appears in your toolset; when called, it "
"re-runs you on its stored prompt plus the user's input and returns the result. Manage these with "
"tool_list, tool_get, tool_update (including enabling/disabling), and tool_delete. Use eval(prompt) "
"to run yourself on an arbitrary prompt and get the result. A user-defined tool covers all of that "
"user's sessions (for a guest, only the current session). Nesting is bounded: do not design a tool "
"or eval that keeps calling itself, as the self-evaluation depth is limited.\n\n"
"RESPONSE STYLE\n"
"Replies are plain, concise, and professional. Never use emojis, decorative symbols, or "
"celebratory language; report outcomes matter-of-factly. State what changed using the "

View File

@ -98,4 +98,20 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
arg("allowed_tools", "Optional list of tool names the sub-agent may use.", kind="array"),
),
),
Action(
name="eval",
method="LOCAL",
path="",
summary="Run a prompt through a fresh Devii sub-agent and return its result (self-evaluation)",
description=(
"Executes the given prompt as if it were a new request to yourself, with full tool "
"access, and returns the result. This is the engine behind user-defined tools. Nesting is "
"limited, so do not build deep self-calling chains."
),
handler="agentic",
requires_auth=False,
params=(
arg("prompt", "The instruction to run as a fresh sub-agent request.", required=True),
),
),
)

View File

@ -10,7 +10,14 @@ from ..config import Settings
from ..errors import ToolInputError
from .lessons import LessonStore
from .loop import TraceCallback, react_loop
from .state import AgentState, get_state
from .state import (
MAX_EVAL_DEPTH,
AgentState,
get_eval_depth,
get_state,
reset_eval_depth,
set_eval_depth,
)
logger = logging.getLogger("devii.agentic.controller")
@ -58,6 +65,7 @@ class AgenticController:
"forget_lessons": self._forget,
"verify": self._verify,
"delegate": self._delegate,
"eval": self._eval,
}
handler = handlers.get(name)
if handler is None:
@ -147,12 +155,53 @@ class AgenticController:
{"status": "success", "verified": bool(confirmed), "summary": summary}, ensure_ascii=False
)
async def _spawn(
self, prompt: str, tools: list[dict[str, Any]], system_prompt: str = SUB_AGENT_SYSTEM_PROMPT
) -> tuple[str, AgentState]:
if self._llm is None or self._dispatcher is None:
raise ToolInputError("Sub-agent execution is not available in this context.")
depth = get_eval_depth()
if depth >= MAX_EVAL_DEPTH:
raise ToolInputError(
"Nested self-evaluation limit reached; a tool or eval cannot keep calling itself."
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
sub_state = AgentState()
token = set_eval_depth(depth + 1)
try:
result = await react_loop(
llm=self._llm,
dispatcher=self._dispatcher,
messages=messages,
tools=tools,
state=sub_state,
settings=self._settings,
max_iterations=self._settings.delegate_max_iterations,
plan_required=self._settings.plan_required,
verify_required=self._settings.verify_required,
on_trace=self._on_trace,
cost_tracker=self._cost_tracker,
chunk_store=self._chunk_store,
)
finally:
reset_eval_depth(token)
return result, sub_state
async def run_subagent(self, prompt: str) -> str:
prompt = str(prompt or "").strip()
if not prompt:
raise ToolInputError("A non-empty prompt is required.")
tools = [tool for tool in self._tools if tool["function"]["name"] != NO_DELEGATE]
result, _ = await self._spawn(prompt, tools)
return result
async def _delegate(self, arguments: dict[str, Any]) -> str:
task = str(arguments.get("task", "")).strip()
if not task:
raise ToolInputError("delegate requires a task description.")
if self._llm is None or self._dispatcher is None:
raise ToolInputError("Delegation is not available in this context.")
allowed = arguments.get("allowed_tools")
if allowed:
allowed_set = {str(name) for name in allowed}
@ -163,26 +212,7 @@ class AgenticController:
]
else:
tools = [tool for tool in self._tools if tool["function"]["name"] != NO_DELEGATE]
messages = [
{"role": "system", "content": SUB_AGENT_SYSTEM_PROMPT},
{"role": "user", "content": task},
]
sub_state = AgentState()
result = await react_loop(
llm=self._llm,
dispatcher=self._dispatcher,
messages=messages,
tools=tools,
state=sub_state,
settings=self._settings,
max_iterations=self._settings.delegate_max_iterations,
plan_required=self._settings.plan_required,
verify_required=self._settings.verify_required,
on_trace=self._on_trace,
cost_tracker=self._cost_tracker,
chunk_store=self._chunk_store,
)
result, sub_state = await self._spawn(task, tools)
return json.dumps(
{
"status": "success",
@ -193,3 +223,10 @@ class AgenticController:
},
ensure_ascii=False,
)
async def _eval(self, arguments: dict[str, Any]) -> str:
prompt = str(arguments.get("prompt", "")).strip()
if not prompt:
raise ToolInputError("eval requires a non-empty prompt.")
result = await self.run_subagent(prompt)
return json.dumps({"status": "success", "result": result}, ensure_ascii=False)

View File

@ -37,6 +37,7 @@ ITERATION_LIMIT_MESSAGE = "[stopped] Maximum iterations reached without a final
LABEL_KEYS = (
"path",
"post_slug",
"project_slug",
"gist_slug",
@ -75,6 +76,12 @@ def call_label(call: dict[str, Any]) -> str:
target_type = arguments.get("target_type")
target = clean(arguments["target_uid"])
return f"{target_type}/{target}" if target_type else target
if arguments.get("command"):
return clean(arguments["command"])
if arguments.get("instance"):
instance = clean(arguments["instance"])
action = arguments.get("action")
return f"{clean(action)} {instance}" if action else instance
for key in LABEL_KEYS:
value = arguments.get(key)
if value not in (None, ""):
@ -122,10 +129,18 @@ async def _run_tool_call(dispatcher: Any, call: dict[str, Any]) -> str:
raw_arguments = function.get("arguments") or "{}"
try:
arguments = json.loads(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments
if not isinstance(arguments, dict):
raise ValueError("arguments must be an object")
except ValueError as exc:
return json.dumps({"error": "tool_input_error", "message": f"Invalid arguments: {exc}"})
except json.JSONDecodeError as exc:
return json.dumps({
"error": "tool_input_truncated",
"message": (
f"The arguments for {name or 'this tool'} were cut off and could not be parsed "
f"({exc.msg} at position {exc.pos}); the model output hit its length limit. "
"Emit only one write tool call per turn (do not batch several file writes into a "
"single response) and resend this one call on its own."
),
})
if not isinstance(arguments, dict):
return json.dumps({"error": "tool_input_error", "message": "Invalid arguments: arguments must be an object"})
return await dispatcher.dispatch(name, arguments)
@ -169,7 +184,12 @@ async def react_loop(
tool_calls = message.get("tool_calls") or []
if tool_calls:
if plan_required and state.plan is None and tool_calls[0]["function"]["name"] != "plan":
needs_plan = plan_required and state.plan is None and any(
call["function"]["name"] != "plan"
and not dispatcher.is_read_only(call["function"]["name"])
for call in tool_calls
)
if needs_plan:
trace("plan-gate")
for call in tool_calls:
messages.append(

View File

@ -7,6 +7,9 @@ from dataclasses import dataclass, field
from typing import Any, Optional
_active_state: contextvars.ContextVar = contextvars.ContextVar("devii_agent_state", default=None)
_eval_depth: contextvars.ContextVar = contextvars.ContextVar("devii_eval_depth", default=0)
MAX_EVAL_DEPTH = 2
@dataclass
@ -35,3 +38,15 @@ def record_mutation(name: str) -> None:
state = _active_state.get()
if state is not None:
state.mutations.add(name)
def get_eval_depth() -> int:
return _eval_depth.get()
def set_eval_depth(value: int) -> contextvars.Token:
return _eval_depth.set(value)
def reset_eval_depth(token: contextvars.Token) -> None:
_eval_depth.reset(token)

View File

@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.devii.container.controller import ContainerController
__all__ = ["ContainerController"]

View File

@ -0,0 +1,152 @@
# retoor <retoor@molodetz.nl>
import json
import logging
from typing import Any
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.services.containers import api, store
from devplacepy.services.containers.api import ContainerError
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.devii.errors import ToolInputError
from devplacepy.services.devii.tasks.schedule import Schedule, from_iso
logger = logging.getLogger("devii.container")
class ContainerController:
def __init__(self, client: Any = None) -> None:
self._client = client
def _ingress_url(self, instance: dict):
slug = instance.get("ingress_slug")
if not slug:
return None
from devplacepy.seo import public_base_url
base = public_base_url()
return f"{base}/p/{slug}" if base else f"/p/{slug}"
def _actor_user(self) -> dict:
username = getattr(self._client, "username", None)
if username:
user = get_table("users").find_one(username=username)
if user:
return user
return {"uid": "admin", "username": username or "admin"}
def _project(self, arguments: dict) -> dict:
slug = str(arguments.get("project_slug", "")).strip()
project = resolve_by_slug(get_table("projects"), slug) if slug else None
if not project:
raise ToolInputError(f"project not found: {slug}")
return project
def _instance(self, project: dict, ref: str) -> dict:
inst = store.get_instance(ref)
if inst is None or inst["project_uid"] != project["uid"]:
for candidate in store.list_instances(project["uid"]):
if candidate["name"] == ref:
return candidate
raise ToolInputError(f"instance not found: {ref}")
return inst
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
handler = getattr(self, "_" + name[len("container_"):], None) if name.startswith("container_") else None
if handler is None:
raise ToolInputError(f"unknown container tool: {name}")
try:
return await handler(arguments)
except ContainerError as exc:
return json.dumps({"error": "container_error", "message": str(exc)})
async def _list_instances(self, arguments) -> str:
project = self._project(arguments)
instances = store.list_instances(project["uid"])
for inst in instances:
inst["ingress_url"] = self._ingress_url(inst)
return json.dumps({"instances": instances}, default=str)
async def _create_instance(self, arguments) -> str:
project = self._project(arguments)
autostart = str(arguments.get("autostart", "true")).lower() not in ("false", "0", "no", "off")
inst = await api.create_instance(
project, name=str(arguments.get("name", "")),
boot_command=str(arguments.get("boot_command", "")), env=arguments.get("env", ""),
cpu_limit=str(arguments.get("cpu_limit", "")), mem_limit=str(arguments.get("mem_limit", "")),
ports=arguments.get("ports", ""), restart_policy=str(arguments.get("restart_policy", "never")),
autostart=autostart, ingress_slug=str(arguments.get("ingress_slug", "")),
ingress_port=arguments.get("ingress_port"), actor=("user", self._actor_user()["uid"]))
return json.dumps({"instance": inst, "ingress_url": self._ingress_url(inst)}, default=str)
async def _instance_action(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
action = str(arguments.get("action", "")).lower()
actor = ("user", self._actor_user()["uid"])
if action == "delete":
api.mark_for_removal(inst, actor=actor)
elif action == "sync":
count = await api.sync_workspace(inst, self._actor_user())
return json.dumps({"status": "synced", "imported": count})
elif action == "restart":
api.request_restart(inst, actor=actor)
elif action in ("start", "resume"):
api.set_desired_state(inst, store.DESIRED_RUNNING, actor=actor)
elif action == "stop":
api.set_desired_state(inst, store.DESIRED_STOPPED, actor=actor)
elif action == "pause":
api.set_desired_state(inst, store.DESIRED_PAUSED, actor=actor)
else:
raise ToolInputError(f"unknown action: {action}")
return json.dumps({"status": "ok", "action": action, "instance": inst["uid"]})
async def _logs(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
if not inst.get("container_id"):
return json.dumps({"logs": "", "status": inst["status"]})
lines: list = []
async def collect(line: str) -> None:
lines.append(line)
tail = int(arguments.get("tail", 200) or 200)
await get_backend().logs(inst["container_id"], follow=False, tail=max(1, min(tail, 2000)), on_log=collect)
return json.dumps({"logs": "\n".join(lines)})
async def _exec(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
if not inst.get("container_id"):
raise ToolInputError("instance is not running")
command = str(arguments.get("command", "")).strip()
if not command:
raise ToolInputError("command is required")
result = await get_backend().exec(inst["container_id"], ["/bin/sh", "-c", command])
actor = self._actor_user()
store.record_event(inst, "exec", "user", actor["uid"], {"command": command, "exit_code": result.exit_code})
return json.dumps({"exit_code": result.exit_code, "output": result.output})
async def _stats(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
return json.dumps({
"instance": inst["name"], "status": inst["status"],
"ingress_url": self._ingress_url(inst),
"runtime": api.instance_runtime(inst),
"stats": api.instance_stats(inst["uid"]),
}, default=str)
async def _schedule(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
run_at = arguments.get("run_at")
try:
schedule = Schedule(
kind=str(arguments.get("kind", "")), cron=arguments.get("cron") or None,
run_at=from_iso(run_at) if run_at else None,
every_seconds=arguments.get("every_seconds"))
except ValueError as exc:
raise ToolInputError(str(exc))
sched = api.add_schedule(inst, str(arguments.get("action", "")), schedule)
return json.dumps({"schedule": sched}, default=str)

View File

@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.devii.customization.controller import CustomizationController
__all__ = ["CustomizationController"]

View File

@ -0,0 +1,125 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
import logging
from typing import Any
from devplacepy.database import (
CUSTOMIZATION_LANGS,
delete_custom_override,
get_custom_override,
list_custom_overrides,
set_custom_override,
)
from devplacepy.services.devii.errors import ToolInputError
logger = logging.getLogger("devii.customization")
PREVIEW_CHARS = 160
class CustomizationController:
def __init__(self, owner_kind: str, owner_id: str) -> None:
self._owner_kind = owner_kind
self._owner_id = owner_id
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
if name == "customize_list":
return self._list()
if name == "customize_get":
return self._get(arguments)
if name == "customize_set_css":
return self._set(arguments, "css", "css")
if name == "customize_set_js":
return self._set(arguments, "js", "js")
if name == "customize_reset":
return self._reset(arguments)
raise ToolInputError(f"Unknown customization tool: {name}")
def _scope(self, arguments: dict[str, Any]) -> str:
scope = str(arguments.get("scope", "")).strip()
if not scope:
raise ToolInputError("A 'scope' is required: 'global' or a page-type string.")
return scope
def _lang(self, arguments: dict[str, Any]) -> str | None:
raw = arguments.get("lang")
if raw is None or str(raw).strip() == "":
return None
lang = str(raw).strip().lower()
if lang not in CUSTOMIZATION_LANGS:
raise ToolInputError("'lang' must be 'css' or 'js'.")
return lang
def _list(self) -> str:
rows = list_custom_overrides(self._owner_kind, self._owner_id)
items = [
{
"scope": row.get("scope"),
"lang": row.get("lang"),
"enabled": bool(row.get("enabled", 1)),
"chars": len(row.get("code") or ""),
"preview": (row.get("code") or "")[:PREVIEW_CHARS],
"updated_at": row.get("updated_at"),
}
for row in rows
]
return json.dumps({"status": "success", "customizations": items}, ensure_ascii=False)
def _get(self, arguments: dict[str, Any]) -> str:
scope = self._scope(arguments)
lang = self._lang(arguments)
if lang is None:
raise ToolInputError("'lang' must be 'css' or 'js'.")
row = get_custom_override(self._owner_kind, self._owner_id, scope, lang)
if not row:
return json.dumps(
{"status": "success", "scope": scope, "lang": lang, "code": None}, ensure_ascii=False
)
return json.dumps(
{
"status": "success",
"scope": scope,
"lang": lang,
"enabled": bool(row.get("enabled", 1)),
"code": row.get("code") or "",
},
ensure_ascii=False,
)
def _set(self, arguments: dict[str, Any], lang: str, code_key: str) -> str:
scope = self._scope(arguments)
code = arguments.get(code_key)
if code is None or str(code).strip() == "":
raise ToolInputError(f"'{code_key}' code is required.")
set_custom_override(self._owner_kind, self._owner_id, scope, lang, str(code))
return json.dumps(
{
"status": "success",
"saved": True,
"scope": scope,
"lang": lang,
"note": "Saved. Call reload_page so the user sees it applied by the server.",
},
ensure_ascii=False,
)
def _reset(self, arguments: dict[str, Any]) -> str:
scope = self._scope(arguments)
lang = self._lang(arguments)
if scope == "all":
removed = delete_custom_override(self._owner_kind, self._owner_id, scope=None, lang=lang)
else:
removed = delete_custom_override(self._owner_kind, self._owner_id, scope=scope, lang=lang)
return json.dumps(
{
"status": "success",
"removed": removed,
"scope": scope,
"lang": lang or "all",
"note": "Removed. Call reload_page so the user sees the default restored.",
},
ensure_ascii=False,
)

View File

@ -14,6 +14,7 @@ from .llm import LLMClient
from .session import DeviiSession
from .store import ConversationStore, TurnAudit, UsageLedger
from .tasks.store import TaskStore, memory_db
from .virtual_tools import VirtualToolStore
logger = logging.getLogger("devii.hub")
@ -48,9 +49,10 @@ class DeviiHub:
owned_db = db if owner_kind == "user" else memory_db()
task_store = TaskStore(owned_db, owner_kind, owner_id)
lessons = LessonStore(owned_db, owner_kind, owner_id)
virtual_tool_store = VirtualToolStore(owned_db, owner_kind, owner_id)
session = DeviiSession(
owner_kind, owner_id, username, settings, llm, lessons, pricing, task_store,
self._stores, is_admin=is_admin,
virtual_tool_store, self._stores, is_admin=is_admin,
)
if owner_kind == "user":
saved = self._stores["conversations"].load(owner_kind, owner_id)

View File

@ -6,11 +6,14 @@ from .actions.avatar_actions import AVATAR_ACTIONS
from .actions.catalog import ACTIONS
from .actions.chunk_actions import CHUNK_ACTIONS
from .actions.client_actions import CLIENT_ACTIONS
from .actions.container_actions import CONTAINER_ACTIONS
from .actions.cost_actions import COST_ACTIONS
from .actions.customization_actions import CUSTOMIZATION_ACTIONS
from .actions.docs_actions import DOCS_ACTIONS
from .actions.fetch_actions import FETCH_ACTIONS
from .actions.rsearch_actions import RSEARCH_ACTIONS
from .actions.spec import Catalog
from .virtual_tools.actions import VIRTUAL_TOOL_ACTIONS
from .agentic.actions import AGENTIC_ACTIONS
from .tasks.actions import TASK_ACTIONS
@ -25,4 +28,7 @@ CATALOG = Catalog(
+ COST_ACTIONS
+ CHUNK_ACTIONS
+ RSEARCH_ACTIONS
+ CONTAINER_ACTIONS
+ CUSTOMIZATION_ACTIONS
+ VIRTUAL_TOOL_ACTIONS
)

View File

@ -22,6 +22,7 @@ from .http_client import PlatformClient
from .llm import LLMClient
from .registry import CATALOG
from .tasks import Scheduler, TaskController, TaskStore
from .virtual_tools import VirtualToolController, VirtualToolStore
logger = logging.getLogger("devii.session")
@ -53,6 +54,7 @@ class DeviiSession:
lessons: LessonStore,
pricing: Pricing,
task_store: TaskStore,
virtual_tool_store: VirtualToolStore,
stores: dict[str, Any],
is_admin: bool = False,
) -> None:
@ -72,10 +74,16 @@ class DeviiSession:
self.agentic = AgenticController(lessons, settings)
self.cost = CostTracker(pricing=pricing)
self.chunks = ChunkStore()
self._virtual_tool_store = virtual_tool_store
self.virtual_tools = VirtualToolController(
virtual_tool_store, self.agentic.run_subagent, set(CATALOG.by_name())
)
self.dispatcher = Dispatcher(
CATALOG, self.client, settings, self.task_controller, self.agentic,
avatar=self.avatar, browser=self.browser,
is_admin=is_admin, quota_provider=self._quota_snapshot,
owner_kind=owner_kind, owner_id=owner_id,
virtual_tools=self.virtual_tools,
)
self.tools = CATALOG.tool_schemas_for(self.client.authenticated, is_admin)
self._system_prompt = _system_prompt_for(is_admin)
@ -108,6 +116,7 @@ class DeviiSession:
self._buffer: list[dict[str, Any]] = []
self._turn_tool_calls = 0
self._pending_session: str | None = None
self._turns: set[asyncio.Task] = set()
def restore_history(self, messages: list[dict[str, Any]]) -> None:
if messages:
@ -222,7 +231,21 @@ class DeviiSession:
await self._emit({"type": "clear"}, buffer=False)
def spawn_turn(self, text: str) -> None:
asyncio.create_task(self._run_turn(text))
task = asyncio.create_task(self._run_turn(text))
self._turns.add(task)
task.add_done_callback(self._turns.discard)
async def cancel_turns(self) -> None:
turns = [task for task in self._turns if not task.done()]
if not turns:
return
for task in turns:
task.cancel()
await asyncio.gather(*turns, return_exceptions=True)
async def reset(self) -> None:
await self.cancel_turns()
await self.reset_conversation()
async def _run_turn(self, text: str) -> None:
turn_id = uuid_utils.uuid7().hex
@ -233,6 +256,7 @@ class DeviiSession:
error = ""
try:
async with self._lock:
self._refresh_tools()
reply = await self.agent.respond(text)
await self._emit({"type": "reply", "text": reply}, buffer=True)
except Exception as exc: # noqa: BLE001 - reported to the browser, recorded for audit
@ -242,6 +266,11 @@ class DeviiSession:
finally:
self._record_turn(turn_id, started_at, text, reply, error, before)
def _refresh_tools(self) -> None:
builtin = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
virtual = self._virtual_tool_store.tool_schemas()
self.tools[:] = builtin + virtual
def _quota_snapshot(self) -> dict[str, Any]:
spent = self._ledger.spent_24h(self.owner_kind, self.owner_id)
turns = self._ledger.turns_24h(self.owner_kind, self.owner_id)

View File

@ -0,0 +1,6 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.devii.virtual_tools.controller import VirtualToolController
from devplacepy.services.devii.virtual_tools.store import VirtualToolStore
__all__ = ["VirtualToolController", "VirtualToolStore"]

View File

@ -0,0 +1,73 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..actions.spec import Action, Param
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
VIRTUAL_TOOL_ACTIONS: tuple[Action, ...] = (
Action(
name="tool_create",
method="LOCAL",
path="",
summary="Create a new user-defined tool that runs a stored prompt when called",
description=(
"Use when the user asks you to remember a new capability ('when I say X, do Y'). The new "
"tool becomes callable on the next turn; when called it re-runs you on its stored prompt "
"plus the user's input. Give a clear trigger-oriented description so you know when to use it."
),
handler="virtual_tool",
requires_auth=False,
params=(
arg("name", "Tool name: a letter then letters, digits, or underscores (2-41 chars).", required=True),
arg("description", "When to use the tool and what it does (your trigger hint).", required=True),
arg("prompt", "The instruction the tool runs each time it is called.", required=True),
arg("input_description", "What the single free-form 'input' argument should contain."),
),
),
Action(
name="tool_list",
method="LOCAL",
path="",
summary="List the user's defined tools with their descriptions and status",
handler="virtual_tool",
requires_auth=False,
),
Action(
name="tool_get",
method="LOCAL",
path="",
summary="Read a user-defined tool's full prompt and settings",
handler="virtual_tool",
requires_auth=False,
params=(arg("name", "Tool name to read.", required=True),),
),
Action(
name="tool_update",
method="LOCAL",
path="",
summary="Update a user-defined tool's description, prompt, input hint, or enabled state",
handler="virtual_tool",
requires_auth=False,
params=(
arg("name", "Tool name to update.", required=True),
arg("description", "New description (optional)."),
arg("prompt", "New stored prompt (optional)."),
arg("input_description", "New input hint (optional)."),
arg("enabled", "Enable (true) or disable (false) the tool without deleting it.", kind="boolean"),
),
),
Action(
name="tool_delete",
method="LOCAL",
path="",
summary="Delete a user-defined tool",
handler="virtual_tool",
requires_auth=False,
params=(arg("name", "Tool name to delete.", required=True),),
),
)

View File

@ -0,0 +1,140 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
import logging
import re
import uuid
from typing import Any, Awaitable, Callable
from devplacepy.services.devii.errors import ToolInputError
from devplacepy.services.devii.virtual_tools.store import VirtualToolStore
logger = logging.getLogger("devii.virtual_tools.controller")
NAME_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]{1,40}$")
PREVIEW_CHARS = 200
Evaluator = Callable[[str], Awaitable[str]]
def _serialize(row: dict[str, Any], full: bool = False) -> dict[str, Any]:
data = {
"name": row.get("name"),
"description": row.get("description"),
"input_description": row.get("input_description") or "",
"enabled": bool(row.get("enabled", 1)),
"updated_at": row.get("updated_at"),
}
if full:
data["prompt"] = row.get("prompt") or ""
else:
data["prompt_preview"] = (row.get("prompt") or "")[:PREVIEW_CHARS]
return data
class VirtualToolController:
def __init__(self, store: VirtualToolStore, evaluator: Evaluator, builtin_names: set[str]) -> None:
self._store = store
self._evaluator = evaluator
self._builtin_names = builtin_names
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
handlers = {
"tool_create": self._create,
"tool_list": self._list,
"tool_get": self._get,
"tool_update": self._update,
"tool_delete": self._delete,
}
handler = handlers.get(name)
if handler is None:
raise ToolInputError(f"Unknown virtual-tool action: {name}")
return handler(arguments)
def has(self, name: str) -> bool:
row = self._store.find(name)
return bool(row and row.get("enabled", 1))
async def run(self, name: str, arguments: dict[str, Any]) -> str:
row = self._store.find(name)
if not row or not row.get("enabled", 1):
raise ToolInputError(f"No enabled virtual tool named '{name}'.")
user_input = str(arguments.get("input", "")).strip()
prompt = row.get("prompt") or ""
composed = f"{prompt}\n\nUser input: {user_input}" if user_input else prompt
result = await self._evaluator(composed)
return json.dumps({"status": "success", "tool": name, "result": result}, ensure_ascii=False)
def _require_name(self, arguments: dict[str, Any]) -> str:
name = str(arguments.get("name", "")).strip()
if not name:
raise ToolInputError("A tool 'name' is required.")
return name
def _create(self, arguments: dict[str, Any]) -> str:
name = self._require_name(arguments)
if not NAME_PATTERN.match(name):
raise ToolInputError(
"Tool name must be 2-41 characters: a letter followed by letters, digits, or underscores."
)
if name in self._builtin_names:
raise ToolInputError(f"'{name}' is a built-in tool name; choose a different name.")
if self._store.find(name):
raise ToolInputError(f"A tool named '{name}' already exists; use tool_update to change it.")
description = str(arguments.get("description", "")).strip()
prompt = str(arguments.get("prompt", "")).strip()
if not description:
raise ToolInputError("A 'description' is required so the tool can be triggered correctly.")
if not prompt:
raise ToolInputError("A 'prompt' is required: the instruction the tool runs when called.")
record = {
"uid": uuid.uuid4().hex,
"name": name,
"description": description,
"prompt": prompt,
"input_description": str(arguments.get("input_description", "")).strip(),
"enabled": 1,
}
stored = self._store.create(record)
return json.dumps({"status": "created", "tool": _serialize(stored)}, ensure_ascii=False)
def _list(self, arguments: dict[str, Any]) -> str:
rows = self._store.list()
return json.dumps(
{"count": len(rows), "tools": [_serialize(row) for row in rows]}, ensure_ascii=False
)
def _get(self, arguments: dict[str, Any]) -> str:
name = self._require_name(arguments)
row = self._store.find(name)
if not row:
raise ToolInputError(f"No tool named '{name}'.")
return json.dumps({"tool": _serialize(row, full=True)}, ensure_ascii=False)
def _update(self, arguments: dict[str, Any]) -> str:
name = self._require_name(arguments)
row = self._store.find(name)
if not row:
raise ToolInputError(f"No tool named '{name}'.")
changes: dict[str, Any] = {}
if "description" in arguments and str(arguments["description"]).strip():
changes["description"] = str(arguments["description"]).strip()
if "prompt" in arguments and str(arguments["prompt"]).strip():
changes["prompt"] = str(arguments["prompt"]).strip()
if "input_description" in arguments:
changes["input_description"] = str(arguments["input_description"]).strip()
if "enabled" in arguments and arguments["enabled"] is not None:
changes["enabled"] = 1 if str(arguments["enabled"]).strip().lower() in ("1", "true", "yes", "on") else 0
if not changes:
raise ToolInputError("No updatable fields supplied (description, prompt, input_description, enabled).")
self._store.update(name, changes)
return json.dumps({"status": "updated", "tool": _serialize(self._store.find(name))}, ensure_ascii=False)
def _delete(self, arguments: dict[str, Any]) -> str:
name = self._require_name(arguments)
deleted = self._store.delete(name)
if not deleted:
raise ToolInputError(f"No tool named '{name}'.")
return json.dumps({"status": "deleted", "name": name}, ensure_ascii=False)

View File

@ -0,0 +1,88 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
logger = logging.getLogger("devii.virtual_tools.store")
TABLE = "devii_virtual_tools"
INDEXED_COLUMNS = (["owner_kind", "owner_id"], ["owner_kind", "owner_id", "name"])
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _schema_for(row: dict[str, Any]) -> dict[str, Any]:
input_description = row.get("input_description") or "Free-form input for this tool."
return {
"type": "function",
"function": {
"name": row["name"],
"description": f"{row.get('description', '')} (your custom tool)",
"parameters": {
"type": "object",
"properties": {
"input": {"type": "string", "description": input_description},
},
"required": [],
"additionalProperties": False,
},
},
}
class VirtualToolStore:
def __init__(self, db: Any, owner_kind: str, owner_id: str) -> None:
self._db = db
self._owner_kind = owner_kind
self._owner_id = owner_id
self._ensure_indexes()
def _ensure_indexes(self) -> None:
if TABLE not in self._db.tables:
return
table = self._db[TABLE]
for columns in INDEXED_COLUMNS:
table.create_index(columns)
@property
def _table(self) -> Any:
return self._db[TABLE]
@property
def _scope(self) -> dict[str, str]:
return {"owner_kind": self._owner_kind, "owner_id": self._owner_id}
def find(self, name: str) -> dict[str, Any] | None:
if TABLE not in self._db.tables:
return None
return self._table.find_one(name=name, **self._scope)
def list(self) -> list[dict[str, Any]]:
if TABLE not in self._db.tables:
return []
return list(self._table.find(**self._scope))
def create(self, record: dict[str, Any]) -> dict[str, Any]:
now = _now_iso()
record = {**record, **self._scope, "created_at": now, "updated_at": now}
self._table.insert(record)
logger.info("Virtual tool created name=%s owner=%s/%s", record.get("name"), self._owner_kind, self._owner_id)
return record
def update(self, name: str, changes: dict[str, Any]) -> None:
changes = {**changes, "name": name, **self._scope, "updated_at": _now_iso()}
self._table.update(changes, ["name", "owner_kind", "owner_id"])
logger.debug("Virtual tool updated name=%s changes=%s", name, list(changes))
def delete(self, name: str) -> bool:
deleted = self._table.delete(name=name, **self._scope)
logger.info("Virtual tool deleted name=%s ok=%s", name, deleted)
return bool(deleted)
def tool_schemas(self) -> list[dict[str, Any]]:
return [_schema_for(row) for row in self.list() if row.get("enabled", 1)]

View File

@ -0,0 +1,91 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import shutil
from pathlib import Path
from devplacepy.config import DATA_DIR
from devplacepy import project_files
from devplacepy.content import create_content_item
from devplacepy.database import get_table, record_fork, delete_fork_relations
from devplacepy.services.jobs.base import JobService
from devplacepy.utils import XP_PROJECT
logger = logging.getLogger(__name__)
STAGING_DIR = DATA_DIR / "fork_staging"
class ForkService(JobService):
kind = "fork"
title = "Fork"
description = (
"Copies a project into a brand-new project owned by the forking user off the request "
"path: it creates the destination project, duplicates the whole virtual filesystem, and "
"records the source-to-fork relation. The resulting project is permanent; only the job "
"tracking row is swept on retention."
)
def __init__(self):
super().__init__(name="fork", interval_seconds=2)
async def process(self, job: dict) -> dict:
payload = job["payload"]
source_uid = payload.get("source_project_uid", "")
forked_by_uid = payload.get("forked_by_uid", "")
title = (payload.get("title") or "").strip()
source = get_table("projects").find_one(uid=source_uid)
if not source:
raise ValueError(f"source project not found: {source_uid}")
user = get_table("users").find_one(uid=forked_by_uid)
if not user:
raise ValueError(f"forking user not found: {forked_by_uid}")
if not title:
raise ValueError("a destination title is required")
new_uid, new_slug = create_content_item("projects", "project", user, {
"title": title,
"description": source.get("description") or "",
"release_date": source.get("release_date") or None,
"demo_date": source.get("demo_date") or None,
"project_type": source.get("project_type") or "software",
"platforms": source.get("platforms") or "",
"status": source.get("status") or "In Development",
"is_private": 1 if source.get("is_private") else 0,
"read_only": 0,
}, title, XP_PROJECT, "First Project", source.get("description") or "", None)
try:
item_count = await asyncio.to_thread(self._copy_files, source_uid, new_uid, user, job["uid"])
record_fork(source_uid, new_uid, forked_by_uid)
except Exception:
self._rollback(new_uid)
raise
return {
"project_uid": new_uid,
"project_url": f"/projects/{new_slug}",
"source_project_uid": source_uid,
"item_count": item_count,
}
def _copy_files(self, source_uid: str, new_uid: str, user: dict, job_uid: str) -> int:
staging = STAGING_DIR / job_uid
try:
project_files.export_to_dir(source_uid, "", staging)
return project_files.import_from_dir(new_uid, staging, user, skip_names=set())
finally:
shutil.rmtree(staging, ignore_errors=True)
def _rollback(self, new_uid: str) -> None:
try:
project_files.delete_all_project_files(new_uid)
delete_fork_relations(new_uid)
get_table("projects").delete(uid=new_uid)
except Exception:
logger.exception("fork rollback failed for %s", new_uid)
def cleanup(self, job: dict) -> None:
shutil.rmtree(STAGING_DIR / job["uid"], ignore_errors=True)

View File

@ -8,15 +8,15 @@ import shutil
import sys
from pathlib import Path
from devplacepy.config import BASE_DIR, STATIC_DIR
from devplacepy.config import BASE_DIR, DATA_DIR
from devplacepy import project_files
from devplacepy.services.jobs.base import JobService
from devplacepy.utils import generate_uid, slugify
logger = logging.getLogger(__name__)
ZIPS_DIR = STATIC_DIR / "uploads" / "zips"
STAGING_DIR = STATIC_DIR / "uploads" / "zip_staging"
ZIPS_DIR = DATA_DIR / "zips"
STAGING_DIR = DATA_DIR / "zip_staging"
WORKER_MODULE = "devplacepy.services.jobs.zip_worker"

View File

@ -858,6 +858,18 @@ button.comment-form-submit:hover {
display: flex;
}
.topnav,
.topnav-mobile-overlay,
.topnav-mobile-panel,
.modal-overlay,
.dialog-overlay,
.context-menu,
.dp-toast-host,
.feed-fab,
.attachment-lightbox {
will-change: transform;
}
@media (max-width: 480px) {
.modal-card {
margin: 0.5rem;

View File

@ -3,7 +3,7 @@
position: fixed;
inset: 0;
background: var(--overlay-dark);
z-index: 2100;
z-index: 2147483647;
align-items: center;
justify-content: center;
}
@ -65,7 +65,7 @@
.context-menu {
display: none;
position: fixed;
z-index: 1900;
z-index: 2147483500;
min-width: 180px;
max-width: 280px;
background: var(--bg-card);

View File

@ -0,0 +1,134 @@
/* retoor <retoor@molodetz.nl> */
/* ---------------- shared ---------------- */
.cm-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
background: var(--border);
color: var(--text-secondary);
}
.cm-badge.cm-running { background: var(--success); color: var(--white); }
.cm-badge.cm-crashed { background: var(--danger); color: var(--white); }
.cm-badge.cm-paused { background: var(--warning); color: var(--white); }
.cm-badge.cm-stopped { background: var(--text-muted); color: var(--white); }
.cm-badge.cm-removed { background: var(--text-muted); color: var(--white); }
.cm-badge.cm-created,
.cm-badge.cm-starting,
.cm-badge.cm-restarting { background: var(--info); color: var(--white); }
.cm-badge.cm-success { background: var(--success); color: var(--white); }
.cm-badge.cm-failed { background: var(--danger); color: var(--white); }
.cm-badge.cm-building,
.cm-badge.cm-queued { background: var(--info); color: var(--white); }
.cm-badge.cm-cancelled { background: var(--text-muted); color: var(--white); }
.cm-muted { color: var(--text-muted); }
/* ---------------- project manager ---------------- */
.cm-page { display: flex; flex-direction: column; gap: 1rem; }
.cm-header { display: flex; align-items: center; gap: 1rem; }
.cm-header-link { margin-left: auto; color: var(--text-secondary); font-size: 0.875rem; }
.cm-header-link:hover { color: var(--accent); }
.cm-body { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; }
.cm-col { min-width: 0; }
.cm-col-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
.cm-col-head h2 { font-size: 1.1rem; margin: 0; }
.cm-list { list-style: none; margin: 0; padding: 0; }
.cm-item { border-radius: var(--radius); }
.cm-item + .cm-item { margin-top: 0.25rem; }
.cm-item:not(:has(.cm-item-link)) { padding: 0.5rem 0.75rem; cursor: pointer; display: flex; justify-content: space-between; gap: 0.5rem; }
.cm-item:not(:has(.cm-item-link)):hover { background: var(--bg-card-hover); }
.cm-item.active { background: var(--accent-light); }
.cm-item-link { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; color: var(--text-primary); border-radius: var(--radius); }
.cm-item-link:hover { background: var(--bg-card-hover); }
.cm-detail { margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 1rem; }
.cm-detail-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; flex-wrap: wrap; }
.cm-detail-name { font-weight: 600; flex: 1; }
.cm-editor { width: 100%; min-height: 260px; font-family: var(--font-mono); font-size: 0.85rem; background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.5rem; resize: vertical; }
.cm-builds { margin-top: 1rem; }
.cm-builds h3 { font-size: 0.95rem; margin: 0 0 0.5rem; }
.cm-log { background: var(--bg-input); color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.8rem; padding: 0.75rem; border-radius: var(--radius); max-height: 320px; overflow: auto; white-space: pre-wrap; margin-top: 0.75rem; }
.cm-modal-form .auth-field select { width: 100%; }
.cm-build-label { cursor: pointer; flex: 1; }
.cm-build-link { color: var(--text-muted); font-size: 0.8rem; }
.cm-build-link:hover { color: var(--accent); }
.cm-modal-form .topic-label { display: flex; align-items: center; justify-content: flex-start; gap: 0.5rem; }
.cm-modal-form .topic-label input { width: auto; margin: 0; }
/* ---------------- admin list ---------------- */
.admin-toolbar-action { margin-left: auto; }
.cm-row-name { color: var(--accent); font-weight: 600; }
.cm-row-project { color: var(--text-secondary); }
.cm-row-project:hover { color: var(--accent); }
/* ---------------- instance detail ---------------- */
.ci-page { display: flex; flex-direction: column; gap: 1rem; }
.ci-header { display: flex; flex-direction: column; gap: 0.5rem; }
.ci-titlebar { display: flex; align-items: center; gap: 0.75rem; }
.ci-title { font-size: 1.5rem; margin: 0; }
.ci-project { color: var(--text-secondary); font-size: 0.875rem; }
.ci-project:hover { color: var(--accent); }
.ci-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
.ci-card { min-width: 0; }
.ci-card h2 { font-size: 1.05rem; margin: 0 0 0.75rem; }
.ci-card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
.ci-card-head h2 { margin: 0; }
.ci-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1rem; }
.ci-actions .ci-danger { color: var(--danger); border-color: var(--danger); }
.ci-runtime { display: flex; flex-direction: column; gap: 0.4rem; }
.ci-kv { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.85rem; }
.ci-kv span { color: var(--text-muted); }
.ci-kv code { font-family: var(--font-mono); color: var(--text-secondary); word-break: break-all; text-align: right; }
.ci-ingress { margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--border); display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; font-size: 0.85rem; }
.ci-ingress-label { color: var(--text-muted); }
.ci-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.ci-metric { display: flex; flex-direction: column; gap: 0.2rem; }
.ci-metric-label { color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; }
.ci-metric-value { font-family: var(--font-mono); font-size: 1.1rem; color: var(--text-primary); }
.ci-schedules { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
.ci-schedule { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
.ci-schedule-action { font-weight: 600; text-transform: uppercase; font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; background: var(--accent-light); color: var(--accent); }
.ci-schedule-next { color: var(--text-secondary); font-family: var(--font-mono); }
.ci-schedule button { margin-left: auto; }
.ci-exec { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
.ci-exec input { flex: 1; min-width: 200px; padding: 0.5rem 0.75rem; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font-family: var(--font-mono); }
.ci-log { background: var(--bg-input); color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.8rem; padding: 0.75rem; border-radius: var(--radius); max-height: 360px; overflow: auto; white-space: pre-wrap; margin: 0; }
.ci-log:focus { outline: 1px solid var(--accent); }
.ci-log.ci-empty { color: var(--text-muted); font-style: italic; }
.cb-tag { font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-muted); }
.cb-error { color: var(--danger); font-size: 0.85rem; margin: 0.75rem 0 0; }
.cb-dockerfile { max-height: 420px; }
.ci-terminal { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.ci-terminal .ci-term-output { border: none; border-radius: 0; max-height: 320px; }
.ci-term-bar { display: flex; align-items: center; gap: 0.5rem; background: var(--bg-input); border-top: 1px solid var(--border); padding: 0.25rem 0.6rem; }
.ci-term-prompt { color: var(--success); font-family: var(--font-mono); font-weight: 600; }
.ci-term-input { flex: 1; background: transparent; border: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 0.8rem; padding: 0.4rem 0; }
.ci-term-input:focus { outline: none; }
.ci-term-input:disabled { color: var(--text-muted); }
@media (max-width: 1024px) {
.cm-body { grid-template-columns: 1fr; }
.ci-grid { grid-template-columns: 1fr; }
}

View File

@ -27,6 +27,14 @@ devii-terminal {
font-size: var(--devii-font-size);
}
devii-terminal .devii-launcher,
devii-terminal .devii-window,
.devii-hl-box,
.devii-hl-callout,
.devii-toast {
will-change: transform;
}
/* Launcher FAB (shown only when closed) */
devii-terminal .devii-launcher {
position: fixed;

View File

@ -323,7 +323,7 @@
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(229, 57, 53, 0.4);
z-index: 100;
z-index: 900;
border: none;
cursor: pointer;
}

View File

@ -0,0 +1,182 @@
/* retoor <retoor@molodetz.nl> */
.fw-host {
--fw-bg: #000000;
--fw-fg: #d0d0d0;
--fw-dim: #6a737d;
--fw-accent: #3fb950;
--fw-bar-bg: #0b0b0b;
--fw-border: #1c1f24;
--fw-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
--fw-radius: 10px;
--fw-font: "SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
--fw-font-size: 14px;
--fw-z: 2147480000;
position: fixed;
inset: 0;
pointer-events: none;
z-index: var(--fw-z);
font-family: var(--fw-font);
font-size: var(--fw-font-size);
}
.fw-host .fw-window {
position: fixed;
pointer-events: auto;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 320px;
min-height: 220px;
max-width: 100vw;
max-height: 100vh;
background: var(--fw-bg);
color: var(--fw-fg);
border: 1px solid var(--fw-border);
border-radius: var(--fw-radius);
box-shadow: var(--fw-shadow);
line-height: 1.5;
animation: fw-pop 0.16s ease;
transition: top 0.18s ease, left 0.18s ease, width 0.18s ease, height 0.18s ease;
}
@keyframes fw-pop {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
.fw-host.fw-dragging .fw-window {
transition: none;
user-select: none;
}
.fw-host.fw-state-normal .fw-titlebar {
cursor: move;
}
.fw-host .fw-resize {
display: none;
position: absolute;
right: 0;
bottom: 0;
width: 18px;
height: 18px;
z-index: 6;
cursor: nwse-resize;
pointer-events: auto;
background-image: linear-gradient(135deg, transparent 0 46%, var(--fw-dim) 46% 54%,
transparent 54% 66%, var(--fw-dim) 66% 74%, transparent 74%);
opacity: 0.55;
}
.fw-host.fw-state-normal .fw-resize {
display: block;
}
.fw-host.fw-state-maximized .fw-window {
top: 12px;
left: 12px;
right: 12px;
bottom: 12px;
width: auto;
height: auto;
resize: none;
}
.fw-host.fw-state-fullscreen .fw-window {
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
border: none;
border-radius: 0;
resize: none;
}
.fw-host .fw-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px 8px 12px;
background: var(--fw-bar-bg);
border-bottom: 1px solid var(--fw-border);
flex: 0 0 auto;
user-select: none;
}
.fw-host .fw-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
color: var(--fw-accent);
font-weight: bold;
letter-spacing: 0.5px;
}
.fw-host .fw-title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fw-host .fw-dot {
flex: 0 0 auto;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--fw-accent);
}
.fw-host .fw-winctl {
flex: 0 0 auto;
}
.fw-host .fw-winctl button {
background: transparent;
color: var(--fw-dim);
border: none;
width: 28px;
height: 24px;
margin-left: 2px;
border-radius: 5px;
font-family: inherit;
font-size: 14px;
cursor: pointer;
}
.fw-host .fw-winctl button:hover {
color: var(--fw-fg);
background: #1b1f25;
}
.fw-host .fw-winctl button[data-win="close"]:hover {
background: #c23b3b;
color: #fff;
}
.fw-host .fw-body {
flex: 1 1 auto;
overflow: hidden;
position: relative;
}
/* Container terminal body */
.ct-body {
background: #000000;
}
.ct-status {
padding: 6px 10px;
color: var(--fw-dim);
font-size: 12px;
}
.ct-xterm {
position: absolute;
inset: 0;
padding: 4px 2px 4px 6px;
}
.ct-xterm .xterm {
height: 100%;
}

View File

@ -224,3 +224,13 @@
width: 180px;
}
}
.pf-readonly-banner {
margin: 0.75rem 0;
padding: 0.6rem 0.9rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-muted);
color: var(--text-muted);
font-size: 0.85rem;
}

View File

@ -20,6 +20,9 @@ import { UserAiUsage } from "./UserAiUsage.js";
import { Tabs } from "./Tabs.js";
import { DeviiTerminal } from "./DeviiTerminal.js";
import { ZipDownloader } from "./ZipDownloader.js";
import { ProjectForker } from "./ProjectForker.js";
import WindowManager from "./components/WindowManager.js";
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
class Application {
constructor() {
@ -46,10 +49,14 @@ class Application {
this.apiKey = new ApiKeyManager();
this.userAiUsage = new UserAiUsage();
this.tabs = new Tabs();
this.windows = new WindowManager();
this.containerTerminals = new ContainerTerminalManager();
this.devii = new DeviiTerminal();
this.zipDownloader = new ZipDownloader();
this.projectForker = new ProjectForker();
}
}
const app = new Application();
window.app = app;
app.containerTerminals.restore();

View File

@ -0,0 +1,257 @@
// retoor <retoor@molodetz.nl>
export class ContainerInstance {
constructor(root) {
this.root = root;
this.slug = root.dataset.slug;
this.uid = root.dataset.uid;
this.status = root.dataset.status;
this.base = `/projects/${this.slug}/containers`;
this.name = root.dataset.name || this.uid;
this.detailPoll = null;
this.logPoll = null;
}
init() {
this.renderActions(this.status);
this.updateTerminalAvailability();
this.bind();
this.startDetailPoll();
this.startLogPoll();
}
bind() {
this.q("#ci-exec-run").addEventListener("click", () => this.execOnce());
this.q("#ci-term-toggle").addEventListener("click", () => this.openTerminal());
const form = document.getElementById("ci-schedule-form");
if (form) {
form.addEventListener("submit", (e) => { e.preventDefault(); this.addSchedule(form); });
const kind = form.elements.kind;
kind.addEventListener("change", () => this.syncScheduleFields(kind.value));
this.syncScheduleFields(kind.value);
}
this.q("#ci-schedules").addEventListener("click", (e) => {
const btn = e.target.closest("[data-schedule-delete]");
if (btn) this.deleteSchedule(btn.dataset.scheduleDelete);
});
}
q(sel) { return this.root.querySelector(sel); }
async getJson(url) {
const res = await fetch(url, { headers: { "Accept": "application/json" } });
if (!res.ok) throw new Error(`request failed: ${res.status}`);
return res.json();
}
async post(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body || {}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data.error && data.error.message) || `request failed: ${res.status}`);
return data;
}
toast(message, type) {
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
}
escape(value) {
const span = document.createElement("span");
span.textContent = value == null ? "" : String(value);
return span.innerHTML;
}
// ---------------- actions ----------------
renderActions(status) {
const running = status === "running";
const paused = status === "paused";
const spec = [
{ action: "start", label: "Start", disabled: running },
{ action: "stop", label: "Stop", disabled: status === "stopped" || status === "created" },
{ action: "restart", label: "Restart", disabled: !running },
{ action: "pause", label: "Pause", disabled: !running },
{ action: "resume", label: "Resume", disabled: !paused },
{ action: "sync", label: "Sync files", disabled: false },
{ action: "delete", label: "Delete", disabled: false },
];
const box = this.q("#ci-actions");
box.innerHTML = "";
for (const item of spec) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-secondary" + (item.action === "delete" ? " ci-danger" : "");
btn.textContent = item.label;
btn.disabled = item.disabled;
btn.addEventListener("click", () => this.instanceAction(item.action));
box.appendChild(btn);
}
}
async instanceAction(action) {
if (action === "delete" && !window.confirm("Delete this instance? The container is removed.")) return;
try {
const url = action === "delete" ? `${this.base}/instances/${this.uid}/delete`
: action === "sync" ? `${this.base}/instances/${this.uid}/sync`
: `${this.base}/instances/${this.uid}/${action}`;
const res = await this.post(url, {});
if (action === "delete") { window.location.href = "/admin/containers"; return; }
if (action === "sync") this.toast(`Synced ${res.data && res.data.imported} files`, "success");
else this.toast(`${action} requested`, "success");
} catch (err) { this.toast(err.message, "error"); }
}
// ---------------- polling ----------------
startDetailPoll() {
const tick = async () => {
try {
const detail = await this.getJson(`${this.base}/instances/${this.uid}`);
this.applyDetail(detail);
} catch { /* ignore */ }
};
tick();
this.detailPoll = setInterval(tick, 4000);
}
applyDetail(detail) {
const inst = detail.instance || {};
if (inst.status && inst.status !== this.status) {
this.status = inst.status;
const badge = this.q("#ci-status");
badge.textContent = inst.status;
badge.className = `cm-badge cm-${inst.status}`;
this.renderActions(inst.status);
this.updateTerminalAvailability();
}
const stats = detail.stats || {};
this.setMetric("samples", stats.samples);
this.setMetric("cpu_avg", `${stats.cpu_avg ?? 0}%`);
this.setMetric("cpu_p95", `${stats.cpu_p95 ?? 0}%`);
this.setMetric("mem_max", stats.mem_max);
const runtime = detail.runtime || {};
const cid = this.q("#ci-container-id");
if (cid) cid.textContent = runtime.container_id || "-";
if (Array.isArray(detail.schedules)) this.renderSchedules(detail.schedules);
}
setMetric(key, value) {
const el = this.q(`[data-metric="${key}"]`);
if (el) el.textContent = value == null ? "0" : value;
}
startLogPoll() {
const pre = this.q("#ci-log");
const tick = async () => {
try {
const data = await this.getJson(`${this.base}/instances/${this.uid}/logs?tail=400`);
const logs = data.logs || "";
pre.classList.toggle("ci-empty", !logs);
pre.textContent = logs || (this.status === "running"
? "No output yet. This panel shows the container's main process (stdout and stderr); commands run in the terminal above are not included here."
: "No logs. Start the instance to capture its main-process output.");
if (logs) pre.scrollTop = pre.scrollHeight;
} catch {
pre.classList.add("ci-empty");
pre.textContent = "Logs are currently unavailable.";
}
};
tick();
this.logPoll = setInterval(tick, 3000);
}
// ---------------- exec ----------------
async execOnce() {
const input = this.q("#ci-exec-input");
const cmd = input.value.trim();
if (!cmd) return;
this.q("#ci-terminal").hidden = false;
const pre = this.q("#ci-term");
try {
const res = await this.post(`${this.base}/instances/${this.uid}/exec`, { command: cmd });
pre.textContent += `$ ${cmd}\n${this.cleanTerm(res.output || "")}\n`;
pre.scrollTop = pre.scrollHeight;
input.value = "";
} catch (err) { this.toast(err.message, "error"); }
}
updateTerminalAvailability() {
const toggle = this.q("#ci-term-toggle");
const running = this.status === "running";
toggle.disabled = !running;
toggle.title = running ? "" : "Start the instance to open an interactive shell";
}
openTerminal() {
if (this.status !== "running") return;
if (window.app && window.app.containerTerminals) {
window.app.containerTerminals.open(this.slug, this.uid, this.name);
}
}
cleanTerm(text) {
return text
.replace(/\x1b\][0-9];[^\x07\x1b]*(\x07|\x1b\\)/g, "")
.replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, "")
.replace(/\x1b[()#][0-9A-Za-z]/g, "")
.replace(/\x1b[=>]/g, "");
}
// ---------------- schedules ----------------
syncScheduleFields(kind) {
const form = document.getElementById("ci-schedule-form");
form.querySelectorAll("[data-sched-field]").forEach((field) => {
field.hidden = field.dataset.schedField !== kind;
});
}
async addSchedule(form) {
const kind = form.elements.kind.value;
const body = { action: form.elements.action.value, kind };
if (kind === "cron") body.cron = form.elements.cron.value.trim();
if (kind === "interval") body.every_seconds = form.elements.every_seconds.value;
if (kind === "once") body.delay_seconds = form.elements.delay_seconds.value;
try {
await this.post(`${this.base}/instances/${this.uid}/schedules`, body);
const modal = document.getElementById("ci-schedule-modal");
if (modal) modal.classList.remove("visible");
form.reset();
this.syncScheduleFields(form.elements.kind.value);
this.toast("Schedule added", "success");
} catch (err) { this.toast(err.message, "error"); }
}
async deleteSchedule(sid) {
const ok = await window.app.dialog.confirm({
title: "Delete schedule",
message: "Delete this schedule? The automated task is removed.",
confirmLabel: "Delete",
danger: true,
});
if (!ok) return;
try {
await this.post(`${this.base}/instances/${this.uid}/schedules/${sid}/delete`, {});
this.toast("Schedule removed", "success");
} catch (err) { this.toast(err.message, "error"); }
}
renderSchedules(schedules) {
const list = this.q("#ci-schedules");
if (!schedules.length) {
list.innerHTML = `<li class="cm-muted ci-schedule-empty">No schedules.</li>`;
return;
}
list.innerHTML = schedules.map((s) => `<li class="ci-schedule" data-sid="${this.escape(s.uid)}">
<span class="ci-schedule-action">${this.escape(s.action)}</span>
<span class="ci-schedule-next">next ${this.escape(s.next_run_at || "-")}</span>
<span class="cm-muted">runs ${this.escape(s.run_count || 0)}</span>
<button type="button" class="btn btn-secondary btn-sm" data-schedule-delete="${this.escape(s.uid)}">Delete</button>
</li>`).join("");
}
}

View File

@ -0,0 +1,53 @@
// retoor <retoor@molodetz.nl>
export class ContainerList {
constructor(root) {
this.root = root;
this.endpoint = root.dataset.endpoint;
this.body = document.getElementById("cm-admin-rows");
this.pollMs = 4000;
}
init() {
setInterval(() => this.refresh(), this.pollMs);
}
escape(value) {
const span = document.createElement("span");
span.textContent = value == null ? "" : String(value);
return span.innerHTML;
}
async refresh() {
let instances;
try {
const res = await fetch(this.endpoint, { headers: { "Accept": "application/json" } });
if (!res.ok) return;
instances = (await res.json()).instances || [];
} catch { return; }
if (!this.body) return;
if (!instances.length) {
this.body.innerHTML = `<tr><td colspan="6" class="admin-empty">No container instances yet. Open a project's container manager to launch one.</td></tr>`;
return;
}
this.body.innerHTML = instances.map((inst) => this.row(inst)).join("");
}
row(inst) {
const uid = this.escape(inst.uid);
const project = inst.project_slug
? `<a class="cm-row-project" href="/projects/${this.escape(inst.project_slug)}/containers">${this.escape(inst.project_title || inst.project_slug)}</a>`
: `<span class="cm-muted">-</span>`;
const ingress = inst.ingress_slug
? `<a href="/p/${this.escape(inst.ingress_slug)}" target="_blank" rel="noopener">/p/${this.escape(inst.ingress_slug)}</a>`
: `<span class="cm-muted">-</span>`;
return `<tr class="cm-row" data-uid="${uid}">
<td><a class="cm-row-name" href="/admin/containers/${uid}">${this.escape(inst.name)}</a></td>
<td>${project}</td>
<td><span class="cm-badge cm-${this.escape(inst.status)}">${this.escape(inst.status)}</span></td>
<td>${ingress}</td>
<td>${this.escape(inst.restart_policy || "never")}</td>
<td><a class="admin-btn admin-btn-sm" href="/admin/containers/${uid}">Manage &rarr;</a></td>
</tr>`;
}
}

View File

@ -0,0 +1,95 @@
// retoor <retoor@molodetz.nl>
export class ContainerManager {
constructor(root) {
this.root = root;
this.slug = root.dataset.slug;
this.base = `/projects/${this.slug}/containers`;
this.instances = [];
this.pollMs = 3000;
}
init() {
const data = JSON.parse(this.root.querySelector("#cm-data").textContent || "{}");
this.instances = data.instances || [];
this.bind();
this.renderInstances();
setInterval(() => this.refresh(), this.pollMs);
}
bind() {
const instForm = document.getElementById("cm-new-instance-form");
if (instForm) instForm.addEventListener("submit", (e) => { e.preventDefault(); this.createInstance(instForm); });
}
q(sel) { return this.root.querySelector(sel); }
closeModal(id) {
const modal = document.getElementById(id);
if (modal) modal.classList.remove("visible");
}
async getJson(url) {
const res = await fetch(url, { headers: { "Accept": "application/json" } });
if (!res.ok) throw new Error(`request failed: ${res.status}`);
return res.json();
}
async post(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body || {}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data.error && data.error.message) || `request failed: ${res.status}`);
return data;
}
toast(message, type) {
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
}
async refresh() {
try {
const data = await this.getJson(`${this.base}/data`);
this.instances = data.instances || [];
this.renderInstances();
} catch { /* ignore poll errors */ }
}
renderInstances() {
const list = this.q("#cm-instance-list");
list.innerHTML = "";
for (const inst of this.instances) {
const li = document.createElement("li");
li.className = "cm-item";
const link = document.createElement("a");
link.className = "cm-item-link";
link.href = `/admin/containers/${inst.uid}`;
link.innerHTML = `<span>${inst.name}</span> <span class="cm-badge cm-${inst.status}">${inst.status}</span>`;
li.appendChild(link);
list.appendChild(li);
}
}
async createInstance(form) {
const name = form.elements.name.value.trim();
if (!name) return;
try {
await this.post(`${this.base}/instances`, {
name,
boot_command: form.elements.boot_command.value.trim(),
restart_policy: form.elements.restart_policy.value,
ports: form.elements.ports.value.trim(),
ingress_slug: form.elements.ingress_slug.value.trim(),
ingress_port: form.elements.ingress_port.value.trim(),
autostart: form.elements.autostart.checked ? "true" : "false",
});
this.closeModal("cm-new-instance-modal");
form.reset();
this.toast("Instance created", "success");
await this.refresh();
} catch (err) { this.toast(err.message, "error"); }
}
}

View File

@ -0,0 +1,99 @@
// retoor <retoor@molodetz.nl>
import { userScope } from "./userScope.js";
const SESSION_NAME = "devplace";
function persistKey() {
return `ct-persistent:${userScope()}`;
}
export class ContainerTerminalManager {
constructor() {
this._terminals = new Map();
this._persistent = null;
}
open(projectSlug, instance, label) {
if (!projectSlug || !instance) return null;
const key = `${projectSlug}::${instance}`;
const existing = this._terminals.get(key);
if (existing && existing.isConnected) {
this._makePersistent(existing, projectSlug, instance, label);
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.focus(existing);
return existing;
}
this._demoteCurrent(null);
const element = document.createElement("container-terminal");
element.setAttribute("project-slug", projectSlug);
element.setAttribute("instance", instance);
if (label) element.setAttribute("label", label);
element.setAttribute("session", SESSION_NAME);
element.setAttribute("persist", "");
document.body.appendChild(element);
this._terminals.set(key, element);
this._persistent = element;
this._remember(projectSlug, instance, label);
return element;
}
_makePersistent(element, projectSlug, instance, label) {
this._demoteCurrent(element);
element.setAttribute("session", SESSION_NAME);
if (typeof element.setPersistent === "function") element.setPersistent(true);
this._persistent = element;
this._remember(projectSlug, instance, label);
}
_demoteCurrent(except) {
if (this._persistent && this._persistent !== except && this._persistent.isConnected) {
if (typeof this._persistent.setPersistent === "function") this._persistent.setPersistent(false);
}
}
forget(element) {
this._terminals.forEach((el, key) => {
if (el === element) this._terminals.delete(key);
});
if (element === this._persistent) {
this._persistent = null;
this._clearRemember();
}
}
restore() {
let record = null;
try {
record = JSON.parse(window.localStorage.getItem(persistKey()) || "null");
} catch (error) {
record = null;
}
if (record && record.slug && record.instance) {
this.open(record.slug, record.instance, record.label || record.instance);
}
}
applyFontSize(size) {
this._terminals.forEach((element) => {
if (element.isConnected && typeof element.setFontSize === "function") {
element.setFontSize(size);
}
});
}
_remember(slug, instance, label) {
try {
window.localStorage.setItem(persistKey(), JSON.stringify({ slug, instance, label: label || instance }));
} catch (error) {
return;
}
}
_clearRemember() {
try {
window.localStorage.removeItem(persistKey());
} catch (error) {
return;
}
}
}

View File

@ -62,9 +62,31 @@ export class FormManager {
initAutoSubmitSelects() {
document.querySelectorAll("[data-auto-submit]").forEach((select) => {
let previous = select.value;
select.addEventListener("change", () => {
const form = select.closest("form");
if (form) form.submit();
if (!form) return;
const message = select.dataset.confirm;
if (!message) {
previous = select.value;
form.submit();
return;
}
const dialog = window.app && window.app.dialog;
if (!dialog) {
if (confirm(message)) { previous = select.value; form.submit(); }
else select.value = previous;
return;
}
dialog.confirm({
message,
title: select.dataset.confirmTitle || "Please confirm",
confirmLabel: select.dataset.confirmLabel,
danger: select.dataset.confirmDanger !== undefined,
}).then((ok) => {
if (ok) { previous = select.value; form.submit(); }
else select.value = previous;
});
});
});
}

View File

@ -0,0 +1,74 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
export class ProjectForker {
constructor() {
this.pollMs = 1500;
this.maxPolls = 200;
this.active = new Set();
document.addEventListener("click", (event) => {
const trigger = event.target.closest("[data-fork-project]");
if (!trigger) return;
event.preventDefault();
this.start(trigger.dataset.forkProject, trigger.dataset.forkName || "");
});
}
async start(enqueueUrl, sourceName) {
if (this.active.has(enqueueUrl)) return;
const title = await window.app.dialog.prompt({
title: "Fork project",
label: "New project name",
value: sourceName ? `${sourceName} (fork)` : "",
confirmLabel: "Fork",
});
if (title === null) return;
const name = title.trim();
if (!name) {
window.app.toast.show("A project name is required", { type: "error" });
return;
}
this.active.add(enqueueUrl);
window.app.toast.show("Forking project...", { type: "info", ms: 4000 });
try {
const job = await Http.sendForm(enqueueUrl, { title: name });
await this.poll(job.status_url);
} catch (err) {
window.app.toast.show("Could not start the fork", { type: "error" });
} finally {
this.active.delete(enqueueUrl);
}
}
poll(statusUrl) {
return new Promise((resolve) => {
let attempts = 0;
const timer = setInterval(async () => {
attempts += 1;
if (attempts > this.maxPolls) {
clearInterval(timer);
window.app.toast.show("Fork timed out", { type: "error" });
resolve();
return;
}
let status;
try {
status = await Http.getJson(statusUrl);
} catch {
return;
}
if (status.status === "done") {
clearInterval(timer);
window.app.toast.show("Fork ready", { type: "success" });
window.location.href = status.project_url;
resolve();
} else if (status.status === "failed") {
clearInterval(timer);
window.app.toast.show("Fork failed: " + (status.error || "unknown error"), { type: "error" });
resolve();
}
}, this.pollMs);
});
}
}

View File

@ -69,7 +69,7 @@ export class AppDialog extends Component {
this.messageEl.hidden = !opts.message;
this.confirmBtn.textContent = opts.confirmLabel || (mode === "alert" ? "OK" : mode === "prompt" ? "Save" : "Confirm");
this.cancelBtn.textContent = opts.cancelLabel || "Cancel";
this.cancelBtn.hidden = mode === "alert";
this.cancelBtn.style.display = mode === "alert" ? "none" : "";
this.confirmBtn.classList.toggle("dialog-danger", !!opts.danger);
if (mode === "prompt") {

View File

@ -0,0 +1,321 @@
// retoor <retoor@molodetz.nl>
import FloatingWindow from "./FloatingWindow.js";
import { userScope } from "../userScope.js";
const XTERM_JS = "/static/vendor/xterm/xterm.js";
const XTERM_CSS = "/static/vendor/xterm/xterm.css";
const XTERM_FIT = "/static/vendor/xterm/addon-fit.js";
const FONT_MIN = 8;
const FONT_MAX = 30;
const FONT_DEFAULT = 13;
let xtermLoading = null;
let cascade = 0;
function fontKey() {
return `ct-fontsize:${userScope()}`;
}
function readFontSize() {
try {
const value = parseInt(window.localStorage.getItem(fontKey()), 10);
return Number.isFinite(value) ? Math.max(FONT_MIN, Math.min(FONT_MAX, value)) : FONT_DEFAULT;
} catch (error) {
return FONT_DEFAULT;
}
}
function writeFontSize(size) {
try {
window.localStorage.setItem(fontKey(), String(size));
} catch (error) {
return;
}
}
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) {
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`failed to load ${src}`));
document.head.appendChild(script);
});
}
function loadCss(href, id) {
if (document.getElementById(id)) return;
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = href;
document.head.appendChild(link);
}
function ensureXterm() {
if (window.Terminal && window.FitAddon) return Promise.resolve();
if (!xtermLoading) {
loadCss(XTERM_CSS, "xterm-css");
xtermLoading = loadScript(XTERM_JS).then(() => loadScript(XTERM_FIT));
}
return xtermLoading;
}
export default class ContainerTerminalElement extends FloatingWindow {
get windowKey() {
return "container-terminal";
}
get titleText() {
return this.getAttribute("label") || this.getAttribute("instance") || "terminal";
}
get fontControls() {
return true;
}
get isPersistent() {
return this.hasAttribute("persist");
}
setPersistent(on) {
if (on) {
this.setAttribute("persist", "");
} else {
this.removeAttribute("persist");
this._clearReconnect();
}
}
_clearReconnect() {
if (this._reconnectTimer) {
window.clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
}
setFontSize(size) {
if (!this.term) return;
this.term.options.fontSize = size;
this._onResize();
}
_changeFont(delta) {
const next = Math.max(FONT_MIN, Math.min(FONT_MAX, readFontSize() + delta));
writeFontSize(next);
if (globalThis.app && globalThis.app.containerTerminals) {
globalThis.app.containerTerminals.applyFontSize(next);
} else {
this.setFontSize(next);
}
}
_defaultGeometry() {
const geometry = super._defaultGeometry();
const offset = (cascade++ % 6) * 26;
geometry.left = Math.max(12, geometry.left + offset);
geometry.top = Math.max(12, geometry.top + offset);
return geometry;
}
async _buildBody(body) {
body.classList.add("ct-body");
this._status = document.createElement("div");
this._status.className = "ct-status";
this._status.textContent = "connecting...";
body.appendChild(this._status);
this._mount = document.createElement("div");
this._mount.className = "ct-xterm";
body.appendChild(this._mount);
try {
await ensureXterm();
} catch (error) {
this._status.textContent = "failed to load terminal assets";
return;
}
if (!this.isConnected) return;
this._initTerm();
}
_initTerm() {
const Terminal = window.Terminal;
const FitAddon = window.FitAddon.FitAddon;
this.term = new Terminal({
cursorBlink: true,
fontFamily: '"SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace',
fontSize: readFontSize(),
theme: { background: "#000000", foreground: "#d0d0d0" },
scrollback: 5000,
});
this.fit = new FitAddon();
this.term.loadAddon(this.fit);
this.term.open(this._mount);
this.term.onData((data) => {
if (this._ws && this._ws.readyState === 1) this._ws.send(data);
});
this._fitSoon();
this._connect();
}
_fitSoon() {
[0, 60, 180, 400].forEach((delay) => window.setTimeout(() => this._onResize(), delay));
}
_connect() {
const slug = this.getAttribute("project-slug");
const instance = this.getAttribute("instance");
const session = this.getAttribute("session") || "";
const scheme = location.protocol === "https:" ? "wss" : "ws";
const query = session ? `?session=${encodeURIComponent(session)}` : "";
const url = `${scheme}://${location.host}/projects/${encodeURIComponent(slug)}`
+ `/containers/instances/${encodeURIComponent(instance)}/exec/ws${query}`;
const ws = new WebSocket(url);
this._ws = ws;
ws.addEventListener("open", () => {
this._reconnects = 0;
if (this._status) this._status.style.display = "none";
this._fitSoon();
if (this.term) this.term.focus();
});
ws.addEventListener("message", (event) => {
if (this.term) this.term.write(event.data);
});
ws.addEventListener("close", (event) => {
this._ws = null;
if (this._closing) return;
if (event.code === 1008) {
if (this.term) this.term.write("\r\n\x1b[31m[forbidden]\x1b[0m\r\n");
return;
}
if (this.isPersistent) {
this._scheduleReconnect();
} else if (this.term) {
this.term.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n");
}
});
ws.addEventListener("error", () => {});
}
_scheduleReconnect() {
if (this._closing || this._reconnectTimer) return;
this._reconnects = (this._reconnects || 0) + 1;
const delay = Math.min(5000, 400 * this._reconnects);
if (this._status) {
this._status.style.display = "";
this._status.textContent = "reconnecting...";
}
this._reconnectTimer = window.setTimeout(() => {
this._reconnectTimer = null;
if (!this._closing && this.isConnected) this._connect();
}, delay);
}
_sendResize() {
if (!this.term || !this._ws || this._ws.readyState !== 1) return;
this._ws.send(JSON.stringify({ type: "resize", cols: this.term.cols, rows: this.term.rows }));
}
_onResize() {
if (!this.fit || !this.term || !this.isConnected) return;
try {
this.fit.fit();
} catch (error) {
return;
}
this._sendResize();
}
_onShow() {
if (this.term) this.term.focus();
}
_contextItems() {
const slug = this.getAttribute("project-slug");
return [
{ label: "Sync files", onSelect: () => this._lifecycle("sync", "Files synced") },
{ label: "Restart", onSelect: () => this._lifecycle("restart", "Restart requested") },
{ label: "Terminate", danger: true, onSelect: () => this._terminate() },
{ separator: true },
{ label: "Minimize", onSelect: () => this.minimizeWindow() },
{ label: "Normalize", onSelect: () => this.normalizeWindow() },
{ separator: true },
{ label: "Project", onSelect: () => this._navigate(`/projects/${slug}`) },
{ label: "Files", onSelect: () => this._navigate(`/projects/${slug}/files`) },
{ separator: true },
{ label: "Close", danger: true, onSelect: () => this.close() },
];
}
_post(action) {
const slug = this.getAttribute("project-slug");
const instance = this.getAttribute("instance");
return fetch(
`/projects/${encodeURIComponent(slug)}/containers/instances/${encodeURIComponent(instance)}/${action}`,
{ method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" } },
);
}
async _lifecycle(action, okMessage) {
try {
const res = await this._post(action);
const data = await res.json().catch(() => ({}));
if (res.ok) this._toast(okMessage, "success");
else this._toast((data.error && data.error.message) || `${action} failed`, "error");
} catch (error) {
this._toast(`${action} failed`, "error");
}
}
async _terminate() {
if (globalThis.app && globalThis.app.dialog) {
const ok = await globalThis.app.dialog.confirm({
title: "Terminate container",
message: `Stop and remove "${this.titleText}"? Synced project files are kept.`,
confirmLabel: "Terminate",
danger: true,
});
if (!ok) return;
}
await this._lifecycle("delete", "Container terminated");
this.close();
}
_navigate(url) {
window.location.assign(url);
}
_toast(message, type) {
if (globalThis.app && globalThis.app.toast) globalThis.app.toast.show(message, { type: type || "info" });
}
_onClose() {
this._closing = true;
this._clearReconnect();
if (globalThis.app && globalThis.app.containerTerminals) {
globalThis.app.containerTerminals.forget(this);
}
try {
if (this._ws) this._ws.close();
} catch (error) {
return this._dispose();
}
this._dispose();
}
_dispose() {
try {
if (this.term) this.term.dispose();
} catch (error) {
// already disposed
}
this.remove();
}
}
customElements.define("container-terminal", ContainerTerminalElement);

View File

@ -0,0 +1,356 @@
// retoor <retoor@molodetz.nl>
const FW_CSS_ID = "floating-window-css";
const MOBILE_QUERY = "(max-width: 640px)";
export default class FloatingWindow extends HTMLElement {
constructor() {
super();
this.state = "normal";
this.geometry = null;
this._drag = null;
this._rafResize = null;
this._mobile = window.matchMedia(MOBILE_QUERY);
}
get windowKey() {
return "floating-window";
}
get titleText() {
return this.getAttribute("label") || "Window";
}
get fontControls() {
return false;
}
connectedCallback() {
this._ensureBaseCss();
this.classList.add("fw-host");
this._buildChrome();
this._registerWindow();
this._viewportHandler = () => this._onViewportChange();
this._mobile.addEventListener("change", this._viewportHandler);
this._winResize = () => {
this._clampWindow();
this._scheduleResize();
};
window.addEventListener("resize", this._winResize);
const saved = this._loadGeometry();
if (saved) {
this.geometry = saved;
this._clampGeometry();
}
this._setState(this._mobile.matches ? "fullscreen" : "normal");
this._buildBody(this.body);
this._observeResize();
this._onShow();
}
disconnectedCallback() {
window.removeEventListener("resize", this._winResize);
this._mobile.removeEventListener("change", this._viewportHandler);
if (this._ro) this._ro.disconnect();
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.unregister(this);
this._onHide();
}
_ensureBaseCss() {
if (document.getElementById(FW_CSS_ID)) return;
const link = document.createElement("link");
link.id = FW_CSS_ID;
link.rel = "stylesheet";
link.href = "/static/css/floating-window.css";
document.head.appendChild(link);
}
_registerWindow() {
if (globalThis.app && globalThis.app.windows) {
globalThis.app.windows.register(this);
}
this.addEventListener("pointerdown", () => {
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.focus(this);
});
if (globalThis.app && globalThis.app.contextMenu) {
globalThis.app.contextMenu.attach(this.win, () => this._contextItems());
}
}
_buildChrome() {
this.innerHTML = "";
this.win = document.createElement("div");
this.win.className = "fw-window";
const fontButtons = this.fontControls ? [
' <button type="button" data-win="font-dec" title="Smaller font">&#8722;</button>',
' <button type="button" data-win="font-inc" title="Larger font">&#43;</button>',
] : [];
this.win.innerHTML = [
'<div class="fw-titlebar">',
' <span class="fw-title"><span class="fw-dot"></span><span class="fw-title-text"></span></span>',
' <span class="fw-winctl">',
...fontButtons,
' <button type="button" data-win="minimize" title="Minimize (smallest usable size)">&#9643;</button>',
' <button type="button" data-win="normalize" title="Normalize (comfortable size)">&#9645;</button>',
' <button type="button" data-win="fullscreen" title="Fullscreen">&#9974;</button>',
' <button type="button" data-win="maximize" title="Maximize">&#9633;</button>',
' <button type="button" data-win="close" title="Close">&#10005;</button>',
" </span>",
"</div>",
'<div class="fw-body"></div>',
'<div class="fw-resize"></div>',
].join("");
this.appendChild(this.win);
this.titlebar = this.win.querySelector(".fw-titlebar");
this.body = this.win.querySelector(".fw-body");
this.win.querySelector(".fw-title-text").textContent = this.titleText;
this.win.querySelectorAll(".fw-winctl button").forEach((button) => {
button.addEventListener("click", () => this._window(button.dataset.win));
});
this.titlebar.addEventListener("mousedown", (event) => this._startDrag(event));
this.titlebar.addEventListener("touchstart", (event) => this._startDrag(event), { passive: false });
this.titlebar.addEventListener("dblclick", () => this._window("maximize"));
const grip = this.win.querySelector(".fw-resize");
grip.addEventListener("mousedown", (event) => this._startResize(event));
grip.addEventListener("touchstart", (event) => this._startResize(event), { passive: false });
}
setTitle(text) {
const el = this.win && this.win.querySelector(".fw-title-text");
if (el) el.textContent = text;
}
_buildBody() {}
_onShow() {}
_onHide() {}
_onResize() {}
_onClose() { this.remove(); }
_changeFont() {}
close() {
this._onClose();
}
_window(action) {
if (action === "close") {
this.close();
} else if (action === "maximize") {
this._setState(this.state === "maximized" ? "normal" : "maximized");
} else if (action === "fullscreen") {
this._setState(this.state === "fullscreen" ? "normal" : "fullscreen");
} else if (action === "font-inc") {
this._changeFont(1);
} else if (action === "font-dec") {
this._changeFont(-1);
} else if (action === "minimize") {
this.minimizeWindow();
} else if (action === "normalize") {
this.normalizeWindow();
}
}
minimizeWindow() {
this._presetGeometry(400, 260);
}
normalizeWindow() {
this._presetGeometry(820, 560);
}
_presetGeometry(width, height) {
const w = Math.max(320, Math.min(width, window.innerWidth - 16));
const h = Math.max(220, Math.min(height, window.innerHeight - 16));
this.geometry = {
width: w,
height: h,
left: Math.max(8, Math.round((window.innerWidth - w) / 2)),
top: Math.max(8, Math.round((window.innerHeight - h) / 2)),
};
this._setState("normal");
this._persistGeometry();
}
_contextItems() {
return [
{ label: "Minimize", onSelect: () => this.minimizeWindow() },
{ label: "Normalize", onSelect: () => this.normalizeWindow() },
{ separator: true },
{ label: "Close", danger: true, onSelect: () => this.close() },
];
}
_setState(state) {
let resolved = state;
if (this._mobile.matches) resolved = "fullscreen";
this.state = resolved;
this.classList.toggle("fw-mobile", this._mobile.matches);
["normal", "maximized", "fullscreen"].forEach((name) => {
this.classList.toggle(`fw-state-${name}`, name === resolved);
});
if (resolved === "normal") {
this._applyGeometry();
} else {
this.win.style.left = "";
this.win.style.top = "";
this.win.style.width = "";
this.win.style.height = "";
}
this._scheduleResize();
}
_defaultGeometry() {
const width = Math.min(820, Math.round(window.innerWidth * 0.94));
const height = Math.min(560, Math.round(window.innerHeight * 0.82));
return {
width,
height,
left: Math.max(12, Math.round((window.innerWidth - width) / 2)),
top: Math.max(12, Math.round((window.innerHeight - height) / 2)),
};
}
_applyGeometry() {
if (!this.geometry) this.geometry = this._defaultGeometry();
const g = this.geometry;
this.win.style.width = `${g.width}px`;
this.win.style.height = `${g.height}px`;
this.win.style.left = `${g.left}px`;
this.win.style.top = `${g.top}px`;
}
_clampGeometry() {
if (!this.geometry) return;
const g = this.geometry;
g.width = Math.min(g.width, window.innerWidth - 16);
g.height = Math.min(g.height, window.innerHeight - 16);
g.left = Math.max(8, Math.min(g.left, window.innerWidth - g.width - 8));
g.top = Math.max(8, Math.min(g.top, window.innerHeight - g.height - 8));
}
_clampWindow() {
if (this.state !== "normal" || !this.geometry) return;
this._clampGeometry();
this._applyGeometry();
this._persistGeometry();
this._scheduleResize();
}
_onViewportChange() {
this._setState("normal");
}
_startDrag(event) {
if (this.state !== "normal") return;
if (event.target.tagName === "BUTTON") return;
event.preventDefault();
const point = event.touches ? event.touches[0] : event;
const rect = this.win.getBoundingClientRect();
this._drag = { dx: point.clientX - rect.left, dy: point.clientY - rect.top };
this.classList.add("fw-dragging");
this._moveHandler = (move) => this._onDrag(move);
this._upHandler = () => this._endDrag();
window.addEventListener("mousemove", this._moveHandler);
window.addEventListener("mouseup", this._upHandler);
window.addEventListener("touchmove", this._moveHandler, { passive: false });
window.addEventListener("touchend", this._upHandler);
}
_onDrag(event) {
if (!this._drag) return;
if (event.cancelable) event.preventDefault();
const point = event.touches ? event.touches[0] : event;
const g = this.geometry;
g.left = Math.max(8, Math.min(point.clientX - this._drag.dx, window.innerWidth - g.width - 8));
g.top = Math.max(8, Math.min(point.clientY - this._drag.dy, window.innerHeight - g.height - 8));
this.win.style.left = `${g.left}px`;
this.win.style.top = `${g.top}px`;
}
_endDrag() {
this._drag = null;
this.classList.remove("fw-dragging");
window.removeEventListener("mousemove", this._moveHandler);
window.removeEventListener("mouseup", this._upHandler);
window.removeEventListener("touchmove", this._moveHandler);
window.removeEventListener("touchend", this._upHandler);
this._persistGeometry();
}
_startResize(event) {
if (this.state !== "normal") return;
event.preventDefault();
event.stopPropagation();
if (!this.geometry) this.geometry = this._defaultGeometry();
const point = event.touches ? event.touches[0] : event;
this._resize = { x: point.clientX, y: point.clientY, w: this.geometry.width, h: this.geometry.height };
this.classList.add("fw-dragging");
this._resizeMove = (move) => this._onResizeDrag(move);
this._resizeUp = () => this._endResize();
window.addEventListener("mousemove", this._resizeMove);
window.addEventListener("mouseup", this._resizeUp);
window.addEventListener("touchmove", this._resizeMove, { passive: false });
window.addEventListener("touchend", this._resizeUp);
}
_onResizeDrag(event) {
if (!this._resize) return;
if (event.cancelable) event.preventDefault();
const point = event.touches ? event.touches[0] : event;
const g = this.geometry;
g.width = Math.max(320, Math.min(this._resize.w + (point.clientX - this._resize.x), window.innerWidth - g.left - 8));
g.height = Math.max(220, Math.min(this._resize.h + (point.clientY - this._resize.y), window.innerHeight - g.top - 8));
this.win.style.width = `${g.width}px`;
this.win.style.height = `${g.height}px`;
this._scheduleResize();
}
_endResize() {
this._resize = null;
this.classList.remove("fw-dragging");
window.removeEventListener("mousemove", this._resizeMove);
window.removeEventListener("mouseup", this._resizeUp);
window.removeEventListener("touchmove", this._resizeMove);
window.removeEventListener("touchend", this._resizeUp);
this._persistGeometry();
}
_observeResize() {
if (typeof ResizeObserver === "undefined") return;
this._ro = new ResizeObserver(() => {
if (this.state === "normal" && this.geometry && !this._drag) {
const rect = this.win.getBoundingClientRect();
this.geometry.width = Math.round(rect.width);
this.geometry.height = Math.round(rect.height);
this._persistGeometry();
}
this._scheduleResize();
});
this._ro.observe(this.win);
if (this.body) this._ro.observe(this.body);
}
_scheduleResize() {
if (this._rafResize) return;
this._rafResize = window.requestAnimationFrame(() => {
this._rafResize = null;
this._onResize();
});
}
_loadGeometry() {
try {
const raw = window.localStorage.getItem(`fw-geo:${this.windowKey}`);
return raw ? JSON.parse(raw) : null;
} catch (error) {
return null;
}
}
_persistGeometry() {
try {
window.localStorage.setItem(`fw-geo:${this.windowKey}`, JSON.stringify(this.geometry));
} catch (error) {
return;
}
}
}

View File

@ -0,0 +1,46 @@
// retoor <retoor@molodetz.nl>
const BASE_Z = 2147480000;
const CEILING_Z = 2147483400;
export default class WindowManager {
constructor() {
this._z = BASE_Z;
this._windows = new Set();
}
register(win) {
this._windows.add(win);
this.focus(win);
}
unregister(win) {
this._windows.delete(win);
}
focus(win) {
if (!win) return;
if (this._z >= CEILING_Z) this._renormalize();
this._z += 1;
try {
win.style.zIndex = String(this._z);
} catch (error) {
return;
}
}
_renormalize() {
const ordered = Array.from(this._windows).sort(
(a, b) => (parseInt(a.style.zIndex, 10) || 0) - (parseInt(b.style.zIndex, 10) || 0),
);
this._z = BASE_Z;
ordered.forEach((win) => {
this._z += 1;
try {
win.style.zIndex = String(this._z);
} catch (error) {
return;
}
});
}
}

View File

@ -7,3 +7,4 @@ import "./AppUpload.js";
import "./AppToast.js";
import "./AppDialog.js";
import "./AppContextMenu.js";
import "./ContainerTerminal.js";

View File

@ -28,6 +28,8 @@ export default class DeviiClient {
return this._navigate(String(args.url || ""));
case "reload_page":
return this._reload();
case "open_terminal":
return this._openTerminal(args);
default:
return { error: `unknown client action '${action}'` };
}
@ -52,9 +54,12 @@ export default class DeviiClient {
loggedIn = true;
}
}
const pageTypeEl = document.querySelector("[data-page-type]");
const pageType = pageTypeEl ? pageTypeEl.getAttribute("data-page-type") : location.pathname;
return {
url: location.href,
path: location.pathname + location.search,
pageType,
title: document.title,
referrer: document.referrer || null,
viewport: { width: window.innerWidth, height: window.innerHeight },
@ -203,4 +208,16 @@ export default class DeviiClient {
window.setTimeout(() => window.location.reload(), NAV_DELAY_MS);
return "reloading";
}
_openTerminal(args) {
const projectSlug = String(args.project_slug || "").trim();
const instance = String(args.instance || "").trim();
const label = String(args.label || instance || "").trim();
if (!projectSlug || !instance) return { error: "project_slug and instance are required" };
if (!globalThis.app || !globalThis.app.containerTerminals) {
return { error: "terminal manager unavailable" };
}
globalThis.app.containerTerminals.open(projectSlug, instance, label);
return { opened: true, instance };
}
}

View File

@ -1,6 +1,8 @@
// retoor <retoor@molodetz.nl>
const RECONNECT_DELAY_MS = 1500;
const WRONG_WORKER_RETRY_MS = 200;
const WRONG_WORKER_CODE = 4013;
export default class DeviiSocket {
constructor(handlers) {
@ -8,6 +10,7 @@ export default class DeviiSocket {
this.ws = null;
this.url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/devii/ws";
this._shouldRun = true;
this._settled = false;
}
connect() {
@ -16,12 +19,15 @@ export default class DeviiSocket {
}
_open() {
this._settled = false;
this.ws = new WebSocket(this.url);
this.ws.addEventListener("open", () => this._emit("onOpen"));
this.ws.addEventListener("close", () => {
this._emit("onClose");
this.ws.addEventListener("close", (event) => {
const transient = !this._settled && event.code === WRONG_WORKER_CODE;
if (!transient) this._emit("onClose");
if (this._shouldRun) {
window.setTimeout(() => this._open(), RECONNECT_DELAY_MS);
const delay = transient ? WRONG_WORKER_RETRY_MS : RECONNECT_DELAY_MS;
window.setTimeout(() => this._open(), delay);
}
});
this.ws.addEventListener("error", () => this._emit("onError"));
@ -32,6 +38,10 @@ export default class DeviiSocket {
} catch {
return;
}
if (!this._settled) {
this._settled = true;
this._emit("onReady");
}
this._emit("onMessage", payload);
});
}

View File

@ -3,9 +3,16 @@
import Markdown from "./markdown.js";
import DeviiSocket from "./DeviiSocket.js";
import DeviiClient from "./DeviiClient.js";
import { userScope } from "../userScope.js";
const CSS_ID = "devii-terminal-css";
const STORAGE_KEY = "devii-terminal-state";
const HISTORY_KEY = "devii-terminal-history";
const FONT_KEY = "devii-fontsize";
const FONT_MIN = 10;
const FONT_MAX = 26;
const FONT_DEFAULT = 14;
const HISTORY_LIMIT = 500;
const MOBILE_QUERY = "(max-width: 640px)";
const TRACE_GLYPHS = {
call: "↳",
@ -16,12 +23,14 @@ const TRACE_GLYPHS = {
"verify-gate": "verification gate",
compact: "context compaction",
};
const CLEAR_WORDS = new Set(["clear", "reset", "/clear", "/reset"]);
const CLEAR_WORDS = new Set(["clear", "/clear"]);
const RESET_WORDS = new Set(["reset", "/reset"]);
const CLOSE_WORDS = new Set(["exit", "close", "quit", "bye", "/exit", "/close", "/quit", "/bye"]);
const HELP = [
"Local commands:",
" /help show this help",
" clear | reset clear the conversation",
" clear clear the conversation",
" reset cancel the running task and clear the conversation",
" exit | close | quit | bye close the terminal",
" /show show devii (the avatar)",
" /hide hide devii",
@ -38,6 +47,8 @@ export default class DeviiTerminalElement extends HTMLElement {
this.socket = null;
this.history = [];
this.historyIndex = 0;
this.draft = "";
this._historyKey = null;
this.state = "closed";
this.geometry = null;
this._drag = null;
@ -49,15 +60,25 @@ export default class DeviiTerminalElement extends HTMLElement {
connectedCallback() {
this._ensureCss();
this._build();
this._applyFont(this._readFont());
if (globalThis.app && typeof globalThis.app.register === "function") {
globalThis.app.register(this);
}
if (globalThis.app && globalThis.app.windows) {
globalThis.app.windows.register(this);
this.addEventListener("pointerdown", () => {
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.focus(this);
});
}
this._mobile.addEventListener("change", () => this._onViewportChange());
window.addEventListener("resize", () => this._clampWindow());
window.addEventListener("pagehide", () => this._captureFocusForUnload());
window.addEventListener("beforeunload", () => this._captureFocusForUnload());
this._identity = null;
this._fetchIdentity().then((id) => { this._identity = id; });
this._fetchIdentity().then((id) => {
this._identity = id;
this._initHistory(id);
});
window.addEventListener("focus", () => {
this._checkIdentity();
this._reportVisibility();
@ -68,7 +89,8 @@ export default class DeviiTerminalElement extends HTMLElement {
this._reportVisibility();
});
this.socket = new DeviiSocket({
onOpen: () => {
onOpen: () => this._reportVisibility(),
onReady: () => {
this._notice("connected.");
this._reportVisibility();
},
@ -147,6 +169,40 @@ export default class DeviiTerminalElement extends HTMLElement {
}
}
_historyKeyFor(identity) {
const scope = identity && identity.loggedIn && identity.username
? `user:${identity.username}`
: "guest";
return `${HISTORY_KEY}:${scope}`;
}
_initHistory(identity) {
this._historyKey = this._historyKeyFor(identity);
this.history = this._loadHistory();
this.historyIndex = this.history.length;
this.draft = "";
}
_loadHistory() {
if (!this._historyKey) return [];
try {
const raw = window.localStorage.getItem(this._historyKey);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : [];
} catch (error) {
return [];
}
}
_saveHistory() {
if (!this._historyKey) return;
try {
window.localStorage.setItem(this._historyKey, JSON.stringify(this.history));
} catch (error) {
return;
}
}
_ensureConnected() {
if (this._connected) return;
this._connected = true;
@ -177,6 +233,10 @@ export default class DeviiTerminalElement extends HTMLElement {
'<div class="devii-titlebar">',
' <span class="devii-title"><span class="devii-dot"></span>Devii</span>',
' <span class="devii-winctl">',
' <button type="button" data-win="font-dec" title="Smaller font">&#8722;</button>',
' <button type="button" data-win="font-inc" title="Larger font">&#43;</button>',
' <button type="button" data-win="minimize" title="Minimize (smallest usable size)">&#9643;</button>',
' <button type="button" data-win="normalize" title="Normalize (comfortable size)">&#9645;</button>',
' <button type="button" data-win="fullscreen" title="Fullscreen">&#9974;</button>',
' <button type="button" data-win="maximize" title="Maximize">&#9633;</button>',
' <button type="button" data-win="close" title="Close">&#10005;</button>',
@ -218,6 +278,9 @@ export default class DeviiTerminalElement extends HTMLElement {
this.titlebar.addEventListener("mousedown", (event) => this._startDrag(event));
this.titlebar.addEventListener("touchstart", (event) => this._startDrag(event), { passive: false });
this.titlebar.addEventListener("dblclick", () => this._window("maximize"));
if (globalThis.app && globalThis.app.contextMenu) {
globalThis.app.contextMenu.attach(this.win, () => this._contextItems());
}
}
open() {
@ -242,9 +305,75 @@ export default class DeviiTerminalElement extends HTMLElement {
this._setState(this.state === "maximized" ? "normal" : "maximized");
} else if (action === "fullscreen") {
this._setState(this.state === "fullscreen" ? "normal" : "fullscreen");
} else if (action === "font-inc") {
this._changeFont(1);
} else if (action === "font-dec") {
this._changeFont(-1);
} else if (action === "minimize") {
this.minimizeWindow();
} else if (action === "normalize") {
this.normalizeWindow();
}
}
minimizeWindow() {
this._presetGeometry(400, 260);
}
normalizeWindow() {
this._presetGeometry(760, 600);
}
_presetGeometry(width, height) {
const w = Math.max(300, Math.min(width, window.innerWidth - 16));
const h = Math.max(220, Math.min(height, window.innerHeight - 16));
this.geometry = {
width: w,
height: h,
left: Math.max(8, Math.round((window.innerWidth - w) / 2)),
top: Math.max(8, Math.round((window.innerHeight - h) / 2)),
};
this._clampGeometry();
this._setState("normal");
this._persist();
}
_contextItems() {
return [
{ label: "Minimize", onSelect: () => this.minimizeWindow() },
{ label: "Normalize", onSelect: () => this.normalizeWindow() },
{ separator: true },
{ label: "Close", danger: true, onSelect: () => this.close() },
];
}
_fontKey() {
return `${FONT_KEY}:${userScope()}`;
}
_readFont() {
try {
const value = parseInt(window.localStorage.getItem(this._fontKey()), 10);
return Number.isFinite(value) ? Math.max(FONT_MIN, Math.min(FONT_MAX, value)) : FONT_DEFAULT;
} catch (error) {
return FONT_DEFAULT;
}
}
_applyFont(size) {
this.style.setProperty("--devii-font-size", `${size}px`);
}
_changeFont(delta) {
const next = Math.max(FONT_MIN, Math.min(FONT_MAX, this._readFont() + delta));
try {
window.localStorage.setItem(this._fontKey(), String(next));
} catch (error) {
return this._applyFont(next);
}
this._applyFont(next);
}
_setState(state) {
let resolved = state;
if (state === "open") {
@ -360,10 +489,10 @@ export default class DeviiTerminalElement extends HTMLElement {
const text = this.input.value;
this.input.value = "";
this._submit(text);
} else if (event.key === "ArrowUp") {
} else if (event.key === "ArrowUp" || event.key === "PageUp") {
event.preventDefault();
this._recall(-1);
} else if (event.key === "ArrowDown") {
} else if (event.key === "ArrowDown" || event.key === "PageDown") {
event.preventDefault();
this._recall(1);
} else if (event.key === "Escape") {
@ -373,20 +502,38 @@ export default class DeviiTerminalElement extends HTMLElement {
_recall(direction) {
if (!this.history.length) return;
this.historyIndex = Math.max(0, Math.min(this.history.length, this.historyIndex + direction));
this.input.value = this.history[this.historyIndex] || "";
if (this.historyIndex === this.history.length) {
this.draft = this.input.value;
}
const target = Math.max(0, Math.min(this.history.length, this.historyIndex + direction));
if (target === this.historyIndex) return;
this.historyIndex = target;
const value = target === this.history.length ? this.draft : this.history[target];
this.input.value = value;
this.input.setSelectionRange(value.length, value.length);
}
_submit(raw) {
const text = (raw || "").trim();
if (!text) return;
this.history.push(text);
if (this.history[this.history.length - 1] !== text) {
this.history.push(text);
if (this.history.length > HISTORY_LIMIT) {
this.history = this.history.slice(-HISTORY_LIMIT);
}
this._saveHistory();
}
this.historyIndex = this.history.length;
this.draft = "";
const lowered = text.toLowerCase();
if (CLEAR_WORDS.has(lowered)) {
if (RESET_WORDS.has(lowered)) {
this._resetConversation();
return;
}
if (CLEAR_WORDS.has(lowered)) {
this._clearConversation();
return;
}
if (CLOSE_WORDS.has(lowered)) {
this.close();
return;
@ -406,6 +553,11 @@ export default class DeviiTerminalElement extends HTMLElement {
this.socket.send({ type: "reset" });
}
_clearConversation() {
this.output.innerHTML = "";
this.socket.send({ type: "clear" });
}
async _local(text) {
const [command, ...rest] = text.slice(1).split(" ");
const argument = rest.join(" ").trim();
@ -468,6 +620,9 @@ export default class DeviiTerminalElement extends HTMLElement {
try {
const response = await fetch("/devii/session", { credentials: "same-origin" });
const data = await response.json();
if (data.baseUrl) {
this.markdown.baseUrl = data.baseUrl;
}
return { loggedIn: !!data.loggedIn, username: data.username || "" };
} catch (error) {
return null;

View File

@ -7,10 +7,13 @@ const SAFE_HREF = /^(https?:\/\/|\/|#|mailto:)/i;
export default class Markdown {
constructor() {
this.highlighter = new Highlighter();
this.baseUrl = "";
}
escape(text) {
return text
return String(text)
.split(String.fromCharCode(0))
.join("")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
@ -22,23 +25,67 @@ export default class Markdown {
const tick = String.fromCharCode(96);
const codeRe = new RegExp(tick + "([^" + tick + "]+)" + tick, "g");
let out = this.escape(text);
out = out.replace(codeRe, (_m, code) => `<code class="md-code">${code}</code>`);
const tokens = [];
const stash = (html) => {
const key = String.fromCharCode(0) + tokens.length + String.fromCharCode(0);
tokens.push(html);
return key;
};
out = out.replace(codeRe, (_m, code) => stash(`<code class="md-code">${code}</code>`));
const imgRe = new RegExp("!\\[([^\\]]*)\\]\\(([^)\\s]+)\\)", "g");
out = out.replace(imgRe, (_m, alt, src) => this._image(alt, src));
out = out.replace(imgRe, (_m, alt, src) => stash(this._image(alt, src)));
const linkRe = new RegExp("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)", "g");
out = out.replace(linkRe, (_m, label, href) => this._link(label, href));
out = out.replace(linkRe, (_m, label, href) => stash(this._link(label, href)));
out = this._autolink(out, stash);
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
out = out.replace(/__([^_]+)__/g, "<strong>$1</strong>");
out = out.replace(/(^|[^*])\*([^*\s][^*]*?)\*/g, "$1<em>$2</em>");
out = out.replace(/~~([^~]+)~~/g, "<del>$1</del>");
out = out.replace(new RegExp(String.fromCharCode(0) + "(\\d+)" + String.fromCharCode(0), "g"), (_m, i) => tokens[Number(i)]);
return out;
}
_resolveHref(href) {
if (/^(https?:\/\/|mailto:|#)/i.test(href)) {
return href;
}
if (href.startsWith("/") && !href.startsWith("//")) {
const base = (this.baseUrl || (typeof globalThis !== "undefined" && globalThis.location ? globalThis.location.origin : "")).replace(/\/+$/, "");
return base + href;
}
return null;
}
_autolink(text, stash) {
const NUL = String.fromCharCode(0);
const absRe = new RegExp("(^|[\\s(])(https?:\\/\\/[^\\s<)" + NUL + "]+)", "g");
text = text.replace(absRe, (_m, pre, url) => pre + this._autoAnchor(url, stash));
const relRe = new RegExp("(^|[\\s(])(\\/[A-Za-z0-9._~\\-][A-Za-z0-9._~\\-\\/%?=&;#:+@]*)", "g");
text = text.replace(relRe, (_m, pre, url) => pre + this._autoAnchor(url, stash));
return text;
}
_autoAnchor(rawUrl, stash) {
let url = rawUrl;
let trail = "";
const match = url.match(/[.,;:!?)\]}'"]+$/);
if (match) {
trail = match[0];
url = url.slice(0, url.length - trail.length);
}
const href = url ? this._resolveHref(url) : null;
if (!href) {
return rawUrl;
}
return stash(`<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>`) + trail;
}
_link(label, href) {
if (!SAFE_HREF.test(href)) {
const resolved = this._resolveHref(href);
if (!resolved || !SAFE_HREF.test(resolved)) {
return this.escape(label);
}
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>`;
return `<a href="${resolved}" target="_blank" rel="noopener noreferrer">${label}</a>`;
}
_image(alt, src) {

View File

@ -0,0 +1,11 @@
// retoor <retoor@molodetz.nl>
export function userScope() {
const docs = window.DEVPLACE_DOCS || {};
let username = docs.username || "";
if (!username) {
const el = document.querySelector(".topnav-user-name");
if (el) username = el.textContent.trim();
}
return username ? `user:${username}` : "guest";
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Some files were not shown because too many files have changed in this diff Show More