diff --git a/.gitignore b/.gitignore index 261aed6d..16626a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ # Node / Next node_modules/ .next/ +*.tsbuildinfo # Env .env diff --git a/backend/alembic/versions/e4f5a6b7c8d9_make_agent_last_seen_nullable.py b/backend/alembic/versions/e4f5a6b7c8d9_make_agent_last_seen_nullable.py new file mode 100644 index 00000000..02d8b596 --- /dev/null +++ b/backend/alembic/versions/e4f5a6b7c8d9_make_agent_last_seen_nullable.py @@ -0,0 +1,27 @@ +"""make agent last_seen_at nullable + +Revision ID: e4f5a6b7c8d9 +Revises: d3e4f5a6b7c8 +Create Date: 2026-02-04 07:10:00.000000 + +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "e4f5a6b7c8d9" +down_revision = "d3e4f5a6b7c8" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column("agents", "last_seen_at", existing_type=sa.DateTime(), nullable=True) + + +def downgrade() -> None: + op.alter_column("agents", "last_seen_at", existing_type=sa.DateTime(), nullable=False) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index d994fb5b..978f9a0f 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -57,7 +57,9 @@ async def _ensure_gateway_session(agent_name: str) -> tuple[str, str | None]: def _with_computed_status(agent: Agent) -> Agent: now = datetime.utcnow() - if agent.last_seen_at and now - agent.last_seen_at > OFFLINE_AFTER: + if agent.last_seen_at is None: + agent.status = "provisioning" + elif now - agent.last_seen_at > OFFLINE_AFTER: agent.status = "offline" return agent @@ -96,6 +98,7 @@ async def create_agent( auth: AuthContext = Depends(require_admin_auth), ) -> Agent: agent = Agent.model_validate(payload) + agent.status = "provisioning" raw_token = generate_agent_token() agent.agent_token_hash = hash_agent_token(raw_token) session_key, session_error = await _ensure_gateway_session(agent.name) @@ -152,6 +155,11 @@ def update_agent( if agent is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) updates = payload.model_dump(exclude_unset=True) + if "status" in updates: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="status is controlled by agent heartbeat", + ) for key, value in updates.items(): setattr(agent, key, value) agent.updated_at = datetime.utcnow() @@ -175,6 +183,8 @@ def heartbeat_agent( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) if payload.status: agent.status = payload.status + elif agent.status == "provisioning": + agent.status = "online" agent.last_seen_at = datetime.utcnow() agent.updated_at = datetime.utcnow() _record_heartbeat(session, agent) @@ -194,7 +204,7 @@ async def heartbeat_or_create_agent( if agent is None: if actor.actor_type == "agent": raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - agent = Agent(name=payload.name, status=payload.status or "online") + agent = Agent(name=payload.name, status="provisioning") raw_token = generate_agent_token() agent.agent_token_hash = hash_agent_token(raw_token) session_key, session_error = await _ensure_gateway_session(agent.name) @@ -261,6 +271,8 @@ async def heartbeat_or_create_agent( session.commit() if payload.status: agent.status = payload.status + elif agent.status == "provisioning": + agent.status = "online" agent.last_seen_at = datetime.utcnow() agent.updated_at = datetime.utcnow() _record_heartbeat(session, agent) diff --git a/backend/app/core/__pycache__/config.cpython-312.pyc b/backend/app/core/__pycache__/config.cpython-312.pyc deleted file mode 100644 index 2695f548..00000000 Binary files a/backend/app/core/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/backend/app/models/agents.py b/backend/app/models/agents.py index 6ac7f21a..29079845 100644 --- a/backend/app/models/agents.py +++ b/backend/app/models/agents.py @@ -11,9 +11,9 @@ class Agent(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) name: str = Field(index=True) - status: str = Field(default="online", index=True) + status: str = Field(default="provisioning", index=True) openclaw_session_id: str | None = Field(default=None, index=True) agent_token_hash: str | None = Field(default=None, index=True) - last_seen_at: datetime = Field(default_factory=datetime.utcnow) + last_seen_at: datetime | None = Field(default=None) created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/app/schemas/agents.py b/backend/app/schemas/agents.py index 243bbdf4..ec6697ba 100644 --- a/backend/app/schemas/agents.py +++ b/backend/app/schemas/agents.py @@ -8,7 +8,7 @@ from sqlmodel import SQLModel class AgentBase(SQLModel): name: str - status: str = "online" + status: str = "provisioning" class AgentCreate(AgentBase): @@ -23,7 +23,7 @@ class AgentUpdate(SQLModel): class AgentRead(AgentBase): id: UUID openclaw_session_id: str | None = None - last_seen_at: datetime + last_seen_at: datetime | None created_at: datetime updated_at: datetime diff --git a/backend/app/services/agent_provisioning.py b/backend/app/services/agent_provisioning.py index 66826081..02110dc6 100644 --- a/backend/app/services/agent_provisioning.py +++ b/backend/app/services/agent_provisioning.py @@ -4,7 +4,7 @@ import re from pathlib import Path from uuid import uuid4 -from jinja2 import Environment, FileSystemLoader, StrictUndefined +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape from app.core.config import settings from app.integrations.openclaw_gateway import ensure_session, send_message @@ -38,7 +38,7 @@ def _slugify(value: str) -> str: def _template_env() -> Environment: return Environment( loader=FileSystemLoader(_templates_root()), - autoescape=False, + autoescape=select_autoescape(default=True), undefined=StrictUndefined, keep_trailing_newline=True, ) diff --git a/frontend/src/app/agents/[agentId]/edit/page.tsx b/frontend/src/app/agents/[agentId]/edit/page.tsx index f344ae18..29b4ffef 100644 --- a/frontend/src/app/agents/[agentId]/edit/page.tsx +++ b/frontend/src/app/agents/[agentId]/edit/page.tsx @@ -9,13 +9,6 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; const apiBase = process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") || @@ -24,15 +17,8 @@ const apiBase = type Agent = { id: string; name: string; - status: string; }; -const statusOptions = [ - { value: "online", label: "Online" }, - { value: "busy", label: "Busy" }, - { value: "offline", label: "Offline" }, -]; - export default function EditAgentPage() { const { getToken, isSignedIn } = useAuth(); const router = useRouter(); @@ -42,7 +28,6 @@ export default function EditAgentPage() { const [agent, setAgent] = useState(null); const [name, setName] = useState(""); - const [status, setStatus] = useState("online"); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -61,7 +46,6 @@ export default function EditAgentPage() { const data = (await response.json()) as Agent; setAgent(data); setName(data.name); - setStatus(data.status); } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong."); } finally { @@ -92,7 +76,7 @@ export default function EditAgentPage() { "Content-Type": "application/json", Authorization: token ? `Bearer ${token}` : "", }, - body: JSON.stringify({ name: trimmed, status }), + body: JSON.stringify({ name: trimmed }), }); if (!response.ok) { throw new Error("Unable to update agent."); @@ -112,8 +96,6 @@ export default function EditAgentPage() {

Sign in to edit agents.

@@ -132,7 +114,7 @@ export default function EditAgentPage() { {agent?.name ?? "Agent"}

- Update the agent name and status. + Status is controlled by agent heartbeat.

@@ -145,21 +127,6 @@ export default function EditAgentPage() { disabled={isLoading} /> -
- - -
{error ? (
{error} diff --git a/frontend/src/app/agents/[agentId]/page.tsx b/frontend/src/app/agents/[agentId]/page.tsx index 169aba04..704b7f96 100644 --- a/frontend/src/app/agents/[agentId]/page.tsx +++ b/frontend/src/app/agents/[agentId]/page.tsx @@ -150,8 +150,6 @@ export default function AgentDetailPage() {

Sign in to view agents.

diff --git a/frontend/src/app/agents/new/page.tsx b/frontend/src/app/agents/new/page.tsx index 128aa044..d9dcb6c1 100644 --- a/frontend/src/app/agents/new/page.tsx +++ b/frontend/src/app/agents/new/page.tsx @@ -9,13 +9,6 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; const apiBase = process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") || @@ -26,18 +19,11 @@ type Agent = { name: string; }; -const statusOptions = [ - { value: "online", label: "Online" }, - { value: "busy", label: "Busy" }, - { value: "offline", label: "Offline" }, -]; - export default function NewAgentPage() { const router = useRouter(); const { getToken, isSignedIn } = useAuth(); const [name, setName] = useState(""); - const [status, setStatus] = useState("online"); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -59,7 +45,7 @@ export default function NewAgentPage() { "Content-Type": "application/json", Authorization: token ? `Bearer ${token}` : "", }, - body: JSON.stringify({ name: trimmed, status }), + body: JSON.stringify({ name: trimmed }), }); if (!response.ok) { throw new Error("Unable to create agent."); @@ -80,8 +66,6 @@ export default function NewAgentPage() {

Sign in to create an agent.

@@ -100,7 +84,7 @@ export default function NewAgentPage() { Register an agent.

- Add an agent to your mission control roster. + Agents start in provisioning until they check in.

@@ -113,21 +97,6 @@ export default function NewAgentPage() { disabled={isLoading} /> -
- - -
{error ? (
{error} diff --git a/frontend/src/app/agents/page.tsx b/frontend/src/app/agents/page.tsx index 3e725ce5..fd151079 100644 --- a/frontend/src/app/agents/page.tsx +++ b/frontend/src/app/agents/page.tsx @@ -266,8 +266,6 @@ export default function AgentsPage() {

Sign in to view agents.

diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 3ef5e9b1..2cf1a784 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -172,8 +172,6 @@ export default function BoardDetailPage() {

Sign in to view boards.

diff --git a/frontend/src/app/boards/new/page.tsx b/frontend/src/app/boards/new/page.tsx index 7552adf7..b6743e1f 100644 --- a/frontend/src/app/boards/new/page.tsx +++ b/frontend/src/app/boards/new/page.tsx @@ -70,8 +70,6 @@ export default function NewBoardPage() {

Sign in to create a board.

diff --git a/frontend/src/app/boards/page.tsx b/frontend/src/app/boards/page.tsx index e415955a..f28bd5cd 100644 --- a/frontend/src/app/boards/page.tsx +++ b/frontend/src/app/boards/page.tsx @@ -112,8 +112,6 @@ export default function BoardsPage() {

Sign in to view boards.

diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index b8d64919..2aeee3ef 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -20,8 +20,6 @@ export default function DashboardPage() {

diff --git a/frontend/src/components/atoms/StatusPill.tsx b/frontend/src/components/atoms/StatusPill.tsx index 379b0f48..dbd66da8 100644 --- a/frontend/src/components/atoms/StatusPill.tsx +++ b/frontend/src/components/atoms/StatusPill.tsx @@ -12,6 +12,7 @@ const STATUS_STYLES: Record< done: "success", online: "success", busy: "warning", + provisioning: "warning", offline: "outline", }; diff --git a/frontend/src/components/organisms/LandingHero.tsx b/frontend/src/components/organisms/LandingHero.tsx index 2ec7c4fb..0f7def6d 100644 --- a/frontend/src/components/organisms/LandingHero.tsx +++ b/frontend/src/components/organisms/LandingHero.tsx @@ -14,8 +14,6 @@ export function LandingHero() { diff --git a/templates/BOOT.md b/templates/BOOT.md index 23a5fd03..d6d9e929 100644 --- a/templates/BOOT.md +++ b/templates/BOOT.md @@ -2,5 +2,6 @@ On startup: 1) Verify API reachability (GET {{ base_url }}/api/v1/gateway/status). -2) If you send a boot message, end with NO_REPLY. -3) If BOOTSTRAP.md exists in this workspace, the agent should run it once and delete it. +2) Connect to Mission Control once by sending a heartbeat check-in. +3) If you send a boot message, end with NO_REPLY. +4) If BOOTSTRAP.md exists in this workspace, the agent should run it once and delete it. diff --git a/templates/HEARTBEAT.md b/templates/HEARTBEAT.md index 3ddc656f..987c5d40 100644 --- a/templates/HEARTBEAT.md +++ b/templates/HEARTBEAT.md @@ -7,6 +7,10 @@ If this file is empty, skip heartbeat work. - AUTH_TOKEN (agent token) - AGENT_NAME +## Schedule +- Run this heartbeat every 10 minutes. +- On first boot, send one immediate check-in before the schedule starts. + ## On every heartbeat 1) Check in: ```bash