forked from retoor/devplacepy
feat: add container manager CLI commands and docker-compose overlay for admin container lifecycle
This commit is contained in:
@@ -205,6 +205,38 @@ ACTIONS: tuple[Action, ...] = (
|
||||
summary="Delete a project",
|
||||
params=(path("project_slug", "Exact project slug copied from a /projects/... link in a listing response; do not build it from the title."),),
|
||||
),
|
||||
Action(
|
||||
name="project_set_private",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/private",
|
||||
summary="Mark a project private (owner-only) or public",
|
||||
description=(
|
||||
"Set value=true to hide the project from everyone except its owner (and administrators), "
|
||||
"or value=false to make it public again. 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),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="project_set_readonly",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/readonly",
|
||||
summary="Mark a project read-only (immutable files) or writable again",
|
||||
description=(
|
||||
"Set value=true to make every file in the project immutable - no writes, edits, line "
|
||||
"edits, moves, deletes, or uploads succeed afterwards, from anyone including you - or "
|
||||
"value=false to allow changes again. This is a significant change: you MUST ask the user "
|
||||
"for explicit confirmation BEFORE calling it, and only pass confirm=true once they have "
|
||||
"agreed. Only the project owner may change this."
|
||||
),
|
||||
params=(
|
||||
path("project_slug", "Project slug or uid."),
|
||||
body("value", "true to make the project read-only, false to make it writable.", required=True),
|
||||
body("confirm", "Must be true, set only after the user has explicitly confirmed.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="project_list_files",
|
||||
method="GET",
|
||||
@@ -230,14 +262,97 @@ ACTIONS: tuple[Action, ...] = (
|
||||
name="project_write_file",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/write",
|
||||
summary="Create or overwrite a text file in a project (parent directories are created automatically)",
|
||||
description="The primary tool for building a project: write any text file by path. Missing parent directories are created recursively.",
|
||||
summary="Create or overwrite a whole text file in a project (parent directories are created automatically)",
|
||||
description=(
|
||||
"Replaces the ENTIRE file with the content you send. Creating a new file needs no prior "
|
||||
"read, but overwriting an existing one requires reading it first (project_read_file). "
|
||||
"For an existing file prefer the surgical line tools (project_replace_lines, "
|
||||
"project_insert_lines, project_delete_lines, project_append_file) instead of rewriting it, "
|
||||
"and never batch several writes in one turn. Use this only to create a new file or fully "
|
||||
"rewrite a small one. Missing parent directories are created recursively."
|
||||
),
|
||||
params=(
|
||||
path("project_slug", "Project slug or uid."),
|
||||
body("path", "Relative file path, e.g. src/app/main.py.", required=True),
|
||||
body("content", "Full file content.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="project_read_lines",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/files/lines",
|
||||
summary="Read a 1-indexed line range of a text file in a project",
|
||||
description=(
|
||||
"Returns {path, start, end, total_lines, lines, content} for the requested range. Use it "
|
||||
"to inspect part of a large file before editing, and to learn total_lines so you can target "
|
||||
"the right range with the line-edit tools."
|
||||
),
|
||||
params=(
|
||||
path("project_slug", "Project slug or uid."),
|
||||
query("path", "Relative file path inside the project.", required=True),
|
||||
query("start", "First line to read (1-indexed, default 1)."),
|
||||
query("end", "Last line to read (inclusive); omit for end of file."),
|
||||
),
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="project_replace_lines",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/replace-lines",
|
||||
summary="Replace an inclusive 1-indexed line range of a text file with new content",
|
||||
description=(
|
||||
"Surgically rewrites lines start..end (inclusive) with content (which may be any number of "
|
||||
"lines, or empty to delete the range). The preferred way to edit an existing file: it leaves "
|
||||
"the rest of the file untouched and never overflows the model output limit."
|
||||
),
|
||||
params=(
|
||||
path("project_slug", "Project slug or uid."),
|
||||
body("path", "Relative file path.", required=True),
|
||||
body("start", "First line to replace (1-indexed).", required=True),
|
||||
body("end", "Last line to replace (inclusive).", required=True),
|
||||
body("content", "Replacement text for those lines (empty deletes them)."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="project_insert_lines",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/insert-lines",
|
||||
summary="Insert content before a 1-indexed line of a text file",
|
||||
description=(
|
||||
"Inserts content before line 'at' without touching existing lines. Use at=1 to prepend and "
|
||||
"at=total_lines+1 to insert at the end."
|
||||
),
|
||||
params=(
|
||||
path("project_slug", "Project slug or uid."),
|
||||
body("path", "Relative file path.", required=True),
|
||||
body("at", "Insert before this 1-indexed line (1 prepends, total+1 appends).", required=True),
|
||||
body("content", "Text to insert.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="project_delete_lines",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/delete-lines",
|
||||
summary="Delete an inclusive 1-indexed line range from a text file",
|
||||
params=(
|
||||
path("project_slug", "Project slug or uid."),
|
||||
body("path", "Relative file path.", required=True),
|
||||
body("start", "First line to delete (1-indexed).", required=True),
|
||||
body("end", "Last line to delete (inclusive).", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="project_append_file",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/append",
|
||||
summary="Append content to the end of a text file in a project",
|
||||
description="Adds content as new lines at the end of the file. Use it to grow a large file across turns without resending the whole thing.",
|
||||
params=(
|
||||
path("project_slug", "Project slug or uid."),
|
||||
body("path", "Relative file path.", required=True),
|
||||
body("content", "Text to append.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="project_upload_file",
|
||||
method="POST",
|
||||
|
||||
@@ -17,6 +17,7 @@ CHUNK_ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
handler="chunks",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
Param(
|
||||
name="chunk_id",
|
||||
|
||||
@@ -23,6 +23,7 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
|
||||
description=CLIENT + " Use this to understand where the user is and what they are looking at before acting or guiding them.",
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="run_js",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
|
||||
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
|
||||
return Param(name=name, location="body", description=description, required=required, type=kind)
|
||||
|
||||
|
||||
SLUG = arg("project_slug", "Project slug or uid that owns the container resources.", required=True)
|
||||
|
||||
CONTAINER_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="container_list_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,
|
||||
summary="List a project's container instances and their status",
|
||||
params=(SLUG,),
|
||||
),
|
||||
Action(
|
||||
name="container_create_instance",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True,
|
||||
summary="Create and start a container instance from a Dockerfile's latest successful build",
|
||||
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."),
|
||||
arg("env", "Optional env vars as KEY=VALUE lines."),
|
||||
arg("ports", "Port maps per line or comma separated. Use a bare container port (e.g. '8899') to auto-assign a unique host port above 20000, or 'host:container' to pin one."),
|
||||
arg("cpu_limit", "Optional CPU limit, e.g. 1 or 1.5."),
|
||||
arg("mem_limit", "Optional memory limit, e.g. 512m or 1g."),
|
||||
arg("autostart", "Start immediately ('true' or 'false', default true)."),
|
||||
arg("ingress_slug", "Optional public ingress slug; the service is then reachable at /p/<slug>."),
|
||||
arg("ingress_port", "Container port to publish at /p/<slug> (must be one of the mapped ports).", kind="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_instance_action",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True,
|
||||
summary="Control an instance: start, stop, restart, pause, resume, delete, or sync",
|
||||
description="sync imports the container /app workspace back into the project files.",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg("action", "start, stop, restart, pause, resume, delete, or sync.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_logs",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
|
||||
summary="Read the recent logs of a running instance",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg("tail", "Number of log lines (default 200).", kind="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_exec",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True,
|
||||
summary="Run a one-shot command inside a running instance and return its output",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg("command", "Command to run, e.g. 'pip list'.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_stats",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
|
||||
summary="Get aggregated resource and runtime statistics for an instance",
|
||||
params=(SLUG, arg("instance", "Instance name, slug, or uid.", required=True)),
|
||||
),
|
||||
Action(
|
||||
name="container_schedule",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True,
|
||||
summary="Schedule a start or stop of an instance (cron, interval, or one-time)",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg("action", "start or stop.", required=True),
|
||||
arg("kind", "once, interval, or cron.", required=True),
|
||||
arg("cron", "Cron expression for kind=cron, e.g. '0 2 * * *'."),
|
||||
arg("run_at", "ISO time for kind=once, e.g. 2026-06-15T02:00:00."),
|
||||
arg("every_seconds", "Interval seconds for kind=interval.", kind="integer"),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -18,6 +18,7 @@ COST_ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
handler="cost",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="cost_stats",
|
||||
@@ -33,5 +34,6 @@ COST_ACTIONS: tuple[Action, ...] = (
|
||||
handler="cost",
|
||||
requires_auth=True,
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -30,9 +30,25 @@ from .spec import Action, Catalog
|
||||
|
||||
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
|
||||
|
||||
CONFIRM_REQUIRED = {"project_set_readonly"}
|
||||
|
||||
logger = logging.getLogger("devii.dispatch")
|
||||
|
||||
|
||||
def _is_confirmed(arguments: dict[str, Any]) -> bool:
|
||||
return str(arguments.get("confirm", "")).strip().lower() in ("true", "1", "yes", "on")
|
||||
|
||||
|
||||
def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError | None:
|
||||
if name in CONFIRM_REQUIRED and not _is_confirmed(arguments):
|
||||
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."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -59,6 +75,25 @@ class Dispatcher:
|
||||
self._cost = CostController(quota_provider=quota_provider)
|
||||
self._chunks = ChunkController(settings)
|
||||
self._rsearch = RsearchController(settings)
|
||||
from ..container import ContainerController
|
||||
self._container = ContainerController(client)
|
||||
self._read_files: set[tuple[str, str]] = set()
|
||||
|
||||
@staticmethod
|
||||
def _file_key(arguments: dict[str, Any]) -> tuple[str, str] | None:
|
||||
from devplacepy.project_files import normalize_path, ProjectFileError as _PFError
|
||||
raw = arguments.get("path")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
path = normalize_path(raw)
|
||||
except _PFError:
|
||||
return None
|
||||
return str(arguments.get("project_slug", "")), path
|
||||
|
||||
def is_read_only(self, name: str) -> bool:
|
||||
action = self._actions.get(name)
|
||||
return bool(action and action.is_read_only)
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
action = self._actions.get(name)
|
||||
@@ -77,6 +112,9 @@ class Dispatcher:
|
||||
"This information is restricted to administrators.",
|
||||
tool=name,
|
||||
)
|
||||
guard = confirmation_error(name, arguments)
|
||||
if guard is not None:
|
||||
raise guard
|
||||
resource_key = self._resource_key(action, arguments)
|
||||
if resource_key:
|
||||
cached = serve_resource(resource_key, self._settings.max_response_chars)
|
||||
@@ -136,6 +174,9 @@ class Dispatcher:
|
||||
if action.handler == "rsearch":
|
||||
return await self._rsearch.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "container":
|
||||
return await self._container.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "avatar":
|
||||
if self._avatar is None:
|
||||
return error_result(
|
||||
@@ -204,7 +245,30 @@ class Dispatcher:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _file_exists(self, arguments: dict[str, Any]) -> bool:
|
||||
slug = str(arguments.get("project_slug", "")).strip()
|
||||
path = str(arguments.get("path", "")).strip()
|
||||
if not slug or not path:
|
||||
return False
|
||||
response = await self._client.call(
|
||||
method="GET",
|
||||
path=f"/projects/{quote(slug, safe='')}/files/raw",
|
||||
params={"path": path},
|
||||
headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
return response.status_code == 200
|
||||
|
||||
async def _run_http(self, action: Action, arguments: dict[str, Any]) -> str:
|
||||
if action.name == "project_write_file":
|
||||
key = self._file_key(arguments)
|
||||
if key is not None and key not in self._read_files and await self._file_exists(arguments):
|
||||
raise ToolInputError(
|
||||
f"Read '{key[1]}' before overwriting it. It already exists; call "
|
||||
"project_read_file first. For an existing file prefer the line tools "
|
||||
"(project_replace_lines, project_insert_lines, project_delete_lines, "
|
||||
"project_append_file); project_write_file replaces the entire file."
|
||||
)
|
||||
|
||||
url_path, params, data, file_field = self._build_request(action, arguments)
|
||||
|
||||
headers = {"X-Requested-With": "fetch"} if action.ajax else None
|
||||
@@ -216,6 +280,10 @@ class Dispatcher:
|
||||
file_field=file_field,
|
||||
headers=headers,
|
||||
)
|
||||
if action.name in ("project_read_file", "project_read_lines", "project_write_file"):
|
||||
key = self._file_key(arguments)
|
||||
if key is not None:
|
||||
self._read_files.add(key)
|
||||
if action.method in MUTATING_METHODS:
|
||||
record_mutation(action.name)
|
||||
store = get_store()
|
||||
|
||||
@@ -17,6 +17,7 @@ DOCS_ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
handler="docs",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
Param(
|
||||
name="query",
|
||||
|
||||
@@ -18,6 +18,7 @@ FETCH_ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
handler="fetch",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
Param(
|
||||
name="url",
|
||||
|
||||
@@ -23,6 +23,7 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
handler="rsearch",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
Param(name="query", location="body", description="The web search query.", required=True),
|
||||
Param(name="count", location="body", description="Number of results (1-100, default 10).", type="integer"),
|
||||
@@ -43,6 +44,7 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
handler="rsearch",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
Param(name="query", location="body", description="The question or prompt to answer.", required=True),
|
||||
Param(name="content", location="body", description="Let the AI read full page content while answering.", type="boolean"),
|
||||
@@ -61,6 +63,7 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
handler="rsearch",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
Param(name="prompt", location="body", description="The prompt to send.", required=True),
|
||||
Param(name="json", location="body", description="Force a valid-JSON-only response.", type="boolean"),
|
||||
@@ -78,6 +81,7 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
handler="rsearch",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
Param(name="url", location="body", description="Public URL of the image to describe.", required=True),
|
||||
),
|
||||
|
||||
@@ -29,10 +29,15 @@ class Action:
|
||||
requires_admin: bool = False
|
||||
handler: Literal[
|
||||
"http", "login", "logout", "status", "task", "agentic", "avatar", "client", "fetch",
|
||||
"docs", "cost", "chunks", "rsearch"
|
||||
"docs", "cost", "chunks", "rsearch", "container"
|
||||
] = "http"
|
||||
freeform_body: bool = False
|
||||
ajax: bool = False
|
||||
read_only: bool = False
|
||||
|
||||
@property
|
||||
def is_read_only(self) -> bool:
|
||||
return self.read_only or self.method.upper() == "GET"
|
||||
|
||||
def tool_schema(self) -> dict[str, Any]:
|
||||
properties: dict[str, Any] = {}
|
||||
|
||||
Reference in New Issue
Block a user