Feat v0.7.10 workflow handoff (#64)
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>
This commit was merged in pull request #64.
This commit is contained in:
2026-04-02 23:16:19 +00:00
committed by xcaliber
parent a9cf71b76d
commit f06c6c954b
17 changed files with 818 additions and 36 deletions

View File

@@ -1,9 +1,11 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -12,6 +14,15 @@ import (
"armature/workflow"
)
// assignmentView is the enriched response for assignment list endpoints.
type assignmentView struct {
models.WorkflowAssignment
WorkflowID string `json:"workflow_id"`
WorkflowName string `json:"workflow_name"`
StageName string `json:"stage_name"`
SLABreached bool `json:"sla_breached"`
}
// ── Assignment Handlers ─────────────────────
// WorkflowAssignmentHandler manages workflow assignment HTTP endpoints.
@@ -147,7 +158,7 @@ func (h *WorkflowAssignmentHandler) Cancel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"cancelled": true})
}
// ListByTeam returns assignments for a team.
// ListByTeam returns enriched assignments for a team.
// GET /api/v1/teams/:teamId/assignments?status=
func (h *WorkflowAssignmentHandler) ListByTeam(c *gin.Context) {
teamID := c.Param("teamId")
@@ -156,20 +167,50 @@ func (h *WorkflowAssignmentHandler) ListByTeam(c *gin.Context) {
result, err := h.stores.Workflows.ListAssignmentsByTeam(c.Request.Context(), teamID, status)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusOK, gin.H{"data": []struct{}{}})
c.JSON(http.StatusOK, gin.H{"data": []assignmentView{}})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
return
}
if result == nil {
c.JSON(http.StatusOK, gin.H{"data": []struct{}{}})
c.JSON(http.StatusOK, gin.H{"data": []assignmentView{}})
return
}
c.JSON(http.StatusOK, gin.H{"data": result})
c.JSON(http.StatusOK, gin.H{"data": h.enrichAssignments(c.Request.Context(), result)})
}
// ListMine returns assignments claimed by or assigned to the current user.
// Assign lets a team admin assign an unassigned assignment to a specific user.
// POST /api/v1/assignments/:id/assign
func (h *WorkflowAssignmentHandler) Assign(c *gin.Context) {
assignmentID := c.Param("id")
ctx := c.Request.Context()
var body struct {
UserID string `json:"user_id"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
}
// Verify the target user exists
targetUser, err := h.stores.Users.GetByID(ctx, body.UserID)
if err != nil || targetUser == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
return
}
// ClaimAssignment sets assigned_to and status=claimed — works for admin assignment
if err := h.stores.Workflows.ClaimAssignment(ctx, assignmentID, body.UserID); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"assigned": true, "assigned_to": body.UserID})
}
// ListMine returns enriched assignments claimed by or assigned to the current user.
// GET /api/v1/assignments/mine?status=
func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) {
userID := c.GetString("user_id")
@@ -181,8 +222,80 @@ func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) {
return
}
if result == nil {
c.JSON(http.StatusOK, gin.H{"data": []struct{}{}})
c.JSON(http.StatusOK, gin.H{"data": []assignmentView{}})
return
}
c.JSON(http.StatusOK, gin.H{"data": result})
c.JSON(http.StatusOK, gin.H{"data": h.enrichAssignments(c.Request.Context(), result)})
}
// enrichAssignments resolves workflow_name, stage_name, and sla_breached
// for each assignment by looking up instance → workflow → stage snapshot.
func (h *WorkflowAssignmentHandler) enrichAssignments(ctx context.Context, assignments []models.WorkflowAssignment) []assignmentView {
views := make([]assignmentView, 0, len(assignments))
// Cache lookups to avoid repeated DB hits for the same workflow/instance
instanceCache := map[string]*models.WorkflowInstance{}
workflowCache := map[string]*models.Workflow{}
for _, a := range assignments {
v := assignmentView{WorkflowAssignment: a}
// Resolve instance → workflow
inst, ok := instanceCache[a.InstanceID]
if !ok {
inst, _ = h.stores.Workflows.GetInstance(ctx, a.InstanceID)
instanceCache[a.InstanceID] = inst
}
if inst != nil {
v.WorkflowID = inst.WorkflowID
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
}
// Resolve stage name and SLA from the published version snapshot
ver, _ := h.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion)
if ver != nil {
stages := parseSnapshotStagesForEnrich(ver.Snapshot)
for _, s := range stages {
if s.Name == a.Stage {
v.StageName = s.Name
if s.SLASeconds != nil && *s.SLASeconds > 0 {
elapsed := time.Since(a.CreatedAt)
v.SLABreached = elapsed.Seconds() > float64(*s.SLASeconds)
}
break
}
}
}
}
// Fallback: use stage field as stage_name if not resolved
if v.StageName == "" {
v.StageName = a.Stage
}
views = append(views, v)
}
return views
}
// parseSnapshotStagesForEnrich is a lightweight stage parser for enrichment.
func parseSnapshotStagesForEnrich(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
}

View File

@@ -1,17 +1,34 @@
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.
@@ -120,6 +137,123 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
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) {