package handlers import ( "context" "database/sql" "encoding/json" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "armature/models" "armature/store" "armature/workflow" ) // instanceView is the enriched response for team instance list endpoints. type instanceView struct { ID string `json:"id"` WorkflowID string `json:"workflow_id"` WorkflowName string `json:"workflow_name"` CurrentStage string `json:"current_stage"` StageName string `json:"stage_name"` Status string `json:"status"` StartedBy string `json:"started_by"` AgeSeconds int `json:"age_seconds"` SLABreached bool `json:"sla_breached"` CreatedAt string `json:"created_at"` } // ── Instance Handlers ─────────────────────── // WorkflowInstanceHandler manages workflow instance HTTP endpoints. type WorkflowInstanceHandler struct { engine *workflow.Engine stores store.Stores } // NewWorkflowInstanceHandler creates an instance handler. func NewWorkflowInstanceHandler(engine *workflow.Engine, stores store.Stores) *WorkflowInstanceHandler { return &WorkflowInstanceHandler{engine: engine, stores: stores} } // Start creates a new workflow instance. // POST /api/v1/workflows/:id/instances func (h *WorkflowInstanceHandler) Start(c *gin.Context) { workflowID := c.Param("id") userID := c.GetString("user_id") var body struct { Data json.RawMessage `json:"data"` Metadata json.RawMessage `json:"metadata"` } if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if len(body.Data) == 0 { body.Data = json.RawMessage(`{}`) } inst, err := h.engine.Start(c.Request.Context(), workflowID, body.Data, userID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, inst) } // ListInstances returns instances for a workflow. // GET /api/v1/workflows/:id/instances?status=&limit=&offset= func (h *WorkflowInstanceHandler) ListInstances(c *gin.Context) { workflowID := c.Param("id") status := c.Query("status") opts := store.ListOptions{} if l := c.Query("limit"); l != "" { opts.Limit, _ = strconv.Atoi(l) } if o := c.Query("offset"); o != "" { opts.Offset, _ = strconv.Atoi(o) } result, err := h.stores.Workflows.ListInstances(c.Request.Context(), workflowID, status, opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list instances"}) return } if result == nil { c.JSON(http.StatusOK, gin.H{"data": []struct{}{}}) return } c.JSON(http.StatusOK, gin.H{"data": result}) } // GetInstance returns a single instance. // GET /api/v1/workflows/:id/instances/:iid func (h *WorkflowInstanceHandler) GetInstance(c *gin.Context) { inst, err := h.stores.Workflows.GetInstance(c.Request.Context(), c.Param("iid")) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "instance not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Verify it belongs to the requested workflow if inst.WorkflowID != c.Param("id") { c.JSON(http.StatusNotFound, gin.H{"error": "instance not found"}) return } c.JSON(http.StatusOK, inst) } // Advance moves an instance to the next stage. // POST /api/v1/workflows/:id/instances/:iid/advance func (h *WorkflowInstanceHandler) Advance(c *gin.Context) { userID := c.GetString("user_id") var body struct { Data json.RawMessage `json:"data"` } if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } inst, err := h.engine.Advance(c.Request.Context(), c.Param("iid"), body.Data, userID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, inst) } // ListTeamInstances returns enriched instances for all workflows owned by a team. // GET /api/v1/teams/:teamId/workflow-instances?status=&limit=&offset= func (h *WorkflowInstanceHandler) ListTeamInstances(c *gin.Context) { teamID := c.Param("teamId") status := c.Query("status") if status == "" { status = "active" // default to active for monitoring } opts := store.ListOptions{} if l := c.Query("limit"); l != "" { opts.Limit, _ = strconv.Atoi(l) } if o := c.Query("offset"); o != "" { opts.Offset, _ = strconv.Atoi(o) } if opts.Limit == 0 { opts.Limit = 50 } result, err := h.stores.Workflows.ListInstancesByTeam(c.Request.Context(), teamID, status, opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list instances"}) return } if result == nil { c.JSON(http.StatusOK, gin.H{"data": []instanceView{}}) return } c.JSON(http.StatusOK, gin.H{"data": h.enrichInstances(c.Request.Context(), result)}) } // enrichInstances resolves workflow_name, stage_name, sla_breached, and age // for each instance by looking up the workflow definition and version snapshot. func (h *WorkflowInstanceHandler) enrichInstances(ctx context.Context, instances []models.WorkflowInstance) []instanceView { views := make([]instanceView, 0, len(instances)) workflowCache := map[string]*models.Workflow{} for _, inst := range instances { v := instanceView{ ID: inst.ID, WorkflowID: inst.WorkflowID, CurrentStage: inst.CurrentStage, StageName: inst.CurrentStage, // default Status: inst.Status, StartedBy: inst.StartedBy, AgeSeconds: int(time.Since(inst.CreatedAt).Seconds()), CreatedAt: inst.CreatedAt.Format(time.RFC3339), } wf, ok := workflowCache[inst.WorkflowID] if !ok { wf, _ = h.stores.Workflows.GetByID(ctx, inst.WorkflowID) workflowCache[inst.WorkflowID] = wf } if wf != nil { v.WorkflowName = wf.Name } // SLA check from version snapshot ver, _ := h.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion) if ver != nil { stages, _ := models.ParseSnapshotStages(ver.Snapshot) for _, s := range stages { if s.Name == inst.CurrentStage { v.StageName = s.Name if s.SLASeconds != nil && *s.SLASeconds > 0 { elapsed := time.Since(inst.StageEnteredAt) v.SLABreached = elapsed.Seconds() > float64(*s.SLASeconds) } break } } } views = append(views, v) } return views } // CancelTeamInstance cancels an instance belonging to a team workflow. // POST /api/v1/teams/:teamId/workflow-instances/:iid/cancel func (h *WorkflowInstanceHandler) CancelTeamInstance(c *gin.Context) { userID := c.GetString("user_id") instanceID := c.Param("iid") // Verify instance belongs to a workflow owned by this team inst, err := h.stores.Workflows.GetInstance(c.Request.Context(), instanceID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "instance not found"}) return } wf, err := h.stores.Workflows.GetByID(c.Request.Context(), inst.WorkflowID) if err != nil || wf.TeamID == nil || *wf.TeamID != c.Param("teamId") { c.JSON(http.StatusForbidden, gin.H{"error": "instance does not belong to this team"}) return } if err := h.engine.Cancel(c.Request.Context(), instanceID, userID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"cancelled": true}) } // Cancel terminates an active instance. // POST /api/v1/workflows/:id/instances/:iid/cancel func (h *WorkflowInstanceHandler) Cancel(c *gin.Context) { userID := c.GetString("user_id") if err := h.engine.Cancel(c.Request.Context(), c.Param("iid"), userID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"cancelled": true}) }