Full Hands‑On Guide: Extending Spring AI Workflow Engine with Human‑in‑the‑Loop Approval
This article walks through adding a zero‑dependency human‑approval node to a Spring AI YAML‑DSL workflow engine, detailing the problem of critical business decisions, the JDK‑based pause‑and‑resume architecture, step‑by‑step code changes, best‑practice recommendations, and real‑world use cases such as large‑payment and content‑review approvals.
Project Background and Requirements
The original Spring AI visual orchestration project builds a LangGraph‑style YAML DSL workflow engine that supports tool calls, LLM dialogue, conditional loops, parallel execution, and variable passing. In real‑world scenarios certain decision points—large payments, AI‑generated content, sensitive operations, and permission requests—require human intervention.
Technical Architecture Design
The extension introduces a human_approval node type that pauses workflow execution, waits for an external API to submit an approval result, and then resumes or terminates the flow. The design relies solely on JDK concurrency utilities ( ConcurrentHashMap, CountDownLatch, and volatile fields) to avoid any third‑party dependencies.
Core Components Diagram
┌─────────────────────────────────────┐
│ AgentController │
│ /api/workflow/approval (async start) │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ WorkflowEngine │
│ Executes workflow, pauses at │
│ human_approval node │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ ApprovalManager │
│ - manages approval requests │
│ - blocks with CountDownLatch │
│ - tracks workflow instance state │
└──────┬───────────────────────┬─────┘
│ │
▼ ▼
┌──────────────┐ ┌────────────────────┐
│ Workflow Thread │ │ HTTP API Thread │
│ (blocked) │ │ (submit result) │
└──────────────┘ └─────────┬──────────┘
│
▼
┌────────────────────┐
│ ApprovalController │
│ /api/approval/submit│
└────────────────────┘Environment Preparation
JDK 17+
Maven 3.6+
Ollama (latest) as local LLM runtime
Model: qwen2.5:7b-instruct
Core Code Implementation
Extending WorkflowDefinition
public static class Node {
// existing fields …
// human_approval specific fields
private String approvalMessage; // description shown to approver
private long timeoutSeconds = 300; // default 5 minutes
private String onReject; // node to jump to when rejected
// getters & setters
public String getApprovalMessage() { return approvalMessage; }
public void setApprovalMessage(String approvalMessage) { this.approvalMessage = approvalMessage; }
public long getTimeoutSeconds() { return timeoutSeconds; }
public void setTimeoutSeconds(long timeoutSeconds) { this.timeoutSeconds = timeoutSeconds; }
public String getOnReject() { return onReject; }
public void setOnReject(String onReject) { this.onReject = onReject; }
}Implementing ApprovalManager
public class ApprovalManager {
public static class ApprovalRequest {
private final String workflowInstanceId;
private final String nodeId;
private final String message;
private final Map<String, Object> context;
private final CountDownLatch latch = new CountDownLatch(1);
private volatile ApprovalResult result;
// constructor & getters/setters omitted for brevity
}
public static class ApprovalResult {
private final boolean approved;
private final String comment;
private ApprovalResult(boolean approved, String comment) { this.approved = approved; this.comment = comment; }
public boolean isApproved() { return approved; }
public String getComment() { return comment; }
public static ApprovalResult approve(String comment) { return new ApprovalResult(true, comment); }
public static ApprovalResult reject(String comment) { return new ApprovalResult(false, comment); }
}
private final Map<String, ApprovalRequest> pendingApprovals = new ConcurrentHashMap<>();
private final Map<String, WorkflowInstance> workflowInstances = new ConcurrentHashMap<>();
public WorkflowInstance createInstance(String instanceId, String workflowPath, Map<String, Object> context) {
WorkflowInstance instance = new WorkflowInstance(instanceId, workflowPath, context);
workflowInstances.put(instanceId, instance);
return instance;
}
public ApprovalResult requestApproval(String instanceId, String nodeId, String message,
Map<String, Object> context, long timeoutSeconds) throws InterruptedException {
ApprovalRequest request = new ApprovalRequest(instanceId, nodeId, message, context);
pendingApprovals.put(instanceId + ":" + nodeId, request);
WorkflowInstance instance = workflowInstances.get(instanceId);
if (instance != null) {
instance.setStatus("WAITING_APPROVAL");
instance.setCurrentNode(nodeId);
}
System.out.println("⏸️ Workflow [" + instanceId + "] paused at node [" + nodeId + "] awaiting human approval…");
boolean completed = request.getLatch().await(timeoutSeconds, TimeUnit.SECONDS);
if (!completed) {
pendingApprovals.remove(instanceId + ":" + nodeId);
throw new RuntimeException("Approval timeout (" + timeoutSeconds + " seconds)");
}
return request.getResult();
}
public boolean submitApproval(String instanceId, String nodeId, ApprovalResult result) {
String key = instanceId + ":" + nodeId;
ApprovalRequest request = pendingApprovals.get(key);
if (request == null) return false;
request.setResult(result);
request.getLatch().countDown();
pendingApprovals.remove(key);
WorkflowInstance instance = workflowInstances.get(instanceId);
if (instance != null) {
instance.setStatus(result.isApproved() ? "RUNNING" : "REJECTED");
}
return true;
}
// additional helper methods omitted for brevity
}Modifying WorkflowEngine
@Component
public class WorkflowEngine {
private final ChatClient chatClient;
private final Map<String, Function<String, String>> toolRegistry;
private final ApprovalManager approvalManager; // new injection
public WorkflowEngine(ChatClient chatClient,
Map<String, Function<String, String>> toolRegistry,
ApprovalManager approvalManager) {
this.chatClient = chatClient;
this.toolRegistry = toolRegistry;
this.approvalManager = approvalManager;
}
private String executeNode(WorkflowDefinition.Node node, Map<String, Object> context,
WorkflowDefinition wf, String instanceId) {
switch (node.getType()) {
case "start": return getNextNodeId(wf, node.getId());
case "tool": return executeToolNode(node, context, wf);
case "llm": return executeLlmNode(node, context, wf);
case "while": return executeWhileNode(node, context, wf, instanceId);
case "parallel": return executeParallelNode(node, context, wf, instanceId);
case "human_approval": return executeHumanApprovalNode(node, context, wf, instanceId);
default: return getNextNodeId(wf, node.getId());
}
}
private String executeHumanApprovalNode(WorkflowDefinition.Node node, Map<String, Object> context,
WorkflowDefinition wf, String instanceId) {
String approvalMessage = node.getApprovalMessage();
if (approvalMessage == null || approvalMessage.trim().isEmpty()) {
approvalMessage = "Please approve node: " + node.getId();
}
long timeout = node.getTimeoutSeconds() <= 0 ? 300 : node.getTimeoutSeconds();
try {
ApprovalManager.ApprovalResult result = approvalManager.requestApproval(
instanceId, node.getId(), approvalMessage, context, timeout);
String output = String.format("Approval result: %s | Comment: %s",
result.isApproved() ? "APPROVED" : "REJECTED", result.getComment());
saveOutput(node.getId(), output, context);
context.put(node.getId() + ".approved", result.isApproved());
context.put(node.getId() + ".comment", result.getComment());
if (result.isApproved()) {
System.out.println("✅ Approval passed, continue execution");
return getNextNodeId(wf, node.getId());
} else {
System.out.println("❌ Approval rejected");
String onReject = node.getOnReject();
return (onReject != null && !onReject.isEmpty()) ? onReject : "end";
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Approval interrupted", e);
}
}
// other node execution methods (tool, llm, while, parallel) unchanged
}Human‑Approval Node Execution Flow
When the engine reaches a human_approval node it calls ApprovalManager.requestApproval, which creates an ApprovalRequest and blocks the workflow thread with CountDownLatch.await. An external client posts the decision to /api/approval/submit; the manager stores the result, counts down the latch, and the workflow thread resumes, storing the outcome in the context and branching according to onReject if necessary.
Practical Scenarios
💰 Large‑payment approval – payment_approval_workflow.yaml demonstrates a 10‑minute timeout and a rejection branch.
📝 Content‑review before publishing – human reviewer decides whether AI‑generated copy can be released.
🔐 Sensitive operation confirmation – delete or modify actions require explicit sign‑off.
👤 Permission‑grant requests – administrators approve role assignments.
Best Practices & Common Pitfalls
Timeout configuration : set a realistic timeout (e.g., 300 s) to avoid indefinite blocking.
Reject handling : always define onReject to control the fallback path; otherwise the workflow ends abruptly.
Concurrency safety : use ConcurrentHashMap for the pending‑approval map and declare mutable result fields as volatile.
Visibility : volatile ensures that status changes made by the approval thread are seen by the workflow thread.
Asynchronous execution : run the engine in a separate thread to keep HTTP responses non‑blocking.
Future Extension Directions
Persist approval records to a database for audit trails.
Send email or WebSocket notifications to approvers.
Support multi‑level approval chains.
Integrate real‑time push via Spring Messaging.
Conclusion
The guide demonstrates a complete, zero‑dependency solution for inserting human‑in‑the‑loop checkpoints into a Spring AI workflow engine. By leveraging JDK concurrency primitives, developers gain fine‑grained control over pause/resume semantics, configurable timeouts, and flexible rejection handling, making the engine suitable for high‑risk business processes such as large‑value payments, content moderation, and privileged operations.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
The Dominant Programmer
Resources and tutorials for programmers' advanced learning journey. Advanced tracks in Java, Python, and C#. Blog: https://blog.csdn.net/badao_liumang_qizhi
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
