fix(scripts): provision edit approval
This commit is contained in:
parent
d1c0a988d3
commit
585fc52cd2
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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…
|
||||
|
|
|
|||
Loading…
Reference in New Issue