feat: add project fork async job service with cli prune/clear commands and shared container image build target

This commit is contained in:
2026-06-09 14:06:02 +00:00
parent c565e3b55c
commit 05c6fa7f3b
90 changed files with 6889 additions and 1146 deletions
+34 -69
View File
@@ -10,11 +10,9 @@ import httpx
from devplacepy import config, project_files
from devplacepy.services.containers import store
from devplacepy.services.containers.locks import NUMBERING_LOCK
from devplacepy.services.containers.templates import DEFAULT_DOCKERFILE
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
from devplacepy.services.jobs import queue
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}$")
@@ -31,13 +29,6 @@ class ContainerError(ValueError):
pass
def validate_image_name(name: str) -> str:
name = (name or "").strip().lower()
if not IMAGE_NAME_RE.match(name):
raise ContainerError("name must be lowercase letters, digits, '.', '_' or '-' (max 63 chars)")
return name
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")
@@ -126,41 +117,6 @@ def parse_env(value) -> dict:
return env
# ---------------- dockerfiles + builds ----------------
async def enqueue_build(dockerfile: dict, version: dict, *, also_latest: bool = True, owner=("system", "system")) -> dict:
async with NUMBERING_LOCK:
head = store.get_dockerfile(dockerfile["uid"])
build_number = int(head.get("latest_build_number") or 0) + 1
store.update_dockerfile(head["uid"], {"latest_build_number": build_number})
image_tag = f"{head['name']}:{build_number}"
build = store.create_build(head, version, build_number, image_tag, also_latest)
job_uid = queue.enqueue("container_build", {"build_uid": build["uid"]}, owner[0], owner[1], f"build {image_tag}")
store.update_build(build["uid"], {"job_uid": job_uid})
return store.get_build(build["uid"])
async def create_dockerfile(project: dict, user: dict, *, name: str, description: str = "",
tags: str = "", content: str = None) -> dict:
name = validate_image_name(name)
for existing in store.list_dockerfiles(project["uid"]):
if existing["name"] == name:
raise ContainerError(f"a Dockerfile named '{name}' already exists in this project")
content = content if content is not None and content.strip() else DEFAULT_DOCKERFILE
dockerfile = store.create_dockerfile(project["uid"], user, name, description, tags)
version = store.create_version(dockerfile, content, user)
build = await enqueue_build(dockerfile, version, owner=("user", user["uid"]))
return {"dockerfile": store.get_dockerfile(dockerfile["uid"]), "version": version, "build": build}
async def save_version(dockerfile: dict, user: dict, content: str, *, force: bool = False) -> dict:
if not force and store.content_hash(content) == dockerfile.get("content_hash"):
return {"version": None, "build": None, "changed": False}
version = store.create_version(dockerfile, content, user)
build = await enqueue_build(store.get_dockerfile(dockerfile["uid"]), version, owner=("user", user["uid"]))
return {"version": version, "build": build, "changed": True}
# ---------------- instances ----------------
def validate_ingress(slug: str, port, port_list) -> tuple:
@@ -181,13 +137,13 @@ def validate_ingress(slug: str, port, port_list) -> tuple:
return slug, ingress_port
async def create_instance(project: dict, dockerfile: dict, build: dict, *, name: str,
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 build.get("status") != store.BUILD_SUCCESS:
raise ContainerError("the selected build has not completed successfully")
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)
@@ -202,7 +158,9 @@ async def create_instance(project: dict, dockerfile: dict, build: dict, *, name:
await asyncio.to_thread(project_files.export_to_dir, project["uid"], "", str(workspace))
row = {
"project_uid": project["uid"], "dockerfile_uid": dockerfile["uid"], "build_uid": build["uid"],
"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 ""),
@@ -215,7 +173,7 @@ async def create_instance(project: dict, dockerfile: dict, build: dict, *, name:
"workspace_dir": str(workspace),
}
instance = store.create_instance(row)
store.record_event(instance, "created", actor[0], actor[1], {"build": build["uid"]})
store.record_event(instance, "created", actor[0], actor[1], {"image": config.CONTAINER_IMAGE})
return instance
@@ -238,18 +196,40 @@ def mark_for_removal(instance: dict, *, actor=("system", "system")) -> None:
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 "{}")
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")))
command = []
boot = (instance.get("boot_command") or "").strip()
if boot:
command = ["/bin/sh", "-c", boot]
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"]},
@@ -284,21 +264,6 @@ def _percentile(values: list, pct: float) -> float:
return float(ordered[index])
def dockerfile_stats(dockerfile_uid: str) -> dict:
builds = store.list_builds(dockerfile_uid, limit=1000)
done = [b for b in builds if b["status"] in (store.BUILD_SUCCESS, store.BUILD_FAILED)]
success = [b for b in done if b["status"] == store.BUILD_SUCCESS]
durations = [int(b.get("duration_ms") or 0) for b in success if b.get("duration_ms")]
return {
"total_builds": len(builds),
"success": len(success),
"failed": len(done) - len(success),
"success_rate": round(len(success) / len(done) * 100, 1) if done else 0.0,
"avg_build_ms": int(sum(durations) / len(durations)) if durations else 0,
"p95_build_ms": int(_percentile(durations, 95)),
}
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]
@@ -116,3 +116,9 @@ class Backend(ABC):
@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: ...
@@ -265,3 +265,16 @@ class DockerCliBackend(Backend):
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
@@ -17,6 +17,7 @@ class FakeBackend(Backend):
self.containers: dict = {}
self.built_images: list = []
self.removed: list = []
self.removed_images: list = []
self.pruned = 0
self._counter = 0
self.fail_build = False
@@ -97,3 +98,9 @@ class FakeBackend(Backend):
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
@@ -1,101 +0,0 @@
# retoor <retoor@molodetz.nl>
import asyncio
import shutil
import time
from pathlib import Path
from devplacepy import config, project_files
from devplacepy.services.base import ConfigField
from devplacepy.services.containers import store
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.jobs.base import JobService
LOG_CAP = 256 * 1024
def _cap(text: str) -> str:
return text[-LOG_CAP:]
class ContainerBuildService(JobService):
kind = "container_build"
default_enabled = False
title = "Container builds"
description = (
"Builds container images for project Dockerfiles asynchronously via the docker CLI, "
"streaming logs and recording every build in the permanent build history."
)
def __init__(self):
super().__init__(name="container_build", interval_seconds=2)
self.config_fields = self.config_fields + [
ConfigField("container_build_network", "Build network", type="str", default="host",
help="docker build --network value so the builder can reach the internet "
"(e.g. 'host' for PyPI access). Empty uses the docker default.",
group="Builds"),
]
async def process(self, job: dict) -> dict:
build_uid = job["payload"].get("build_uid")
build = store.get_build(build_uid)
if build is None:
raise RuntimeError("build row missing")
if build["status"] == store.BUILD_CANCELLED:
return {}
version = store.get_version(build["dockerfile_version_uid"])
dockerfile = store.get_dockerfile(build["dockerfile_uid"])
if version is None or dockerfile is None:
store.update_build(build_uid, {"status": store.BUILD_FAILED, "error": "version or dockerfile missing"})
return {}
store.update_build(build_uid, {"status": store.BUILD_BUILDING, "started_at": store.now()})
ctx = Path(config.CONTAINER_BUILD_CONTEXTS_DIR) / build_uid
await asyncio.to_thread(project_files.export_to_dir, build["project_uid"], "", str(ctx))
lines: list = []
async def on_log(line: str) -> None:
lines.append(line)
if len(lines) % 20 == 0:
store.update_build(build_uid, {"logs": _cap("\n".join(lines))})
tags = [build["image_tag"]]
if build.get("also_latest"):
tags.append(f"{dockerfile['name']}:latest")
network = str(self.get_config().get("container_build_network", "host") or "")
started = time.monotonic()
try:
result = await get_backend().build(
context_dir=str(ctx), dockerfile_text=version["content"], tags=tags, on_log=on_log, network=network)
except Exception as exc:
result = None
error = str(exc)
else:
error = result.error
finally:
await asyncio.to_thread(shutil.rmtree, str(ctx), ignore_errors=True)
duration_ms = int((time.monotonic() - started) * 1000)
logs = _cap("\n".join(lines))
latest = store.get_build(build_uid)
if latest and latest["status"] == store.BUILD_CANCELLED:
store.update_build(build_uid, {"logs": logs, "completed_at": store.now(), "duration_ms": duration_ms})
self.log(f"build {build['image_tag']} cancelled")
return {"item_count": len(lines)}
if result is not None and result.success:
store.update_build(build_uid, {
"status": store.BUILD_SUCCESS, "image_id": result.image_id, "logs": logs,
"completed_at": store.now(), "duration_ms": duration_ms,
})
self.log(f"build {build['image_tag']} succeeded ({duration_ms}ms)")
else:
store.update_build(build_uid, {
"status": store.BUILD_FAILED, "error": (error or "build failed")[:2000], "logs": logs,
"completed_at": store.now(), "duration_ms": duration_ms,
})
self.log(f"build {build['image_tag']} failed")
return {"item_count": len(lines)}
+245
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
+66
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 "$@"
+2 -7
View File
@@ -2,6 +2,7 @@
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
@@ -106,13 +107,7 @@ class ContainerService(BaseService):
store.update_instance(uid, {"status": store.ST_PAUSED})
async def _launch(self, backend, inst) -> None:
build = store.get_build(inst["build_uid"])
if build is None or build.get("status") != store.BUILD_SUCCESS:
store.update_instance(inst["uid"], {"status": store.ST_CRASHED})
store.record_event(inst, "launch_failed", "reconciler", "", {"reason": "no successful build"})
self.log(f"instance {inst['name']} has no successful build")
return
spec = api.run_spec_for(inst, build["image_tag"])
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
try:
cid = await backend.run(spec)
except Exception as exc:
+1 -117
View File
@@ -1,21 +1,11 @@
# retoor <retoor@molodetz.nl>
import hashlib
import json
from datetime import datetime, timezone
from devplacepy.database import db, get_table
from devplacepy.utils import generate_uid, make_combined_slug
DF_ACTIVE = "active"
DF_ARCHIVED = "archived"
BUILD_QUEUED = "queued"
BUILD_BUILDING = "building"
BUILD_SUCCESS = "success"
BUILD_FAILED = "failed"
BUILD_CANCELLED = "cancelled"
ST_CREATED = "created"
ST_STARTING = "starting"
ST_RUNNING = "running"
@@ -38,114 +28,10 @@ def now() -> str:
return datetime.now(timezone.utc).isoformat()
def content_hash(text: str) -> str:
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()
def _exists(table: str) -> bool:
return table in db.tables
# ---------------- dockerfiles ----------------
def create_dockerfile(project_uid, user, name, description, tags) -> dict:
uid = generate_uid()
row = {
"uid": uid, "project_uid": project_uid, "user_uid": user["uid"],
"name": name, "slug": make_combined_slug(name, uid),
"description": description or "", "tags": tags or "", "author": user.get("username", ""),
"current_version": 0, "latest_build_number": 0,
"content_hash": "", "status": DF_ACTIVE,
"created_at": now(), "updated_at": now(),
}
get_table("dockerfiles").insert(row)
return get_dockerfile(uid)
def get_dockerfile(uid_or_slug: str):
table = get_table("dockerfiles")
if not _exists("dockerfiles"):
return None
return table.find_one(uid=uid_or_slug) or table.find_one(slug=uid_or_slug)
def list_dockerfiles(project_uid: str) -> list:
if not _exists("dockerfiles"):
return []
return sorted(get_table("dockerfiles").find(project_uid=project_uid),
key=lambda r: r.get("created_at", ""), reverse=True)
def update_dockerfile(uid: str, changes: dict) -> None:
get_table("dockerfiles").update({"uid": uid, "updated_at": now(), **changes}, ["uid"])
# ---------------- versions (immutable) ----------------
def create_version(dockerfile: dict, content: str, user: dict) -> dict:
version = int(dockerfile.get("current_version") or 0) + 1
uid = generate_uid()
get_table("dockerfile_versions").insert({
"uid": uid, "dockerfile_uid": dockerfile["uid"], "project_uid": dockerfile["project_uid"],
"version": version, "content": content, "content_hash": content_hash(content),
"created_at": now(), "created_by": user["uid"],
})
update_dockerfile(dockerfile["uid"], {"current_version": version, "content_hash": content_hash(content)})
return get_table("dockerfile_versions").find_one(uid=uid)
def get_version(uid: str):
if not _exists("dockerfile_versions"):
return None
return get_table("dockerfile_versions").find_one(uid=uid)
def current_version(dockerfile: dict):
if not _exists("dockerfile_versions"):
return None
return get_table("dockerfile_versions").find_one(
dockerfile_uid=dockerfile["uid"], version=int(dockerfile.get("current_version") or 0))
def list_versions(dockerfile_uid: str) -> list:
if not _exists("dockerfile_versions"):
return []
return sorted(get_table("dockerfile_versions").find(dockerfile_uid=dockerfile_uid),
key=lambda r: r.get("version", 0), reverse=True)
# ---------------- builds ----------------
def create_build(dockerfile: dict, version: dict, build_number: int, image_tag: str, also_latest: bool) -> dict:
uid = generate_uid()
get_table("builds").insert({
"uid": uid, "dockerfile_uid": dockerfile["uid"], "dockerfile_version_uid": version["uid"],
"project_uid": dockerfile["project_uid"], "build_number": build_number,
"image_tag": image_tag, "also_latest": 1 if also_latest else 0,
"status": BUILD_QUEUED, "job_uid": "", "image_id": "", "logs": "", "error": "",
"duration_ms": 0, "queued_at": now(), "started_at": "", "completed_at": "", "created_at": now(),
})
return get_build(uid)
def get_build(uid: str):
if not _exists("builds"):
return None
return get_table("builds").find_one(uid=uid)
def update_build(uid: str, changes: dict) -> None:
get_table("builds").update({"uid": uid, **changes}, ["uid"])
def list_builds(dockerfile_uid: str, limit: int = 50) -> list:
if not _exists("builds"):
return []
rows = sorted(get_table("builds").find(dockerfile_uid=dockerfile_uid),
key=lambda r: r.get("build_number", 0), reverse=True)
return rows[:limit]
# ---------------- instances ----------------
def create_instance(row: dict) -> dict:
@@ -169,14 +55,12 @@ def get_instance(uid: str):
return table.find_one(uid=uid) or table.find_one(slug=uid)
def list_instances(project_uid: str = None, dockerfile_uid: str = None) -> list:
def list_instances(project_uid: str = None) -> list:
if not _exists("instances"):
return []
filters = {}
if project_uid:
filters["project_uid"] = project_uid
if dockerfile_uid:
filters["dockerfile_uid"] = dockerfile_uid
return sorted(get_table("instances").find(**filters),
key=lambda r: r.get("created_at", ""), reverse=True)
@@ -1,50 +0,0 @@
# retoor <retoor@molodetz.nl>
DEFAULT_DOCKERFILE = """# syntax=docker/dockerfile:1
FROM python:3.13-slim-bookworm
ENV PYTHONUNBUFFERED=1 \\
PYTHONDONTWRITEBYTECODE=1
RUN useradd --create-home --shell /bin/bash app
WORKDIR /app
COPY . /app
RUN chown -R app:app /app
USER app
EXPOSE 8000
CMD ["python3", "-m", "http.server", "8000", "--directory", "/app"]
"""
PLAYWRIGHT_DOCKERFILE = """# syntax=docker/dockerfile:1
FROM python:3.13-slim-bookworm AS base
ENV PYTHONUNBUFFERED=1 \\
PYTHONDONTWRITEBYTECODE=1 \\
PIP_NO_CACHE_DIR=1 \\
PIP_DISABLE_PIP_VERSION_CHECK=1 \\
DEBIAN_FRONTEND=noninteractive \\
PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
RUN apt-get update && apt-get install -y --no-install-recommends \\
git curl ca-certificates build-essential libpq-dev \\
&& rm -rf /var/lib/apt/lists/*
FROM base AS deps
RUN pip install \\
numpy pandas requests \\
fastapi "uvicorn[standard]" pydantic \\
sqlalchemy psycopg2-binary redis \\
httpx aiofiles python-dotenv \\
playwright \\
&& playwright install --with-deps chromium
FROM deps AS runtime
RUN useradd --create-home --shell /bin/bash app
WORKDIR /app
COPY . /app
RUN chown -R app:app /app /opt/playwright
USER app
CMD ["sleep", "infinity"]
"""
+31 -1
View File
@@ -212,11 +212,14 @@ ACTIONS: tuple[Action, ...] = (
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. Only the project owner may change this."
"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(
@@ -419,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",
@@ -110,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)."),
),
),
)
@@ -10,47 +10,6 @@ def arg(name: str, description: str, required: bool = False, kind: str = "string
SLUG = arg("project_slug", "Project slug or uid that owns the container resources.", required=True)
CONTAINER_ACTIONS: tuple[Action, ...] = (
Action(
name="container_list_dockerfiles",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
summary="List the Dockerfiles, builds, and instances of a project",
params=(SLUG,),
),
Action(
name="container_create_dockerfile",
method="LOCAL", path="", handler="container", requires_admin=True,
summary="Create a Dockerfile in a project and queue its first build",
description="With no content a lean python-slim default builds in seconds and serves /app on port 8000 (long-lived, ingress-ready). Pass full content for anything else; for browser automation start from python-slim and add 'pip install playwright && playwright install --with-deps chromium'.",
params=(
SLUG,
arg("name", "Image name, lowercase letters/digits/.-_ (max 63 chars).", required=True),
arg("description", "Optional description."),
arg("tags", "Optional comma separated tags."),
arg("content", "Optional full Dockerfile content."),
),
),
Action(
name="container_update_dockerfile",
method="LOCAL", path="", handler="container", requires_admin=True,
summary="Replace a Dockerfile's content, creating a new immutable version and queueing a build",
params=(
SLUG,
arg("dockerfile", "Dockerfile name, slug, or uid.", required=True),
arg("content", "New Dockerfile content.", required=True),
),
),
Action(
name="container_build",
method="LOCAL", path="", handler="container", requires_admin=True,
summary="Rebuild the current version of a Dockerfile",
params=(SLUG, arg("dockerfile", "Dockerfile name, slug, or uid.", required=True)),
),
Action(
name="container_build_status",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
summary="Get a build's status and recent log tail",
params=(SLUG, arg("build_uid", "Build uid.", required=True)),
),
Action(
name="container_list_instances",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
@@ -60,10 +19,9 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
Action(
name="container_create_instance",
method="LOCAL", path="", handler="container", requires_admin=True,
summary="Create and start a container instance from a Dockerfile's latest successful build",
summary="Create and start a container instance (runs the shared ppy image with the project files mounted at /app)",
params=(
SLUG,
arg("dockerfile", "Dockerfile name, slug, or uid.", required=True),
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."),
@@ -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"),
),
),
)
@@ -30,7 +30,7 @@ from .spec import Action, Catalog
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
CONFIRM_REQUIRED = {"project_set_readonly"}
CONFIRM_REQUIRED = {"project_set_readonly", "project_set_private", "customize_set_css", "customize_set_js", "customize_reset"}
logger = logging.getLogger("devii.dispatch")
@@ -40,13 +40,36 @@ def _is_confirmed(arguments: dict[str, Any]) -> bool:
def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError | None:
if name in CONFIRM_REQUIRED and not _is_confirmed(arguments):
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(
"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 "
"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."
)
return None
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:
@@ -61,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
@@ -77,6 +103,9 @@ class Dispatcher:
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
@@ -98,6 +127,15 @@ class Dispatcher:
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))
@@ -177,6 +215,16 @@ class Dispatcher:
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(
+47 -14
View File
@@ -87,23 +87,27 @@ SYSTEM_PROMPT = (
"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. Marking a project "
"read-only is significant and largely locks it down, so you MUST obtain explicit user confirmation "
"before calling project_set_readonly with value=true, and only then pass confirm=true; never set a "
"project read-only on your own initiative.\n\n"
"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 Docker containers with the container_* "
"tools: author Dockerfiles (a high-quality default is provided), build versioned images "
"asynchronously, create and control instances "
"(container_instance_action: start/stop/restart/pause/resume/delete/sync), read logs, run one-shot "
"commands (container_exec), inspect stats, and schedule starts/stops. Builds and instances run on "
"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. "
"Builds are asynchronous and can take a while for heavy base images. "
"container_build_status reports a 'status' (queued, building, success, failed, cancelled) "
"and a 'terminal' flag: a status of queued or building is NORMAL in-progress, not an error - keep "
"polling patiently (wait between polls) and do NOT change the Dockerfile or start a new build until "
"'terminal' is true. Only treat status 'failed' (or a 'build_error' field) as a failure to fix. "
"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 "
@@ -116,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 "
@@ -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),
),
),
)
+60 -23
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)
@@ -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)
@@ -41,15 +41,6 @@ class ContainerController:
raise ToolInputError(f"project not found: {slug}")
return project
def _dockerfile(self, project: dict, ref: str) -> dict:
df = store.get_dockerfile(ref)
if df is None or df["project_uid"] != project["uid"]:
for candidate in store.list_dockerfiles(project["uid"]):
if candidate["name"] == ref:
return candidate
raise ToolInputError(f"dockerfile not found: {ref}")
return df
def _instance(self, project: dict, ref: str) -> dict:
inst = store.get_instance(ref)
if inst is None or inst["project_uid"] != project["uid"]:
@@ -59,19 +50,6 @@ class ContainerController:
raise ToolInputError(f"instance not found: {ref}")
return inst
def _build_view(self, build: dict) -> dict:
if not build:
return {}
terminal = build["status"] in (store.BUILD_SUCCESS, store.BUILD_FAILED, store.BUILD_CANCELLED)
view = {
"uid": build["uid"], "status": build["status"], "terminal": terminal,
"build_number": build.get("build_number"), "image_tag": build.get("image_tag"),
"image_id": build.get("image_id") or None, "duration_ms": build.get("duration_ms"),
}
if build.get("error"):
view["build_error"] = build["error"]
return view
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:
@@ -81,44 +59,6 @@ class ContainerController:
except ContainerError as exc:
return json.dumps({"error": "container_error", "message": str(exc)})
async def _list_dockerfiles(self, arguments) -> str:
project = self._project(arguments)
return json.dumps({
"dockerfiles": store.list_dockerfiles(project["uid"]),
"instances": store.list_instances(project["uid"]),
}, ensure_ascii=False, default=str)
async def _create_dockerfile(self, arguments) -> str:
project = self._project(arguments)
result = await api.create_dockerfile(
project, self._actor_user(), name=str(arguments.get("name", "")),
description=str(arguments.get("description", "")), tags=str(arguments.get("tags", "")),
content=arguments.get("content") or None)
return json.dumps({"dockerfile": result["dockerfile"], "build": self._build_view(result["build"])}, default=str)
async def _update_dockerfile(self, arguments) -> str:
project = self._project(arguments)
df = self._dockerfile(project, str(arguments.get("dockerfile", "")))
result = await api.save_version(df, self._actor_user(), str(arguments.get("content", "")))
return json.dumps({"changed": result["changed"], "build": self._build_view(result["build"])}, default=str)
async def _build(self, arguments) -> str:
project = self._project(arguments)
df = self._dockerfile(project, str(arguments.get("dockerfile", "")))
version = store.current_version(df)
if version is None:
raise ToolInputError("dockerfile has no version to build")
build = await api.enqueue_build(df, version, owner=("user", self._actor_user()["uid"]))
return json.dumps({"build": self._build_view(build)}, default=str)
async def _build_status(self, arguments) -> str:
build = store.get_build(str(arguments.get("build_uid", "")))
if build is None:
raise ToolInputError("build not found")
view = self._build_view(build)
view["logs_tail"] = (build.get("logs") or "")[-4000:]
return json.dumps(view, default=str)
async def _list_instances(self, arguments) -> str:
project = self._project(arguments)
instances = store.list_instances(project["uid"])
@@ -128,13 +68,9 @@ class ContainerController:
async def _create_instance(self, arguments) -> str:
project = self._project(arguments)
df = self._dockerfile(project, str(arguments.get("dockerfile", "")))
successes = [b for b in store.list_builds(df["uid"]) if b["status"] == store.BUILD_SUCCESS]
if not successes:
raise ToolInputError("dockerfile has no successful build yet")
autostart = str(arguments.get("autostart", "true")).lower() not in ("false", "0", "no", "off")
inst = await api.create_instance(
project, df, successes[0], name=str(arguments.get("name", "")),
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")),
@@ -199,7 +135,6 @@ class ContainerController:
"ingress_url": self._ingress_url(inst),
"runtime": api.instance_runtime(inst),
"stats": api.instance_stats(inst["uid"]),
"dockerfile_stats": api.dockerfile_stats(inst["dockerfile_uid"]),
}, default=str)
async def _schedule(self, arguments) -> str:
@@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.devii.customization.controller import CustomizationController
__all__ = ["CustomizationController"]
@@ -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,
)
+3 -1
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)
+4
View File
@@ -8,10 +8,12 @@ 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
@@ -27,4 +29,6 @@ CATALOG = Catalog(
+ CHUNK_ACTIONS
+ RSEARCH_ACTIONS
+ CONTAINER_ACTIONS
+ CUSTOMIZATION_ACTIONS
+ VIRTUAL_TOOL_ACTIONS
)
+14
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)
@@ -248,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
@@ -257,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)
@@ -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"]
@@ -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),),
),
)
@@ -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)
@@ -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)]
+91
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)