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>
114 lines
3.0 KiB
Go
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]
|
|
}
|