feat(agents): Add heartbeat configuration and delete confirmation for agents
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
"""add agent heartbeat config column
|
||||
|
||||
Revision ID: 2b4c2f7b3eda
|
||||
Revises: 69858cb75533
|
||||
Create Date: 2026-02-04 16:36:55.587762
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2b4c2f7b3eda'
|
||||
down_revision = '69858cb75533'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE agents ADD COLUMN IF NOT EXISTS heartbeat_config JSON"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE agents DROP COLUMN IF EXISTS heartbeat_config")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add agent heartbeat config
|
||||
|
||||
Revision ID: 69858cb75533
|
||||
Revises: f1a2b3c4d5e6
|
||||
Create Date: 2026-02-04 16:32:42.028772
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '69858cb75533'
|
||||
down_revision = 'f1a2b3c4d5e6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE agents ADD COLUMN IF NOT EXISTS heartbeat_config JSON"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE agents DROP COLUMN IF EXISTS heartbeat_config"
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""ensure heartbeat config column
|
||||
|
||||
Revision ID: cefef25d4634
|
||||
Revises: 2b4c2f7b3eda
|
||||
Create Date: 2026-02-04 16:38:25.234627
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'cefef25d4634'
|
||||
down_revision = '2b4c2f7b3eda'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE agents ADD COLUMN IF NOT EXISTS heartbeat_config JSON"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE agents DROP COLUMN IF EXISTS heartbeat_config"
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""add agent delete confirmation
|
||||
|
||||
Revision ID: e0f28e965fa5
|
||||
Revises: cefef25d4634
|
||||
Create Date: 2026-02-04 16:55:33.389505
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e0f28e965fa5'
|
||||
down_revision = 'cefef25d4634'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE agents ADD COLUMN IF NOT EXISTS delete_requested_at TIMESTAMP"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE agents ADD COLUMN IF NOT EXISTS delete_confirm_token_hash VARCHAR"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE agents DROP COLUMN IF EXISTS delete_confirm_token_hash"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE agents DROP COLUMN IF EXISTS delete_requested_at"
|
||||
)
|
||||
@@ -9,13 +9,13 @@ from sqlmodel import Session, col, select
|
||||
from sqlalchemy import update
|
||||
|
||||
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
|
||||
from app.core.agent_tokens import generate_agent_token, hash_agent_token
|
||||
from app.core.agent_tokens import generate_agent_token, hash_agent_token, verify_agent_token
|
||||
from app.core.auth import AuthContext
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.integrations.openclaw_gateway import (
|
||||
GatewayConfig,
|
||||
OpenClawGatewayError,
|
||||
delete_session,
|
||||
ensure_session,
|
||||
send_message,
|
||||
)
|
||||
@@ -24,13 +24,18 @@ from app.models.activity_events import ActivityEvent
|
||||
from app.models.boards import Board
|
||||
from app.schemas.agents import (
|
||||
AgentCreate,
|
||||
AgentDeleteConfirm,
|
||||
AgentHeartbeat,
|
||||
AgentHeartbeatCreate,
|
||||
AgentRead,
|
||||
AgentUpdate,
|
||||
)
|
||||
from app.services.activity_log import record_activity
|
||||
from app.services.agent_provisioning import send_provisioning_message
|
||||
from app.services.agent_provisioning import (
|
||||
DEFAULT_HEARTBEAT_CONFIG,
|
||||
send_provisioning_message,
|
||||
send_update_message,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/agents", tags=["agents"])
|
||||
|
||||
@@ -82,6 +87,8 @@ async def _ensure_gateway_session(
|
||||
|
||||
def _with_computed_status(agent: Agent) -> Agent:
|
||||
now = datetime.utcnow()
|
||||
if agent.status == "deleting":
|
||||
return agent
|
||||
if agent.last_seen_at is None:
|
||||
agent.status = "provisioning"
|
||||
elif now - agent.last_seen_at > OFFLINE_AFTER:
|
||||
@@ -98,11 +105,14 @@ def _record_heartbeat(session: Session, agent: Agent) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _record_provisioning_failure(session: Session, agent: Agent, error: str) -> None:
|
||||
def _record_instruction_failure(
|
||||
session: Session, agent: Agent, error: str, action: str
|
||||
) -> None:
|
||||
action_label = action.replace("_", " ").capitalize()
|
||||
record_activity(
|
||||
session,
|
||||
event_type="agent.provision.failed",
|
||||
message=f"Provisioning message failed: {error}",
|
||||
event_type=f"agent.{action}.failed",
|
||||
message=f"{action_label} message failed: {error}",
|
||||
agent_id=agent.id,
|
||||
)
|
||||
|
||||
@@ -116,10 +126,12 @@ def _record_wakeup_failure(session: Session, agent: Agent, error: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _send_wakeup_message(agent: Agent, config: GatewayConfig) -> None:
|
||||
async def _send_wakeup_message(
|
||||
agent: Agent, config: GatewayConfig, verb: str = "provisioned"
|
||||
) -> None:
|
||||
session_key = agent.openclaw_session_id or _build_session_key(agent.name)
|
||||
message = (
|
||||
f"Hello {agent.name}. Your workspace has been provisioned.\n\n"
|
||||
f"Hello {agent.name}. Your workspace has been {verb}.\n\n"
|
||||
"Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run it once "
|
||||
"then delete it. Begin heartbeats after startup."
|
||||
)
|
||||
@@ -147,6 +159,8 @@ async def create_agent(
|
||||
agent.status = "provisioning"
|
||||
raw_token = generate_agent_token()
|
||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||
if agent.heartbeat_config is None:
|
||||
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
|
||||
session_key, session_error = await _ensure_gateway_session(agent.name, config)
|
||||
agent.openclaw_session_id = session_key
|
||||
session.add(agent)
|
||||
@@ -177,11 +191,11 @@ async def create_agent(
|
||||
agent_id=agent.id,
|
||||
)
|
||||
except OpenClawGatewayError as exc:
|
||||
_record_provisioning_failure(session, agent, str(exc))
|
||||
_record_instruction_failure(session, agent, str(exc), "provision")
|
||||
_record_wakeup_failure(session, agent, str(exc))
|
||||
session.commit()
|
||||
except Exception as exc: # pragma: no cover - unexpected provisioning errors
|
||||
_record_provisioning_failure(session, agent, str(exc))
|
||||
_record_instruction_failure(session, agent, str(exc), "provision")
|
||||
_record_wakeup_failure(session, agent, str(exc))
|
||||
session.commit()
|
||||
return agent
|
||||
@@ -222,6 +236,8 @@ async def update_agent(
|
||||
for key, value in updates.items():
|
||||
setattr(agent, key, value)
|
||||
agent.updated_at = datetime.utcnow()
|
||||
if agent.heartbeat_config is None:
|
||||
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
session.refresh(agent)
|
||||
@@ -236,7 +252,7 @@ async def update_agent(
|
||||
session.commit()
|
||||
session.refresh(agent)
|
||||
except OpenClawGatewayError as exc:
|
||||
_record_provisioning_failure(session, agent, str(exc))
|
||||
_record_instruction_failure(session, agent, str(exc), "update")
|
||||
session.commit()
|
||||
raw_token = generate_agent_token()
|
||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||
@@ -244,12 +260,12 @@ async def update_agent(
|
||||
session.commit()
|
||||
session.refresh(agent)
|
||||
try:
|
||||
await send_provisioning_message(agent, board, raw_token)
|
||||
await _send_wakeup_message(agent, config)
|
||||
await send_update_message(agent, board, raw_token)
|
||||
await _send_wakeup_message(agent, config, verb="updated")
|
||||
record_activity(
|
||||
session,
|
||||
event_type="agent.reprovisioned",
|
||||
message=f"Re-provisioned agent {agent.name}.",
|
||||
event_type="agent.updated",
|
||||
message=f"Updated agent {agent.name}.",
|
||||
agent_id=agent.id,
|
||||
)
|
||||
record_activity(
|
||||
@@ -260,11 +276,11 @@ async def update_agent(
|
||||
)
|
||||
session.commit()
|
||||
except OpenClawGatewayError as exc:
|
||||
_record_provisioning_failure(session, agent, str(exc))
|
||||
_record_instruction_failure(session, agent, str(exc), "update")
|
||||
_record_wakeup_failure(session, agent, str(exc))
|
||||
session.commit()
|
||||
except Exception as exc: # pragma: no cover - unexpected provisioning errors
|
||||
_record_provisioning_failure(session, agent, str(exc))
|
||||
_record_instruction_failure(session, agent, str(exc), "update")
|
||||
_record_wakeup_failure(session, agent, str(exc))
|
||||
session.commit()
|
||||
return _with_computed_status(agent)
|
||||
@@ -307,7 +323,12 @@ async def heartbeat_or_create_agent(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
board = _require_board(session, payload.board_id)
|
||||
config = _require_gateway_config(board)
|
||||
agent = Agent(name=payload.name, status="provisioning", board_id=board.id)
|
||||
agent = Agent(
|
||||
name=payload.name,
|
||||
status="provisioning",
|
||||
board_id=board.id,
|
||||
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
|
||||
)
|
||||
raw_token = generate_agent_token()
|
||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||
session_key, session_error = await _ensure_gateway_session(agent.name, config)
|
||||
@@ -340,11 +361,11 @@ async def heartbeat_or_create_agent(
|
||||
agent_id=agent.id,
|
||||
)
|
||||
except OpenClawGatewayError as exc:
|
||||
_record_provisioning_failure(session, agent, str(exc))
|
||||
_record_instruction_failure(session, agent, str(exc), "provision")
|
||||
_record_wakeup_failure(session, agent, str(exc))
|
||||
session.commit()
|
||||
except Exception as exc: # pragma: no cover - unexpected provisioning errors
|
||||
_record_provisioning_failure(session, agent, str(exc))
|
||||
_record_instruction_failure(session, agent, str(exc), "provision")
|
||||
_record_wakeup_failure(session, agent, str(exc))
|
||||
session.commit()
|
||||
elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
|
||||
@@ -352,6 +373,8 @@ async def heartbeat_or_create_agent(
|
||||
elif agent.agent_token_hash is None and actor.actor_type == "user":
|
||||
raw_token = generate_agent_token()
|
||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||
if agent.heartbeat_config is None:
|
||||
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
session.refresh(agent)
|
||||
@@ -367,11 +390,11 @@ async def heartbeat_or_create_agent(
|
||||
agent_id=agent.id,
|
||||
)
|
||||
except OpenClawGatewayError as exc:
|
||||
_record_provisioning_failure(session, agent, str(exc))
|
||||
_record_instruction_failure(session, agent, str(exc), "provision")
|
||||
_record_wakeup_failure(session, agent, str(exc))
|
||||
session.commit()
|
||||
except Exception as exc: # pragma: no cover - unexpected provisioning errors
|
||||
_record_provisioning_failure(session, agent, str(exc))
|
||||
_record_instruction_failure(session, agent, str(exc), "provision")
|
||||
_record_wakeup_failure(session, agent, str(exc))
|
||||
session.commit()
|
||||
elif not agent.openclaw_session_id:
|
||||
@@ -414,51 +437,104 @@ def delete_agent(
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
) -> dict[str, bool]:
|
||||
agent = session.get(Agent, agent_id)
|
||||
if agent:
|
||||
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
|
||||
config = _require_gateway_config(board)
|
||||
async def _gateway_cleanup() -> None:
|
||||
if agent.openclaw_session_id:
|
||||
await delete_session(agent.openclaw_session_id, config=config)
|
||||
main_session = board.gateway_main_session_key or "agent:main:main"
|
||||
if main_session:
|
||||
workspace_root = (
|
||||
board.gateway_workspace_root or "~/.openclaw/workspaces"
|
||||
)
|
||||
workspace_path = f"{workspace_root.rstrip('/')}/{_slugify(agent.name)}"
|
||||
cleanup_message = (
|
||||
"Cleanup request for deleted agent.\n\n"
|
||||
f"Agent name: {agent.name}\n"
|
||||
f"Agent id: {agent.id}\n"
|
||||
f"Session key: {agent.openclaw_session_id or _build_session_key(agent.name)}\n"
|
||||
f"Workspace path: {workspace_path}\n\n"
|
||||
"Actions:\n"
|
||||
"1) Remove the workspace directory.\n"
|
||||
"2) Delete any lingering session artifacts.\n"
|
||||
"Reply NO_REPLY."
|
||||
)
|
||||
await ensure_session(main_session, config=config, label="Main Agent")
|
||||
await send_message(
|
||||
cleanup_message,
|
||||
session_key=main_session,
|
||||
config=config,
|
||||
deliver=False,
|
||||
)
|
||||
if agent is None:
|
||||
return {"ok": True}
|
||||
if agent.status == "deleting" and agent.delete_confirm_token_hash:
|
||||
return {"ok": True}
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
|
||||
config = _require_gateway_config(board)
|
||||
raw_token = generate_agent_token()
|
||||
agent.delete_confirm_token_hash = hash_agent_token(raw_token)
|
||||
agent.delete_requested_at = datetime.utcnow()
|
||||
agent.status = "deleting"
|
||||
agent.updated_at = datetime.utcnow()
|
||||
session.add(agent)
|
||||
record_activity(
|
||||
session,
|
||||
event_type="agent.delete.requested",
|
||||
message=f"Delete requested for {agent.name}.",
|
||||
agent_id=agent.id,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
asyncio.run(_gateway_cleanup())
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Gateway cleanup failed: {exc}",
|
||||
) from exc
|
||||
session.execute(
|
||||
update(ActivityEvent)
|
||||
.where(col(ActivityEvent.agent_id) == agent.id)
|
||||
.values(agent_id=None)
|
||||
async def _gateway_cleanup_request() -> None:
|
||||
main_session = board.gateway_main_session_key or "agent:main:main"
|
||||
if not main_session:
|
||||
return
|
||||
workspace_root = board.gateway_workspace_root or "~/.openclaw/workspaces"
|
||||
workspace_path = f"{workspace_root.rstrip('/')}/{_slugify(agent.name)}"
|
||||
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
|
||||
cleanup_message = (
|
||||
"Cleanup request for deleted agent.\n\n"
|
||||
f"Agent name: {agent.name}\n"
|
||||
f"Agent id: {agent.id}\n"
|
||||
f"Session key: {agent.openclaw_session_id or _build_session_key(agent.name)}\n"
|
||||
f"Workspace path: {workspace_path}\n\n"
|
||||
"Actions:\n"
|
||||
"1) Remove the workspace directory.\n"
|
||||
"2) Delete the agent session from the gateway.\n"
|
||||
"3) Confirm deletion by calling:\n"
|
||||
f" POST {base_url}/api/v1/agents/{agent.id}/delete/confirm\n"
|
||||
" Body: {\"token\": \"" + raw_token + "\"}\n"
|
||||
"Reply NO_REPLY."
|
||||
)
|
||||
session.delete(agent)
|
||||
await ensure_session(main_session, config=config, label="Main Agent")
|
||||
await send_message(
|
||||
cleanup_message,
|
||||
session_key=main_session,
|
||||
config=config,
|
||||
deliver=False,
|
||||
)
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
asyncio.run(_gateway_cleanup_request())
|
||||
except OpenClawGatewayError as exc:
|
||||
_record_instruction_failure(session, agent, str(exc), "delete")
|
||||
session.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Gateway cleanup request failed: {exc}",
|
||||
) from exc
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{agent_id}/delete/confirm")
|
||||
def confirm_delete_agent(
|
||||
agent_id: str,
|
||||
payload: AgentDeleteConfirm,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, bool]:
|
||||
agent = session.get(Agent, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if agent.status != "deleting":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Agent is not pending deletion.",
|
||||
)
|
||||
if not agent.delete_confirm_token_hash:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Delete confirmation not requested.",
|
||||
)
|
||||
if not verify_agent_token(payload.token, agent.delete_confirm_token_hash):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token.")
|
||||
|
||||
record_activity(
|
||||
session,
|
||||
event_type="agent.delete.confirmed",
|
||||
message=f"Deleted agent {agent.name}.",
|
||||
agent_id=None,
|
||||
)
|
||||
session.execute(
|
||||
update(ActivityEvent)
|
||||
.where(col(ActivityEvent.agent_id) == agent.id)
|
||||
.values(agent_id=None)
|
||||
)
|
||||
session.delete(agent)
|
||||
session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -13,6 +13,11 @@ from app.integrations.openclaw_gateway import (
|
||||
openclaw_call,
|
||||
send_message,
|
||||
)
|
||||
from app.integrations.openclaw_gateway_protocol import (
|
||||
GATEWAY_EVENTS,
|
||||
GATEWAY_METHODS,
|
||||
PROTOCOL_VERSION,
|
||||
)
|
||||
from app.db.session import get_session
|
||||
from app.models.boards import Board
|
||||
|
||||
@@ -196,3 +201,14 @@ async def send_session_message(
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/commands")
|
||||
async def gateway_commands(
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"methods": GATEWAY_METHODS,
|
||||
"events": GATEWAY_EVENTS,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ from uuid import uuid4
|
||||
|
||||
import websockets
|
||||
|
||||
from app.integrations.openclaw_gateway_protocol import PROTOCOL_VERSION
|
||||
|
||||
|
||||
class OpenClawGatewayError(RuntimeError):
|
||||
@@ -64,23 +65,10 @@ async def _send_request(
|
||||
return await _await_response(ws, request_id)
|
||||
|
||||
|
||||
async def _handle_challenge(
|
||||
ws: websockets.WebSocketClientProtocol,
|
||||
first_message: str | bytes | None,
|
||||
config: GatewayConfig,
|
||||
) -> None:
|
||||
if not first_message:
|
||||
return
|
||||
if isinstance(first_message, bytes):
|
||||
first_message = first_message.decode("utf-8")
|
||||
data = json.loads(first_message)
|
||||
if data.get("type") != "event" or data.get("event") != "connect.challenge":
|
||||
return
|
||||
|
||||
connect_id = str(uuid4())
|
||||
def _build_connect_params(config: GatewayConfig) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {
|
||||
"minProtocol": 3,
|
||||
"maxProtocol": 3,
|
||||
"minProtocol": PROTOCOL_VERSION,
|
||||
"maxProtocol": PROTOCOL_VERSION,
|
||||
"client": {
|
||||
"id": "gateway-client",
|
||||
"version": "1.0.0",
|
||||
@@ -90,11 +78,26 @@ async def _handle_challenge(
|
||||
}
|
||||
if config.token:
|
||||
params["auth"] = {"token": config.token}
|
||||
return params
|
||||
|
||||
|
||||
async def _ensure_connected(
|
||||
ws: websockets.WebSocketClientProtocol,
|
||||
first_message: str | bytes | None,
|
||||
config: GatewayConfig,
|
||||
) -> None:
|
||||
if first_message:
|
||||
if isinstance(first_message, bytes):
|
||||
first_message = first_message.decode("utf-8")
|
||||
data = json.loads(first_message)
|
||||
if data.get("type") != "event" or data.get("event") != "connect.challenge":
|
||||
pass
|
||||
connect_id = str(uuid4())
|
||||
response = {
|
||||
"type": "req",
|
||||
"id": connect_id,
|
||||
"method": "connect",
|
||||
"params": params,
|
||||
"params": _build_connect_params(config),
|
||||
}
|
||||
await ws.send(json.dumps(response))
|
||||
await _await_response(ws, connect_id)
|
||||
@@ -114,7 +117,7 @@ async def openclaw_call(
|
||||
first_message = await asyncio.wait_for(ws.recv(), timeout=2)
|
||||
except asyncio.TimeoutError:
|
||||
first_message = None
|
||||
await _handle_challenge(ws, first_message, config)
|
||||
await _ensure_connected(ws, first_message, config)
|
||||
return await _send_request(ws, method, params)
|
||||
except OpenClawGatewayError:
|
||||
raise
|
||||
|
||||
119
backend/app/integrations/openclaw_gateway_protocol.py
Normal file
119
backend/app/integrations/openclaw_gateway_protocol.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
PROTOCOL_VERSION = 3
|
||||
|
||||
# NOTE: These are the base gateway methods from the OpenClaw gateway repo.
|
||||
# The gateway can expose additional methods at runtime via channel plugins.
|
||||
GATEWAY_METHODS = [
|
||||
"health",
|
||||
"logs.tail",
|
||||
"channels.status",
|
||||
"channels.logout",
|
||||
"status",
|
||||
"usage.status",
|
||||
"usage.cost",
|
||||
"tts.status",
|
||||
"tts.providers",
|
||||
"tts.enable",
|
||||
"tts.disable",
|
||||
"tts.convert",
|
||||
"tts.setProvider",
|
||||
"config.get",
|
||||
"config.set",
|
||||
"config.apply",
|
||||
"config.patch",
|
||||
"config.schema",
|
||||
"exec.approvals.get",
|
||||
"exec.approvals.set",
|
||||
"exec.approvals.node.get",
|
||||
"exec.approvals.node.set",
|
||||
"exec.approval.request",
|
||||
"exec.approval.resolve",
|
||||
"wizard.start",
|
||||
"wizard.next",
|
||||
"wizard.cancel",
|
||||
"wizard.status",
|
||||
"talk.mode",
|
||||
"models.list",
|
||||
"agents.list",
|
||||
"agents.files.list",
|
||||
"agents.files.get",
|
||||
"agents.files.set",
|
||||
"skills.status",
|
||||
"skills.bins",
|
||||
"skills.install",
|
||||
"skills.update",
|
||||
"update.run",
|
||||
"voicewake.get",
|
||||
"voicewake.set",
|
||||
"sessions.list",
|
||||
"sessions.preview",
|
||||
"sessions.patch",
|
||||
"sessions.reset",
|
||||
"sessions.delete",
|
||||
"sessions.compact",
|
||||
"last-heartbeat",
|
||||
"set-heartbeats",
|
||||
"wake",
|
||||
"node.pair.request",
|
||||
"node.pair.list",
|
||||
"node.pair.approve",
|
||||
"node.pair.reject",
|
||||
"node.pair.verify",
|
||||
"device.pair.list",
|
||||
"device.pair.approve",
|
||||
"device.pair.reject",
|
||||
"device.token.rotate",
|
||||
"device.token.revoke",
|
||||
"node.rename",
|
||||
"node.list",
|
||||
"node.describe",
|
||||
"node.invoke",
|
||||
"node.invoke.result",
|
||||
"node.event",
|
||||
"cron.list",
|
||||
"cron.status",
|
||||
"cron.add",
|
||||
"cron.update",
|
||||
"cron.remove",
|
||||
"cron.run",
|
||||
"cron.runs",
|
||||
"system-presence",
|
||||
"system-event",
|
||||
"send",
|
||||
"agent",
|
||||
"agent.identity.get",
|
||||
"agent.wait",
|
||||
"browser.request",
|
||||
"chat.history",
|
||||
"chat.abort",
|
||||
"chat.send",
|
||||
]
|
||||
|
||||
GATEWAY_EVENTS = [
|
||||
"connect.challenge",
|
||||
"agent",
|
||||
"chat",
|
||||
"presence",
|
||||
"tick",
|
||||
"talk.mode",
|
||||
"shutdown",
|
||||
"health",
|
||||
"heartbeat",
|
||||
"cron",
|
||||
"node.pair.requested",
|
||||
"node.pair.resolved",
|
||||
"node.invoke.request",
|
||||
"device.pair.requested",
|
||||
"device.pair.resolved",
|
||||
"voicewake.changed",
|
||||
"exec.approval.requested",
|
||||
"exec.approval.resolved",
|
||||
]
|
||||
|
||||
GATEWAY_METHODS_SET = frozenset(GATEWAY_METHODS)
|
||||
GATEWAY_EVENTS_SET = frozenset(GATEWAY_EVENTS)
|
||||
|
||||
|
||||
def is_known_gateway_method(method: str) -> bool:
|
||||
return method in GATEWAY_METHODS_SET
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import Column, JSON
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
@@ -15,6 +17,11 @@ class Agent(SQLModel, table=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)
|
||||
heartbeat_config: dict[str, Any] | None = Field(
|
||||
default=None, sa_column=Column(JSON)
|
||||
)
|
||||
delete_requested_at: datetime | None = Field(default=None)
|
||||
delete_confirm_token_hash: str | None = Field(default=None, index=True)
|
||||
last_seen_at: datetime | None = Field(default=None)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
@@ -10,6 +11,7 @@ class AgentBase(SQLModel):
|
||||
board_id: UUID | None = None
|
||||
name: str
|
||||
status: str = "provisioning"
|
||||
heartbeat_config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class AgentCreate(AgentBase):
|
||||
@@ -20,6 +22,7 @@ class AgentUpdate(SQLModel):
|
||||
board_id: UUID | None = None
|
||||
name: str | None = None
|
||||
status: str | None = None
|
||||
heartbeat_config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class AgentRead(AgentBase):
|
||||
@@ -37,3 +40,7 @@ class AgentHeartbeat(SQLModel):
|
||||
class AgentHeartbeatCreate(AgentHeartbeat):
|
||||
name: str
|
||||
board_id: UUID | None = None
|
||||
|
||||
|
||||
class AgentDeleteConfirm(SQLModel):
|
||||
token: str
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
|
||||
@@ -22,6 +24,8 @@ TEMPLATE_FILES = [
|
||||
"USER.md",
|
||||
]
|
||||
|
||||
DEFAULT_HEARTBEAT_CONFIG = {"every": "10m", "target": "none"}
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
@@ -36,6 +40,21 @@ def _slugify(value: str) -> str:
|
||||
return slug or uuid4().hex
|
||||
|
||||
|
||||
def _agent_key(agent: Agent) -> str:
|
||||
session_key = agent.openclaw_session_id or ""
|
||||
if session_key.startswith("agent:"):
|
||||
parts = session_key.split(":")
|
||||
if len(parts) >= 2 and parts[1]:
|
||||
return parts[1]
|
||||
return _slugify(agent.name)
|
||||
|
||||
|
||||
def _heartbeat_config(agent: Agent) -> dict[str, Any]:
|
||||
if agent.heartbeat_config:
|
||||
return agent.heartbeat_config
|
||||
return DEFAULT_HEARTBEAT_CONFIG.copy()
|
||||
|
||||
|
||||
def _template_env() -> Environment:
|
||||
return Environment(
|
||||
loader=FileSystemLoader(_templates_root()),
|
||||
@@ -69,15 +88,14 @@ def _workspace_path(agent_name: str, workspace_root: str) -> str:
|
||||
return f"{root}/{_slugify(agent_name)}"
|
||||
|
||||
|
||||
def build_provisioning_message(agent: Agent, board: Board, auth_token: str) -> str:
|
||||
def _build_context(agent: Agent, board: Board, auth_token: str) -> dict[str, str]:
|
||||
agent_id = str(agent.id)
|
||||
workspace_root = board.gateway_workspace_root or "~/.openclaw/workspaces"
|
||||
workspace_path = _workspace_path(agent.name, workspace_root)
|
||||
session_key = agent.openclaw_session_id or ""
|
||||
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
|
||||
main_session_key = board.gateway_main_session_key or "agent:main:main"
|
||||
|
||||
context = {
|
||||
return {
|
||||
"agent_name": agent.name,
|
||||
"agent_id": agent_id,
|
||||
"board_id": str(board.id),
|
||||
@@ -93,20 +111,34 @@ def build_provisioning_message(agent: Agent, board: Board, auth_token: str) -> s
|
||||
"user_notes": "Fill in user context.",
|
||||
}
|
||||
|
||||
templates = _read_templates(context)
|
||||
|
||||
file_blocks = "".join(
|
||||
def _build_file_blocks(context: dict[str, str]) -> str:
|
||||
templates = _read_templates(context)
|
||||
return "".join(
|
||||
_render_file_block(name, templates.get(name, "")) for name in TEMPLATE_FILES
|
||||
)
|
||||
|
||||
|
||||
def build_provisioning_message(agent: Agent, board: Board, auth_token: str) -> str:
|
||||
context = _build_context(agent, board, auth_token)
|
||||
file_blocks = _build_file_blocks(context)
|
||||
heartbeat_snippet = json.dumps(
|
||||
{
|
||||
"id": _agent_key(agent),
|
||||
"workspace": context["workspace_path"],
|
||||
"heartbeat": _heartbeat_config(agent),
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
return (
|
||||
"Provision a new OpenClaw agent workspace.\n\n"
|
||||
f"Agent name: {agent.name}\n"
|
||||
f"Agent id: {agent_id}\n"
|
||||
f"Session key: {session_key}\n"
|
||||
f"Workspace path: {workspace_path}\n\n"
|
||||
f"Base URL: {base_url}\n"
|
||||
f"Auth token: {auth_token}\n\n"
|
||||
f"Agent name: {context['agent_name']}\n"
|
||||
f"Agent id: {context['agent_id']}\n"
|
||||
f"Session key: {context['session_key']}\n"
|
||||
f"Workspace path: {context['workspace_path']}\n\n"
|
||||
f"Base URL: {context['base_url']}\n"
|
||||
f"Auth token: {context['auth_token']}\n\n"
|
||||
"Steps:\n"
|
||||
"0) IMPORTANT: Do NOT replace or repurpose the main agent. Keep "
|
||||
f"{context['main_session_key']} unchanged and its workspace intact.\n"
|
||||
@@ -115,7 +147,56 @@ def build_provisioning_message(agent: Agent, board: Board, auth_token: str) -> s
|
||||
"3) Update TOOLS.md if BASE_URL/AUTH_TOKEN must change.\n"
|
||||
"4) Leave BOOTSTRAP.md in place; the agent should run it on first start and delete it.\n"
|
||||
"5) Register agent id in OpenClaw so it uses this workspace path "
|
||||
"(never overwrite the main agent session).\n\n"
|
||||
"(never overwrite the main agent session).\n"
|
||||
" IMPORTANT: Do NOT use ~/.openclaw/workspace-<name>. The canonical path "
|
||||
"is ~/.openclaw/workspaces/<slug>.\n"
|
||||
"6) Add/update the per-agent heartbeat config in the gateway config "
|
||||
"for this agent (merge into agents.list entry):\n"
|
||||
"```json\n"
|
||||
f"{heartbeat_snippet}\n"
|
||||
"```\n"
|
||||
"Note: if any agents.list entry defines heartbeat, only those agents "
|
||||
"run heartbeats.\n\n"
|
||||
"Files:" + file_blocks
|
||||
)
|
||||
|
||||
|
||||
def build_update_message(agent: Agent, board: Board, auth_token: str) -> str:
|
||||
context = _build_context(agent, board, auth_token)
|
||||
file_blocks = _build_file_blocks(context)
|
||||
heartbeat_snippet = json.dumps(
|
||||
{
|
||||
"id": _agent_key(agent),
|
||||
"workspace": context["workspace_path"],
|
||||
"heartbeat": _heartbeat_config(agent),
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
return (
|
||||
"Update an existing OpenClaw agent workspace.\n\n"
|
||||
f"Agent name: {context['agent_name']}\n"
|
||||
f"Agent id: {context['agent_id']}\n"
|
||||
f"Session key: {context['session_key']}\n"
|
||||
f"Workspace path: {context['workspace_path']}\n\n"
|
||||
f"Base URL: {context['base_url']}\n"
|
||||
f"Auth token: {context['auth_token']}\n\n"
|
||||
"Steps:\n"
|
||||
"0) IMPORTANT: Do NOT replace or repurpose the main agent. Keep "
|
||||
f"{context['main_session_key']} unchanged and its workspace intact.\n"
|
||||
"1) Locate the existing workspace directory (do NOT create a new one or change its path).\n"
|
||||
"2) Overwrite the files below with the exact contents.\n"
|
||||
"3) Update TOOLS.md with the new BASE_URL/AUTH_TOKEN/SESSION_KEY values.\n"
|
||||
"4) Do NOT create a new agent or session; update the existing one in place.\n"
|
||||
"5) Keep BOOTSTRAP.md only if it already exists; do not recreate it if missing.\n\n"
|
||||
" IMPORTANT: Do NOT use ~/.openclaw/workspace-<name>. The canonical path "
|
||||
"is ~/.openclaw/workspaces/<slug>.\n"
|
||||
"6) Update the per-agent heartbeat config in the gateway config for this agent:\n"
|
||||
"```json\n"
|
||||
f"{heartbeat_snippet}\n"
|
||||
"```\n"
|
||||
"Note: if any agents.list entry defines heartbeat, only those agents "
|
||||
"run heartbeats.\n\n"
|
||||
"Files:" + file_blocks
|
||||
)
|
||||
|
||||
@@ -132,3 +213,17 @@ async def send_provisioning_message(
|
||||
await ensure_session(main_session, config=config, label="Main Agent")
|
||||
message = build_provisioning_message(agent, board, auth_token)
|
||||
await send_message(message, session_key=main_session, config=config, deliver=False)
|
||||
|
||||
|
||||
async def send_update_message(
|
||||
agent: Agent,
|
||||
board: Board,
|
||||
auth_token: str,
|
||||
) -> None:
|
||||
main_session = board.gateway_main_session_key or "agent:main:main"
|
||||
if not board.gateway_url:
|
||||
return
|
||||
config = GatewayConfig(url=board.gateway_url, token=board.gateway_token)
|
||||
await ensure_session(main_session, config=config, label="Main Agent")
|
||||
message = build_update_message(agent, board, auth_token)
|
||||
await send_message(message, session_key=main_session, config=config, deliver=False)
|
||||
|
||||
@@ -25,6 +25,10 @@ type Agent = {
|
||||
id: string;
|
||||
name: string;
|
||||
board_id?: string | null;
|
||||
heartbeat_config?: {
|
||||
every?: string;
|
||||
target?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type Board = {
|
||||
@@ -44,6 +48,8 @@ export default function EditAgentPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [boards, setBoards] = useState<Board[]>([]);
|
||||
const [boardId, setBoardId] = useState("");
|
||||
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
|
||||
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -59,9 +65,6 @@ export default function EditAgentPage() {
|
||||
}
|
||||
const data = (await response.json()) as Board[];
|
||||
setBoards(data);
|
||||
if (!boardId && data.length > 0) {
|
||||
setBoardId(data[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
}
|
||||
@@ -85,6 +88,12 @@ export default function EditAgentPage() {
|
||||
if (data.board_id) {
|
||||
setBoardId(data.board_id);
|
||||
}
|
||||
if (data.heartbeat_config?.every) {
|
||||
setHeartbeatEvery(data.heartbeat_config.every);
|
||||
}
|
||||
if (data.heartbeat_config?.target) {
|
||||
setHeartbeatTarget(data.heartbeat_config.target);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
@@ -98,6 +107,17 @@ export default function EditAgentPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSignedIn, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (boardId) return;
|
||||
if (agent?.board_id) {
|
||||
setBoardId(agent.board_id);
|
||||
return;
|
||||
}
|
||||
if (boards.length > 0) {
|
||||
setBoardId(boards[0].id);
|
||||
}
|
||||
}, [agent, boards, boardId]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isSignedIn || !agentId) return;
|
||||
@@ -120,7 +140,14 @@ export default function EditAgentPage() {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
body: JSON.stringify({ name: trimmed, board_id: boardId }),
|
||||
body: JSON.stringify({
|
||||
name: trimmed,
|
||||
board_id: boardId,
|
||||
heartbeat_config: {
|
||||
every: heartbeatEvery.trim() || "10m",
|
||||
target: heartbeatTarget,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to update agent.");
|
||||
@@ -195,6 +222,38 @@ export default function EditAgentPage() {
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-strong">
|
||||
Heartbeat interval
|
||||
</label>
|
||||
<Input
|
||||
value={heartbeatEvery}
|
||||
onChange={(event) => setHeartbeatEvery(event.target.value)}
|
||||
placeholder="e.g. 10m"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-quiet">
|
||||
Set how often this agent runs HEARTBEAT.md.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-strong">
|
||||
Heartbeat target
|
||||
</label>
|
||||
<Select
|
||||
value={heartbeatTarget}
|
||||
onValueChange={(value) => setHeartbeatTarget(value)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select target" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None (no outbound message)</SelectItem>
|
||||
<SelectItem value="last">Last channel</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
|
||||
{error}
|
||||
|
||||
@@ -39,6 +39,8 @@ export default function NewAgentPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [boards, setBoards] = useState<Board[]>([]);
|
||||
const [boardId, setBoardId] = useState<string>("");
|
||||
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
|
||||
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -89,7 +91,14 @@ export default function NewAgentPage() {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
body: JSON.stringify({ name: trimmed, board_id: boardId }),
|
||||
body: JSON.stringify({
|
||||
name: trimmed,
|
||||
board_id: boardId,
|
||||
heartbeat_config: {
|
||||
every: heartbeatEvery.trim() || "10m",
|
||||
target: heartbeatTarget,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to create agent.");
|
||||
@@ -165,6 +174,38 @@ export default function NewAgentPage() {
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-strong">
|
||||
Heartbeat interval
|
||||
</label>
|
||||
<Input
|
||||
value={heartbeatEvery}
|
||||
onChange={(event) => setHeartbeatEvery(event.target.value)}
|
||||
placeholder="e.g. 10m"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-quiet">
|
||||
Set how often this agent runs HEARTBEAT.md (e.g. 10m, 30m, 2h).
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-strong">
|
||||
Heartbeat target
|
||||
</label>
|
||||
<Select
|
||||
value={heartbeatTarget}
|
||||
onValueChange={(value) => setHeartbeatTarget(value)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select target" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None (no outbound message)</SelectItem>
|
||||
<SelectItem value="last">Last channel</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
|
||||
{error}
|
||||
|
||||
@@ -204,7 +204,7 @@ export default function AgentsPage() {
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to delete agent.");
|
||||
}
|
||||
setAgents((prev) => prev.filter((agent) => agent.id !== deleteTarget.id));
|
||||
await loadAgents();
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
|
||||
@@ -14,6 +14,7 @@ const STATUS_STYLES: Record<
|
||||
busy: "warning",
|
||||
provisioning: "warning",
|
||||
offline: "outline",
|
||||
deleting: "danger",
|
||||
};
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
|
||||
@@ -9,7 +9,7 @@ If this file is empty, skip heartbeat work.
|
||||
- BOARD_ID
|
||||
|
||||
## Schedule
|
||||
- Run this heartbeat every 10 minutes.
|
||||
- Schedule is controlled by gateway heartbeat config (default: every 10 minutes).
|
||||
- On first boot, send one immediate check-in before the schedule starts.
|
||||
|
||||
## On every heartbeat
|
||||
|
||||
Reference in New Issue
Block a user