diff --git a/control-plane/config/agentfield.yaml b/control-plane/config/agentfield.yaml index eff7dd4e..7340f8a3 100644 --- a/control-plane/config/agentfield.yaml +++ b/control-plane/config/agentfield.yaml @@ -12,6 +12,9 @@ agentfield: webhook_max_attempts: 3 # Number of attempts before marking the webhook as failed webhook_retry_backoff: 1s # Initial backoff between webhook retries (exponential) webhook_max_retry_backoff: 5s # Upper bound for webhook retry backoff + approval: + webhook_secret: "" # Optional HMAC-SHA256 secret for verifying approval webhook callbacks + default_expiry_hours: 72 # Default approval expiry (hours) ui: enabled: true diff --git a/control-plane/internal/config/config.go b/control-plane/internal/config/config.go index 9c34ca76..db102aae 100644 --- a/control-plane/internal/config/config.go +++ b/control-plane/internal/config/config.go @@ -37,6 +37,15 @@ type AgentFieldConfig struct { NodeHealth NodeHealthConfig `yaml:"node_health" mapstructure:"node_health"` ExecutionCleanup ExecutionCleanupConfig `yaml:"execution_cleanup" mapstructure:"execution_cleanup"` ExecutionQueue ExecutionQueueConfig `yaml:"execution_queue" mapstructure:"execution_queue"` + Approval ApprovalConfig `yaml:"approval" mapstructure:"approval"` +} + +// ApprovalConfig holds configuration for the execution approval workflow. +// The control plane manages execution state only — agents are responsible for +// communicating with external approval services (e.g. hax-sdk). +type ApprovalConfig struct { + WebhookSecret string `yaml:"webhook_secret" mapstructure:"webhook_secret"` // Optional HMAC-SHA256 secret for verifying webhook callbacks + DefaultExpiryHours int `yaml:"default_expiry_hours" mapstructure:"default_expiry_hours"` // Default approval expiry (hours); 0 = 72h } // NodeHealthConfig holds configuration for agent node health monitoring. @@ -303,6 +312,16 @@ func applyEnvOverrides(cfg *Config) { cfg.Features.DID.Authorization.InternalToken = val } + // Approval workflow overrides + if val := os.Getenv("AGENTFIELD_APPROVAL_WEBHOOK_SECRET"); val != "" { + cfg.AgentField.Approval.WebhookSecret = val + } + if val := os.Getenv("AGENTFIELD_APPROVAL_DEFAULT_EXPIRY_HOURS"); val != "" { + if i, err := strconv.Atoi(val); err == nil { + cfg.AgentField.Approval.DefaultExpiryHours = i + } + } + // Connector overrides if val := os.Getenv("AGENTFIELD_CONNECTOR_ENABLED"); val != "" { cfg.Features.Connector.Enabled = val == "true" || val == "1" diff --git a/control-plane/internal/events/execution_events.go b/control-plane/internal/events/execution_events.go index c2ff374f..f3e4baa8 100644 --- a/control-plane/internal/events/execution_events.go +++ b/control-plane/internal/events/execution_events.go @@ -13,11 +13,13 @@ import ( type ExecutionEventType string const ( - ExecutionCreated ExecutionEventType = "execution_created" - ExecutionStarted ExecutionEventType = "execution_started" - ExecutionUpdated ExecutionEventType = "execution_updated" - ExecutionCompleted ExecutionEventType = "execution_completed" - ExecutionFailed ExecutionEventType = "execution_failed" + ExecutionCreated ExecutionEventType = "execution_created" + ExecutionStarted ExecutionEventType = "execution_started" + ExecutionUpdated ExecutionEventType = "execution_updated" + ExecutionCompleted ExecutionEventType = "execution_completed" + ExecutionFailed ExecutionEventType = "execution_failed" + ExecutionWaiting ExecutionEventType = "execution_waiting" + ExecutionApprovalResolved ExecutionEventType = "execution_approval_resolved" ) // ExecutionEvent represents an execution state change event @@ -177,3 +179,31 @@ func PublishExecutionFailed(executionID, workflowID, agentNodeID string, data in } GlobalExecutionEventBus.Publish(event) } + +// PublishExecutionWaiting publishes an event when an execution enters the waiting state. +func PublishExecutionWaiting(executionID, workflowID, agentNodeID string, data interface{}) { + event := ExecutionEvent{ + Type: ExecutionWaiting, + ExecutionID: executionID, + WorkflowID: workflowID, + AgentNodeID: agentNodeID, + Status: types.ExecutionStatusWaiting, + Timestamp: time.Now(), + Data: data, + } + GlobalExecutionEventBus.Publish(event) +} + +// PublishExecutionApprovalResolved publishes an event when an approval decision is received. +func PublishExecutionApprovalResolved(executionID, workflowID, agentNodeID, newStatus string, data interface{}) { + event := ExecutionEvent{ + Type: ExecutionApprovalResolved, + ExecutionID: executionID, + WorkflowID: workflowID, + AgentNodeID: agentNodeID, + Status: newStatus, + Timestamp: time.Now(), + Data: data, + } + GlobalExecutionEventBus.Publish(event) +} diff --git a/control-plane/internal/handlers/execute.go b/control-plane/internal/handlers/execute.go index 1c47c3ba..53e68580 100644 --- a/control-plane/internal/handlers/execute.go +++ b/control-plane/internal/handlers/execute.go @@ -39,6 +39,8 @@ type ExecutionStore interface { StoreWorkflowExecution(ctx context.Context, execution *types.WorkflowExecution) error UpdateWorkflowExecution(ctx context.Context, executionID string, updateFunc func(*types.WorkflowExecution) (*types.WorkflowExecution, error)) error GetWorkflowExecution(ctx context.Context, executionID string) (*types.WorkflowExecution, error) + QueryWorkflowExecutions(ctx context.Context, filters types.WorkflowExecutionFilters) ([]*types.WorkflowExecution, error) + StoreWorkflowExecutionEvent(ctx context.Context, event *types.WorkflowExecutionEvent) error GetExecutionEventBus() *events.ExecutionEventBus } @@ -88,6 +90,7 @@ type ExecutionStatusResponse struct { ExecutionID string `json:"execution_id"` RunID string `json:"run_id"` Status string `json:"status"` + StatusReason *string `json:"status_reason,omitempty"` Result interface{} `json:"result,omitempty"` Error *string `json:"error,omitempty"` ErrorDetails interface{} `json:"error_details,omitempty"` @@ -96,6 +99,10 @@ type ExecutionStatusResponse struct { DurationMS *int64 `json:"duration_ms,omitempty"` WebhookRegistered bool `json:"webhook_registered"` WebhookEvents []*types.ExecutionWebhookEvent `json:"webhook_events,omitempty"` + // Approval fields (populated when execution has an active approval request) + ApprovalRequestID *string `json:"approval_request_id,omitempty"` + ApprovalStatus *string `json:"approval_status,omitempty"` + ApprovalRequestURL *string `json:"approval_request_url,omitempty"` } // BatchStatusRequest allows the UI to fetch multiple execution statuses at once. @@ -107,12 +114,13 @@ type BatchStatusRequest struct { type BatchStatusResponse map[string]ExecutionStatusResponse type executionStatusUpdateRequest struct { - Status string `json:"status" binding:"required"` - Result map[string]interface{} `json:"result,omitempty"` - Error string `json:"error,omitempty"` - DurationMS *int64 `json:"duration_ms,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - Progress *int `json:"progress,omitempty"` + Status string `json:"status" binding:"required"` + StatusReason *string `json:"status_reason,omitempty"` + Result map[string]interface{} `json:"result,omitempty"` + Error string `json:"error,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Progress *int `json:"progress,omitempty"` } type executionController struct { @@ -411,7 +419,7 @@ func (c *executionController) handleStatus(ctx *gin.Context) { return } - ctx.JSON(http.StatusOK, renderStatus(exec)) + ctx.JSON(http.StatusOK, c.renderStatusWithApproval(reqCtx, exec)) } func (c *executionController) handleBatchStatus(ctx *gin.Context) { @@ -440,7 +448,7 @@ func (c *executionController) handleBatchStatus(ctx *gin.Context) { } continue } - response[id] = renderStatus(exec) + response[id] = c.renderStatusWithApproval(reqCtx, exec) } ctx.JSON(http.StatusOK, response) @@ -488,7 +496,29 @@ func (c *executionController) handleStatusUpdate(ctx *gin.Context) { return nil, fmt.Errorf("execution %s not found", executionID) } + // Guard: executions in "waiting" state can only transition to + // running, cancelled, or failed. The approval webhook handler + // manages the waiting→running transition; direct jumps to + // succeeded or timeout would desync the executions and + // workflow_executions tables. + if current.Status == types.ExecutionStatusWaiting { + switch normalizedStatus { + case string(types.ExecutionStatusRunning), + string(types.ExecutionStatusCancelled), + string(types.ExecutionStatusFailed): + // allowed + default: + logger.Logger.Warn(). + Str("execution_id", executionID). + Str("current_status", string(current.Status)). + Str("requested_status", normalizedStatus). + Msg("rejecting status update: execution is waiting for approval") + return nil, fmt.Errorf("execution %s is in 'waiting' state; only running, cancelled, or failed transitions are allowed", executionID) + } + } + current.Status = normalizedStatus + current.StatusReason = req.StatusReason if len(resultBytes) > 0 { current.ResultPayload = json.RawMessage(resultBytes) current.ResultURI = resultURI @@ -547,6 +577,8 @@ func (c *executionController) handleStatusUpdate(ctx *gin.Context) { elapsed = time.Duration(*updated.DurationMS) * time.Millisecond } + c.updateWorkflowExecutionStatus(reqCtx, executionID, normalizedStatus, req.StatusReason) + if isTerminal { c.updateWorkflowExecutionFinalState(reqCtx, executionID, types.ExecutionStatus(normalizedStatus), updated.ResultPayload, elapsed, errorMsg) if updated.WebhookRegistered { @@ -559,12 +591,63 @@ func (c *executionController) handleStatusUpdate(ctx *gin.Context) { "error": req.Error, "progress": req.Progress, } + if req.StatusReason != nil && strings.TrimSpace(*req.StatusReason) != "" { + eventData["status_reason"] = strings.TrimSpace(*req.StatusReason) + } if inputPayload := decodeJSON(updated.InputPayload); inputPayload != nil { eventData["input"] = inputPayload } c.publishExecutionEvent(updated, normalizedStatus, eventData) - ctx.JSON(http.StatusOK, renderStatus(updated)) + ctx.JSON(http.StatusOK, c.renderStatusWithApproval(reqCtx, updated)) +} + +func (c *executionController) updateWorkflowExecutionStatus( + ctx context.Context, + executionID string, + status string, + statusReason *string, +) { + if c.store == nil { + return + } + + var normalizedReason *string + if statusReason != nil { + trimmed := strings.TrimSpace(*statusReason) + if trimmed != "" { + normalizedReason = &trimmed + } + } + + err := c.store.UpdateWorkflowExecution(ctx, executionID, func(current *types.WorkflowExecution) (*types.WorkflowExecution, error) { + if current == nil { + return nil, fmt.Errorf("execution with ID %s not found", executionID) + } + + current.Status = status + current.StatusReason = normalizedReason + current.UpdatedAt = time.Now().UTC() + + if !types.IsTerminalExecutionStatus(status) { + current.CompletedAt = nil + if current.DurationMS != nil { + current.DurationMS = nil + } + } + + return current, nil + }) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "not found") { + return + } + logger.Logger.Error(). + Err(err). + Str("execution_id", executionID). + Str("status", status). + Msg("failed to update workflow execution status") + } } func (c *executionController) publishExecutionEvent(exec *types.Execution, status string, data map[string]interface{}) { @@ -1052,11 +1135,22 @@ func (c *executionController) completeExecution(ctx context.Context, plan *prepa resultURI := c.savePayload(ctx, result) var lastErr error + var alreadyCancelled bool for attempt := 0; attempt < 5; attempt++ { updated, err := c.store.UpdateExecutionRecord(ctx, plan.exec.ExecutionID, func(current *types.Execution) (*types.Execution, error) { if current == nil { return nil, fmt.Errorf("execution %s not found", plan.exec.ExecutionID) } + // Guard: don't overwrite if already cancelled (e.g. by approval rejection webhook) + // or waiting for approval — the approval webhook handler manages the transition. + if current.Status == types.ExecutionStatusCancelled || current.Status == types.ExecutionStatusWaiting { + logger.Logger.Info(). + Str("execution_id", plan.exec.ExecutionID). + Str("current_status", string(current.Status)). + Msg("skipping completion update; execution already cancelled or waiting for approval") + alreadyCancelled = true + return current, nil + } now := time.Now().UTC() current.Status = types.ExecutionStatusSucceeded current.ResultPayload = json.RawMessage(result) @@ -1069,6 +1163,9 @@ func (c *executionController) completeExecution(ctx context.Context, plan *prepa return current, nil }) if err == nil { + if alreadyCancelled { + return nil + } c.updateWorkflowExecutionFinalState( ctx, plan.exec.ExecutionID, @@ -1104,11 +1201,22 @@ func (c *executionController) failExecution(ctx context.Context, plan *preparedE errMsg := callErr.Error() resultURI := c.savePayload(ctx, result) var lastErr error + var alreadyCancelled bool for attempt := 0; attempt < 5; attempt++ { updated, err := c.store.UpdateExecutionRecord(ctx, plan.exec.ExecutionID, func(current *types.Execution) (*types.Execution, error) { if current == nil { return nil, fmt.Errorf("execution %s not found", plan.exec.ExecutionID) } + // Guard: don't overwrite if already cancelled (e.g. by approval rejection webhook) + // or waiting for approval — the approval webhook handler manages the transition. + if current.Status == types.ExecutionStatusCancelled || current.Status == types.ExecutionStatusWaiting { + logger.Logger.Info(). + Str("execution_id", plan.exec.ExecutionID). + Str("current_status", string(current.Status)). + Msg("skipping failure update; execution already cancelled or waiting for approval") + alreadyCancelled = true + return current, nil + } now := time.Now().UTC() current.Status = types.ExecutionStatusFailed current.ErrorMessage = &errMsg @@ -1123,6 +1231,9 @@ func (c *executionController) failExecution(ctx context.Context, plan *preparedE return current, nil }) if err == nil { + if alreadyCancelled { + return nil + } c.updateWorkflowExecutionFinalState( ctx, plan.exec.ExecutionID, @@ -1457,6 +1568,7 @@ func renderStatus(exec *types.Execution) ExecutionStatusResponse { ExecutionID: exec.ExecutionID, RunID: exec.RunID, Status: exec.Status, + StatusReason: exec.StatusReason, Result: decodeJSON(exec.ResultPayload), Error: exec.ErrorMessage, StartedAt: exec.StartedAt.UTC().Format(time.RFC3339), @@ -1473,6 +1585,23 @@ func renderStatus(exec *types.Execution) ExecutionStatusResponse { return resp } +// renderStatusWithApproval enriches the base status response with approval +// fields from the corresponding WorkflowExecution record, if one exists. +func (c *executionController) renderStatusWithApproval(ctx context.Context, exec *types.Execution) ExecutionStatusResponse { + resp := renderStatus(exec) + + // Best-effort enrichment — if the lookup fails we still return the base response. + wfExec, err := c.store.GetWorkflowExecution(ctx, exec.ExecutionID) + if err != nil || wfExec == nil { + return resp + } + + resp.ApprovalRequestID = wfExec.ApprovalRequestID + resp.ApprovalStatus = wfExec.ApprovalStatus + resp.ApprovalRequestURL = wfExec.ApprovalRequestURL + return resp +} + func (c *executionController) ensureWorkflowExecutionRecord(ctx context.Context, exec *types.Execution, target *parsedTarget, payload []byte) { workflowExec := c.buildWorkflowExecutionRecord(ctx, exec, target, payload) if workflowExec == nil { diff --git a/control-plane/internal/handlers/execute_approval.go b/control-plane/internal/handlers/execute_approval.go new file mode 100644 index 00000000..1fd1a86b --- /dev/null +++ b/control-plane/internal/handlers/execute_approval.go @@ -0,0 +1,348 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/Agent-Field/agentfield/control-plane/internal/events" + "github.com/Agent-Field/agentfield/control-plane/internal/logger" + "github.com/Agent-Field/agentfield/control-plane/pkg/types" + + "github.com/gin-gonic/gin" +) + +// approvalController handles approval-related endpoints. +// The control plane manages execution state only — the agent is responsible +// for communicating with external approval services (e.g. hax-sdk). +type approvalController struct { + store ExecutionStore +} + +// RequestApprovalRequest is the body for POST /executions/:execution_id/request-approval. +// The agent creates the approval request externally and passes the metadata here +// so the CP can track it and transition the execution to "waiting". +type RequestApprovalRequest struct { + ApprovalRequestID string `json:"approval_request_id" binding:"required"` + ApprovalRequestURL string `json:"approval_request_url,omitempty"` + CallbackURL string `json:"callback_url,omitempty"` + ExpiresInHours *int `json:"expires_in_hours,omitempty"` +} + +// RequestApprovalResponse is returned when the execution transitions to waiting. +type RequestApprovalResponse struct { + ApprovalRequestID string `json:"approval_request_id"` + ApprovalRequestURL string `json:"approval_request_url"` + Status string `json:"status"` +} + +// ApprovalStatusResponse is returned by GET /executions/:execution_id/approval-status. +type ApprovalStatusResponse struct { + Status string `json:"status"` + Response *string `json:"response,omitempty"` + RequestURL string `json:"request_url,omitempty"` + RequestedAt string `json:"requested_at,omitempty"` + RespondedAt *string `json:"responded_at,omitempty"` + ExpiresAt *string `json:"expires_at,omitempty"` +} + +// RequestApprovalHandler transitions an execution to "waiting" and stores +// the approval metadata provided by the agent. +func RequestApprovalHandler(store ExecutionStore) gin.HandlerFunc { + ctrl := &approvalController{store: store} + return ctrl.handleRequestApproval +} + +// GetApprovalStatusHandler returns the approval status for an execution. +func GetApprovalStatusHandler(store ExecutionStore) gin.HandlerFunc { + ctrl := &approvalController{store: store} + return ctrl.handleGetApprovalStatus +} + +func (c *approvalController) handleRequestApproval(ctx *gin.Context) { + executionID := ctx.Param("execution_id") + if executionID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "execution_id is required"}) + return + } + + var req RequestApprovalRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid request body: %v", err)}) + return + } + + reqCtx := ctx.Request.Context() + + // Look up the workflow execution and validate state + wfExec, err := c.store.GetWorkflowExecution(reqCtx, executionID) + if err != nil { + logger.Logger.Error().Err(err).Str("execution_id", executionID).Msg("failed to get workflow execution for approval request") + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up execution"}) + return + } + if wfExec == nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("execution %s not found", executionID)}) + return + } + + // Execution must be in running state to request approval + normalized := types.NormalizeExecutionStatus(wfExec.Status) + if normalized != types.ExecutionStatusRunning { + ctx.JSON(http.StatusConflict, gin.H{ + "error": "invalid_state", + "message": fmt.Sprintf("execution is in '%s' state; must be 'running' to request approval", normalized), + }) + return + } + + // Prevent duplicate approval requests + if wfExec.ApprovalRequestID != nil && *wfExec.ApprovalRequestID != "" { + ctx.JSON(http.StatusConflict, gin.H{ + "error": "approval_already_requested", + "message": "An approval request already exists for this execution", + "approval_request_id": *wfExec.ApprovalRequestID, + }) + return + } + + now := time.Now().UTC() + statusReason := "waiting_for_approval" + approvalStatus := "pending" + + // Compute expiry timestamp from expires_in_hours (default 72h) + expiryHours := 72 + if req.ExpiresInHours != nil && *req.ExpiresInHours > 0 { + expiryHours = *req.ExpiresInHours + } + expiresAt := now.Add(time.Duration(expiryHours) * time.Hour) + + // Transition the lightweight execution record to waiting + _, updateErr := c.store.UpdateExecutionRecord(reqCtx, executionID, func(current *types.Execution) (*types.Execution, error) { + if current == nil { + return nil, fmt.Errorf("execution %s not found", executionID) + } + current.Status = types.ExecutionStatusWaiting + current.StatusReason = &statusReason + return current, nil + }) + if updateErr != nil { + logger.Logger.Error().Err(updateErr).Str("execution_id", executionID).Msg("failed to update execution record to waiting") + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update execution status"}) + return + } + + // Update the workflow execution with approval metadata + waiting status + err = c.store.UpdateWorkflowExecution(reqCtx, executionID, func(current *types.WorkflowExecution) (*types.WorkflowExecution, error) { + if current == nil { + return nil, fmt.Errorf("execution %s not found", executionID) + } + current.Status = types.ExecutionStatusWaiting + current.StatusReason = &statusReason + current.ApprovalRequestID = &req.ApprovalRequestID + if req.ApprovalRequestURL != "" { + current.ApprovalRequestURL = &req.ApprovalRequestURL + } + current.ApprovalStatus = &approvalStatus + current.ApprovalRequestedAt = &now + if req.CallbackURL != "" { + current.ApprovalCallbackURL = &req.CallbackURL + } + current.ApprovalExpiresAt = &expiresAt + return current, nil + }) + if err != nil { + logger.Logger.Error().Err(err).Str("execution_id", executionID).Msg("failed to update workflow execution with approval data") + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update execution with approval data"}) + return + } + + // Emit execution event for observability + waitingStatus := types.ExecutionStatusWaiting + eventPayload, _ := json.Marshal(map[string]interface{}{ + "approval_request_id": req.ApprovalRequestID, + "approval_request_url": req.ApprovalRequestURL, + "wait_kind": "approval", + }) + event := &types.WorkflowExecutionEvent{ + ExecutionID: executionID, + WorkflowID: wfExec.WorkflowID, + RunID: wfExec.RunID, + EventType: "execution.waiting", + Status: &waitingStatus, + StatusReason: &statusReason, + Payload: eventPayload, + EmittedAt: now, + } + if storeErr := c.store.StoreWorkflowExecutionEvent(reqCtx, event); storeErr != nil { + logger.Logger.Warn().Err(storeErr).Str("execution_id", executionID).Msg("failed to store approval event (non-fatal)") + } + + // Publish waiting event to the execution event bus + if bus := c.store.GetExecutionEventBus(); bus != nil { + bus.Publish(events.ExecutionEvent{ + Type: events.ExecutionWaiting, + ExecutionID: executionID, + WorkflowID: wfExec.WorkflowID, + AgentNodeID: wfExec.AgentNodeID, + Status: types.ExecutionStatusWaiting, + Timestamp: now, + Data: map[string]interface{}{ + "status_reason": statusReason, + "approval_request_id": req.ApprovalRequestID, + "approval_request_url": req.ApprovalRequestURL, + "wait_kind": "approval", + }, + }) + } + + logger.Logger.Info(). + Str("execution_id", executionID). + Str("approval_request_id", req.ApprovalRequestID). + Msg("execution transitioned to waiting for approval") + + ctx.JSON(http.StatusOK, RequestApprovalResponse{ + ApprovalRequestID: req.ApprovalRequestID, + ApprovalRequestURL: req.ApprovalRequestURL, + Status: "pending", + }) +} + +func (c *approvalController) handleGetApprovalStatus(ctx *gin.Context) { + executionID := ctx.Param("execution_id") + if executionID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "execution_id is required"}) + return + } + + reqCtx := ctx.Request.Context() + wfExec, err := c.store.GetWorkflowExecution(reqCtx, executionID) + if err != nil { + logger.Logger.Error().Err(err).Str("execution_id", executionID).Msg("failed to get workflow execution for approval status") + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up execution"}) + return + } + if wfExec == nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("execution %s not found", executionID)}) + return + } + + if wfExec.ApprovalRequestID == nil { + ctx.JSON(http.StatusNotFound, gin.H{ + "error": "no_approval_request", + "message": "No approval request exists for this execution", + }) + return + } + + status := "unknown" + if wfExec.ApprovalStatus != nil { + status = *wfExec.ApprovalStatus + } + + requestedAt := "" + if wfExec.ApprovalRequestedAt != nil { + requestedAt = wfExec.ApprovalRequestedAt.Format(time.RFC3339) + } + + var respondedAt *string + if wfExec.ApprovalRespondedAt != nil { + formatted := wfExec.ApprovalRespondedAt.Format(time.RFC3339) + respondedAt = &formatted + } + + requestURL := "" + if wfExec.ApprovalRequestURL != nil { + requestURL = *wfExec.ApprovalRequestURL + } + + var expiresAtStr *string + if wfExec.ApprovalExpiresAt != nil { + formatted := wfExec.ApprovalExpiresAt.Format(time.RFC3339) + expiresAtStr = &formatted + } + + ctx.JSON(http.StatusOK, ApprovalStatusResponse{ + Status: status, + Response: wfExec.ApprovalResponse, + RequestURL: requestURL, + RequestedAt: requestedAt, + ExpiresAt: expiresAtStr, + RespondedAt: respondedAt, + }) +} + +// AgentScopedRequestApprovalHandler is the agent-scoped version of RequestApprovalHandler. +// It enforces that the execution belongs to the agent identified by :node_id. +func AgentScopedRequestApprovalHandler(store ExecutionStore) gin.HandlerFunc { + ctrl := &approvalController{store: store} + return func(ctx *gin.Context) { + nodeID := ctx.Param("node_id") + executionID := ctx.Param("execution_id") + if nodeID == "" || executionID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "node_id and execution_id are required"}) + return + } + + // Verify the execution belongs to this agent + if !ctrl.verifyExecutionOwnership(ctx, executionID, nodeID) { + return // verifyExecutionOwnership writes the response + } + + // Delegate to the standard handler + ctrl.handleRequestApproval(ctx) + } +} + +// AgentScopedGetApprovalStatusHandler is the agent-scoped version of GetApprovalStatusHandler. +// It enforces that the execution belongs to the agent identified by :node_id. +func AgentScopedGetApprovalStatusHandler(store ExecutionStore) gin.HandlerFunc { + ctrl := &approvalController{store: store} + return func(ctx *gin.Context) { + nodeID := ctx.Param("node_id") + executionID := ctx.Param("execution_id") + if nodeID == "" || executionID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "node_id and execution_id are required"}) + return + } + + // Verify the execution belongs to this agent + if !ctrl.verifyExecutionOwnership(ctx, executionID, nodeID) { + return + } + + // Delegate to the standard handler + ctrl.handleGetApprovalStatus(ctx) + } +} + +// verifyExecutionOwnership checks that the execution's AgentNodeID matches the +// node_id from the URL path. Returns false and writes an error response if +// verification fails; returns true if the caller may proceed. +func (c *approvalController) verifyExecutionOwnership(ctx *gin.Context, executionID, nodeID string) bool { + reqCtx := ctx.Request.Context() + wfExec, err := c.store.GetWorkflowExecution(reqCtx, executionID) + if err != nil { + logger.Logger.Error().Err(err).Str("execution_id", executionID).Msg("failed to get workflow execution for ownership check") + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up execution"}) + return false + } + if wfExec == nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("execution %s not found", executionID)}) + return false + } + if wfExec.AgentNodeID != nodeID { + logger.Logger.Warn(). + Str("execution_id", executionID). + Str("requested_node", nodeID). + Str("actual_node", wfExec.AgentNodeID). + Msg("agent-scoped approval request denied: execution belongs to a different agent") + ctx.JSON(http.StatusForbidden, gin.H{ + "error": "execution_ownership_mismatch", + "message": "this execution does not belong to the requesting agent", + }) + return false + } + return true +} diff --git a/control-plane/internal/handlers/execute_approval_test.go b/control-plane/internal/handlers/execute_approval_test.go new file mode 100644 index 00000000..c2320a77 --- /dev/null +++ b/control-plane/internal/handlers/execute_approval_test.go @@ -0,0 +1,446 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Agent-Field/agentfield/control-plane/pkg/types" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// RequestApprovalHandler +// --------------------------------------------------------------------------- + +func ptr(s string) *string { return &s } + +func TestRequestApprovalHandler_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + + agent := &types.AgentNode{ID: "agent-1"} + store := newTestExecutionStorage(agent) + + // Seed a running execution + now := time.Now().UTC() + require.NoError(t, store.CreateExecutionRecord(context.Background(), &types.Execution{ + ExecutionID: "exec-1", + RunID: "run-1", + AgentNodeID: "agent-1", + Status: types.ExecutionStatusRunning, + StartedAt: now, + CreatedAt: now, + })) + require.NoError(t, store.StoreWorkflowExecution(context.Background(), &types.WorkflowExecution{ + ExecutionID: "exec-1", + WorkflowID: "wf-1", + RunID: ptr("run-1"), + AgentNodeID: "agent-1", + Status: types.ExecutionStatusRunning, + StartedAt: now, + })) + + router := gin.New() + router.POST("/api/v1/agents/:node_id/executions/:execution_id/request-approval", + AgentScopedRequestApprovalHandler(store)) + + body, _ := json.Marshal(map[string]any{ + "approval_request_id": "req-abc-123", + "approval_request_url": "https://hub.example.com/review/req-abc-123", + "callback_url": "https://agent.example.com/webhooks/approval", + "expires_in_hours": 24, + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/agent-1/executions/exec-1/request-approval", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "req-abc-123", result["approval_request_id"]) + assert.Equal(t, "https://hub.example.com/review/req-abc-123", result["approval_request_url"]) + assert.Equal(t, "pending", result["status"]) + + // Verify execution state transitioned to waiting + wfExec, err := store.GetWorkflowExecution(context.Background(), "exec-1") + require.NoError(t, err) + require.NotNil(t, wfExec) + assert.Equal(t, types.ExecutionStatusWaiting, wfExec.Status) + assert.NotNil(t, wfExec.ApprovalRequestID) + assert.Equal(t, "req-abc-123", *wfExec.ApprovalRequestID) + assert.NotNil(t, wfExec.ApprovalRequestURL) + assert.Equal(t, "https://hub.example.com/review/req-abc-123", *wfExec.ApprovalRequestURL) + assert.NotNil(t, wfExec.ApprovalStatus) + assert.Equal(t, "pending", *wfExec.ApprovalStatus) + assert.NotNil(t, wfExec.ApprovalRequestedAt) + assert.NotNil(t, wfExec.ApprovalCallbackURL) + assert.Equal(t, "https://agent.example.com/webhooks/approval", *wfExec.ApprovalCallbackURL) + assert.NotNil(t, wfExec.ApprovalExpiresAt) + + // Verify lightweight execution record also transitioned + execRecord, err := store.GetExecutionRecord(context.Background(), "exec-1") + require.NoError(t, err) + require.NotNil(t, execRecord) + assert.Equal(t, types.ExecutionStatusWaiting, execRecord.Status) + + // Verify status reason + require.NotNil(t, wfExec.StatusReason) + assert.Equal(t, "waiting_for_approval", *wfExec.StatusReason) +} + +func TestRequestApprovalHandler_MissingApprovalRequestID(t *testing.T) { + gin.SetMode(gin.TestMode) + + agent := &types.AgentNode{ID: "agent-1"} + store := newTestExecutionStorage(agent) + + // The execution must exist so ownership check passes and we reach body validation + now := time.Now().UTC() + require.NoError(t, store.StoreWorkflowExecution(context.Background(), &types.WorkflowExecution{ + ExecutionID: "exec-1", + WorkflowID: "wf-1", + AgentNodeID: "agent-1", + Status: types.ExecutionStatusRunning, + StartedAt: now, + })) + + router := gin.New() + router.POST("/api/v1/agents/:node_id/executions/:execution_id/request-approval", + AgentScopedRequestApprovalHandler(store)) + + // Missing required approval_request_id + body, _ := json.Marshal(map[string]any{ + "approval_request_url": "https://hub.example.com/review", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/agent-1/executions/exec-1/request-approval", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusBadRequest, resp.Code) +} + +func TestRequestApprovalHandler_ExecutionNotFound(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := newTestExecutionStorage(&types.AgentNode{ID: "agent-1"}) + + router := gin.New() + router.POST("/api/v1/agents/:node_id/executions/:execution_id/request-approval", + AgentScopedRequestApprovalHandler(store)) + + body, _ := json.Marshal(map[string]any{ + "approval_request_id": "req-abc", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/agent-1/executions/nonexistent/request-approval", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusNotFound, resp.Code) +} + +func TestRequestApprovalHandler_ExecutionNotRunning(t *testing.T) { + gin.SetMode(gin.TestMode) + + agent := &types.AgentNode{ID: "agent-1"} + store := newTestExecutionStorage(agent) + + now := time.Now().UTC() + // Execution in "succeeded" state — cannot request approval + require.NoError(t, store.StoreWorkflowExecution(context.Background(), &types.WorkflowExecution{ + ExecutionID: "exec-done", + WorkflowID: "wf-1", + AgentNodeID: "agent-1", + Status: types.ExecutionStatusSucceeded, + StartedAt: now, + })) + + router := gin.New() + router.POST("/api/v1/agents/:node_id/executions/:execution_id/request-approval", + AgentScopedRequestApprovalHandler(store)) + + body, _ := json.Marshal(map[string]any{ + "approval_request_id": "req-abc", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/agent-1/executions/exec-done/request-approval", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusConflict, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "invalid_state", result["error"]) +} + +func TestRequestApprovalHandler_DuplicateRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + agent := &types.AgentNode{ID: "agent-1"} + store := newTestExecutionStorage(agent) + + now := time.Now().UTC() + existingReqID := "req-existing" + require.NoError(t, store.StoreWorkflowExecution(context.Background(), &types.WorkflowExecution{ + ExecutionID: "exec-dup", + WorkflowID: "wf-1", + AgentNodeID: "agent-1", + Status: types.ExecutionStatusRunning, + StartedAt: now, + ApprovalRequestID: &existingReqID, + })) + + router := gin.New() + router.POST("/api/v1/agents/:node_id/executions/:execution_id/request-approval", + AgentScopedRequestApprovalHandler(store)) + + body, _ := json.Marshal(map[string]any{ + "approval_request_id": "req-new", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/agent-1/executions/exec-dup/request-approval", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusConflict, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "approval_already_requested", result["error"]) +} + +func TestRequestApprovalHandler_OwnershipMismatch(t *testing.T) { + gin.SetMode(gin.TestMode) + + agent := &types.AgentNode{ID: "agent-1"} + store := newTestExecutionStorage(agent) + + now := time.Now().UTC() + require.NoError(t, store.StoreWorkflowExecution(context.Background(), &types.WorkflowExecution{ + ExecutionID: "exec-other", + WorkflowID: "wf-1", + AgentNodeID: "agent-2", // belongs to a different agent + Status: types.ExecutionStatusRunning, + StartedAt: now, + })) + + router := gin.New() + router.POST("/api/v1/agents/:node_id/executions/:execution_id/request-approval", + AgentScopedRequestApprovalHandler(store)) + + body, _ := json.Marshal(map[string]any{ + "approval_request_id": "req-abc", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/agent-1/executions/exec-other/request-approval", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusForbidden, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "execution_ownership_mismatch", result["error"]) +} + +func TestRequestApprovalHandler_DefaultExpiresInHours(t *testing.T) { + gin.SetMode(gin.TestMode) + + agent := &types.AgentNode{ID: "agent-1"} + store := newTestExecutionStorage(agent) + + now := time.Now().UTC() + require.NoError(t, store.CreateExecutionRecord(context.Background(), &types.Execution{ + ExecutionID: "exec-def", + RunID: "run-1", + AgentNodeID: "agent-1", + Status: types.ExecutionStatusRunning, + StartedAt: now, + CreatedAt: now, + })) + require.NoError(t, store.StoreWorkflowExecution(context.Background(), &types.WorkflowExecution{ + ExecutionID: "exec-def", + WorkflowID: "wf-1", + RunID: ptr("run-1"), + AgentNodeID: "agent-1", + Status: types.ExecutionStatusRunning, + StartedAt: now, + })) + + router := gin.New() + router.POST("/api/v1/agents/:node_id/executions/:execution_id/request-approval", + AgentScopedRequestApprovalHandler(store)) + + // No expires_in_hours — should default to 72 + body, _ := json.Marshal(map[string]any{ + "approval_request_id": "req-def", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/agent-1/executions/exec-def/request-approval", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + wfExec, err := store.GetWorkflowExecution(context.Background(), "exec-def") + require.NoError(t, err) + require.NotNil(t, wfExec.ApprovalExpiresAt) + + // Expiry should be approximately 72 hours from now + expectedExpiry := now.Add(72 * time.Hour) + diff := wfExec.ApprovalExpiresAt.Sub(expectedExpiry) + assert.Less(t, diff.Abs(), 5*time.Second, "expiry should be ~72 hours from now") +} + +// --------------------------------------------------------------------------- +// GetApprovalStatusHandler +// --------------------------------------------------------------------------- + +func TestGetApprovalStatusHandler_Pending(t *testing.T) { + gin.SetMode(gin.TestMode) + + agent := &types.AgentNode{ID: "agent-1"} + store := newTestExecutionStorage(agent) + + now := time.Now().UTC() + reqID := "req-pending" + reqURL := "https://hub.example.com/review/req-pending" + status := "pending" + expiresAt := now.Add(72 * time.Hour) + require.NoError(t, store.StoreWorkflowExecution(context.Background(), &types.WorkflowExecution{ + ExecutionID: "exec-pend", + WorkflowID: "wf-1", + AgentNodeID: "agent-1", + Status: types.ExecutionStatusWaiting, + StartedAt: now, + ApprovalRequestID: &reqID, + ApprovalRequestURL: &reqURL, + ApprovalStatus: &status, + ApprovalRequestedAt: &now, + ApprovalExpiresAt: &expiresAt, + })) + + router := gin.New() + router.GET("/api/v1/agents/:node_id/executions/:execution_id/approval-status", + AgentScopedGetApprovalStatusHandler(store)) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/agent-1/executions/exec-pend/approval-status", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "pending", result["status"]) + assert.Equal(t, "https://hub.example.com/review/req-pending", result["request_url"]) + assert.NotEmpty(t, result["requested_at"]) + assert.Nil(t, result["responded_at"]) + assert.NotEmpty(t, result["expires_at"]) +} + +func TestGetApprovalStatusHandler_Approved(t *testing.T) { + gin.SetMode(gin.TestMode) + + agent := &types.AgentNode{ID: "agent-1"} + store := newTestExecutionStorage(agent) + + now := time.Now().UTC() + respondedAt := now.Add(1 * time.Hour) + reqID := "req-approved" + status := "approved" + responseJSON := `{"decision":"approved","feedback":"Looks great!"}` + require.NoError(t, store.StoreWorkflowExecution(context.Background(), &types.WorkflowExecution{ + ExecutionID: "exec-appr", + WorkflowID: "wf-1", + AgentNodeID: "agent-1", + Status: types.ExecutionStatusRunning, + StartedAt: now, + ApprovalRequestID: &reqID, + ApprovalStatus: &status, + ApprovalResponse: &responseJSON, + ApprovalRequestedAt: &now, + ApprovalRespondedAt: &respondedAt, + })) + + router := gin.New() + router.GET("/api/v1/agents/:node_id/executions/:execution_id/approval-status", + AgentScopedGetApprovalStatusHandler(store)) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/agent-1/executions/exec-appr/approval-status", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "approved", result["status"]) + assert.NotNil(t, result["response"]) + assert.NotNil(t, result["responded_at"]) +} + +func TestGetApprovalStatusHandler_NoApprovalRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + agent := &types.AgentNode{ID: "agent-1"} + store := newTestExecutionStorage(agent) + + now := time.Now().UTC() + // Execution exists but has no approval request + require.NoError(t, store.StoreWorkflowExecution(context.Background(), &types.WorkflowExecution{ + ExecutionID: "exec-no-approval", + WorkflowID: "wf-1", + AgentNodeID: "agent-1", + Status: types.ExecutionStatusRunning, + StartedAt: now, + })) + + router := gin.New() + router.GET("/api/v1/agents/:node_id/executions/:execution_id/approval-status", + AgentScopedGetApprovalStatusHandler(store)) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/agent-1/executions/exec-no-approval/approval-status", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusNotFound, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "no_approval_request", result["error"]) +} + +func TestGetApprovalStatusHandler_ExecutionNotFound(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := newTestExecutionStorage(&types.AgentNode{ID: "agent-1"}) + + router := gin.New() + router.GET("/api/v1/agents/:node_id/executions/:execution_id/approval-status", + AgentScopedGetApprovalStatusHandler(store)) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/agent-1/executions/nonexistent/approval-status", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusNotFound, resp.Code) +} diff --git a/control-plane/internal/handlers/test_helpers_test.go b/control-plane/internal/handlers/test_helpers_test.go index 2a4bf1af..c9b2320c 100644 --- a/control-plane/internal/handlers/test_helpers_test.go +++ b/control-plane/internal/handlers/test_helpers_test.go @@ -264,6 +264,24 @@ func (s *testExecutionStorage) UpdateExecutionRecord(ctx context.Context, execut return &out, nil } +func (s *testExecutionStorage) QueryWorkflowExecutions(ctx context.Context, filters types.WorkflowExecutionFilters) ([]*types.WorkflowExecution, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var results []*types.WorkflowExecution + for _, wfExec := range s.workflowExecutions { + if filters.ApprovalRequestID != nil && (wfExec.ApprovalRequestID == nil || *wfExec.ApprovalRequestID != *filters.ApprovalRequestID) { + continue + } + results = append(results, wfExec) + } + return results, nil +} + +func (s *testExecutionStorage) StoreWorkflowExecutionEvent(ctx context.Context, event *types.WorkflowExecutionEvent) error { + return nil +} + func (s *testExecutionStorage) QueryExecutionRecords(ctx context.Context, filter types.ExecutionFilter) ([]*types.Execution, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/control-plane/internal/handlers/ui/dashboard.go b/control-plane/internal/handlers/ui/dashboard.go index 58e0281c..9f8e2bbe 100644 --- a/control-plane/internal/handlers/ui/dashboard.go +++ b/control-plane/internal/handlers/ui/dashboard.go @@ -101,10 +101,10 @@ func (c *DashboardCache) Set(data *DashboardSummaryResponse) { type TimeRangePreset string const ( - TimeRangePreset1h TimeRangePreset = "1h" - TimeRangePreset24h TimeRangePreset = "24h" - TimeRangePreset7d TimeRangePreset = "7d" - TimeRangePreset30d TimeRangePreset = "30d" + TimeRangePreset1h TimeRangePreset = "1h" + TimeRangePreset24h TimeRangePreset = "24h" + TimeRangePreset7d TimeRangePreset = "7d" + TimeRangePreset30d TimeRangePreset = "30d" TimeRangePresetCustom TimeRangePreset = "custom" ) @@ -117,8 +117,8 @@ type TimeRangeInfo struct { // ComparisonData contains delta information comparing current to previous period type ComparisonData struct { - PreviousPeriod TimeRangeInfo `json:"previous_period"` - OverviewDelta EnhancedOverviewDelta `json:"overview_delta"` + PreviousPeriod TimeRangeInfo `json:"previous_period"` + OverviewDelta EnhancedOverviewDelta `json:"overview_delta"` } // EnhancedOverviewDelta contains changes compared to the previous period @@ -137,12 +137,12 @@ type HotspotSummary struct { // HotspotItem represents a single reasoner's failure statistics type HotspotItem struct { - ReasonerID string `json:"reasoner_id"` - TotalExecutions int `json:"total_executions"` - FailedExecutions int `json:"failed_executions"` - ErrorRate float64 `json:"error_rate"` - ContributionPct float64 `json:"contribution_pct"` - TopErrors []ErrorCount `json:"top_errors"` + ReasonerID string `json:"reasoner_id"` + TotalExecutions int `json:"total_executions"` + FailedExecutions int `json:"failed_executions"` + ErrorRate float64 `json:"error_rate"` + ContributionPct float64 `json:"contribution_pct"` + TopErrors []ErrorCount `json:"top_errors"` } // ErrorCount tracks error message frequency @@ -166,16 +166,16 @@ type HeatmapCell struct { // Enhanced dashboard response structures type EnhancedDashboardResponse struct { - GeneratedAt time.Time `json:"generated_at"` - TimeRange TimeRangeInfo `json:"time_range"` - Overview EnhancedOverview `json:"overview"` - ExecutionTrends ExecutionTrends `json:"execution_trends"` - AgentHealth AgentHealthSummary `json:"agent_health"` - Workflows WorkflowInsights `json:"workflows"` - Incidents []IncidentItem `json:"incidents"` - Comparison *ComparisonData `json:"comparison,omitempty"` - Hotspots HotspotSummary `json:"hotspots"` - ActivityPatterns ActivityPatterns `json:"activity_patterns"` + GeneratedAt time.Time `json:"generated_at"` + TimeRange TimeRangeInfo `json:"time_range"` + Overview EnhancedOverview `json:"overview"` + ExecutionTrends ExecutionTrends `json:"execution_trends"` + AgentHealth AgentHealthSummary `json:"agent_health"` + Workflows WorkflowInsights `json:"workflows"` + Incidents []IncidentItem `json:"incidents"` + Comparison *ComparisonData `json:"comparison,omitempty"` + Hotspots HotspotSummary `json:"hotspots"` + ActivityPatterns ActivityPatterns `json:"activity_patterns"` } type EnhancedOverview struct { @@ -290,9 +290,9 @@ type enhancedCacheEntry struct { // EnhancedDashboardCache provides time-range-aware caching for the enhanced dashboard response type EnhancedDashboardCache struct { - entries map[string]*enhancedCacheEntry - mutex sync.RWMutex - maxSize int + entries map[string]*enhancedCacheEntry + mutex sync.RWMutex + maxSize int } // NewEnhancedDashboardCache creates a new cache instance for enhanced dashboard data @@ -495,17 +495,17 @@ func parseTimeRangeParams(c *gin.Context, now time.Time) (startTime, endTime tim endStr := c.Query("end_time") if startStr == "" || endStr == "" { logger.Logger.Warn().Msg("start_time and end_time required for custom range, falling back to 24h") - return now.Add(-24*time.Hour), now, TimeRangePreset24h, nil + return now.Add(-24 * time.Hour), now, TimeRangePreset24h, nil } startTime, err = time.Parse(time.RFC3339, startStr) if err != nil { logger.Logger.Warn().Err(err).Msg("invalid start_time format, falling back to 24h") - return now.Add(-24*time.Hour), now, TimeRangePreset24h, nil + return now.Add(-24 * time.Hour), now, TimeRangePreset24h, nil } endTime, err = time.Parse(time.RFC3339, endStr) if err != nil { logger.Logger.Warn().Err(err).Msg("invalid end_time format, falling back to 24h") - return now.Add(-24*time.Hour), now, TimeRangePreset24h, nil + return now.Add(-24 * time.Hour), now, TimeRangePreset24h, nil } default: // Default to 24h @@ -588,6 +588,27 @@ func (h *DashboardHandler) GetEnhancedDashboardSummaryHandler(c *gin.Context) { return } + statusWaiting := string(types.ExecutionStatusWaiting) + waitingExecutions, err := h.store.QueryExecutionRecords(ctx, types.ExecutionFilter{ + Status: &statusWaiting, + Limit: 12, + SortBy: "started_at", + SortDescending: true, + }) + if err != nil { + logger.Logger.Error().Err(err).Msg("failed to query waiting executions for enhanced dashboard") + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to load active workflow data"}) + return + } + + activeExecutions := append(runningExecutions, waitingExecutions...) + sort.Slice(activeExecutions, func(i, j int) bool { + return activeExecutions[i].StartedAt.After(activeExecutions[j].StartedAt) + }) + if len(activeExecutions) > 12 { + activeExecutions = activeExecutions[:12] + } + // Build time range info timeRange := TimeRangeInfo{ StartTime: startTime, @@ -598,7 +619,7 @@ func (h *DashboardHandler) GetEnhancedDashboardSummaryHandler(c *gin.Context) { overview := h.buildEnhancedOverviewForRange(agents, executions, startTime, endTime) trends := buildExecutionTrendsForRange(executions, startTime, endTime, preset) agentHealth := h.buildAgentHealthSummary(ctx, agents) - workflows := buildWorkflowInsights(executions, runningExecutions) + workflows := buildWorkflowInsights(executions, activeExecutions) incidents := buildIncidentItems(executions, 10) hotspots := buildHotspotSummary(executions) activityPatterns := buildActivityPatterns(executions) @@ -845,9 +866,9 @@ func buildComparisonData(current, previous EnhancedOverview, prevStart, prevEnd // buildHotspotSummary aggregates failures by reasoner func buildHotspotSummary(executions []*types.Execution) HotspotSummary { type reasonerStats struct { - total int - failed int - errorMsgs map[string]int + total int + failed int + errorMsgs map[string]int } statsMap := make(map[string]*reasonerStats) diff --git a/control-plane/internal/handlers/ui/execution_timeline.go b/control-plane/internal/handlers/ui/execution_timeline.go index cd99fe2e..f34472e0 100644 --- a/control-plane/internal/handlers/ui/execution_timeline.go +++ b/control-plane/internal/handlers/ui/execution_timeline.go @@ -193,7 +193,7 @@ func (h *ExecutionTimelineHandler) generateTimelineData(ctx context.Context) ([] dataPoint.Successful++ case string(types.ExecutionStatusFailed): dataPoint.Failed++ - case string(types.ExecutionStatusRunning), string(types.ExecutionStatusPending), string(types.ExecutionStatusQueued): + case string(types.ExecutionStatusRunning), string(types.ExecutionStatusWaiting), string(types.ExecutionStatusPending), string(types.ExecutionStatusQueued): dataPoint.Running++ } diff --git a/control-plane/internal/handlers/ui/executions.go b/control-plane/internal/handlers/ui/executions.go index e809f683..3ecc7739 100644 --- a/control-plane/internal/handlers/ui/executions.go +++ b/control-plane/internal/handlers/ui/executions.go @@ -132,6 +132,7 @@ type ExecutionSummary struct { AgentNodeID string `json:"agent_node_id"` ReasonerID string `json:"reasoner_id"` Status string `json:"status"` + StatusReason *string `json:"status_reason,omitempty"` DurationMS int `json:"duration_ms"` InputSize int `json:"input_size"` OutputSize int `json:"output_size"` @@ -172,11 +173,18 @@ type ExecutionDetailsResponse struct { WorkflowName *string `json:"workflow_name,omitempty"` WorkflowTags []string `json:"workflow_tags"` Status string `json:"status"` + StatusReason *string `json:"status_reason,omitempty"` StartedAt *string `json:"started_at,omitempty"` CompletedAt *string `json:"completed_at,omitempty"` DurationMS *int `json:"duration_ms,omitempty"` ErrorMessage *string `json:"error_message,omitempty"` RetryCount int `json:"retry_count"` + ApprovalRequestID *string `json:"approval_request_id,omitempty"` + ApprovalRequestURL *string `json:"approval_request_url,omitempty"` + ApprovalStatus *string `json:"approval_status,omitempty"` + ApprovalResponse *string `json:"approval_response,omitempty"` + ApprovalRequestedAt *string `json:"approval_requested_at,omitempty"` + ApprovalRespondedAt *string `json:"approval_responded_at,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt *string `json:"updated_at,omitempty"` Notes []types.ExecutionNote `json:"notes"` @@ -429,7 +437,7 @@ func (h *ExecutionHandler) GetExecutionStatsHandler(c *gin.Context) { stats.SuccessfulCount++ case string(types.ExecutionStatusFailed): stats.FailedCount++ - case string(types.ExecutionStatusRunning), string(types.ExecutionStatusPending), string(types.ExecutionStatusQueued): + case string(types.ExecutionStatusRunning), string(types.ExecutionStatusWaiting), string(types.ExecutionStatusPending), string(types.ExecutionStatusQueued): stats.RunningCount++ } @@ -664,6 +672,7 @@ func (h *ExecutionHandler) toExecutionSummary(exec *types.Execution) ExecutionSu AgentNodeID: exec.AgentNodeID, ReasonerID: exec.ReasonerID, Status: types.NormalizeExecutionStatus(exec.Status), + StatusReason: exec.StatusReason, DurationMS: duration, InputSize: len(exec.InputPayload), OutputSize: len(exec.ResultPayload), @@ -701,7 +710,7 @@ func (h *ExecutionHandler) toExecutionDetails(ctx context.Context, exec *types.E webhookRegistered := exec.WebhookRegistered webhookEvents := exec.WebhookEvents - return ExecutionDetailsResponse{ + resp := ExecutionDetailsResponse{ ID: 0, ExecutionID: exec.ExecutionID, WorkflowID: exec.RunID, @@ -720,6 +729,7 @@ func (h *ExecutionHandler) toExecutionDetails(ctx context.Context, exec *types.E WorkflowName: nil, WorkflowTags: nil, Status: types.NormalizeExecutionStatus(exec.Status), + StatusReason: exec.StatusReason, StartedAt: startedAt, CompletedAt: completedAt, DurationMS: durationPtr, @@ -733,6 +743,26 @@ func (h *ExecutionHandler) toExecutionDetails(ctx context.Context, exec *types.E WebhookRegistered: webhookRegistered, WebhookEvents: webhookEvents, } + + // Enrich with approval fields from workflow execution (if available) + if h.storage != nil { + if wfExec, err := h.storage.GetWorkflowExecution(ctx, exec.ExecutionID); err == nil && wfExec != nil { + resp.ApprovalRequestID = wfExec.ApprovalRequestID + resp.ApprovalRequestURL = wfExec.ApprovalRequestURL + resp.ApprovalStatus = wfExec.ApprovalStatus + resp.ApprovalResponse = wfExec.ApprovalResponse + if wfExec.ApprovalRequestedAt != nil { + formatted := wfExec.ApprovalRequestedAt.Format(time.RFC3339) + resp.ApprovalRequestedAt = &formatted + } + if wfExec.ApprovalRespondedAt != nil { + formatted := wfExec.ApprovalRespondedAt.Format(time.RFC3339) + resp.ApprovalRespondedAt = &formatted + } + } + } + + return resp } func (h *ExecutionHandler) resolveExecutionData(ctx context.Context, raw []byte, uri *string) (interface{}, int) { diff --git a/control-plane/internal/handlers/ui/workflow_runs.go b/control-plane/internal/handlers/ui/workflow_runs.go index 8e0265a0..97adec83 100644 --- a/control-plane/internal/handlers/ui/workflow_runs.go +++ b/control-plane/internal/handlers/ui/workflow_runs.go @@ -81,12 +81,17 @@ type apiWorkflowExecution struct { AgentNodeID string `json:"agent_node_id"` ReasonerID string `json:"reasoner_id"` Status string `json:"status"` + StatusReason *string `json:"status_reason,omitempty"` StartedAt string `json:"started_at"` CompletedAt *string `json:"completed_at,omitempty"` WorkflowDepth int `json:"workflow_depth"` ActiveChildren int `json:"active_children"` PendingChildren int `json:"pending_children"` LastUpdatedAt *string `json:"last_updated_at,omitempty"` + // Approval fields (populated when execution has an approval request) + ApprovalRequestID *string `json:"approval_request_id,omitempty"` + ApprovalRequestURL *string `json:"approval_request_url,omitempty"` + ApprovalStatus *string `json:"approval_status,omitempty"` } func (h *WorkflowRunHandler) ListWorkflowRunsHandler(c *gin.Context) { @@ -200,7 +205,9 @@ func convertAggregationToSummary(agg *storage.RunSummaryAggregation) WorkflowRun // Check if terminal summary.Terminal = summary.Status == string(types.ExecutionStatusSucceeded) || - summary.Status == string(types.ExecutionStatusFailed) + summary.Status == string(types.ExecutionStatusFailed) || + summary.Status == string(types.ExecutionStatusTimeout) || + summary.Status == string(types.ExecutionStatusCancelled) // Calculate duration if completed if summary.Terminal { @@ -213,30 +220,30 @@ func convertAggregationToSummary(agg *storage.RunSummaryAggregation) WorkflowRun return summary } -// deriveStatusFromCounts determines overall workflow status from status counts +// deriveStatusFromCounts determines overall workflow status from status counts. +// Priority: active (running/waiting/pending/queued) > failed > timeout > cancelled > succeeded. func deriveStatusFromCounts(statusCounts map[string]int, activeExecutions int) string { + // If there are active executions (running, waiting, pending, queued), the workflow is running + if activeExecutions > 0 { + return string(types.ExecutionStatusRunning) + } + // If there are any failed executions, the workflow is failed if statusCounts[string(types.ExecutionStatusFailed)] > 0 { return string(types.ExecutionStatusFailed) } - // If there are active executions, the workflow is running - if activeExecutions > 0 { - return string(types.ExecutionStatusRunning) - } - - // If all executions succeeded, the workflow succeeded - totalSucceeded := statusCounts[string(types.ExecutionStatusSucceeded)] - totalAll := 0 - for _, count := range statusCounts { - totalAll += count + // If there are any timed-out executions, the workflow timed out + if statusCounts[string(types.ExecutionStatusTimeout)] > 0 { + return string(types.ExecutionStatusTimeout) } - if totalSucceeded == totalAll && totalAll > 0 { - return string(types.ExecutionStatusSucceeded) + // If there are any cancelled executions, the workflow is cancelled + if statusCounts[string(types.ExecutionStatusCancelled)] > 0 { + return string(types.ExecutionStatusCancelled) } - // Default to succeeded if no active work + // All executions are in terminal non-error states (succeeded) or no executions exist return string(types.ExecutionStatusSucceeded) } @@ -315,6 +322,9 @@ func (h *WorkflowRunHandler) GetWorkflowRunDetailHandler(c *gin.Context) { } } + // Enrich executions in waiting status with approval data from workflow executions + h.enrichApprovalData(ctx, apiExecutions) + detail.Executions = apiExecutions c.JSON(http.StatusOK, detail) @@ -375,6 +385,7 @@ func summarizeRun(runID string, executions []*types.Execution) WorkflowRunSummar normalized := types.NormalizeExecutionStatus(exec.Status) summary.StatusCounts[normalized]++ if normalized == string(types.ExecutionStatusRunning) || + normalized == string(types.ExecutionStatusWaiting) || normalized == string(types.ExecutionStatusPending) || normalized == string(types.ExecutionStatusQueued) { active++ @@ -462,7 +473,7 @@ func buildAPIExecutions(nodes []handlers.WorkflowDAGNode) []apiWorkflowExecution pendingChildren := 0 for _, child := range children { switch types.NormalizeExecutionStatus(child.Status) { - case string(types.ExecutionStatusRunning): + case string(types.ExecutionStatusRunning), string(types.ExecutionStatusWaiting): activeChildren++ case string(types.ExecutionStatusPending), string(types.ExecutionStatusQueued): pendingChildren++ @@ -483,6 +494,7 @@ func buildAPIExecutions(nodes []handlers.WorkflowDAGNode) []apiWorkflowExecution AgentNodeID: node.AgentNodeID, ReasonerID: node.ReasonerID, Status: node.Status, + StatusReason: node.StatusReason, StartedAt: node.StartedAt, CompletedAt: node.CompletedAt, WorkflowDepth: node.WorkflowDepth, @@ -494,6 +506,25 @@ func buildAPIExecutions(nodes []handlers.WorkflowDAGNode) []apiWorkflowExecution return apiNodes } +// enrichApprovalData looks up workflow executions for any api nodes in waiting status +// and populates their approval fields. +func (h *WorkflowRunHandler) enrichApprovalData(ctx context.Context, executions []apiWorkflowExecution) { + for i := range executions { + // Only look up approval data for executions that have a waiting-related status + normalized := types.NormalizeExecutionStatus(executions[i].Status) + if normalized != types.ExecutionStatusWaiting { + continue + } + wfExec, err := h.storage.GetWorkflowExecution(ctx, executions[i].ExecutionID) + if err != nil || wfExec == nil { + continue + } + executions[i].ApprovalRequestID = wfExec.ApprovalRequestID + executions[i].ApprovalRequestURL = wfExec.ApprovalRequestURL + executions[i].ApprovalStatus = wfExec.ApprovalStatus + } +} + func parsePositiveInt(value string, fallback int) int { v, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || v <= 0 { diff --git a/control-plane/internal/handlers/webhook_approval.go b/control-plane/internal/handlers/webhook_approval.go new file mode 100644 index 00000000..1607d1bb --- /dev/null +++ b/control-plane/internal/handlers/webhook_approval.go @@ -0,0 +1,470 @@ +package handlers + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/Agent-Field/agentfield/control-plane/internal/events" + "github.com/Agent-Field/agentfield/control-plane/internal/logger" + "github.com/Agent-Field/agentfield/control-plane/pkg/types" + + "github.com/gin-gonic/gin" +) + +// ApprovalWebhookPayload is the normalized payload for approval processing. +type ApprovalWebhookPayload struct { + RequestID string `json:"requestId"` + Decision string `json:"decision"` // "approved", "rejected", "request_changes", "expired" + Response json.RawMessage `json:"response,omitempty"` + Feedback string `json:"feedback,omitempty"` + Timestamp string `json:"timestamp,omitempty"` +} + +// haxSDKWebhookEnvelope is the actual envelope format hax-sdk sends. +type haxSDKWebhookEnvelope struct { + ID string `json:"id"` + Type string `json:"type"` // "completed", "expired", etc. + CreatedAt string `json:"createdAt"` + Data map[string]interface{} `json:"data"` +} + +// parseWebhookPayload attempts to extract an ApprovalWebhookPayload from the raw body. +// It supports two formats: +// 1. hax-sdk envelope: {"id":"evt_...","type":"completed","data":{"requestId":"...","response":{"decision":"approved"}}} +// 2. Direct flat format: {"requestId":"...","decision":"approved","feedback":"..."} +func parseWebhookPayload(bodyBytes []byte) (*ApprovalWebhookPayload, error) { + // First try the hax-sdk envelope format + var envelope haxSDKWebhookEnvelope + if err := json.Unmarshal(bodyBytes, &envelope); err == nil && envelope.Data != nil && envelope.Type != "" { + payload := &ApprovalWebhookPayload{} + + // Extract requestId from data + if rid, ok := envelope.Data["requestId"].(string); ok { + payload.RequestID = rid + } + payload.Timestamp = envelope.CreatedAt + + // Extract decision from data.response.decision (plan-review template format) + if respRaw, ok := envelope.Data["response"]; ok { + if respMap, ok := respRaw.(map[string]interface{}); ok { + if dec, ok := respMap["decision"].(string); ok { + payload.Decision = dec + } + if fb, ok := respMap["feedback"].(string); ok { + payload.Feedback = fb + } + // Preserve full response + if respJSON, err := json.Marshal(respMap); err == nil { + payload.Response = respJSON + } + } + } + + // Handle "expired" event type from hax-sdk + if envelope.Type == "expired" { + payload.Decision = "expired" + } + + if payload.RequestID != "" && payload.Decision != "" { + return payload, nil + } + // If we couldn't extract enough from envelope, fall through to flat format + } + + // Fall back to flat format + var flat ApprovalWebhookPayload + if err := json.Unmarshal(bodyBytes, &flat); err != nil { + return nil, fmt.Errorf("could not parse webhook payload: %w", err) + } + return &flat, nil +} + +// webhookApprovalController handles the approval webhook callback. +type webhookApprovalController struct { + store ExecutionStore + webhookSecret string // optional HMAC-SHA256 secret for signature verification +} + +// ApprovalWebhookHandler receives approval responses via webhook callback. +// Can be called by external services (e.g. hax-sdk) or by agents directly. +// The optional webhookSecret enables HMAC-SHA256 signature verification. +func ApprovalWebhookHandler(store ExecutionStore, webhookSecret string) gin.HandlerFunc { + ctrl := &webhookApprovalController{ + store: store, + webhookSecret: webhookSecret, + } + return ctrl.handleApprovalWebhook +} + +func (c *webhookApprovalController) handleApprovalWebhook(ctx *gin.Context) { + // Read the raw body for signature verification + bodyBytes, err := io.ReadAll(io.LimitReader(ctx.Request.Body, 1<<20)) // 1MB limit + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"}) + return + } + + // Verify HMAC-SHA256 signature if webhook secret is configured + if c.webhookSecret != "" { + signature := ctx.GetHeader("X-Hax-Signature") + if signature == "" { + signature = ctx.GetHeader("X-Webhook-Signature") + } + if signature == "" { + signature = ctx.GetHeader("X-Hub-Signature-256") + } + if !c.verifySignature(bodyBytes, signature) { + logger.Logger.Warn().Msg("approval webhook signature verification failed") + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid webhook signature"}) + return + } + } + + payload, parseErr := parseWebhookPayload(bodyBytes) + if parseErr != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid payload: %v", parseErr)}) + return + } + + if payload.RequestID == "" { + logger.Logger.Warn().Str("raw_body", string(bodyBytes)).Msg("webhook payload missing requestId") + ctx.JSON(http.StatusBadRequest, gin.H{"error": "requestId is required"}) + return + } + + // Validate decision + decision := payload.Decision + switch decision { + case "approved", "rejected", "request_changes", "expired": + // valid + default: + logger.Logger.Warn().Str("decision", decision).Str("raw_body", string(bodyBytes)).Msg("webhook payload has invalid decision") + ctx.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid decision '%s'; must be approved, rejected, request_changes, or expired", decision)}) + return + } + + reqCtx := ctx.Request.Context() + + // Find the workflow execution by approval_request_id + executionID, wfExec, err := c.findExecutionByApprovalRequestID(ctx, payload.RequestID) + if err != nil { + logger.Logger.Error().Err(err).Str("request_id", payload.RequestID).Msg("failed to find execution for approval webhook") + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up execution"}) + return + } + if wfExec == nil { + logger.Logger.Warn().Str("request_id", payload.RequestID).Msg("no execution found for approval request ID") + ctx.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("no execution found for approval request %s", payload.RequestID)}) + return + } + + // Idempotency: if execution is no longer in waiting state, it was already + // processed by a previous webhook delivery. Return 200 so the sender + // (hax-sdk retry queue) considers it delivered and stops retrying. + normalized := types.NormalizeExecutionStatus(wfExec.Status) + if normalized != types.ExecutionStatusWaiting { + logger.Logger.Info(). + Str("execution_id", executionID). + Str("current_status", normalized). + Str("request_id", payload.RequestID). + Msg("approval webhook is a duplicate — execution already resolved") + approvalStatus := "" + if wfExec.ApprovalStatus != nil { + approvalStatus = *wfExec.ApprovalStatus + } + ctx.JSON(http.StatusOK, gin.H{ + "status": "already_processed", + "execution_id": executionID, + "current_status": normalized, + "approval_status": approvalStatus, + }) + return + } + + now := time.Now().UTC() + var responseStr *string + if len(payload.Response) > 0 { + s := string(payload.Response) + responseStr = &s + } else if payload.Feedback != "" { + s := fmt.Sprintf(`{"feedback":%q}`, payload.Feedback) + responseStr = &s + } + + // Determine the new execution status based on decision + var newStatus string + var newStatusReason *string + switch decision { + case "approved": + newStatus = types.ExecutionStatusRunning + reason := "approval_granted" + newStatusReason = &reason + case "rejected": + newStatus = types.ExecutionStatusCancelled + reason := "approval_rejected" + if payload.Feedback != "" { + reason = fmt.Sprintf("approval_rejected: %s", payload.Feedback) + } + newStatusReason = &reason + case "request_changes": + newStatus = types.ExecutionStatusRunning + reason := "approval_changes_requested" + if payload.Feedback != "" { + reason = fmt.Sprintf("approval_changes_requested: %s", payload.Feedback) + } + newStatusReason = &reason + case "expired": + newStatus = types.ExecutionStatusCancelled + reason := "approval_expired" + newStatusReason = &reason + } + + // Update the lightweight execution record + var recordSyncFailed bool + _, updateErr := c.store.UpdateExecutionRecord(reqCtx, executionID, func(current *types.Execution) (*types.Execution, error) { + if current == nil { + return nil, fmt.Errorf("execution %s not found", executionID) + } + current.Status = newStatus + current.StatusReason = newStatusReason + if decision != "approved" && decision != "request_changes" { + current.CompletedAt = &now + dur := now.Sub(current.StartedAt).Milliseconds() + current.DurationMS = &dur + } + return current, nil + }) + if updateErr != nil { + logger.Logger.Error().Err(updateErr).Str("execution_id", executionID).Msg("failed to update execution record from approval webhook — proceeding with workflow update") + recordSyncFailed = true + } + + // Update the workflow execution with approval resolution (authoritative — must not lose the decision) + err = c.store.UpdateWorkflowExecution(reqCtx, executionID, func(current *types.WorkflowExecution) (*types.WorkflowExecution, error) { + if current == nil { + return nil, fmt.Errorf("execution %s not found", executionID) + } + current.Status = newStatus + current.StatusReason = newStatusReason + current.ApprovalStatus = &decision + current.ApprovalResponse = responseStr + current.ApprovalRespondedAt = &now + if decision != "approved" && decision != "request_changes" { + current.CompletedAt = &now + dur := now.Sub(current.StartedAt).Milliseconds() + current.DurationMS = &dur + } + // Clear approval fields so the agent can issue a new approval request + if decision == "request_changes" { + current.ApprovalRequestID = nil + current.ApprovalRequestURL = nil + current.ApprovalCallbackURL = nil + } + return current, nil + }) + if err != nil { + logger.Logger.Error().Err(err).Str("execution_id", executionID).Msg("failed to update workflow execution from approval webhook") + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update execution"}) + return + } + + // Emit observability event + eventType := "execution.approval_resolved" + eventPayload, _ := json.Marshal(map[string]interface{}{ + "approval_request_id": payload.RequestID, + "decision": decision, + "feedback": payload.Feedback, + "new_status": newStatus, + }) + event := &types.WorkflowExecutionEvent{ + ExecutionID: executionID, + WorkflowID: wfExec.WorkflowID, + RunID: wfExec.RunID, + EventType: eventType, + Status: &newStatus, + StatusReason: newStatusReason, + Payload: eventPayload, + EmittedAt: now, + } + if storeErr := c.store.StoreWorkflowExecutionEvent(reqCtx, event); storeErr != nil { + logger.Logger.Warn().Err(storeErr).Str("execution_id", executionID).Msg("failed to store approval resolved event (non-fatal)") + } + + // Publish dedicated approval resolved event to the execution event bus + if bus := c.store.GetExecutionEventBus(); bus != nil { + bus.Publish(events.ExecutionEvent{ + Type: events.ExecutionApprovalResolved, + ExecutionID: executionID, + WorkflowID: wfExec.WorkflowID, + AgentNodeID: wfExec.AgentNodeID, + Status: newStatus, + Timestamp: now, + Data: map[string]interface{}{ + "approval_decision": decision, + "approval_request_id": payload.RequestID, + "feedback": payload.Feedback, + }, + }) + } + + logger.Logger.Info(). + Str("execution_id", executionID). + Str("request_id", payload.RequestID). + Str("decision", decision). + Str("new_status", newStatus). + Msg("approval webhook processed, execution state updated") + + response := gin.H{ + "status": "processed", + "execution_id": executionID, + "decision": decision, + "new_status": newStatus, + } + if recordSyncFailed { + response["warning"] = "lightweight execution record update failed — workflow execution is authoritative" + } + ctx.JSON(http.StatusOK, response) + + // Notify the agent's callback URL if one was registered + if wfExec.ApprovalCallbackURL != nil && *wfExec.ApprovalCallbackURL != "" { + go c.notifyApprovalCallback(*wfExec.ApprovalCallbackURL, executionID, decision, newStatus, payload.Feedback, responseStr, payload.RequestID) + } +} + +// findExecutionByApprovalRequestID looks up a workflow execution by its approval_request_id. +// Uses the indexed approval_request_id column via QueryWorkflowExecutions. +func (c *webhookApprovalController) findExecutionByApprovalRequestID(ctx *gin.Context, requestID string) (string, *types.WorkflowExecution, error) { + reqCtx := ctx.Request.Context() + + results, err := c.store.QueryWorkflowExecutions(reqCtx, types.WorkflowExecutionFilters{ + ApprovalRequestID: &requestID, + Limit: 1, + }) + if err != nil { + return "", nil, fmt.Errorf("failed to query workflow executions by approval_request_id: %w", err) + } + if len(results) == 0 { + return "", nil, nil + } + + wfExec := results[0] + return wfExec.ExecutionID, wfExec, nil +} + +// verifySignature verifies the HMAC-SHA256 signature of the webhook payload. +// Supports multiple signature formats: +// - hax-sdk format: "t=1234567890,v1=" (signs "timestamp.payload") +// - Raw hex: "" (signs payload directly) +// - Prefixed: "sha256=" (signs payload directly) +func (c *webhookApprovalController) verifySignature(body []byte, signature string) bool { + if c.webhookSecret == "" { + return true // No secret configured, skip verification + } + if signature == "" { + return false + } + + // Try hax-sdk format: "t=timestamp,v1=signature" + if ts, sig, ok := parseHaxSignature(signature); ok { + signedPayload := fmt.Sprintf("%s.%s", ts, string(body)) + mac := hmac.New(sha256.New, []byte(c.webhookSecret)) + mac.Write([]byte(signedPayload)) + expectedMAC := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(sig), []byte(expectedMAC)) + } + + // Fall back to simple signature verification + sig := trimSignaturePrefix(signature) + mac := hmac.New(sha256.New, []byte(c.webhookSecret)) + mac.Write(body) + expectedMAC := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(sig), []byte(expectedMAC)) +} + +// parseHaxSignature parses "t=timestamp,v1=signature" format. +func parseHaxSignature(sig string) (timestamp, signature string, ok bool) { + parts := make(map[string]string) + for _, part := range splitSignatureParts(sig) { + if idx := indexOf(part, '='); idx > 0 { + parts[part[:idx]] = part[idx+1:] + } + } + ts, hasT := parts["t"] + v1, hasV1 := parts["v1"] + if hasT && hasV1 { + return ts, v1, true + } + return "", "", false +} + +func splitSignatureParts(s string) []string { + var parts []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == ',' { + parts = append(parts, s[start:i]) + start = i + 1 + } + } + parts = append(parts, s[start:]) + return parts +} + +func indexOf(s string, c byte) int { + for i := 0; i < len(s); i++ { + if s[i] == c { + return i + } + } + return -1 +} + +// trimSignaturePrefix removes common signature prefixes like "sha256=". +func trimSignaturePrefix(sig string) string { + if len(sig) > 7 && sig[:7] == "sha256=" { + return sig[7:] + } + return sig +} + +// notifyApprovalCallback POSTs the approval result to the agent's registered callback URL. +// Called asynchronously (go routine) — best-effort, does not block the webhook response. +func (c *webhookApprovalController) notifyApprovalCallback(callbackURL, executionID, decision, newStatus, feedback string, response *string, approvalRequestID string) { + callbackPayload := map[string]interface{}{ + "execution_id": executionID, + "decision": decision, + "new_status": newStatus, + "feedback": feedback, + "approval_request_id": approvalRequestID, + } + if response != nil { + callbackPayload["response"] = *response + } + + body, err := json.Marshal(callbackPayload) + if err != nil { + logger.Logger.Error().Err(err).Str("callback_url", callbackURL).Msg("failed to marshal approval callback payload") + return + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Post(callbackURL, "application/json", bytes.NewReader(body)) + if err != nil { + logger.Logger.Warn().Err(err).Str("callback_url", callbackURL).Str("execution_id", executionID).Msg("approval callback delivery failed") + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + logger.Logger.Warn().Int("status", resp.StatusCode).Str("callback_url", callbackURL).Str("execution_id", executionID).Msg("approval callback returned error") + } else { + logger.Logger.Info().Str("callback_url", callbackURL).Str("execution_id", executionID).Msg("approval callback delivered") + } +} diff --git a/control-plane/internal/handlers/webhook_approval_test.go b/control-plane/internal/handlers/webhook_approval_test.go new file mode 100644 index 00000000..3adef6b6 --- /dev/null +++ b/control-plane/internal/handlers/webhook_approval_test.go @@ -0,0 +1,781 @@ +package handlers + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/agentfield/control-plane/pkg/types" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedWaitingExecution creates a running execution, transitions it to waiting +// with the given approval_request_id, and returns the seeded store. +func seedWaitingExecution(t *testing.T, executionID, agentNodeID, approvalRequestID string) *testExecutionStorage { + t.Helper() + + agent := &types.AgentNode{ID: agentNodeID} + store := newTestExecutionStorage(agent) + + now := time.Now().UTC() + status := "pending" + runID := "run-1" + + require.NoError(t, store.CreateExecutionRecord(context.Background(), &types.Execution{ + ExecutionID: executionID, + RunID: "run-1", + AgentNodeID: agentNodeID, + Status: types.ExecutionStatusWaiting, + StartedAt: now, + CreatedAt: now, + })) + + require.NoError(t, store.StoreWorkflowExecution(context.Background(), &types.WorkflowExecution{ + ExecutionID: executionID, + WorkflowID: "wf-1", + RunID: &runID, + AgentNodeID: agentNodeID, + Status: types.ExecutionStatusWaiting, + StartedAt: now, + ApprovalRequestID: &approvalRequestID, + ApprovalStatus: &status, + ApprovalRequestedAt: &now, + })) + + return store +} + +// --------------------------------------------------------------------------- +// Flat format webhook +// --------------------------------------------------------------------------- + +func TestApprovalWebhook_Approved_FlatFormat(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := seedWaitingExecution(t, "exec-1", "agent-1", "req-abc") + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, "")) + + body, _ := json.Marshal(map[string]any{ + "requestId": "req-abc", + "decision": "approved", + "feedback": "Looks good!", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "processed", result["status"]) + assert.Equal(t, "exec-1", result["execution_id"]) + assert.Equal(t, "approved", result["decision"]) + assert.Equal(t, types.ExecutionStatusRunning, result["new_status"]) + + // Verify execution transitioned back to running + wfExec, err := store.GetWorkflowExecution(context.Background(), "exec-1") + require.NoError(t, err) + assert.Equal(t, types.ExecutionStatusRunning, wfExec.Status) + assert.NotNil(t, wfExec.ApprovalStatus) + assert.Equal(t, "approved", *wfExec.ApprovalStatus) + assert.NotNil(t, wfExec.ApprovalRespondedAt) + assert.NotNil(t, wfExec.ApprovalResponse) + // ApprovalRequestID should be preserved for approved + assert.NotNil(t, wfExec.ApprovalRequestID) + + // Verify lightweight execution record + execRecord, err := store.GetExecutionRecord(context.Background(), "exec-1") + require.NoError(t, err) + assert.Equal(t, types.ExecutionStatusRunning, execRecord.Status) +} + +func TestApprovalWebhook_Rejected_FlatFormat(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := seedWaitingExecution(t, "exec-rej", "agent-1", "req-rej") + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, "")) + + body, _ := json.Marshal(map[string]any{ + "requestId": "req-rej", + "decision": "rejected", + "feedback": "Plan needs more detail.", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "rejected", result["decision"]) + assert.Equal(t, types.ExecutionStatusCancelled, result["new_status"]) + + // Verify execution transitioned to cancelled + wfExec, err := store.GetWorkflowExecution(context.Background(), "exec-rej") + require.NoError(t, err) + assert.Equal(t, types.ExecutionStatusCancelled, wfExec.Status) + assert.NotNil(t, wfExec.CompletedAt) + assert.NotNil(t, wfExec.DurationMS) +} + +func TestApprovalWebhook_RequestChanges_FlatFormat(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := seedWaitingExecution(t, "exec-rc", "agent-1", "req-rc") + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, "")) + + body, _ := json.Marshal(map[string]any{ + "requestId": "req-rc", + "decision": "request_changes", + "feedback": "Add error handling to step 2.", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "request_changes", result["decision"]) + assert.Equal(t, types.ExecutionStatusRunning, result["new_status"]) + + // Verify execution transitioned back to running + wfExec, err := store.GetWorkflowExecution(context.Background(), "exec-rc") + require.NoError(t, err) + assert.Equal(t, types.ExecutionStatusRunning, wfExec.Status) + + // request_changes clears approval fields so agent can re-request + assert.Nil(t, wfExec.ApprovalRequestID) + assert.Nil(t, wfExec.ApprovalRequestURL) + assert.Nil(t, wfExec.ApprovalCallbackURL) + + // Should NOT have CompletedAt (still running) + assert.Nil(t, wfExec.CompletedAt) +} + +func TestApprovalWebhook_Expired_FlatFormat(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := seedWaitingExecution(t, "exec-exp", "agent-1", "req-exp") + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, "")) + + body, _ := json.Marshal(map[string]any{ + "requestId": "req-exp", + "decision": "expired", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "expired", result["decision"]) + assert.Equal(t, types.ExecutionStatusCancelled, result["new_status"]) + + wfExec, err := store.GetWorkflowExecution(context.Background(), "exec-exp") + require.NoError(t, err) + assert.Equal(t, types.ExecutionStatusCancelled, wfExec.Status) + assert.NotNil(t, wfExec.CompletedAt) +} + +// --------------------------------------------------------------------------- +// hax-sdk envelope format webhook +// --------------------------------------------------------------------------- + +func TestApprovalWebhook_HaxSDKEnvelopeFormat(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := seedWaitingExecution(t, "exec-hax", "agent-1", "req-hax") + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, "")) + + body, _ := json.Marshal(map[string]any{ + "id": "evt_001", + "type": "completed", + "createdAt": time.Now().UTC().Format(time.RFC3339), + "data": map[string]any{ + "requestId": "req-hax", + "response": map[string]any{ + "decision": "approved", + "feedback": "Approved via Response Hub", + }, + }, + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "approved", result["decision"]) + assert.Equal(t, types.ExecutionStatusRunning, result["new_status"]) + + wfExec, err := store.GetWorkflowExecution(context.Background(), "exec-hax") + require.NoError(t, err) + assert.Equal(t, types.ExecutionStatusRunning, wfExec.Status) + assert.Equal(t, "approved", *wfExec.ApprovalStatus) +} + +func TestApprovalWebhook_HaxSDKExpiredEvent(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := seedWaitingExecution(t, "exec-hax-exp", "agent-1", "req-hax-exp") + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, "")) + + body, _ := json.Marshal(map[string]any{ + "id": "evt_002", + "type": "expired", + "createdAt": time.Now().UTC().Format(time.RFC3339), + "data": map[string]any{ + "requestId": "req-hax-exp", + "response": map[string]any{}, + }, + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + wfExec, err := store.GetWorkflowExecution(context.Background(), "exec-hax-exp") + require.NoError(t, err) + assert.Equal(t, types.ExecutionStatusCancelled, wfExec.Status) + assert.Equal(t, "expired", *wfExec.ApprovalStatus) +} + +// --------------------------------------------------------------------------- +// Idempotency +// --------------------------------------------------------------------------- + +func TestApprovalWebhook_IdempotentDuplicate(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := seedWaitingExecution(t, "exec-idem", "agent-1", "req-idem") + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, "")) + + makeBody := func() []byte { + b, _ := json.Marshal(map[string]any{ + "requestId": "req-idem", + "decision": "approved", + }) + return b + } + + // First call — should process + req1 := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(makeBody())) + req1.Header.Set("Content-Type", "application/json") + resp1 := httptest.NewRecorder() + router.ServeHTTP(resp1, req1) + require.Equal(t, http.StatusOK, resp1.Code) + + var result1 map[string]any + require.NoError(t, json.Unmarshal(resp1.Body.Bytes(), &result1)) + assert.Equal(t, "processed", result1["status"]) + + // Second call — should be idempotent + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(makeBody())) + req2.Header.Set("Content-Type", "application/json") + resp2 := httptest.NewRecorder() + router.ServeHTTP(resp2, req2) + require.Equal(t, http.StatusOK, resp2.Code) + + var result2 map[string]any + require.NoError(t, json.Unmarshal(resp2.Body.Bytes(), &result2)) + assert.Equal(t, "already_processed", result2["status"]) +} + +// --------------------------------------------------------------------------- +// Error cases +// --------------------------------------------------------------------------- + +func TestApprovalWebhook_MissingRequestID(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := newTestExecutionStorage(&types.AgentNode{ID: "agent-1"}) + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, "")) + + body, _ := json.Marshal(map[string]any{ + "decision": "approved", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusBadRequest, resp.Code) +} + +func TestApprovalWebhook_InvalidDecision(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := newTestExecutionStorage(&types.AgentNode{ID: "agent-1"}) + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, "")) + + body, _ := json.Marshal(map[string]any{ + "requestId": "req-bad", + "decision": "maybe", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusBadRequest, resp.Code) +} + +func TestApprovalWebhook_UnknownRequestID(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := newTestExecutionStorage(&types.AgentNode{ID: "agent-1"}) + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, "")) + + body, _ := json.Marshal(map[string]any{ + "requestId": "req-unknown", + "decision": "approved", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusNotFound, resp.Code) +} + +// --------------------------------------------------------------------------- +// HMAC signature verification +// --------------------------------------------------------------------------- + +func TestApprovalWebhook_ValidSignature(t *testing.T) { + gin.SetMode(gin.TestMode) + + secret := "test-webhook-secret" + store := seedWaitingExecution(t, "exec-sig", "agent-1", "req-sig") + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, secret)) + + body, _ := json.Marshal(map[string]any{ + "requestId": "req-sig", + "decision": "approved", + }) + + // Compute HMAC-SHA256 signature + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + signature := hex.EncodeToString(mac.Sum(nil)) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Webhook-Signature", signature) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "approved", result["decision"]) +} + +func TestApprovalWebhook_InvalidSignature(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := seedWaitingExecution(t, "exec-badsig", "agent-1", "req-badsig") + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, "real-secret")) + + body, _ := json.Marshal(map[string]any{ + "requestId": "req-badsig", + "decision": "approved", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Webhook-Signature", "deadbeef") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusUnauthorized, resp.Code) +} + +func TestApprovalWebhook_MissingSignatureWhenRequired(t *testing.T) { + gin.SetMode(gin.TestMode) + + store := seedWaitingExecution(t, "exec-nosig", "agent-1", "req-nosig") + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, "real-secret")) + + body, _ := json.Marshal(map[string]any{ + "requestId": "req-nosig", + "decision": "approved", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + // No signature header + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusUnauthorized, resp.Code) +} + +func TestApprovalWebhook_HaxSignatureFormat(t *testing.T) { + gin.SetMode(gin.TestMode) + + secret := "hax-webhook-secret" + store := seedWaitingExecution(t, "exec-hax-sig", "agent-1", "req-hax-sig") + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, secret)) + + body, _ := json.Marshal(map[string]any{ + "requestId": "req-hax-sig", + "decision": "approved", + }) + + // Compute hax-sdk style signature: t=timestamp,v1=signature + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + signedPayload := timestamp + "." + string(body) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signedPayload)) + sig := hex.EncodeToString(mac.Sum(nil)) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Hax-Signature", fmt.Sprintf("t=%s,v1=%s", timestamp, sig)) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) +} + +// --------------------------------------------------------------------------- +// Callback notification +// --------------------------------------------------------------------------- + +func TestApprovalWebhook_CallbackNotification(t *testing.T) { + gin.SetMode(gin.TestMode) + + agent := &types.AgentNode{ID: "agent-1"} + store := newTestExecutionStorage(agent) + + // Track callback + var callbackReceived atomic.Bool + var callbackBody map[string]any + callbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callbackReceived.Store(true) + _ = json.NewDecoder(r.Body).Decode(&callbackBody) + w.WriteHeader(http.StatusOK) + })) + defer callbackServer.Close() + + now := time.Now().UTC() + reqID := "req-cb" + status := "pending" + callbackURL := callbackServer.URL + "/webhooks/approval" + + require.NoError(t, store.CreateExecutionRecord(context.Background(), &types.Execution{ + ExecutionID: "exec-cb", + RunID: "run-1", + AgentNodeID: "agent-1", + Status: types.ExecutionStatusWaiting, + StartedAt: now, + CreatedAt: now, + })) + cbRunID := "run-1" + require.NoError(t, store.StoreWorkflowExecution(context.Background(), &types.WorkflowExecution{ + ExecutionID: "exec-cb", + WorkflowID: "wf-1", + RunID: &cbRunID, + AgentNodeID: "agent-1", + Status: types.ExecutionStatusWaiting, + StartedAt: now, + ApprovalRequestID: &reqID, + ApprovalStatus: &status, + ApprovalRequestedAt: &now, + ApprovalCallbackURL: &callbackURL, + })) + + router := gin.New() + router.POST("/api/v1/webhooks/approval-response", ApprovalWebhookHandler(store, "")) + + body, _ := json.Marshal(map[string]any{ + "requestId": "req-cb", + "decision": "approved", + "feedback": "LGTM", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/approval-response", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + // Wait briefly for the async callback goroutine + time.Sleep(200 * time.Millisecond) + + assert.True(t, callbackReceived.Load(), "callback should have been received") + assert.Equal(t, "exec-cb", callbackBody["execution_id"]) + assert.Equal(t, "approved", callbackBody["decision"]) +} + +// --------------------------------------------------------------------------- +// Full end-to-end flow: request → waiting → webhook → resume +// --------------------------------------------------------------------------- + +func TestApprovalFlow_EndToEnd(t *testing.T) { + gin.SetMode(gin.TestMode) + + agent := &types.AgentNode{ID: "agent-e2e"} + store := newTestExecutionStorage(agent) + + now := time.Now().UTC() + require.NoError(t, store.CreateExecutionRecord(context.Background(), &types.Execution{ + ExecutionID: "exec-e2e", + RunID: "run-e2e", + AgentNodeID: "agent-e2e", + Status: types.ExecutionStatusRunning, + StartedAt: now, + CreatedAt: now, + })) + e2eRunID := "run-e2e" + require.NoError(t, store.StoreWorkflowExecution(context.Background(), &types.WorkflowExecution{ + ExecutionID: "exec-e2e", + WorkflowID: "wf-e2e", + RunID: &e2eRunID, + AgentNodeID: "agent-e2e", + Status: types.ExecutionStatusRunning, + StartedAt: now, + })) + + router := gin.New() + router.POST("/api/v1/agents/:node_id/executions/:execution_id/request-approval", + AgentScopedRequestApprovalHandler(store)) + router.GET("/api/v1/agents/:node_id/executions/:execution_id/approval-status", + AgentScopedGetApprovalStatusHandler(store)) + router.POST("/api/v1/webhooks/approval-response", + ApprovalWebhookHandler(store, "")) + + // ---- Step 1: Request approval ---- + reqBody, _ := json.Marshal(map[string]any{ + "approval_request_id": "req-e2e-flow", + "approval_request_url": "https://hub.example.com/review/req-e2e-flow", + "expires_in_hours": 48, + }) + reqApproval := httptest.NewRequest(http.MethodPost, + "/api/v1/agents/agent-e2e/executions/exec-e2e/request-approval", + bytes.NewReader(reqBody)) + reqApproval.Header.Set("Content-Type", "application/json") + respApproval := httptest.NewRecorder() + router.ServeHTTP(respApproval, reqApproval) + + require.Equal(t, http.StatusOK, respApproval.Code, "request approval should succeed") + + // ---- Step 2: Verify status is pending ---- + reqStatus := httptest.NewRequest(http.MethodGet, + "/api/v1/agents/agent-e2e/executions/exec-e2e/approval-status", nil) + respStatus := httptest.NewRecorder() + router.ServeHTTP(respStatus, reqStatus) + + require.Equal(t, http.StatusOK, respStatus.Code) + var statusResult map[string]any + require.NoError(t, json.Unmarshal(respStatus.Body.Bytes(), &statusResult)) + assert.Equal(t, "pending", statusResult["status"]) + + // Verify execution is in waiting state + wfExec, _ := store.GetWorkflowExecution(context.Background(), "exec-e2e") + assert.Equal(t, types.ExecutionStatusWaiting, wfExec.Status) + + // ---- Step 3: Webhook resolves the approval ---- + webhookBody, _ := json.Marshal(map[string]any{ + "requestId": "req-e2e-flow", + "decision": "approved", + "feedback": "Ship it!", + }) + reqWebhook := httptest.NewRequest(http.MethodPost, + "/api/v1/webhooks/approval-response", + bytes.NewReader(webhookBody)) + reqWebhook.Header.Set("Content-Type", "application/json") + respWebhook := httptest.NewRecorder() + router.ServeHTTP(respWebhook, reqWebhook) + + require.Equal(t, http.StatusOK, respWebhook.Code) + var webhookResult map[string]any + require.NoError(t, json.Unmarshal(respWebhook.Body.Bytes(), &webhookResult)) + assert.Equal(t, "approved", webhookResult["decision"]) + assert.Equal(t, types.ExecutionStatusRunning, webhookResult["new_status"]) + + // ---- Step 4: Verify final state ---- + reqStatusFinal := httptest.NewRequest(http.MethodGet, + "/api/v1/agents/agent-e2e/executions/exec-e2e/approval-status", nil) + respStatusFinal := httptest.NewRecorder() + router.ServeHTTP(respStatusFinal, reqStatusFinal) + + require.Equal(t, http.StatusOK, respStatusFinal.Code) + var finalStatus map[string]any + require.NoError(t, json.Unmarshal(respStatusFinal.Body.Bytes(), &finalStatus)) + assert.Equal(t, "approved", finalStatus["status"]) + assert.NotNil(t, finalStatus["responded_at"]) + + // Verify execution is back to running + wfExec, _ = store.GetWorkflowExecution(context.Background(), "exec-e2e") + assert.Equal(t, types.ExecutionStatusRunning, wfExec.Status) + assert.Nil(t, wfExec.CompletedAt, "approved execution should not be completed") + + // Verify lightweight execution record + execRecord, _ := store.GetExecutionRecord(context.Background(), "exec-e2e") + assert.Equal(t, types.ExecutionStatusRunning, execRecord.Status) +} + +// --------------------------------------------------------------------------- +// End-to-end: request_changes flow (agent can re-request after changes) +// --------------------------------------------------------------------------- + +func TestApprovalFlow_RequestChanges_ThenReapprove(t *testing.T) { + gin.SetMode(gin.TestMode) + + agent := &types.AgentNode{ID: "agent-rc"} + store := newTestExecutionStorage(agent) + + now := time.Now().UTC() + require.NoError(t, store.CreateExecutionRecord(context.Background(), &types.Execution{ + ExecutionID: "exec-rc-flow", + RunID: "run-rc", + AgentNodeID: "agent-rc", + Status: types.ExecutionStatusRunning, + StartedAt: now, + CreatedAt: now, + })) + rcRunID := "run-rc" + require.NoError(t, store.StoreWorkflowExecution(context.Background(), &types.WorkflowExecution{ + ExecutionID: "exec-rc-flow", + WorkflowID: "wf-rc", + RunID: &rcRunID, + AgentNodeID: "agent-rc", + Status: types.ExecutionStatusRunning, + StartedAt: now, + })) + + router := gin.New() + router.POST("/api/v1/agents/:node_id/executions/:execution_id/request-approval", + AgentScopedRequestApprovalHandler(store)) + router.POST("/api/v1/webhooks/approval-response", + ApprovalWebhookHandler(store, "")) + + // ---- Round 1: Request approval ---- + reqBody1, _ := json.Marshal(map[string]any{ + "approval_request_id": "req-round1", + }) + r1 := httptest.NewRequest(http.MethodPost, + "/api/v1/agents/agent-rc/executions/exec-rc-flow/request-approval", + bytes.NewReader(reqBody1)) + r1.Header.Set("Content-Type", "application/json") + w1 := httptest.NewRecorder() + router.ServeHTTP(w1, r1) + require.Equal(t, http.StatusOK, w1.Code) + + // ---- Round 1: Reviewer requests changes ---- + webhookBody1, _ := json.Marshal(map[string]any{ + "requestId": "req-round1", + "decision": "request_changes", + "feedback": "Add tests please", + }) + rw1 := httptest.NewRequest(http.MethodPost, + "/api/v1/webhooks/approval-response", + bytes.NewReader(webhookBody1)) + rw1.Header.Set("Content-Type", "application/json") + ww1 := httptest.NewRecorder() + router.ServeHTTP(ww1, rw1) + require.Equal(t, http.StatusOK, ww1.Code) + + // Execution should be back to running with cleared approval fields + wfExec, _ := store.GetWorkflowExecution(context.Background(), "exec-rc-flow") + assert.Equal(t, types.ExecutionStatusRunning, wfExec.Status) + assert.Nil(t, wfExec.ApprovalRequestID, "approval fields should be cleared after request_changes") + + // ---- Round 2: Agent re-requests approval ---- + reqBody2, _ := json.Marshal(map[string]any{ + "approval_request_id": "req-round2", + }) + r2 := httptest.NewRequest(http.MethodPost, + "/api/v1/agents/agent-rc/executions/exec-rc-flow/request-approval", + bytes.NewReader(reqBody2)) + r2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + router.ServeHTTP(w2, r2) + require.Equal(t, http.StatusOK, w2.Code, "second approval request should succeed after request_changes") + + // ---- Round 2: Approved this time ---- + webhookBody2, _ := json.Marshal(map[string]any{ + "requestId": "req-round2", + "decision": "approved", + }) + rw2 := httptest.NewRequest(http.MethodPost, + "/api/v1/webhooks/approval-response", + bytes.NewReader(webhookBody2)) + rw2.Header.Set("Content-Type", "application/json") + ww2 := httptest.NewRecorder() + router.ServeHTTP(ww2, rw2) + require.Equal(t, http.StatusOK, ww2.Code) + + wfExec, _ = store.GetWorkflowExecution(context.Background(), "exec-rc-flow") + assert.Equal(t, types.ExecutionStatusRunning, wfExec.Status) + assert.Equal(t, "approved", *wfExec.ApprovalStatus) +} diff --git a/control-plane/internal/handlers/workflow_dag.go b/control-plane/internal/handlers/workflow_dag.go index c4cad60d..333ffc12 100644 --- a/control-plane/internal/handlers/workflow_dag.go +++ b/control-plane/internal/handlers/workflow_dag.go @@ -33,6 +33,7 @@ type WorkflowDAGNode struct { AgentNodeID string `json:"agent_node_id"` ReasonerID string `json:"reasoner_id"` Status string `json:"status"` + StatusReason *string `json:"status_reason,omitempty"` StartedAt string `json:"started_at"` CompletedAt *string `json:"completed_at,omitempty"` DurationMS *int64 `json:"duration_ms,omitempty"` @@ -485,6 +486,7 @@ func executionToDAGNode(exec *types.Execution, depth int) WorkflowDAGNode { AgentNodeID: exec.AgentNodeID, ReasonerID: exec.ReasonerID, Status: types.NormalizeExecutionStatus(exec.Status), + StatusReason: exec.StatusReason, StartedAt: started, CompletedAt: completed, DurationMS: exec.DurationMS, @@ -498,22 +500,34 @@ func executionToDAGNode(exec *types.Execution, depth int) WorkflowDAGNode { func deriveOverallStatus(executions []*types.Execution) string { hasRunning := false hasFailed := false + hasTimeout := false + hasCancelled := false for _, exec := range executions { status := types.NormalizeExecutionStatus(exec.Status) switch status { - case string(types.ExecutionStatusRunning), string(types.ExecutionStatusPending), string(types.ExecutionStatusQueued): + case string(types.ExecutionStatusRunning), string(types.ExecutionStatusWaiting), string(types.ExecutionStatusPending), string(types.ExecutionStatusQueued): hasRunning = true case string(types.ExecutionStatusFailed): hasFailed = true + case string(types.ExecutionStatusTimeout): + hasTimeout = true + case string(types.ExecutionStatusCancelled): + hasCancelled = true } } - // Priority: running > failed > succeeded + // Priority: running > failed > timeout > cancelled > succeeded if hasRunning { return string(types.ExecutionStatusRunning) } if hasFailed { return string(types.ExecutionStatusFailed) } + if hasTimeout { + return string(types.ExecutionStatusTimeout) + } + if hasCancelled { + return string(types.ExecutionStatusCancelled) + } return string(types.ExecutionStatusSucceeded) } diff --git a/control-plane/internal/handlers/workflow_dag_test.go b/control-plane/internal/handlers/workflow_dag_test.go index 728c5e2a..8bb5c54e 100644 --- a/control-plane/internal/handlers/workflow_dag_test.go +++ b/control-plane/internal/handlers/workflow_dag_test.go @@ -497,7 +497,7 @@ func TestBuildExecutionDAG_WithSessionAndActor(t *testing.T) { } func TestDeriveOverallStatus_PriorityOrder(t *testing.T) { - // Test status priority: running > failed > succeeded + // Test status priority: running > failed > timeout > cancelled > succeeded tests := []struct { name string statuses []string @@ -508,11 +508,46 @@ func TestDeriveOverallStatus_PriorityOrder(t *testing.T) { statuses: []string{"succeeded", "running", "failed"}, expected: "running", }, + { + name: "waiting counts as running", + statuses: []string{"succeeded", "waiting", "succeeded"}, + expected: "running", + }, + { + name: "waiting has priority over failed", + statuses: []string{"succeeded", "waiting", "failed"}, + expected: "running", + }, + { + name: "waiting has priority over timeout", + statuses: []string{"timeout", "waiting"}, + expected: "running", + }, { name: "failed has priority over succeeded", statuses: []string{"succeeded", "failed", "succeeded"}, expected: "failed", }, + { + name: "failed has priority over timeout", + statuses: []string{"succeeded", "failed", "timeout"}, + expected: "failed", + }, + { + name: "timeout has priority over succeeded", + statuses: []string{"succeeded", "timeout", "succeeded"}, + expected: "timeout", + }, + { + name: "timeout has priority over cancelled", + statuses: []string{"cancelled", "timeout"}, + expected: "timeout", + }, + { + name: "cancelled has priority over succeeded", + statuses: []string{"succeeded", "cancelled", "succeeded"}, + expected: "cancelled", + }, { name: "all succeeded", statuses: []string{"succeeded", "succeeded"}, @@ -528,6 +563,16 @@ func TestDeriveOverallStatus_PriorityOrder(t *testing.T) { statuses: []string{"succeeded", "pending", "succeeded"}, expected: "running", }, + { + name: "all timeout returns timeout not succeeded", + statuses: []string{"timeout", "timeout"}, + expected: "timeout", + }, + { + name: "all cancelled returns cancelled not succeeded", + statuses: []string{"cancelled", "cancelled"}, + expected: "cancelled", + }, } for _, tt := range tests { diff --git a/control-plane/internal/handlers/workflow_execution_events.go b/control-plane/internal/handlers/workflow_execution_events.go index 5272a9da..27f78cfb 100644 --- a/control-plane/internal/handlers/workflow_execution_events.go +++ b/control-plane/internal/handlers/workflow_execution_events.go @@ -54,6 +54,15 @@ func WorkflowExecutionEventHandler(store ExecutionStore) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create execution: %v", err)}) return } + // Also ensure a workflow_executions record exists so that + // approval endpoints (which query workflow_executions) work + // for executions reported through this event-based path. + wfExec := buildWorkflowExecutionFromEvent(&req, now) + if storeErr := store.StoreWorkflowExecution(ctx, wfExec); storeErr != nil { + // Non-fatal: the lightweight record was already created. + // Log but don't fail the request. + fmt.Printf("WARN: failed to create workflow execution record for %s: %v\n", req.ExecutionID, storeErr) + } c.JSON(http.StatusOK, gin.H{"success": true, "created": true}) return } @@ -165,6 +174,48 @@ func applyEventToExecution(current *types.Execution, req *WorkflowExecutionEvent } } +func buildWorkflowExecutionFromEvent(req *WorkflowExecutionEventRequest, now time.Time) *types.WorkflowExecution { + runID := firstNonEmpty(req.RunID, req.WorkflowID, req.ExecutionID) + agentNodeID := firstNonEmpty(req.AgentNodeID, req.Type) + reasonerID := firstNonEmpty(req.ReasonerID, req.Type, "reasoner") + status := types.NormalizeExecutionStatus(req.Status) + inputPayload := marshalJSON(req.InputData) + outputPayload := marshalJSON(req.Result) + workflowName := fmt.Sprintf("%s.%s", agentNodeID, reasonerID) + + wfExec := &types.WorkflowExecution{ + WorkflowID: runID, + ExecutionID: req.ExecutionID, + RunID: &runID, + AgentNodeID: agentNodeID, + ReasonerID: reasonerID, + Status: status, + InputData: inputPayload, + OutputData: outputPayload, + StartedAt: now, + CreatedAt: now, + UpdatedAt: now, + WorkflowName: &workflowName, + } + + if req.ParentExecutionID != nil { + wfExec.ParentExecutionID = req.ParentExecutionID + } + if req.ParentWorkflowID != nil { + wfExec.ParentWorkflowID = req.ParentWorkflowID + } + if req.Error != "" { + errCopy := req.Error + wfExec.ErrorMessage = &errCopy + } + if types.IsTerminalExecutionStatus(status) { + completed := now + wfExec.CompletedAt = &completed + } + + return wfExec +} + func marshalJSON(value interface{}) json.RawMessage { if value == nil { return nil diff --git a/control-plane/internal/server/server.go b/control-plane/internal/server/server.go index 3e34a219..d6d59b1b 100644 --- a/control-plane/internal/server/server.go +++ b/control-plane/internal/server/server.go @@ -1229,6 +1229,19 @@ func (s *AgentFieldServer) setupRoutes() { agentAPI.POST("/executions/batch-status", handlers.BatchExecutionStatusHandler(s.storage)) agentAPI.POST("/executions/:execution_id/status", handlers.UpdateExecutionStatusHandler(s.storage, s.payloadStore, s.webhookDispatcher, s.config.AgentField.ExecutionQueue.AgentCallTimeout)) + // Approval workflow endpoints — CP manages execution state only; + // agents handle external approval service communication directly. + agentAPI.POST("/executions/:execution_id/request-approval", handlers.RequestApprovalHandler(s.storage)) + agentAPI.GET("/executions/:execution_id/approval-status", handlers.GetApprovalStatusHandler(s.storage)) + + // Agent-scoped approval routes — enforce that the execution belongs to the requesting agent. + // Agents should use these instead of the global routes above. + agentAPI.POST("/agents/:node_id/executions/:execution_id/request-approval", handlers.AgentScopedRequestApprovalHandler(s.storage)) + agentAPI.GET("/agents/:node_id/executions/:execution_id/approval-status", handlers.AgentScopedGetApprovalStatusHandler(s.storage)) + + // Approval resolution webhook (called by agents or external services when approval resolves) + agentAPI.POST("/webhooks/approval-response", handlers.ApprovalWebhookHandler(s.storage, s.config.AgentField.Approval.WebhookSecret)) + // Execution notes endpoints for app.note() feature agentAPI.POST("/executions/note", handlers.AddExecutionNoteHandler(s.storage)) agentAPI.GET("/executions/:execution_id/notes", handlers.GetExecutionNotesHandler(s.storage)) @@ -1332,8 +1345,8 @@ func (s *AgentFieldServer) setupRoutes() { // Agent Tag VC endpoint (for agents to download their own verified tag credential) if s.tagVCVerifier != nil { - agentAPI.GET("/agents/:agentId/tag-vc", func(c *gin.Context) { - agentID := c.Param("agentId") + agentAPI.GET("/agents/:node_id/tag-vc", func(c *gin.Context) { + agentID := c.Param("node_id") if agentID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "agent_id is required"}) return diff --git a/control-plane/internal/services/workflowstatus/aggregator.go b/control-plane/internal/services/workflowstatus/aggregator.go index 6200f0c3..7966d62f 100644 --- a/control-plane/internal/services/workflowstatus/aggregator.go +++ b/control-plane/internal/services/workflowstatus/aggregator.go @@ -27,6 +27,7 @@ var ( string(types.ExecutionStatusTimeout), string(types.ExecutionStatusCancelled), string(types.ExecutionStatusRunning), + string(types.ExecutionStatusWaiting), string(types.ExecutionStatusQueued), string(types.ExecutionStatusPending), string(types.ExecutionStatusSucceeded), @@ -90,7 +91,7 @@ func AggregateExecutions(executions []*types.WorkflowExecution, steps []*types.W } switch normalized { - case string(types.ExecutionStatusRunning), string(types.ExecutionStatusQueued), string(types.ExecutionStatusPending): + case string(types.ExecutionStatusRunning), string(types.ExecutionStatusWaiting), string(types.ExecutionStatusQueued), string(types.ExecutionStatusPending): result.ActiveExecutions++ } @@ -152,7 +153,7 @@ func AggregateExecutions(executions []*types.WorkflowExecution, steps []*types.W if result.ActiveExecutions > 0 { switch baseStatus { - case string(types.ExecutionStatusQueued), string(types.ExecutionStatusPending), string(types.ExecutionStatusRunning): + case string(types.ExecutionStatusQueued), string(types.ExecutionStatusPending), string(types.ExecutionStatusWaiting), string(types.ExecutionStatusRunning): result.Status = baseStatus default: result.Status = string(types.ExecutionStatusRunning) diff --git a/control-plane/internal/services/workflowstatus/aggregator_test.go b/control-plane/internal/services/workflowstatus/aggregator_test.go index bd862cc8..9f9a51f0 100644 --- a/control-plane/internal/services/workflowstatus/aggregator_test.go +++ b/control-plane/internal/services/workflowstatus/aggregator_test.go @@ -103,6 +103,24 @@ func TestAggregateExecutions_QueuedOnly(t *testing.T) { } } +func TestAggregateExecutions_WaitingIsActiveNonTerminal(t *testing.T) { + now := time.Now() + executions := []*types.WorkflowExecution{ + makeExecution("waiting", now, nil, true), + } + + agg := AggregateExecutions(executions, nil) + if agg.Status != string(types.ExecutionStatusWaiting) { + t.Fatalf("expected waiting, got %s", agg.Status) + } + if agg.ActiveExecutions != 1 { + t.Fatalf("expected active executions, got %d", agg.ActiveExecutions) + } + if agg.Terminal { + t.Fatal("expected non-terminal workflow") + } +} + func TestAggregateExecutions_TimeoutBeatsCancelled(t *testing.T) { now := time.Now() executions := []*types.WorkflowExecution{ diff --git a/control-plane/internal/storage/execution_records.go b/control-plane/internal/storage/execution_records.go index 769e858f..82a94256 100644 --- a/control-plane/internal/storage/execution_records.go +++ b/control-plane/internal/storage/execution_records.go @@ -34,13 +34,13 @@ func (ls *LocalStorage) CreateExecutionRecord(ctx context.Context, exec *types.E INSERT INTO executions ( execution_id, run_id, parent_execution_id, agent_node_id, reasoner_id, node_id, - status, input_payload, result_payload, error_message, + status, status_reason, input_payload, result_payload, error_message, input_uri, result_uri, session_id, actor_id, started_at, completed_at, duration_ms, notes, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` // Serialize notes to JSON var notesJSON []byte @@ -62,6 +62,7 @@ func (ls *LocalStorage) CreateExecutionRecord(ctx context.Context, exec *types.E exec.ReasonerID, exec.NodeID, exec.Status, + exec.StatusReason, bytesOrNil(exec.InputPayload), bytesOrNil(exec.ResultPayload), exec.ErrorMessage, @@ -88,7 +89,7 @@ func (ls *LocalStorage) GetExecutionRecord(ctx context.Context, executionID stri query := ` SELECT execution_id, run_id, parent_execution_id, agent_node_id, reasoner_id, node_id, - status, input_payload, result_payload, error_message, + status, status_reason, input_payload, result_payload, error_message, input_uri, result_uri, session_id, actor_id, started_at, completed_at, duration_ms, @@ -125,7 +126,7 @@ func (ls *LocalStorage) UpdateExecutionRecord(ctx context.Context, executionID s row := tx.QueryRowContext(ctx, ` SELECT execution_id, run_id, parent_execution_id, agent_node_id, reasoner_id, node_id, - status, input_payload, result_payload, error_message, + status, status_reason, input_payload, result_payload, error_message, input_uri, result_uri, session_id, actor_id, started_at, completed_at, duration_ms, @@ -169,6 +170,7 @@ func (ls *LocalStorage) UpdateExecutionRecord(ctx context.Context, executionID s reasoner_id = ?, node_id = ?, status = ?, + status_reason = ?, input_payload = ?, result_payload = ?, error_message = ?, @@ -192,6 +194,7 @@ func (ls *LocalStorage) UpdateExecutionRecord(ctx context.Context, executionID s updated.ReasonerID, updated.NodeID, updated.Status, + updated.StatusReason, bytesOrNil(updated.InputPayload), bytesOrNil(updated.ResultPayload), updated.ErrorMessage, @@ -270,7 +273,7 @@ func (ls *LocalStorage) QueryExecutionRecords(ctx context.Context, filter types. queryBuilder.WriteString(` SELECT execution_id, run_id, parent_execution_id, agent_node_id, reasoner_id, node_id, - status, input_payload, result_payload, error_message, + status, status_reason, input_payload, result_payload, error_message, input_uri, result_uri, session_id, actor_id, started_at, completed_at, duration_ms, @@ -418,7 +421,8 @@ func (ls *LocalStorage) QueryRunSummaries(ctx context.Context, filter types.Exec SUM(CASE WHEN LOWER(status) = 'running' THEN 1 ELSE 0 END) AS running_count, SUM(CASE WHEN LOWER(status) = 'pending' THEN 1 ELSE 0 END) AS pending_count, SUM(CASE WHEN LOWER(status) = 'queued' THEN 1 ELSE 0 END) AS queued_count, - SUM(CASE WHEN LOWER(status) IN ('running','pending','queued') THEN 1 ELSE 0 END) AS active_executions, + SUM(CASE WHEN LOWER(status) = 'waiting' THEN 1 ELSE 0 END) AS waiting_count, + SUM(CASE WHEN LOWER(status) IN ('running','pending','queued','waiting') THEN 1 ELSE 0 END) AS active_executions, MAX(CASE WHEN parent_execution_id IS NULL OR parent_execution_id = '' THEN execution_id END) AS root_execution_id, MAX(CASE WHEN parent_execution_id IS NULL OR parent_execution_id = '' THEN agent_node_id END) AS root_agent_node_id, MAX(CASE WHEN parent_execution_id IS NULL OR parent_execution_id = '' THEN reasoner_id END) AS root_reasoner_id, @@ -426,7 +430,7 @@ func (ls *LocalStorage) QueryRunSummaries(ctx context.Context, filter types.Exec MAX(actor_id) AS actor_id, CASE WHEN SUM(CASE WHEN LOWER(status) IN ('failed','cancelled','timeout') THEN 1 ELSE 0 END) > 0 THEN 2 - WHEN SUM(CASE WHEN LOWER(status) IN ('running','pending','queued') THEN 1 ELSE 0 END) > 0 THEN 1 + WHEN SUM(CASE WHEN LOWER(status) IN ('running','pending','queued','waiting') THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END AS status_rank FROM executions @@ -465,6 +469,7 @@ func (ls *LocalStorage) QueryRunSummaries(ctx context.Context, filter types.Exec runningCount int pendingCount int queuedCount int + waitingCount int activeExecutions int rootExecutionID sql.NullString rootAgentNodeID sql.NullString @@ -486,6 +491,7 @@ func (ls *LocalStorage) QueryRunSummaries(ctx context.Context, filter types.Exec &runningCount, &pendingCount, &queuedCount, + &waitingCount, &activeExecutions, &rootExecutionID, &rootAgentNodeID, @@ -507,6 +513,7 @@ func (ls *LocalStorage) QueryRunSummaries(ctx context.Context, filter types.Exec string(types.ExecutionStatusCancelled): cancelledCount, string(types.ExecutionStatusTimeout): timeoutCount, string(types.ExecutionStatusRunning): runningCount, + string(types.ExecutionStatusWaiting): waitingCount, string(types.ExecutionStatusPending): pendingCount, string(types.ExecutionStatusQueued): queuedCount, }, @@ -705,6 +712,7 @@ func (ls *LocalStorage) getRunAggregation(ctx context.Context, runID string) (*R // Count active executions if normalized == string(types.ExecutionStatusRunning) || + normalized == string(types.ExecutionStatusWaiting) || normalized == string(types.ExecutionStatusPending) || normalized == string(types.ExecutionStatusQueued) { activeCount += count @@ -1033,6 +1041,7 @@ func scanExecution(scanner interface { actorID sql.NullString inputURI sql.NullString resultURI sql.NullString + statusReason sql.NullString inputPayload []byte resultPayload []byte errorMessage sql.NullString @@ -1049,6 +1058,7 @@ func scanExecution(scanner interface { &exec.ReasonerID, &exec.NodeID, &exec.Status, + &statusReason, &inputPayload, &resultPayload, &errorMessage, @@ -1079,6 +1089,9 @@ func scanExecution(scanner interface { if actorID.Valid { exec.ActorID = &actorID.String } + if statusReason.Valid { + exec.StatusReason = &statusReason.String + } exec.InputPayload = append(json.RawMessage(nil), inputPayload...) if len(resultPayload) > 0 { exec.ResultPayload = append(json.RawMessage(nil), resultPayload...) diff --git a/control-plane/internal/storage/execution_state_validation.go b/control-plane/internal/storage/execution_state_validation.go index 8651ddc6..49b32c1b 100644 --- a/control-plane/internal/storage/execution_state_validation.go +++ b/control-plane/internal/storage/execution_state_validation.go @@ -26,7 +26,8 @@ func validateExecutionStateTransition(currentStatus, newStatus string) error { string(types.ExecutionStatusUnknown): {string(types.ExecutionStatusPending)}, string(types.ExecutionStatusPending): {string(types.ExecutionStatusQueued), string(types.ExecutionStatusRunning), string(types.ExecutionStatusCancelled)}, string(types.ExecutionStatusQueued): {string(types.ExecutionStatusRunning), string(types.ExecutionStatusCancelled)}, - string(types.ExecutionStatusRunning): {string(types.ExecutionStatusSucceeded), string(types.ExecutionStatusFailed), string(types.ExecutionStatusCancelled), string(types.ExecutionStatusTimeout)}, + string(types.ExecutionStatusWaiting): {string(types.ExecutionStatusRunning), string(types.ExecutionStatusCancelled), string(types.ExecutionStatusFailed)}, + string(types.ExecutionStatusRunning): {string(types.ExecutionStatusWaiting), string(types.ExecutionStatusSucceeded), string(types.ExecutionStatusFailed), string(types.ExecutionStatusCancelled), string(types.ExecutionStatusTimeout)}, string(types.ExecutionStatusSucceeded): {}, string(types.ExecutionStatusFailed): {}, string(types.ExecutionStatusCancelled): {}, diff --git a/control-plane/internal/storage/local.go b/control-plane/internal/storage/local.go index 7f841a12..e60e4023 100644 --- a/control-plane/internal/storage/local.go +++ b/control-plane/internal/storage/local.go @@ -75,7 +75,10 @@ func (ls *LocalStorage) getWorkflowExecutionByID(ctx context.Context, q DBTX, ex status, started_at, completed_at, duration_ms, state_version, last_event_sequence, active_children, pending_children, pending_terminal_status, status_reason, lease_owner, lease_expires_at, - error_message, retry_count, workflow_name, workflow_tags, notes, created_at, updated_at + error_message, retry_count, + approval_request_id, approval_request_url, approval_status, approval_response, + approval_requested_at, approval_responded_at, approval_callback_url, approval_expires_at, + workflow_name, workflow_tags, notes, created_at, updated_at FROM workflow_executions WHERE execution_id = ?` row := q.QueryRowContext(ctx, query, executionID) @@ -88,6 +91,8 @@ func (ls *LocalStorage) getWorkflowExecutionByID(ctx context.Context, q DBTX, ex var statusReason sql.NullString var leaseOwner sql.NullString var leaseExpires sql.NullTime + var approvalRequestID, approvalRequestURL, approvalStatus, approvalResponse, approvalCallbackURL sql.NullString + var approvalRequestedAt, approvalRespondedAt, approvalExpiresAt sql.NullTime err := row.Scan( &execution.WorkflowID, &execution.ExecutionID, &execution.AgentFieldRequestID, &runID, &execution.SessionID, &execution.ActorID, &execution.AgentNodeID, @@ -98,7 +103,10 @@ func (ls *LocalStorage) getWorkflowExecutionByID(ctx context.Context, q DBTX, ex &execution.StateVersion, &execution.LastEventSequence, &execution.ActiveChildren, &execution.PendingChildren, &pendingTerminal, &statusReason, &leaseOwner, &leaseExpires, - &execution.ErrorMessage, &execution.RetryCount, &execution.WorkflowName, + &execution.ErrorMessage, &execution.RetryCount, + &approvalRequestID, &approvalRequestURL, &approvalStatus, &approvalResponse, + &approvalRequestedAt, &approvalRespondedAt, &approvalCallbackURL, &approvalExpiresAt, + &execution.WorkflowName, &workflowTagsJSON, ¬esJSON, &execution.CreatedAt, &execution.UpdatedAt, ) @@ -139,6 +147,33 @@ func (ls *LocalStorage) getWorkflowExecutionByID(ctx context.Context, q DBTX, ex t := leaseExpires.Time execution.LeaseExpiresAt = &t } + if approvalRequestID.Valid { + execution.ApprovalRequestID = &approvalRequestID.String + } + if approvalRequestURL.Valid { + execution.ApprovalRequestURL = &approvalRequestURL.String + } + if approvalStatus.Valid { + execution.ApprovalStatus = &approvalStatus.String + } + if approvalResponse.Valid { + execution.ApprovalResponse = &approvalResponse.String + } + if approvalRequestedAt.Valid { + t := approvalRequestedAt.Time + execution.ApprovalRequestedAt = &t + } + if approvalRespondedAt.Valid { + t := approvalRespondedAt.Time + execution.ApprovalRespondedAt = &t + } + if approvalCallbackURL.Valid { + execution.ApprovalCallbackURL = &approvalCallbackURL.String + } + if approvalExpiresAt.Valid { + t := approvalExpiresAt.Time + execution.ApprovalExpiresAt = &t + } // Unmarshal workflow tags if len(workflowTagsJSON) > 0 { @@ -2094,10 +2129,15 @@ const sqliteWorkflowExecutionInsertQuery = `INSERT INTO workflow_executions ( status, started_at, completed_at, duration_ms, state_version, last_event_sequence, active_children, pending_children, pending_terminal_status, status_reason, lease_owner, lease_expires_at, - error_message, retry_count, workflow_name, workflow_tags, notes, created_at, updated_at + error_message, retry_count, + approval_request_id, approval_request_url, approval_status, approval_response, + approval_requested_at, approval_responded_at, approval_callback_url, approval_expires_at, + workflow_name, workflow_tags, notes, created_at, updated_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ? )` // executeWorkflowInsert performs the actual database insert/update operation @@ -2134,7 +2174,11 @@ func (ls *LocalStorage) executeWorkflowInsert(ctx context.Context, q DBTX, execu status = ?, completed_at = ?, duration_ms = ?, state_version = ?, last_event_sequence = ?, active_children = ?, pending_children = ?, pending_terminal_status = ?, status_reason = ?, lease_owner = ?, lease_expires_at = ?, - output_data = ?, output_size = ?, error_message = ?, notes = ?, updated_at = ? + output_data = ?, output_size = ?, error_message = ?, + approval_request_id = ?, approval_request_url = ?, approval_status = ?, + approval_response = ?, approval_requested_at = ?, approval_responded_at = ?, + approval_callback_url = ?, approval_expires_at = ?, + notes = ?, updated_at = ? WHERE execution_id = ?` _, err = q.ExecContext(ctx, updateQuery, @@ -2142,6 +2186,9 @@ func (ls *LocalStorage) executeWorkflowInsert(ctx context.Context, q DBTX, execu execution.StateVersion, execution.LastEventSequence, execution.ActiveChildren, execution.PendingChildren, execution.PendingTerminalStatus, execution.StatusReason, execution.LeaseOwner, execution.LeaseExpiresAt, execution.OutputData, execution.OutputSize, execution.ErrorMessage, + execution.ApprovalRequestID, execution.ApprovalRequestURL, execution.ApprovalStatus, + execution.ApprovalResponse, execution.ApprovalRequestedAt, execution.ApprovalRespondedAt, + execution.ApprovalCallbackURL, execution.ApprovalExpiresAt, notesJSON, time.Now(), execution.ExecutionID) if err != nil { @@ -2184,7 +2231,11 @@ func (ls *LocalStorage) executeWorkflowInsert(ctx context.Context, q DBTX, execu execution.Status, execution.StartedAt, execution.CompletedAt, execution.DurationMS, execution.StateVersion, execution.LastEventSequence, execution.ActiveChildren, execution.PendingChildren, execution.PendingTerminalStatus, execution.StatusReason, execution.LeaseOwner, execution.LeaseExpiresAt, - execution.ErrorMessage, execution.RetryCount, execution.WorkflowName, + execution.ErrorMessage, execution.RetryCount, + execution.ApprovalRequestID, execution.ApprovalRequestURL, execution.ApprovalStatus, + execution.ApprovalResponse, execution.ApprovalRequestedAt, execution.ApprovalRespondedAt, + execution.ApprovalCallbackURL, execution.ApprovalExpiresAt, + execution.WorkflowName, workflowTagsJSON, notesJSON, execution.CreatedAt, execution.UpdatedAt, ) @@ -2455,7 +2506,11 @@ func (ls *LocalStorage) QueryWorkflowExecutions(ctx context.Context, filters typ workflow_executions.lease_owner, workflow_executions.lease_expires_at, workflow_executions.error_message, workflow_executions.retry_count, workflow_executions.workflow_name, workflow_executions.workflow_tags, - workflow_executions.notes, workflow_executions.created_at, workflow_executions.updated_at + workflow_executions.notes, workflow_executions.created_at, workflow_executions.updated_at, + workflow_executions.approval_request_id, workflow_executions.approval_request_url, + workflow_executions.approval_status, workflow_executions.approval_response, + workflow_executions.approval_requested_at, workflow_executions.approval_responded_at, + workflow_executions.approval_callback_url, workflow_executions.approval_expires_at FROM workflow_executions` var conditions []string @@ -2502,6 +2557,10 @@ func (ls *LocalStorage) QueryWorkflowExecutions(ctx context.Context, filters typ conditions = append(conditions, "workflow_executions.status = ?") args = append(args, *filters.Status) } + if filters.ApprovalRequestID != nil { + conditions = append(conditions, "workflow_executions.approval_request_id = ?") + args = append(args, *filters.ApprovalRequestID) + } if filters.StartTime != nil { conditions = append(conditions, "workflow_executions.started_at >= ?") args = append(args, *filters.StartTime) @@ -2567,6 +2626,8 @@ func (ls *LocalStorage) QueryWorkflowExecutions(ctx context.Context, filters typ var runID sql.NullString var leaseOwner sql.NullString var leaseExpires sql.NullTime + var approvalRequestID, approvalRequestURL, approvalStatus, approvalResponse, approvalCallbackURL sql.NullString + var approvalRequestedAt, approvalRespondedAt, approvalExpiresAt sql.NullTime err := rows.Scan( &execution.ID, &execution.WorkflowID, &execution.ExecutionID, @@ -2582,6 +2643,10 @@ func (ls *LocalStorage) QueryWorkflowExecutions(ctx context.Context, filters typ &execution.ErrorMessage, &execution.RetryCount, &execution.WorkflowName, &workflowTagsJSON, ¬esJSON, &execution.CreatedAt, &execution.UpdatedAt, + &approvalRequestID, &approvalRequestURL, + &approvalStatus, &approvalResponse, + &approvalRequestedAt, &approvalRespondedAt, + &approvalCallbackURL, &approvalExpiresAt, ) if err != nil { return nil, fmt.Errorf("failed to scan workflow execution row: %w", err) @@ -2614,6 +2679,33 @@ func (ls *LocalStorage) QueryWorkflowExecutions(ctx context.Context, filters typ t := leaseExpires.Time execution.LeaseExpiresAt = &t } + if approvalRequestID.Valid { + execution.ApprovalRequestID = &approvalRequestID.String + } + if approvalRequestURL.Valid { + execution.ApprovalRequestURL = &approvalRequestURL.String + } + if approvalStatus.Valid { + execution.ApprovalStatus = &approvalStatus.String + } + if approvalResponse.Valid { + execution.ApprovalResponse = &approvalResponse.String + } + if approvalRequestedAt.Valid { + t := approvalRequestedAt.Time + execution.ApprovalRequestedAt = &t + } + if approvalRespondedAt.Valid { + t := approvalRespondedAt.Time + execution.ApprovalRespondedAt = &t + } + if approvalCallbackURL.Valid { + execution.ApprovalCallbackURL = &approvalCallbackURL.String + } + if approvalExpiresAt.Valid { + t := approvalExpiresAt.Time + execution.ApprovalExpiresAt = &t + } if len(workflowTagsJSON) > 0 { if err := json.Unmarshal(workflowTagsJSON, &execution.WorkflowTags); err != nil { diff --git a/control-plane/internal/storage/local_query_test.go b/control-plane/internal/storage/local_query_test.go index ecd93da0..422100b9 100644 --- a/control-plane/internal/storage/local_query_test.go +++ b/control-plane/internal/storage/local_query_test.go @@ -204,6 +204,9 @@ func TestSanitizeFTS5Query(t *testing.T) { func TestValidateExecutionStateTransition(t *testing.T) { require.NoError(t, validateExecutionStateTransition(string(types.ExecutionStatusPending), string(types.ExecutionStatusRunning))) require.NoError(t, validateExecutionStateTransition(string(types.ExecutionStatusRunning), string(types.ExecutionStatusRunning))) + require.NoError(t, validateExecutionStateTransition(string(types.ExecutionStatusRunning), string(types.ExecutionStatusWaiting))) + require.NoError(t, validateExecutionStateTransition(string(types.ExecutionStatusWaiting), string(types.ExecutionStatusRunning))) + require.NoError(t, validateExecutionStateTransition(string(types.ExecutionStatusWaiting), string(types.ExecutionStatusCancelled))) err := validateExecutionStateTransition(string(types.ExecutionStatusRunning), string(types.ExecutionStatusPending)) require.Error(t, err) @@ -211,4 +214,7 @@ func TestValidateExecutionStateTransition(t *testing.T) { require.ErrorAs(t, err, &transitionErr) require.Equal(t, string(types.ExecutionStatusRunning), transitionErr.CurrentState) require.Equal(t, string(types.ExecutionStatusPending), transitionErr.NewState) + + err = validateExecutionStateTransition(string(types.ExecutionStatusQueued), string(types.ExecutionStatusWaiting)) + require.Error(t, err) } diff --git a/control-plane/internal/storage/models.go b/control-plane/internal/storage/models.go index bd2761ed..abb0e92d 100644 --- a/control-plane/internal/storage/models.go +++ b/control-plane/internal/storage/models.go @@ -11,6 +11,7 @@ type ExecutionRecordModel struct { ReasonerID string `gorm:"column:reasoner_id;not null;index"` NodeID string `gorm:"column:node_id;not null;index"` Status string `gorm:"column:status;not null;index"` + StatusReason *string `gorm:"column:status_reason"` InputPayload []byte `gorm:"column:input_payload"` ResultPayload []byte `gorm:"column:result_payload"` ErrorMessage *string `gorm:"column:error_message"` @@ -141,6 +142,14 @@ type WorkflowExecutionModel struct { LeaseExpiresAt *time.Time `gorm:"column:lease_expires_at"` ErrorMessage *string `gorm:"column:error_message"` RetryCount int `gorm:"column:retry_count;default:0"` + ApprovalRequestID *string `gorm:"column:approval_request_id;index:idx_workflow_executions_approval_request_id"` + ApprovalRequestURL *string `gorm:"column:approval_request_url"` + ApprovalStatus *string `gorm:"column:approval_status"` + ApprovalResponse *string `gorm:"column:approval_response"` + ApprovalRequestedAt *time.Time `gorm:"column:approval_requested_at"` + ApprovalRespondedAt *time.Time `gorm:"column:approval_responded_at"` + ApprovalCallbackURL *string `gorm:"column:approval_callback_url"` + ApprovalExpiresAt *time.Time `gorm:"column:approval_expires_at"` Notes string `gorm:"column:notes;default:'[]'"` CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` diff --git a/control-plane/internal/storage/workflow_execution_queries_test.go b/control-plane/internal/storage/workflow_execution_queries_test.go index 6ce45cd5..b8e6beb4 100644 --- a/control-plane/internal/storage/workflow_execution_queries_test.go +++ b/control-plane/internal/storage/workflow_execution_queries_test.go @@ -36,6 +36,14 @@ var workflowExecutionLifecycleColumns = []string{ "lease_expires_at", "error_message", "retry_count", + "approval_request_id", + "approval_request_url", + "approval_status", + "approval_response", + "approval_requested_at", + "approval_responded_at", + "approval_callback_url", + "approval_expires_at", "workflow_name", "workflow_tags", "notes", @@ -53,7 +61,7 @@ func TestWorkflowExecutionInsertQueriesCoverLifecycleColumns(t *testing.T) { {name: "sqlite", query: sqliteWorkflowExecutionInsertQuery, placeholder: "?"}, } - const expectedPlaceholders = 35 + const expectedPlaceholders = 43 for _, tc := range tests { tc := tc diff --git a/control-plane/migrations/024_execution_approval_state.sql b/control-plane/migrations/024_execution_approval_state.sql new file mode 100644 index 00000000..b1189aec --- /dev/null +++ b/control-plane/migrations/024_execution_approval_state.sql @@ -0,0 +1,17 @@ +-- 024_execution_approval_state.sql +-- Adds approval tracking columns to workflow_executions for human-in-the-loop approval flows. +-- Also adds status_reason to the lightweight executions table so polling APIs can surface it. +-- All columns are nullable — existing rows are unaffected. + +-- Approval tracking on workflow_executions +ALTER TABLE workflow_executions ADD COLUMN approval_request_id TEXT; +ALTER TABLE workflow_executions ADD COLUMN approval_request_url TEXT; +ALTER TABLE workflow_executions ADD COLUMN approval_status TEXT; +ALTER TABLE workflow_executions ADD COLUMN approval_response TEXT; +ALTER TABLE workflow_executions ADD COLUMN approval_requested_at TIMESTAMP; +ALTER TABLE workflow_executions ADD COLUMN approval_responded_at TIMESTAMP; + +CREATE INDEX IF NOT EXISTS idx_workflow_executions_approval_request_id ON workflow_executions(approval_request_id); + +-- status_reason on lightweight executions table (mirrors workflow_executions.status_reason) +ALTER TABLE executions ADD COLUMN status_reason TEXT; diff --git a/control-plane/migrations/025_approval_callback_url.sql b/control-plane/migrations/025_approval_callback_url.sql new file mode 100644 index 00000000..4ef46491 --- /dev/null +++ b/control-plane/migrations/025_approval_callback_url.sql @@ -0,0 +1,5 @@ +-- 025_approval_callback_url.sql +-- Adds callback URL column so the control plane can push approval results +-- directly to the waiting agent instead of requiring polling. + +ALTER TABLE workflow_executions ADD COLUMN approval_callback_url TEXT; diff --git a/control-plane/migrations/026_approval_expires_at.sql b/control-plane/migrations/026_approval_expires_at.sql new file mode 100644 index 00000000..c237a300 --- /dev/null +++ b/control-plane/migrations/026_approval_expires_at.sql @@ -0,0 +1,6 @@ +-- 026_approval_expires_at.sql +-- Adds approval_expires_at to workflow_executions so consumers (UI, agents) +-- can derive "expired" from status=waiting + now > approval_expires_at +-- without needing a separate execution status. + +ALTER TABLE workflow_executions ADD COLUMN approval_expires_at TIMESTAMP; diff --git a/control-plane/pkg/types/execution.go b/control-plane/pkg/types/execution.go index 404e7884..6b68e7e7 100644 --- a/control-plane/pkg/types/execution.go +++ b/control-plane/pkg/types/execution.go @@ -26,10 +26,11 @@ type Execution struct { ResultURI *string `json:"result_uri,omitempty" db:"result_uri"` // Lifecycle - Status string `json:"status" db:"status"` - StartedAt time.Time `json:"started_at" db:"started_at"` - CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` - DurationMS *int64 `json:"duration_ms,omitempty" db:"duration_ms"` + Status string `json:"status" db:"status"` + StatusReason *string `json:"status_reason,omitempty" db:"status_reason"` + StartedAt time.Time `json:"started_at" db:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` + DurationMS *int64 `json:"duration_ms,omitempty" db:"duration_ms"` // Optional metadata SessionID *string `json:"session_id,omitempty" db:"session_id"` diff --git a/control-plane/pkg/types/status.go b/control-plane/pkg/types/status.go index 9888c0bb..412c9c1e 100644 --- a/control-plane/pkg/types/status.go +++ b/control-plane/pkg/types/status.go @@ -9,6 +9,7 @@ const ( ExecutionStatusUnknown ExecutionStatus = "unknown" ExecutionStatusPending ExecutionStatus = "pending" ExecutionStatusQueued ExecutionStatus = "queued" + ExecutionStatusWaiting ExecutionStatus = "waiting" ExecutionStatusRunning ExecutionStatus = "running" ExecutionStatusSucceeded ExecutionStatus = "succeeded" ExecutionStatusFailed ExecutionStatus = "failed" @@ -20,6 +21,7 @@ var canonicalExecutionStatuses = map[ExecutionStatus]struct{}{ ExecutionStatusUnknown: {}, ExecutionStatusPending: {}, ExecutionStatusQueued: {}, + ExecutionStatusWaiting: {}, ExecutionStatusRunning: {}, ExecutionStatusSucceeded: {}, ExecutionStatusFailed: {}, @@ -28,22 +30,24 @@ var canonicalExecutionStatuses = map[ExecutionStatus]struct{}{ } var executionStatusAliases = map[string]ExecutionStatus{ - "success": ExecutionStatusSucceeded, - "successful": ExecutionStatusSucceeded, - "completed": ExecutionStatusSucceeded, - "complete": ExecutionStatusSucceeded, - "done": ExecutionStatusSucceeded, - "ok": ExecutionStatusSucceeded, - "error": ExecutionStatusFailed, - "failure": ExecutionStatusFailed, - "errored": ExecutionStatusFailed, - "canceled": ExecutionStatusCancelled, - "cancel": ExecutionStatusCancelled, - "timed_out": ExecutionStatusTimeout, - "wait": ExecutionStatusQueued, - "waiting": ExecutionStatusQueued, - "in_progress": ExecutionStatusRunning, - "processing": ExecutionStatusRunning, + "success": ExecutionStatusSucceeded, + "successful": ExecutionStatusSucceeded, + "completed": ExecutionStatusSucceeded, + "complete": ExecutionStatusSucceeded, + "done": ExecutionStatusSucceeded, + "ok": ExecutionStatusSucceeded, + "error": ExecutionStatusFailed, + "failure": ExecutionStatusFailed, + "errored": ExecutionStatusFailed, + "canceled": ExecutionStatusCancelled, + "cancel": ExecutionStatusCancelled, + "timed_out": ExecutionStatusTimeout, + "wait": ExecutionStatusQueued, + "awaiting_approval": ExecutionStatusWaiting, + "awaiting_human": ExecutionStatusWaiting, + "approval_pending": ExecutionStatusWaiting, + "in_progress": ExecutionStatusRunning, + "processing": ExecutionStatusRunning, } // NormalizeExecutionStatus maps arbitrary status strings onto the canonical execution statuses used by the AgentField platform. diff --git a/control-plane/pkg/types/status_test.go b/control-plane/pkg/types/status_test.go index 9bde37df..a5624cb8 100644 --- a/control-plane/pkg/types/status_test.go +++ b/control-plane/pkg/types/status_test.go @@ -4,16 +4,17 @@ import "testing" func TestNormalizeExecutionStatus(t *testing.T) { cases := map[string]string{ - "": string(ExecutionStatusUnknown), - " ": string(ExecutionStatusUnknown), - "Completed": string(ExecutionStatusSucceeded), - "success": string(ExecutionStatusSucceeded), - "FAILED": string(ExecutionStatusFailed), - "canceled": string(ExecutionStatusCancelled), - "TIMED_OUT": string(ExecutionStatusTimeout), - "waiting": string(ExecutionStatusQueued), - "processing": string(ExecutionStatusRunning), - "custom-status": string(ExecutionStatusUnknown), + "": string(ExecutionStatusUnknown), + " ": string(ExecutionStatusUnknown), + "Completed": string(ExecutionStatusSucceeded), + "success": string(ExecutionStatusSucceeded), + "FAILED": string(ExecutionStatusFailed), + "canceled": string(ExecutionStatusCancelled), + "TIMED_OUT": string(ExecutionStatusTimeout), + "waiting": string(ExecutionStatusWaiting), + "awaiting_approval": string(ExecutionStatusWaiting), + "processing": string(ExecutionStatusRunning), + "custom-status": string(ExecutionStatusUnknown), } for input, expected := range cases { @@ -25,7 +26,7 @@ func TestNormalizeExecutionStatus(t *testing.T) { func TestIsTerminalExecutionStatus(t *testing.T) { terminals := []string{"succeeded", "failed", "cancelled", "timeout", "completed"} - nonTerminals := []string{"pending", "queued", "running", "processing"} + nonTerminals := []string{"pending", "queued", "waiting", "running", "processing"} for _, status := range terminals { if !IsTerminalExecutionStatus(status) { diff --git a/control-plane/pkg/types/types.go b/control-plane/pkg/types/types.go index 18d8481d..678080f8 100644 --- a/control-plane/pkg/types/types.go +++ b/control-plane/pkg/types/types.go @@ -691,6 +691,16 @@ type WorkflowExecution struct { ErrorMessage *string `json:"error_message,omitempty" db:"error_message"` RetryCount int `json:"retry_count" db:"retry_count"` + // Approval tracking (populated when status is "waiting" with reason "waiting_for_approval") + ApprovalRequestID *string `json:"approval_request_id,omitempty" db:"approval_request_id"` + ApprovalRequestURL *string `json:"approval_request_url,omitempty" db:"approval_request_url"` + ApprovalStatus *string `json:"approval_status,omitempty" db:"approval_status"` + ApprovalResponse *string `json:"approval_response,omitempty" db:"approval_response"` + ApprovalRequestedAt *time.Time `json:"approval_requested_at,omitempty" db:"approval_requested_at"` + ApprovalRespondedAt *time.Time `json:"approval_responded_at,omitempty" db:"approval_responded_at"` + ApprovalCallbackURL *string `json:"approval_callback_url,omitempty" db:"approval_callback_url"` + ApprovalExpiresAt *time.Time `json:"approval_expires_at,omitempty" db:"approval_expires_at"` + // Webhook observability (non-persisted) WebhookRegistered bool `json:"webhook_registered,omitempty" db:"-"` WebhookEvents []*ExecutionWebhookEvent `json:"webhook_events,omitempty" db:"-"` @@ -878,6 +888,7 @@ type WorkflowExecutionFilters struct { ActorID *string `json:"actor_id,omitempty"` AgentNodeID *string `json:"agent_node_id,omitempty"` Status *string `json:"status,omitempty"` + ApprovalRequestID *string `json:"approval_request_id,omitempty"` StartTime *time.Time `json:"start_time,omitempty"` EndTime *time.Time `json:"end_time,omitempty"` Search *string `json:"search,omitempty"` diff --git a/control-plane/web/client/src/components/WorkflowDAG/HoverDetailPanel.tsx b/control-plane/web/client/src/components/WorkflowDAG/HoverDetailPanel.tsx index 00360be5..fbbc2c4b 100644 --- a/control-plane/web/client/src/components/WorkflowDAG/HoverDetailPanel.tsx +++ b/control-plane/web/client/src/components/WorkflowDAG/HoverDetailPanel.tsx @@ -28,6 +28,7 @@ const STATUS_TONE_TOKEN_MAP: Record = succeeded: "success", failed: "error", running: "info", + waiting: "warning", queued: "warning", pending: "warning", timeout: "neutral", diff --git a/control-plane/web/client/src/components/WorkflowDAG/WorkflowNode.tsx b/control-plane/web/client/src/components/WorkflowDAG/WorkflowNode.tsx index 4a1fef31..de837470 100644 --- a/control-plane/web/client/src/components/WorkflowDAG/WorkflowNode.tsx +++ b/control-plane/web/client/src/components/WorkflowDAG/WorkflowNode.tsx @@ -53,6 +53,7 @@ const STATUS_TONE_TOKEN_MAP: Record = { succeeded: "success", failed: "error", running: "info", + waiting: "warning", queued: "warning", pending: "warning", timeout: "neutral", diff --git a/control-plane/web/client/src/components/WorkflowDAG/sections/StatusSection.tsx b/control-plane/web/client/src/components/WorkflowDAG/sections/StatusSection.tsx index d3bb8734..4bc1d8b9 100644 --- a/control-plane/web/client/src/components/WorkflowDAG/sections/StatusSection.tsx +++ b/control-plane/web/client/src/components/WorkflowDAG/sections/StatusSection.tsx @@ -49,6 +49,7 @@ const STATUS_TONE_MAP: Record = { succeeded: "success", failed: "error", running: "info", + waiting: "warning", pending: "warning", queued: "warning", timeout: "info", @@ -86,6 +87,17 @@ export function StatusSection({ node, details }: StatusSectionProps) { label: "Currently Running", description: "Execution is in progress", }; + case "waiting": + return { + icon: ( + + ), + label: "Awaiting Input", + description: "Execution is paused waiting for human input", + }; case "pending": case "queued": return { diff --git a/control-plane/web/client/src/components/execution/CompactExecutionHeader.tsx b/control-plane/web/client/src/components/execution/CompactExecutionHeader.tsx index 3f6bcf9b..5b3efbed 100644 --- a/control-plane/web/client/src/components/execution/CompactExecutionHeader.tsx +++ b/control-plane/web/client/src/components/execution/CompactExecutionHeader.tsx @@ -3,6 +3,7 @@ import { ExternalLink, Clock, RotateCcw, + PauseCircle, } from "@/components/ui/icon-bridge"; import { ArrowDown, ArrowUp } from "@/components/ui/icon-bridge"; import { useNavigate } from "react-router-dom"; @@ -103,7 +104,7 @@ export function CompactExecutionHeader({ @@ -205,6 +206,39 @@ export function CompactExecutionHeader({ )} + {/* Status Reason */} + {execution.status_reason && normalizedStatus !== "waiting" && ( +
+ {execution.status_reason.replace(/_/g, " ")} +
+ )} + + {/* Approval Status */} + {normalizedStatus === "waiting" && execution.approval_request_url && ( + + )} + + {/* Waiting with status_reason but no approval URL */} + {normalizedStatus === "waiting" && !execution.approval_request_url && execution.status_reason && ( +
+ + + {execution.status_reason.replace(/_/g, " ")} + +
+ )} + {/* VC Badge */} {!vcLoading && vcStatus?.has_vc && (
diff --git a/control-plane/web/client/src/components/execution/ExecutionApprovalPanel.tsx b/control-plane/web/client/src/components/execution/ExecutionApprovalPanel.tsx new file mode 100644 index 00000000..22d31abf --- /dev/null +++ b/control-plane/web/client/src/components/execution/ExecutionApprovalPanel.tsx @@ -0,0 +1,219 @@ +import { + CheckCircle, + XCircle, + Clock, + ExternalLink, + PauseCircle, + Timer, +} from "@/components/ui/icon-bridge"; +import type { WorkflowExecution } from "../../types/executions"; +import { Badge } from "../ui/badge"; +import { CollapsibleSection } from "./CollapsibleSection"; +import { CopyButton } from "../ui/copy-button"; +import { cn } from "../../lib/utils"; + +interface ExecutionApprovalPanelProps { + execution: WorkflowExecution; +} + +function getApprovalStatusConfig(status?: string) { + switch (status) { + case "approved": + return { + label: "Approved", + icon: CheckCircle, + badgeVariant: "success" as const, + color: "text-green-500", + description: "The plan was approved by a human reviewer.", + }; + case "rejected": + return { + label: "Rejected", + icon: XCircle, + badgeVariant: "failed" as const, + color: "text-red-500", + description: "The plan was rejected by a human reviewer.", + }; + case "expired": + return { + label: "Expired", + icon: Timer, + badgeVariant: "pending" as const, + color: "text-muted-foreground", + description: "The approval request expired before a decision was made.", + }; + case "pending": + default: + return { + label: "Pending", + icon: PauseCircle, + badgeVariant: "pending" as const, + color: "text-amber-500", + description: "Waiting for a human reviewer to approve or reject the plan.", + }; + } +} + +function formatTimestamp(ts?: string): string { + if (!ts) return "—"; + try { + return new Date(ts).toLocaleString(); + } catch { + return ts; + } +} + +function parseFeedback(response?: string): string | null { + if (!response) return null; + try { + const parsed = JSON.parse(response); + if (parsed.feedback) return parsed.feedback; + if (parsed.decision) return null; // just the decision, no feedback text + return response; + } catch { + return response; + } +} + +export function ExecutionApprovalPanel({ execution }: ExecutionApprovalPanelProps) { + const hasApproval = !!execution.approval_request_id; + + if (!hasApproval) { + return ( +
+ +

+ No approval request for this execution. +

+
+ ); + } + + const config = getApprovalStatusConfig(execution.approval_status); + const StatusIcon = config.icon; + const feedback = parseFeedback(execution.approval_response); + + return ( +
+ {/* Approval Status Hero */} + + + {config.label} + + } + > +
+

+ {config.description} +

+ + {/* Timeline */} +
+
+
+ +
+
+

Approval Requested

+

+ {formatTimestamp(execution.approval_requested_at)} +

+
+
+ + {execution.approval_responded_at && ( +
+
+ +
+
+

+ Decision: {config.label} +

+

+ {formatTimestamp(execution.approval_responded_at)} +

+
+
+ )} +
+ + {/* Feedback */} + {feedback && ( +
+

Reviewer Feedback

+
+

{feedback}

+
+
+ )} +
+
+ + {/* Request Details */} + +
+ {/* Request ID */} +
+ +
+ + {execution.approval_request_id} + + +
+
+ + {/* Review URL */} + {execution.approval_request_url && ( +
+ + + Open in Hub + + +
+ )} + + {/* Timestamps */} +
+ + + {formatTimestamp(execution.approval_requested_at)} + +
+ + {execution.approval_responded_at && ( +
+ + + {formatTimestamp(execution.approval_responded_at)} + +
+ )} +
+
+
+ ); +} diff --git a/control-plane/web/client/src/components/reasoners/ExecutionHistoryList.tsx b/control-plane/web/client/src/components/reasoners/ExecutionHistoryList.tsx index 99f80221..023d42c3 100644 --- a/control-plane/web/client/src/components/reasoners/ExecutionHistoryList.tsx +++ b/control-plane/web/client/src/components/reasoners/ExecutionHistoryList.tsx @@ -95,6 +95,10 @@ export function ExecutionHistoryList({ history, onLoadMore }: ExecutionHistoryLi icon:
)} + {activeTab === 'approval' && ( +
+ + + {execution.approval_status === 'pending' ? 'Awaiting human review' : `Approval ${execution.approval_status}`} + +
+ )} + {activeTab === 'meta' && (
@@ -387,6 +409,12 @@ export function EnhancedExecutionDetailPage() {
)} + {activeTab === 'approval' && ( +
+ +
+ )} + {activeTab === 'debug' && (
diff --git a/control-plane/web/client/src/types/executions.ts b/control-plane/web/client/src/types/executions.ts index 27861dc3..4f6ea4b0 100644 --- a/control-plane/web/client/src/types/executions.ts +++ b/control-plane/web/client/src/types/executions.ts @@ -89,7 +89,7 @@ export interface ExecutionStats { } export interface ExecutionEvent { - type: "execution_started" | "execution_completed" | "execution_failed"; + type: "execution_started" | "execution_completed" | "execution_failed" | "execution_waiting" | "execution_approval_resolved" | "execution_updated"; execution: ExecutionSummary; timestamp: string; } @@ -115,11 +115,18 @@ export interface WorkflowExecution { workflow_name?: string; workflow_tags: string[]; status: CanonicalStatus; + status_reason?: string; started_at: string; completed_at?: string; duration_ms?: number; error_message?: string; retry_count: number; + approval_request_id?: string; + approval_request_url?: string; + approval_status?: string; + approval_response?: string; + approval_requested_at?: string; + approval_responded_at?: string; created_at: string; updated_at: string; notes?: ExecutionNote[]; diff --git a/control-plane/web/client/src/utils/status.ts b/control-plane/web/client/src/utils/status.ts index 7ae1b5c5..c94c54ff 100644 --- a/control-plane/web/client/src/utils/status.ts +++ b/control-plane/web/client/src/utils/status.ts @@ -1,6 +1,7 @@ export type CanonicalStatus = | 'pending' | 'queued' + | 'waiting' | 'running' | 'succeeded' | 'failed' @@ -11,6 +12,7 @@ export type CanonicalStatus = const CANONICAL_STATUS_SET = new Set([ 'pending', 'queued', + 'waiting', 'running', 'succeeded', 'failed', @@ -22,7 +24,11 @@ const CANONICAL_STATUS_SET = new Set([ const STATUS_MAP: Record = { pending: 'pending', queued: 'queued', - waiting: 'queued', + wait: 'queued', // legacy: short alias preserved for backward compat + waiting: 'waiting', + awaiting_approval: 'waiting', + awaiting_human: 'waiting', + approval_pending: 'waiting', running: 'running', processing: 'running', in_progress: 'running', @@ -80,6 +86,10 @@ export function isRunningStatus(status?: string | null): boolean { return normalizeExecutionStatus(status) === 'running'; } +export function isWaitingStatus(status?: string | null): boolean { + return normalizeExecutionStatus(status) === 'waiting'; +} + export function isQueuedStatus(status?: string | null): boolean { const normalized = normalizeExecutionStatus(status); return normalized === 'queued' || normalized === 'pending'; @@ -97,6 +107,8 @@ export function getStatusLabel(status?: string | null): string { return 'Timed Out'; case 'running': return 'Running'; + case 'waiting': + return 'Waiting'; case 'queued': return 'Queued'; case 'pending': @@ -126,6 +138,7 @@ export interface StatusTheme { const STATUS_HEX: Record = { pending: { base: '#f59e0b', light: '#fbbf24' }, queued: { base: '#f59e0b', light: '#fbbf24' }, + waiting: { base: '#d97706', light: '#f59e0b' }, running: { base: '#2563eb', light: '#60a5fa' }, succeeded: { base: '#16a34a', light: '#22c55e' }, failed: { base: '#ef4444', light: '#f87171' }, @@ -137,6 +150,7 @@ const STATUS_HEX: Record = { const STATUS_TONE_MAP: Record = { pending: 'warning', queued: 'warning', + waiting: 'warning', running: 'info', succeeded: 'success', failed: 'error', @@ -148,6 +162,7 @@ const STATUS_TONE_MAP: Record = { const BADGE_VARIANT: Record = { pending: 'secondary', queued: 'secondary', + waiting: 'secondary', running: 'secondary', succeeded: 'default', failed: 'destructive', @@ -180,6 +195,7 @@ function createStatusTheme(status: CanonicalStatus): StatusTheme { const STATUS_THEME: Record = { pending: createStatusTheme('pending'), queued: createStatusTheme('queued'), + waiting: createStatusTheme('waiting'), running: createStatusTheme('running'), succeeded: createStatusTheme('succeeded'), failed: createStatusTheme('failed'), diff --git a/examples/python_agent_nodes/waiting_state/main.py b/examples/python_agent_nodes/waiting_state/main.py new file mode 100644 index 00000000..1346c7ed --- /dev/null +++ b/examples/python_agent_nodes/waiting_state/main.py @@ -0,0 +1,150 @@ +""" +Waiting State Agent - Human-in-the-Loop Approval Example + +Demonstrates: +- Requesting human approval mid-execution (waiting state) +- Polling for approval status +- Handling approved / rejected / expired decisions +- Using app.pause() for a blocking approval flow inside a reasoner +""" + +import os +import uuid + +from agentfield import Agent, AIConfig, ApprovalResult + +app = Agent( + node_id="waiting-state-demo", + agentfield_server=os.getenv("AGENTFIELD_URL", "http://localhost:8080"), + ai_config=AIConfig( + model=os.getenv("SMALL_MODEL", "openai/gpt-4o-mini"), temperature=0.7 + ), +) + + +# ============= SKILL (DETERMINISTIC) ============= + + +@app.skill() +def build_proposal(title: str, description: str) -> dict: + """Builds a structured proposal document for human review.""" + return { + "title": title, + "description": description, + "proposal_id": f"prop-{uuid.uuid4().hex[:8]}", + "status": "draft", + } + + +# ============= REASONERS (AI-POWERED) ============= + + +@app.reasoner() +async def plan_with_approval(task: str) -> dict: + """ + Creates a plan and pauses execution for human approval before proceeding. + + Flow: + 1. AI generates a plan for the given task + 2. Execution transitions to "waiting" state + 3. Human reviews and approves/rejects + 4. Execution resumes based on the decision + + This demonstrates the full waiting state lifecycle using app.pause(), + which blocks until the approval resolves or times out. + """ + # Step 1: Generate a plan using AI + plan = await app.ai( + system="You are a project planner. Create a concise 3-step plan.", + user=f"Create a plan for: {task}", + ) + + # Step 2: Build the proposal + proposal = build_proposal( + title=f"Plan: {task}", + description=plan.text if hasattr(plan, "text") else str(plan), + ) + + # Step 3: Request human approval — execution pauses here + # In production, approval_request_id would come from creating a request + # on an external approval service (e.g. hax-sdk Response Hub). + approval_request_id = f"req-{uuid.uuid4().hex[:12]}" + + result: ApprovalResult = await app.pause( + approval_request_id=approval_request_id, + approval_request_url=f"https://hub.example.com/review/{approval_request_id}", + expires_in_hours=24, + timeout=3600, # Wait up to 1 hour + ) + + # Step 4: Handle the decision + if result.approved: + return { + "status": "approved", + "proposal": proposal, + "feedback": result.feedback, + "message": "Plan approved! Proceeding with execution.", + } + elif result.changes_requested: + return { + "status": "changes_requested", + "proposal": proposal, + "feedback": result.feedback, + "message": "Changes requested. Revising the plan.", + } + else: + # rejected or expired + return { + "status": result.decision, + "proposal": proposal, + "feedback": result.feedback, + "message": f"Plan was {result.decision}. Halting execution.", + } + + +@app.reasoner() +async def quick_review(content: str) -> dict: + """ + Demonstrates the low-level approval API for more control. + + Instead of app.pause() (which blocks), this uses the client methods + directly to request approval and poll for the result. + """ + # Request approval via the low-level client API + approval_request_id = f"req-{uuid.uuid4().hex[:12]}" + exec_ctx = app._get_current_execution_context() + + await app.client.request_approval( + execution_id=exec_ctx.execution_id, + approval_request_id=approval_request_id, + approval_request_url=f"https://hub.example.com/review/{approval_request_id}", + expires_in_hours=1, + ) + + # Poll until resolved (with exponential backoff) + status = await app.client.wait_for_approval( + execution_id=exec_ctx.execution_id, + poll_interval=5.0, + max_interval=30.0, + timeout=3600, + ) + + return { + "content_reviewed": content[:100], + "approval_status": status.status, + "response": status.response, + } + + +# ============= START SERVER OR CLI ============= + +if __name__ == "__main__": + print("Waiting State Demo Agent") + print("Node: waiting-state-demo") + print("Control Plane: http://localhost:8080") + print() + print("Reasoners:") + print(" - plan_with_approval: Full pause/resume flow with app.pause()") + print(" - quick_review: Low-level approval API with polling") + + app.run(auto_port=True) diff --git a/examples/ts-node-examples/package-lock.json b/examples/ts-node-examples/package-lock.json index 37a2e1d0..26a40aad 100644 --- a/examples/ts-node-examples/package-lock.json +++ b/examples/ts-node-examples/package-lock.json @@ -22,7 +22,7 @@ }, "../../sdk/typescript": { "name": "@agentfield/sdk", - "version": "0.1.41-rc.3", + "version": "0.1.43-rc.1", "license": "Apache-2.0", "dependencies": { "@ai-sdk/anthropic": "^2.0.53", diff --git a/examples/ts-node-examples/waiting-state/main.ts b/examples/ts-node-examples/waiting-state/main.ts new file mode 100644 index 00000000..9a3fa210 --- /dev/null +++ b/examples/ts-node-examples/waiting-state/main.ts @@ -0,0 +1,135 @@ +/** + * Waiting State Agent - Human-in-the-Loop Approval Example + * + * Demonstrates: + * - Requesting human approval mid-execution (waiting state) + * - Polling for approval status with exponential backoff + * - Handling approved / rejected / expired decisions + * - Using the ApprovalClient for low-level control + */ + +import 'dotenv/config'; +import { Agent, ApprovalClient } from '@agentfield/sdk'; +import crypto from 'node:crypto'; + +const agentFieldUrl = process.env.AGENTFIELD_URL ?? 'http://localhost:8080'; + +const agent = new Agent({ + nodeId: process.env.AGENT_ID ?? 'waiting-state-demo', + agentFieldUrl, + port: Number(process.env.PORT ?? 8005), + publicUrl: process.env.AGENT_CALLBACK_URL, + version: '1.0.0', + devMode: true, + apiKey: process.env.AGENTFIELD_API_KEY, + aiConfig: { + provider: 'openai', + model: process.env.SMALL_MODEL ?? 'gpt-4o-mini', + apiKey: process.env.OPENAI_API_KEY, + }, +}); + +// Create an ApprovalClient for the low-level approval API +const approvalClient = new ApprovalClient({ + baseURL: agentFieldUrl, + nodeId: process.env.AGENT_ID ?? 'waiting-state-demo', + apiKey: process.env.AGENTFIELD_API_KEY, +}); + +/** + * Reasoner that generates a plan and pauses for human approval. + * + * Flow: + * 1. AI generates a plan for the given task + * 2. Execution transitions to "waiting" state via approval request + * 3. Human reviews and approves/rejects (via webhook) + * 4. Execution resumes based on the decision + */ +agent.reasoner< + { task: string }, + { status: string; plan: string; feedback?: string } +>('planWithApproval', async (ctx) => { + ctx.note('Starting plan generation', ['approval', 'start']); + + // Step 1: Generate a plan using AI + const plan = await ctx.ai( + `You are a project planner. Create a concise 3-step plan for: ${ctx.input.task}`, + { temperature: 0.7 } + ); + const planText = typeof plan === 'string' ? plan : JSON.stringify(plan); + + ctx.note('Plan generated, requesting approval', ['approval', 'waiting']); + + // Step 2: Request human approval — transitions execution to "waiting" + const approvalRequestId = `req-${crypto.randomBytes(6).toString('hex')}`; + + const approvalResponse = await approvalClient.requestApproval( + ctx.executionId, + { + projectId: 'waiting-state-demo', + title: `Plan Review: ${ctx.input.task}`, + description: planText, + expiresInHours: 24, + } + ); + + // Step 3: Wait for approval resolution (polls with exponential backoff) + const result = await approvalClient.waitForApproval(ctx.executionId, { + pollIntervalMs: 5_000, + maxIntervalMs: 30_000, + timeoutMs: 3_600_000, // 1 hour + }); + + ctx.note(`Approval resolved: ${result.status}`, ['approval', 'resolved']); + + // Step 4: Handle the decision + const feedback = result.response?.feedback as string | undefined; + + if (result.status === 'approved') { + return { + status: 'approved', + plan: planText, + feedback, + }; + } + + return { + status: result.status, + plan: planText, + feedback: feedback ?? `Plan was ${result.status}`, + }; +}); + +/** + * Simple reasoner that demonstrates approval status polling + * without blocking — useful for fire-and-forget approval checks. + */ +agent.reasoner< + { executionId: string }, + { status: string; response?: Record } +>('checkApproval', async (ctx) => { + const status = await approvalClient.getApprovalStatus(ctx.input.executionId); + + return { + status: status.status, + response: status.response, + }; +}); + + +async function main() { + await agent.serve(); + console.log(`Waiting State Demo Agent listening on http://localhost:${agent.config.port}`); + console.log(`Control Plane: ${agentFieldUrl}`); + console.log(); + console.log('Reasoners:'); + console.log(' - planWithApproval: Generates plan, pauses for approval, resumes'); + console.log(' - checkApproval: Polls approval status for a given execution'); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/examples/ts-node-examples/waiting-state/package.json b/examples/ts-node-examples/waiting-state/package.json new file mode 100644 index 00000000..04c99333 --- /dev/null +++ b/examples/ts-node-examples/waiting-state/package.json @@ -0,0 +1,18 @@ +{ + "name": "@agentfield/waiting-state-example", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node --import tsx main.ts", + "dev": "tsx main.ts" + }, + "dependencies": { + "@agentfield/sdk": "^0.1.36", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.3.0" + } +} diff --git a/sdk/go/client/approval.go b/sdk/go/client/approval.go new file mode 100644 index 00000000..1d9bd85a --- /dev/null +++ b/sdk/go/client/approval.go @@ -0,0 +1,132 @@ +package client + +import ( + "context" + "fmt" + "math" + "net/http" + "net/url" + "time" +) + +// RequestApprovalRequest is the payload for requesting human approval. +type RequestApprovalRequest struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + TemplateType string `json:"template_type"` + Payload map[string]interface{} `json:"payload,omitempty"` + ProjectID string `json:"project_id"` + ExpiresInHours int `json:"expires_in_hours,omitempty"` +} + +// RequestApprovalResponse is returned after creating an approval request. +type RequestApprovalResponse struct { + ApprovalRequestID string `json:"approval_request_id"` + ApprovalRequestURL string `json:"approval_request_url"` +} + +// ApprovalStatusResponse is returned by the approval status endpoint. +type ApprovalStatusResponse struct { + Status string `json:"status"` // pending, approved, rejected, expired + Response map[string]interface{} `json:"response,omitempty"` + RequestURL string `json:"request_url,omitempty"` + RequestedAt string `json:"requested_at,omitempty"` + RespondedAt string `json:"responded_at,omitempty"` +} + +// WaitForApprovalOptions configures the blocking WaitForApproval helper. +type WaitForApprovalOptions struct { + // PollInterval is the initial polling interval (default: 5s). + PollInterval time.Duration + // MaxInterval is the maximum polling interval (default: 60s). + MaxInterval time.Duration + // BackoffFactor is the multiplier applied to the interval each iteration (default: 2). + BackoffFactor float64 +} + +func (o *WaitForApprovalOptions) defaults() { + if o.PollInterval == 0 { + o.PollInterval = 5 * time.Second + } + if o.MaxInterval == 0 { + o.MaxInterval = 60 * time.Second + } + if o.BackoffFactor == 0 { + o.BackoffFactor = 2.0 + } +} + +// RequestApproval requests human approval for an execution, transitioning it +// to the "waiting" state on the control plane. +// +// Calls POST /api/v1/agents/{nodeID}/executions/{executionID}/request-approval. +func (c *Client) RequestApproval(ctx context.Context, nodeID, executionID string, req RequestApprovalRequest) (*RequestApprovalResponse, error) { + route := fmt.Sprintf("/api/v1/agents/%s/executions/%s/request-approval", + url.PathEscape(nodeID), url.PathEscape(executionID)) + + var resp RequestApprovalResponse + if err := c.do(ctx, http.MethodPost, route, req, &resp); err != nil { + return nil, fmt.Errorf("request approval: %w", err) + } + return &resp, nil +} + +// GetApprovalStatus returns the current approval status for an execution. +// +// Calls GET /api/v1/agents/{nodeID}/executions/{executionID}/approval-status. +func (c *Client) GetApprovalStatus(ctx context.Context, nodeID, executionID string) (*ApprovalStatusResponse, error) { + route := fmt.Sprintf("/api/v1/agents/%s/executions/%s/approval-status", + url.PathEscape(nodeID), url.PathEscape(executionID)) + + var resp ApprovalStatusResponse + if err := c.do(ctx, http.MethodGet, route, nil, &resp); err != nil { + return nil, fmt.Errorf("get approval status: %w", err) + } + return &resp, nil +} + +// WaitForApproval polls the approval status endpoint with exponential backoff +// until the status is no longer "pending" or the context is cancelled. +func (c *Client) WaitForApproval(ctx context.Context, nodeID, executionID string, opts *WaitForApprovalOptions) (*ApprovalStatusResponse, error) { + o := WaitForApprovalOptions{} + if opts != nil { + o = *opts + } + o.defaults() + + interval := o.PollInterval + + for { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("wait for approval: %w", ctx.Err()) + case <-time.After(interval): + } + + resp, err := c.GetApprovalStatus(ctx, nodeID, executionID) + if err != nil { + // Transient failure — back off and retry. + interval = minDuration( + time.Duration(float64(interval)*o.BackoffFactor), + o.MaxInterval, + ) + continue + } + + if resp.Status != "pending" { + return resp, nil + } + + interval = minDuration( + time.Duration(math.Round(float64(interval)*o.BackoffFactor)), + o.MaxInterval, + ) + } +} + +func minDuration(a, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} diff --git a/sdk/go/client/approval_test.go b/sdk/go/client/approval_test.go new file mode 100644 index 00000000..7ef75594 --- /dev/null +++ b/sdk/go/client/approval_test.go @@ -0,0 +1,293 @@ +package client + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// RequestApproval +// --------------------------------------------------------------------------- + +func TestRequestApproval(t *testing.T) { + var receivedBody map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.URL.Path, "/request-approval") + + _ = json.NewDecoder(r.Body).Decode(&receivedBody) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "approval_request_id": "req-abc", + "approval_request_url": "https://hub.example.com/r/req-abc", + }) + })) + defer server.Close() + + c, err := New(server.URL, WithBearerToken("tok")) + require.NoError(t, err) + + resp, err := c.RequestApproval(context.Background(), "node-1", "exec-1", RequestApprovalRequest{ + Title: "Plan Review", + TemplateType: "plan-review-v1", + ProjectID: "proj-1", + }) + + require.NoError(t, err) + assert.Equal(t, "req-abc", resp.ApprovalRequestID) + assert.Equal(t, "https://hub.example.com/r/req-abc", resp.ApprovalRequestURL) + + // Verify the request body was sent correctly + assert.Equal(t, "Plan Review", receivedBody["title"]) + assert.Equal(t, "plan-review-v1", receivedBody["template_type"]) + assert.Equal(t, "proj-1", receivedBody["project_id"]) +} + +func TestRequestApproval_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"not found"}`)) + })) + defer server.Close() + + c, err := New(server.URL) + require.NoError(t, err) + + _, err = c.RequestApproval(context.Background(), "node-1", "exec-1", RequestApprovalRequest{ + ProjectID: "p", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "request approval") +} + +// --------------------------------------------------------------------------- +// GetApprovalStatus +// --------------------------------------------------------------------------- + +func TestGetApprovalStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Contains(t, r.URL.Path, "/approval-status") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "approved", + "response": map[string]string{"decision": "approved", "feedback": "LGTM"}, + "request_url": "https://hub.example.com/r/req-abc", + "requested_at": "2026-02-25T10:00:00Z", + "responded_at": "2026-02-25T11:00:00Z", + }) + })) + defer server.Close() + + c, err := New(server.URL) + require.NoError(t, err) + + resp, err := c.GetApprovalStatus(context.Background(), "node-1", "exec-1") + require.NoError(t, err) + + assert.Equal(t, "approved", resp.Status) + assert.Equal(t, "https://hub.example.com/r/req-abc", resp.RequestURL) + assert.Equal(t, "2026-02-25T10:00:00Z", resp.RequestedAt) + assert.Equal(t, "2026-02-25T11:00:00Z", resp.RespondedAt) + assert.NotNil(t, resp.Response) +} + +func TestGetApprovalStatus_Pending(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "pending", + "request_url": "https://hub.example.com/r/req-abc", + "requested_at": "2026-02-25T10:00:00Z", + }) + })) + defer server.Close() + + c, err := New(server.URL) + require.NoError(t, err) + + resp, err := c.GetApprovalStatus(context.Background(), "node-1", "exec-1") + require.NoError(t, err) + assert.Equal(t, "pending", resp.Status) + assert.Empty(t, resp.RespondedAt) +} + +func TestGetApprovalStatus_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal"}`)) + })) + defer server.Close() + + c, err := New(server.URL) + require.NoError(t, err) + + _, err = c.GetApprovalStatus(context.Background(), "node-1", "exec-1") + assert.Error(t, err) + assert.Contains(t, err.Error(), "get approval status") +} + +// --------------------------------------------------------------------------- +// WaitForApproval +// --------------------------------------------------------------------------- + +func TestWaitForApproval_ResolvesOnApproved(t *testing.T) { + var callCount atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := callCount.Add(1) + w.Header().Set("Content-Type", "application/json") + if n == 1 { + _ = json.NewEncoder(w).Encode(map[string]string{"status": "pending"}) + } else { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "approved", + "response": map[string]string{"decision": "approved"}, + }) + } + })) + defer server.Close() + + c, err := New(server.URL) + require.NoError(t, err) + + resp, err := c.WaitForApproval(context.Background(), "node-1", "exec-1", &WaitForApprovalOptions{ + PollInterval: 10 * time.Millisecond, + MaxInterval: 20 * time.Millisecond, + }) + + require.NoError(t, err) + assert.Equal(t, "approved", resp.Status) + assert.GreaterOrEqual(t, callCount.Load(), int32(2)) +} + +func TestWaitForApproval_ResolvesOnRejected(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "rejected"}) + })) + defer server.Close() + + c, err := New(server.URL) + require.NoError(t, err) + + resp, err := c.WaitForApproval(context.Background(), "node-1", "exec-1", &WaitForApprovalOptions{ + PollInterval: 10 * time.Millisecond, + }) + + require.NoError(t, err) + assert.Equal(t, "rejected", resp.Status) +} + +func TestWaitForApproval_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "pending"}) + })) + defer server.Close() + + c, err := New(server.URL) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, err = c.WaitForApproval(ctx, "node-1", "exec-1", &WaitForApprovalOptions{ + PollInterval: 10 * time.Millisecond, + MaxInterval: 10 * time.Millisecond, + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "wait for approval") +} + +func TestWaitForApproval_RetriesOnTransientError(t *testing.T) { + var callCount atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := callCount.Add(1) + if n == 1 { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"transient"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "approved"}) + })) + defer server.Close() + + c, err := New(server.URL) + require.NoError(t, err) + + resp, err := c.WaitForApproval(context.Background(), "node-1", "exec-1", &WaitForApprovalOptions{ + PollInterval: 10 * time.Millisecond, + MaxInterval: 20 * time.Millisecond, + }) + + require.NoError(t, err) + assert.Equal(t, "approved", resp.Status) + assert.GreaterOrEqual(t, callCount.Load(), int32(2)) +} + +func TestWaitForApproval_ResolvesOnExpired(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "expired", + "request_url": "https://hub.example.com/r/req-abc", + "requested_at": "2026-02-25T10:00:00Z", + "responded_at": "2026-02-28T10:00:00Z", + }) + })) + defer server.Close() + + c, err := New(server.URL) + require.NoError(t, err) + + resp, err := c.WaitForApproval(context.Background(), "node-1", "exec-1", &WaitForApprovalOptions{ + PollInterval: 10 * time.Millisecond, + }) + + require.NoError(t, err) + assert.Equal(t, "expired", resp.Status) +} + +func TestGetApprovalStatus_Expired(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "expired", + "request_url": "https://hub.example.com/r/req-abc", + "requested_at": "2026-02-25T10:00:00Z", + "responded_at": "2026-02-28T10:00:00Z", + }) + })) + defer server.Close() + + c, err := New(server.URL) + require.NoError(t, err) + + resp, err := c.GetApprovalStatus(context.Background(), "node-1", "exec-1") + require.NoError(t, err) + assert.Equal(t, "expired", resp.Status) +} + +func TestWaitForApproval_DefaultOptions(t *testing.T) { + opts := WaitForApprovalOptions{} + opts.defaults() + + assert.Equal(t, 5*time.Second, opts.PollInterval) + assert.Equal(t, 60*time.Second, opts.MaxInterval) + assert.Equal(t, 2.0, opts.BackoffFactor) +} diff --git a/sdk/go/types/status.go b/sdk/go/types/status.go new file mode 100644 index 00000000..916cc3ca --- /dev/null +++ b/sdk/go/types/status.go @@ -0,0 +1,83 @@ +package types + +import "strings" + +// TerminalStatuses contains execution statuses that represent completed work. +var TerminalStatuses = map[string]bool{ + ExecutionStatusSucceeded: true, + ExecutionStatusFailed: true, + ExecutionStatusCancelled: true, + ExecutionStatusTimeout: true, +} + +// ActiveStatuses contains execution statuses that are still in progress and +// should be polled. +var ActiveStatuses = map[string]bool{ + ExecutionStatusPending: true, + ExecutionStatusQueued: true, + ExecutionStatusWaiting: true, + ExecutionStatusRunning: true, +} + +// statusAliases maps common alternative names to canonical status values. +var statusAliases = map[string]string{ + "success": ExecutionStatusSucceeded, + "successful": ExecutionStatusSucceeded, + "completed": ExecutionStatusSucceeded, + "complete": ExecutionStatusSucceeded, + "done": ExecutionStatusSucceeded, + "ok": ExecutionStatusSucceeded, + "error": ExecutionStatusFailed, + "failure": ExecutionStatusFailed, + "errored": ExecutionStatusFailed, + "canceled": ExecutionStatusCancelled, + "cancel": ExecutionStatusCancelled, + "timed_out": ExecutionStatusTimeout, + "wait": ExecutionStatusQueued, + "awaiting_approval": ExecutionStatusWaiting, + "awaiting_human": ExecutionStatusWaiting, + "approval_pending": ExecutionStatusWaiting, + "in_progress": ExecutionStatusRunning, + "processing": ExecutionStatusRunning, +} + +// canonicalStatuses is the set of all known canonical status strings. +var canonicalStatuses = map[string]bool{ + ExecutionStatusPending: true, + ExecutionStatusQueued: true, + ExecutionStatusWaiting: true, + ExecutionStatusRunning: true, + ExecutionStatusSucceeded: true, + ExecutionStatusFailed: true, + ExecutionStatusCancelled: true, + ExecutionStatusTimeout: true, + "unknown": true, +} + +// NormalizeStatus maps an arbitrary status string to its canonical form. +// Returns "unknown" for unrecognized values. +func NormalizeStatus(status string) string { + s := strings.TrimSpace(strings.ToLower(status)) + if s == "" { + return "unknown" + } + if canonicalStatuses[s] { + return s + } + if alias, ok := statusAliases[s]; ok { + return alias + } + return "unknown" +} + +// IsTerminalStatus returns true if the given status represents a completed +// execution that will not transition further. +func IsTerminalStatus(status string) bool { + return TerminalStatuses[NormalizeStatus(status)] +} + +// IsActiveStatus returns true if the given status represents an execution +// that is still in progress and should continue to be polled. +func IsActiveStatus(status string) bool { + return ActiveStatuses[NormalizeStatus(status)] +} diff --git a/sdk/go/types/types.go b/sdk/go/types/types.go index 27c54781..3775ebd8 100644 --- a/sdk/go/types/types.go +++ b/sdk/go/types/types.go @@ -70,6 +70,18 @@ type NodeStatusUpdate struct { HealthScore *int `json:"health_score,omitempty"` } +// Canonical execution status values used by the control plane. +const ( + ExecutionStatusPending = "pending" + ExecutionStatusQueued = "queued" + ExecutionStatusWaiting = "waiting" + ExecutionStatusRunning = "running" + ExecutionStatusSucceeded = "succeeded" + ExecutionStatusFailed = "failed" + ExecutionStatusCancelled = "cancelled" + ExecutionStatusTimeout = "timeout" +) + // LeaseResponse informs the agent how long the lease lasts. type LeaseResponse struct { LeaseSeconds int `json:"lease_seconds"` @@ -106,6 +118,7 @@ type WorkflowExecutionEvent struct { Type string `json:"type,omitempty"` AgentNodeID string `json:"agent_node_id,omitempty"` Status string `json:"status"` + StatusReason *string `json:"status_reason,omitempty"` ParentExecutionID *string `json:"parent_execution_id,omitempty"` ParentWorkflowID *string `json:"parent_workflow_id,omitempty"` InputData map[string]interface{} `json:"input_data,omitempty"` diff --git a/sdk/python/agentfield/__init__.py b/sdk/python/agentfield/__init__.py index 2981090d..f129ce4a 100644 --- a/sdk/python/agentfield/__init__.py +++ b/sdk/python/agentfield/__init__.py @@ -54,6 +54,7 @@ RegistrationError, ValidationError, ) +from .client import ApprovalRequestResponse, ApprovalResult, ApprovalStatusResponse __all__ = [ "Agent", @@ -99,6 +100,10 @@ "HEADER_CALLER_DID", "HEADER_DID_SIGNATURE", "HEADER_DID_TIMESTAMP", + # Approval response types + "ApprovalRequestResponse", + "ApprovalResult", + "ApprovalStatusResponse", # Exceptions "AgentFieldError", "AgentFieldClientError", diff --git a/sdk/python/agentfield/agent.py b/sdk/python/agentfield/agent.py index d5f97d54..e7918982 100644 --- a/sdk/python/agentfield/agent.py +++ b/sdk/python/agentfield/agent.py @@ -30,7 +30,7 @@ from agentfield.agent_registry import clear_current_agent, set_current_agent from agentfield.agent_server import AgentServer from agentfield.agent_workflow import AgentWorkflow -from agentfield.client import AgentFieldClient +from agentfield.client import AgentFieldClient, ApprovalResult from agentfield.dynamic_skills import DynamicMCPSkillManager from agentfield.execution_context import ( ExecutionContext, @@ -342,6 +342,71 @@ def _resolve_callback_url(callback_url: Optional[str], port: int) -> str: return f"http://localhost:{port}" +class _PauseManager: + """Manages pending execution pause futures resolved via webhook callback. + + Each call to ``Agent.pause()`` registers an ``asyncio.Future`` keyed by + ``approval_request_id``. When the webhook route receives a resolution + callback from the control plane it resolves the matching future, unblocking + the caller. + """ + + def __init__(self) -> None: + self._pending: Dict[str, asyncio.Future] = {} + # Also track execution_id → approval_request_id for fallback resolution + self._exec_to_request: Dict[str, str] = {} + self._lock = asyncio.Lock() + + async def register(self, approval_request_id: str, execution_id: str = "") -> asyncio.Future: + """Register a new pending pause and return the Future to await.""" + async with self._lock: + if approval_request_id in self._pending: + return self._pending[approval_request_id] + loop = asyncio.get_running_loop() + future = loop.create_future() + self._pending[approval_request_id] = future + if execution_id: + self._exec_to_request[execution_id] = approval_request_id + return future + + async def resolve(self, approval_request_id: str, result: "ApprovalResult") -> bool: + """Resolve a pending pause by approval_request_id. Returns True if a waiter was found.""" + async with self._lock: + future = self._pending.pop(approval_request_id, None) + # Clean up execution mapping + exec_id = None + for eid, rid in self._exec_to_request.items(): + if rid == approval_request_id: + exec_id = eid + break + if exec_id: + self._exec_to_request.pop(exec_id, None) + if future and not future.done(): + future.set_result(result) + return True + return False + + async def resolve_by_execution_id(self, execution_id: str, result: "ApprovalResult") -> bool: + """Fallback: resolve by execution_id when approval_request_id is not in the callback.""" + async with self._lock: + request_id = self._exec_to_request.pop(execution_id, None) + if request_id: + future = self._pending.pop(request_id, None) + if future and not future.done(): + future.set_result(result) + return True + return False + + async def cancel_all(self) -> None: + """Cancel all pending futures (for shutdown).""" + async with self._lock: + for future in self._pending.values(): + if not future.done(): + future.cancel() + self._pending.clear() + self._exec_to_request.clear() + + class Agent(FastAPI): """ AgentField Agent - FastAPI subclass for creating AI agent nodes. @@ -531,6 +596,9 @@ def __init__( self.client.caller_agent_id = self.node_id self._current_execution_context: Optional[ExecutionContext] = None + # Manages pending pause/approval futures resolved via webhook callback + self._pause_manager = _PauseManager() + # Initialize async execution manager (will be lazily created when needed) self._async_execution_manager: Optional[AsyncExecutionManager] = None @@ -1553,7 +1621,10 @@ def decorator(func: Callable) -> Callable: # Extract function metadata func_name = func.__name__ reasoner_id = decorator_name or func_name - endpoint_path = decorator_path or f"/reasoners/{func_name}" + if decorator_path: + endpoint_path = decorator_path if decorator_path.startswith("/reasoners/") else f"/reasoners/{decorator_path.lstrip('/')}" + else: + endpoint_path = f"/reasoners/{reasoner_id}" # Get type hints for input/output schemas type_hints = get_type_hints(func) @@ -2168,7 +2239,7 @@ def decorator(func: Callable) -> Callable: # Extract function metadata func_name = func.__name__ skill_id = decorator_name or func_name - endpoint_path = decorator_path or f"/skills/{func_name}" + endpoint_path = decorator_path or f"/skills/{skill_id}" self._set_skill_vc_override(skill_id, vc_enabled) if require_realtime_validation: self._realtime_validation_functions.add(skill_id) @@ -3602,6 +3673,159 @@ async def _send_note(): thread.daemon = True thread.start() + async def pause( + self, + approval_request_id: str, + approval_request_url: str = "", + expires_in_hours: int = 72, + timeout: Optional[float] = None, + execution_id: Optional[str] = None, + ) -> ApprovalResult: + """Pause the current execution for external approval. + + Transitions the execution to "waiting" on the control plane, then + blocks until the approval webhook callback resolves it or the timeout + is reached. + + The agent is responsible for creating the approval request on an + external service (e.g. hax-sdk) *before* calling this method and + passing the resulting ``approval_request_id``. + + Args: + approval_request_id: ID of the approval request on the external service. + approval_request_url: URL where the human can review the request. + expires_in_hours: Expiry passed to the control plane. + timeout: Max seconds to wait. ``None`` defaults to ``expires_in_hours``. + execution_id: Override the current execution. Defaults to active context. + + Returns: + ApprovalResult with the human's decision and feedback. + If the timeout elapses without resolution, returns + ``ApprovalResult(decision="expired")``. + + Raises: + AgentFieldClientError: If the control plane request fails. + RuntimeError: If the agent is not serving (no callback URL). + """ + from agentfield.exceptions import AgentFieldClientError + + # Resolve execution_id from context if not provided + if not execution_id: + ctx = self._get_current_execution_context() + execution_id = ctx.execution_id + + if not execution_id: + raise AgentFieldClientError("No execution_id available — cannot pause") + + # Build the callback URL from the agent's base URL + if not self.base_url: + raise RuntimeError( + "Agent is not serving — call app.serve() before app.pause(). " + "The callback URL is required for the control plane to notify " + "the agent when the approval resolves." + ) + callback_url = f"{self.base_url}/webhooks/approval" + + # Register a future *before* telling the CP, so we don't miss a fast callback + future = await self._pause_manager.register(approval_request_id, execution_id) + + # Tell the CP to transition to "waiting" + try: + await self.client.request_approval( + execution_id=execution_id, + approval_request_id=approval_request_id, + approval_request_url=approval_request_url, + callback_url=callback_url, + expires_in_hours=expires_in_hours, + ) + except Exception: + # Clean up the future if we couldn't even tell the CP + await self._pause_manager.resolve( + approval_request_id, + ApprovalResult(decision="error", feedback="failed to notify control plane", + execution_id=execution_id, approval_request_id=approval_request_id), + ) + raise + + self.note( + f"Execution paused — waiting for approval {approval_request_id}", + tags=["approval", "waiting"], + ) + + effective_timeout = timeout if timeout is not None else expires_in_hours * 3600.0 + try: + result = await asyncio.wait_for(future, timeout=effective_timeout) + except asyncio.TimeoutError: + # Timeout is a normal outcome — return an "expired" result instead of raising. + expired_result = ApprovalResult( + decision="expired", + feedback="timed out waiting for approval", + execution_id=execution_id, + approval_request_id=approval_request_id, + ) + await self._pause_manager.resolve(approval_request_id, expired_result) + return expired_result + + return result + + async def wait_for_resume( + self, + approval_request_id: str, + execution_id: Optional[str] = None, + timeout: Optional[float] = None, + ) -> ApprovalResult: + """Wait for a previously-initiated pause to resolve. + + Use for crash recovery: the approval was already requested (the + execution is already ``waiting`` on the CP) and we just need to wait + for the callback. Does *not* call the CP again. + + If the webhook callback does not arrive within *timeout*, falls back to + a single status poll via the control plane. + + Args: + approval_request_id: The known approval request ID to wait for. + execution_id: Execution ID. Defaults to active context. + timeout: Max seconds to wait. + + Returns: + ApprovalResult with the resolution. + """ + from agentfield.exceptions import AgentFieldClientError + + if not execution_id: + ctx = self._get_current_execution_context() + execution_id = ctx.execution_id + + future = await self._pause_manager.register(approval_request_id, execution_id or "") + + effective_timeout = timeout if timeout is not None else 72 * 3600.0 + try: + result = await asyncio.wait_for(future, timeout=effective_timeout) + return result + except asyncio.TimeoutError: + pass + + # Fallback: poll CP once + try: + status_resp = await self.client.get_approval_status(execution_id or "") + if status_resp.status != "pending": + return ApprovalResult( + decision=status_resp.status, + execution_id=execution_id or "", + approval_request_id=approval_request_id, + raw_response=status_resp.response, + ) + except AgentFieldClientError: + pass + + return ApprovalResult( + decision="expired", + feedback="approval timed out without response", + execution_id=execution_id or "", + approval_request_id=approval_request_id, + ) + def _get_current_execution_context(self) -> ExecutionContext: """ Get the current execution context, creating a new one if none exists. diff --git a/sdk/python/agentfield/agent_server.py b/sdk/python/agentfield/agent_server.py index 3e0b4a18..8b68689e 100644 --- a/sdk/python/agentfield/agent_server.py +++ b/sdk/python/agentfield/agent_server.py @@ -540,6 +540,65 @@ async def get_mcp_server_tools(alias: str): "tools": [], } + # ----------------------------------------------------------------- + # Approval webhook — receives callbacks from the control plane when + # an execution's approval state resolves. Auto-registered so every + # agent gets this endpoint at ``POST /webhooks/approval``. + # ----------------------------------------------------------------- + @self.agent.post("/webhooks/approval") + async def approval_webhook(request: Request): + """Receive approval resolution callback from the control plane.""" + from agentfield.client import ApprovalResult + import json as _json + + try: + body = await request.json() + except Exception: + return {"error": "invalid JSON"}, 400 + + execution_id = body.get("execution_id", "") + decision = body.get("decision", "") + feedback = body.get("feedback", "") + approval_request_id = body.get("approval_request_id", "") + + if not execution_id or not decision: + return {"error": "execution_id and decision are required", "status": 400} + + # Parse the raw response field (may be a JSON string or dict) + raw_response = None + resp_field = body.get("response") + if resp_field: + if isinstance(resp_field, str): + try: + raw_response = _json.loads(resp_field) + except (ValueError, _json.JSONDecodeError): + raw_response = {"raw": resp_field} + elif isinstance(resp_field, dict): + raw_response = resp_field + + result = ApprovalResult( + decision=decision, + feedback=feedback, + execution_id=execution_id, + approval_request_id=approval_request_id, + raw_response=raw_response, + ) + + # Try to resolve by approval_request_id first, then by execution_id + resolved = False + if approval_request_id: + resolved = await self.agent._pause_manager.resolve(approval_request_id, result) + if not resolved and execution_id: + resolved = await self.agent._pause_manager.resolve_by_execution_id(execution_id, result) + + if self.agent.dev_mode: + log_debug( + f"Approval webhook: execution_id={execution_id} " + f"decision={decision} resolved={resolved}" + ) + + return {"status": "received", "resolved": resolved} + async def _graceful_shutdown(self, timeout_seconds: int = 30): """ Perform graceful shutdown with cleanup. diff --git a/sdk/python/agentfield/client.py b/sdk/python/agentfield/client.py index c6f97f5d..8dad52e3 100644 --- a/sdk/python/agentfield/client.py +++ b/sdk/python/agentfield/client.py @@ -36,6 +36,46 @@ httpx = None # type: ignore +# --------------------------------------------------------------------------- +# Typed response models for approval helpers +# --------------------------------------------------------------------------- + +@dataclass +class ApprovalRequestResponse: + """Response from requesting approval for an execution.""" + approval_request_id: str + approval_request_url: str + + +@dataclass +class ApprovalStatusResponse: + """Response from polling approval status.""" + status: str # pending, approved, rejected, expired + response: Optional[Dict[str, Any]] = None + request_url: Optional[str] = None + requested_at: Optional[str] = None + responded_at: Optional[str] = None + + +@dataclass +class ApprovalResult: + """Outcome of a human approval request, returned by ``Agent.pause()``.""" + + decision: str # "approved", "rejected", "request_changes", "expired", "error" + feedback: str = "" + execution_id: str = "" + approval_request_id: str = "" + raw_response: Optional[Dict[str, Any]] = None + + @property + def approved(self) -> bool: + return self.decision == "approved" + + @property + def changes_requested(self) -> bool: + return self.decision == "request_changes" + + # Python 3.8 compatibility: asyncio.to_thread was added in Python 3.9 if sys.version_info >= (3, 9): from asyncio import to_thread as _to_thread @@ -1640,6 +1680,163 @@ async def cleanup_async_executions(self) -> int: f"Failed to cleanup async executions: {e}" ) from e + # ------------------------------------------------------------------ # + # Approval helpers # + # ------------------------------------------------------------------ # + + async def request_approval( + self, + execution_id: str, + approval_request_id: str, + approval_request_url: str = "", + callback_url: str = "", + expires_in_hours: int = 72, + ) -> ApprovalRequestResponse: + """Request human approval for an execution, transitioning it to ``waiting``. + + Calls ``POST /api/v1/agents/{node}/executions/{id}/request-approval`` + on the control plane. The agent is responsible for creating the + approval request on an external service (e.g. hax-sdk) first and + passing the resulting IDs here so the CP can track it. + + Args: + execution_id: The execution to pause for approval. + approval_request_id: ID of the approval request on the external service. + approval_request_url: URL where the human can review the request. + callback_url: URL the CP should POST to when the approval resolves. + expires_in_hours: Time before the request expires. + + Returns: + ApprovalRequestResponse with ``approval_request_id`` and ``approval_request_url``. + + Raises: + AgentFieldClientError: If the request fails. + """ + node_id = self.caller_agent_id or "" + body: Dict[str, Any] = { + "approval_request_id": approval_request_id, + "expires_in_hours": expires_in_hours, + } + if approval_request_url: + body["approval_request_url"] = approval_request_url + if callback_url: + body["callback_url"] = callback_url + url = f"{self.api_base}/agents/{node_id}/executions/{execution_id}/request-approval" + + try: + client = await self.get_async_http_client() + response = await client.post( + url, + json=body, + headers=self._sanitize_header_values(self._get_headers_with_context(None)), + timeout=30, + ) + except Exception as exc: + raise AgentFieldClientError( + f"Failed to request approval: {exc}" + ) from exc + + if response.status_code >= 400: + raise AgentFieldClientError( + f"Approval request failed ({response.status_code}): {response.text[:500]}" + ) + + data = response.json() + return ApprovalRequestResponse( + approval_request_id=data.get("approval_request_id", ""), + approval_request_url=data.get("approval_request_url", ""), + ) + + async def get_approval_status( + self, + execution_id: str, + ) -> ApprovalStatusResponse: + """Get the current approval status for an execution. + + Calls ``GET /api/v1/agents/{node}/executions/{id}/approval-status``. + + Returns: + ApprovalStatusResponse with ``status`` (pending/approved/rejected/expired), + ``response``, ``request_url``, ``requested_at``, ``responded_at``. + + Raises: + AgentFieldClientError: If the request fails. + """ + node_id = self.caller_agent_id or "" + url = f"{self.api_base}/agents/{node_id}/executions/{execution_id}/approval-status" + + try: + client = await self.get_async_http_client() + response = await client.get( + url, + headers=self._sanitize_header_values(self._get_headers_with_context(None)), + timeout=30, + ) + except Exception as exc: + raise AgentFieldClientError( + f"Failed to get approval status: {exc}" + ) from exc + + if response.status_code >= 400: + raise AgentFieldClientError( + f"Approval status request failed ({response.status_code}): {response.text[:500]}" + ) + + data = response.json() + return ApprovalStatusResponse( + status=data.get("status", "unknown"), + response=data.get("response"), + request_url=data.get("request_url"), + requested_at=data.get("requested_at"), + responded_at=data.get("responded_at"), + ) + + async def wait_for_approval( + self, + execution_id: str, + poll_interval: float = 5.0, + max_interval: float = 60.0, + timeout: Optional[float] = None, + ) -> ApprovalStatusResponse: + """Poll approval status with exponential backoff until resolved. + + Args: + execution_id: Execution ID to wait for. + poll_interval: Initial polling interval in seconds. + max_interval: Maximum polling interval in seconds. + timeout: Total timeout in seconds (None = wait indefinitely). + + Returns: + ApprovalStatusResponse with the final approval status (approved/rejected/expired). + + Raises: + AgentFieldClientError: If polling encounters a non-retryable error. + ExecutionTimeoutError: If timeout is reached. + """ + start_time = time.time() + interval = poll_interval + backoff_factor = 2.0 + + while True: + if timeout is not None and (time.time() - start_time) >= timeout: + raise ExecutionTimeoutError( + f"Approval for execution {execution_id} timed out after {timeout}s" + ) + + await asyncio.sleep(interval) + + try: + result = await self.get_approval_status(execution_id) + except AgentFieldClientError: + # Transient failure — back off and retry + interval = min(interval * backoff_factor, max_interval) + continue + + if result.status != "pending": + return result + + interval = min(interval * backoff_factor, max_interval) + async def close_async_execution_manager(self) -> None: """ Close the async execution manager and cleanup resources. diff --git a/sdk/python/agentfield/execution_state.py b/sdk/python/agentfield/execution_state.py index faf91369..4189679c 100644 --- a/sdk/python/agentfield/execution_state.py +++ b/sdk/python/agentfield/execution_state.py @@ -32,6 +32,7 @@ class ExecutionStatus(Enum): PENDING = "pending" QUEUED = "queued" + WAITING = "waiting" RUNNING = "running" SUCCEEDED = "succeeded" FAILED = "failed" @@ -199,6 +200,7 @@ def is_active(self) -> bool: return self.status in { ExecutionStatus.PENDING, ExecutionStatus.QUEUED, + ExecutionStatus.WAITING, ExecutionStatus.RUNNING, } @@ -245,7 +247,7 @@ def update_status( # Update metrics based on status change current_time = time.time() - if old_status == ExecutionStatus.QUEUED and status == ExecutionStatus.RUNNING: + if old_status in {ExecutionStatus.PENDING, ExecutionStatus.QUEUED, ExecutionStatus.WAITING} and status == ExecutionStatus.RUNNING: self.metrics.start_time = current_time elif status in { ExecutionStatus.SUCCEEDED, diff --git a/sdk/python/agentfield/status.py b/sdk/python/agentfield/status.py index 99864d69..5c10c20c 100644 --- a/sdk/python/agentfield/status.py +++ b/sdk/python/agentfield/status.py @@ -7,6 +7,7 @@ CANONICAL_STATUSES: Tuple[str, ...] = ( "pending", "queued", + "waiting", "running", "succeeded", "failed", @@ -31,7 +32,9 @@ "cancel": "cancelled", "timed_out": "timeout", "wait": "queued", - "waiting": "queued", + "awaiting_approval": "waiting", + "awaiting_human": "waiting", + "approval_pending": "waiting", "in_progress": "running", "processing": "running", } diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index be494165..416bbc12 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -56,6 +56,7 @@ dev = [ "pytest>=7.4,<9", "pytest-asyncio>=0.21,<0.24", "pytest-cov>=4.1,<5", + "pytest-httpx>=0.30,<1; python_version>='3.10'", "responses>=0.23,<0.26", "respx>=0.20,<0.22", "freezegun>=1.2,<2", @@ -75,7 +76,8 @@ markers = [ "contract: API/interface stability tests", "unit: isolated unit tests", "integration: tests that can touch network/services", - "mcp: tests that exercise MCP/network interactions" + "mcp: tests that exercise MCP/network interactions", + "httpx_mock: pytest-httpx marker for configuring mock behavior" ] addopts = "-ra -q -m \"not mcp\" --strict-markers --strict-config --cov=agentfield.client --cov=agentfield.agent_field_handler --cov=agentfield.execution_context --cov=agentfield.execution_state --cov=agentfield.memory --cov=agentfield.rate_limiter --cov=agentfield.result_cache --cov-report=term-missing:skip-covered" asyncio_mode = "auto" diff --git a/sdk/python/requirements-dev.txt b/sdk/python/requirements-dev.txt index 3e8fc94e..179b8e39 100644 --- a/sdk/python/requirements-dev.txt +++ b/sdk/python/requirements-dev.txt @@ -3,6 +3,7 @@ pytest>=7.4,<9 pytest-asyncio>=0.21,<0.24 pytest-cov>=4.1,<5 +pytest-httpx>=0.30,<1; python_version>='3.10' responses>=0.23,<0.26 respx>=0.20,<0.22 freezegun>=1.2,<2 diff --git a/sdk/python/tests/test_agent_integration.py b/sdk/python/tests/test_agent_integration.py index 1d94775c..20e59e4f 100644 --- a/sdk/python/tests/test_agent_integration.py +++ b/sdk/python/tests/test_agent_integration.py @@ -95,7 +95,7 @@ async def generate_report(report_id: str) -> dict: transport=httpx.ASGITransport(app=agent), base_url="http://test" ) as client: response = await client.post( - "/reasoners/generate_report", + "/reasoners/reports_generate", json={"report_id": "r-123"}, headers={ "x-workflow-id": "wf-custom", diff --git a/sdk/python/tests/test_approval.py b/sdk/python/tests/test_approval.py new file mode 100644 index 00000000..e5941a03 --- /dev/null +++ b/sdk/python/tests/test_approval.py @@ -0,0 +1,245 @@ +"""Tests for approval workflow helpers on AgentFieldClient.""" + +import pytest + +pytest.importorskip("pytest_httpx", reason="pytest-httpx requires Python >=3.10") + +from agentfield.client import ( + AgentFieldClient, + ApprovalRequestResponse, + ApprovalStatusResponse, +) +from agentfield.exceptions import AgentFieldClientError, ExecutionTimeoutError + + +BASE_URL = "http://localhost:8080" +API_BASE = f"{BASE_URL}/api/v1" +NODE_ID = "test-node" +EXECUTION_ID = "exec-123" + + +@pytest.fixture +def client(): + """Create an AgentFieldClient pointed at a mock control plane.""" + c = AgentFieldClient(base_url=BASE_URL, api_key="test-key") + c.caller_agent_id = NODE_ID + return c + + +# --------------------------------------------------------------------------- +# request_approval +# --------------------------------------------------------------------------- + + +async def test_request_approval_returns_typed_response(client, httpx_mock): + """request_approval should return an ApprovalRequestResponse dataclass.""" + url = f"{API_BASE}/agents/{NODE_ID}/executions/{EXECUTION_ID}/request-approval" + httpx_mock.add_response( + method="POST", + url=url, + json={ + "approval_request_id": "req-abc", + "approval_request_url": "https://hub.example.com/r/req-abc", + }, + ) + + result = await client.request_approval( + execution_id=EXECUTION_ID, + approval_request_id="req-abc", + approval_request_url="https://hub.example.com/r/req-abc", + ) + + assert isinstance(result, ApprovalRequestResponse) + assert result.approval_request_id == "req-abc" + assert result.approval_request_url == "https://hub.example.com/r/req-abc" + + +async def test_request_approval_raises_on_http_error(client, httpx_mock): + """request_approval should raise AgentFieldClientError on 4xx/5xx.""" + url = f"{API_BASE}/agents/{NODE_ID}/executions/{EXECUTION_ID}/request-approval" + httpx_mock.add_response( + method="POST", + url=url, + json={"error": "execution not found"}, + status_code=404, + ) + + with pytest.raises(AgentFieldClientError, match="404"): + await client.request_approval( + execution_id=EXECUTION_ID, + approval_request_id="req-fail", + ) + + +# --------------------------------------------------------------------------- +# get_approval_status +# --------------------------------------------------------------------------- + + +async def test_get_approval_status_returns_typed_response(client, httpx_mock): + """get_approval_status should return an ApprovalStatusResponse dataclass.""" + url = f"{API_BASE}/agents/{NODE_ID}/executions/{EXECUTION_ID}/approval-status" + httpx_mock.add_response( + method="GET", + url=url, + json={ + "status": "approved", + "response": {"decision": "approved", "feedback": "LGTM"}, + "request_url": "https://hub.example.com/r/req-abc", + "requested_at": "2026-02-25T10:00:00Z", + "responded_at": "2026-02-25T11:00:00Z", + }, + ) + + result = await client.get_approval_status(EXECUTION_ID) + + assert isinstance(result, ApprovalStatusResponse) + assert result.status == "approved" + assert result.response == {"decision": "approved", "feedback": "LGTM"} + assert result.request_url == "https://hub.example.com/r/req-abc" + assert result.requested_at == "2026-02-25T10:00:00Z" + assert result.responded_at == "2026-02-25T11:00:00Z" + + +async def test_get_approval_status_pending(client, httpx_mock): + """get_approval_status should return pending when not yet resolved.""" + url = f"{API_BASE}/agents/{NODE_ID}/executions/{EXECUTION_ID}/approval-status" + httpx_mock.add_response( + method="GET", + url=url, + json={ + "status": "pending", + "request_url": "https://hub.example.com/r/req-abc", + "requested_at": "2026-02-25T10:00:00Z", + }, + ) + + result = await client.get_approval_status(EXECUTION_ID) + + assert isinstance(result, ApprovalStatusResponse) + assert result.status == "pending" + assert result.responded_at is None + assert result.response is None + + +async def test_get_approval_status_expired(client, httpx_mock): + """get_approval_status should return expired when request times out.""" + url = f"{API_BASE}/agents/{NODE_ID}/executions/{EXECUTION_ID}/approval-status" + httpx_mock.add_response( + method="GET", + url=url, + json={ + "status": "expired", + "request_url": "https://hub.example.com/r/req-abc", + "requested_at": "2026-02-25T10:00:00Z", + "responded_at": "2026-02-28T10:00:00Z", + }, + ) + + result = await client.get_approval_status(EXECUTION_ID) + + assert isinstance(result, ApprovalStatusResponse) + assert result.status == "expired" + assert result.responded_at == "2026-02-28T10:00:00Z" + + +async def test_get_approval_status_raises_on_http_error(client, httpx_mock): + """get_approval_status should raise on server errors.""" + url = f"{API_BASE}/agents/{NODE_ID}/executions/{EXECUTION_ID}/approval-status" + httpx_mock.add_response( + method="GET", + url=url, + json={"error": "internal"}, + status_code=500, + ) + + with pytest.raises(AgentFieldClientError, match="500"): + await client.get_approval_status(EXECUTION_ID) + + +# --------------------------------------------------------------------------- +# wait_for_approval +# --------------------------------------------------------------------------- + + +async def test_wait_for_approval_resolves_on_approved(client, httpx_mock): + """wait_for_approval should return once status is no longer pending.""" + url = f"{API_BASE}/agents/{NODE_ID}/executions/{EXECUTION_ID}/approval-status" + + # First call returns pending, second returns approved + httpx_mock.add_response(method="GET", url=url, json={"status": "pending"}) + httpx_mock.add_response( + method="GET", + url=url, + json={"status": "approved", "response": {"decision": "approved"}}, + ) + + result = await client.wait_for_approval( + EXECUTION_ID, + poll_interval=0.01, + max_interval=0.02, + ) + + assert isinstance(result, ApprovalStatusResponse) + assert result.status == "approved" + + +async def test_wait_for_approval_resolves_on_rejected(client, httpx_mock): + """wait_for_approval should return on rejected status.""" + url = f"{API_BASE}/agents/{NODE_ID}/executions/{EXECUTION_ID}/approval-status" + httpx_mock.add_response( + method="GET", + url=url, + json={"status": "rejected", "response": {"feedback": "needs work"}}, + ) + + result = await client.wait_for_approval(EXECUTION_ID, poll_interval=0.01) + + assert result.status == "rejected" + + +async def test_wait_for_approval_resolves_on_expired(client, httpx_mock): + """wait_for_approval should return on expired status.""" + url = f"{API_BASE}/agents/{NODE_ID}/executions/{EXECUTION_ID}/approval-status" + httpx_mock.add_response( + method="GET", + url=url, + json={"status": "expired", "request_url": "https://hub.example.com/r/req-abc"}, + ) + + result = await client.wait_for_approval(EXECUTION_ID, poll_interval=0.01) + + assert result.status == "expired" + + +@pytest.mark.httpx_mock(assert_all_responses_were_requested=False) +async def test_wait_for_approval_timeout(client, httpx_mock): + """wait_for_approval should raise ExecutionTimeoutError on timeout.""" + url = f"{API_BASE}/agents/{NODE_ID}/executions/{EXECUTION_ID}/approval-status" + + # Always return pending (add enough responses for the polling loop) + for _ in range(20): + httpx_mock.add_response(method="GET", url=url, json={"status": "pending"}) + + with pytest.raises(ExecutionTimeoutError, match="timed out"): + await client.wait_for_approval( + EXECUTION_ID, + poll_interval=0.01, + max_interval=0.01, + timeout=0.05, + ) + + +async def test_wait_for_approval_retries_on_transient_error(client, httpx_mock): + """wait_for_approval should back off and retry on transient HTTP errors.""" + url = f"{API_BASE}/agents/{NODE_ID}/executions/{EXECUTION_ID}/approval-status" + + # First call fails, second succeeds + httpx_mock.add_response( + method="GET", url=url, json={"error": "transient"}, status_code=500 + ) + httpx_mock.add_response(method="GET", url=url, json={"status": "approved"}) + + result = await client.wait_for_approval(EXECUTION_ID, poll_interval=0.01) + + assert result.status == "approved" diff --git a/sdk/python/tests/test_execution_state.py b/sdk/python/tests/test_execution_state.py index e38535a0..b7f25594 100644 --- a/sdk/python/tests/test_execution_state.py +++ b/sdk/python/tests/test_execution_state.py @@ -21,6 +21,16 @@ def test_execution_state_lifecycle_and_properties(): assert st.is_terminal is True +def test_execution_state_waiting_is_active_non_terminal(): + st = ExecutionState(execution_id="ew1", target="node.skill", input_data={}) + st.update_status(ExecutionStatus.WAITING) + assert st.is_active is True + assert st.is_terminal is False + + st.update_status(ExecutionStatus.RUNNING) + assert st.metrics.start_time is not None + + def test_execution_state_failure_and_cancel(): st = ExecutionState(execution_id="e2", target="t", input_data={}) st.set_error("boom") diff --git a/sdk/python/tests/test_reasoner_path_normalization.py b/sdk/python/tests/test_reasoner_path_normalization.py new file mode 100644 index 00000000..1357c1bc --- /dev/null +++ b/sdk/python/tests/test_reasoner_path_normalization.py @@ -0,0 +1,192 @@ +"""Tests for reasoner path normalization. + +When a positional argument is passed to @agent.reasoner(), it sets the endpoint +path, not the name. The control plane always forwards requests to +/reasoners/{reasoner_id}, so the SDK must normalize custom paths to include the +/reasoners/ prefix. Otherwise the agent returns 404 on forwarded requests. +""" + +import httpx +import pytest + +from agentfield.router import AgentRouter + +from tests.helpers import create_test_agent + + +@pytest.mark.asyncio +async def test_positional_path_normalized_to_reasoners_prefix(monkeypatch): + """@agent.reasoner("call_b") should register endpoint at /reasoners/call_b.""" + agent, _ = create_test_agent(monkeypatch) + agent.async_config.enable_async_execution = False + agent.agentfield_server = None + + @agent.reasoner("call_b") + async def call_b(input: str) -> dict: + return {"echo": input} + + # Verify the reasoner is registered with the correct ID + assert any(r["id"] == "call_b" for r in agent.reasoners) + + # The key assertion: endpoint must be reachable at /reasoners/call_b + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), base_url="http://test" + ) as client: + resp = await client.post( + "/reasoners/call_b", + json={"input": "hello"}, + headers={"x-workflow-id": "wf-1", "x-execution-id": "exec-1"}, + ) + + assert resp.status_code == 200 + assert resp.json() == {"echo": "hello"} + + +@pytest.mark.asyncio +async def test_positional_path_with_leading_slash(monkeypatch): + """@agent.reasoner("/my_func") should normalize to /reasoners/my_func.""" + agent, _ = create_test_agent(monkeypatch) + agent.async_config.enable_async_execution = False + agent.agentfield_server = None + + @agent.reasoner("/my_func") + async def my_func(value: int) -> dict: + return {"value": value} + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), base_url="http://test" + ) as client: + resp = await client.post( + "/reasoners/my_func", + json={"value": 42}, + headers={"x-workflow-id": "wf-2", "x-execution-id": "exec-2"}, + ) + + assert resp.status_code == 200 + assert resp.json() == {"value": 42} + + +@pytest.mark.asyncio +async def test_positional_path_already_prefixed(monkeypatch): + """@agent.reasoner("/reasoners/foo") should remain unchanged.""" + agent, _ = create_test_agent(monkeypatch) + agent.async_config.enable_async_execution = False + agent.agentfield_server = None + + @agent.reasoner("/reasoners/foo") + async def foo(x: int) -> dict: + return {"x": x} + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), base_url="http://test" + ) as client: + resp = await client.post( + "/reasoners/foo", + json={"x": 1}, + headers={"x-workflow-id": "wf-3", "x-execution-id": "exec-3"}, + ) + + assert resp.status_code == 200 + assert resp.json() == {"x": 1} + + +@pytest.mark.asyncio +async def test_dynamic_positional_path(monkeypatch): + """@agent.reasoner(f"handler-{i}") should normalize to /reasoners/handler-{i}.""" + agent, _ = create_test_agent(monkeypatch) + agent.async_config.enable_async_execution = False + agent.agentfield_server = None + + for i in range(3): + idx = i + + @agent.reasoner(f"handler-{i}") + async def handler(input_data: dict, _idx=idx) -> dict: + return {"id": _idx} + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), base_url="http://test" + ) as client: + for i in range(3): + resp = await client.post( + f"/reasoners/handler-{i}", + json={"input_data": {}}, + headers={"x-workflow-id": "wf-dyn", "x-execution-id": f"exec-dyn-{i}"}, + ) + assert resp.status_code == 200, f"handler-{i} not reachable at /reasoners/handler-{i}" + + +@pytest.mark.asyncio +async def test_default_path_unchanged(monkeypatch): + """@agent.reasoner() should still register at /reasoners/{func_name}.""" + agent, _ = create_test_agent(monkeypatch) + agent.async_config.enable_async_execution = False + agent.agentfield_server = None + + @agent.reasoner() + async def compute(value: int) -> dict: + return {"result": value * 2} + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), base_url="http://test" + ) as client: + resp = await client.post( + "/reasoners/compute", + json={"value": 5}, + headers={"x-workflow-id": "wf-def", "x-execution-id": "exec-def"}, + ) + + assert resp.status_code == 200 + assert resp.json() == {"result": 10} + + +@pytest.mark.asyncio +async def test_bare_decorator_unchanged(monkeypatch): + """@agent.reasoner (no parens) should still register at /reasoners/{func_name}.""" + agent, _ = create_test_agent(monkeypatch) + agent.async_config.enable_async_execution = False + agent.agentfield_server = None + + @agent.reasoner + async def ping(msg: str) -> dict: + return {"pong": msg} + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), base_url="http://test" + ) as client: + resp = await client.post( + "/reasoners/ping", + json={"msg": "hi"}, + headers={"x-workflow-id": "wf-bare", "x-execution-id": "exec-bare"}, + ) + + assert resp.status_code == 200 + assert resp.json() == {"pong": "hi"} + + +@pytest.mark.asyncio +async def test_router_positional_path_normalized(monkeypatch): + """Router reasoner with positional path should also normalize.""" + agent, _ = create_test_agent(monkeypatch) + agent.async_config.enable_async_execution = False + agent.agentfield_server = None + + router = AgentRouter() + + @router.reasoner("process") + async def process(data: str) -> dict: + return {"processed": data} + + agent.include_router(router) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), base_url="http://test" + ) as client: + resp = await client.post( + "/reasoners/process", + json={"data": "test"}, + headers={"x-workflow-id": "wf-router", "x-execution-id": "exec-router"}, + ) + + assert resp.status_code == 200 + assert resp.json() == {"processed": "test"} diff --git a/sdk/python/tests/test_status_utils.py b/sdk/python/tests/test_status_utils.py index fc286993..d38839dd 100644 --- a/sdk/python/tests/test_status_utils.py +++ b/sdk/python/tests/test_status_utils.py @@ -12,7 +12,8 @@ def test_status_normalization_all_values(): "canceled": "cancelled", "cancel": "cancelled", "timed_out": "timeout", - "WAITING": "queued", + "WAITING": "waiting", + "awaiting_approval": "waiting", "in_progress": "running", "": "unknown", None: "unknown", @@ -27,6 +28,6 @@ def test_is_terminal_aligns_with_terminal_set(): for status in TERMINAL_STATUSES: assert is_terminal(status) - non_terminals = ["pending", "queued", "running", "unknown", "mystery"] + non_terminals = ["pending", "queued", "waiting", "running", "unknown", "mystery"] for status in non_terminals: assert not is_terminal(status) diff --git a/sdk/typescript/src/approval/ApprovalClient.ts b/sdk/typescript/src/approval/ApprovalClient.ts new file mode 100644 index 00000000..5e1dc03b --- /dev/null +++ b/sdk/typescript/src/approval/ApprovalClient.ts @@ -0,0 +1,170 @@ +/** + * Approval workflow helpers for the AgentField TypeScript SDK. + * + * Provides methods to request human approval for an execution, + * poll for approval status, and wait until resolved. + */ + +import axios, { type AxiosInstance } from 'axios'; +import { httpAgent, httpsAgent } from '../utils/httpAgents.js'; + +/** Payload sent when requesting approval. */ +export interface RequestApprovalPayload { + title?: string; + description?: string; + templateType?: string; + payload?: Record; + projectId: string; + expiresInHours?: number; +} + +/** Response from the control plane after creating an approval request. */ +export interface ApprovalRequestResponse { + approvalRequestId: string; + approvalRequestUrl: string; +} + +/** Approval status returned by the polling endpoint. */ +export interface ApprovalStatusResponse { + status: 'pending' | 'approved' | 'rejected' | 'expired'; + response?: Record; + requestUrl?: string; + requestedAt?: string; + respondedAt?: string; +} + +/** Options for the blocking `waitForApproval` helper. */ +export interface WaitForApprovalOptions { + /** Initial polling interval in milliseconds (default: 5000). */ + pollIntervalMs?: number; + /** Maximum polling interval in milliseconds (default: 60000). */ + maxIntervalMs?: number; + /** Total timeout in milliseconds (default: unlimited). */ + timeoutMs?: number; +} + +export class ApprovalClient { + private readonly http: AxiosInstance; + private readonly nodeId: string; + private readonly headers: Record; + + constructor(opts: { + baseURL: string; + nodeId: string; + apiKey?: string; + headers?: Record; + }) { + this.http = axios.create({ + baseURL: opts.baseURL.replace(/\/$/, ''), + timeout: 30_000, + httpAgent, + httpsAgent, + }); + this.nodeId = opts.nodeId; + + const merged: Record = { ...(opts.headers ?? {}) }; + if (opts.apiKey) { + merged['X-API-Key'] = opts.apiKey; + } + this.headers = merged; + } + + /** + * Request human approval, transitioning the execution to `waiting`. + * + * Calls `POST /api/v1/agents/{node}/executions/{id}/request-approval`. + */ + async requestApproval( + executionId: string, + payload: RequestApprovalPayload + ): Promise { + const body = { + title: payload.title ?? 'Approval Request', + description: payload.description ?? '', + template_type: payload.templateType ?? 'plan-review-v1', + payload: payload.payload ?? {}, + project_id: payload.projectId, + expires_in_hours: payload.expiresInHours ?? 72, + }; + + const res = await this.http.post( + `/api/v1/agents/${encodeURIComponent(this.nodeId)}/executions/${encodeURIComponent(executionId)}/request-approval`, + body, + { headers: { ...this.headers, 'Content-Type': 'application/json' } } + ); + + return { + approvalRequestId: res.data.approval_request_id ?? '', + approvalRequestUrl: res.data.approval_request_url ?? '', + }; + } + + /** + * Get the current approval status for an execution. + * + * Calls `GET /api/v1/agents/{node}/executions/{id}/approval-status`. + */ + async getApprovalStatus(executionId: string): Promise { + const res = await this.http.get( + `/api/v1/agents/${encodeURIComponent(this.nodeId)}/executions/${encodeURIComponent(executionId)}/approval-status`, + { headers: this.headers } + ); + + const data = res.data; + return { + status: data.status ?? 'pending', + response: data.response, + requestUrl: data.request_url, + requestedAt: data.requested_at, + respondedAt: data.responded_at, + }; + } + + /** + * Poll approval status with exponential backoff until resolved. + * + * Returns once the status is no longer `pending` (i.e. approved, rejected, + * or expired). + */ + async waitForApproval( + executionId: string, + opts?: WaitForApprovalOptions + ): Promise { + const pollInterval = opts?.pollIntervalMs ?? 5_000; + const maxInterval = opts?.maxIntervalMs ?? 60_000; + const timeout = opts?.timeoutMs; + const backoffFactor = 2; + + const startTime = Date.now(); + let interval = pollInterval; + + while (true) { + if (timeout != null && Date.now() - startTime >= timeout) { + throw new Error( + `Approval for execution ${executionId} timed out after ${timeout}ms` + ); + } + + await sleep(interval); + + let data: ApprovalStatusResponse; + try { + data = await this.getApprovalStatus(executionId); + } catch { + // Transient failure — back off and retry + interval = Math.min(interval * backoffFactor, maxInterval); + continue; + } + + if (data.status !== 'pending') { + return data; + } + + interval = Math.min(interval * backoffFactor, maxInterval); + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/sdk/typescript/src/client/AgentFieldClient.ts b/sdk/typescript/src/client/AgentFieldClient.ts index 10bad49b..14a22cdf 100644 --- a/sdk/typescript/src/client/AgentFieldClient.ts +++ b/sdk/typescript/src/client/AgentFieldClient.ts @@ -17,6 +17,7 @@ export interface ExecutionStatusUpdate { error?: string; durationMs?: number; progress?: number; + statusReason?: string; } export class AgentFieldClient { @@ -127,9 +128,10 @@ this.http = axios.create({ workflowId?: string; reasonerId: string; agentNodeId: string; - status: 'running' | 'succeeded' | 'failed'; + status: 'waiting' | 'running' | 'succeeded' | 'failed'; parentExecutionId?: string; parentWorkflowId?: string; + statusReason?: string; inputData?: Record; result?: any; error?: string; @@ -143,6 +145,7 @@ this.http = axios.create({ type: event.reasonerId, agent_node_id: event.agentNodeId, status: event.status, + status_reason: event.statusReason, parent_execution_id: event.parentExecutionId, parent_workflow_id: event.parentWorkflowId ?? event.workflowId ?? event.runId, input_data: event.inputData ?? {}, @@ -176,7 +179,8 @@ this.http = axios.create({ result: update.result, error: update.error, duration_ms: update.durationMs, - progress: update.progress !== undefined ? Math.round(update.progress) : undefined + progress: update.progress !== undefined ? Math.round(update.progress) : undefined, + status_reason: update.statusReason }; const bodyStr = JSON.stringify(payload); diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 4bca243b..eed2a1ff 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -21,3 +21,5 @@ export * from './types/agent.js'; export * from './types/reasoner.js'; export * from './types/skill.js'; export * from './types/mcp.js'; +export * from './status/ExecutionStatus.js'; +export * from './approval/ApprovalClient.js'; diff --git a/sdk/typescript/src/status/ExecutionStatus.ts b/sdk/typescript/src/status/ExecutionStatus.ts new file mode 100644 index 00000000..5c85b074 --- /dev/null +++ b/sdk/typescript/src/status/ExecutionStatus.ts @@ -0,0 +1,87 @@ +/** + * Canonical execution status utilities for the AgentField TypeScript SDK. + * + * Mirrors the control plane's status normalization and terminal-state logic + * so that SDK consumers can classify execution statuses consistently. + */ + +/** Canonical execution status values used by the control plane. */ +export const ExecutionStatus = { + PENDING: 'pending', + QUEUED: 'queued', + WAITING: 'waiting', + RUNNING: 'running', + SUCCEEDED: 'succeeded', + FAILED: 'failed', + CANCELLED: 'cancelled', + TIMEOUT: 'timeout', + UNKNOWN: 'unknown', +} as const; + +export type ExecutionStatusValue = (typeof ExecutionStatus)[keyof typeof ExecutionStatus]; + +/** All canonical status strings. */ +export const CANONICAL_STATUSES: ReadonlySet = new Set( + Object.values(ExecutionStatus) +); + +/** Statuses that represent a completed execution (no further transitions). */ +export const TERMINAL_STATUSES: ReadonlySet = new Set([ + ExecutionStatus.SUCCEEDED, + ExecutionStatus.FAILED, + ExecutionStatus.CANCELLED, + ExecutionStatus.TIMEOUT, +]); + +/** Statuses that represent an active, pollable execution. */ +export const ACTIVE_STATUSES: ReadonlySet = new Set([ + ExecutionStatus.PENDING, + ExecutionStatus.QUEUED, + ExecutionStatus.WAITING, + ExecutionStatus.RUNNING, +]); + +/** Human-friendly aliases that map to canonical statuses. */ +const STATUS_ALIASES: Record = { + success: 'succeeded', + successful: 'succeeded', + completed: 'succeeded', + complete: 'succeeded', + done: 'succeeded', + ok: 'succeeded', + error: 'failed', + failure: 'failed', + errored: 'failed', + canceled: 'cancelled', + cancel: 'cancelled', + timed_out: 'timeout', + wait: 'queued', + awaiting_approval: 'waiting', + awaiting_human: 'waiting', + approval_pending: 'waiting', + in_progress: 'running', + processing: 'running', +}; + +/** + * Normalize an arbitrary status string to its canonical form. + * + * Returns `"unknown"` for unrecognized or empty values. + */ +export function normalizeStatus(status: string | null | undefined): string { + if (status == null) return 'unknown'; + const normalized = status.trim().toLowerCase(); + if (!normalized) return 'unknown'; + if (CANONICAL_STATUSES.has(normalized)) return normalized; + return STATUS_ALIASES[normalized] ?? 'unknown'; +} + +/** Return `true` if the status represents a terminal (completed) execution. */ +export function isTerminal(status: string | null | undefined): boolean { + return TERMINAL_STATUSES.has(normalizeStatus(status)); +} + +/** Return `true` if the status represents an active, pollable execution. */ +export function isActive(status: string | null | undefined): boolean { + return ACTIVE_STATUSES.has(normalizeStatus(status)); +} diff --git a/sdk/typescript/tests/approval.test.ts b/sdk/typescript/tests/approval.test.ts new file mode 100644 index 00000000..e64b3893 --- /dev/null +++ b/sdk/typescript/tests/approval.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { ApprovalClient } from '../src/approval/ApprovalClient.js'; + +/** + * Minimal HTTP server that returns canned JSON responses for approval endpoints. + */ +function createMockServer(responses: Array<{ status: number; body: unknown }>) { + let callIndex = 0; + const server = http.createServer((req, res) => { + const resp = responses[Math.min(callIndex++, responses.length - 1)]; + res.writeHead(resp.status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(resp.body)); + }); + return server; +} + +function serverURL(server: http.Server): string { + const addr = server.address() as AddressInfo; + return `http://127.0.0.1:${addr.port}`; +} + +describe('ApprovalClient', () => { + let server: http.Server; + + afterEach(() => { + return new Promise((resolve) => { + if (server?.listening) { + server.closeAllConnections(); + server.close(() => resolve()); + } else { + resolve(); + } + }); + }); + + // ------------------------------------------------------------------- + // requestApproval + // ------------------------------------------------------------------- + + describe('requestApproval', () => { + it('returns typed response on success', async () => { + server = createMockServer([ + { + status: 200, + body: { + approval_request_id: 'req-abc', + approval_request_url: 'https://hub.example.com/r/req-abc', + }, + }, + ]); + await new Promise((resolve) => server.listen(0, resolve)); + + const client = new ApprovalClient({ + baseURL: serverURL(server), + nodeId: 'test-node', + apiKey: 'key-1', + }); + + const result = await client.requestApproval('exec-1', { + projectId: 'proj-1', + title: 'Plan Review', + }); + + expect(result.approvalRequestId).toBe('req-abc'); + expect(result.approvalRequestUrl).toBe('https://hub.example.com/r/req-abc'); + }); + + it('throws on HTTP error', async () => { + server = createMockServer([ + { status: 404, body: { error: 'not found' } }, + ]); + await new Promise((resolve) => server.listen(0, resolve)); + + const client = new ApprovalClient({ + baseURL: serverURL(server), + nodeId: 'test-node', + }); + + await expect( + client.requestApproval('exec-1', { projectId: 'p' }) + ).rejects.toThrow(); + }); + }); + + // ------------------------------------------------------------------- + // getApprovalStatus + // ------------------------------------------------------------------- + + describe('getApprovalStatus', () => { + it('returns typed response for approved status', async () => { + server = createMockServer([ + { + status: 200, + body: { + status: 'approved', + response: { decision: 'approved', feedback: 'LGTM' }, + request_url: 'https://hub.example.com/r/req-abc', + requested_at: '2026-02-25T10:00:00Z', + responded_at: '2026-02-25T11:00:00Z', + }, + }, + ]); + await new Promise((resolve) => server.listen(0, resolve)); + + const client = new ApprovalClient({ + baseURL: serverURL(server), + nodeId: 'test-node', + }); + + const result = await client.getApprovalStatus('exec-1'); + + expect(result.status).toBe('approved'); + expect(result.response).toEqual({ decision: 'approved', feedback: 'LGTM' }); + expect(result.requestUrl).toBe('https://hub.example.com/r/req-abc'); + expect(result.requestedAt).toBe('2026-02-25T10:00:00Z'); + expect(result.respondedAt).toBe('2026-02-25T11:00:00Z'); + }); + + it('returns pending with no response fields', async () => { + server = createMockServer([ + { + status: 200, + body: { + status: 'pending', + request_url: 'https://hub.example.com/r/req-abc', + requested_at: '2026-02-25T10:00:00Z', + }, + }, + ]); + await new Promise((resolve) => server.listen(0, resolve)); + + const client = new ApprovalClient({ + baseURL: serverURL(server), + nodeId: 'test-node', + }); + + const result = await client.getApprovalStatus('exec-1'); + + expect(result.status).toBe('pending'); + expect(result.response).toBeUndefined(); + expect(result.respondedAt).toBeUndefined(); + }); + + it('throws on server error', async () => { + server = createMockServer([ + { status: 500, body: { error: 'internal' } }, + ]); + await new Promise((resolve) => server.listen(0, resolve)); + + const client = new ApprovalClient({ + baseURL: serverURL(server), + nodeId: 'test-node', + }); + + await expect(client.getApprovalStatus('exec-1')).rejects.toThrow(); + }); + }); + + // ------------------------------------------------------------------- + // waitForApproval + // ------------------------------------------------------------------- + + describe('waitForApproval', () => { + it('resolves once status is no longer pending', async () => { + server = createMockServer([ + { status: 200, body: { status: 'pending' } }, + { status: 200, body: { status: 'approved', response: { decision: 'approved' } } }, + ]); + await new Promise((resolve) => server.listen(0, resolve)); + + const client = new ApprovalClient({ + baseURL: serverURL(server), + nodeId: 'test-node', + }); + + const result = await client.waitForApproval('exec-1', { + pollIntervalMs: 50, + maxIntervalMs: 50, + }); + + expect(result.status).toBe('approved'); + }); + + it('returns rejected status', async () => { + server = createMockServer([ + { status: 200, body: { status: 'rejected', response: { feedback: 'needs work' } } }, + ]); + await new Promise((resolve) => server.listen(0, resolve)); + + const client = new ApprovalClient({ + baseURL: serverURL(server), + nodeId: 'test-node', + }); + + const result = await client.waitForApproval('exec-1', { + pollIntervalMs: 50, + }); + + expect(result.status).toBe('rejected'); + }); + + it('throws on timeout', async () => { + server = createMockServer( + Array.from({ length: 20 }, () => ({ + status: 200, + body: { status: 'pending' }, + })) + ); + await new Promise((resolve) => server.listen(0, resolve)); + + const client = new ApprovalClient({ + baseURL: serverURL(server), + nodeId: 'test-node', + }); + + await expect( + client.waitForApproval('exec-1', { + pollIntervalMs: 20, + maxIntervalMs: 20, + timeoutMs: 100, + }) + ).rejects.toThrow(/timed out/); + }); + + it('retries on transient errors and eventually resolves', async () => { + server = createMockServer([ + { status: 500, body: { error: 'transient' } }, + { status: 200, body: { status: 'approved' } }, + ]); + await new Promise((resolve) => server.listen(0, resolve)); + + const client = new ApprovalClient({ + baseURL: serverURL(server), + nodeId: 'test-node', + }); + + const result = await client.waitForApproval('exec-1', { + pollIntervalMs: 50, + maxIntervalMs: 50, + }); + + expect(result.status).toBe('approved'); + }); + + it('resolves on expired status', async () => { + server = createMockServer([ + { + status: 200, + body: { + status: 'expired', + request_url: 'https://hub.example.com/r/req-abc', + requested_at: '2026-02-25T10:00:00Z', + responded_at: '2026-02-28T10:00:00Z', + }, + }, + ]); + await new Promise((resolve) => server.listen(0, resolve)); + + const client = new ApprovalClient({ + baseURL: serverURL(server), + nodeId: 'test-node', + }); + + const result = await client.waitForApproval('exec-1', { + pollIntervalMs: 50, + }); + + expect(result.status).toBe('expired'); + }); + }); + + describe('getApprovalStatus — expired', () => { + it('returns expired status', async () => { + server = createMockServer([ + { + status: 200, + body: { + status: 'expired', + request_url: 'https://hub.example.com/r/req-abc', + requested_at: '2026-02-25T10:00:00Z', + responded_at: '2026-02-28T10:00:00Z', + }, + }, + ]); + await new Promise((resolve) => server.listen(0, resolve)); + + const client = new ApprovalClient({ + baseURL: serverURL(server), + nodeId: 'test-node', + }); + + const result = await client.getApprovalStatus('exec-1'); + + expect(result.status).toBe('expired'); + expect(result.respondedAt).toBe('2026-02-28T10:00:00Z'); + }); + }); +}); diff --git a/tests/functional/tests/test_waiting_state.py b/tests/functional/tests/test_waiting_state.py new file mode 100644 index 00000000..2364d271 --- /dev/null +++ b/tests/functional/tests/test_waiting_state.py @@ -0,0 +1,441 @@ +""" +Functional tests for the waiting state (human-in-the-loop approval) workflow. + +Tests the full end-to-end flow: +1. Agent starts an execution +2. Execution transitions to "waiting" via the approval request API +3. Approval status is polled (returns "pending") +4. External webhook resolves the approval (approved/rejected/expired) +5. Execution resumes with the appropriate status + +These tests exercise the control plane approval endpoints directly via HTTP, +validating the state machine transitions without requiring an external approval +service. +""" + +import asyncio +import uuid + +import pytest + +from utils import run_agent_server + +from agentfield import Agent + + +def _unique_node_id(prefix: str = "test-waiting") -> str: + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _start_execution(client, node_id: str, reasoner_id: str, input_data: dict) -> dict: + """Start a sync execution and return the response payload.""" + resp = await client.post( + f"/api/v1/execute/{node_id}.{reasoner_id}", + json={"input": input_data}, + timeout=30.0, + ) + assert resp.status_code == 200, f"Execute failed: {resp.text}" + return resp.json() + + +async def _request_approval( + client, + node_id: str, + execution_id: str, + approval_request_id: str, + **kwargs, +) -> dict: + """Request approval for an execution, transitioning it to waiting.""" + body = {"approval_request_id": approval_request_id, **kwargs} + resp = await client.post( + f"/api/v1/agents/{node_id}/executions/{execution_id}/request-approval", + json=body, + timeout=10.0, + ) + return resp.json(), resp.status_code + + +async def _get_approval_status(client, node_id: str, execution_id: str) -> dict: + """Poll the approval status for an execution.""" + resp = await client.get( + f"/api/v1/agents/{node_id}/executions/{execution_id}/approval-status", + timeout=10.0, + ) + return resp.json(), resp.status_code + + +async def _send_approval_webhook(client, request_id: str, decision: str, feedback: str = "") -> dict: + """Send an approval webhook to resolve a waiting execution.""" + body = { + "requestId": request_id, + "decision": decision, + } + if feedback: + body["feedback"] = feedback + resp = await client.post( + "/api/v1/webhooks/approval-response", + json=body, + timeout=10.0, + ) + return resp.json(), resp.status_code + + +async def _get_execution_status(client, execution_id: str) -> dict: + """Get the execution status from the executions API.""" + resp = await client.get( + f"/api/v1/executions/{execution_id}", + timeout=10.0, + ) + if resp.status_code != 200: + return {"status": "unknown"}, resp.status_code + return resp.json(), resp.status_code + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.functional +@pytest.mark.asyncio +async def test_approval_request_transitions_to_waiting(make_test_agent, async_http_client): + """ + Verify that requesting approval transitions an execution from + running to waiting state. + """ + node_id = _unique_node_id() + agent = make_test_agent(node_id=node_id) + + @agent.reasoner() + async def slow_task(message: str) -> dict: + # This reasoner just returns — the test will request approval + # on the execution after it completes. For the real flow, + # the agent would call app.pause() mid-execution. + return {"message": message, "status": "done"} + + async with run_agent_server(agent): + # Start an execution + exec_result = await _start_execution( + async_http_client, node_id, "slow_task", {"message": "test"} + ) + execution_id = exec_result["execution_id"] + assert exec_result["status"] == "succeeded" + + # Note: In a real scenario, the agent would request approval during + # execution (while running). For this test, we verify the API + # contract by checking behavior against a completed execution. + # The control plane rejects approval requests for non-running executions. + approval_result, status_code = await _request_approval( + async_http_client, + node_id, + execution_id, + "req-test-1", + ) + + # Since execution is already succeeded, this should be rejected + assert status_code == 409, f"Expected conflict, got {status_code}: {approval_result}" + + +@pytest.mark.functional +@pytest.mark.asyncio +async def test_full_approval_flow_via_async_execution(make_test_agent, async_http_client): + """ + Full end-to-end approval flow using async execution: + 1. Start async execution (returns immediately while running) + 2. Request approval (transitions to waiting) + 3. Poll status (should be pending) + 4. Send webhook (resolves to approved) + 5. Verify execution resumes + """ + node_id = _unique_node_id() + agent = make_test_agent(node_id=node_id) + + @agent.reasoner() + async def long_running_task(message: str) -> dict: + # Sleep to keep the execution running long enough for the test + await asyncio.sleep(60) + return {"message": message} + + async with run_agent_server(agent): + # Start an async execution (returns immediately, execution runs in background) + resp = await async_http_client.post( + f"/api/v1/execute/async/{node_id}.long_running_task", + json={"input": {"message": "test approval flow"}}, + timeout=10.0, + ) + assert resp.status_code == 202, f"Async execute failed: {resp.text}" + exec_data = resp.json() + execution_id = exec_data["execution_id"] + + # Wait a moment for execution to start + await asyncio.sleep(2) + + # Request approval — should transition to waiting + approval_request_id = f"req-{uuid.uuid4().hex[:8]}" + approval_result, status_code = await _request_approval( + async_http_client, + node_id, + execution_id, + approval_request_id, + approval_request_url=f"https://hub.example.com/review/{approval_request_id}", + expires_in_hours=1, + ) + assert status_code == 200, f"Request approval failed ({status_code}): {approval_result}" + assert approval_result["status"] == "pending" + + # Poll approval status — should be pending + status_result, status_code = await _get_approval_status( + async_http_client, node_id, execution_id + ) + assert status_code == 200 + assert status_result["status"] == "pending" + assert status_result.get("request_url") == f"https://hub.example.com/review/{approval_request_id}" + + # Send webhook to approve + webhook_result, webhook_status = await _send_approval_webhook( + async_http_client, + approval_request_id, + "approved", + feedback="LGTM!", + ) + assert webhook_status == 200, f"Webhook failed ({webhook_status}): {webhook_result}" + assert webhook_result["decision"] == "approved" + assert webhook_result["new_status"] == "running" + + # Verify approval status is now approved + final_status, _ = await _get_approval_status( + async_http_client, node_id, execution_id + ) + assert final_status["status"] == "approved" + assert final_status.get("responded_at") is not None + + +@pytest.mark.functional +@pytest.mark.asyncio +async def test_approval_rejected_cancels_execution(make_test_agent, async_http_client): + """ + Verify that rejecting an approval transitions the execution to cancelled. + """ + node_id = _unique_node_id() + agent = make_test_agent(node_id=node_id) + + @agent.reasoner() + async def pending_task(message: str) -> dict: + await asyncio.sleep(60) + return {"message": message} + + async with run_agent_server(agent): + # Start async execution + resp = await async_http_client.post( + f"/api/v1/execute/async/{node_id}.pending_task", + json={"input": {"message": "test rejection"}}, + timeout=10.0, + ) + assert resp.status_code == 202 + execution_id = resp.json()["execution_id"] + + await asyncio.sleep(2) + + # Request approval + approval_request_id = f"req-rej-{uuid.uuid4().hex[:8]}" + result, status = await _request_approval( + async_http_client, node_id, execution_id, approval_request_id + ) + assert status == 200 + + # Reject via webhook + webhook_result, webhook_status = await _send_approval_webhook( + async_http_client, + approval_request_id, + "rejected", + feedback="Plan needs more detail", + ) + assert webhook_status == 200 + assert webhook_result["decision"] == "rejected" + assert webhook_result["new_status"] == "cancelled" + + +@pytest.mark.functional +@pytest.mark.asyncio +async def test_approval_expired_cancels_execution(make_test_agent, async_http_client): + """ + Verify that an expired approval transitions the execution to cancelled. + """ + node_id = _unique_node_id() + agent = make_test_agent(node_id=node_id) + + @agent.reasoner() + async def expiring_task(message: str) -> dict: + await asyncio.sleep(60) + return {"message": message} + + async with run_agent_server(agent): + resp = await async_http_client.post( + f"/api/v1/execute/async/{node_id}.expiring_task", + json={"input": {"message": "test expiry"}}, + timeout=10.0, + ) + assert resp.status_code == 202 + execution_id = resp.json()["execution_id"] + + await asyncio.sleep(2) + + approval_request_id = f"req-exp-{uuid.uuid4().hex[:8]}" + result, status = await _request_approval( + async_http_client, node_id, execution_id, approval_request_id + ) + assert status == 200 + + # Simulate expiry via webhook + webhook_result, webhook_status = await _send_approval_webhook( + async_http_client, approval_request_id, "expired" + ) + assert webhook_status == 200 + assert webhook_result["decision"] == "expired" + assert webhook_result["new_status"] == "cancelled" + + +@pytest.mark.functional +@pytest.mark.asyncio +async def test_approval_webhook_idempotent(make_test_agent, async_http_client): + """ + Verify that sending the same webhook twice returns success (idempotent). + """ + node_id = _unique_node_id() + agent = make_test_agent(node_id=node_id) + + @agent.reasoner() + async def idem_task(message: str) -> dict: + await asyncio.sleep(60) + return {"message": message} + + async with run_agent_server(agent): + resp = await async_http_client.post( + f"/api/v1/execute/async/{node_id}.idem_task", + json={"input": {"message": "test idempotency"}}, + timeout=10.0, + ) + assert resp.status_code == 202 + execution_id = resp.json()["execution_id"] + + await asyncio.sleep(2) + + approval_request_id = f"req-idem-{uuid.uuid4().hex[:8]}" + result, status = await _request_approval( + async_http_client, node_id, execution_id, approval_request_id + ) + assert status == 200 + + # First webhook + r1, s1 = await _send_approval_webhook( + async_http_client, approval_request_id, "approved" + ) + assert s1 == 200 + assert r1["status"] == "processed" + + # Duplicate webhook — should return 200 with already_processed + r2, s2 = await _send_approval_webhook( + async_http_client, approval_request_id, "approved" + ) + assert s2 == 200 + assert r2["status"] == "already_processed" + + +@pytest.mark.functional +@pytest.mark.asyncio +async def test_approval_duplicate_request_rejected(make_test_agent, async_http_client): + """ + Verify that requesting approval twice on the same execution is rejected. + """ + node_id = _unique_node_id() + agent = make_test_agent(node_id=node_id) + + @agent.reasoner() + async def dup_task(message: str) -> dict: + await asyncio.sleep(60) + return {"message": message} + + async with run_agent_server(agent): + resp = await async_http_client.post( + f"/api/v1/execute/async/{node_id}.dup_task", + json={"input": {"message": "test duplicate"}}, + timeout=10.0, + ) + assert resp.status_code == 202 + execution_id = resp.json()["execution_id"] + + await asyncio.sleep(2) + + # First approval request + r1, s1 = await _request_approval( + async_http_client, node_id, execution_id, f"req-dup-1-{uuid.uuid4().hex[:8]}" + ) + assert s1 == 200 + + # Second approval request — should be rejected. + # After the first request, execution is in "waiting" state (not "running"), + # so the handler rejects with "invalid_state" before reaching the duplicate check. + r2, s2 = await _request_approval( + async_http_client, node_id, execution_id, f"req-dup-2-{uuid.uuid4().hex[:8]}" + ) + assert s2 == 409 + assert r2.get("error") == "invalid_state" + + +@pytest.mark.functional +@pytest.mark.asyncio +async def test_hax_sdk_envelope_webhook_format(make_test_agent, async_http_client): + """ + Verify that the hax-sdk envelope webhook format is correctly handled. + """ + node_id = _unique_node_id() + agent = make_test_agent(node_id=node_id) + + @agent.reasoner() + async def hax_task(message: str) -> dict: + await asyncio.sleep(60) + return {"message": message} + + async with run_agent_server(agent): + resp = await async_http_client.post( + f"/api/v1/execute/async/{node_id}.hax_task", + json={"input": {"message": "test hax format"}}, + timeout=10.0, + ) + assert resp.status_code == 202 + execution_id = resp.json()["execution_id"] + + await asyncio.sleep(2) + + approval_request_id = f"req-hax-{uuid.uuid4().hex[:8]}" + result, status = await _request_approval( + async_http_client, node_id, execution_id, approval_request_id + ) + assert status == 200 + + # Send in hax-sdk envelope format + webhook_resp = await async_http_client.post( + "/api/v1/webhooks/approval-response", + json={ + "id": f"evt_{uuid.uuid4().hex[:8]}", + "type": "completed", + "createdAt": "2026-03-02T12:00:00Z", + "data": { + "requestId": approval_request_id, + "response": { + "decision": "approved", + "feedback": "Approved from Response Hub", + }, + }, + }, + timeout=10.0, + ) + assert webhook_resp.status_code == 200 + webhook_data = webhook_resp.json() + assert webhook_data["decision"] == "approved" + assert webhook_data["new_status"] == "running"