Changeset 0.37.15 (#227)
This commit is contained in:
@@ -241,6 +241,33 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// v0.37.15: Read script.star from archive if present.
|
||||
// Keeps the manifest clean — authors write a file, installer injects
|
||||
// it as _starlark_script for the sandbox runner.
|
||||
if _, hasInline := manifest["_starlark_script"]; !hasInline {
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
base := filepath.Base(name)
|
||||
if base == "script.star" && !f.FileInfo().IsDir() {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
data, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
script := string(data)
|
||||
if strings.TrimSpace(script) != "" {
|
||||
manifest["_starlark_script"] = script
|
||||
log.Printf("[packages] Injected script.star (%d bytes) into manifest", len(data))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
pkgID, _ := manifest["id"].(string)
|
||||
title, _ := manifest["title"].(string)
|
||||
|
||||
@@ -123,6 +123,169 @@ func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"completed": true, "assignment_id": assignmentID})
|
||||
}
|
||||
|
||||
// ── Lifecycle operations (v0.37.15) ──
|
||||
|
||||
// Unclaim returns a claimed assignment to unassigned.
|
||||
// POST /api/v1/workflow-assignments/:id/unclaim
|
||||
func (h *WorkflowAssignmentHandler) Unclaim(c *gin.Context) {
|
||||
assignmentID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
// Auth: claimer or team admin or global admin
|
||||
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
|
||||
if err != nil || a == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if role != "admin" && (a.AssignedTo == nil || *a.AssignedTo != userID) {
|
||||
isTA := false
|
||||
if h.stores.Teams != nil {
|
||||
isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID)
|
||||
}
|
||||
if !isTA {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only the claimer or a team admin can unclaim"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
n, err := h.stores.Workflows.UnclaimAssignment(c.Request.Context(), assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unclaim assignment"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"})
|
||||
return
|
||||
}
|
||||
|
||||
// Emit WS event
|
||||
channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID)
|
||||
if h.hub != nil && channelID != "" {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"assignment_id": assignmentID,
|
||||
"unclaimed_by": userID,
|
||||
"channel_id": channelID,
|
||||
})
|
||||
evt := events.Event{
|
||||
Label: "workflow.unclaimed",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
}
|
||||
pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "")
|
||||
for _, uid := range pids {
|
||||
h.hub.PublishToUser(uid, evt)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"unclaimed": true, "assignment_id": assignmentID})
|
||||
}
|
||||
|
||||
// Reassign changes the assigned_to on a claimed assignment.
|
||||
// POST /api/v1/workflow-assignments/:id/reassign
|
||||
func (h *WorkflowAssignmentHandler) Reassign(c *gin.Context) {
|
||||
assignmentID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Auth: team admin or global admin
|
||||
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
|
||||
if err != nil || a == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if role != "admin" {
|
||||
isTA := false
|
||||
if h.stores.Teams != nil {
|
||||
isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID)
|
||||
}
|
||||
if !isTA {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
n, err := h.stores.Workflows.ReassignAssignment(c.Request.Context(), assignmentID, body.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reassign assignment"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"})
|
||||
return
|
||||
}
|
||||
|
||||
// Emit WS event
|
||||
channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID)
|
||||
if h.hub != nil && channelID != "" {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"assignment_id": assignmentID,
|
||||
"reassigned_by": userID,
|
||||
"new_assignee": body.UserID,
|
||||
"channel_id": channelID,
|
||||
})
|
||||
evt := events.Event{
|
||||
Label: "workflow.reassigned",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
}
|
||||
pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "")
|
||||
for _, uid := range pids {
|
||||
h.hub.PublishToUser(uid, evt)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"reassigned": true, "assignment_id": assignmentID, "new_assignee": body.UserID})
|
||||
}
|
||||
|
||||
// CancelAssignment cancels a single assignment.
|
||||
// POST /api/v1/workflow-assignments/:id/cancel
|
||||
func (h *WorkflowAssignmentHandler) CancelAssignment(c *gin.Context) {
|
||||
assignmentID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
// Auth: team admin or global admin
|
||||
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
|
||||
if err != nil || a == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if role != "admin" {
|
||||
isTA := false
|
||||
if h.stores.Teams != nil {
|
||||
isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID)
|
||||
}
|
||||
if !isTA {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
n, err := h.stores.Workflows.CancelAssignment(c.Request.Context(), assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel assignment"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in cancellable state"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"cancelled": true, "assignment_id": assignmentID})
|
||||
}
|
||||
|
||||
// CommentOnAssignment adds a review comment to an assignment.
|
||||
// POST /api/v1/workflow-assignments/:id/comment
|
||||
func (h *WorkflowAssignmentHandler) CommentOnAssignment(c *gin.Context) {
|
||||
|
||||
@@ -302,6 +302,128 @@ func (h *WorkflowInstanceHandler) Reject(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Cancel (v0.37.15) ────────────────────────
|
||||
|
||||
// CancelInstance cancels a workflow instance and all its open assignments.
|
||||
// POST /api/v1/channels/:id/workflow/cancel
|
||||
func (h *WorkflowInstanceHandler) CancelInstance(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
channelID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
workflowID, _, status, err := h.readWorkflowState(ctx, channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
|
||||
return
|
||||
}
|
||||
if status != "active" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot cancel"})
|
||||
return
|
||||
}
|
||||
|
||||
// Auth: instance owner, team admin, or global admin
|
||||
if role != "admin" {
|
||||
ch, err := h.stores.Channels.GetByID(ctx, channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
isOwner := ch.UserID == userID
|
||||
isTeamAdmin := false
|
||||
if ch.TeamID != nil && h.stores.Teams != nil {
|
||||
isTeamAdmin, _ = h.stores.Teams.IsTeamAdmin(ctx, *ch.TeamID, userID)
|
||||
}
|
||||
if !isOwner && !isTeamAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only instance owner, team admin, or global admin can cancel"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel the workflow instance
|
||||
if err := h.stores.Channels.CancelWorkflow(ctx, channelID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel workflow"})
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel all open assignments
|
||||
cancelled, _ := h.stores.Workflows.CancelAssignmentsForChannel(ctx, channelID)
|
||||
|
||||
// Emit WS event
|
||||
h.emitWorkflowEvent("workflow.cancelled", channelID, map[string]any{
|
||||
"channel_id": channelID,
|
||||
"workflow_id": workflowID,
|
||||
"cancelled_by": userID,
|
||||
"assignments_cancelled": cancelled,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cancelled": true,
|
||||
"channel_id": channelID,
|
||||
"assignments_cancelled": cancelled,
|
||||
})
|
||||
}
|
||||
|
||||
// CancelTeamInstance cancels a workflow instance via the team-admin monitor.
|
||||
// POST /api/v1/teams/:teamId/workflows/monitor/instances/:channelId/cancel
|
||||
func (h *WorkflowInstanceHandler) CancelTeamInstance(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
channelID := c.Param("channelId")
|
||||
userID := c.GetString("user_id")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Auth: team admin or global admin
|
||||
if role != "admin" {
|
||||
if h.stores.Teams == nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
|
||||
return
|
||||
}
|
||||
isTA, _ := h.stores.Teams.IsTeamAdmin(ctx, teamID, userID)
|
||||
if !isTA {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the channel belongs to this team
|
||||
ch, err := h.stores.Channels.GetByID(ctx, channelID)
|
||||
if err != nil || ch.TeamID == nil || *ch.TeamID != teamID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow instance not found for this team"})
|
||||
return
|
||||
}
|
||||
|
||||
_, _, status, err := h.readWorkflowState(ctx, channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
|
||||
return
|
||||
}
|
||||
if status != "active" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot cancel"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Channels.CancelWorkflow(ctx, channelID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel workflow"})
|
||||
return
|
||||
}
|
||||
|
||||
cancelled, _ := h.stores.Workflows.CancelAssignmentsForChannel(ctx, channelID)
|
||||
|
||||
h.emitWorkflowEvent("workflow.cancelled", channelID, map[string]any{
|
||||
"channel_id": channelID,
|
||||
"cancelled_by": userID,
|
||||
"assignments_cancelled": cancelled,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cancelled": true,
|
||||
"channel_id": channelID,
|
||||
"assignments_cancelled": cancelled,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
func (h *WorkflowInstanceHandler) readWorkflowState(ctx context.Context, channelID string) (workflowID string, currentStage int, status string, err error) {
|
||||
|
||||
@@ -712,6 +712,7 @@ func main() {
|
||||
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
|
||||
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
|
||||
protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
|
||||
protected.POST("/channels/:id/workflow/cancel", wfInstH.CancelInstance) // v0.37.15
|
||||
|
||||
// Workflow assignments (v0.26.4 — team assignment queue)
|
||||
wfAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub)
|
||||
@@ -720,6 +721,9 @@ func main() {
|
||||
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||
protected.GET("/workflow-assignments/:id", wfAssignH.GetAssignment)
|
||||
protected.POST("/workflow-assignments/:id/comment", wfAssignH.CommentOnAssignment)
|
||||
protected.POST("/workflow-assignments/:id/unclaim", wfAssignH.Unclaim) // v0.37.15
|
||||
protected.POST("/workflow-assignments/:id/reassign", wfAssignH.Reassign) // v0.37.15
|
||||
protected.POST("/workflow-assignments/:id/cancel", wfAssignH.CancelAssignment) // v0.37.15
|
||||
|
||||
// Tasks (v0.27.1, permissions v0.27.2)
|
||||
taskH := handlers.NewTaskHandler(stores)
|
||||
@@ -1086,6 +1090,10 @@ func main() {
|
||||
teamWfMon := handlers.NewWorkflowMonitorHandler(stores)
|
||||
teamScoped.GET("/workflows/monitor/instances", teamWfMon.ListTeamActiveInstances)
|
||||
|
||||
// Team workflow instance cancel (v0.37.15)
|
||||
teamWfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner)
|
||||
teamScoped.POST("/workflows/monitor/instances/:channelId/cancel", teamWfInstH.CancelTeamInstance)
|
||||
|
||||
// Team tasks — admin CRUD (v0.27.5)
|
||||
teamTaskH := handlers.NewTaskHandler(stores)
|
||||
teamScoped.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.CreateTeamTask)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/}}
|
||||
|
||||
{{define "surface-team-admin"}}
|
||||
<div id="team-admin-mount" class="surface-team-admin" style="flex-direction:column;">
|
||||
<div id="team-admin-mount" class="surface-team-admin" style="height:100%;overflow:hidden;">
|
||||
<div class="settings-placeholder" style="padding:40px;text-align:center;">Loading team admin…</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -510,6 +510,9 @@ type ChannelStore interface {
|
||||
// CompleteWorkflow sets workflow_status='completed' and ai_mode='off'.
|
||||
CompleteWorkflow(ctx context.Context, channelID string, finalStage int, stageData json.RawMessage) error
|
||||
|
||||
// CancelWorkflow sets workflow_status='cancelled' and ai_mode='off'. (v0.37.15)
|
||||
CancelWorkflow(ctx context.Context, channelID string) error
|
||||
|
||||
// RejectWorkflowToStage resets current_stage (no stage_data change).
|
||||
RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error
|
||||
|
||||
|
||||
@@ -644,6 +644,15 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) CancelWorkflow(ctx context.Context, channelID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET workflow_status = 'cancelled', ai_mode = 'off', last_activity_at = $1
|
||||
WHERE id = $2 AND type = 'workflow'
|
||||
`, time.Now().UTC(), channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
|
||||
@@ -449,6 +449,56 @@ func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID
|
||||
return channelID, err
|
||||
}
|
||||
|
||||
// ── Lifecycle operations (v0.37.15) ──
|
||||
|
||||
func (s *WorkflowStore) CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'cancelled'
|
||||
WHERE channel_id = $1 AND status IN ('unassigned', 'claimed')
|
||||
`, channelID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'unassigned', assigned_to = NULL, claimed_at = NULL
|
||||
WHERE id = $1 AND status = 'claimed'
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = $1, claimed_at = $2
|
||||
WHERE id = $3 AND status = 'claimed'
|
||||
`, newUserID, time.Now().UTC(), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) CancelAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'cancelled'
|
||||
WHERE id = $1 AND status IN ('unassigned', 'claimed')
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) {
|
||||
// Find least-recently-assigned team member
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
|
||||
@@ -645,6 +645,15 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) CancelWorkflow(ctx context.Context, channelID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET workflow_status = 'cancelled', ai_mode = 'off', last_activity_at = ?
|
||||
WHERE id = ? AND type = 'workflow'
|
||||
`, time.Now().UTC().Format(timeFmt), channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
||||
now := time.Now().UTC().Format(timeFmt)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
|
||||
@@ -452,6 +452,56 @@ func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID
|
||||
return channelID, err
|
||||
}
|
||||
|
||||
// ── Lifecycle operations (v0.37.15) ──
|
||||
|
||||
func (s *WorkflowStore) CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'cancelled'
|
||||
WHERE channel_id = ? AND status IN ('unassigned', 'claimed')
|
||||
`, channelID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'unassigned', assigned_to = NULL, claimed_at = NULL
|
||||
WHERE id = ? AND status = 'claimed'
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = ?, claimed_at = ?
|
||||
WHERE id = ? AND status = 'claimed'
|
||||
`, newUserID, time.Now().UTC().Format(timeFmt), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) CancelAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'cancelled'
|
||||
WHERE id = ? AND status IN ('unassigned', 'claimed')
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01 00:00:00') as last_claim
|
||||
|
||||
@@ -55,6 +55,23 @@ type WorkflowStore interface {
|
||||
// Returns the assigned user ID, or "" if no members available.
|
||||
TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error)
|
||||
|
||||
// ── Lifecycle operations (v0.37.15) ──
|
||||
|
||||
// CancelAssignmentsForChannel sets status='cancelled' on all
|
||||
// unassigned/claimed assignments for a channel.
|
||||
CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error)
|
||||
|
||||
// UnclaimAssignment returns a claimed assignment to unassigned.
|
||||
// Returns rows affected (0 = not claimed or not found).
|
||||
UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error)
|
||||
|
||||
// ReassignAssignment changes assigned_to on a claimed assignment.
|
||||
// Returns rows affected.
|
||||
ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error)
|
||||
|
||||
// CancelAssignment sets a single assignment to cancelled.
|
||||
CancelAssignment(ctx context.Context, assignmentID string) (int64, error)
|
||||
|
||||
// ── Review Comments (v0.35.0) ──
|
||||
|
||||
// GetAssignmentByID returns a single assignment by ID.
|
||||
|
||||
Reference in New Issue
Block a user