mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 09:13: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 .oauth import require_same_origin_for_mutations, require_session
|
||||||
from .plan_api import _dispatch_followup, _thread_metadata
|
from .plan_api import _dispatch_followup, _thread_metadata
|
||||||
from .thread_api import _user_owns_thread
|
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(
|
workflow_approval_router = APIRouter(
|
||||||
prefix="/dashboard/api/workflow-approval",
|
prefix="/dashboard/api/workflow-approval",
|
||||||
|
|
@ -19,6 +23,33 @@ workflow_approval_router = APIRouter(
|
||||||
_SESSION_DEP = Depends(require_session)
|
_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")
|
@workflow_approval_router.post("/{thread_id}/{fingerprint}/approve")
|
||||||
async def approve_workflow_push(
|
async def approve_workflow_push(
|
||||||
thread_id: str, fingerprint: str, session: dict[str, Any] = _SESSION_DEP
|
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 { Link } from "@tanstack/react-router"
|
||||||
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
||||||
import { Map as MapIcon } from "lucide-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 { useModelOptions } from "@/lib/agents/provider/useModelOptions"
|
||||||
import { useIsMobile } from "@/lib/useIsMobile"
|
import { useIsMobile } from "@/lib/useIsMobile"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
useApproveWorkflowPush,
|
||||||
|
useRejectWorkflowPush,
|
||||||
|
useWorkflowApprovals,
|
||||||
|
} from "@/lib/agents/queries"
|
||||||
|
import { WorkflowApprovalCard } from "@/components/agents/WorkflowApprovalCard"
|
||||||
|
|
||||||
interface AgentThreadViewProps {
|
interface AgentThreadViewProps {
|
||||||
thread: AgentThread
|
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.
|
// Show a loading state during that one-time fetch instead of the empty state.
|
||||||
const isHydrating = stream.isThreadLoading && !hasMessages
|
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 (
|
return (
|
||||||
<div className="flex min-w-0 flex-1">
|
<div className="flex min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
|
|
@ -167,6 +198,24 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||||
)}
|
)}
|
||||||
{hasConversation ? (
|
{hasConversation ? (
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
<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
|
||||||
messages={baseMessages}
|
messages={baseMessages}
|
||||||
queuedMessages={queuedMessages}
|
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
|
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 {
|
export interface ThreadsPageParams {
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
|
|
@ -265,6 +283,24 @@ export const agentsApi = {
|
||||||
),
|
),
|
||||||
streamUrl: (threadId: string) =>
|
streamUrl: (threadId: string) =>
|
||||||
`${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`,
|
`${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"
|
export type ThreadGroup = "today" | "last7" | "last30" | "older"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
ScheduleUpdateRequest,
|
ScheduleUpdateRequest,
|
||||||
SidebarThreads,
|
SidebarThreads,
|
||||||
ThreadsPageParams,
|
ThreadsPageParams,
|
||||||
|
WorkflowApproval,
|
||||||
} from "./api"
|
} from "./api"
|
||||||
import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
|
import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
|
||||||
|
|
||||||
|
|
@ -17,6 +18,8 @@ export const agentThreadKeys = {
|
||||||
["agent-threads", "lists", "sidebar", params] as const,
|
["agent-threads", "lists", "sidebar", params] as const,
|
||||||
detail: (threadId: string) => ["agent-threads", threadId] as const,
|
detail: (threadId: string) => ["agent-threads", threadId] as const,
|
||||||
prDiff: (threadId: string) => ["agent-threads", threadId, "pr-diff"] 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) =>
|
page: (params: ThreadsPageParams) =>
|
||||||
["agent-threads", "lists", "page", params] as const,
|
["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() {
|
export function useAgentSchedules() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: agentScheduleKeys.all,
|
queryKey: agentScheduleKeys.all,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue