feat(workflow): wire await_approval into the approval inbox

This commit is contained in:
matevip 2026-05-08 15:06:46 +08:00
parent 247caaac3d
commit e3af03645b
2 changed files with 117 additions and 4 deletions

View File

@ -260,6 +260,78 @@ public class ApprovalWorkflowService implements ApplicationRunner {
toolCallPayload, siblingToolCalls, agentId, null);
}
/**
* Workflow-scoped approval request creates a {@code mate_tool_approval}
* row keyed to a workflow run + step instead of a conversation, so an
* {@code await_approval} step is visible in the same approval inbox the
* tool-approval flow uses. Returns the row's auto-generated long id; the
* caller (typically {@code AwaitApprovalStepAdapter}) writes that id back
* onto {@code mate_workflow_run_pause.external_approval_id} so a future
* approval-resolve callback can map "approval X resolved → resume run Y".
*
* <p>The approval row's {@code conversationId} is set to
* {@code "workflow:run:{runId}"} as a synthetic key that lets the
* existing {@link ApprovalService#findPendingByConversation} surface the
* workflow approval to operator UIs without needing a parallel query
* surface. {@code toolName} is set to {@code "workflow:{kind}"} so the
* inbox can group / filter workflow approvals from tool approvals.
*
* <p>v0 keeps the resume path through {@code WorkflowResumeController}
* with the pauseToken; this method does not yet wire a resolveresume
* callback. The approval row's purpose for v0 is operator visibility
* and a stable foreign key for the pause record.
*/
public Long requestWorkflowApproval(long workspaceId,
long runId,
Long stepId,
String approvalKind,
String approvalMessage,
java.util.List<String> approverChannels,
Integer timeoutSecs) {
try {
ToolApprovalEntity entity = new ToolApprovalEntity();
// pendingId is the string handle the existing approval pipeline
// uses for resolve / get; "wf-" prefix lets future code branch
// on workflow-scoped vs tool-scoped approvals at a glance. The
// pending_id column is VARCHAR(32) so we trim a no-dashes UUID
// down to fit ("wf-" + 24 hex chars = 27 chars; collisions of
// 24 hex chars per workflow are astronomically rare and we
// also fall back to UNIQUE-key violation handling).
String shortId = java.util.UUID.randomUUID().toString()
.replace("-", "").substring(0, 24);
entity.setPendingId("wf-" + shortId);
entity.setConversationId("workflow:run:" + runId);
String kind = approvalKind == null || approvalKind.isBlank() ? "manual" : approvalKind.trim();
entity.setToolName("workflow:" + kind);
entity.setSummary(approvalMessage == null ? "" : approvalMessage);
// Encode approver channels in tool_arguments so the inbox UI can
// render which channels were asked. Plain JSON to keep parsing
// trivial on the read path.
try {
if (approverChannels != null && !approverChannels.isEmpty()) {
entity.setToolArguments(objectMapper.writeValueAsString(
java.util.Map.of(
"runId", runId,
"stepId", stepId,
"approverChannels", approverChannels)));
}
} catch (Exception e) {
log.warn("[ApprovalWorkflow] failed to encode approverChannels: {}", e.getMessage());
}
entity.setStatus("PENDING");
entity.setCreatedAt(LocalDateTime.now());
entity.setExpireAt(LocalDateTime.now().plusSeconds(
timeoutSecs != null && timeoutSecs > 0 ? timeoutSecs : 30 * 60));
approvalMapper.insert(entity);
log.info("[ApprovalWorkflow] requested workflow approval row id={}, runId={}, workspace={}, kind={}",
entity.getId(), runId, workspaceId, kind);
return entity.getId();
} catch (Exception e) {
log.warn("[ApprovalWorkflow] requestWorkflowApproval failed: {}", e.getMessage());
return null;
}
}
/**
* Resolve a pending approval (approve / deny) following the RFC-067 §4.2 two-phase
* contract: snapshot DB UPDATE conditional on {@code status='PENDING'}

View File

@ -1,7 +1,9 @@
package vip.mate.workflow.runtime.mode;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.workflow.compiler.ir.StepMode;
import vip.mate.workflow.compiler.ir.WorkflowStep;
import vip.mate.workflow.model.WorkflowRunPauseEntity;
@ -37,16 +39,24 @@ import java.util.UUID;
* declares a {@code timeoutSecs}; otherwise it stays {@code null} and the
* resumer treats the pause as open-ended.
*
* <p>The {@code external_approval_id} column on the pause row is reserved
* for a future integration that bridges workflow pauses into the
* tool-approval inbox; v0 leaves it null and routes resolution through the
* pause-token path above.
* <p>The {@code external_approval_id} column on the pause row links to the
* {@code mate_tool_approval} row created via
* {@link ApprovalWorkflowService#requestWorkflowApproval} so the workflow
* pause is visible in the same approval inbox the tool-approval flow uses.
* Resolution still goes through {@code WorkflowResumeController} +
* pauseToken the approval row is for operator visibility today; v1 wires
* the resolveresume callback so an inbox decision can also fire the
* resumer.
*/
@Component
public class AwaitApprovalStepAdapter implements StepAdapter {
private final WorkflowRunPauseMapper pauseMapper;
private final WorkflowRunStepMapper stepMapper;
/** Optional not all test contexts wire the approval module up. The
* adapter falls back to a no-op approval row when null. */
@Autowired(required = false)
private ApprovalWorkflowService approvalService;
public AwaitApprovalStepAdapter(WorkflowRunPauseMapper pauseMapper,
WorkflowRunStepMapper stepMapper) {
@ -77,6 +87,8 @@ public class AwaitApprovalStepAdapter implements StepAdapter {
String pauseToken = UUID.randomUUID().toString();
LocalDateTime now = LocalDateTime.now();
// Insert the pause row first so we have a stable id to reference
// even if the approval-service call below fails.
WorkflowRunPauseEntity pause = new WorkflowRunPauseEntity();
pause.setRunId(context.runId());
pause.setStepId(stepRow.getId());
@ -88,6 +100,35 @@ public class AwaitApprovalStepAdapter implements StepAdapter {
}
pauseMapper.insert(pause);
// Bridge into the approval inbox: create a mate_tool_approval row so
// the workflow pause shows up alongside tool approvals, then write
// the row id back as external_approval_id for the future
// resolveresume callback. Failures here are non-fatal the run
// is still resolvable via pauseToken + WorkflowResumeController.
if (approvalService != null) {
try {
Long approvalId = approvalService.requestWorkflowApproval(
context.workspaceId(),
context.runId(),
stepRow.getId(),
cfg.approvalKind(),
cfg.approvalMessage(),
cfg.approverChannels(),
cfg.timeoutSecs());
if (approvalId != null) {
pause.setExternalApprovalId(approvalId);
pauseMapper.updateById(pause);
}
} catch (Exception e) {
// Non-fatal log and continue. The pause row is the
// canonical record for v0; the approval row is a parallel
// visibility surface that can rebuild later if needed.
org.slf4j.LoggerFactory.getLogger(AwaitApprovalStepAdapter.class)
.warn("await_approval failed to create approval row for run {}: {}",
context.runId(), e.getMessage());
}
}
return StepResult.paused(pauseToken,
"awaiting " + (cfg.approvalKind() == null ? "approval" : cfg.approvalKind()));
}