Feat v0.3.4 team roles signoff #18

Merged
xcaliber merged 3 commits from feat/v0.3.4-team-roles-signoff into main 2026-03-28 01:15:34 +00:00
22 changed files with 952 additions and 19 deletions
Showing only changes of commit 748da6c2b4 - Show all commits

View File

@@ -160,14 +160,14 @@ finalizes the extension lifecycle model.
| SLA scanner | ✅ | Background goroutine (5-min interval) checking active instances against per-stage `sla_seconds`. Fires `workflow.sla_breach` event, marks breach in instance metadata (idempotent). |
| Staleness sweep | ✅ | Per-workflow `staleness_timeout_hours` column. Scanner marks idle instances as `stale`, cancels open assignments, fires `workflow.stale` event. |
### v0.3.4 — Team Roles + Multi-party Validation
### v0.3.4 — Team Roles + Multi-party Validation (complete)
| Step | Status | Description |
|------|--------|-------------|
| Team roles | | Expand `team_members.role` beyond admin/member. Role-based stage assignment rules. |
| Multi-party sign-off | | 2-party validation at stage boundaries. |
| Extension lifecycle | | Define permanent vs PoC packages. Graduation criteria. |
| Trigger composition | | How triggers, schedules, and workflows compose. Design doc. |
| Team roles | | Removed CHECK constraint on `team_members.role`. Custom roles stored in `teams.settings["roles"]`. Team roles API (`GET/PUT /teams/:teamId/roles`). Role-based stage assignment via `stage_config.required_role`. Frontend: dynamic role selects, role management panel. |
| Multi-party sign-off | | `workflow_signoffs` table (both dialects). `StageConfig.validation` with `required_approvals`, `required_role`, `reject_action`. Engine validation gate in `advanceInternal`. `SubmitSignoff` engine method. Signoff HTTP API (`POST/GET /instances/:iid/signoffs`). Frontend signoff panel in monitor tab. |
| Extension lifecycle | | Design doc: `docs/DESIGN-EXTENSION-LIFECYCLE.md`. Permanent vs PoC classification, graduation criteria, install model. |
| Trigger composition | | Design doc: `docs/DESIGN-TRIGGER-COMPOSITION.md`. Triggers/schedules can start workflows, workflows emit events, no circular invocation. |
### v0.3.5 — Settings Audit + ICD + Tests

View File

@@ -0,0 +1,49 @@
# Extension Lifecycle: Permanent vs PoC
## Overview
Packages (extensions) fall into two categories: **permanent** and **PoC** (proof-of-concept).
This classification drives install-by-default behavior and maintenance expectations.
## Permanent Packages
Permanent packages are part of the platform kernel or essential surfaces. They are always
available and maintained by core contributors.
| Package | Reason |
|------------------|--------------------------------|
| admin | System administration surface |
| team-admin | Team management surface |
| settings | User settings surface |
| login | Authentication surface |
| welcome | Onboarding / empty-state |
## PoC Packages
Everything else in `packages/` is PoC. These demonstrate the extension model but are not
required for platform operation. Examples: tasks, schedules, dashboard, editor, git-board,
icd-test-runner, sdk-test-runner.
Retired builtins (csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer,
regex-tester) are PoC packages migrated from the kernel during the v0.2.9 retirement.
## Graduation Criteria
A PoC package may be promoted to permanent when all of the following are met:
1. **Active usage** — 3+ active users or teams depend on it.
2. **Test coverage** — At least basic smoke tests exist (Starlark or integration).
3. **No kernel special-casing** — The package operates entirely through the public extension
API. No `if pkg == "X"` branches in core.
4. **Documentation** — README with install instructions, config options, and screenshots.
5. **Maintenance owner** — A named contributor commits to reviewing issues.
## Deprecation
Packages that no longer meet graduation criteria (e.g., zero usage for 6 months) may be
archived. Archived packages remain installable but receive no updates.
## Install Model
All packages use explicit installation: `POST /api/v1/packages/:id/install`. There is no
auto-install. The kernel ships clean; teams choose what they need.

View File

@@ -0,0 +1,57 @@
# Trigger Composition: Triggers, Schedules, and Workflows
## Overview
Three automation primitives exist in the platform: **triggers** (event-driven), **schedules**
(time-driven), and **workflows** (multi-step state machines). This document defines how they
compose without creating circular dependencies.
## Composition Rules
### Triggers can start workflows
A trigger's Starlark handler may call `workflow.start(workflow_id, data)` to create a new
workflow instance. This is the primary entry point for event-driven workflow initiation.
Example: a `ticket.created` trigger fires and starts an approval workflow.
### Schedules can start workflows
A schedule's Starlark handler may also call `workflow.start(...)`. This enables time-based
batch processing workflows.
Example: a daily 9am schedule starts a standup collection workflow.
### Workflows emit events that triggers listen to
Workflow lifecycle events (`workflow.started`, `workflow.advanced`, `workflow.completed`,
`workflow.signoff`, etc.) are published on the event bus. Triggers may subscribe to these
events to perform side effects (notifications, external API calls, audit logging).
### Workflows cannot start triggers or schedules
Workflows do not directly invoke triggers or create schedules. Workflow Starlark hooks
operate within the workflow's own context and do not have access to trigger/schedule
management APIs. This prevents uncontrolled fan-out.
## Circular Invocation Guard
The automated stage cycle guard (max 10 consecutive automated stages) already prevents
infinite loops within a single workflow. Cross-workflow cycles are prevented by this rule:
**A workflow event handler (trigger) may start a new workflow instance, but the new instance
is not processed synchronously.** It enters the work queue as a separate execution context.
The bus does not re-enter the originating workflow's advance call.
This means:
- `workflow.completed` trigger → `workflow.start(B)` is allowed (async).
- Workflow A automated stage → `workflow.start(B)` where B immediately completes and
triggers A again → allowed but rate-limited by the cycle guard on each individual workflow.
## Not Supported (by design)
- Triggers cannot modify a running workflow instance (read-only access via Starlark builtins).
- Schedules cannot advance or cancel workflow instances directly.
- Workflows cannot create, modify, or delete triggers or schedules.
These restrictions keep the composition model simple and auditable.

View File

@@ -22,8 +22,7 @@ CREATE TABLE IF NOT EXISTS team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member'
CHECK (role IN ('admin', 'member')),
role VARCHAR(50) NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(team_id, user_id)
);

View File

@@ -131,3 +131,19 @@ CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status
ON workflow_assignments(team_id, status);
CREATE INDEX IF NOT EXISTS idx_workflow_assignments_assigned
ON workflow_assignments(assigned_to) WHERE assigned_to IS NOT NULL;
-- ── Workflow Signoffs (v0.3.4) ────────────────
CREATE TABLE IF NOT EXISTS workflow_signoffs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE,
stage TEXT NOT NULL,
user_id TEXT NOT NULL,
decision TEXT NOT NULL CHECK (decision IN ('approve', 'reject')),
comment TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(instance_id, stage, user_id)
);
CREATE INDEX IF NOT EXISTS idx_workflow_signoffs_instance_stage
ON workflow_signoffs(instance_id, stage);

View File

@@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS team_members (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')),
role TEXT NOT NULL DEFAULT 'member',
joined_at TEXT DEFAULT (datetime('now')),
UNIQUE(team_id, user_id)
);

View File

@@ -120,3 +120,19 @@ CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status
ON workflow_assignments(team_id, status);
CREATE INDEX IF NOT EXISTS idx_workflow_assignments_assigned
ON workflow_assignments(assigned_to);
-- ── Workflow Signoffs (v0.3.4) ────────────────
CREATE TABLE IF NOT EXISTS workflow_signoffs (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE,
stage TEXT NOT NULL,
user_id TEXT NOT NULL,
decision TEXT NOT NULL CHECK (decision IN ('approve', 'reject')),
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(instance_id, stage, user_id)
);
CREATE INDEX IF NOT EXISTS idx_workflow_signoffs_instance_stage
ON workflow_signoffs(instance_id, stage);

View File

@@ -256,7 +256,8 @@ func TruncateAll(t *testing.T) {
// HA
"rate_limit_counters",
"ws_tickets",
// Workflows (FK order: assignments → instances → versions → stages → workflows)
// Workflows (FK order: signoffs → assignments → instances → versions → stages → workflows)
"workflow_signoffs",
"workflow_assignments",
"workflow_instances",
"workflow_versions",

View File

@@ -67,6 +67,8 @@ var routeTable = map[string]Direction{
"workflow.error": DirToClient, // engine/hook error
"workflow.sla_breach": DirToClient, // SLA exceeded → team + admins (v0.3.3)
"workflow.stale": DirToClient, // instance marked stale (v0.3.3)
"workflow.signoff": DirToClient, // signoff submitted (v0.3.4)
"workflow.rejected": DirToClient, // rejection triggered cancel/reroute (v0.3.4)
// Plugin hooks — never cross the wire
"plugin.hook.": DirLocal,

View File

@@ -1,7 +1,10 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
@@ -29,11 +32,11 @@ type updateTeamRequest struct {
type addMemberRequest struct {
UserID string `json:"user_id" binding:"required"`
Role string `json:"role" binding:"required,oneof=admin member"`
Role string `json:"role" binding:"required,min=1,max=50"`
}
type updateMemberRequest struct {
Role string `json:"role" binding:"required,oneof=admin member"`
Role string `json:"role" binding:"required,min=1,max=50"`
}
// ── Handler ─────────────────────────────────
@@ -258,6 +261,12 @@ func (h *TeamHandler) AddMember(c *gin.Context) {
return
}
// Validate role against team's configured roles
if err := validateTeamRole(ctx, h.stores, teamID, req.Role); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
id, err := h.stores.Teams.AddMemberReturningID(ctx, teamID, req.UserID, req.Role)
if err != nil {
if database.IsUniqueViolation(err) {
@@ -286,6 +295,12 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
return
}
// Validate role against team's configured roles
if err := validateTeamRole(c.Request.Context(), h.stores, teamID, req.Role); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := h.stores.Teams.UpdateMemberRoleByID(c.Request.Context(), memberID, teamID, req.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
@@ -403,3 +418,101 @@ func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"actions": actions})
}
// ── Team Roles API (v0.3.4) ─────────────────
// builtinRoles are always present in every team's role list.
var builtinRoles = []string{"admin", "member"}
// ListRoles returns the team's configured roles (builtins + custom).
// GET /api/v1/teams/:teamId/roles
func (h *TeamHandler) ListRoles(c *gin.Context) {
teamID := getTeamID(c)
roles := getTeamRoles(c.Request.Context(), h.stores, teamID)
c.JSON(http.StatusOK, gin.H{"data": roles})
}
// UpdateRoles replaces the team's roles array. Builtins (admin, member) are always included.
// PUT /api/v1/teams/:teamId/roles
func (h *TeamHandler) UpdateRoles(c *gin.Context) {
teamID := getTeamID(c)
var body struct {
Roles []string `json:"roles" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Ensure builtins are present
roleSet := map[string]bool{}
for _, r := range builtinRoles {
roleSet[r] = true
}
for _, r := range body.Roles {
if r != "" && len(r) <= 50 {
roleSet[r] = true
}
}
roles := make([]string, 0, len(roleSet))
for r := range roleSet {
roles = append(roles, r)
}
rolesJSON, _ := json.Marshal(map[string]any{"roles": roles})
if err := h.stores.Teams.MergeSettings(c.Request.Context(), teamID, string(rolesJSON)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update roles"})
return
}
c.JSON(http.StatusOK, gin.H{"data": roles})
AuditLog(h.stores.Audit, c, "team.update_roles", "team", teamID, map[string]interface{}{"roles": roles})
}
// getTeamRoles reads the roles array from team settings, falling back to builtins.
func getTeamRoles(ctx context.Context, stores store.Stores, teamID string) []string {
team, err := stores.Teams.GetByID(ctx, teamID)
if err != nil || team.Settings == nil {
return builtinRoles
}
rolesRaw, ok := team.Settings["roles"]
if !ok {
return builtinRoles
}
// rolesRaw is []interface{} from JSONMap
arr, ok := rolesRaw.([]interface{})
if !ok {
return builtinRoles
}
roles := make([]string, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
roles = append(roles, s)
}
}
if len(roles) == 0 {
return builtinRoles
}
return roles
}
// validateTeamRole checks that a role string is valid for the given team.
func validateTeamRole(ctx context.Context, stores store.Stores, teamID, role string) error {
roles := getTeamRoles(ctx, stores, teamID)
for _, r := range roles {
if r == role {
return nil
}
}
// Also allow builtins unconditionally (in case settings are empty)
for _, r := range builtinRoles {
if r == role {
return nil
}
}
return fmt.Errorf("role %q is not configured for this team", role)
}

View File

@@ -30,12 +30,28 @@ func NewWorkflowAssignmentHandler(engine *workflow.Engine, stores store.Stores)
func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) {
userID := c.GetString("user_id")
assignmentID := c.Param("id")
ctx := c.Request.Context()
if err := h.stores.Workflows.ClaimAssignment(c.Request.Context(), assignmentID, userID); err != nil {
// Claim first, then verify role. Rollback if role check fails.
if err := h.stores.Workflows.ClaimAssignment(ctx, assignmentID, userID); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
// Role check: find the assignment in user's claimed list and verify role
claimed, _ := h.stores.Workflows.ListAssignmentsByUser(ctx, userID, models.AssignmentStatusClaimed)
for _, a := range claimed {
if a.ID == assignmentID {
if rerr := workflow.CheckClaimRole(ctx, h.stores, &a, userID); rerr != nil {
// Rollback: unclaim
h.stores.Workflows.UnclaimAssignment(ctx, assignmentID)
c.JSON(http.StatusForbidden, gin.H{"error": rerr.Error()})
return
}
break
}
}
c.JSON(http.StatusOK, gin.H{"claimed": true})
}

View File

@@ -0,0 +1,72 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
"switchboard-core/workflow"
)
// ── Signoff Handlers (v0.3.4) ─────────────────
// WorkflowSignoffHandler manages signoff HTTP endpoints.
type WorkflowSignoffHandler struct {
engine *workflow.Engine
stores store.Stores
}
// NewWorkflowSignoffHandler creates a signoff handler.
func NewWorkflowSignoffHandler(engine *workflow.Engine, stores store.Stores) *WorkflowSignoffHandler {
return &WorkflowSignoffHandler{engine: engine, stores: stores}
}
// Submit records a signoff (approve/reject) for the current stage of an instance.
// POST /api/v1/instances/:iid/signoffs
func (h *WorkflowSignoffHandler) Submit(c *gin.Context) {
userID := c.GetString("user_id")
instanceID := c.Param("iid")
var body struct {
Decision string `json:"decision" binding:"required,oneof=approve reject"`
Comment string `json:"comment"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
so, err := h.engine.SubmitSignoff(c.Request.Context(), instanceID, userID, body.Decision, body.Comment)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, so)
}
// List returns signoffs for the current stage of an instance.
// GET /api/v1/instances/:iid/signoffs
func (h *WorkflowSignoffHandler) List(c *gin.Context) {
instanceID := c.Param("iid")
// Get the instance to determine current stage
inst, err := h.stores.Workflows.GetInstance(c.Request.Context(), instanceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "instance not found"})
return
}
signoffs, err := h.stores.Workflows.ListSignoffs(c.Request.Context(), instanceID, inst.CurrentStage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
if signoffs == nil {
signoffs = []models.WorkflowSignoff{}
}
c.JSON(http.StatusOK, gin.H{"data": signoffs})
}

View File

@@ -762,3 +762,135 @@ func TestWorkflowAssignment_ListByTeam(t *testing.T) {
t.Errorf("teamB count = %d, want 1", len(listB))
}
}
// ── Signoff store tests (v0.3.4) ──────────────
func TestWorkflowSignoff_CreateAndList(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Signer", "signer@test.com")
teamID := database.SeedTestTeam(t, "Signoff Team", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID, WorkflowVersion: ver,
CurrentStage: stage1, StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
// Create two signoffs
so1 := &models.WorkflowSignoff{
InstanceID: inst.ID, Stage: stage1,
UserID: userID, Decision: models.SignoffApprove, Comment: "LGTM",
}
if err := s.Workflows.CreateSignoff(ctx, so1); err != nil {
t.Fatalf("create signoff: %v", err)
}
if so1.ID == "" {
t.Fatal("signoff ID not populated")
}
if so1.CreatedAt.IsZero() {
t.Fatal("signoff created_at not populated")
}
user2 := database.SeedTestUser(t, "Signer2", "signer2@test.com")
so2 := &models.WorkflowSignoff{
InstanceID: inst.ID, Stage: stage1,
UserID: user2, Decision: models.SignoffReject, Comment: "Needs work",
}
if err := s.Workflows.CreateSignoff(ctx, so2); err != nil {
t.Fatalf("create signoff 2: %v", err)
}
// List should return both, ordered by created_at
list, err := s.Workflows.ListSignoffs(ctx, inst.ID, stage1)
if err != nil {
t.Fatalf("list signoffs: %v", err)
}
if len(list) != 2 {
t.Fatalf("signoff count = %d, want 2", len(list))
}
if list[0].Decision != models.SignoffApprove {
t.Errorf("first signoff decision = %s, want approve", list[0].Decision)
}
if list[1].Decision != models.SignoffReject {
t.Errorf("second signoff decision = %s, want reject", list[1].Decision)
}
// UNIQUE constraint: same user + instance + stage should fail
dup := &models.WorkflowSignoff{
InstanceID: inst.ID, Stage: stage1,
UserID: userID, Decision: models.SignoffApprove,
}
if err := s.Workflows.CreateSignoff(ctx, dup); err == nil {
t.Fatal("expected UNIQUE violation for duplicate signoff, got nil")
}
}
func TestWorkflowSignoff_ListEmpty(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
// ListSignoffs on non-existent instance should return empty, not error
list, err := s.Workflows.ListSignoffs(ctx, "nonexistent", "stage")
if err != nil {
t.Fatalf("list signoffs error: %v", err)
}
if list != nil && len(list) != 0 {
t.Fatalf("expected empty list, got %d", len(list))
}
}
func TestWorkflowSignoff_Count(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Counter", "counter@test.com")
teamID := database.SeedTestTeam(t, "Count Team", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID, WorkflowVersion: ver,
CurrentStage: stage1, StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
// No signoffs yet
count, err := s.Workflows.CountSignoffs(ctx, inst.ID, stage1, models.SignoffApprove)
if err != nil {
t.Fatalf("count signoffs error: %v", err)
}
if count != 0 {
t.Fatalf("expected 0, got %d", count)
}
// Add 2 approvals and 1 rejection
user2 := database.SeedTestUser(t, "Counter2", "counter2@test.com")
user3 := database.SeedTestUser(t, "Counter3", "counter3@test.com")
s.Workflows.CreateSignoff(ctx, &models.WorkflowSignoff{
InstanceID: inst.ID, Stage: stage1, UserID: userID, Decision: models.SignoffApprove,
})
s.Workflows.CreateSignoff(ctx, &models.WorkflowSignoff{
InstanceID: inst.ID, Stage: stage1, UserID: user2, Decision: models.SignoffApprove,
})
s.Workflows.CreateSignoff(ctx, &models.WorkflowSignoff{
InstanceID: inst.ID, Stage: stage1, UserID: user3, Decision: models.SignoffReject,
})
approveCount, _ := s.Workflows.CountSignoffs(ctx, inst.ID, stage1, models.SignoffApprove)
if approveCount != 2 {
t.Errorf("approve count = %d, want 2", approveCount)
}
rejectCount, _ := s.Workflows.CountSignoffs(ctx, inst.ID, stage1, models.SignoffReject)
if rejectCount != 1 {
t.Errorf("reject count = %d, want 1", rejectCount)
}
}

View File

@@ -433,6 +433,11 @@ func main() {
protected.POST("/assignments/:id/cancel", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfAssignH.Cancel)
protected.GET("/assignments/mine", wfAssignH.ListMine)
// Workflow signoffs (v0.3.4)
wfSignoffH := handlers.NewWorkflowSignoffHandler(wfEngine, stores)
protected.POST("/instances/:iid/signoffs", wfSignoffH.Submit)
protected.GET("/instances/:iid/signoffs", wfSignoffH.List)
// Surface discovery (v0.25.0, v0.28.7: unified packages)
pkgH := handlers.NewPackageHandler(stores)
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
@@ -515,6 +520,8 @@ func main() {
teamScoped.POST("/members", teams.AddMember)
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
teamScoped.GET("/roles", teams.ListRoles)
teamScoped.PUT("/roles", teams.UpdateRoles)
// Team groups (team admins manage team-scoped groups)
teamScoped.GET("/groups", groupH.ListTeamGroups)
@@ -566,6 +573,11 @@ func main() {
teamWfAssignH := handlers.NewWorkflowAssignmentHandler(wfEngine, stores)
teamScoped.GET("/assignments", teamWfAssignH.ListByTeam)
// Team workflow signoffs (v0.3.4)
teamWfSignoffH := handlers.NewWorkflowSignoffHandler(wfEngine, stores)
teamScoped.POST("/instances/:iid/signoffs", teamWfSignoffH.Submit)
teamScoped.GET("/instances/:iid/signoffs", teamWfSignoffH.List)
}
// Public global settings (non-admin users can read safe subset)

View File

@@ -504,6 +504,25 @@ type WorkflowAssignment struct {
CreatedAt time.Time `json:"created_at"`
}
// ── Workflow Signoff (v0.3.4) ───────────────
// WorkflowSignoff records a user's approval or rejection at a stage boundary.
type WorkflowSignoff struct {
ID string `json:"id"`
InstanceID string `json:"instance_id"`
Stage string `json:"stage"`
UserID string `json:"user_id"`
Decision string `json:"decision"` // approve | reject
Comment string `json:"comment"`
CreatedAt time.Time `json:"created_at"`
}
// Signoff decision constants.
const (
SignoffApprove = "approve"
SignoffReject = "reject"
)
// ── Workflow Version (immutable snapshot) ───
// WorkflowVersion is an immutable snapshot of a workflow definition

View File

@@ -660,6 +660,48 @@ func (s *WorkflowStore) ListAssignmentsByUser(ctx context.Context, userID string
return s.queryAssignments(ctx, q, args...)
}
// ── Signoffs (v0.3.4) ─────────────────────────
func (s *WorkflowStore) CreateSignoff(ctx context.Context, so *models.WorkflowSignoff) error {
return DB.QueryRowContext(ctx, `
INSERT INTO workflow_signoffs (instance_id, stage, user_id, decision, comment)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at`,
so.InstanceID, so.Stage, so.UserID, so.Decision, so.Comment,
).Scan(&so.ID, &so.CreatedAt)
}
func (s *WorkflowStore) ListSignoffs(ctx context.Context, instanceID, stage string) ([]models.WorkflowSignoff, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, instance_id, stage, user_id, decision, comment, created_at
FROM workflow_signoffs
WHERE instance_id = $1 AND stage = $2
ORDER BY created_at`, instanceID, stage)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.WorkflowSignoff
for rows.Next() {
var so models.WorkflowSignoff
if err := rows.Scan(&so.ID, &so.InstanceID, &so.Stage, &so.UserID,
&so.Decision, &so.Comment, &so.CreatedAt); err != nil {
return nil, err
}
result = append(result, so)
}
return result, rows.Err()
}
func (s *WorkflowStore) CountSignoffs(ctx context.Context, instanceID, stage, decision string) (int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM workflow_signoffs
WHERE instance_id = $1 AND stage = $2 AND decision = $3`,
instanceID, stage, decision).Scan(&count)
return count, err
}
func (s *WorkflowStore) queryAssignments(ctx context.Context, q string, args ...interface{}) ([]models.WorkflowAssignment, error) {
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {

View File

@@ -686,6 +686,50 @@ func (s *WorkflowStore) ListAssignmentsByUser(ctx context.Context, userID string
return s.queryAssignments(ctx, q, args...)
}
// ── Signoffs (v0.3.4) ─────────────────────────
func (s *WorkflowStore) CreateSignoff(ctx context.Context, so *models.WorkflowSignoff) error {
so.ID = store.NewID()
so.CreatedAt = time.Now().UTC()
_, err := DB.ExecContext(ctx, `
INSERT INTO workflow_signoffs (id, instance_id, stage, user_id, decision, comment, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
so.ID, so.InstanceID, so.Stage, so.UserID, so.Decision, so.Comment,
so.CreatedAt.Format(timeFmt))
return err
}
func (s *WorkflowStore) ListSignoffs(ctx context.Context, instanceID, stage string) ([]models.WorkflowSignoff, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, instance_id, stage, user_id, decision, comment, created_at
FROM workflow_signoffs
WHERE instance_id = ? AND stage = ?
ORDER BY created_at`, instanceID, stage)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.WorkflowSignoff
for rows.Next() {
var so models.WorkflowSignoff
if err := rows.Scan(&so.ID, &so.InstanceID, &so.Stage, &so.UserID,
&so.Decision, &so.Comment, st(&so.CreatedAt)); err != nil {
return nil, err
}
result = append(result, so)
}
return result, rows.Err()
}
func (s *WorkflowStore) CountSignoffs(ctx context.Context, instanceID, stage, decision string) (int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM workflow_signoffs
WHERE instance_id = ? AND stage = ? AND decision = ?`,
instanceID, stage, decision).Scan(&count)
return count, err
}
func (s *WorkflowStore) queryAssignments(ctx context.Context, q string, args ...interface{}) ([]models.WorkflowAssignment, error) {
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {

View File

@@ -52,4 +52,9 @@ type WorkflowStore interface {
ListAssignmentsByTeam(ctx context.Context, teamID string, status string) ([]models.WorkflowAssignment, error)
ListAssignmentsByInstance(ctx context.Context, instanceID string) ([]models.WorkflowAssignment, error)
ListAssignmentsByUser(ctx context.Context, userID string, status string) ([]models.WorkflowAssignment, error)
// Signoffs (v0.3.4)
CreateSignoff(ctx context.Context, s *models.WorkflowSignoff) error
ListSignoffs(ctx context.Context, instanceID, stage string) ([]models.WorkflowSignoff, error)
CountSignoffs(ctx context.Context, instanceID, stage, decision string) (int, error)
}

View File

@@ -139,6 +139,44 @@ func (e *Engine) advanceInternal(ctx context.Context, instanceID string, stageDa
}
currentStage := stages[currentOrdinal]
// ── Validation gate (v0.3.4) ──────────────
sc := ParseStageConfig(currentStage.StageConfig)
if sc.Validation != nil && sc.Validation.RequiredApprovals > 0 {
// Check for rejections first
rejectCount, _ := e.stores.Workflows.CountSignoffs(ctx, instanceID, currentStage.Name, models.SignoffReject)
if rejectCount > 0 {
action := sc.Validation.RejectAction
if action == "" || action == "cancel" {
_ = e.Cancel(ctx, instanceID, userID)
e.emit(ctx, "workflow.rejected", instanceID, map[string]any{
"instance_id": instanceID,
"stage": currentStage.Name,
})
return e.stores.Workflows.GetInstance(ctx, instanceID)
}
// Reroute to named stage
target, rerr := ResolveStageByName(stages, action)
if rerr != nil {
return nil, fmt.Errorf("reject_action stage %q not found: %w", action, rerr)
}
merged := mergeJSON(inst.StageData, stageData)
if err := e.stores.Workflows.AdvanceStage(ctx, instanceID, stages[target].Name, merged); err != nil {
return nil, fmt.Errorf("reject reroute: %w", err)
}
e.emit(ctx, "workflow.rejected", instanceID, map[string]any{
"instance_id": instanceID,
"stage": currentStage.Name,
"rerouted_to": stages[target].Name,
})
return e.stores.Workflows.GetInstance(ctx, instanceID)
}
approveCount, _ := e.stores.Workflows.CountSignoffs(ctx, instanceID, currentStage.Name, models.SignoffApprove)
if approveCount < sc.Validation.RequiredApprovals {
return nil, fmt.Errorf("insufficient approvals (%d of %d required)", approveCount, sc.Validation.RequiredApprovals)
}
}
// Merge stage data
merged := mergeJSON(inst.StageData, stageData)
@@ -301,6 +339,112 @@ func (e *Engine) AdvancePublic(ctx context.Context, entryToken string, stageData
return e.advanceInternal(ctx, inst.ID, stageData, inst.StartedBy, 0)
}
// ── Signoffs (v0.3.4) ─────────────────────────
// SubmitSignoff records a user's approval or rejection at the current stage boundary.
func (e *Engine) SubmitSignoff(ctx context.Context, instanceID, userID, decision, comment string) (*models.WorkflowSignoff, error) {
inst, err := e.stores.Workflows.GetInstance(ctx, instanceID)
if err != nil {
return nil, fmt.Errorf("instance not found: %w", err)
}
if inst.Status != models.InstanceStatusActive {
return nil, fmt.Errorf("instance is %s, not active", inst.Status)
}
// Load version snapshot to get current stage config
ver, err := e.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion)
if err != nil {
return nil, fmt.Errorf("version not found: %w", err)
}
var stages []models.WorkflowStage
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
return nil, fmt.Errorf("corrupt snapshot: %w", err)
}
// Find current stage
var currentStage *models.WorkflowStage
for i := range stages {
if stages[i].Name == inst.CurrentStage {
currentStage = &stages[i]
break
}
}
if currentStage == nil {
return nil, fmt.Errorf("current stage %q not found", inst.CurrentStage)
}
sc := ParseStageConfig(currentStage.StageConfig)
if sc.Validation == nil || sc.Validation.RequiredApprovals == 0 {
return nil, fmt.Errorf("stage %q does not require signoffs", currentStage.Name)
}
// Role check: if validation has a required_role, verify the user has it
if sc.Validation.RequiredRole != "" && currentStage.AssignmentTeamID != nil {
member, merr := e.stores.Teams.GetMember(ctx, *currentStage.AssignmentTeamID, userID)
if merr != nil {
return nil, fmt.Errorf("user is not a member of the assignment team")
}
if member.Role != sc.Validation.RequiredRole {
return nil, fmt.Errorf("role %q required to sign off (you have %q)", sc.Validation.RequiredRole, member.Role)
}
}
so := &models.WorkflowSignoff{
InstanceID: instanceID,
Stage: inst.CurrentStage,
UserID: userID,
Decision: decision,
Comment: comment,
}
if err := e.stores.Workflows.CreateSignoff(ctx, so); err != nil {
return nil, fmt.Errorf("create signoff: %w", err)
}
e.emit(ctx, "workflow.signoff", instanceID, map[string]any{
"instance_id": instanceID,
"stage": inst.CurrentStage,
"user_id": userID,
"decision": decision,
})
return so, nil
}
// CheckClaimRole verifies that a user has the required_role (from stage_config)
// to claim an assignment. Returns nil if allowed, error if forbidden.
func CheckClaimRole(ctx context.Context, stores store.Stores, assignment *models.WorkflowAssignment, userID string) error {
inst, err := stores.Workflows.GetInstance(ctx, assignment.InstanceID)
if err != nil {
return nil // can't verify — allow claim
}
ver, err := stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion)
if err != nil {
return nil
}
var stages []models.WorkflowStage
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
return nil
}
for _, s := range stages {
if s.Name == assignment.Stage {
sc := ParseStageConfig(s.StageConfig)
if sc.RequiredRole == "" {
return nil // no role restriction
}
member, merr := stores.Teams.GetMember(ctx, assignment.TeamID, userID)
if merr != nil {
return fmt.Errorf("not a member of team")
}
if member.Role != sc.RequiredRole {
return fmt.Errorf("role %q required for this stage (you have %q)", sc.RequiredRole, member.Role)
}
return nil
}
}
return nil // stage not found in snapshot — allow
}
// emit publishes an event on the bus.
func (e *Engine) emit(_ context.Context, label, room string, payload map[string]any) {
if e.bus == nil {

View File

@@ -24,8 +24,28 @@ type Condition struct {
// StageConfig holds non-routing config from stage_config JSON.
type StageConfig struct {
AutoAssign string `json:"auto_assign,omitempty"`
OnAdvance *OnAdvanceReference `json:"on_advance,omitempty"`
AutoAssign string `json:"auto_assign,omitempty"`
RequiredRole string `json:"required_role,omitempty"`
Validation *ValidationConfig `json:"validation,omitempty"`
OnAdvance *OnAdvanceReference `json:"on_advance,omitempty"`
}
// ValidationConfig defines multi-party sign-off requirements for a stage.
type ValidationConfig struct {
RequiredApprovals int `json:"required_approvals"` // e.g. 2
RequiredRole string `json:"required_role,omitempty"` // role needed to sign off
RejectAction string `json:"reject_action,omitempty"` // "cancel" | stage name
}
// ParseStageConfig unmarshals a stage_config JSON blob into StageConfig.
// Returns zero-value StageConfig on empty/invalid input.
func ParseStageConfig(raw json.RawMessage) StageConfig {
var sc StageConfig
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
return sc
}
_ = json.Unmarshal(raw, &sc)
return sc
}
// OnAdvanceReference points to a Starlark hook for on_advance processing.

View File

@@ -1,5 +1,6 @@
/**
* Team Admin > Members
* v0.3.4: custom team roles support
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
@@ -9,6 +10,16 @@ export default function MembersSection({ teamId }) {
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [users, setUsers] = useState([]);
const [roles, setRoles] = useState(['admin', 'member']);
const [showRoles, setShowRoles] = useState(false);
const [newRole, setNewRole] = useState('');
const loadRoles = useCallback(async () => {
try {
const resp = await sw.api.get(`/teams/${teamId}/roles`);
setRoles(resp.data || ['admin', 'member']);
} catch { setRoles(['admin', 'member']); }
}, [teamId]);
const load = useCallback(async () => {
try {
@@ -18,7 +29,7 @@ export default function MembersSection({ teamId }) {
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
useEffect(() => { load(); loadRoles(); }, [load, loadRoles]);
async function openAdd() {
setShowAdd(true);
@@ -83,8 +94,7 @@ export default function MembersSection({ teamId }) {
</div>
<div class="form-group"><label>Role</label>
<select name="role">
<option value="member">Member</option>
<option value="admin">Admin</option>
${roles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
</div>
</div>
@@ -102,13 +112,58 @@ export default function MembersSection({ teamId }) {
<strong style="flex:1;">${m.username || m.user_id}</strong>
<select value=${m.role || 'member'} style="width:100px;"
onChange=${e => updateRole(m.id || m.user_id, e.target.value)}>
<option value="member">Member</option>
<option value="admin">Admin</option>
${roles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
<button class="btn-small btn-danger" onClick=${() => removeMember(m.id || m.user_id)}>Remove</button>
</div>
`)}
</div>
<div style="margin-top:24px;border-top:1px solid var(--border-1);padding-top:12px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<h4 style="margin:0;flex:1;">Team Roles</h4>
<button class="btn-small" onClick=${() => setShowRoles(!showRoles)}>
${showRoles ? 'Hide' : 'Manage'}
</button>
</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;">
${roles.map(r => html`<span key=${r} class="badge">${r}</span>`)}
</div>
${showRoles && html`
<div style="display:flex;gap:8px;align-items:center;">
<input value=${newRole} onInput=${e => setNewRole(e.target.value)}
placeholder="New role name" style="width:150px;" />
<button class="btn-small btn-primary" onClick=${async () => {
if (!newRole.trim()) return;
const updated = [...new Set([...roles, newRole.trim()])];
try {
await sw.api.put('/teams/' + teamId + '/roles', { roles: updated });
setRoles(updated);
setNewRole('');
sw.toast('Role added', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}}>Add Role</button>
</div>
<div class="admin-list" style="margin-top:8px;">
${roles.filter(r => r !== 'admin' && r !== 'member').map(r => html`
<div class="admin-surface-row" key=${r}>
<span style="flex:1;">${r}</span>
<button class="btn-small btn-danger" onClick=${async () => {
const updated = roles.filter(x => x !== r);
try {
await sw.api.put('/teams/' + teamId + '/roles', { roles: updated });
setRoles(updated);
sw.toast('Role removed', 'success');
load(); // re-check members
} catch (e) { sw.toast(e.message, 'error'); }
}}>Remove</button>
</div>
`)}
${roles.filter(r => r !== 'admin' && r !== 'member').length === 0 &&
html`<div class="empty-hint">No custom roles</div>`}
</div>
`}
</div>
</div>
`;
}

View File

@@ -288,7 +288,29 @@ function StageForm({ stage, teams, onSave, onCancel }) {
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
const [sla, setSla] = useState(stage?.sla_seconds || '');
// v0.3.4: stage_config fields
const sc = stage?.stage_config ? (typeof stage.stage_config === 'string' ? JSON.parse(stage.stage_config || '{}') : stage.stage_config) : {};
const [requiredRole, setRequiredRole] = useState(sc.required_role || '');
const [valApprovals, setValApprovals] = useState(sc.validation?.required_approvals || '');
const [valRole, setValRole] = useState(sc.validation?.required_role || '');
const [valReject, setValReject] = useState(sc.validation?.reject_action || 'cancel');
const [teamRoles, setTeamRoles] = useState(['admin', 'member']);
useEffect(() => {
if (!assignTeam) return;
sw.api.get(`/teams/${assignTeam}/roles`).then(r => setTeamRoles(r.data || ['admin', 'member'])).catch(() => {});
}, [assignTeam]);
function submit() {
const stageConfig = {};
if (requiredRole) stageConfig.required_role = requiredRole;
if (valApprovals) {
stageConfig.validation = {
required_approvals: parseInt(valApprovals, 10),
...(valRole ? { required_role: valRole } : {}),
reject_action: valReject || 'cancel',
};
}
onSave({
name,
stage_mode: mode,
@@ -298,6 +320,7 @@ function StageForm({ stage, teams, onSave, onCancel }) {
assignment_team_id: assignTeam || null,
auto_transition: autoTransition,
sla_seconds: sla ? parseInt(sla, 10) : null,
stage_config: Object.keys(stageConfig).length ? stageConfig : {},
});
}
@@ -351,6 +374,40 @@ function StageForm({ stage, teams, onSave, onCancel }) {
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
</div>
</div>
${assignTeam && html`
<div class="form-row">
<div class="form-group">
<label>Required Role (claim)</label>
<select value=${requiredRole} onChange=${e => setRequiredRole(e.target.value)}>
<option value="">\u2014 any member \u2014</option>
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
</div>
</div>
<div style="margin-top:8px;padding:8px;border:1px dashed var(--border-1);border-radius:4px;">
<strong style="font-size:12px;">Multi-party Validation</strong>
<div class="form-row" style="margin-top:4px;">
<div class="form-group">
<label>Required Approvals</label>
<input type="number" min="0" value=${valApprovals}
onInput=${e => setValApprovals(e.target.value)} placeholder="0 = none" />
</div>
<div class="form-group">
<label>Signoff Role</label>
<select value=${valRole} onChange=${e => setValRole(e.target.value)}>
<option value="">\u2014 any member \u2014</option>
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
</div>
<div class="form-group">
<label>On Reject</label>
<select value=${valReject} onChange=${e => setValReject(e.target.value)}>
<option value="cancel">Cancel instance</option>
</select>
</div>
</div>
</div>
`}
<label class="toggle-label">
<input type="checkbox" checked=${autoTransition} onChange=${e => setAutoTransition(e.target.checked)} />
<span class="toggle-track"></span><span>Auto-advance when complete</span>
@@ -465,6 +522,7 @@ function AssignmentsTab({ teamId }) {
function MonitorTab({ teamId }) {
const [instances, setInstances] = useState([]);
const [loading, setLoading] = useState(true);
const [expandedSignoff, setExpandedSignoff] = useState(null);
const load = useCallback(async () => {
try {
@@ -501,10 +559,13 @@ function MonitorTab({ teamId }) {
<span class="text-muted" style="font-size:11px;">Age: ${_formatDuration(inst.age_seconds)}</span>
</div>
<div style="display:flex;gap:4px;">
<button class="btn-small"
onClick=${() => setExpandedSignoff(expandedSignoff === inst.id ? null : inst.id)}>Signoffs</button>
<button class="btn-small"
onClick=${() => { location.href = sw.base + '/w/' + inst.channel_id; }}>Open</button>
<button class="btn-small btn-danger" onClick=${() => cancelInstance(inst.channel_id)}>Cancel</button>
</div>
${expandedSignoff === inst.id && html`<${SignoffPanel} instanceId=${inst.id} teamId=${teamId} />`}
</div>
`)}
${instances.length === 0 && html`<div class="empty-hint">No active instances</div>`}
@@ -512,3 +573,61 @@ function MonitorTab({ teamId }) {
</div>
`;
}
// ── Signoff Panel (v0.3.4) ──────────────────
function SignoffPanel({ instanceId, teamId }) {
const [signoffs, setSignoffs] = useState([]);
const [loading, setLoading] = useState(true);
const [comment, setComment] = useState('');
const load = useCallback(async () => {
try {
const resp = await sw.api.get(`/instances/${instanceId}/signoffs`);
setSignoffs(resp.data || []);
} catch { setSignoffs([]); }
finally { setLoading(false); }
}, [instanceId]);
useEffect(() => { load(); }, [load]);
async function submit(decision) {
try {
await sw.api.post(`/instances/${instanceId}/signoffs`, { decision, comment });
sw.toast(decision === 'approve' ? 'Approved' : 'Rejected', 'success');
setComment('');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<span class="text-muted">Loading\u2026</span>`;
const approvals = signoffs.filter(s => s.decision === 'approve').length;
const rejections = signoffs.filter(s => s.decision === 'reject').length;
return html`
<div style="margin-top:8px;padding:8px;border:1px solid var(--border-1);border-radius:4px;font-size:12px;">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
<span>${approvals} approve${approvals !== 1 ? 's' : ''}</span>
${rejections > 0 && html`<span style="color:var(--danger);">${rejections} reject${rejections !== 1 ? 's' : ''}</span>`}
</div>
<div style="display:flex;gap:4px;align-items:center;">
<input value=${comment} onInput=${e => setComment(e.target.value)}
placeholder="Comment (optional)" style="flex:1;font-size:12px;" />
<button class="btn-small btn-success" onClick=${() => submit('approve')}>Approve</button>
<button class="btn-small btn-danger" onClick=${() => submit('reject')}>Reject</button>
</div>
${signoffs.length > 0 && html`
<div style="margin-top:6px;">
${signoffs.map(s => html`
<div key=${s.id} style="display:flex;gap:4px;align-items:center;font-size:11px;margin-top:2px;">
<span class="badge ${s.decision === 'approve' ? 'badge-success' : 'badge-danger'}">${s.decision}</span>
<span>${s.user_id}</span>
${s.comment && html`<span class="text-muted">\u2014 ${s.comment}</span>`}
</div>
`)}
</div>
`}
</div>
`;
}