From 585fc52cd242a63962cd5a5c524ce1491e6a6652 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 22 May 2026 01:31:48 -0500 Subject: [PATCH] fix(scripts): provision edit approval --- backend/app/api/agents.py | 27 ++++++ backend/app/schemas/agents.py | 8 ++ .../app/services/openclaw/provisioning_db.py | 85 +++++++++++++++++-- frontend/src/app/agents/[agentId]/page.tsx | 67 ++++++++++++++- 4 files changed, 177 insertions(+), 10 deletions(-) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 50fc399..23c8302 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -157,6 +157,33 @@ async def heartbeat_or_create_agent( return await service.heartbeat_or_create_agent(payload=payload, actor=actor) +@router.post("/{agent_id}/confirm-provision", response_model=AgentRead) +async def confirm_agent_provision( + agent_id: str, + session: AsyncSession = SESSION_DEP, + auth: AuthContext = AUTH_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> AgentRead: + """Approve a pending provisioning action and push it to the gateway.""" + service = AgentLifecycleService(session) + return await service.confirm_agent_provision( + agent_id=agent_id, + ctx=ctx, + user=auth.user, + ) + + +@router.post("/{agent_id}/reject-provision", response_model=AgentRead) +async def reject_agent_provision( + agent_id: str, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> AgentRead: + """Reject a pending provisioning action — no gateway changes are made.""" + service = AgentLifecycleService(session) + return await service.reject_agent_provision(agent_id=agent_id, ctx=ctx) + + @router.delete("/{agent_id}", response_model=OkResponse) async def delete_agent( agent_id: str, diff --git a/backend/app/schemas/agents.py b/backend/app/schemas/agents.py index 0e115d5..6713199 100644 --- a/backend/app/schemas/agents.py +++ b/backend/app/schemas/agents.py @@ -245,6 +245,14 @@ class AgentRead(AgentBase): default=None, description="Last heartbeat timestamp.", ) + provision_action: str | None = Field( + default=None, + description="Pending provisioning action awaiting approval (e.g. 'update').", + ) + provision_requested_at: datetime | None = Field( + default=None, + description="When the pending provisioning action was queued.", + ) created_at: datetime = Field(description="Creation timestamp.") updated_at: datetime = Field(description="Last update timestamp.") diff --git a/backend/app/services/openclaw/provisioning_db.py b/backend/app/services/openclaw/provisioning_db.py index 4dc4f0f..518629f 100644 --- a/backend/app/services/openclaw/provisioning_db.py +++ b/backend/app/services/openclaw/provisioning_db.py @@ -1624,27 +1624,96 @@ class AgentLifecycleService(OpenClawDBService): updates=updates, make_main=make_main, ) - target = await self.resolve_agent_update_target( + # Validate the target resolves before saving — surfaces config errors early. + await self.resolve_agent_update_target( agent=agent, make_main=make_main, main_gateway=main_gateway, gateway_for_main=gateway_for_main, ) + mark_provision_requested(agent, action="update", status="updating") + self.session.add(agent) + await self.session.commit() + await self.session.refresh(agent) + self.logger.info("agent.update.pending_approval agent_id=%s", agent.id) + return self.to_agent_read(self.with_computed_status(agent)) + + async def confirm_agent_provision( + self, + *, + agent_id: str, + ctx: OrganizationContext, + user: User | None, + ) -> AgentRead: + """Approve a pending provisioning action and push it to the gateway.""" + agent = await Agent.objects.by_id(agent_id).first(self.session) + if agent is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + await self.require_agent_access(agent=agent, ctx=ctx, write=True) + if not agent.provision_action: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="No pending provisioning action to confirm", + ) + + # Re-derive target from committed agent state. + if agent.board_id is None: + gateway = await self.get_main_agent_gateway(agent) + if gateway is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot resolve gateway for main agent", + ) + target = AgentUpdateProvisionTarget( + is_main_agent=True, + board=None, + gateway=gateway, + ) + else: + board = await self.require_board(agent.board_id) + gateway, _config = await self.require_gateway(board) + target = AgentUpdateProvisionTarget( + is_main_agent=False, + board=board, + gateway=gateway, + ) + raw_token = self.mark_agent_update_pending(agent) self.session.add(agent) await self.session.commit() await self.session.refresh(agent) + provision_request = AgentUpdateProvisionRequest( target=target, raw_token=raw_token, - user=options.user, - force_bootstrap=options.force, + user=user, + force_bootstrap=True, ) - await self.provision_updated_agent( - agent=agent, - request=provision_request, - ) - self.logger.info("agent.update.success agent_id=%s", agent.id) + await self.provision_updated_agent(agent=agent, request=provision_request) + self.logger.info("agent.provision.confirmed agent_id=%s", agent.id) + return self.to_agent_read(self.with_computed_status(agent)) + + async def reject_agent_provision( + self, + *, + agent_id: str, + ctx: OrganizationContext, + ) -> AgentRead: + """Reject a pending provisioning action — no gateway changes are made.""" + agent = await Agent.objects.by_id(agent_id).first(self.session) + if agent is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + await self.require_agent_access(agent=agent, ctx=ctx, write=True) + if not agent.provision_action: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="No pending provisioning action to reject", + ) + mark_provision_complete(agent, status="offline", clear_confirm_token=True) + self.session.add(agent) + await self.session.commit() + await self.session.refresh(agent) + self.logger.info("agent.provision.rejected agent_id=%s", agent.id) return self.to_agent_read(self.with_computed_status(agent)) async def heartbeat_agent( diff --git a/frontend/src/app/agents/[agentId]/page.tsx b/frontend/src/app/agents/[agentId]/page.tsx index 3c49ad2..15e93ce 100644 --- a/frontend/src/app/agents/[agentId]/page.tsx +++ b/frontend/src/app/agents/[agentId]/page.tsx @@ -35,6 +35,7 @@ import type { } from "@/api/generated/model"; import { Markdown } from "@/components/atoms/Markdown"; import { StatusPill } from "@/components/atoms/StatusPill"; +import { customFetch } from "@/api/mutator"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; @@ -47,6 +48,11 @@ import { DialogTitle, } from "@/components/ui/dialog"; +type AgentReadExtended = AgentRead & { + provision_action?: string | null; + provision_requested_at?: string | null; +}; + export default function AgentDetailPage() { const { isSignedIn } = useAuth(); const router = useRouter(); @@ -58,6 +64,8 @@ export default function AgentDetailPage() { const [deleteOpen, setDeleteOpen] = useState(false); const [deleteError, setDeleteError] = useState(null); + const [provisionActionPending, setProvisionActionPending] = useState(false); + const [provisionActionError, setProvisionActionError] = useState(null); const agentQuery = useGetAgentApiV1AgentsAgentIdGet< getAgentApiV1AgentsAgentIdGetResponse, @@ -97,8 +105,10 @@ export default function AgentDetailPage() { }, }); - const agent: AgentRead | null = - agentQuery.data?.status === 200 ? agentQuery.data.data : null; + const agent = ( + agentQuery.data?.status === 200 ? agentQuery.data.data : null + ) as AgentReadExtended | null; + const events = useMemo(() => { if (activityQuery.data?.status !== 200) return []; return activityQuery.data.data.items ?? []; @@ -147,6 +157,20 @@ export default function AgentDetailPage() { deleteMutation.mutate({ agentId }); }; + const handleProvisionAction = async (action: "confirm" | "reject") => { + if (!agentId) return; + setProvisionActionPending(true); + setProvisionActionError(null); + try { + await customFetch(`/api/v1/agents/${agentId}/${action}-provision`, { method: "POST" }); + agentQuery.refetch(); + } catch (err) { + setProvisionActionError(err instanceof Error ? err.message : "Action failed"); + } finally { + setProvisionActionPending(false); + } + }; + return ( @@ -212,6 +236,45 @@ export default function AgentDetailPage() { ) : null} + {agent?.provision_action ? ( +
+
+
+

+ Pending approval — provisioning changes are waiting for review +

+

+ Action: {agent.provision_action} + {agent.provision_requested_at + ? ` · Requested ${formatRelative(agent.provision_requested_at)}` + : null} +

+
+
+ + +
+
+ {provisionActionError ? ( +

{provisionActionError}

+ ) : null} +
+ ) : null} + {isLoading ? (
Loading agent details…