forked from retoor/devplacepy
feat: add project fork async job service with cli prune/clear commands and shared container image build target
This commit is contained in:
@@ -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)}
|
||||
@@ -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
|
||||
|
||||
Executable
+2669
File diff suppressed because it is too large
Load Diff
@@ -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,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,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"]
|
||||
"""
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user