Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2084,6 +2084,10 @@ Existing Agent Run with additional flow linkage fields.

Edges are sorted by priority (descending); first match wins.

**Approval routing**: On `human.approval`, the engine follows the edge whose `meta.outcome` matches the decision (`approved`/`rejected`). An approval falls back to the success path when no matching edge exists; a rejection without an explicit `meta.outcome=rejected` edge FAILS the run instead of falling through the success path.

**Loop routing**: `loop` nodes route to their body or done node via the `next_node_id` returned by the loop executor; without a done node the flow completes gracefully when the loop finishes.

### Execution Modes

**Normal Mode**: Engine follows edges deterministically. Agents run only when `agent.run` nodes are hit.
Expand Down Expand Up @@ -2126,7 +2130,8 @@ Registered via `huf_tools` hook so agents can interact with flows:
### Security

- **Expression edges**: AST-based restricted evaluator; no imports, no function calls, no attribute access
- **Human approval**: User/role verification before approve/reject
- **Human approval**: User/role verification before approve/reject (`approval_type` `user` and `users` are treated as synonyms)
- **Permissions**: Huf Manager has `create` on Flow Run (requires `bench migrate` on deploy to apply); agent tool handlers (`run_flow`, `get_flow_run`, `resume_flow_run`, `approve_flow_run`) enforce the same permission checks and approver-identity verification as the REST endpoints
- **Hop limit**: Safety guard against infinite loops (default 100)

## Subsystems and Advanced Architectures
Expand Down
105 changes: 105 additions & 0 deletions frontend/src/components/ApprovalsBell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Bell } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { approveFlowRun, getPendingApprovals, rejectFlowRun } from '../services/flowApi';
import type { PendingApproval } from '../services/flowApi';

const REFRESH_INTERVAL_MS = 60000;
const FLOW_EVENTS = ['frappe:flow_paused', 'frappe:flow_completed', 'frappe:flow_error'];

export function ApprovalsBell() {
const [approvals, setApprovals] = useState<PendingApproval[]>([]);

const fetchApprovals = useCallback(async () => {
try {
const list = await getPendingApprovals();
setApprovals(list || []);
} catch (error) {
console.warn('ApprovalsBell: failed to fetch pending approvals', error);
}
}, []);

useEffect(() => {
fetchApprovals();
const interval = setInterval(fetchApprovals, REFRESH_INTERVAL_MS);
FLOW_EVENTS.forEach((event) => window.addEventListener(event, fetchApprovals));
return () => {
clearInterval(interval);
FLOW_EVENTS.forEach((event) => window.removeEventListener(event, fetchApprovals));
};
}, [fetchApprovals]);

const handleApprove = async (flowRunId: string) => {
try {
await approveFlowRun(flowRunId);
} catch (error) {
console.warn('ApprovalsBell: failed to approve flow run', error);
}
await fetchApprovals();
};

const handleReject = async (flowRunId: string) => {
try {
await rejectFlowRun(flowRunId);
} catch (error) {
console.warn('ApprovalsBell: failed to reject flow run', error);
}
await fetchApprovals();
};

const count = approvals.length;

return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative" aria-label="Pending approvals">
<Bell className="w-4 h-4" />
{count > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-4 min-w-[16px] px-1 py-0 flex items-center justify-center"
>
{count > 9 ? '9+' : count}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="px-4 py-3 border-b text-sm font-semibold">Pending approvals</div>
{count === 0 ? (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
No approvals waiting
</div>
) : (
<div className="max-h-80 overflow-y-auto divide-y">
{approvals.map((approval) => (
<div key={approval.flow_run_id} className="px-4 py-3 space-y-2">
<div>
<div className="text-sm font-medium">{approval.title || approval.flow_id}</div>
<div className="text-xs text-muted-foreground">
{approval.flow_id}
{approval.waiting_since ? ` · waiting since ${approval.waiting_since}` : ''}
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => handleApprove(approval.flow_run_id)}>
Approve
</Button>
<Button size="sm" variant="outline" onClick={() => handleReject(approval.flow_run_id)}>
Reject
</Button>
<Button size="sm" variant="ghost" asChild>
<Link to={`/flows/${approval.flow_id}?run=${approval.flow_run_id}`}>Open</Link>
</Button>
</div>
</div>
))}
</div>
)}
</PopoverContent>
</Popover>
);
}
63 changes: 57 additions & 6 deletions frontend/src/components/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -413,12 +413,12 @@ export function RightSidebar({ onToggle, variant = 'panel' }: RightSidebarProps)
<div>
<Label htmlFor="edge-type" className="text-xs">Edge Type</Label>
<Select
value={selectedEdge.data?.type || 'always'}
value={selectedEdge.data?.edgeType || 'always'}
onValueChange={(value) => {
if (!activeFlow) return;
updateEdges(
activeFlow.edges.map((edge) =>
edge.id === selectedEdge.id ? { ...edge, data: { ...edge.data, type: value } } : edge
edge.id === selectedEdge.id ? { ...edge, data: { ...edge.data, edgeType: value } } : edge
)
);
}}
Expand All @@ -435,24 +435,75 @@ export function RightSidebar({ onToggle, variant = 'panel' }: RightSidebarProps)
</Select>
</div>

{selectedEdge.data?.type === 'expression' && (
{selectedEdge.data?.edgeType === 'expression' && (
<div>
<Label htmlFor="edge-expr" className="text-xs">Condition Expression</Label>
<Input
id="edge-expr"
value={selectedEdge.data?.expression || ''}
value={selectedEdge.data?.condition || ''}
onChange={(e) => {
if (!activeFlow) return;
updateEdges(
activeFlow.edges.map((edge) =>
edge.id === selectedEdge.id ? { ...edge, data: { ...edge.data, expression: e.target.value } } : edge
edge.id === selectedEdge.id ? { ...edge, data: { ...edge.data, condition: e.target.value } } : edge
)
);
}}
placeholder="e.g., {{context.status}} == 'approved'"
placeholder='e.g., context["status"] == "approved"'
/>
</div>
)}

<div>
<Label htmlFor="edge-priority" className="text-xs">Priority</Label>
<Input
id="edge-priority"
type="number"
value={selectedEdge.data?.priority ?? 0}
onChange={(e) => {
if (!activeFlow) return;
const priority = e.target.value === '' ? 0 : Number(e.target.value);
updateEdges(
activeFlow.edges.map((edge) =>
edge.id === selectedEdge.id ? { ...edge, data: { ...edge.data, priority } } : edge
)
);
}}
/>
<p className="text-[10px] text-muted-foreground mt-1">Higher priority edges are evaluated first</p>
</div>

<div>
<Label htmlFor="edge-outcome" className="text-xs">Approval Outcome</Label>
<Select
value={selectedEdge.data?.meta?.outcome || 'none'}
onValueChange={(value) => {
if (!activeFlow) return;
updateEdges(
activeFlow.edges.map((edge) => {
if (edge.id !== selectedEdge.id) return edge;
const meta = { ...(edge.data?.meta || {}) };
if (value === 'none') {
delete meta.outcome;
} else {
meta.outcome = value;
}
return { ...edge, data: { ...edge.data, meta } };
})
);
}}
>
<SelectTrigger id="edge-outcome">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="approved">approved</SelectItem>
<SelectItem value="rejected">rejected</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground mt-1">For edges leaving a Human Approval node: route this edge when the decision matches.</p>
</div>
</div>
</>
) : selectedNode ? (
Expand Down
1 change: 1 addition & 0 deletions frontend/src/contexts/SocketContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export function SocketProvider({ children }: { children: ReactNode }) {
'flow_node_end',
'flow_paused',
'flow_completed',
'flow_failed',
'flow_error',
];

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/layouts/UnifiedHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
BreadcrumbSeparator,
} from '../components/ui/breadcrumb';
import { BreadcrumbItem as BreadcrumbItemType } from './UnifiedLayout';
import { ApprovalsBell } from '../components/ApprovalsBell';

interface UnifiedHeaderProps {
actions?: ReactNode;
Expand Down Expand Up @@ -85,6 +86,7 @@ export function UnifiedHeader({ actions, breadcrumbs }: UnifiedHeaderProps) {
</div> */}

<div className="flex items-center gap-2">
<ApprovalsBell />
{actions}
</div>
</div>
Expand Down
25 changes: 24 additions & 1 deletion frontend/src/services/flowApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export interface BackendFlowGraph {

export interface BackendNode {
id: string;
type: 'trigger.webhook' | 'agent.run' | 'tool.call' | 'router.llm' | 'human.approval' | 'condition' | 'http_request' | 'transform' | 'loop' | 'end';
type: 'trigger.webhook' | 'trigger.schedule' | 'trigger.doc-event' | 'agent.run' | 'tool.call' | 'router.llm' | 'human.approval' | 'condition' | 'http_request' | 'transform' | 'loop' | 'end';
config: Record<string, unknown>;
/** Frontend-only: stored for visual layout, ignored by engine */
_position?: { x: number; y: number };
Expand Down Expand Up @@ -104,6 +104,19 @@ export interface FlowRunDetail {
completed_at: string | null;
}

/** Pending human approval (from get_pending_approvals endpoint) */
export interface PendingApproval {
flow_run_id: string;
flow_id: string;
current_node_id: string;
title: string;
instructions: string;
approval_type: string;
started_at: string | null;
waiting_since: string | null;
view_link: string;
}

// ─── Flow Definition APIs ────────────────────────────────────────────

const FLOW_LIST_FIELDS = [
Expand Down Expand Up @@ -281,3 +294,13 @@ export async function resumeFlowRun(
handleFrappeError(error, `Error resuming flow run ${flowRunId}`);
}
}

/** List flow runs waiting for human approval */
export async function getPendingApprovals(): Promise<PendingApproval[]> {
try {
const result = await call.get('huf.ai.flow_api.get_pending_approvals');
return result.message as PendingApproval[];
} catch (error) {
handleFrappeError(error, 'Error fetching pending approvals');
}
}
17 changes: 15 additions & 2 deletions frontend/src/services/flowSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ function mapFrontendNodeTypeToBackend(node: FlowNode): BackendNode['type'] {
if (nodeType === 'end') return 'end';

if (nodeType === 'trigger') {
const triggerType = node.data?.triggerConfig?.type;
if (triggerType === 'schedule') return 'trigger.schedule';
if (triggerType === 'doc-event') return 'trigger.doc-event';
return 'trigger.webhook';
}

Expand Down Expand Up @@ -178,10 +181,16 @@ function buildNodeData(node: BackendNode): FlowNodeData {
};

if (frontendType === 'trigger') {
const triggerType =
node.type === 'trigger.schedule'
? ('schedule' as const)
: node.type === 'trigger.doc-event'
? ('doc-event' as const)
: ('webhook' as const);
base.triggerConfig = {
type: 'webhook' as const,
type: triggerType,
...node.config,
};
} as FlowNodeData['triggerConfig'];
} else if (frontendType === 'action') {
base.actionConfig = {
type: mapBackendActionType(node.type),
Expand All @@ -195,6 +204,8 @@ function buildNodeData(node: BackendNode): FlowNodeData {
function getDefaultLabel(backendType: string): string {
const labels: Record<string, string> = {
'trigger.webhook': 'Webhook Trigger',
'trigger.schedule': 'Schedule Trigger',
'trigger.doc-event': 'Document Event Trigger',
'agent.run': 'Run Agent',
'tool.call': 'Call Tool',
'router.llm': 'LLM Router',
Expand All @@ -211,6 +222,8 @@ function getDefaultLabel(backendType: string): string {
function getDefaultIcon(backendType: string): string {
const icons: Record<string, string> = {
'trigger.webhook': 'Webhook',
'trigger.schedule': 'Clock',
'trigger.doc-event': 'Database',
'agent.run': 'Bot',
'tool.call': 'Play',
'router.llm': 'GitBranch',
Expand Down
Loading