Changeset 0.37.15 (#227)

This commit is contained in:
2026-03-23 19:58:17 +00:00
parent b7746c3004
commit d005e8a30f
23 changed files with 1764 additions and 170 deletions

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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) {