feat(FN-3549): add mailbox approvals inbox and decision flow

- Add dashboard approvals APIs and route handlers for listing requests, fetching detail, and submitting approve/deny decisions
- Emit approval SSE events and subscribe mailbox refresh logic to keep approval lists and badges live
- Extend Mailbox view with an Approvals tab, pending/history filters, request detail history, and approve/deny actions
- Update mailbox styles, approval route tests, and docs for the new approvals workflow

Fusion-Task-Id: FN-3549
This commit is contained in:
Fusion
2026-05-09 05:38:34 -07:00
committed by gsxdsm
parent 9e6574c777
commit f3626489ed
9 changed files with 770 additions and 113 deletions

View File

@@ -69,6 +69,7 @@ import type {
DockerExtraCli,
DockerNodeStatus,
ProjectNodePathMapping,
ApprovalRequestStatus,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
@@ -7598,6 +7599,55 @@ export interface SendMessageInput {
wakeImmediately?: boolean;
}
export interface ApprovalRequestSummary {
id: string;
status: ApprovalRequestStatus;
actionCategory: string;
actionSummary: string;
agentId: string;
taskId?: string;
createdAt: string;
updatedAt: string;
decidedAt?: string;
decidedBy?: string;
}
export interface ApprovalRequestDetail extends ApprovalRequestSummary {
requester: {
actorId: string;
actorType: "agent" | "user" | "system";
actorName: string;
};
runId?: string;
requestedAt: string;
completedAt?: string;
targetAction: {
category: string;
action: string;
summary: string;
resourceType: string;
resourceId: string;
context?: Record<string, unknown>;
};
history: Array<{
id: string;
eventType: string;
actor: {
actorId: string;
actorType: "agent" | "user" | "system";
actorName: string;
};
note?: string;
createdAt: string;
}>;
}
export interface ApprovalListResponse {
requests: ApprovalRequestSummary[];
total: number;
pendingCount: number;
}
/** Fetch inbox messages for the current user. */
export function fetchInbox(
options?: { limit?: number; offset?: number; unreadOnly?: boolean; type?: MessageType },
@@ -7681,6 +7731,34 @@ export function fetchAgentMailbox(agentId: string, projectId?: string): Promise<
return api<AgentMailboxResponse>(withProjectId(`/agents/${encodeURIComponent(agentId)}/mailbox`, projectId));
}
export function fetchApprovals(
options?: { status?: ApprovalRequestStatus; limit?: number; offset?: number },
projectId?: string,
): Promise<ApprovalListResponse> {
const params = new URLSearchParams();
if (options?.status) params.set("status", options.status);
if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.offset !== undefined) params.set("offset", String(options.offset));
if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<ApprovalListResponse>(`/approvals${query}`);
}
export function fetchApprovalDetail(id: string, projectId?: string): Promise<ApprovalRequestDetail> {
return api<ApprovalRequestDetail>(withProjectId(`/approvals/${encodeURIComponent(id)}`, projectId));
}
export function decideApproval(
id: string,
input: { decision: "approve" | "deny"; comment?: string },
projectId?: string,
): Promise<ApprovalRequestDetail> {
return api<ApprovalRequestDetail>(withProjectId(`/approvals/${encodeURIComponent(id)}/decision`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Fetch reflection history for an agent. */
export function fetchAgentReflections(agentId: string, limit?: number, projectId?: string): Promise<AgentReflection[]> {
const params = new URLSearchParams();

View File

@@ -25,10 +25,10 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 var(--space-xs);
border-radius: 10px;
border-radius: var(--radius-pill);
background: var(--color-error);
color: var(--fab-text);
font-size: var(--font-size-3xs, 0.7rem);
@@ -51,7 +51,7 @@
.mailbox-tab {
justify-content: center;
min-height: 32px;
min-height: 2rem;
padding: var(--space-sm) var(--space-md);
color: var(--text-muted);
border-color: var(--border);
@@ -74,8 +74,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
min-width: 1rem;
height: 1rem;
padding: 0 var(--btn-border-width);
border-radius: var(--radius-md);
background: var(--color-error);
@@ -138,8 +138,8 @@
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--bg-tertiary);
color: var(--text-muted);
@@ -184,8 +184,8 @@
}
.mailbox-item-unread-dot {
width: 8px;
height: 8px;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--todo);
flex-shrink: 0;
@@ -223,14 +223,14 @@
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 var(--space-xs);
font-size: var(--font-size-xs, 0.8rem);
font-weight: 600;
background: var(--todo);
color: var(--fab-text);
border-radius: 10px;
border-radius: var(--radius-pill);
flex-shrink: 0;
margin-top: var(--space-xs);
}
@@ -358,7 +358,7 @@
.mailbox-markdown-table th,
.mailbox-markdown-table td {
border: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
border: var(--btn-border-width) solid color-mix(in srgb, var(--border) 85%, transparent);
padding: var(--space-xs) var(--space-sm);
}
@@ -494,7 +494,7 @@
.mailbox-agent-select {
width: 100%;
max-width: 300px;
max-width: 18.75rem;
}
.mailbox-agents-content {
@@ -539,6 +539,43 @@
font-size: var(--font-size-3xs, 0.7rem);
}
.mailbox-approval-filters {
display: flex;
gap: var(--space-sm);
padding-bottom: var(--space-sm);
}
.mailbox-approval-item {
align-items: center;
}
.mailbox-approval-status-dot {
flex-shrink: 0;
}
.mailbox-approval-status-dot--pending {
color: var(--color-warning);
}
.mailbox-approval-status-dot--approved,
.mailbox-approval-status-dot--completed {
color: var(--color-success);
}
.mailbox-approval-status-dot--denied {
color: var(--color-error);
}
.mailbox-approval-status {
text-transform: capitalize;
font-size: var(--font-size-xs, 0.8rem);
color: var(--text-muted);
}
.mailbox-approval-comment {
min-height: calc(var(--space-2xl) + var(--space-xl));
}
/* Skeleton loading */
.mailbox-skeleton {
display: flex;
@@ -554,8 +591,8 @@
}
.mailbox-skeleton-avatar {
width: 32px;
height: 32px;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--bg-tertiary);
animation: pulse 1.5s ease-in-out infinite;
@@ -569,7 +606,7 @@
}
.mailbox-skeleton-line {
height: 12px;
height: 0.75rem;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
animation: pulse 1.5s ease-in-out infinite;
@@ -612,7 +649,7 @@
.mailbox-view .mailbox-split-layout {
display: grid;
grid-template-columns: minmax(280px, 38%) minmax(0, 1fr);
grid-template-columns: minmax(17.5rem, 38%) minmax(0, 1fr);
gap: var(--space-lg);
height: 100%;
min-height: 0;
@@ -643,7 +680,7 @@
justify-content: center;
gap: var(--space-md);
padding: var(--space-xl);
border: 1px dashed color-mix(in srgb, var(--text-muted) 45%, transparent);
border: var(--btn-border-width) dashed color-mix(in srgb, var(--text-muted) 45%, transparent);
border-radius: var(--radius-md);
color: var(--text-muted);
background: color-mix(in srgb, var(--surface) 80%, transparent);
@@ -678,13 +715,13 @@
.mailbox-modal .mailbox-header-actions .modal-close,
.mailbox-view .mailbox-header-actions .btn,
.mailbox-view .mailbox-header-actions .btn-icon {
min-height: 36px;
min-height: 2.25rem;
}
.mailbox-modal .mailbox-header-actions .btn-icon,
.mailbox-modal .mailbox-header-actions .modal-close,
.mailbox-view .mailbox-header-actions .btn-icon {
min-width: 36px;
min-width: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -714,14 +751,14 @@
.mailbox-modal .mailbox-content {
max-height: calc(100dvh - var(--header-height) - var(--space-2xl) - var(--space-xl));
padding: var(--space-md);
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0) + var(--standalone-bottom-gap));
}
.mailbox-view[style*="--keyboard-overlap"],
.mailbox-modal[style*="--keyboard-overlap"] {
height: var(--vv-height, 100dvh);
max-height: var(--vv-height, 100dvh);
transform: translateY(var(--vv-offset-top, 0px));
transform: translateY(var(--vv-offset-top, 0));
will-change: transform;
}
@@ -759,7 +796,7 @@
}
.mailbox-modal .mailbox-agents {
min-height: 200px;
min-height: 12.5rem;
}
.mailbox-modal .mailbox-empty {
@@ -773,7 +810,7 @@
.mailbox-modal .mailbox-agent-subtab {
flex: 1;
justify-content: center;
min-height: 36px;
min-height: 2.25rem;
padding: var(--space-sm) var(--space-md);
}
@@ -808,11 +845,11 @@
overflow-y: auto;
padding: var(--space-md);
/* Account for mobile nav bar at bottom */
padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap) + var(--space-lg));
padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0) + var(--standalone-bottom-gap) + var(--space-lg));
}
.mailbox-view[style*="--keyboard-overlap"] .mailbox-content {
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--space-md));
padding-bottom: calc(env(safe-area-inset-bottom, 0) + var(--space-md));
}
.mailbox-view .mailbox-split-layout {
@@ -857,7 +894,7 @@
}
.mailbox-view .mailbox-agents {
min-height: 200px;
min-height: 12.5rem;
}
.mailbox-view .mailbox-empty {
@@ -871,7 +908,7 @@
.mailbox-view .mailbox-agent-subtab {
flex: 1;
justify-content: center;
min-height: 36px;
min-height: 2.25rem;
padding: var(--space-sm) var(--space-md);
}
@@ -886,7 +923,7 @@
}
.message-composer-footer .btn {
min-height: 36px;
min-height: 2.25rem;
}
.message-composer-field {

View File

@@ -23,10 +23,15 @@ import {
deleteMessage,
fetchConversation,
fetchAgents,
fetchApprovals,
fetchApprovalDetail,
decideApproval,
type InboxResponse,
type OutboxResponse,
type AgentMailboxResponse,
type Agent,
type ApprovalRequestSummary,
type ApprovalRequestDetail,
} from "../api";
import { MailboxMessageContent } from "./MailboxMessageContent";
import { MessageComposer } from "./MessageComposer";
@@ -36,7 +41,7 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
// ── Types ─────────────────────────────────────────────────────────────────
type MailboxTab = "inbox" | "outbox" | "agents";
type MailboxTab = "inbox" | "outbox" | "agents" | "approvals";
interface MailboxViewProps {
projectId?: string;
@@ -168,6 +173,12 @@ export function MailboxView({
const [agentSubTab, setAgentSubTab] = useState<"inbox" | "outbox">("inbox");
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
const [agents, setAgents] = useState<Agent[]>([]);
const [approvalSubTab, setApprovalSubTab] = useState<"pending" | "history">("pending");
const [approvals, setApprovals] = useState<ApprovalRequestSummary[]>([]);
const [approvalPendingCount, setApprovalPendingCount] = useState(0);
const [selectedApproval, setSelectedApproval] = useState<ApprovalRequestDetail | null>(null);
const [approvalComment, setApprovalComment] = useState("");
const [approvalDecisionLoading, setApprovalDecisionLoading] = useState<false | "approve" | "deny">(false);
const agentNamesById = useMemo(
() => new Map(agents.map((agent) => [agent.id, agent.name ?? ""])),
@@ -252,12 +263,37 @@ export function MailboxView({
}
}, [projectId, onUnreadCountChange]);
const loadApprovals = useCallback(async (status: "pending" | "history") => {
setIsLoading(true);
try {
const list = await fetchApprovals({ status: status === "pending" ? "pending" : undefined, limit: 100 }, projectId);
if (status === "pending") {
setApprovals(list.requests);
} else {
const [approved, denied, completed] = await Promise.all([
fetchApprovals({ status: "approved", limit: 100 }, projectId),
fetchApprovals({ status: "denied", limit: 100 }, projectId),
fetchApprovals({ status: "completed", limit: 100 }, projectId),
]);
setApprovals([...approved.requests, ...denied.requests, ...completed.requests].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)));
}
setApprovalPendingCount(list.pendingCount);
} catch {
// Silently fail
} finally {
setIsLoading(false);
}
}, [projectId]);
// Load data on tab change
useEffect(() => {
if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox();
else if (activeTab === "agents") loadAgents();
}, [activeTab, loadInbox, loadOutbox, loadAgents]);
else if (activeTab === "approvals") {
void loadApprovals(approvalSubTab);
}
}, [activeTab, loadInbox, loadOutbox, loadAgents, loadApprovals, approvalSubTab]);
// Load agent mailbox when selected
useEffect(() => {
@@ -289,6 +325,8 @@ export function MailboxView({
void loadInbox();
} else if (activeTab === "outbox") {
void loadOutbox();
} else if (activeTab === "approvals") {
void loadApprovals(approvalSubTab);
}
if (selectedAgentId) {
@@ -302,9 +340,12 @@ export function MailboxView({
"message:received": onMailboxUpdate,
"message:read": onMailboxUpdate,
"message:deleted": onMailboxUpdate,
"approval:requested": onMailboxUpdate,
"approval:updated": onMailboxUpdate,
"approval:decided": onMailboxUpdate,
},
});
}, [projectId, activeTab, selectedAgentId, refreshUnreadCount, loadInbox, loadOutbox, loadAgentMailbox]);
}, [projectId, activeTab, selectedAgentId, refreshUnreadCount, loadInbox, loadOutbox, loadAgentMailbox, loadApprovals, approvalSubTab]);
// ── Actions ───────────────────────────────────────────────────────────
@@ -465,6 +506,33 @@ export function MailboxView({
setComposeReplyContext(null);
}, []);
const handleOpenApproval = useCallback(async (request: ApprovalRequestSummary) => {
try {
const detail = await fetchApprovalDetail(request.id, projectId);
setSelectedApproval(detail);
setApprovalComment("");
} catch {
addToast?.("Failed to load approval request", "error");
}
}, [projectId, addToast]);
const handleApprovalDecision = useCallback(async (decision: "approve" | "deny") => {
if (!selectedApproval || approvalDecisionLoading) return;
setApprovalDecisionLoading(decision);
try {
await decideApproval(selectedApproval.id, { decision, comment: approvalComment || undefined }, projectId);
await loadApprovals(approvalSubTab);
const updated = await fetchApprovalDetail(selectedApproval.id, projectId);
setSelectedApproval(updated);
setApprovalComment("");
addToast?.(`Request ${decision === "approve" ? "approved" : "denied"}`, "success");
} catch {
addToast?.("Failed to submit decision", "error");
} finally {
setApprovalDecisionLoading(false);
}
}, [selectedApproval, approvalDecisionLoading, approvalComment, projectId, loadApprovals, approvalSubTab, addToast]);
// ── Render ────────────────────────────────────────────────────────────
const renderMessageDetail = () => {
@@ -647,6 +715,53 @@ export function MailboxView({
</div>
)}
{activeTab === "approvals" && (
<div className="mailbox-approvals" data-testid="mailbox-approvals">
<div className="mailbox-approval-filters" data-testid="mailbox-approval-filters">
<button
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${approvalSubTab === "pending" ? "active" : ""}`}
onClick={() => { setApprovalSubTab("pending"); setSelectedApproval(null); }}
data-testid="mailbox-approval-filter-pending"
>
Pending
</button>
<button
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${approvalSubTab === "history" ? "active" : ""}`}
onClick={() => { setApprovalSubTab("history"); setSelectedApproval(null); }}
data-testid="mailbox-approval-filter-history"
>
History
</button>
</div>
<div className="mailbox-list" data-testid="mailbox-approval-list">
{approvals.length === 0 && !isLoading && (
<div className="mailbox-empty" data-testid="mailbox-approval-empty">
<InboxIcon size={32} />
<p>{approvalSubTab === "pending" ? "No pending approvals" : "No historical approvals"}</p>
</div>
)}
{approvals.map((request) => (
<div
key={request.id}
className="mailbox-item mailbox-approval-item"
onClick={() => void handleOpenApproval(request)}
data-testid={`mailbox-approval-item-${request.id}`}
>
<div className={`status-dot mailbox-approval-status-dot mailbox-approval-status-dot--${request.status}`} />
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-from">{request.agentId} · {request.actionCategory}</span>
<span className="mailbox-item-time">{formatTimestamp(request.createdAt)}</span>
</div>
<div className="mailbox-item-preview">{request.actionSummary}</div>
</div>
<span className={`mailbox-approval-status mailbox-approval-status--${request.status}`}>{request.status}</span>
</div>
))}
</div>
</div>
)}
{activeTab === "agents" && (
<div className="mailbox-agents" data-testid="mailbox-agents">
{agents.length === 0 ? (
@@ -796,6 +911,58 @@ export function MailboxView({
return renderMessageDetail();
}
if (activeTab === "approvals" && selectedApproval) {
return (
<div className="mailbox-message-detail mailbox-approval-detail" data-testid="mailbox-approval-detail">
{isMobile && (
<button className="btn btn-sm btn-secondary" onClick={() => setSelectedApproval(null)} data-testid="mailbox-approval-back-to-list"> Back</button>
)}
<div className="mailbox-message-detail-header">
<div className="mailbox-message-detail-meta">
<span className="mailbox-message-type">{selectedApproval.actionCategory}</span>
<span className="mailbox-message-time">{selectedApproval.status}</span>
</div>
</div>
<div className="mailbox-message-body">
<strong>{selectedApproval.actionSummary}</strong>
<p>Requester: {selectedApproval.requester.actorName} ({selectedApproval.agentId})</p>
{selectedApproval.taskId && <p>Task: {selectedApproval.taskId}</p>}
<p>Requested: {formatTimestamp(selectedApproval.createdAt)}</p>
</div>
<div className="mailbox-conversation" data-testid="mailbox-approval-history">
{selectedApproval.history.map((event) => (
<div key={event.id} className="mailbox-conversation-msg">
<div className="mailbox-conversation-msg-header">
<span>{event.eventType}</span>
<span>{event.actor.actorName}</span>
</div>
{event.note && <div className="mailbox-item-preview">{event.note}</div>}
</div>
))}
</div>
{selectedApproval.status === "pending" && (
<div className="mailbox-approval-decision" data-testid="mailbox-approval-decision">
<textarea
className="message-composer-textarea mailbox-approval-comment"
value={approvalComment}
onChange={(event) => setApprovalComment(event.target.value)}
placeholder="Optional comment"
data-testid="mailbox-approval-comment"
/>
<div className="mailbox-header-actions">
<button className="btn btn-sm btn-secondary" onClick={() => void handleApprovalDecision("deny")} disabled={approvalDecisionLoading !== false} data-testid="mailbox-approval-deny">
Deny
</button>
<button className="btn btn-sm btn-primary" onClick={() => void handleApprovalDecision("approve")} disabled={approvalDecisionLoading !== false} data-testid="mailbox-approval-approve">
Approve
</button>
</div>
</div>
)}
</div>
);
}
return (
<div className="mailbox-split-empty" data-testid="mailbox-split-empty">
<Mail size={24} />
@@ -843,6 +1010,7 @@ export function MailboxView({
onClick={() => {
if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox();
else if (activeTab === "approvals") loadApprovals(approvalSubTab);
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
}}
disabled={isLoading}
@@ -875,12 +1043,21 @@ export function MailboxView({
</button>
<button
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "agents" ? "active" : ""}`}
onClick={() => { setActiveTab("agents"); setSelectedMessage(null); }}
onClick={() => { setActiveTab("agents"); setSelectedMessage(null); setSelectedApproval(null); }}
data-testid="mailbox-tab-agents"
>
<Bot size={14} />
<span>Agents</span>
</button>
<button
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "approvals" ? "active" : ""}`}
onClick={() => { setActiveTab("approvals"); setSelectedMessage(null); setSelectedApproval(null); }}
data-testid="mailbox-tab-approvals"
>
<CheckCheck size={14} />
<span>Approvals</span>
{approvalPendingCount > 0 && <span className="mailbox-tab-badge" data-testid="mailbox-approvals-pending-badge">{approvalPendingCount}</span>}
</button>
</div>
<div className="mailbox-content" data-testid="mailbox-content">
@@ -896,6 +1073,7 @@ export function MailboxView({
) : (
<>
{renderMessageDetail()}
{activeTab === "approvals" && selectedApproval && renderDetailPane()}
{showComposer && (
<MessageComposer
recipient={composeRecipient}
@@ -907,7 +1085,7 @@ export function MailboxView({
addToast={addToast}
/>
)}
{!selectedMessage && !showComposer && renderListPane()}
{!selectedMessage && !selectedApproval && !showComposer && renderListPane()}
</>
)}
</div>

View File

@@ -5,6 +5,7 @@ import { MailboxView } from "../MailboxView";
import * as apiModule from "../../api";
import * as viewportModule from "../../hooks/useViewportMode";
import * as mobileKeyboardModule from "../../hooks/useMobileKeyboard";
import * as sseBusModule from "../../sse-bus";
import type { Agent } from "../../api";
import type { Message } from "@fusion/core";
@@ -20,6 +21,9 @@ vi.mock("../../api", () => ({
fetchConversation: vi.fn(),
sendMessage: vi.fn(),
fetchAgents: vi.fn(),
fetchApprovals: vi.fn(),
fetchApprovalDetail: vi.fn(),
decideApproval: vi.fn(),
}));
vi.mock("../../hooks/useViewportMode", () => ({
@@ -30,6 +34,14 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: vi.fn(),
}));
const sseSubscriptions: Array<Record<string, () => void>> = [];
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn((_url: string, options: { events: Record<string, () => void> }) => {
sseSubscriptions.push(options.events);
return () => {};
}),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
X: () => <span data-testid="icon-x">X</span>,
@@ -59,6 +71,10 @@ const mockMarkAllMessagesRead = vi.mocked(apiModule.markAllMessagesRead);
const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
const mockSendMessage = vi.mocked(apiModule.sendMessage);
const mockFetchApprovals = vi.mocked(apiModule.fetchApprovals);
const mockFetchApprovalDetail = vi.mocked(apiModule.fetchApprovalDetail);
const mockDecideApproval = vi.mocked(apiModule.decideApproval);
const mockSubscribeSse = vi.mocked(sseBusModule.subscribeSse);
const mockUseViewportMode = vi.mocked(viewportModule.useViewportMode);
const mockUseMobileKeyboard = vi.mocked(mobileKeyboardModule.useMobileKeyboard);
@@ -153,6 +169,7 @@ function makeOutboxResponse(messages: Message[]) {
describe("MailboxView", () => {
beforeEach(() => {
vi.clearAllMocks();
sseSubscriptions.length = 0;
mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
@@ -163,6 +180,7 @@ describe("MailboxView", () => {
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 2 });
mockFetchAgents.mockResolvedValue(mockAgents);
mockSendMessage.mockResolvedValue({ ...mockMessage, id: "msg-sent" });
mockFetchApprovals.mockResolvedValue({ requests: [], total: 0, pendingCount: 0 });
});
it("renders the mailbox view", async () => {
@@ -192,7 +210,7 @@ describe("MailboxView", () => {
});
});
it("renders all three tabs", async () => {
it("renders all four tabs", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
@@ -204,6 +222,210 @@ describe("MailboxView", () => {
expect(screen.getByTestId("mailbox-tab-inbox")).toBeDefined();
expect(screen.getByTestId("mailbox-tab-outbox")).toBeDefined();
expect(screen.getByTestId("mailbox-tab-agents")).toBeDefined();
expect(screen.getByTestId("mailbox-tab-approvals")).toBeDefined();
});
it("shows approvals pending badge and loads approvals tab", async () => {
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({
requests: [{
id: "apr-1",
status: "pending",
actionCategory: "command_execution",
actionSummary: "Run npm test",
agentId: "agent-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}],
total: 1,
pendingCount: 2,
});
render(<MailboxView {...defaultProps} />);
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-tab-approvals"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-approvals-pending-badge")).toHaveTextContent("2");
expect(screen.getByTestId("mailbox-approval-item-apr-1")).toBeDefined();
});
});
it("renders approval detail metadata and history", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({
requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", taskId: "FN-1", createdAt: now, updatedAt: now }],
total: 1,
pendingCount: 1,
});
mockFetchApprovalDetail.mockResolvedValue({
id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", taskId: "FN-1", createdAt: now, updatedAt: now,
requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now,
targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" },
history: [{ id: "evt-1", eventType: "created", actor: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, createdAt: now }],
});
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await waitFor(() => {
expect(screen.getByTestId("mailbox-approval-detail")).toBeDefined();
expect(screen.getByText(/Requester: Agent 1/)).toBeDefined();
expect(screen.getByText(/Task: FN-1/)).toBeDefined();
expect(screen.getByTestId("mailbox-approval-history")).toBeDefined();
});
});
it("allows approving a pending approval request", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({
requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }],
total: 1,
pendingCount: 1,
});
mockFetchApprovalDetail.mockResolvedValue({
id: "apr-1",
status: "pending",
actionCategory: "command_execution",
actionSummary: "Run npm test",
agentId: "agent-001",
createdAt: now,
updatedAt: now,
requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" },
requestedAt: now,
targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" },
history: [{ id: "evt-1", eventType: "created", actor: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, createdAt: now }],
});
mockDecideApproval.mockResolvedValue({
id: "apr-1",
status: "approved",
actionCategory: "command_execution",
actionSummary: "Run npm test",
agentId: "agent-001",
createdAt: now,
updatedAt: now,
requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" },
requestedAt: now,
targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" },
history: [{ id: "evt-2", eventType: "approved", actor: { actorId: "user", actorType: "user", actorName: "User" }, createdAt: now }],
});
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-approve")); });
await waitFor(() => {
expect(mockDecideApproval).toHaveBeenCalledWith("apr-1", { decision: "approve", comment: undefined }, undefined);
});
});
it("allows denying a pending approval request", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
mockDecideApproval.mockResolvedValue({ id: "apr-1", status: "denied", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-deny")); });
await waitFor(() => {
expect(mockDecideApproval).toHaveBeenCalledWith("apr-1", { decision: "deny", comment: undefined }, undefined);
});
});
it("disables decision buttons while submission is pending", async () => {
const now = new Date().toISOString();
let resolveDecision: (() => void) | undefined;
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
mockDecideApproval.mockImplementation(() => new Promise((resolve) => { resolveDecision = () => resolve({} as any); }));
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-approve")); });
expect(screen.getByTestId("mailbox-approval-approve")).toBeDisabled();
expect(screen.getByTestId("mailbox-approval-deny")).toBeDisabled();
expect(mockDecideApproval).toHaveBeenCalledTimes(1);
resolveDecision?.();
});
it("uses mobile stacked layout for approvals detail and back navigation", async () => {
const now = new Date().toISOString();
mockUseViewportMode.mockReturnValue("mobile");
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await waitFor(() => {
expect(screen.getByTestId("mailbox-approval-detail")).toBeDefined();
expect(screen.queryByTestId("mailbox-approval-list")).toBeNull();
expect(screen.getByTestId("mailbox-approval-back-to-list")).toBeDefined();
});
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-approval-back-to-list")); });
await waitFor(() => {
expect(screen.getByTestId("mailbox-approval-list")).toBeDefined();
});
});
it("refreshes approvals on approval SSE events", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
const latest = sseSubscriptions.at(-1);
expect(latest).toBeDefined();
await act(async () => {
latest?.["approval:requested"]?.();
latest?.["approval:updated"]?.();
latest?.["approval:decided"]?.();
});
await waitFor(() => {
expect(mockFetchApprovals).toHaveBeenCalled();
expect(mockSubscribeSse).toHaveBeenCalled();
});
});
it("moves decided requests into history view", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals
.mockResolvedValueOnce({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 })
.mockResolvedValueOnce({ requests: [], total: 0, pendingCount: 0 })
.mockResolvedValueOnce({ requests: [{ id: "apr-1", status: "approved", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 0 })
.mockResolvedValue({ requests: [], total: 0, pendingCount: 0 });
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
mockDecideApproval.mockResolvedValue({ id: "apr-1", status: "approved", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-approve")); });
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-approval-filter-history")); });
await waitFor(() => {
expect(mockFetchApprovals).toHaveBeenCalled();
});
});
it("shows inbox tab as active by default", async () => {

View File

@@ -14,19 +14,27 @@ class MockApprovalRequestStore {
list(input: any = {}) {
let rows = [...state.requests.values()];
if (input.status) rows = rows.filter((r) => r.status === input.status);
if (input.requesterActorId) rows = rows.filter((r) => r.requester.actorId === input.requesterActorId);
if (input.taskId) rows = rows.filter((r) => r.taskId === input.taskId);
return rows;
const offset = input.offset ?? 0;
const limit = input.limit ?? rows.length;
return rows.slice(offset, offset + limit);
}
get(id: string) {
return state.requests.get(id) ?? null;
}
decide(id: string, status: "approved" | "denied") {
decide(id: string, status: "approved" | "denied", input?: { actor?: any; note?: string }) {
const req = state.requests.get(id);
if (!req) throw new Error("Approval request not found");
if (req.status !== "pending") throw new Error(`Invalid approval request transition: ${req.status} -> ${status}`);
req.status = status;
state.audits.set(id, [...(state.audits.get(id) ?? []), { event: status }]);
req.decidedAt = new Date().toISOString();
req.updatedAt = req.decidedAt;
state.audits.set(id, [...(state.audits.get(id) ?? []), {
id: `evt-${status}`,
eventType: status,
actor: input?.actor ?? { actorId: "user", actorType: "user", actorName: "User" },
note: input?.note,
createdAt: req.decidedAt,
}]);
return req;
}
getAuditHistory(id: string) {
@@ -92,62 +100,106 @@ describe("approval routes", async () => {
beforeEach(() => {
updateAgent.mockClear();
const now = new Date().toISOString();
state.task = { id: "FN-1", paused: true, pausedByAgentId: "agent-1" };
state.agent = { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" };
state.requests = new Map([
["apr-1", { id: "apr-1", status: "pending", requester: { actorId: "agent-1" }, taskId: "FN-1" }],
["apr-2", { id: "apr-2", status: "denied", requester: { actorId: "agent-1" }, taskId: "FN-1" }],
["apr-1", {
id: "apr-1",
status: "pending",
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
targetAction: { category: "command_execution", summary: "Run command", action: "bash", resourceType: "command", resourceId: "cmd-1" },
taskId: "FN-1",
createdAt: now,
updatedAt: now,
requestedAt: now,
}],
["apr-2", {
id: "apr-2",
status: "denied",
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
targetAction: { category: "network_api", summary: "Fetch URL", action: "web_fetch", resourceType: "url", resourceId: "https://example.com" },
taskId: "FN-1",
createdAt: now,
updatedAt: now,
requestedAt: now,
}],
]);
state.audits = new Map([
["apr-1", [{ id: "evt-created", eventType: "created", actor: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" }, createdAt: now }]],
["apr-2", [{ id: "evt-denied", eventType: "denied", actor: { actorId: "dashboard", actorType: "user", actorName: "User" }, createdAt: now }]],
]);
state.audits = new Map([["apr-1", [{ event: "created" }]]]);
});
it("lists and filters requests", async () => {
it("lists with status filtering and pendingCount", async () => {
const app = createApp();
const res = await get(app, "/api/approval-requests?status=pending");
const res = await get(app, "/api/approvals?status=pending");
expect(res.status).toBe(200);
expect((res.body as any[]).map((r) => r.id)).toEqual(["apr-1"]);
expect(res.body.total).toBe(1);
expect(res.body.pendingCount).toBe(1);
expect(res.body.requests).toHaveLength(1);
expect(res.body.requests[0]).toMatchObject({
id: "apr-1",
actionCategory: "command_execution",
actionSummary: "Run command",
agentId: "agent-1",
});
});
it("returns detail with history", async () => {
const app = createApp();
const res = await get(app, "/api/approvals/apr-1");
expect(res.status).toBe(200);
expect(res.body.id).toBe("apr-1");
expect(res.body.history).toHaveLength(1);
expect(res.body.targetAction.summary).toBe("Run command");
});
it("returns 404 for missing request", async () => {
const app = createApp();
const res = await get(app, "/api/approval-requests/missing");
const res = await get(app, "/api/approvals/missing");
expect(res.status).toBe(404);
});
it("returns audit history", async () => {
it("decides approval and unpauses task/agent", async () => {
const app = createApp();
const res = await get(app, "/api/approval-requests/apr-1/audit");
const res = await request(
app,
"POST",
"/api/approvals/apr-1/decision",
JSON.stringify({ decision: "approve", comment: "looks good" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual([{ event: "created" }]);
});
it("approves and unpauses task/agent", async () => {
const app = createApp();
const res = await request(app, "POST", "/api/approval-requests/apr-1/approve", JSON.stringify({}));
expect(res.status).toBe(200);
expect((res.body as any).status).toBe("approved");
expect(res.body.status).toBe("approved");
expect(res.body.history.at(-1)?.eventType).toBe("approved");
expect(res.body.history.at(-1)?.note).toBe("looks good");
expect(state.task.paused).toBe(false);
expect(updateAgent).toHaveBeenCalledWith("agent-1", { pauseReason: undefined });
});
it("denies and unpauses task/agent", async () => {
it("supports deny decision", async () => {
const app = createApp();
const res = await request(app, "POST", "/api/approval-requests/apr-1/deny", JSON.stringify({}));
expect(res.status).toBe(200);
expect((res.body as any).status).toBe("denied");
expect(state.task.paused).toBe(false);
});
it("no-ops when task already unpaused", async () => {
state.task.paused = false;
const app = createApp();
const res = await request(app, "POST", "/api/approval-requests/apr-1/deny", JSON.stringify({}));
const res = await request(
app,
"POST",
"/api/approvals/apr-1/decision",
JSON.stringify({ decision: "deny" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.status).toBe("denied");
});
it("returns 409 for invalid transition", async () => {
const app = createApp();
const res = await request(app, "POST", "/api/approval-requests/apr-2/approve", JSON.stringify({}));
const res = await request(
app,
"POST",
"/api/approvals/apr-2/decision",
JSON.stringify({ decision: "approve" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(409);
});
});

View File

@@ -1,6 +1,7 @@
import { AgentStore, ApprovalRequestStore, type ApprovalRequestActorSnapshot, type ApprovalRequestStatus } from "@fusion/core";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
import { emitApprovalSseEvent } from "../sse.js";
const DEFAULT_ACTOR: ApprovalRequestActorSnapshot = {
actorId: "user",
@@ -8,10 +9,39 @@ const DEFAULT_ACTOR: ApprovalRequestActorSnapshot = {
actorName: "User",
};
function parseOptionalString(value: unknown, field: string): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value !== "string") throw badRequest(`${field} must be a string`);
return value;
interface ApprovalRequestSummaryDto {
id: string;
status: ApprovalRequestStatus;
actionCategory: string;
actionSummary: string;
agentId: string;
taskId?: string;
createdAt: string;
updatedAt: string;
decidedAt?: string;
decidedBy?: string;
}
interface ApprovalRequestDetailDto extends ApprovalRequestSummaryDto {
requester: ApprovalRequestActorSnapshot;
runId?: string;
requestedAt: string;
completedAt?: string;
targetAction: {
category: string;
action: string;
summary: string;
resourceType: string;
resourceId: string;
context?: Record<string, unknown>;
};
history: Array<{
id: string;
eventType: string;
actor: ApprovalRequestActorSnapshot;
note?: string;
createdAt: string;
}>;
}
function parseOptionalInt(value: unknown, field: string): number | undefined {
@@ -27,6 +57,46 @@ function parseStatus(value: unknown): ApprovalRequestStatus | undefined {
throw badRequest("status must be one of: pending, approved, denied, completed");
}
function getDeciderActorId(
history: Array<{ eventType: string; actor: ApprovalRequestActorSnapshot }>,
): string | undefined {
const decisionEvent = [...history].reverse().find((entry) => entry.eventType === "approved" || entry.eventType === "denied");
return decisionEvent?.actor.actorId;
}
function toSummaryDto(
request: import("@fusion/core").ApprovalRequest,
history: Array<{ eventType: string; actor: ApprovalRequestActorSnapshot }>,
): ApprovalRequestSummaryDto {
return {
id: request.id,
status: request.status,
actionCategory: request.targetAction.category,
actionSummary: request.targetAction.summary,
agentId: request.requester.actorId,
taskId: request.taskId,
createdAt: request.createdAt,
updatedAt: request.updatedAt,
decidedAt: request.decidedAt,
decidedBy: getDeciderActorId(history),
};
}
function toDetailDto(
request: import("@fusion/core").ApprovalRequest,
history: import("@fusion/core").ApprovalRequestAuditEvent[],
): ApprovalRequestDetailDto {
return {
...toSummaryDto(request, history),
requester: request.requester,
runId: request.runId,
requestedAt: request.requestedAt,
completedAt: request.completedAt,
targetAction: request.targetAction,
history,
};
}
async function resumeAfterDecision(params: {
scopedStore: import("@fusion/core").TaskStore;
request: import("@fusion/core").ApprovalRequest;
@@ -69,57 +139,55 @@ async function resumeAfterDecision(params: {
export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
const { router, getProjectContext, rethrowAsApiError, runtimeLogger } = ctx;
router.get("/approval-requests", async (req, res) => {
router.get("/approvals", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
const requests = approvalStore.list({
status: parseStatus(req.query.status),
requesterActorId: parseOptionalString(req.query.requesterActorId, "requesterActorId"),
taskId: parseOptionalString(req.query.taskId, "taskId"),
runId: parseOptionalString(req.query.runId, "runId"),
limit: parseOptionalInt(req.query.limit, "limit"),
offset: parseOptionalInt(req.query.offset, "offset"),
const status = parseStatus(req.query.status);
const limit = parseOptionalInt(req.query.limit, "limit") ?? 50;
const offset = parseOptionalInt(req.query.offset, "offset") ?? 0;
const requests = approvalStore.list({ status, limit, offset });
const summaries = requests.map((request) => {
const history = approvalStore.getAuditHistory(request.id);
return toSummaryDto(request, history);
});
res.json(requests);
const total = approvalStore.list({ status, limit: Number.MAX_SAFE_INTEGER, offset: 0 }).length;
const pendingCount = approvalStore.list({ status: "pending", limit: Number.MAX_SAFE_INTEGER, offset: 0 }).length;
res.json({ requests: summaries, total, pendingCount });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
router.get("/approval-requests/:id", async (req, res) => {
router.get("/approvals/:id", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
const requestId = String(req.params.id);
const request = approvalStore.get(requestId);
if (!request) throw notFound("Approval request not found");
res.json(request);
const history = approvalStore.getAuditHistory(requestId);
res.json(toDetailDto(request, history));
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
router.get("/approval-requests/:id/audit", async (req, res) => {
router.post("/approvals/:id/decision", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
const requestId = String(req.params.id);
const request = approvalStore.get(requestId);
if (!request) throw notFound("Approval request not found");
res.json(approvalStore.getAuditHistory(requestId));
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
const body = (req.body ?? {}) as { decision?: "approve" | "deny"; comment?: string; actor?: ApprovalRequestActorSnapshot };
if (body.decision !== "approve" && body.decision !== "deny") {
throw badRequest("decision must be one of: approve, deny");
}
if (body.comment !== undefined && typeof body.comment !== "string") {
throw badRequest("comment must be a string");
}
const decideHandler = (status: "approved" | "denied") => async (req: import("express").Request, res: import("express").Response) => {
try {
const body = (req.body ?? {}) as { actor?: ApprovalRequestActorSnapshot; note?: string };
const { store: scopedStore } = await getProjectContext(req);
const { store: scopedStore, projectId } = await getProjectContext(req);
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
const requestId = String(req.params.id);
const existing = approvalStore.get(requestId);
@@ -129,13 +197,11 @@ export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
if (!actor || typeof actor.actorId !== "string" || typeof actor.actorType !== "string" || typeof actor.actorName !== "string") {
throw badRequest("actor must include actorId, actorType, and actorName");
}
if (body.note !== undefined && typeof body.note !== "string") {
throw badRequest("note must be a string");
}
const targetStatus = body.decision === "approve" ? "approved" : "denied";
let updated;
try {
updated = approvalStore.decide(requestId, status, { actor, note: body.note });
updated = approvalStore.decide(requestId, targetStatus, { actor, note: body.comment });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Invalid approval request transition")) {
@@ -145,13 +211,14 @@ export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
}
await resumeAfterDecision({ scopedStore, request: updated, runtimeLogger });
res.json(updated);
const history = approvalStore.getAuditHistory(requestId);
const detail = toDetailDto(updated, history);
emitApprovalSseEvent("approval:updated", detail, projectId);
emitApprovalSseEvent("approval:decided", detail, projectId);
res.json(detail);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
};
router.post("/approval-requests/:id/approve", decideHandler("approved"));
router.post("/approval-requests/:id/deny", decideHandler("denied"));
});
}

View File

@@ -215,6 +215,18 @@ export type MessageSseEventType =
| "message:read"
| "message:deleted";
export type ApprovalSseEventType = "approval:requested" | "approval:updated" | "approval:decided";
type ApprovalSseListener = (event: ApprovalSseEventType, payload: unknown, projectId?: string) => void;
const approvalSseListeners = new Set<ApprovalSseListener>();
export function emitApprovalSseEvent(event: ApprovalSseEventType, payload: unknown, projectId?: string): void {
for (const listener of approvalSseListeners) {
listener(event, payload, projectId);
}
}
/**
* Normalized plugin lifecycle payload emitted via SSE.
* This is the stable contract the UI can reconcile.
@@ -556,6 +568,11 @@ export function createSSE(
send(`event: message:deleted\ndata: ${JSON.stringify({ id: messageId })}\n\n`);
};
const onApprovalEvent: ApprovalSseListener = (event, payload, eventProjectId) => {
if (projectId && eventProjectId && eventProjectId !== projectId) return;
send(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
};
// --- Chat store event handlers ---
const onChatSessionCreated = (session: unknown) => {
send(`event: chat:session:created\ndata: ${JSON.stringify(session)}\n\n`);
@@ -671,6 +688,7 @@ export function createSSE(
messageStore.off("message:read", onMessageRead);
messageStore.off("message:deleted", onMessageDeleted);
}
approvalSseListeners.delete(onApprovalEvent);
if (chatStore) {
chatStore.off("chat:session:created", onChatSessionCreated);
chatStore.off("chat:session:updated", onChatSessionUpdated);
@@ -799,6 +817,8 @@ export function createSSE(
// Sent as a named event so the client's EventSource can detect it
// (SSE comments starting with ":" are silently consumed and never
// fire event listeners in the browser).
approvalSseListeners.add(onApprovalEvent);
registerManagedConnection({
id: connectionId,
clientId,