feat: add container manager CLI commands and docker-compose overlay for admin container lifecycle

This commit is contained in:
2026-06-09 04:41:27 +00:00
parent 317126efc2
commit c565e3b55c
83 changed files with 6078 additions and 84 deletions
+117 -2
View File
@@ -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),
),
+6 -1
View File
@@ -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] = {}
+38
View File
@@ -70,6 +70,44 @@ SYSTEM_PROMPT = (
"trial-and-error, and never tell the user that a page or capability does not exist without "
"confirming against the docs. If a guess returns a 404, that means you guessed - search the docs "
"instead of concluding the feature is missing.\n\n"
"WRITING AND EDITING FILES\n"
"Creating a new file needs no prior read, but overwriting an existing one does: call "
"project_read_file first. project_write_file replaces the ENTIRE file and is rejected when the "
"path already exists and you have not read it this session, so use it only to create a new file "
"or fully rewrite a small one. To change an existing "
"file, prefer the surgical line tools - project_read_lines to inspect a range and learn total_lines, "
"then project_replace_lines, project_insert_lines, project_delete_lines, or project_append_file to "
"edit just the affected lines. These leave the rest of the file untouched and let you build or modify "
"very large files across turns without resending the whole thing. A single model completion has a "
"maximum output length: emit only ONE file write per turn and never batch several writes into one "
"response, or their combined content overflows the completion and the last file is truncated. If a "
"tool result has error 'tool_input_truncated', your output was cut off - resend that single write or "
"line edit on its own.\n\n"
"PROJECT PRIVACY AND READ-ONLY\n"
"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"
"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 "
"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. "
"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 "
"is a relative /p/<slug>, the admin has not set the public Site URL in admin settings yet.\n\n"
"REMOTE WEB TOOLS (rsearch)\n"
"The rsearch_* tools (rsearch, rsearch_answer, rsearch_chat, rsearch_describe_image) reach an "
"EXTERNAL public web/AI service, not this platform. They are not platform-specific, so platform "
+25 -5
View File
@@ -37,6 +37,7 @@ ITERATION_LIMIT_MESSAGE = "[stopped] Maximum iterations reached without a final
LABEL_KEYS = (
"path",
"post_slug",
"project_slug",
"gist_slug",
@@ -75,6 +76,12 @@ def call_label(call: dict[str, Any]) -> str:
target_type = arguments.get("target_type")
target = clean(arguments["target_uid"])
return f"{target_type}/{target}" if target_type else target
if arguments.get("command"):
return clean(arguments["command"])
if arguments.get("instance"):
instance = clean(arguments["instance"])
action = arguments.get("action")
return f"{clean(action)} {instance}" if action else instance
for key in LABEL_KEYS:
value = arguments.get(key)
if value not in (None, ""):
@@ -122,10 +129,18 @@ async def _run_tool_call(dispatcher: Any, call: dict[str, Any]) -> str:
raw_arguments = function.get("arguments") or "{}"
try:
arguments = json.loads(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments
if not isinstance(arguments, dict):
raise ValueError("arguments must be an object")
except ValueError as exc:
return json.dumps({"error": "tool_input_error", "message": f"Invalid arguments: {exc}"})
except json.JSONDecodeError as exc:
return json.dumps({
"error": "tool_input_truncated",
"message": (
f"The arguments for {name or 'this tool'} were cut off and could not be parsed "
f"({exc.msg} at position {exc.pos}); the model output hit its length limit. "
"Emit only one write tool call per turn (do not batch several file writes into a "
"single response) and resend this one call on its own."
),
})
if not isinstance(arguments, dict):
return json.dumps({"error": "tool_input_error", "message": "Invalid arguments: arguments must be an object"})
return await dispatcher.dispatch(name, arguments)
@@ -169,7 +184,12 @@ async def react_loop(
tool_calls = message.get("tool_calls") or []
if tool_calls:
if plan_required and state.plan is None and tool_calls[0]["function"]["name"] != "plan":
needs_plan = plan_required and state.plan is None and any(
call["function"]["name"] != "plan"
and not dispatcher.is_read_only(call["function"]["name"])
for call in tool_calls
)
if needs_plan:
trace("plan-gate")
for call in tool_calls:
messages.append(
@@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.devii.container.controller import ContainerController
__all__ = ["ContainerController"]
@@ -0,0 +1,217 @@
# retoor <retoor@molodetz.nl>
import json
import logging
from typing import Any
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.services.containers import api, store
from devplacepy.services.containers.api import ContainerError
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.devii.errors import ToolInputError
from devplacepy.services.devii.tasks.schedule import Schedule, from_iso
logger = logging.getLogger("devii.container")
class ContainerController:
def __init__(self, client: Any = None) -> None:
self._client = client
def _ingress_url(self, instance: dict):
slug = instance.get("ingress_slug")
if not slug:
return None
from devplacepy.seo import public_base_url
base = public_base_url()
return f"{base}/p/{slug}" if base else f"/p/{slug}"
def _actor_user(self) -> dict:
username = getattr(self._client, "username", None)
if username:
user = get_table("users").find_one(username=username)
if user:
return user
return {"uid": "admin", "username": username or "admin"}
def _project(self, arguments: dict) -> dict:
slug = str(arguments.get("project_slug", "")).strip()
project = resolve_by_slug(get_table("projects"), slug) if slug else None
if not project:
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"]:
for candidate in store.list_instances(project["uid"]):
if candidate["name"] == ref:
return candidate
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:
raise ToolInputError(f"unknown container tool: {name}")
try:
return await handler(arguments)
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"])
for inst in instances:
inst["ingress_url"] = self._ingress_url(inst)
return json.dumps({"instances": instances}, default=str)
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", "")),
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")),
autostart=autostart, ingress_slug=str(arguments.get("ingress_slug", "")),
ingress_port=arguments.get("ingress_port"), actor=("user", self._actor_user()["uid"]))
return json.dumps({"instance": inst, "ingress_url": self._ingress_url(inst)}, default=str)
async def _instance_action(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
action = str(arguments.get("action", "")).lower()
actor = ("user", self._actor_user()["uid"])
if action == "delete":
api.mark_for_removal(inst, actor=actor)
elif action == "sync":
count = await api.sync_workspace(inst, self._actor_user())
return json.dumps({"status": "synced", "imported": count})
elif action == "restart":
api.request_restart(inst, actor=actor)
elif action in ("start", "resume"):
api.set_desired_state(inst, store.DESIRED_RUNNING, actor=actor)
elif action == "stop":
api.set_desired_state(inst, store.DESIRED_STOPPED, actor=actor)
elif action == "pause":
api.set_desired_state(inst, store.DESIRED_PAUSED, actor=actor)
else:
raise ToolInputError(f"unknown action: {action}")
return json.dumps({"status": "ok", "action": action, "instance": inst["uid"]})
async def _logs(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
if not inst.get("container_id"):
return json.dumps({"logs": "", "status": inst["status"]})
lines: list = []
async def collect(line: str) -> None:
lines.append(line)
tail = int(arguments.get("tail", 200) or 200)
await get_backend().logs(inst["container_id"], follow=False, tail=max(1, min(tail, 2000)), on_log=collect)
return json.dumps({"logs": "\n".join(lines)})
async def _exec(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
if not inst.get("container_id"):
raise ToolInputError("instance is not running")
command = str(arguments.get("command", "")).strip()
if not command:
raise ToolInputError("command is required")
result = await get_backend().exec(inst["container_id"], ["/bin/sh", "-c", command])
actor = self._actor_user()
store.record_event(inst, "exec", "user", actor["uid"], {"command": command, "exit_code": result.exit_code})
return json.dumps({"exit_code": result.exit_code, "output": result.output})
async def _stats(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
return json.dumps({
"instance": inst["name"], "status": inst["status"],
"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:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
run_at = arguments.get("run_at")
try:
schedule = Schedule(
kind=str(arguments.get("kind", "")), cron=arguments.get("cron") or None,
run_at=from_iso(run_at) if run_at else None,
every_seconds=arguments.get("every_seconds"))
except ValueError as exc:
raise ToolInputError(str(exc))
sched = api.add_schedule(inst, str(arguments.get("action", "")), schedule)
return json.dumps({"schedule": sched}, default=str)
+2
View File
@@ -6,6 +6,7 @@ from .actions.avatar_actions import AVATAR_ACTIONS
from .actions.catalog import ACTIONS
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.docs_actions import DOCS_ACTIONS
from .actions.fetch_actions import FETCH_ACTIONS
@@ -25,4 +26,5 @@ CATALOG = Catalog(
+ COST_ACTIONS
+ CHUNK_ACTIONS
+ RSEARCH_ACTIONS
+ CONTAINER_ACTIONS
)
+16 -1
View File
@@ -108,6 +108,7 @@ class DeviiSession:
self._buffer: list[dict[str, Any]] = []
self._turn_tool_calls = 0
self._pending_session: str | None = None
self._turns: set[asyncio.Task] = set()
def restore_history(self, messages: list[dict[str, Any]]) -> None:
if messages:
@@ -222,7 +223,21 @@ class DeviiSession:
await self._emit({"type": "clear"}, buffer=False)
def spawn_turn(self, text: str) -> None:
asyncio.create_task(self._run_turn(text))
task = asyncio.create_task(self._run_turn(text))
self._turns.add(task)
task.add_done_callback(self._turns.discard)
async def cancel_turns(self) -> None:
turns = [task for task in self._turns if not task.done()]
if not turns:
return
for task in turns:
task.cancel()
await asyncio.gather(*turns, return_exceptions=True)
async def reset(self) -> None:
await self.cancel_turns()
await self.reset_conversation()
async def _run_turn(self, text: str) -> None:
turn_id = uuid_utils.uuid7().hex