Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions omnigent/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2337,6 +2337,7 @@ async def _on_hosts_changed(_host_id: str, owner: str | None) -> None:
permission_store=permission_store,
agent_store=agent_store,
agent_cache=agent_cache,
scheduled_task_store=scheduled_task_store,
),
prefix="/v1",
tags=["hosts"],
Expand Down
81 changes: 80 additions & 1 deletion omnigent/server/routes/hosts.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import secrets
from typing import Any

from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, HTTPException, Query, Request, Response
from pydantic import BaseModel

from omnigent.db.utils import now_epoch
Expand Down Expand Up @@ -56,6 +56,7 @@
from omnigent.stores import AgentStore, ConversationStore
from omnigent.stores.host_store import HostStore, host_is_live
from omnigent.stores.permission_store import PermissionStore
from omnigent.stores.scheduled_task_store import ScheduledTaskStore

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -529,6 +530,7 @@ def create_hosts_router(
permission_store: PermissionStore | None = None,
agent_store: AgentStore | None = None,
agent_cache: AgentCache | None = None,
scheduled_task_store: ScheduledTaskStore | None = None,
) -> APIRouter:
"""Build the router for host REST endpoints.

Expand All @@ -549,6 +551,11 @@ def create_hosts_router(
:func:`omnigent.server.app.create_app` always supplies it.
:param agent_cache: Agent-spec cache used to read the agent's
``os_env.cwd`` boundary. Paired with ``agent_store``.
:param scheduled_task_store: Store backing recurring tasks, read by
host deregistration to refuse deleting a host that tasks still
pin. ``None`` (deployments without the scheduler, and minimal
test wirings) skips that check — no task can be pinned when no
task can exist.
:returns: A FastAPI router with host endpoints.
"""
router = APIRouter()
Expand Down Expand Up @@ -652,6 +659,78 @@ async def get_host(request: Request, host_id: str) -> dict[str, Any]:
"runners": [],
}

@router.delete("/hosts/{host_id}", status_code=204)
async def delete_host(request: Request, host_id: str) -> Response:
"""Deregister (delete) a host the caller owns.

Removes a retired self-registered host so it stops appearing in
session-creation pickers. Any session still bound to it is fully
unbound by the store — ``host_id``, ``runner_id``, ``workspace``,
and ``git_branch`` are nulled together, leaving a session that
can be rebound to another host rather than one wedged against a
row that no longer exists.

Deregistration is not permanent on its own: an external host
keeps its ``host_id`` in its local config and no server-side
credential is destroyed here (only server-managed hosts ever
hold a launch token, and those are refused below), so a daemon
that dials again simply recreates the row. Stop the daemon
first.

Refuses a host that is currently online (409): retire it after it
goes offline so an active machine is not pulled out from under
running work. Server-managed sandbox hosts are likewise refused
(409) — their lifecycle belongs to the server that created them,
and deleting only the row would orphan the live sandbox. A host
that scheduled tasks pin is refused too (409): the pin is a soft
reference, so deleting the host would leave each task failing on
every fire. Re-point those tasks at another host (or delete
them) first.

:param request: The incoming request (for auth).
:param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``.
:returns: 204 No Content on success.
:raises HTTPException: 404 if the host does not exist, 403 if the
caller is not the owner, 409 if the host is online,
server-managed, or pinned by a scheduled task.
"""
# Same auth shape as get_host: require_user 401s unauthenticated
# callers when auth is configured; a None user_id means auth is
# disabled entirely (single-user server, reserved "local" owner).
user_id = require_user(request, auth_provider)
host = await asyncio.to_thread(host_store.get_host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="host not found")
if user_id is not None and host.user_id != user_id:
raise HTTPException(status_code=403, detail="not your host")
if host.sandbox_provider is not None:
raise HTTPException(
status_code=409,
detail="server-managed host cannot be deregistered manually",
)
if host_is_live(host):
raise HTTPException(
status_code=409,
detail="host is online; retire it after it goes offline",
)
if scheduled_task_store is not None:
pinned = await asyncio.to_thread(scheduled_task_store.list_by_host_id, host_id)
if pinned:
# Refuse rather than silently unpin: an unpinned task falls
# back to whichever host of the owner's is online at fire
# time, so clearing the pin here would relocate the work
# instead of surfacing the choice. PATCH can re-point a task
# at another host, which is the repair.
raise HTTPException(
status_code=409,
detail=(
f"{len(pinned)} scheduled task(s) are pinned to this host; "
"re-point or delete them first: " + ", ".join(task.id for task in pinned)
),
)
await asyncio.to_thread(host_store.delete_host, host_id)
return Response(status_code=204)

@router.get("/hosts/{host_id}/harnesses/{harness}/model-options")
async def get_host_model_options(
request: Request,
Expand Down
17 changes: 13 additions & 4 deletions omnigent/stores/host_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -787,11 +787,20 @@ def delete_host(self, host_id: str) -> None:

Managed-host teardown: removes the host from the picker AND
revokes its launch token in one operation (the row IS the
credential). Explicitly nulls ``conversations.host_id`` for any
sessions still bound to this host — the DB no longer cascades
this via FK. No-op when the row does not exist — deletion is
credential). No-op when the row does not exist — deletion is
invoked from best-effort cleanup paths that may race.

Fully unbinds every session still pointing at the host, nulling
``host_id``, ``runner_id``, ``workspace``, and ``git_branch``
together (the DB no longer cascades this via FK). All four go
because the machine behind them is gone: a row keeping its
``runner_id`` can never relaunch, rebind (the atomic bind
matches only ``runner_id IS NULL``), or stop (Stop needs both
``host_id`` and ``runner_id``), so a partial unbind wedges the
session with no API left to repair it. Clearing ``host_id`` and
``workspace`` in the same statement never violates
``ck_conversation_metadata_workspace_required_for_host``.

:param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``.
"""
with self._session("delete_host") as session:
Expand All @@ -801,7 +810,7 @@ def delete_host(self, host_id: str) -> None:
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.host_id == host_id,
)
.values(host_id=None)
.values(host_id=None, runner_id=None, workspace=None, git_branch=None)
)
session.execute(
sql_delete(SqlHost).where(
Expand Down
17 changes: 17 additions & 0 deletions omnigent/stores/scheduled_task_store/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ def list(self, *, owner_user_id: str | None = None) -> list[ScheduledTask]:
"""
...

@abstractmethod
def list_by_host_id(self, host_id: str) -> builtins.list[ScheduledTask]:
"""
List every task pinned to *host_id*, in any state.

Host deregistration uses this to refuse deleting a host that
tasks still pin: the pin is a soft reference (no FK, Rule R032),
so a deleted host leaves each task failing at fire time with
``host_not_found``. Paused tasks count — they break the same way
once resumed.

:param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``.
:returns: Tasks with ``host_id`` matching, ordered like
:meth:`list`.
"""
...

@abstractmethod
def list_active(self) -> builtins.list[ScheduledTask]:
"""
Expand Down
12 changes: 12 additions & 0 deletions omnigent/stores/scheduled_task_store/sqlalchemy_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,18 @@ def list(self, *, owner_user_id: str | None = None) -> list[ScheduledTask]:
rows = session.execute(stmt).scalars().all()
return [_to_entity(r) for r in rows]

def list_by_host_id(self, host_id: str) -> builtins.list[ScheduledTask]:
"""List every task pinned to *host_id*, in any state."""
with self._session("list_tasks_by_host_id") as session:
stmt = (
select(SqlScheduledTask)
.where(SqlScheduledTask.workspace_id == current_workspace_id())
.where(SqlScheduledTask.host_id == host_id)
.order_by(asc(SqlScheduledTask.created_at), asc(SqlScheduledTask.id))
)
rows = session.execute(stmt).scalars().all()
return [_to_entity(r) for r in rows]

def list_active(self) -> builtins.list[ScheduledTask]:
"""List active scheduled tasks ordered by ``created_at ASC, id ASC``."""
with self._session("list_active_tasks") as session:
Expand Down
35 changes: 35 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -7258,6 +7258,41 @@
}
},
"/v1/hosts/{host_id}": {
"delete": {
"description": "Deregister (delete) a host the caller owns.\n\nRemoves a retired self-registered host so it stops appearing in\nsession-creation pickers. Any session still bound to it is fully\nunbound by the store \u2014 `host_id`, `runner_id`, `workspace`,\nand `git_branch` are nulled together, leaving a session that\ncan be rebound to another host rather than one wedged against a\nrow that no longer exists.\n\nDeregistration is not permanent on its own: an external host\nkeeps its `host_id` in its local config and no server-side\ncredential is destroyed here (only server-managed hosts ever\nhold a launch token, and those are refused below), so a daemon\nthat dials again simply recreates the row. Stop the daemon\nfirst.\n\nRefuses a host that is currently online (409): retire it after it\ngoes offline so an active machine is not pulled out from under\nrunning work. Server-managed sandbox hosts are likewise refused\n(409) \u2014 their lifecycle belongs to the server that created them,\nand deleting only the row would orphan the live sandbox. A host\nthat scheduled tasks pin is refused too (409): the pin is a soft\nreference, so deleting the host would leave each task failing on\nevery fire. Re-point those tasks at another host (or delete\nthem) first.\n\n**Returns:** 204 No Content on success.\n\n**Raises**\n\n- `HTTPException` \u2014 404 if the host does not exist, 403 if the caller is not the owner, 409 if the host is online, server-managed, or pinned by a scheduled task.",
"operationId": "delete_host_v1_hosts__host_id__delete",
"parameters": [
{
"description": "Host identifier, e.g. `\"host_a1b2c3d4...\"`.",
"in": "path",
"name": "host_id",
"required": true,
"schema": {
"title": "Host Id",
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Delete Host",
"tags": [
"hosts"
]
},
"get": {
"description": "Get details for a single host.\n\n**Returns:** Host details dict \u2014 the `list_hosts` fields (including `gateway_inference`, `None` when unreported) plus `runners`.\n\n**Raises**\n\n- `HTTPException` \u2014 404 if the host does not exist.",
"operationId": "get_host_v1_hosts__host_id__get",
Expand Down
Loading
Loading