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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user