Files
synapbus/internal/reactions/model.go
T
Algis DumbrisandClaude Opus 4.6 e51adc376e feat: message reactions and workflow states (010-reactions-workflows)
Add typed reactions (approve/reject/in_progress/done/published) with
toggle semantics. Workflow state derived from highest-priority reaction.
New reactions package with model, SQLite store, and service layer.

REST API: POST/GET/DELETE /api/messages/{id}/reactions for toggle/query,
PUT /api/channels/{name}/settings for workflow config, GET by-state
endpoint for listing messages by workflow state.

MCP: react/unreact/get_reactions/list_by_state actions via bridge.

Web UI: WorkflowBadge (colored state pills) and ReactionPills (toggle
pills with agent names) components integrated into channel view.

Channel settings: auto_approve, stalemate_remind_after,
stalemate_escalate_after columns. CLI: channels update command.

Migration 013_reactions.sql adds message_reactions table and channel
workflow columns. 29+ new test cases across model and store.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:30:06 +02:00

114 lines
3.0 KiB
Go

// Package reactions provides message reaction types, storage, and workflow state logic.
package reactions
import (
"encoding/json"
"errors"
"time"
)
// Valid reaction types.
const (
ReactionApprove = "approve"
ReactionReject = "reject"
ReactionInProgress = "in_progress"
ReactionDone = "done"
ReactionPublished = "published"
)
// Workflow states (derived from reactions).
const (
StateProposed = "proposed"
StateApproved = "approved"
StateInProgress = "in_progress"
StateRejected = "rejected"
StateDone = "done"
StatePublished = "published"
)
// reactionPriority maps reaction types to their priority for state derivation.
// Higher number = higher priority = wins for badge display.
var reactionPriority = map[string]int{
ReactionApprove: 2,
ReactionInProgress: 3,
ReactionReject: 4,
ReactionDone: 5,
ReactionPublished: 6,
}
// reactionToState maps reaction types to workflow states.
var reactionToState = map[string]string{
ReactionApprove: StateApproved,
ReactionReject: StateRejected,
ReactionInProgress: StateInProgress,
ReactionDone: StateDone,
ReactionPublished: StatePublished,
}
// TerminalStates are states that should not trigger stalemate checks.
var TerminalStates = map[string]bool{
StateRejected: true,
StateDone: true,
StatePublished: true,
}
// MaxReactionsPerMessage is the safety limit.
const MaxReactionsPerMessage = 100
// Sentinel errors.
var (
ErrInvalidReaction = errors.New("invalid reaction type: must be one of approve, reject, in_progress, done, published")
ErrReactionLimit = errors.New("maximum reactions per message (100) reached")
ErrNotMember = errors.New("only channel members can react to messages")
)
// Reaction represents a single reaction on a message.
type Reaction struct {
ID int64 `json:"id"`
MessageID int64 `json:"message_id"`
AgentName string `json:"agent_name"`
Reaction string `json:"reaction"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
}
// ValidReactions is the set of allowed reaction types.
var ValidReactions = map[string]bool{
ReactionApprove: true,
ReactionReject: true,
ReactionInProgress: true,
ReactionDone: true,
ReactionPublished: true,
}
// IsValidReaction returns true if the reaction type is valid.
func IsValidReaction(r string) bool {
return ValidReactions[r]
}
// ComputeWorkflowState derives the workflow state from a list of reactions.
// Returns "proposed" if no reactions exist.
func ComputeWorkflowState(reactions []*Reaction) string {
if len(reactions) == 0 {
return StateProposed
}
highestPriority := 0
highestState := StateProposed
for _, r := range reactions {
p, ok := reactionPriority[r.Reaction]
if ok && p > highestPriority {
highestPriority = p
highestState = reactionToState[r.Reaction]
}
}
return highestState
}
// IsTerminalState returns true if the state should not trigger stalemate checks.
func IsTerminalState(state string) bool {
return TerminalStates[state]
}