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

This commit is contained in:
2026-06-09 14:06:02 +00:00
parent c565e3b55c
commit 05c6fa7f3b
90 changed files with 6889 additions and 1146 deletions
+31 -1
View File
@@ -212,11 +212,14 @@ ACTIONS: tuple[Action, ...] = (
summary="Mark a project private (owner-only) or public",
description=(
"Set value=true to hide the project from everyone except its owner (and administrators), "
"or value=false to make it public again. Only the project owner may change this."
"or value=false to make it public again. This is a significant change: you MUST ask the "
"user for explicit confirmation BEFORE calling it, and only pass confirm=true once they "
"have agreed. Only the project owner may change this."
),
params=(
path("project_slug", "Project slug or uid."),
body("value", "true to make the project private, false to make it public.", required=True),
body("confirm", "Must be true, set only after the user has explicitly confirmed.", required=True),
),
),
Action(
@@ -419,6 +422,33 @@ ACTIONS: tuple[Action, ...] = (
params=(path("uid", "Zip job uid returned by zip_project."),),
requires_auth=False,
),
Action(
name="fork_project",
method="POST",
path="/projects/{project_slug}/fork",
summary="Fork a project into a new project owned by the current user",
description=(
"Queues a background fork job and returns {uid, status_url}. Poll the status_url with "
"fork_status until status is 'done', then give the user the project_url of the new fork."
),
params=(
path("project_slug", "Slug or uid of the project to fork."),
body("title", "Title for the new forked project.", required=True),
),
requires_auth=True,
),
Action(
name="fork_status",
method="GET",
path="/forks/{uid}",
summary="Check a fork job and obtain the new project once finished",
description=(
"Returns the job status. When status is 'done', project_url points at the new forked "
"project; while 'pending' or 'running', poll again shortly."
),
params=(path("uid", "Fork job uid returned by fork_project."),),
requires_auth=False,
),
Action(
name="search_users",
method="GET",
@@ -110,4 +110,22 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
handler="client",
requires_auth=False,
),
Action(
name="open_terminal",
method="LOCAL",
path="",
summary="Open a floating interactive terminal window attached to a running container instance",
description=(
CLIENT + " Opens a new xterm.js window in the user's browser connected to the container's "
"interactive shell (admin only). Resolve the instance first with container_list_instances, "
"then pass the project slug and the instance slug or uid."
),
handler="client",
requires_admin=True,
params=(
arg("project_slug", "Project slug or uid that owns the container.", required=True),
arg("instance", "Container instance slug or uid.", required=True),
arg("label", "Optional window title (defaults to the instance name)."),
),
),
)
@@ -10,47 +10,6 @@ def arg(name: str, description: str, required: bool = False, kind: str = "string
SLUG = arg("project_slug", "Project slug or uid that owns the container resources.", required=True)
CONTAINER_ACTIONS: tuple[Action, ...] = (
Action(
name="container_list_dockerfiles",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
summary="List the Dockerfiles, builds, and instances of a project",
params=(SLUG,),
),
Action(
name="container_create_dockerfile",
method="LOCAL", path="", handler="container", requires_admin=True,
summary="Create a Dockerfile in a project and queue its first build",
description="With no content a lean python-slim default builds in seconds and serves /app on port 8000 (long-lived, ingress-ready). Pass full content for anything else; for browser automation start from python-slim and add 'pip install playwright && playwright install --with-deps chromium'.",
params=(
SLUG,
arg("name", "Image name, lowercase letters/digits/.-_ (max 63 chars).", required=True),
arg("description", "Optional description."),
arg("tags", "Optional comma separated tags."),
arg("content", "Optional full Dockerfile content."),
),
),
Action(
name="container_update_dockerfile",
method="LOCAL", path="", handler="container", requires_admin=True,
summary="Replace a Dockerfile's content, creating a new immutable version and queueing a build",
params=(
SLUG,
arg("dockerfile", "Dockerfile name, slug, or uid.", required=True),
arg("content", "New Dockerfile content.", required=True),
),
),
Action(
name="container_build",
method="LOCAL", path="", handler="container", requires_admin=True,
summary="Rebuild the current version of a Dockerfile",
params=(SLUG, arg("dockerfile", "Dockerfile name, slug, or uid.", required=True)),
),
Action(
name="container_build_status",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
summary="Get a build's status and recent log tail",
params=(SLUG, arg("build_uid", "Build uid.", required=True)),
),
Action(
name="container_list_instances",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
@@ -60,10 +19,9 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
Action(
name="container_create_instance",
method="LOCAL", path="", handler="container", requires_admin=True,
summary="Create and start a container instance from a Dockerfile's latest successful build",
summary="Create and start a container instance (runs the shared ppy image with the project files mounted at /app)",
params=(
SLUG,
arg("dockerfile", "Dockerfile name, slug, or uid.", required=True),
arg("name", "Instance name.", required=True),
arg("boot_command", "Optional command to run on boot, e.g. 'python app.py'."),
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
@@ -0,0 +1,100 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
SCOPE = (
"Either 'global' (applies to every page of the site for this user) or a page-type string "
"as returned by get_page_context.pageType (e.g. '/posts/{slug}'), which applies to every "
"page of that type."
)
CUSTOMIZATION_ACTIONS: tuple[Action, ...] = (
Action(
name="customize_list",
method="LOCAL",
path="",
summary="List the user's saved CSS/JS customizations (scopes, languages, sizes)",
description=(
"Returns every stored customization for the current user (or guest session), with its "
"scope, language, enabled flag and a short preview of the code."
),
handler="customization",
requires_auth=False,
read_only=True,
),
Action(
name="customize_get",
method="LOCAL",
path="",
summary="Read the full saved CSS or JS code for one scope",
description="Use this before editing so you build on existing customization rather than overwriting it.",
handler="customization",
requires_auth=False,
read_only=True,
params=(
arg("scope", SCOPE, required=True),
arg("lang", "Which code to read: 'css' or 'js'.", required=True),
),
),
Action(
name="customize_set_css",
method="LOCAL",
path="",
summary="Permanently save custom CSS for a page type or the whole site",
description=(
"Persists custom CSS for the current user. Before calling, ask the user whether it should "
"apply to the current page TYPE or to the whole site (global). The CSS is the final, full "
"stylesheet for that scope and replaces any previously saved CSS for it."
),
handler="customization",
requires_auth=False,
params=(
arg("scope", SCOPE, required=True),
arg("css", "The full CSS source to save for this scope.", required=True),
arg("confirm", "Must be true after the user has confirmed the scope.", required=True, kind="boolean"),
),
),
Action(
name="customize_set_js",
method="LOCAL",
path="",
summary="Permanently save custom JavaScript for a page type or the whole site",
description=(
"Persists custom JavaScript for the current user. Before calling, ask the user whether it "
"should apply to the current page TYPE or to the whole site (global). The code runs after "
"the application boots, with access to window, document and the global 'app'. It is the "
"final, full script for that scope and replaces any previously saved JS for it."
),
handler="customization",
requires_auth=False,
params=(
arg("scope", SCOPE, required=True),
arg("js", "The full JavaScript source to save for this scope.", required=True),
arg("confirm", "Must be true after the user has confirmed the scope.", required=True, kind="boolean"),
),
),
Action(
name="customize_reset",
method="LOCAL",
path="",
summary="Delete saved customizations, restoring the default look and behaviour",
description=(
"Removes saved customizations. Omit lang to remove both CSS and JS for the scope. Pass "
"scope='all' to remove every customization for this user. This cannot be undone."
),
handler="customization",
requires_auth=False,
params=(
arg("scope", SCOPE + " Use 'all' to remove every customization.", required=True),
arg("lang", "Optional: limit the reset to 'css' or 'js'. Omit to remove both."),
arg("confirm", "Must be true after the user has confirmed the deletion.", required=True, kind="boolean"),
),
),
)
@@ -30,7 +30,7 @@ from .spec import Action, Catalog
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
CONFIRM_REQUIRED = {"project_set_readonly"}
CONFIRM_REQUIRED = {"project_set_readonly", "project_set_private", "customize_set_css", "customize_set_js", "customize_reset"}
logger = logging.getLogger("devii.dispatch")
@@ -40,13 +40,36 @@ def _is_confirmed(arguments: dict[str, Any]) -> bool:
def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError | None:
if name in CONFIRM_REQUIRED and not _is_confirmed(arguments):
if name not in CONFIRM_REQUIRED or _is_confirmed(arguments):
return None
if name in ("customize_set_css", "customize_set_js"):
scope = str(arguments.get("scope", "")).strip() or "(unspecified)"
return ToolInputError(
"Setting a project read-only makes every file immutable and blocks all further "
"edits. Ask the user to confirm this explicitly first, then call again with "
"Before saving, confirm the scope with the user: should this apply to THIS page type "
f"('{scope}') or to the whole site ('global')? Ask them, then call again with the chosen "
"scope and confirm=true."
)
if name == "customize_reset":
return ToolInputError(
"Deleting customizations cannot be undone. Ask the user to confirm, then call again with "
"confirm=true."
)
return None
if name == "project_set_private":
making_private = str(arguments.get("value", "")).strip().lower() in ("true", "1", "yes", "on")
change = (
"hide the project from everyone except its owner and administrators"
if making_private
else "make the project public so everyone can see it and all its files"
)
return ToolInputError(
f"Changing a project's visibility will {change}. Ask the user to confirm this explicitly "
"first, then call again with confirm=true."
)
return ToolInputError(
"Setting a project read-only makes every file immutable and blocks all further "
"edits. Ask the user to confirm this explicitly first, then call again with "
"confirm=true."
)
class Dispatcher:
@@ -61,6 +84,9 @@ class Dispatcher:
browser: Any = None,
is_admin: bool = False,
quota_provider: Any = None,
owner_kind: str = "guest",
owner_id: str = "",
virtual_tools: Any = None,
) -> None:
self._actions = catalog.by_name()
self._client = client
@@ -77,6 +103,9 @@ class Dispatcher:
self._rsearch = RsearchController(settings)
from ..container import ContainerController
self._container = ContainerController(client)
from ..customization import CustomizationController
self._customization = CustomizationController(owner_kind, owner_id)
self._virtual_tools = virtual_tools
self._read_files: set[tuple[str, str]] = set()
@staticmethod
@@ -98,6 +127,15 @@ class Dispatcher:
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
action = self._actions.get(name)
if action is None:
if self._virtual_tools is not None and self._virtual_tools.has(name):
logger.info("Dispatch virtual tool %s args=%s", name, list(arguments))
try:
return await self._virtual_tools.run(name, arguments)
except DeviiError as exc:
return error_result(exc)
except Exception as exc: # noqa: BLE001 - surfaced to the model as data
logger.exception("Virtual tool %s crashed", name)
return unexpected_result(exc)
return error_result(ToolInputError(f"Unknown tool: {name}"))
logger.info("Dispatch %s args=%s", name, list(arguments))
@@ -177,6 +215,16 @@ class Dispatcher:
if action.handler == "container":
return await self._container.dispatch(action.name, arguments)
if action.handler == "customization":
return await self._customization.dispatch(action.name, arguments)
if action.handler == "virtual_tool":
if self._virtual_tools is None:
return error_result(
ToolInputError("User-defined tools are not available in this context.")
)
return await self._virtual_tools.dispatch(action.name, arguments)
if action.handler == "avatar":
if self._avatar is None:
return error_result(