All checks were successful
CI/CD / test-go-pg (push) Successful in 2m54s
CI/CD / test-sqlite (push) Successful in 3m1s
CI/CD / build-and-deploy (push) Successful in 1m47s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
269 lines
8.0 KiB
Go
269 lines
8.0 KiB
Go
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 := parseSnapshotStagesForView(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
|
|
}
|
|
|
|
func parseSnapshotStagesForView(snapshot json.RawMessage) []models.WorkflowStage {
|
|
var wrapped struct {
|
|
Stages []models.WorkflowStage `json:"stages"`
|
|
}
|
|
if err := json.Unmarshal(snapshot, &wrapped); err == nil && len(wrapped.Stages) > 0 {
|
|
return wrapped.Stages
|
|
}
|
|
var stages []models.WorkflowStage
|
|
json.Unmarshal(snapshot, &stages)
|
|
return stages
|
|
}
|
|
|
|
// 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})
|
|
}
|