fix(scripts): provision edit approval

This commit is contained in:
null 2026-05-22 01:31:48 -05:00
parent d1c0a988d3
commit 585fc52cd2
4 changed files with 177 additions and 10 deletions

View File

@ -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,

View File

@ -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.")

View File

@ -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(

View File

@ -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<string | null>(null);
const [provisionActionPending, setProvisionActionPending] = useState(false);
const [provisionActionError, setProvisionActionError] = useState<string | null>(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<ActivityEventRead[]>(() => {
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 (
<DashboardShell>
<SignedOut>
@ -212,6 +236,45 @@ export default function AgentDetailPage() {
</div>
) : null}
{agent?.provision_action ? (
<div className="rounded-xl border border-[color:rgba(234,179,8,0.4)] bg-[color:rgba(234,179,8,0.08)] p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[color:var(--warning,#ca8a04)]">
Pending approval provisioning changes are waiting for review
</p>
<p className="mt-0.5 text-xs text-muted">
Action: <span className="font-mono">{agent.provision_action}</span>
{agent.provision_requested_at
? ` · Requested ${formatRelative(agent.provision_requested_at)}`
: null}
</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="border-[color:rgba(248,113,113,0.45)] text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
disabled={provisionActionPending}
onClick={() => handleProvisionAction("reject")}
>
Reject
</Button>
<Button
size="sm"
disabled={provisionActionPending}
onClick={() => handleProvisionAction("confirm")}
>
{provisionActionPending ? "Processing…" : "Approve"}
</Button>
</div>
</div>
{provisionActionError ? (
<p className="mt-2 text-xs text-[color:var(--danger)]">{provisionActionError}</p>
) : null}
</div>
) : null}
{isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Loading agent details