mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
feat: Add dashboard UI for workflow push approvals (#103)
This commit is contained in:
parent
a7ddd79e98
commit
1bea7ae2d2
5 changed files with 314 additions and 2 deletions
|
|
@ -9,7 +9,11 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from .oauth import require_same_origin_for_mutations, require_session
|
||||
from .plan_api import _dispatch_followup, _thread_metadata
|
||||
from .thread_api import _user_owns_thread
|
||||
from .workflow_approval import decide_workflow_push_approval
|
||||
from .workflow_approval import (
|
||||
WORKFLOW_APPROVAL_PENDING,
|
||||
decide_workflow_push_approval,
|
||||
get_workflow_push_approvals,
|
||||
)
|
||||
|
||||
workflow_approval_router = APIRouter(
|
||||
prefix="/dashboard/api/workflow-approval",
|
||||
|
|
@ -19,6 +23,33 @@ workflow_approval_router = APIRouter(
|
|||
_SESSION_DEP = Depends(require_session)
|
||||
|
||||
|
||||
def _approval_record_response(record: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"fingerprint": record.get("fingerprint"),
|
||||
"status": record.get("status"),
|
||||
"repo": record.get("repo"),
|
||||
"branch": record.get("branch"),
|
||||
"files": record.get("files"),
|
||||
"requested_at": record.get("requested_at"),
|
||||
}
|
||||
|
||||
|
||||
@workflow_approval_router.get("/{thread_id}")
|
||||
async def list_workflow_approvals_for_thread(
|
||||
thread_id: str, session: dict[str, Any] = _SESSION_DEP
|
||||
) -> dict[str, Any]:
|
||||
metadata = await _thread_metadata(thread_id)
|
||||
if not _user_owns_thread(metadata, session["sub"], session.get("email")):
|
||||
raise HTTPException(403, "only the thread owner can view workflow push approvals")
|
||||
approvals = await get_workflow_push_approvals(thread_id)
|
||||
pending = [
|
||||
_approval_record_response(record)
|
||||
for record in approvals.values()
|
||||
if record.get("status") == WORKFLOW_APPROVAL_PENDING
|
||||
]
|
||||
return {"approvals": pending}
|
||||
|
||||
|
||||
@workflow_approval_router.post("/{thread_id}/{fingerprint}/approve")
|
||||
async def approve_workflow_push(
|
||||
thread_id: str, fingerprint: str, session: dict[str, Any] = _SESSION_DEP
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
||||
import { Map as MapIcon } from "lucide-react"
|
||||
|
|
@ -23,6 +23,12 @@ import { useSubmitAgentMessage } from "@/lib/agents/provider/useSubmitAgentMessa
|
|||
import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
|
||||
import { useIsMobile } from "@/lib/useIsMobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
useApproveWorkflowPush,
|
||||
useRejectWorkflowPush,
|
||||
useWorkflowApprovals,
|
||||
} from "@/lib/agents/queries"
|
||||
import { WorkflowApprovalCard } from "@/components/agents/WorkflowApprovalCard"
|
||||
|
||||
interface AgentThreadViewProps {
|
||||
thread: AgentThread
|
||||
|
|
@ -126,6 +132,31 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
// Show a loading state during that one-time fetch instead of the empty state.
|
||||
const isHydrating = stream.isThreadLoading && !hasMessages
|
||||
|
||||
const approvalsQuery = useWorkflowApprovals(thread.id)
|
||||
const approveMutation = useApproveWorkflowPush(thread.id)
|
||||
const rejectMutation = useRejectWorkflowPush(thread.id)
|
||||
|
||||
const approvals = approvalsQuery.data
|
||||
const isApprovalOwner = thread.isOwner ?? false
|
||||
|
||||
const hasWorkflowApprovalError = useMemo(
|
||||
() =>
|
||||
baseMessages.some((message) =>
|
||||
message.chunks.some(
|
||||
(chunk) =>
|
||||
chunk.kind === "tool-execution" &&
|
||||
chunk.output?.includes("WorkflowPushApprovalRequired")
|
||||
)
|
||||
),
|
||||
[baseMessages]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasWorkflowApprovalError) {
|
||||
void approvalsQuery.refetch()
|
||||
}
|
||||
}, [hasWorkflowApprovalError])
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<div
|
||||
|
|
@ -167,6 +198,24 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
)}
|
||||
{hasConversation ? (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{approvals && approvals.length > 0 && (
|
||||
<div className="shrink-0 px-4 pt-4">
|
||||
{approvals.map((approval) => (
|
||||
<WorkflowApprovalCard
|
||||
key={approval.fingerprint}
|
||||
approval={approval}
|
||||
isOwner={isApprovalOwner}
|
||||
isPending={
|
||||
approveMutation.isPending || rejectMutation.isPending
|
||||
}
|
||||
onApprove={() =>
|
||||
approveMutation.mutate(approval.fingerprint)
|
||||
}
|
||||
onReject={() => rejectMutation.mutate(approval.fingerprint)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Messages
|
||||
messages={baseMessages}
|
||||
queuedMessages={queuedMessages}
|
||||
|
|
|
|||
138
ui/src/components/agents/WorkflowApprovalCard.tsx
Normal file
138
ui/src/components/agents/WorkflowApprovalCard.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { Check, FileCode2, ShieldAlert, X } from "lucide-react"
|
||||
|
||||
import type { WorkflowApproval } from "@/lib/agents/api"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
type ApprovalStatus = "pending" | "approved" | "rejected" | string
|
||||
|
||||
interface WorkflowApprovalCardProps {
|
||||
approval: WorkflowApproval
|
||||
isOwner: boolean
|
||||
isPending: boolean
|
||||
onApprove: (fingerprint: string) => void
|
||||
onReject: (fingerprint: string) => void
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: ApprovalStatus) {
|
||||
switch (status) {
|
||||
case "approved":
|
||||
return "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
case "rejected":
|
||||
return "bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
default:
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: ApprovalStatus) {
|
||||
switch (status) {
|
||||
case "approved":
|
||||
return "Approved"
|
||||
case "rejected":
|
||||
return "Rejected"
|
||||
default:
|
||||
return "Pending approval"
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkflowApprovalCard({
|
||||
approval,
|
||||
isOwner,
|
||||
isPending,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: WorkflowApprovalCardProps) {
|
||||
const actionable = isOwner && approval.status === "pending" && !isPending
|
||||
const decided = approval.status !== "pending"
|
||||
|
||||
return (
|
||||
<Card className="mx-auto mb-4 w-full max-w-3xl border-amber-500/20 bg-amber-500/5 dark:bg-amber-500/10">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="size-4 text-amber-600 dark:text-amber-400" />
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Workflow push approval required
|
||||
</CardTitle>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase ${statusBadgeClass(
|
||||
approval.status
|
||||
)}`}
|
||||
>
|
||||
{statusLabel(approval.status)}
|
||||
</span>
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
Open SWE is trying to push GitHub workflow file changes in{" "}
|
||||
<strong>{approval.repo}</strong> on <strong>{approval.branch}</strong>
|
||||
. Approve only if this exact workflow diff is expected.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-[11px] font-medium text-[var(--ui-text-muted)]">
|
||||
<FileCode2 className="size-3.5" />
|
||||
Changed workflow files
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{approval.files.map((file) => (
|
||||
<li
|
||||
key={file}
|
||||
className="font-mono text-[11px] break-all text-[var(--ui-text)]"
|
||||
>
|
||||
{file}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="pt-1 text-[11px] text-[var(--ui-text-muted)]">
|
||||
Fingerprint:{" "}
|
||||
<code className="rounded bg-[var(--ui-panel)] px-1 py-0.5 text-[var(--ui-text)]">
|
||||
{approval.fingerprint}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
{actionable ? (
|
||||
<CardFooter className="gap-2 border-t border-[var(--ui-border)] pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onApprove(approval.fingerprint)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Check className="mr-1 size-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => onReject(approval.fingerprint)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<X className="mr-1 size-3.5" />
|
||||
Reject
|
||||
</Button>
|
||||
</CardFooter>
|
||||
) : decided ? (
|
||||
<CardFooter className="border-t border-[var(--ui-border)] pt-3 text-[11px] text-[var(--ui-text-muted)]">
|
||||
This workflow push has been {approval.status}. If the workflow files
|
||||
change, a new fingerprint will be required.
|
||||
</CardFooter>
|
||||
) : null}
|
||||
{!isOwner && approval.status === "pending" && (
|
||||
<CardFooter className="border-t border-[var(--ui-border)] pt-3 text-[11px] text-[var(--ui-text-muted)]">
|
||||
Only the thread owner can approve or reject this workflow push.
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -65,6 +65,24 @@ export interface ThreadRecoveryPatch {
|
|||
filename: string
|
||||
}
|
||||
|
||||
export interface WorkflowApproval {
|
||||
fingerprint: string
|
||||
status: string
|
||||
repo: string
|
||||
branch: string
|
||||
files: Array<string>
|
||||
requested_at: string
|
||||
}
|
||||
|
||||
export interface WorkflowApprovalsPayload {
|
||||
approvals: Array<WorkflowApproval>
|
||||
}
|
||||
|
||||
export interface WorkflowApprovalDecision {
|
||||
status: string
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export interface ThreadsPageParams {
|
||||
limit?: number
|
||||
offset?: number
|
||||
|
|
@ -265,6 +283,24 @@ export const agentsApi = {
|
|||
),
|
||||
streamUrl: (threadId: string) =>
|
||||
`${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`,
|
||||
listWorkflowApprovals: (threadId: string) =>
|
||||
agentsRequest<WorkflowApprovalsPayload>(
|
||||
`/workflow-approval/${encodeURIComponent(threadId)}`
|
||||
),
|
||||
approveWorkflowPush: (threadId: string, fingerprint: string) =>
|
||||
agentsRequest<WorkflowApprovalDecision>(
|
||||
`/workflow-approval/${encodeURIComponent(threadId)}/${encodeURIComponent(
|
||||
fingerprint
|
||||
)}/approve`,
|
||||
{ method: "POST" }
|
||||
),
|
||||
rejectWorkflowPush: (threadId: string, fingerprint: string) =>
|
||||
agentsRequest<WorkflowApprovalDecision>(
|
||||
`/workflow-approval/${encodeURIComponent(threadId)}/${encodeURIComponent(
|
||||
fingerprint
|
||||
)}/reject`,
|
||||
{ method: "POST" }
|
||||
),
|
||||
}
|
||||
|
||||
export type ThreadGroup = "today" | "last7" | "last30" | "older"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
ScheduleUpdateRequest,
|
||||
SidebarThreads,
|
||||
ThreadsPageParams,
|
||||
WorkflowApproval,
|
||||
} from "./api"
|
||||
import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
|
||||
|
||||
|
|
@ -17,6 +18,8 @@ export const agentThreadKeys = {
|
|||
["agent-threads", "lists", "sidebar", params] as const,
|
||||
detail: (threadId: string) => ["agent-threads", threadId] as const,
|
||||
prDiff: (threadId: string) => ["agent-threads", threadId, "pr-diff"] as const,
|
||||
workflowApprovals: (threadId: string) =>
|
||||
["agent-threads", threadId, "workflow-approvals"] as const,
|
||||
page: (params: ThreadsPageParams) =>
|
||||
["agent-threads", "lists", "page", params] as const,
|
||||
}
|
||||
|
|
@ -125,6 +128,61 @@ export function useAgentThreadPrDiff(threadId: string, enabled: boolean) {
|
|||
})
|
||||
}
|
||||
|
||||
export function useWorkflowApprovals(threadId: string) {
|
||||
return useQuery({
|
||||
queryKey: agentThreadKeys.workflowApprovals(threadId),
|
||||
queryFn: async () => {
|
||||
const { approvals } = await agentsApi.listWorkflowApprovals(threadId)
|
||||
return approvals
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data
|
||||
return data?.some((a) => a.status === "pending") ? 3000 : false
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useApproveWorkflowPush(threadId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (fingerprint: string) =>
|
||||
agentsApi.approveWorkflowPush(threadId, fingerprint),
|
||||
onSuccess: (_, fingerprint) => {
|
||||
queryClient.setQueryData<Array<WorkflowApproval> | undefined>(
|
||||
agentThreadKeys.workflowApprovals(threadId),
|
||||
(prev) =>
|
||||
prev?.map((record) =>
|
||||
record.fingerprint === fingerprint
|
||||
? { ...record, status: "approved" }
|
||||
: record
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRejectWorkflowPush(threadId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (fingerprint: string) =>
|
||||
agentsApi.rejectWorkflowPush(threadId, fingerprint),
|
||||
onSuccess: (_, fingerprint) => {
|
||||
queryClient.setQueryData<Array<WorkflowApproval> | undefined>(
|
||||
agentThreadKeys.workflowApprovals(threadId),
|
||||
(prev) =>
|
||||
prev?.map((record) =>
|
||||
record.fingerprint === fingerprint
|
||||
? { ...record, status: "rejected" }
|
||||
: record
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAgentSchedules() {
|
||||
return useQuery({
|
||||
queryKey: agentScheduleKeys.all,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue