diff --git a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java index 3f9d342d..70fc7322 100644 --- a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java +++ b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java @@ -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". + * + *

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. + * + *

v0 keeps the resume path through {@code WorkflowResumeController} + * with the pauseToken; this method does not yet wire a resolve→resume + * 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 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'} → diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/AwaitApprovalStepAdapter.java b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/AwaitApprovalStepAdapter.java index 4a5d9e16..082a6991 100644 --- a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/AwaitApprovalStepAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/AwaitApprovalStepAdapter.java @@ -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. * - *

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. + *

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 resolve→resume 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 + // resolve→resume 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())); }