fix(heatmap&import_agent): corrections
This commit is contained in:
parent
ac6320f6de
commit
e083e4e10c
|
|
@ -7,7 +7,9 @@ from typing import TYPE_CHECKING
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import Date as SADate
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
from sqlalchemy import cast as sa_cast
|
||||||
from sqlmodel import func, select
|
from sqlmodel import func, select
|
||||||
|
|
||||||
from app.api.deps import ORG_MEMBER_DEP, OrganizationContext
|
from app.api.deps import ORG_MEMBER_DEP, OrganizationContext
|
||||||
|
|
@ -267,9 +269,6 @@ async def get_forgejo_heatmap(
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> HeatmapResponse:
|
) -> HeatmapResponse:
|
||||||
"""Return per-day issue event counts (created + closed) for the last 365 days."""
|
"""Return per-day issue event counts (created + closed) for the last 365 days."""
|
||||||
from sqlalchemy import Date as SADate
|
|
||||||
from sqlalchemy import cast as sa_cast
|
|
||||||
|
|
||||||
if organization_id and organization_id != ctx.organization.id:
|
if organization_id and organization_id != ctx.organization.id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ from app.models.gateways import Gateway
|
||||||
from app.models.skills import GatewayInstalledSkill
|
from app.models.skills import GatewayInstalledSkill
|
||||||
from app.schemas.common import OkResponse
|
from app.schemas.common import OkResponse
|
||||||
from app.schemas.gateways import (
|
from app.schemas.gateways import (
|
||||||
|
GatewayAgentImportPreviewResponse,
|
||||||
|
GatewayAgentImportRequest,
|
||||||
|
GatewayAgentImportResponse,
|
||||||
GatewayCreate,
|
GatewayCreate,
|
||||||
GatewayRead,
|
GatewayRead,
|
||||||
GatewayTemplatesSyncResult,
|
GatewayTemplatesSyncResult,
|
||||||
|
|
@ -185,6 +188,50 @@ async def sync_gateway_templates(
|
||||||
return await service.sync_templates(gateway, query=sync_query, auth=auth)
|
return await service.sync_templates(gateway, query=sync_query, auth=auth)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{gateway_id}/agents/import-preview",
|
||||||
|
response_model=GatewayAgentImportPreviewResponse,
|
||||||
|
)
|
||||||
|
async def preview_import_gateway_agents(
|
||||||
|
gateway_id: UUID,
|
||||||
|
session: AsyncSession = SESSION_DEP,
|
||||||
|
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||||
|
) -> GatewayAgentImportPreviewResponse:
|
||||||
|
"""Preview existing gateway runtime agents that can be imported."""
|
||||||
|
service = GatewayAdminLifecycleService(session)
|
||||||
|
gateway = await service.require_gateway(
|
||||||
|
gateway_id=gateway_id,
|
||||||
|
organization_id=ctx.organization.id,
|
||||||
|
)
|
||||||
|
return await service.preview_gateway_agent_import(gateway=gateway)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{gateway_id}/agents/import",
|
||||||
|
response_model=GatewayAgentImportResponse,
|
||||||
|
)
|
||||||
|
async def import_gateway_agents(
|
||||||
|
gateway_id: UUID,
|
||||||
|
payload: GatewayAgentImportRequest,
|
||||||
|
session: AsyncSession = SESSION_DEP,
|
||||||
|
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||||
|
) -> GatewayAgentImportResponse:
|
||||||
|
"""Import selected existing gateway runtime agents into Pipeline."""
|
||||||
|
service = GatewayAdminLifecycleService(session)
|
||||||
|
gateway = await service.require_gateway(
|
||||||
|
gateway_id=gateway_id,
|
||||||
|
organization_id=ctx.organization.id,
|
||||||
|
)
|
||||||
|
return await service.import_gateway_agents(
|
||||||
|
gateway=gateway,
|
||||||
|
gateway_agent_ids=payload.gateway_agent_ids,
|
||||||
|
reconcile_after_import=payload.reconcile_after_import,
|
||||||
|
rotate_tokens=payload.rotate_tokens,
|
||||||
|
reset_sessions=payload.reset_sessions,
|
||||||
|
force_bootstrap=payload.force_bootstrap,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{gateway_id}", response_model=OkResponse)
|
@router.delete("/{gateway_id}", response_model=OkResponse)
|
||||||
async def delete_gateway(
|
async def delete_gateway(
|
||||||
gateway_id: UUID,
|
gateway_id: UUID,
|
||||||
|
|
|
||||||
|
|
@ -89,3 +89,62 @@ class GatewayTemplatesSyncResult(SQLModel):
|
||||||
agents_skipped: int
|
agents_skipped: int
|
||||||
main_updated: bool
|
main_updated: bool
|
||||||
errors: list[GatewayTemplatesSyncError] = Field(default_factory=list)
|
errors: list[GatewayTemplatesSyncError] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayAgentImportCandidate(SQLModel):
|
||||||
|
"""One gateway runtime agent candidate discovered for import."""
|
||||||
|
|
||||||
|
gateway_agent_id: str
|
||||||
|
gateway_agent_name: str | None = None
|
||||||
|
session_key: str | None = None
|
||||||
|
existing_agent_id: UUID | None = None
|
||||||
|
importable: bool
|
||||||
|
inferred_board_id: UUID | None = None
|
||||||
|
inferred_is_board_lead: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayAgentImportPreviewResponse(SQLModel):
|
||||||
|
"""Discovery preview response for importing existing gateway agents."""
|
||||||
|
|
||||||
|
gateway_id: UUID
|
||||||
|
discovered_count: int
|
||||||
|
importable_count: int
|
||||||
|
already_tracked_count: int
|
||||||
|
candidates: list[GatewayAgentImportCandidate] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayAgentImportRequest(SQLModel):
|
||||||
|
"""Request payload listing gateway runtime agents to import."""
|
||||||
|
|
||||||
|
gateway_agent_ids: list[str] = Field(default_factory=list, min_length=1)
|
||||||
|
reconcile_after_import: bool = False
|
||||||
|
rotate_tokens: bool = True
|
||||||
|
reset_sessions: bool = True
|
||||||
|
force_bootstrap: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayAgentReconcileError(SQLModel):
|
||||||
|
"""One reconciliation error for an imported agent."""
|
||||||
|
|
||||||
|
agent_id: UUID | None = None
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayAgentImportReconcileSummary(SQLModel):
|
||||||
|
"""Summary for optional post-import reconcile execution."""
|
||||||
|
|
||||||
|
attempted: int
|
||||||
|
updated: int
|
||||||
|
skipped: int
|
||||||
|
errors: list[GatewayAgentReconcileError] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayAgentImportResponse(SQLModel):
|
||||||
|
"""Import summary response for existing gateway runtime agents."""
|
||||||
|
|
||||||
|
gateway_id: UUID
|
||||||
|
imported_count: int
|
||||||
|
skipped_count: int
|
||||||
|
imported_agent_ids: list[UUID] = Field(default_factory=list)
|
||||||
|
skipped_gateway_agent_ids: list[str] = Field(default_factory=list)
|
||||||
|
reconcile: GatewayAgentImportReconcileSummary | None = None
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlmodel import col
|
from sqlmodel import col
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
from app.core.auth import AuthContext
|
from app.core.auth import AuthContext
|
||||||
from app.core.logging import TRACE_LEVEL
|
from app.core.logging import TRACE_LEVEL
|
||||||
|
|
@ -16,16 +18,25 @@ from app.db import crud
|
||||||
from app.models.activity_events import ActivityEvent
|
from app.models.activity_events import ActivityEvent
|
||||||
from app.models.agents import Agent
|
from app.models.agents import Agent
|
||||||
from app.models.approvals import Approval
|
from app.models.approvals import Approval
|
||||||
|
from app.models.boards import Board
|
||||||
from app.models.board_webhooks import BoardWebhook
|
from app.models.board_webhooks import BoardWebhook
|
||||||
from app.models.gateways import Gateway
|
from app.models.gateways import Gateway
|
||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
from app.schemas.gateways import GatewayTemplatesSyncResult
|
from app.schemas.gateways import (
|
||||||
|
GatewayAgentImportCandidate,
|
||||||
|
GatewayAgentImportPreviewResponse,
|
||||||
|
GatewayAgentImportReconcileSummary,
|
||||||
|
GatewayAgentImportResponse,
|
||||||
|
GatewayAgentReconcileError,
|
||||||
|
GatewayTemplatesSyncResult,
|
||||||
|
)
|
||||||
from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG
|
from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG
|
||||||
from app.services.openclaw.db_service import OpenClawDBService
|
from app.services.openclaw.db_service import OpenClawDBService
|
||||||
from app.services.openclaw.error_messages import normalize_gateway_error_message
|
from app.services.openclaw.error_messages import normalize_gateway_error_message
|
||||||
from app.services.openclaw.gateway_compat import check_gateway_version_compatibility
|
from app.services.openclaw.gateway_compat import check_gateway_version_compatibility
|
||||||
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
|
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
|
||||||
from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call
|
from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call
|
||||||
|
from app.services.openclaw.internal.agent_key import agent_key as resolve_agent_key
|
||||||
from app.services.openclaw.lifecycle_orchestrator import AgentLifecycleOrchestrator
|
from app.services.openclaw.lifecycle_orchestrator import AgentLifecycleOrchestrator
|
||||||
from app.services.openclaw.provisioning_db import (
|
from app.services.openclaw.provisioning_db import (
|
||||||
GatewayTemplateSyncOptions,
|
GatewayTemplateSyncOptions,
|
||||||
|
|
@ -40,6 +51,44 @@ if TYPE_CHECKING:
|
||||||
from app.models.users import User
|
from app.models.users import User
|
||||||
|
|
||||||
|
|
||||||
|
_LEAD_SESSION_KEY_RE = re.compile(
|
||||||
|
r"^agent:lead-(?P<board_id>[0-9a-fA-F-]{36}):main$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dict_list(value: object) -> list[dict[str, object]]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key in ("agents", "sessions", "items", "data"):
|
||||||
|
nested = value.get(key)
|
||||||
|
if isinstance(nested, list):
|
||||||
|
return [item for item in nested if isinstance(item, dict)]
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [item for item in value if isinstance(item, dict)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_session_key_for_runtime_agent(
|
||||||
|
runtime_agent_id: str,
|
||||||
|
session_by_agent_key: dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
existing = session_by_agent_key.get(runtime_agent_id)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
return f"agent:{runtime_agent_id}:main"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_lead_board_id(session_key: str) -> UUID | None:
|
||||||
|
matched = _LEAD_SESSION_KEY_RE.match(session_key)
|
||||||
|
if matched is None:
|
||||||
|
return None
|
||||||
|
board_id_raw = matched.group("board_id")
|
||||||
|
try:
|
||||||
|
return UUID(board_id_raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class AbstractGatewayMainAgentManager(ABC):
|
class AbstractGatewayMainAgentManager(ABC):
|
||||||
"""Abstract manager for gateway-main agent naming/profile behavior."""
|
"""Abstract manager for gateway-main agent naming/profile behavior."""
|
||||||
|
|
||||||
|
|
@ -287,6 +336,309 @@ class GatewayAdminLifecycleService(OpenClawDBService):
|
||||||
notify=False,
|
notify=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def preview_gateway_agent_import(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
gateway: Gateway,
|
||||||
|
) -> GatewayAgentImportPreviewResponse:
|
||||||
|
"""Return runtime agents that can be imported into Pipeline metadata."""
|
||||||
|
config = GatewayClientConfig(
|
||||||
|
url=gateway.url,
|
||||||
|
token=gateway.token,
|
||||||
|
allow_insecure_tls=gateway.allow_insecure_tls,
|
||||||
|
disable_device_pairing=gateway.disable_device_pairing,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
runtime_agents_raw = await openclaw_call("agents.list", config=config)
|
||||||
|
runtime_sessions_raw = await openclaw_call(
|
||||||
|
"sessions.list",
|
||||||
|
{"limit": 500},
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
except OpenClawGatewayError as exc:
|
||||||
|
detail = normalize_gateway_error_message(str(exc))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Gateway runtime query failed: {detail}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
runtime_agents = _as_dict_list(runtime_agents_raw)
|
||||||
|
runtime_sessions = _as_dict_list(runtime_sessions_raw)
|
||||||
|
|
||||||
|
session_by_agent_key: dict[str, str] = {}
|
||||||
|
for runtime_session in runtime_sessions:
|
||||||
|
key = runtime_session.get("key")
|
||||||
|
if not isinstance(key, str):
|
||||||
|
continue
|
||||||
|
parts = key.split(":")
|
||||||
|
if len(parts) < 2 or parts[0] != "agent":
|
||||||
|
continue
|
||||||
|
session_by_agent_key[parts[1]] = key
|
||||||
|
|
||||||
|
existing_agents = await Agent.objects.filter_by(gateway_id=gateway.id).all(self.session)
|
||||||
|
existing_by_session_key = {
|
||||||
|
(agent.openclaw_session_id or "").strip(): agent
|
||||||
|
for agent in existing_agents
|
||||||
|
if (agent.openclaw_session_id or "").strip()
|
||||||
|
}
|
||||||
|
existing_by_runtime_key = {
|
||||||
|
resolve_agent_key(agent): agent
|
||||||
|
for agent in existing_agents
|
||||||
|
}
|
||||||
|
|
||||||
|
lead_board_ids: set[UUID] = set()
|
||||||
|
normalized_runtime_agents: list[tuple[str, str | None, str]] = []
|
||||||
|
for runtime_agent in runtime_agents:
|
||||||
|
runtime_id = runtime_agent.get("id")
|
||||||
|
if not isinstance(runtime_id, str) or not runtime_id.strip():
|
||||||
|
continue
|
||||||
|
runtime_key = runtime_id.strip()
|
||||||
|
runtime_name = runtime_agent.get("name")
|
||||||
|
normalized_name = runtime_name.strip() if isinstance(runtime_name, str) else None
|
||||||
|
session_key = _derive_session_key_for_runtime_agent(runtime_key, session_by_agent_key)
|
||||||
|
lead_board_id = _parse_lead_board_id(session_key)
|
||||||
|
if lead_board_id is not None:
|
||||||
|
lead_board_ids.add(lead_board_id)
|
||||||
|
normalized_runtime_agents.append((runtime_key, normalized_name, session_key))
|
||||||
|
|
||||||
|
board_by_id: dict[UUID, Board] = {}
|
||||||
|
if lead_board_ids:
|
||||||
|
board_rows = (
|
||||||
|
await self.session.exec(
|
||||||
|
select(Board).where(
|
||||||
|
col(Board.id).in_(lead_board_ids),
|
||||||
|
col(Board.organization_id) == gateway.organization_id,
|
||||||
|
col(Board.gateway_id) == gateway.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
board_by_id = {board.id: board for board in board_rows}
|
||||||
|
|
||||||
|
candidates: list[GatewayAgentImportCandidate] = []
|
||||||
|
for runtime_id, runtime_name, session_key in normalized_runtime_agents:
|
||||||
|
tracked_agent = existing_by_session_key.get(session_key)
|
||||||
|
if tracked_agent is None:
|
||||||
|
tracked_agent = existing_by_runtime_key.get(runtime_id)
|
||||||
|
lead_board_id = _parse_lead_board_id(session_key)
|
||||||
|
inferred_board_id = (
|
||||||
|
lead_board_id if lead_board_id is not None and lead_board_id in board_by_id else None
|
||||||
|
)
|
||||||
|
inferred_is_board_lead = inferred_board_id is not None
|
||||||
|
candidates.append(
|
||||||
|
GatewayAgentImportCandidate(
|
||||||
|
gateway_agent_id=runtime_id,
|
||||||
|
gateway_agent_name=runtime_name,
|
||||||
|
session_key=session_key,
|
||||||
|
existing_agent_id=tracked_agent.id if tracked_agent is not None else None,
|
||||||
|
importable=tracked_agent is None,
|
||||||
|
inferred_board_id=inferred_board_id,
|
||||||
|
inferred_is_board_lead=inferred_is_board_lead,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates.sort(key=lambda item: item.gateway_agent_id.lower())
|
||||||
|
discovered_count = len(candidates)
|
||||||
|
importable_count = sum(1 for item in candidates if item.importable)
|
||||||
|
already_tracked_count = discovered_count - importable_count
|
||||||
|
return GatewayAgentImportPreviewResponse(
|
||||||
|
gateway_id=gateway.id,
|
||||||
|
discovered_count=discovered_count,
|
||||||
|
importable_count=importable_count,
|
||||||
|
already_tracked_count=already_tracked_count,
|
||||||
|
candidates=candidates,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def import_gateway_agents(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
gateway: Gateway,
|
||||||
|
gateway_agent_ids: list[str],
|
||||||
|
reconcile_after_import: bool = False,
|
||||||
|
rotate_tokens: bool = True,
|
||||||
|
reset_sessions: bool = True,
|
||||||
|
force_bootstrap: bool = True,
|
||||||
|
) -> GatewayAgentImportResponse:
|
||||||
|
"""Import selected runtime agents as Pipeline agent rows."""
|
||||||
|
preview = await self.preview_gateway_agent_import(gateway=gateway)
|
||||||
|
candidates_by_runtime_id = {
|
||||||
|
item.gateway_agent_id: item
|
||||||
|
for item in preview.candidates
|
||||||
|
}
|
||||||
|
seen: set[str] = set()
|
||||||
|
deduped_ids: list[str] = []
|
||||||
|
for runtime_id in gateway_agent_ids:
|
||||||
|
cleaned = runtime_id.strip()
|
||||||
|
if not cleaned or cleaned in seen:
|
||||||
|
continue
|
||||||
|
seen.add(cleaned)
|
||||||
|
deduped_ids.append(cleaned)
|
||||||
|
|
||||||
|
imported_agent_ids: list[UUID] = []
|
||||||
|
skipped_gateway_agent_ids: list[str] = []
|
||||||
|
for runtime_id in deduped_ids:
|
||||||
|
candidate = candidates_by_runtime_id.get(runtime_id)
|
||||||
|
if candidate is None or not candidate.importable:
|
||||||
|
skipped_gateway_agent_ids.append(runtime_id)
|
||||||
|
continue
|
||||||
|
imported = Agent(
|
||||||
|
name=candidate.gateway_agent_name or f"Imported {runtime_id}",
|
||||||
|
board_id=candidate.inferred_board_id,
|
||||||
|
gateway_id=gateway.id,
|
||||||
|
status="offline",
|
||||||
|
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
|
||||||
|
openclaw_session_id=candidate.session_key,
|
||||||
|
is_board_lead=candidate.inferred_is_board_lead,
|
||||||
|
)
|
||||||
|
self.session.add(imported)
|
||||||
|
await self.session.flush()
|
||||||
|
imported_agent_ids.append(imported.id)
|
||||||
|
|
||||||
|
await self.session.commit()
|
||||||
|
reconcile_summary: GatewayAgentImportReconcileSummary | None = None
|
||||||
|
if reconcile_after_import and imported_agent_ids:
|
||||||
|
reconcile_summary = await self.reconcile_imported_agents(
|
||||||
|
gateway=gateway,
|
||||||
|
agent_ids=imported_agent_ids,
|
||||||
|
rotate_tokens=rotate_tokens,
|
||||||
|
reset_sessions=reset_sessions,
|
||||||
|
force_bootstrap=force_bootstrap,
|
||||||
|
)
|
||||||
|
|
||||||
|
return GatewayAgentImportResponse(
|
||||||
|
gateway_id=gateway.id,
|
||||||
|
imported_count=len(imported_agent_ids),
|
||||||
|
skipped_count=len(skipped_gateway_agent_ids),
|
||||||
|
imported_agent_ids=imported_agent_ids,
|
||||||
|
skipped_gateway_agent_ids=skipped_gateway_agent_ids,
|
||||||
|
reconcile=reconcile_summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reconcile_imported_agents(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
gateway: Gateway,
|
||||||
|
agent_ids: list[UUID],
|
||||||
|
rotate_tokens: bool,
|
||||||
|
reset_sessions: bool,
|
||||||
|
force_bootstrap: bool,
|
||||||
|
) -> GatewayAgentImportReconcileSummary:
|
||||||
|
"""Run post-import lifecycle reconcile for imported agent rows."""
|
||||||
|
if not agent_ids:
|
||||||
|
return GatewayAgentImportReconcileSummary(
|
||||||
|
attempted=0,
|
||||||
|
updated=0,
|
||||||
|
skipped=0,
|
||||||
|
errors=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
agents = (
|
||||||
|
await self.session.exec(
|
||||||
|
select(Agent).where(
|
||||||
|
col(Agent.id).in_(agent_ids),
|
||||||
|
col(Agent.gateway_id) == gateway.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
boards_by_id: dict[UUID, Board] = {}
|
||||||
|
board_ids = [agent.board_id for agent in agents if agent.board_id is not None]
|
||||||
|
if board_ids:
|
||||||
|
board_rows = (
|
||||||
|
await self.session.exec(
|
||||||
|
select(Board).where(
|
||||||
|
col(Board.id).in_(board_ids),
|
||||||
|
col(Board.gateway_id) == gateway.id,
|
||||||
|
col(Board.organization_id) == gateway.organization_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
boards_by_id = {board.id: board for board in board_rows}
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
errors: list[GatewayAgentReconcileError] = []
|
||||||
|
main_session_key = GatewayAgentIdentity.session_key(gateway)
|
||||||
|
for agent in agents:
|
||||||
|
board: Board | None = None
|
||||||
|
if agent.board_id is not None:
|
||||||
|
board = boards_by_id.get(agent.board_id)
|
||||||
|
if board is None:
|
||||||
|
skipped += 1
|
||||||
|
errors.append(
|
||||||
|
GatewayAgentReconcileError(
|
||||||
|
agent_id=agent.id,
|
||||||
|
message="Skipping reconcile: board is missing or not mapped to gateway.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
elif (agent.openclaw_session_id or "").strip() != main_session_key:
|
||||||
|
skipped += 1
|
||||||
|
errors.append(
|
||||||
|
GatewayAgentReconcileError(
|
||||||
|
agent_id=agent.id,
|
||||||
|
message=(
|
||||||
|
"Skipping reconcile: boardless imported agent is not the gateway-main "
|
||||||
|
"session and cannot be reprovisioned automatically."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not rotate_tokens:
|
||||||
|
skipped += 1
|
||||||
|
errors.append(
|
||||||
|
GatewayAgentReconcileError(
|
||||||
|
agent_id=agent.id,
|
||||||
|
message=(
|
||||||
|
"Skipping reconcile: rotate_tokens=false is unsupported for imported "
|
||||||
|
"agents without an existing backend token hash."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
await AgentLifecycleOrchestrator(self.session).run_lifecycle(
|
||||||
|
gateway=gateway,
|
||||||
|
agent_id=agent.id,
|
||||||
|
board=board,
|
||||||
|
user=None,
|
||||||
|
action="update",
|
||||||
|
auth_token=None,
|
||||||
|
force_bootstrap=force_bootstrap,
|
||||||
|
reset_session=reset_sessions,
|
||||||
|
wake=False,
|
||||||
|
deliver_wakeup=False,
|
||||||
|
wakeup_verb="updated",
|
||||||
|
clear_confirm_token=False,
|
||||||
|
raise_gateway_errors=True,
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
skipped += 1
|
||||||
|
message = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
||||||
|
errors.append(
|
||||||
|
GatewayAgentReconcileError(
|
||||||
|
agent_id=agent.id,
|
||||||
|
message=f"Reconcile failed: {message}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
skipped += 1
|
||||||
|
errors.append(
|
||||||
|
GatewayAgentReconcileError(
|
||||||
|
agent_id=agent.id,
|
||||||
|
message=f"Reconcile failed: {exc}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
return GatewayAgentImportReconcileSummary(
|
||||||
|
attempted=len(agents),
|
||||||
|
updated=updated,
|
||||||
|
skipped=skipped,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
async def clear_agent_foreign_keys(self, *, agent_id: UUID) -> None:
|
async def clear_agent_foreign_keys(self, *, agent_id: UUID) -> None:
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
await crud.update_where(
|
await crud.update_where(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""Add unique constraint to agents.openclaw_session_id.
|
||||||
|
|
||||||
|
Revision ID: c1d2e3f4a5b6
|
||||||
|
Revises: b6c7d8e9f0a1
|
||||||
|
Create Date: 2026-05-20 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "c1d2e3f4a5b6"
|
||||||
|
down_revision = "b6c7d8e9f0a1"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Drop the plain index first, then replace with a unique one.
|
||||||
|
# NULL values are excluded from unique constraints in PostgreSQL so
|
||||||
|
# multiple agents with NULL openclaw_session_id are still allowed.
|
||||||
|
op.drop_index("ix_agents_openclaw_session_id", table_name="agents")
|
||||||
|
op.create_index(
|
||||||
|
"ix_agents_openclaw_session_id",
|
||||||
|
"agents",
|
||||||
|
["openclaw_session_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where="openclaw_session_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_agents_openclaw_session_id", table_name="agents")
|
||||||
|
op.create_index(
|
||||||
|
"ix_agents_openclaw_session_id",
|
||||||
|
"agents",
|
||||||
|
["openclaw_session_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,448 @@
|
||||||
|
# ruff: noqa: INP001
|
||||||
|
"""Integration tests for gateway runtime agent import preview and import APIs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import APIRouter, FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
||||||
|
from sqlmodel import SQLModel, select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app import models as _models
|
||||||
|
from app.api.deps import require_org_admin
|
||||||
|
from app.api.gateways import router as gateways_router
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models.agents import Agent
|
||||||
|
from app.models.boards import Board
|
||||||
|
from app.models.gateways import Gateway
|
||||||
|
from app.models.organization_members import OrganizationMember
|
||||||
|
from app.models.organizations import Organization
|
||||||
|
from app.models.users import User
|
||||||
|
import app.services.openclaw.admin_service as admin_service
|
||||||
|
from app.core.agent_tokens import hash_agent_token
|
||||||
|
from app.services.organizations import OrganizationContext
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_engine() -> AsyncEngine:
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _build_test_app(
|
||||||
|
session_maker: async_sessionmaker[AsyncSession],
|
||||||
|
ctx: OrganizationContext,
|
||||||
|
) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
api_v1 = APIRouter(prefix="/api/v1")
|
||||||
|
api_v1.include_router(gateways_router)
|
||||||
|
app.include_router(api_v1)
|
||||||
|
|
||||||
|
async def _override_get_session() -> AsyncSession:
|
||||||
|
async with session_maker() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async def _override_require_org_admin() -> OrganizationContext:
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = _override_get_session
|
||||||
|
app.dependency_overrides[require_org_admin] = _override_require_org_admin
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gateway_agent_import_preview_reports_importable_and_tracked(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
organization = Organization(id=uuid4(), name="Pipeline")
|
||||||
|
user = User(id=uuid4(), clerk_user_id="user_123", email="user@example.com")
|
||||||
|
member = OrganizationMember(
|
||||||
|
id=uuid4(),
|
||||||
|
organization_id=organization.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role="owner",
|
||||||
|
all_boards_read=True,
|
||||||
|
all_boards_write=True,
|
||||||
|
)
|
||||||
|
gateway = Gateway(
|
||||||
|
id=uuid4(),
|
||||||
|
organization_id=organization.id,
|
||||||
|
name="Main Gateway",
|
||||||
|
url="ws://gateway.example.local",
|
||||||
|
workspace_root="/workspace",
|
||||||
|
)
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
organization_id=organization.id,
|
||||||
|
name="Platform",
|
||||||
|
slug="platform",
|
||||||
|
gateway_id=gateway.id,
|
||||||
|
)
|
||||||
|
tracked_agent = Agent(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=None,
|
||||||
|
gateway_id=gateway.id,
|
||||||
|
name="Already Tracked",
|
||||||
|
openclaw_session_id="agent:existing-main:main",
|
||||||
|
is_board_lead=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
lead_runtime_id = f"lead-{board.id}"
|
||||||
|
|
||||||
|
async def _fake_openclaw_call(
|
||||||
|
method: str,
|
||||||
|
params: object | None = None,
|
||||||
|
*,
|
||||||
|
config: object,
|
||||||
|
) -> object:
|
||||||
|
_ = params, config
|
||||||
|
if method == "agents.list":
|
||||||
|
return {
|
||||||
|
"agents": [
|
||||||
|
{"id": "existing-main", "name": "Existing Main"},
|
||||||
|
{"id": "legacy-worker", "name": "Legacy Worker"},
|
||||||
|
{"id": lead_runtime_id, "name": "Legacy Lead"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if method == "sessions.list":
|
||||||
|
return {
|
||||||
|
"sessions": [
|
||||||
|
{"key": "agent:existing-main:main"},
|
||||||
|
{"key": "agent:legacy-worker:main"},
|
||||||
|
{"key": f"agent:{lead_runtime_id}:main"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
raise AssertionError(f"Unexpected method {method}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(admin_service, "openclaw_call", _fake_openclaw_call)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session_maker() as session:
|
||||||
|
session.add(organization)
|
||||||
|
session.add(user)
|
||||||
|
session.add(member)
|
||||||
|
session.add(gateway)
|
||||||
|
session.add(board)
|
||||||
|
session.add(tracked_agent)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
app = _build_test_app(
|
||||||
|
session_maker,
|
||||||
|
OrganizationContext(organization=organization, member=member),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"/api/v1/gateways/{gateway.id}/agents/import-preview"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["discovered_count"] == 3
|
||||||
|
assert data["importable_count"] == 2
|
||||||
|
assert data["already_tracked_count"] == 1
|
||||||
|
|
||||||
|
candidates = {item["gateway_agent_id"]: item for item in data["candidates"]}
|
||||||
|
assert candidates["existing-main"]["importable"] is False
|
||||||
|
assert candidates["existing-main"]["existing_agent_id"] == str(tracked_agent.id)
|
||||||
|
assert candidates["legacy-worker"]["importable"] is True
|
||||||
|
assert candidates[lead_runtime_id]["importable"] is True
|
||||||
|
assert candidates[lead_runtime_id]["inferred_board_id"] == str(board.id)
|
||||||
|
assert candidates[lead_runtime_id]["inferred_is_board_lead"] is True
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gateway_agent_import_creates_selected_candidates(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
organization = Organization(id=uuid4(), name="Pipeline")
|
||||||
|
user = User(id=uuid4(), clerk_user_id="user_123", email="user@example.com")
|
||||||
|
member = OrganizationMember(
|
||||||
|
id=uuid4(),
|
||||||
|
organization_id=organization.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role="owner",
|
||||||
|
all_boards_read=True,
|
||||||
|
all_boards_write=True,
|
||||||
|
)
|
||||||
|
gateway = Gateway(
|
||||||
|
id=uuid4(),
|
||||||
|
organization_id=organization.id,
|
||||||
|
name="Main Gateway",
|
||||||
|
url="ws://gateway.example.local",
|
||||||
|
workspace_root="/workspace",
|
||||||
|
)
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
organization_id=organization.id,
|
||||||
|
name="Platform",
|
||||||
|
slug="platform",
|
||||||
|
gateway_id=gateway.id,
|
||||||
|
)
|
||||||
|
tracked_agent = Agent(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=None,
|
||||||
|
gateway_id=gateway.id,
|
||||||
|
name="Already Tracked",
|
||||||
|
openclaw_session_id="agent:existing-main:main",
|
||||||
|
is_board_lead=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
lead_runtime_id = f"lead-{board.id}"
|
||||||
|
|
||||||
|
async def _fake_openclaw_call(
|
||||||
|
method: str,
|
||||||
|
params: object | None = None,
|
||||||
|
*,
|
||||||
|
config: object,
|
||||||
|
) -> object:
|
||||||
|
_ = params, config
|
||||||
|
if method == "agents.list":
|
||||||
|
return {
|
||||||
|
"agents": [
|
||||||
|
{"id": "existing-main", "name": "Existing Main"},
|
||||||
|
{"id": "legacy-worker", "name": "Legacy Worker"},
|
||||||
|
{"id": lead_runtime_id, "name": "Legacy Lead"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if method == "sessions.list":
|
||||||
|
return {
|
||||||
|
"sessions": [
|
||||||
|
{"key": "agent:existing-main:main"},
|
||||||
|
{"key": "agent:legacy-worker:main"},
|
||||||
|
{"key": f"agent:{lead_runtime_id}:main"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
raise AssertionError(f"Unexpected method {method}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(admin_service, "openclaw_call", _fake_openclaw_call)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session_maker() as session:
|
||||||
|
session.add(organization)
|
||||||
|
session.add(user)
|
||||||
|
session.add(member)
|
||||||
|
session.add(gateway)
|
||||||
|
session.add(board)
|
||||||
|
session.add(tracked_agent)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
app = _build_test_app(
|
||||||
|
session_maker,
|
||||||
|
OrganizationContext(organization=organization, member=member),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/gateways/{gateway.id}/agents/import",
|
||||||
|
json={
|
||||||
|
"gateway_agent_ids": [
|
||||||
|
"existing-main",
|
||||||
|
"legacy-worker",
|
||||||
|
lead_runtime_id,
|
||||||
|
"unknown-agent",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["imported_count"] == 2
|
||||||
|
assert data["skipped_count"] == 2
|
||||||
|
assert set(data["skipped_gateway_agent_ids"]) == {"existing-main", "unknown-agent"}
|
||||||
|
assert len(data["imported_agent_ids"]) == 2
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
gateway_agents = (
|
||||||
|
await session.exec(
|
||||||
|
select(Agent).where(Agent.gateway_id == gateway.id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
# original tracked + imported worker + imported lead
|
||||||
|
assert len(gateway_agents) == 3
|
||||||
|
by_name = {agent.name: agent for agent in gateway_agents}
|
||||||
|
imported_worker = by_name["Legacy Worker"]
|
||||||
|
imported_lead = by_name["Legacy Lead"]
|
||||||
|
assert imported_worker.board_id is None
|
||||||
|
assert imported_worker.is_board_lead is False
|
||||||
|
assert imported_worker.openclaw_session_id == "agent:legacy-worker:main"
|
||||||
|
assert imported_lead.board_id == board.id
|
||||||
|
assert imported_lead.is_board_lead is True
|
||||||
|
assert imported_lead.openclaw_session_id == f"agent:{lead_runtime_id}:main"
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gateway_agent_import_reconcile_updates_supported_agents(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
organization = Organization(id=uuid4(), name="Pipeline")
|
||||||
|
user = User(id=uuid4(), clerk_user_id="user_123", email="user@example.com")
|
||||||
|
member = OrganizationMember(
|
||||||
|
id=uuid4(),
|
||||||
|
organization_id=organization.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role="owner",
|
||||||
|
all_boards_read=True,
|
||||||
|
all_boards_write=True,
|
||||||
|
)
|
||||||
|
gateway = Gateway(
|
||||||
|
id=uuid4(),
|
||||||
|
organization_id=organization.id,
|
||||||
|
name="Main Gateway",
|
||||||
|
url="ws://gateway.example.local",
|
||||||
|
workspace_root="/workspace",
|
||||||
|
)
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
organization_id=organization.id,
|
||||||
|
name="Platform",
|
||||||
|
slug="platform",
|
||||||
|
gateway_id=gateway.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
lead_runtime_id = f"lead-{board.id}"
|
||||||
|
|
||||||
|
async def _fake_openclaw_call(
|
||||||
|
method: str,
|
||||||
|
params: object | None = None,
|
||||||
|
*,
|
||||||
|
config: object,
|
||||||
|
) -> object:
|
||||||
|
_ = params, config
|
||||||
|
if method == "agents.list":
|
||||||
|
return {
|
||||||
|
"agents": [
|
||||||
|
{"id": "legacy-worker", "name": "Legacy Worker"},
|
||||||
|
{"id": lead_runtime_id, "name": "Legacy Lead"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if method == "sessions.list":
|
||||||
|
return {
|
||||||
|
"sessions": [
|
||||||
|
{"key": "agent:legacy-worker:main"},
|
||||||
|
{"key": f"agent:{lead_runtime_id}:main"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
raise AssertionError(f"Unexpected method {method}")
|
||||||
|
|
||||||
|
async def _fake_run_lifecycle(
|
||||||
|
self: object,
|
||||||
|
*,
|
||||||
|
gateway: Gateway,
|
||||||
|
agent_id: object,
|
||||||
|
board: Board | None,
|
||||||
|
user: object,
|
||||||
|
action: str,
|
||||||
|
auth_token: str | None,
|
||||||
|
force_bootstrap: bool,
|
||||||
|
reset_session: bool,
|
||||||
|
wake: bool,
|
||||||
|
deliver_wakeup: bool,
|
||||||
|
wakeup_verb: str | None,
|
||||||
|
clear_confirm_token: bool,
|
||||||
|
raise_gateway_errors: bool,
|
||||||
|
) -> Agent:
|
||||||
|
_ = (
|
||||||
|
user,
|
||||||
|
action,
|
||||||
|
auth_token,
|
||||||
|
force_bootstrap,
|
||||||
|
reset_session,
|
||||||
|
wake,
|
||||||
|
deliver_wakeup,
|
||||||
|
wakeup_verb,
|
||||||
|
clear_confirm_token,
|
||||||
|
raise_gateway_errors,
|
||||||
|
)
|
||||||
|
assert board is not None
|
||||||
|
session = getattr(self, "session")
|
||||||
|
agent = await session.get(Agent, agent_id)
|
||||||
|
assert agent is not None
|
||||||
|
assert agent.gateway_id == gateway.id
|
||||||
|
agent.status = "online"
|
||||||
|
agent.agent_token_hash = hash_agent_token("rotated-token")
|
||||||
|
session.add(agent)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(agent)
|
||||||
|
return agent
|
||||||
|
|
||||||
|
monkeypatch.setattr(admin_service, "openclaw_call", _fake_openclaw_call)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
admin_service.AgentLifecycleOrchestrator,
|
||||||
|
"run_lifecycle",
|
||||||
|
_fake_run_lifecycle,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session_maker() as session:
|
||||||
|
session.add(organization)
|
||||||
|
session.add(user)
|
||||||
|
session.add(member)
|
||||||
|
session.add(gateway)
|
||||||
|
session.add(board)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
app = _build_test_app(
|
||||||
|
session_maker,
|
||||||
|
OrganizationContext(organization=organization, member=member),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/gateways/{gateway.id}/agents/import",
|
||||||
|
json={
|
||||||
|
"gateway_agent_ids": ["legacy-worker", lead_runtime_id],
|
||||||
|
"reconcile_after_import": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["imported_count"] == 2
|
||||||
|
assert data["reconcile"] is not None
|
||||||
|
assert data["reconcile"]["attempted"] == 2
|
||||||
|
assert data["reconcile"]["updated"] == 1
|
||||||
|
assert data["reconcile"]["skipped"] == 1
|
||||||
|
assert len(data["reconcile"]["errors"]) == 1
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
agents = (
|
||||||
|
await session.exec(
|
||||||
|
select(Agent).where(Agent.gateway_id == gateway.id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
by_name = {agent.name: agent for agent in agents}
|
||||||
|
assert by_name["Legacy Lead"].status == "online"
|
||||||
|
assert by_name["Legacy Lead"].agent_token_hash
|
||||||
|
assert by_name["Legacy Worker"].status == "offline"
|
||||||
|
assert by_name["Legacy Worker"].agent_token_hash is None
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
@ -8,6 +8,7 @@ import Link from "next/link";
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { GatewayAgentImportDialog } from "@/components/gateways/GatewayAgentImportDialog";
|
||||||
import { GatewaysTable } from "@/components/gateways/GatewaysTable";
|
import { GatewaysTable } from "@/components/gateways/GatewaysTable";
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
@ -20,6 +21,7 @@ import {
|
||||||
useDeleteGatewayApiV1GatewaysGatewayIdDelete,
|
useDeleteGatewayApiV1GatewaysGatewayIdDelete,
|
||||||
useListGatewaysApiV1GatewaysGet,
|
useListGatewaysApiV1GatewaysGet,
|
||||||
} from "@/api/generated/gateways/gateways";
|
} from "@/api/generated/gateways/gateways";
|
||||||
|
import { getListAgentsApiV1AgentsGetQueryKey } from "@/api/generated/agents/agents";
|
||||||
import { createOptimisticListDeleteMutation } from "@/lib/list-delete";
|
import { createOptimisticListDeleteMutation } from "@/lib/list-delete";
|
||||||
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
||||||
import type { GatewayRead } from "@/api/generated/model";
|
import type { GatewayRead } from "@/api/generated/model";
|
||||||
|
|
@ -38,8 +40,10 @@ export default function GatewaysPage() {
|
||||||
|
|
||||||
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<GatewayRead | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<GatewayRead | null>(null);
|
||||||
|
const [importTarget, setImportTarget] = useState<GatewayRead | null>(null);
|
||||||
|
|
||||||
const gatewaysKey = getListGatewaysApiV1GatewaysGetQueryKey();
|
const gatewaysKey = getListGatewaysApiV1GatewaysGetQueryKey();
|
||||||
|
const agentsKey = getListAgentsApiV1AgentsGetQueryKey();
|
||||||
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
|
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
|
||||||
listGatewaysApiV1GatewaysGetResponse,
|
listGatewaysApiV1GatewaysGetResponse,
|
||||||
ApiError
|
ApiError
|
||||||
|
|
@ -87,6 +91,11 @@ export default function GatewaysPage() {
|
||||||
deleteMutation.mutate({ gatewayId: deleteTarget.id });
|
deleteMutation.mutate({ gatewayId: deleteTarget.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImported = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: gatewaysKey });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: agentsKey });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
|
|
@ -122,6 +131,7 @@ export default function GatewaysPage() {
|
||||||
showActions
|
showActions
|
||||||
stickyHeader
|
stickyHeader
|
||||||
onDelete={setDeleteTarget}
|
onDelete={setDeleteTarget}
|
||||||
|
onImport={setImportTarget}
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: "No gateways yet",
|
title: "No gateways yet",
|
||||||
description:
|
description:
|
||||||
|
|
@ -155,6 +165,17 @@ export default function GatewaysPage() {
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
isConfirming={deleteMutation.isPending}
|
isConfirming={deleteMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GatewayAgentImportDialog
|
||||||
|
open={Boolean(importTarget)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setImportTarget(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
gateway={importTarget}
|
||||||
|
onImported={handleImported}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,345 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { GatewayRead } from "@/api/generated/model";
|
||||||
|
import { ApiError } from "@/api/mutator";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
getGatewayAgentImportPreview,
|
||||||
|
importGatewayAgents,
|
||||||
|
type GatewayAgentImportCandidate,
|
||||||
|
type GatewayAgentImportPreviewResponse,
|
||||||
|
type GatewayAgentImportResponse,
|
||||||
|
} from "@/lib/api-gateway-agent-import";
|
||||||
|
|
||||||
|
type ImportMode = "import_only" | "import_and_reconcile";
|
||||||
|
|
||||||
|
type GatewayAgentImportDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
gateway: GatewayRead | null;
|
||||||
|
onImported: (result: GatewayAgentImportResponse) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function shortUuid(value: string | null): string {
|
||||||
|
if (!value) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
return value.length > 14 ? `${value.slice(0, 6)}…${value.slice(-6)}` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GatewayAgentImportDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
gateway,
|
||||||
|
onImported,
|
||||||
|
}: GatewayAgentImportDialogProps) {
|
||||||
|
const [manualSelectedAgentIds, setManualSelectedAgentIds] = useState<Set<string> | null>(null);
|
||||||
|
const [importMode, setImportMode] = useState<ImportMode>("import_and_reconcile");
|
||||||
|
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const previewQuery = useQuery<GatewayAgentImportPreviewResponse, ApiError>({
|
||||||
|
queryKey: ["gateway-agent-import-preview", gateway?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!gateway) {
|
||||||
|
throw new Error("Gateway is required");
|
||||||
|
}
|
||||||
|
return getGatewayAgentImportPreview(gateway.id);
|
||||||
|
},
|
||||||
|
enabled: open && Boolean(gateway?.id),
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
});
|
||||||
|
|
||||||
|
const importableCandidates = useMemo(
|
||||||
|
() =>
|
||||||
|
(previewQuery.data?.candidates ?? []).filter(
|
||||||
|
(candidate) => candidate.importable,
|
||||||
|
),
|
||||||
|
[previewQuery.data?.candidates],
|
||||||
|
);
|
||||||
|
const defaultSelectedAgentIds = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
importableCandidates.map((candidate) => candidate.gateway_agent_id),
|
||||||
|
),
|
||||||
|
[importableCandidates],
|
||||||
|
);
|
||||||
|
const selectedAgentIds = manualSelectedAgentIds ?? defaultSelectedAgentIds;
|
||||||
|
|
||||||
|
const importMutation = useMutation<
|
||||||
|
GatewayAgentImportResponse,
|
||||||
|
ApiError,
|
||||||
|
string[]
|
||||||
|
>({
|
||||||
|
mutationFn: async (gatewayAgentIds) => {
|
||||||
|
if (!gateway) {
|
||||||
|
throw new Error("Gateway is required");
|
||||||
|
}
|
||||||
|
return importGatewayAgents(gateway.id, {
|
||||||
|
gateway_agent_ids: gatewayAgentIds,
|
||||||
|
reconcile_after_import: importMode === "import_and_reconcile",
|
||||||
|
rotate_tokens: importMode === "import_and_reconcile",
|
||||||
|
reset_sessions: importMode === "import_and_reconcile",
|
||||||
|
force_bootstrap: importMode === "import_and_reconcile",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
const reconcileText = result.reconcile
|
||||||
|
? ` Reconcile updated ${result.reconcile.updated}/${result.reconcile.attempted}.`
|
||||||
|
: "";
|
||||||
|
setResultMessage(
|
||||||
|
`Imported ${result.imported_count} agent${result.imported_count === 1 ? "" : "s"}.${reconcileText}`,
|
||||||
|
);
|
||||||
|
onImported(result);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCount = selectedAgentIds.size;
|
||||||
|
const canImport = selectedCount > 0 && !importMutation.isPending;
|
||||||
|
|
||||||
|
const toggleCandidate = (candidate: GatewayAgentImportCandidate) => {
|
||||||
|
if (!candidate.importable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setManualSelectedAgentIds((prev) => {
|
||||||
|
const next = new Set(prev ?? selectedAgentIds);
|
||||||
|
if (next.has(candidate.gateway_agent_id)) {
|
||||||
|
next.delete(candidate.gateway_agent_id);
|
||||||
|
} else {
|
||||||
|
next.add(candidate.gateway_agent_id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllImportable = () => {
|
||||||
|
setManualSelectedAgentIds(
|
||||||
|
new Set(importableCandidates.map((candidate) => candidate.gateway_agent_id)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setManualSelectedAgentIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
importMutation.mutate([...selectedAgentIds]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
setManualSelectedAgentIds(null);
|
||||||
|
setResultMessage(null);
|
||||||
|
}
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Existing Gateway Agents</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Preview gateway runtime agents for <strong>{gateway?.name}</strong>,
|
||||||
|
then import selected entries into Pipeline.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm">
|
||||||
|
<div className="text-muted-foreground">Discovered</div>
|
||||||
|
<div className="text-base font-semibold text-foreground">
|
||||||
|
{previewQuery.data?.discovered_count ?? 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm">
|
||||||
|
<div className="text-muted-foreground">Importable</div>
|
||||||
|
<div className="text-base font-semibold text-foreground">
|
||||||
|
{previewQuery.data?.importable_count ?? 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm">
|
||||||
|
<div className="text-muted-foreground">Already Tracked</div>
|
||||||
|
<div className="text-base font-semibold text-foreground">
|
||||||
|
{previewQuery.data?.already_tracked_count ?? 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Selected {selectedCount} of {importableCandidates.length} importable
|
||||||
|
agents
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={selectAllImportable}
|
||||||
|
>
|
||||||
|
Select All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearSelection}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[360px] overflow-auto rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 bg-muted/70 text-xs uppercase text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left">Agent</th>
|
||||||
|
<th className="px-3 py-2 text-left">Session</th>
|
||||||
|
<th className="px-3 py-2 text-left">Board</th>
|
||||||
|
<th className="px-3 py-2 text-left">Status</th>
|
||||||
|
<th className="px-3 py-2 text-right">Select</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{previewQuery.isLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-3 py-6 text-center text-muted-foreground">
|
||||||
|
Loading import preview…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : previewQuery.error ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-3 py-6 text-center text-red-500">
|
||||||
|
{previewQuery.error.message}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : previewQuery.data?.candidates.length ? (
|
||||||
|
previewQuery.data.candidates.map((candidate) => {
|
||||||
|
const isSelected = selectedAgentIds.has(
|
||||||
|
candidate.gateway_agent_id,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={candidate.gateway_agent_id}
|
||||||
|
className="border-t border-border"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="font-medium text-foreground">
|
||||||
|
{candidate.gateway_agent_name ?? candidate.gateway_agent_id}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{candidate.gateway_agent_id}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{candidate.session_key ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{shortUuid(candidate.inferred_board_id)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{candidate.importable ? (
|
||||||
|
<Badge variant="outline">Importable</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge>Tracked</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{candidate.importable ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={isSelected ? "primary" : "secondary"}
|
||||||
|
onClick={() => toggleCandidate(candidate)}
|
||||||
|
>
|
||||||
|
{isSelected ? "Selected" : "Select"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-3 py-6 text-center text-muted-foreground">
|
||||||
|
No gateway agents found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:max-w-sm">
|
||||||
|
<label className="text-sm font-medium text-foreground">Import Mode</label>
|
||||||
|
<Select
|
||||||
|
value={importMode}
|
||||||
|
onValueChange={(value) => setImportMode(value as ImportMode)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="import_and_reconcile">
|
||||||
|
Import + Reconcile
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="import_only">Import Only</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Reconcile rotates tokens and pushes templates so imported agents can
|
||||||
|
check in with Pipeline auth.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{resultMessage ? (
|
||||||
|
<p className="text-sm text-foreground">{resultMessage}</p>
|
||||||
|
) : null}
|
||||||
|
{importMutation.error ? (
|
||||||
|
<p className="text-sm text-red-500">{importMutation.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleImport} disabled={!canImport}>
|
||||||
|
{importMutation.isPending ? "Importing…" : "Import Selected"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ type GatewaysTableProps = {
|
||||||
columnOrder?: string[];
|
columnOrder?: string[];
|
||||||
disableSorting?: boolean;
|
disableSorting?: boolean;
|
||||||
onDelete?: (gateway: GatewayRead) => void;
|
onDelete?: (gateway: GatewayRead) => void;
|
||||||
|
onImport?: (gateway: GatewayRead) => void;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
emptyState?: Omit<DataTableEmptyState, "icon"> & {
|
emptyState?: Omit<DataTableEmptyState, "icon"> & {
|
||||||
icon?: DataTableEmptyState["icon"];
|
icon?: DataTableEmptyState["icon"];
|
||||||
|
|
@ -62,6 +63,7 @@ export function GatewaysTable({
|
||||||
columnOrder,
|
columnOrder,
|
||||||
disableSorting = false,
|
disableSorting = false,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onImport,
|
||||||
emptyMessage = "No gateways found.",
|
emptyMessage = "No gateways found.",
|
||||||
emptyState,
|
emptyState,
|
||||||
}: GatewaysTableProps) {
|
}: GatewaysTableProps) {
|
||||||
|
|
@ -139,8 +141,31 @@ export function GatewaysTable({
|
||||||
rowActions={
|
rowActions={
|
||||||
showActions
|
showActions
|
||||||
? {
|
? {
|
||||||
getEditHref: (gateway) => `/gateways/${gateway.id}/edit`,
|
actions: [
|
||||||
onDelete,
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
href: (gateway) => `/gateways/${gateway.id}/edit`,
|
||||||
|
},
|
||||||
|
...(onImport
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "import-agents",
|
||||||
|
label: "Import Agents",
|
||||||
|
onClick: onImport,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(onDelete
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
onClick: onDelete,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,12 +51,13 @@ export function ForgejoHeatmap({
|
||||||
const { weeks, monthLabels } = useMemo(() => {
|
const { weeks, monthLabels } = useMemo(() => {
|
||||||
const data = new Map<string, number>(days.map((d) => [d.date, d.count]));
|
const data = new Map<string, number>(days.map((d) => [d.date, d.count]));
|
||||||
|
|
||||||
// Start on the Sunday that is ~52 weeks before today
|
// Start on the Sunday 52 weeks before the current week's Sunday,
|
||||||
|
// so the last column always contains today and future cells are clipped.
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const start = new Date(today);
|
const start = new Date(today);
|
||||||
start.setDate(start.getDate() - WEEKS * 7 + 1);
|
start.setDate(start.getDate() - start.getDay()); // rewind to this week's Sunday
|
||||||
start.setDate(start.getDate() - start.getDay()); // rewind to Sunday
|
start.setDate(start.getDate() - (WEEKS - 1) * 7); // go back 52 more weeks
|
||||||
|
|
||||||
type Cell = { date: string; count: number; future: boolean };
|
type Cell = { date: string; count: number; future: boolean };
|
||||||
const builtWeeks: Cell[][] = [];
|
const builtWeeks: Cell[][] = [];
|
||||||
|
|
@ -96,7 +97,7 @@ export function ForgejoHeatmap({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ overflowX: "auto" }}>
|
<div style={{ overflowX: "auto" }} className="flex justify-center">
|
||||||
<svg
|
<svg
|
||||||
width={svgW}
|
width={svgW}
|
||||||
height={svgH}
|
height={svgH}
|
||||||
|
|
@ -117,12 +118,14 @@ export function ForgejoHeatmap({
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Day-of-week labels: Mon=row1, Wed=row3, Fri=row5 */}
|
{/* Day-of-week labels: Mon=row1, Wed=row3, Fri=row5
|
||||||
|
y = cell top + CELL - 2 puts the baseline near the bottom of the
|
||||||
|
10px cell, which reads as vertically centred for a 10px font. */}
|
||||||
{(["Mon", "Wed", "Fri"] as const).map((label, i) => (
|
{(["Mon", "Wed", "Fri"] as const).map((label, i) => (
|
||||||
<text
|
<text
|
||||||
key={label}
|
key={label}
|
||||||
x={0}
|
x={0}
|
||||||
y={TOP + (i * 2 + 1) * STRIDE - GAP}
|
y={TOP + (i * 2 + 1) * STRIDE + CELL - 2}
|
||||||
fontSize={10}
|
fontSize={10}
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
className="text-muted"
|
className="text-muted"
|
||||||
|
|
@ -158,7 +161,7 @@ export function ForgejoHeatmap({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="mt-2 flex items-center justify-end gap-1.5 text-xs text-muted">
|
<div className="mt-2 flex items-center justify-center gap-1.5 text-xs text-muted">
|
||||||
<span>Less</span>
|
<span>Less</span>
|
||||||
{LEVEL_FILL.map((fill, i) => (
|
{LEVEL_FILL.map((fill, i) => (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { customFetch } from "@/api/mutator";
|
||||||
|
|
||||||
|
type ApiResponse<T> = {
|
||||||
|
data: T;
|
||||||
|
status: number;
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayAgentImportCandidate = {
|
||||||
|
gateway_agent_id: string;
|
||||||
|
gateway_agent_name: string | null;
|
||||||
|
session_key: string | null;
|
||||||
|
existing_agent_id: string | null;
|
||||||
|
importable: boolean;
|
||||||
|
inferred_board_id: string | null;
|
||||||
|
inferred_is_board_lead: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayAgentImportPreviewResponse = {
|
||||||
|
gateway_id: string;
|
||||||
|
discovered_count: number;
|
||||||
|
importable_count: number;
|
||||||
|
already_tracked_count: number;
|
||||||
|
candidates: GatewayAgentImportCandidate[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayAgentReconcileError = {
|
||||||
|
agent_id: string | null;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayAgentImportReconcileSummary = {
|
||||||
|
attempted: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
errors: GatewayAgentReconcileError[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayAgentImportResponse = {
|
||||||
|
gateway_id: string;
|
||||||
|
imported_count: number;
|
||||||
|
skipped_count: number;
|
||||||
|
imported_agent_ids: string[];
|
||||||
|
skipped_gateway_agent_ids: string[];
|
||||||
|
reconcile: GatewayAgentImportReconcileSummary | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayAgentImportRequest = {
|
||||||
|
gateway_agent_ids: string[];
|
||||||
|
reconcile_after_import: boolean;
|
||||||
|
rotate_tokens: boolean;
|
||||||
|
reset_sessions: boolean;
|
||||||
|
force_bootstrap: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await customFetch<ApiResponse<T>>(path, init ?? {});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGatewayAgentImportPreview(
|
||||||
|
gatewayId: string,
|
||||||
|
): Promise<GatewayAgentImportPreviewResponse> {
|
||||||
|
return fetchJson<GatewayAgentImportPreviewResponse>(
|
||||||
|
`/api/v1/gateways/${gatewayId}/agents/import-preview`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importGatewayAgents(
|
||||||
|
gatewayId: string,
|
||||||
|
payload: GatewayAgentImportRequest,
|
||||||
|
): Promise<GatewayAgentImportResponse> {
|
||||||
|
return fetchJson<GatewayAgentImportResponse>(
|
||||||
|
`/api/v1/gateways/${gatewayId}/agents/import`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue