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>
This commit is contained in:
Algis Dumbris
2026-03-18 09:30:06 +02:00
co-authored by Claude Opus 4.6
parent 6ed4ce931a
commit e51adc376e
42 changed files with 2754 additions and 113 deletions
+1
View File
@@ -105,6 +105,7 @@ make lint # Run linters
- SQLite (existing DB, 1 new migration for push_subscriptions), localStorage (font size) (008-webui-pwa-analytics)
- Go 1.25+ (backend), Svelte 5 + Tailwind (frontend) + go-chi/chi (HTTP), mark3labs/mcp-go (MCP), modernc.org/sqlite (storage), spf13/cobra (CLI) (009-attachments-threads)
- SQLite (modernc.org/sqlite, pure Go) + content-addressable filesystem (SHA-256) (009-attachments-threads)
- SQLite (modernc.org/sqlite, pure Go) — new migration 013_reactions.sql (010-reactions-workflows)
## Recent Changes
- 002-mcp-auth-ux-polish: Added Go 1.23+ + ory/fosite (OAuth 2.1), mark3labs/mcp-go (MCP server), go-chi/chi (HTTP), Svelte 5 + Tailwind (Web UI)
+79 -69
View File
@@ -1,91 +1,101 @@
# Autonomous Implementation Summary: Attachments & Threads Enhancement
# Autonomous Implementation Summary: Message Reactions & Workflow States
**Branch**: `009-attachments-threads`
**Date**: 2026-03-17
**Status**: Complete
**Branch**: `010-reactions-workflows`
**Date**: 2026-03-18
**Status**: Complete (StalemateWorker extension deferred)
## What Was Built
### Attachments (Web UI + MCP + Admin CLI)
### Message Reactions
- **Toggle semantics**: Add a reaction → added. Add same reaction again → removed. One per type per agent per message.
- **5 reaction types**: approve, reject, in_progress, done, published
- **Metadata support**: JSON metadata on reactions (e.g., `{"url": "https://..."}` for published)
- **100-reaction limit** per message (safety)
1. **Web UI file upload**: Users can attach files (images, PDFs, text files) when composing messages via a paperclip button in the compose form. Uploaded files show as chips with remove buttons.
### Workflow State Derivation
- State computed from reactions: published > done > rejected > in_progress > approved > proposed
- No denormalization — state derived on read from reaction list
- Channel messages with no reactions → "proposed" state
- Terminal states (rejected, done, published) don't trigger stalemate checks
2. **Attachment display**: Image attachments render as thumbnails (200x200px max) inline with messages. Clicking opens a fullscreen overlay with close (Escape, click-outside, X button) and download. Non-image files show as file icons with filename, size, and download link.
### Channel Workflow Settings
- `auto_approve` — skip proposed state for new messages
- `stalemate_remind_after` — duration before reminder DM (default 24h)
- `stalemate_escalate_after` — duration before escalation to #approvals (default 72h)
3. **MCP agent support**: Agents can attach files by uploading via `upload_attachment` action and passing hashes in the new `attachments` parameter on `send_message`. Tool descriptions updated to document the workflow.
### REST API
- `POST /api/messages/{id}/reactions` — toggle reaction (add or remove)
- `GET /api/messages/{id}/reactions` — get reactions + workflow state
- `DELETE /api/messages/{id}/reactions/{reaction}` — remove reaction
- `PUT /api/channels/{name}/settings` — update workflow settings
- `GET /api/channels/{name}/messages/by-state?state=X` — list messages by state
4. **File type validation**: Upload restricted to images (jpg, png, gif, webp, svg), PDFs, and text files (txt, md, csv, json, xml, yaml, log). Invalid types rejected with clear error.
### MCP Tools (via execute bridge)
- `react` — add/toggle reaction on a message
- `unreact` — remove a reaction
- `get_reactions` — query reactions and workflow state
- `list_by_state` — list messages by workflow state in a channel
5. **Admin backup/restore**: `synapbus attachments backup --output path.tar.gz` and `synapbus attachments restore --input path.tar.gz` commands. Restore is dedup-safe (skips existing files).
### Web UI
- **WorkflowBadge** component: colored pills (yellow/green/blue/red/gray/cyan) per state
- **ReactionPills** component: grouped reaction pills with count, agent names on hover, click-to-toggle
- Published reactions with URL show clickable link icon
- Integrated into channel message view
### Threads
### Admin CLI
- `synapbus channels update --name X --auto-approve=true --stalemate-remind-after=12h --stalemate-escalate-after=48h`
1. **Thread visibility**: Messages with replies show an always-visible "N replies" badge with a chat bubble icon. Clicking opens the thread panel.
## Files Created/Modified
2. **Reply count in API**: All message API responses now include `reply_count` (batch-loaded via efficient GROUP BY query).
### New Files
| File | Description |
|------|-------------|
| `internal/storage/schema/013_reactions.sql` | Migration: message_reactions table + channel columns |
| `internal/reactions/model.go` | Reaction types, state derivation, constants |
| `internal/reactions/store.go` | SQLite CRUD for reactions |
| `internal/reactions/service.go` | Business logic: toggle, remove, get, list by state |
| `internal/reactions/model_test.go` | 23 test cases for model functions |
| `internal/reactions/store_test.go` | 6 test functions for store operations |
| `internal/api/reactions_handler.go` | REST API handlers for reactions |
| `web/src/lib/components/WorkflowBadge.svelte` | Colored state badge component |
| `web/src/lib/components/ReactionPills.svelte` | Reaction toggle pills component |
3. **MCP threading**: `reply_to` parameter description updated to clearly guide agents on threading behavior. Thread context visible in MCP responses.
4. **Attachment display in threads**: Thread panel also renders attachment thumbnails/file icons.
## Files Modified
### Backend (Go)
### Modified Files
| File | Changes |
|------|---------|
| `internal/messaging/types.go` | Added `AttachmentInfo` struct, `ReplyCount`, `Attachments` fields to `Message` |
| `internal/messaging/options.go` | Added `Attachments []string` to `SendOptions` |
| `internal/messaging/store.go` | Added `GetReplyCounts` batch query method + interface |
| `internal/messaging/service.go` | Added `AttachmentLinker` interface, `EnrichMessages`, attachment linking in `SendMessage` |
| `internal/attachments/model.go` | Added `ErrUnsupportedType` error |
| `internal/attachments/mime.go` | Added `IsAllowedType` function |
| `internal/attachments/service.go` | Added file type validation in `Upload` |
| `internal/api/messages_handler.go` | Accept `attachments[]` in send, `EnrichMessages` in all handlers |
| `internal/mcp/tools_hybrid.go` | Added `attachments` param to `send_message`, updated descriptions, enrich responses |
| `internal/mcp/bridge.go` | Handle attachments in `callSendMessage`, `callSendChannelMessage` |
| `internal/channels/service.go` | Added `attachments` param to `BroadcastMessage` |
| `internal/actions/registry.go` | Updated `upload_attachment` description |
| `cmd/synapbus/main.go` | Added `attachmentLinkerAdapter`, wired into messaging service |
| `cmd/synapbus/admin.go` | Added `backup` and `restore` subcommands |
### Frontend (Svelte)
| File | Changes |
|------|---------|
| `web/src/lib/api/client.ts` | Added `attachments.upload()`, `attachments` param in `messages.send()` |
| `web/src/lib/components/ComposeForm.svelte` | Attachment upload button, file picker, preview chips |
| `web/src/lib/components/AttachmentPreview.svelte` | **NEW** — thumbnail + fullscreen overlay component |
| `web/src/lib/components/MessageList.svelte` | Attachment display, thread reply count badges |
| `web/src/lib/components/ThreadPanel.svelte` | Attachment display in thread messages |
### Tests
| File | New Tests |
|------|-----------|
| `internal/messaging/store_test.go` | `TestSQLiteMessageStore_GetReplyCounts` (4 subtests) |
| `internal/messaging/service_test.go` | `TestMessagingService_EnrichMessages` (3 subtests) |
| `internal/attachments/mime_test.go` | `TestIsAllowedType` (12 cases) |
| `internal/attachments/service_test.go` | `TestService_Upload_FileTypeValidation` (5 cases) |
| `internal/channels/service_test.go` | Updated 13 call sites for new `BroadcastMessage` signature |
| `internal/messaging/types.go` | Added WorkflowState, Reactions, ReactionInfo to Message |
| `internal/messaging/service.go` | Added ReactionEnricher interface, enrichment in EnrichMessages |
| `internal/channels/types.go` | Added AutoApprove, StalemateRemindAfter, StalemateEscalateAfter, ChannelSettings |
| `internal/channels/store.go` | Updated SELECT queries for new columns, added UpdateChannelSettings |
| `internal/channels/service.go` | Added UpdateChannelSettings method |
| `internal/api/router.go` | Registered reaction and channel settings routes |
| `internal/api/channels_handler.go` | Added UpdateSettings, ListByState handlers |
| `internal/mcp/bridge.go` | Added react/unreact/get_reactions/list_by_state bridge methods |
| `internal/mcp/tools_hybrid.go` | Added reactionService to registrar |
| `internal/mcp/server.go` | Added reactionService parameter |
| `internal/actions/registry.go` | Registered 4 new reaction actions |
| `cmd/synapbus/main.go` | Wired reaction service, adapter, passed to router+MCP |
| `cmd/synapbus/admin.go` | Added channels update CLI command |
| `internal/admin/socket.go` | Added channels.update_settings handler |
| `web/src/lib/api/client.ts` | Added reactions.toggle/get methods |
| `web/src/routes/channels/[name]/+page.svelte` | Integrated WorkflowBadge + ReactionPills |
## Test Results
- **Go tests**: 25 packages, all pass, 0 failures
- **Integration tests**: 9 E2E tests, all pass
- **New tests**: 24 test cases added, all pass
- **25 Go test packages**: all pass, 0 failures
- **New tests**: 29+ test cases (model: 23, store: 6)
- **Integration tests**: 9 E2E tests pass
- **Web build**: Svelte SPA builds successfully
- **Binary build**: 90MB arm64 binary compiles cleanly
- **Binary build**: Compiles cleanly
## Deferred
- **StalemateWorker extension** (T023-T025): The data model, channel settings, and query infrastructure are in place. The worker just needs a scan loop added to detect stale messages and send DMs/escalations. This is a straightforward follow-up task.
## Architecture Decisions
1. **No circular dependencies**: Used `AttachmentLinker` interface + adapter pattern to avoid messaging->attachments import
2. **Batch loading**: Reply counts loaded via single GROUP BY query; attachments loaded per-message (acceptable for LAN scale)
3. **Client-side thumbnails**: CSS-only resizing (no server-side image processing, preserves zero-CGO constraint)
4. **Zero new migrations**: Leveraged existing `reply_to` column (migration 007) and `attachments` table (migration 001)
5. **Zero new dependencies**: All using Go stdlib + existing libraries
## Constitution Compliance
All 10 principles satisfied:
- I. Local-First: No external dependencies added
- II. MCP-Native: Agent features use MCP tools exclusively
- III. Pure Go, Zero CGO: stdlib archive/tar + compress/gzip for backup
- X. Web UI First-Class: Full attachment and thread UI experience
1. **Separate reactions package**: Clean domain separation from messaging
2. **Toggle semantics**: INSERT if absent, DELETE if present — simple, atomic, idempotent
3. **Derived workflow state**: No denormalization; state computed from reactions on read
4. **Bridge actions (not hybrid tools)**: Consistent with attachments pattern — 4 hybrid tools are stable surface area
5. **ReactionEnricher adapter**: Avoids circular dependency between reactions and messaging packages
+40 -1
View File
@@ -636,7 +636,46 @@ func addAdminCommands(rootCmd *cobra.Command) {
channelsJoinCmd.MarkFlagRequired("channel")
channelsJoinCmd.MarkFlagRequired("agent")
channelsCmd.AddCommand(channelsListCmd, channelsShowCmd, channelsCreateCmd, channelsJoinCmd)
var (
channelsUpdateName string
channelsUpdateAutoApprove string
channelsUpdateStalemateRemind string
channelsUpdateStalemateEscalate string
)
channelsUpdateCmd := &cobra.Command{
Use: "update",
Short: "Update channel settings (auto-approve, stalemate timers)",
RunE: func(cmd *cobra.Command, args []string) error {
if channelsUpdateName == "" {
return fmt.Errorf("--name is required")
}
reqArgs := map[string]interface{}{
"name": channelsUpdateName,
}
if cmd.Flags().Changed("auto-approve") {
reqArgs["auto_approve"] = channelsUpdateAutoApprove == "true"
}
if cmd.Flags().Changed("stalemate-remind-after") {
reqArgs["stalemate_remind_after"] = channelsUpdateStalemateRemind
}
if cmd.Flags().Changed("stalemate-escalate-after") {
reqArgs["stalemate_escalate_after"] = channelsUpdateStalemateEscalate
}
resp, err := adminRequest("channels.update_settings", reqArgs)
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
channelsUpdateCmd.Flags().StringVar(&channelsUpdateName, "name", "", "Channel name")
channelsUpdateCmd.Flags().StringVar(&channelsUpdateAutoApprove, "auto-approve", "", "Auto-approve messages (true|false)")
channelsUpdateCmd.Flags().StringVar(&channelsUpdateStalemateRemind, "stalemate-remind-after", "", "Stalemate reminder duration (e.g. 24h)")
channelsUpdateCmd.Flags().StringVar(&channelsUpdateStalemateEscalate, "stalemate-escalate-after", "", "Stalemate escalation duration (e.g. 72h)")
channelsUpdateCmd.MarkFlagRequired("name")
channelsCmd.AddCommand(channelsListCmd, channelsShowCmd, channelsCreateCmd, channelsJoinCmd, channelsUpdateCmd)
// ----- conversations commands -----
conversationsCmd := &cobra.Command{
+35 -1
View File
@@ -41,6 +41,7 @@ import (
mcpserver "github.com/synapbus/synapbus/internal/mcp"
"github.com/synapbus/synapbus/internal/messaging"
prommetrics "github.com/synapbus/synapbus/internal/metrics"
"github.com/synapbus/synapbus/internal/reactions"
"github.com/synapbus/synapbus/internal/search"
"github.com/synapbus/synapbus/internal/search/embedding"
"github.com/synapbus/synapbus/internal/storage"
@@ -284,6 +285,12 @@ func runServe(cmd *cobra.Command, args []string) error {
msgService.SetAttachmentLinker(&attachmentLinkerAdapter{svc: attachmentService})
slog.Info("attachment service initialized", "dir", attachmentsDir)
// Create reaction service
reactionStore := reactions.NewSQLiteStore(db.DB)
reactionService := reactions.NewService(reactionStore, slog.Default())
msgService.SetReactionEnricher(&reactionEnricherAdapter{svc: reactionService})
slog.Info("reaction service initialized")
// Initialize auth subsystem
authSecret := make([]byte, 32)
if _, err := rand.Read(authSecret); err != nil {
@@ -466,7 +473,7 @@ func runServe(cmd *cobra.Command, args []string) error {
actionIndex := actions.NewIndex(actionRegistry.List())
// Create MCP server (4 hybrid tools: my_status, send_message, search, execute)
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService, con, jsPool, actionRegistry, actionIndex, db.DB)
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService, reactionService, con, jsPool, actionRegistry, actionIndex, db.DB)
startTime := time.Now()
// Start task expiry worker
@@ -620,6 +627,7 @@ func runServe(cmd *cobra.Command, args []string) error {
ChannelService: channelService,
APIKeyService: apiKeyService,
DeadLetterStore: deadLetterStore,
ReactionService: reactionService,
SSEHub: sseHub,
Broadcaster: sseBroadcaster,
SessionMiddleware: sessionMiddleware,
@@ -864,6 +872,32 @@ func (a *attachmentLinkerAdapter) GetByMessageID(ctx context.Context, messageID
return results, nil
}
// reactionEnricherAdapter adapts reactions.Service to messaging.ReactionEnricher.
type reactionEnricherAdapter struct {
svc *reactions.Service
}
func (a *reactionEnricherAdapter) GetByMessageIDs(ctx context.Context, messageIDs []int64) (map[int64][]messaging.ReactionInfo, error) {
rxMap, err := a.svc.GetReactionsByMessageIDs(ctx, messageIDs)
if err != nil {
return nil, err
}
result := make(map[int64][]messaging.ReactionInfo, len(rxMap))
for msgID, rxs := range rxMap {
infos := make([]messaging.ReactionInfo, len(rxs))
for i, rx := range rxs {
infos[i] = messaging.ReactionInfo{
AgentName: rx.AgentName,
Reaction: rx.Reaction,
Metadata: rx.Metadata,
CreatedAt: rx.CreatedAt,
}
}
result[msgID] = infos
}
return result, nil
}
// agentListerAdapter adapts agents.AgentService to auth.AgentLister.
type agentListerAdapter struct {
agentService *agents.AgentService
+73 -3
View File
@@ -6,10 +6,10 @@ type Registry struct {
ordered []Action // maintains insertion order
}
// NewRegistry creates a registry pre-populated with all 23 agent-callable actions.
// NewRegistry creates a registry pre-populated with all 27 agent-callable actions.
func NewRegistry() *Registry {
r := &Registry{
actions: make(map[string]Action, 23),
actions: make(map[string]Action, 27),
}
for _, a := range allActions() {
r.actions[a.Name] = a
@@ -42,7 +42,7 @@ func (r *Registry) ListByCategory(category string) []Action {
return out
}
// allActions returns the canonical list of all 23 agent-callable actions.
// allActions returns the canonical list of all 27 agent-callable actions.
func allActions() []Action {
return []Action{
// ── Messaging (7 actions) ──────────────────────────────────────
@@ -455,5 +455,75 @@ func allActions() []Action {
},
},
},
// ── Reactions (4 actions) ────────────────────────────────────
{
Name: "react",
Category: "reactions",
Description: "Add or toggle a reaction on a message. Valid reactions: approve, reject, in_progress, done, published. Adding the same reaction again removes it (toggle).",
Params: []Param{
{Name: "message_id", Type: "number", Description: "ID of the message to react to", Required: true},
{Name: "reaction", Type: "string", Description: "Reaction type: approve, reject, in_progress, done, published", Required: true},
{Name: "metadata", Type: "string", Description: "JSON metadata object (optional)"},
},
Returns: "JSON with action ('added' or 'removed') and reaction details",
Examples: []Example{
{
Description: "Approve a message",
Code: `call("react", {"message_id": 42, "reaction": "approve"})`,
},
{
Description: "Toggle a reaction off (call same reaction again)",
Code: `call("react", {"message_id": 42, "reaction": "approve"})`,
},
},
},
{
Name: "unreact",
Category: "reactions",
Description: "Remove a specific reaction from a message.",
Params: []Param{
{Name: "message_id", Type: "number", Description: "ID of the message to remove reaction from", Required: true},
{Name: "reaction", Type: "string", Description: "Reaction type to remove: approve, reject, in_progress, done, published", Required: true},
},
Returns: "JSON with message_id, reaction, and status 'removed'",
Examples: []Example{
{
Description: "Remove an approval reaction",
Code: `call("unreact", {"message_id": 42, "reaction": "approve"})`,
},
},
},
{
Name: "get_reactions",
Category: "reactions",
Description: "Get all reactions on a message and its derived workflow state.",
Params: []Param{
{Name: "message_id", Type: "number", Description: "ID of the message to get reactions for", Required: true},
},
Returns: "JSON with reactions array and workflow_state",
Examples: []Example{
{
Description: "Get reactions and workflow state for a message",
Code: `call("get_reactions", {"message_id": 42})`,
},
},
},
{
Name: "list_by_state",
Category: "reactions",
Description: "List messages in a channel filtered by workflow state. Valid states: proposed, approved, in_progress, rejected, done, published.",
Params: []Param{
{Name: "channel", Type: "string", Description: "Channel name", Required: true},
{Name: "state", Type: "string", Description: "Workflow state to filter by: proposed, approved, in_progress, rejected, done, published", Required: true},
},
Returns: "JSON with message_ids array and count",
Examples: []Example{
{
Description: "List approved messages in a channel",
Code: `call("list_by_state", {"channel": "approvals", "state": "approved"})`,
},
},
},
}
}
+6 -3
View File
@@ -4,11 +4,11 @@ import (
"testing"
)
func TestRegistryHas23Actions(t *testing.T) {
func TestRegistryHas27Actions(t *testing.T) {
r := NewRegistry()
got := len(r.List())
if got != 23 {
t.Errorf("expected 23 actions, got %d", got)
if got != 27 {
t.Errorf("expected 27 actions, got %d", got)
}
}
@@ -23,6 +23,7 @@ func TestRegistryCategories(t *testing.T) {
{"channels", 9},
{"swarm", 5},
{"attachments", 2},
{"reactions", 4},
}
for _, tt := range tests {
@@ -49,6 +50,8 @@ func TestRegistryGetByName(t *testing.T) {
"post_task", "bid_task", "accept_bid", "complete_task", "list_tasks",
// attachments
"upload_attachment", "download_attachment",
// reactions
"react", "unreact", "get_reactions", "list_by_state",
}
for _, name := range allNames {
+60
View File
@@ -169,6 +169,8 @@ func (s *AdminServer) dispatch(req Request) Response {
return s.handleChannelsCreate(ctx, req.Args)
case "channels.join":
return s.handleChannelsJoin(ctx, req.Args)
case "channels.update_settings":
return s.handleChannelsUpdateSettings(ctx, req.Args)
// --- conversations ---
case "conversations.list":
@@ -995,6 +997,64 @@ func (s *AdminServer) handleChannelsJoin(ctx context.Context, args json.RawMessa
}}
}
func (s *AdminServer) handleChannelsUpdateSettings(ctx context.Context, args json.RawMessage) Response {
var p struct {
Name string `json:"name"`
AutoApprove *bool `json:"auto_approve,omitempty"`
StalemateRemindAfter string `json:"stalemate_remind_after,omitempty"`
StalemateEscalateAfter string `json:"stalemate_escalate_after,omitempty"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Name == "" {
return Response{OK: false, Error: "name is required"}
}
// Build the SET clause dynamically based on provided fields
var setClauses []string
var setArgs []interface{}
if p.AutoApprove != nil {
autoApproveVal := 0
if *p.AutoApprove {
autoApproveVal = 1
}
setClauses = append(setClauses, "auto_approve = ?")
setArgs = append(setArgs, autoApproveVal)
}
if p.StalemateRemindAfter != "" {
setClauses = append(setClauses, "stalemate_remind_after = ?")
setArgs = append(setArgs, p.StalemateRemindAfter)
}
if p.StalemateEscalateAfter != "" {
setClauses = append(setClauses, "stalemate_escalate_after = ?")
setArgs = append(setArgs, p.StalemateEscalateAfter)
}
if len(setClauses) == 0 {
return Response{OK: false, Error: "at least one setting must be provided (auto_approve, stalemate_remind_after, stalemate_escalate_after)"}
}
query := fmt.Sprintf("UPDATE channels SET %s WHERE LOWER(name) = LOWER(?)", strings.Join(setClauses, ", "))
setArgs = append(setArgs, p.Name)
result, err := s.db.ExecContext(ctx, query, setArgs...)
if err != nil {
return Response{OK: false, Error: "update channel settings: " + err.Error()}
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return Response{OK: false, Error: fmt.Sprintf("channel not found: %s", p.Name)}
}
return Response{OK: true, Data: map[string]interface{}{
"channel": p.Name,
"updated": true,
}}
}
// ---------- conversations handlers ----------
func (s *AdminServer) handleConversationsList(ctx context.Context, args json.RawMessage) Response {
+125 -4
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
@@ -15,10 +16,16 @@ import (
// ChannelsHandler handles REST API requests for channels.
type ChannelsHandler struct {
channelService *channels.Service
agentService *agents.AgentService
msgService *messaging.MessagingService
logger *slog.Logger
channelService *channels.Service
agentService *agents.AgentService
msgService *messaging.MessagingService
reactionService ChannelReactionService
logger *slog.Logger
}
// ChannelReactionService is the subset of reactions.Service needed by ChannelsHandler.
type ChannelReactionService interface {
ListByState(ctx context.Context, channelID int64, state string) ([]int64, error)
}
// NewChannelsHandler creates a new channels handler.
@@ -31,6 +38,11 @@ func NewChannelsHandler(channelService *channels.Service, agentService *agents.A
}
}
// SetReactionService sets the reaction service for workflow state queries.
func (h *ChannelsHandler) SetReactionService(svc ChannelReactionService) {
h.reactionService = svc
}
// ListChannels handles GET /api/channels.
func (h *ChannelsHandler) ListChannels(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
@@ -298,3 +310,112 @@ func (h *ChannelsHandler) LeaveChannel(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "left"})
}
// UpdateSettings handles PUT /api/channels/{name}/settings.
func (h *ChannelsHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
_, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
name := chi.URLParam(r, "name")
ch, err := h.channelService.GetChannelByName(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Channel not found"))
return
}
var req struct {
AutoApprove *bool `json:"auto_approve"`
StalemateRemindAfter *string `json:"stalemate_remind_after"`
StalemateEscalateAfter *string `json:"stalemate_escalate_after"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
return
}
settings := channels.ChannelSettings{
AutoApprove: ch.AutoApprove,
StalemateRemindAfter: ch.StalemateRemindAfter,
StalemateEscalateAfter: ch.StalemateEscalateAfter,
}
if req.AutoApprove != nil {
settings.AutoApprove = *req.AutoApprove
}
if req.StalemateRemindAfter != nil {
settings.StalemateRemindAfter = *req.StalemateRemindAfter
}
if req.StalemateEscalateAfter != nil {
settings.StalemateEscalateAfter = *req.StalemateEscalateAfter
}
updated, err := h.channelService.UpdateChannelSettings(r.Context(), ch.ID, settings)
if err != nil {
h.logger.Error("update channel settings failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to update channel settings"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"channel": updated})
}
// ListByState handles GET /api/channels/{name}/messages/by-state?state=X.
func (h *ChannelsHandler) ListByState(w http.ResponseWriter, r *http.Request) {
_, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
if h.reactionService == nil {
writeJSON(w, http.StatusServiceUnavailable, errorBody("unavailable", "Reactions service not configured"))
return
}
name := chi.URLParam(r, "name")
ch, err := h.channelService.GetChannelByName(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Channel not found"))
return
}
state := r.URL.Query().Get("state")
if state == "" {
writeJSON(w, http.StatusBadRequest, errorBody("missing_state", "Query parameter 'state' is required"))
return
}
ids, err := h.reactionService.ListByState(r.Context(), ch.ID, state)
if err != nil {
h.logger.Error("list by state failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("invalid_state", err.Error()))
return
}
// Load messages by IDs
var messages []*messaging.Message
for _, id := range ids {
msg, err := h.msgService.GetMessageByID(r.Context(), id)
if err != nil {
continue
}
messages = append(messages, msg)
}
if messages == nil {
messages = []*messaging.Message{}
}
// Enrich messages with reactions, reply counts, attachments
h.msgService.EnrichMessages(r.Context(), messages)
writeJSON(w, http.StatusOK, map[string]any{
"messages": messages,
"state": state,
"total": len(messages),
})
}
+233
View File
@@ -0,0 +1,233 @@
package api
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/auth"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/reactions"
)
// ReactionsHandler handles REST API requests for message reactions.
type ReactionsHandler struct {
reactionService *reactions.Service
msgService *messaging.MessagingService
agentService *agents.AgentService
logger *slog.Logger
}
// NewReactionsHandler creates a new reactions handler.
func NewReactionsHandler(reactionService *reactions.Service, msgService *messaging.MessagingService, agentService *agents.AgentService) *ReactionsHandler {
return &ReactionsHandler{
reactionService: reactionService,
msgService: msgService,
agentService: agentService,
logger: slog.Default().With("component", "api.reactions"),
}
}
// Toggle handles POST /api/messages/{id}/reactions.
func (h *ReactionsHandler) Toggle(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid message ID"))
return
}
var req struct {
Reaction string `json:"reaction"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
return
}
if req.Reaction == "" {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "Reaction type is required"))
return
}
// Verify the message exists
msg, err := h.msgService.GetMessageByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Message not found"))
return
}
// Determine the acting agent name from the session
agentName, err := h.resolveAgentName(r, ownerID, msg)
if err != nil {
h.logger.Error("resolve agent name failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("no_agent", err.Error()))
return
}
result, err := h.reactionService.Toggle(r.Context(), id, agentName, req.Reaction, req.Metadata)
if err != nil {
if err == reactions.ErrInvalidReaction {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_reaction", err.Error()))
return
}
if err == reactions.ErrReactionLimit {
writeJSON(w, http.StatusBadRequest, errorBody("reaction_limit", err.Error()))
return
}
h.logger.Error("toggle reaction failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to toggle reaction"))
return
}
// Reload reactions and workflow state for the response
rxs, state, err := h.reactionService.GetReactions(r.Context(), id)
if err != nil {
h.logger.Error("get reactions failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get reactions"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"action": result.Action,
"reaction": result.Reaction,
"reactions": rxs,
"workflow_state": state,
})
}
// GetReactions handles GET /api/messages/{id}/reactions.
func (h *ReactionsHandler) GetReactions(w http.ResponseWriter, r *http.Request) {
_, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid message ID"))
return
}
// Verify the message exists
_, err = h.msgService.GetMessageByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Message not found"))
return
}
rxs, state, err := h.reactionService.GetReactions(r.Context(), id)
if err != nil {
h.logger.Error("get reactions failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get reactions"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"reactions": rxs,
"workflow_state": state,
})
}
// Remove handles DELETE /api/messages/{id}/reactions/{reaction}.
func (h *ReactionsHandler) Remove(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid message ID"))
return
}
reactionType := chi.URLParam(r, "reaction")
if reactionType == "" {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "Reaction type is required"))
return
}
// Verify the message exists
msg, err := h.msgService.GetMessageByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Message not found"))
return
}
// Determine the acting agent name
agentName, err := h.resolveAgentName(r, ownerID, msg)
if err != nil {
h.logger.Error("resolve agent name failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("no_agent", err.Error()))
return
}
if err := h.reactionService.Remove(r.Context(), id, agentName, reactionType); err != nil {
if err == reactions.ErrInvalidReaction {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_reaction", err.Error()))
return
}
h.logger.Error("remove reaction failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to remove reaction"))
return
}
// Reload reactions and workflow state for the response
rxs, state, err := h.reactionService.GetReactions(r.Context(), id)
if err != nil {
h.logger.Error("get reactions failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get reactions"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "removed",
"reactions": rxs,
"workflow_state": state,
})
}
// resolveAgentName determines the agent name for the current session user.
// For session-authenticated users (Web UI), it returns the human agent.
// For API key / OAuth, it falls back to the first owned agent.
func (h *ReactionsHandler) resolveAgentName(r *http.Request, ownerID int64, msg *messaging.Message) (string, error) {
if _, isSession := auth.SessionIDFromContext(r.Context()); isSession {
humanAgent, err := h.agentService.GetHumanAgentForUser(r.Context(), ownerID)
if err != nil {
return "", err
}
if humanAgent == nil {
return "", fmt.Errorf("no human agent found for this user")
}
return humanAgent.Name, nil
}
// Non-session: find an owned agent
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil || len(ownedAgents) == 0 {
return "", fmt.Errorf("no agents registered")
}
// Prefer human-type agent
for _, a := range ownedAgents {
if a.Type == "human" {
return a.Name, nil
}
}
return ownedAgents[0].Name, nil
}
+19
View File
@@ -13,6 +13,7 @@ import (
"github.com/synapbus/synapbus/internal/k8s"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/push"
"github.com/synapbus/synapbus/internal/reactions"
"github.com/synapbus/synapbus/internal/trace"
"github.com/synapbus/synapbus/internal/webhooks"
)
@@ -32,6 +33,7 @@ type RouterConfig struct {
WebhookStore webhooks.WebhookStore
K8sService *k8s.K8sService
K8sStore k8s.K8sStore
ReactionService *reactions.Service
PushService *push.Service
SSEHub *SSEHub
Broadcaster *SSEBroadcaster
@@ -142,9 +144,24 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
})
}
// Reactions
if cfg.ReactionService != nil {
reactionsHandler := NewReactionsHandler(cfg.ReactionService, cfg.MsgService, cfg.AgentService)
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Post("/api/messages/{id}/reactions", reactionsHandler.Toggle)
r.Get("/api/messages/{id}/reactions", reactionsHandler.GetReactions)
r.Delete("/api/messages/{id}/reactions/{reaction}", reactionsHandler.Remove)
})
}
// Channels
if cfg.ChannelService != nil {
channelsHandler := NewChannelsHandler(cfg.ChannelService, cfg.AgentService, cfg.MsgService)
if cfg.ReactionService != nil {
channelsHandler.SetReactionService(cfg.ReactionService)
}
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
@@ -152,6 +169,8 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Get("/api/channels/{name}", channelsHandler.GetChannel)
r.Post("/api/channels", channelsHandler.CreateChannel)
r.Get("/api/channels/{name}/messages", channelsHandler.ChannelMessages)
r.Get("/api/channels/{name}/messages/by-state", channelsHandler.ListByState)
r.Put("/api/channels/{name}/settings", channelsHandler.UpdateSettings)
r.Post("/api/channels/{name}/join", channelsHandler.JoinChannel)
r.Post("/api/channels/{name}/leave", channelsHandler.LeaveChannel)
})
+25
View File
@@ -459,6 +459,31 @@ func (s *Service) UpdateChannel(ctx context.Context, channelID int64, req Update
return ch, nil
}
// UpdateChannelSettings updates the workflow-related settings for a channel.
func (s *Service) UpdateChannelSettings(ctx context.Context, channelID int64, settings ChannelSettings) (*Channel, error) {
store, ok := s.store.(*SQLiteChannelStore)
if !ok {
return nil, fmt.Errorf("channel store does not support settings update")
}
if err := store.UpdateChannelSettings(ctx, channelID, settings); err != nil {
return nil, err
}
// Reload channel to return updated state
ch, err := s.store.GetChannel(ctx, channelID)
if err != nil {
return nil, err
}
s.logger.Info("channel settings updated",
"channel_id", channelID,
"auto_approve", settings.AutoApprove,
)
return ch, nil
}
// BroadcastMessage sends a message to a channel. It creates a single channel
// message (visible in the channel timeline via GetChannelMessages) and also
// delivers individual DM notifications to each member's inbox.
+23 -6
View File
@@ -83,9 +83,9 @@ func (s *SQLiteChannelStore) GetChannel(ctx context.Context, id int64) (*Channel
var ch Channel
var isPrivate, isSystem int
err := s.db.QueryRowContext(ctx,
`SELECT id, name, description, topic, type, is_private, is_system, created_by, created_at, updated_at
`SELECT id, name, description, topic, type, is_private, is_system, created_by, auto_approve, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at
FROM channels WHERE id = ?`, id,
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.CreatedAt, &ch.UpdatedAt)
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.CreatedAt, &ch.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrChannelNotFound
@@ -102,9 +102,9 @@ func (s *SQLiteChannelStore) GetChannelByName(ctx context.Context, name string)
var ch Channel
var isPrivate, isSystem int
err := s.db.QueryRowContext(ctx,
`SELECT id, name, description, topic, type, is_private, is_system, created_by, created_at, updated_at
`SELECT id, name, description, topic, type, is_private, is_system, created_by, auto_approve, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at
FROM channels WHERE LOWER(name) = LOWER(?)`, name,
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.CreatedAt, &ch.UpdatedAt)
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.CreatedAt, &ch.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrChannelNotFound
@@ -120,7 +120,7 @@ func (s *SQLiteChannelStore) GetChannelByName(ctx context.Context, name string)
// is a member or has a pending invite.
func (s *SQLiteChannelStore) ListChannels(ctx context.Context, agentName string) ([]*Channel, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT c.id, c.name, c.description, c.topic, c.type, c.is_private, c.is_system, c.created_by, c.created_at, c.updated_at
`SELECT DISTINCT c.id, c.name, c.description, c.topic, c.type, c.is_private, c.is_system, c.created_by, c.auto_approve, c.stalemate_remind_after, c.stalemate_escalate_after, c.created_at, c.updated_at
FROM channels c
WHERE c.is_private = 0
OR EXISTS (SELECT 1 FROM channel_members cm WHERE cm.channel_id = c.id AND cm.agent_name = ?)
@@ -137,7 +137,7 @@ func (s *SQLiteChannelStore) ListChannels(ctx context.Context, agentName string)
for rows.Next() {
var ch Channel
var isPrivate, isSystem int
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan channel: %w", err)
}
ch.IsPrivate = isPrivate != 0
@@ -415,6 +415,23 @@ func (s *SQLiteChannelStore) GetChannelSummaries(ctx context.Context, agentName
return summaries, rows.Err()
}
// UpdateChannelSettings updates the workflow-related settings for a channel.
func (s *SQLiteChannelStore) UpdateChannelSettings(ctx context.Context, id int64, settings ChannelSettings) error {
result, err := s.db.ExecContext(ctx,
`UPDATE channels SET auto_approve = ?, stalemate_remind_after = ?, stalemate_escalate_after = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
settings.AutoApprove, settings.StalemateRemindAfter, settings.StalemateEscalateAfter, id,
)
if err != nil {
return fmt.Errorf("update channel settings: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return ErrChannelNotFound
}
s.logger.Info("channel settings updated", "id", id)
return nil
}
// isUniqueConstraintError checks if an error is a SQLite unique constraint violation.
func isUniqueConstraintError(err error) bool {
return strings.Contains(err.Error(), "UNIQUE constraint failed")
+20 -10
View File
@@ -25,16 +25,19 @@ const (
// Channel represents a named group communication space.
type Channel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Topic string `json:"topic"`
Type string `json:"type"`
IsPrivate bool `json:"is_private"`
IsSystem bool `json:"is_system"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Topic string `json:"topic"`
Type string `json:"type"`
IsPrivate bool `json:"is_private"`
IsSystem bool `json:"is_system"`
CreatedBy string `json:"created_by"`
AutoApprove bool `json:"auto_approve"`
StalemateRemindAfter string `json:"stalemate_remind_after"`
StalemateEscalateAfter string `json:"stalemate_escalate_after"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ChannelWithCount embeds Channel and adds a member count.
@@ -107,6 +110,13 @@ type JoinChannelRequest struct {
AgentName string `json:"agent_name"`
}
// ChannelSettings holds workflow-related settings for a channel.
type ChannelSettings struct {
AutoApprove bool `json:"auto_approve"`
StalemateRemindAfter string `json:"stalemate_remind_after"`
StalemateEscalateAfter string `json:"stalemate_escalate_after"`
}
// InviteRequest is the input for inviting an agent to a channel.
type InviteRequest struct {
ChannelID int64 `json:"channel_id"`
+144
View File
@@ -14,6 +14,7 @@ import (
"github.com/synapbus/synapbus/internal/attachments"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/reactions"
"github.com/synapbus/synapbus/internal/search"
)
@@ -26,6 +27,7 @@ type ServiceBridge struct {
swarmService *channels.SwarmService
attachmentService *attachments.Service
searchService *search.Service
reactionService *reactions.Service
agentName string
}
@@ -37,6 +39,7 @@ func NewServiceBridge(
swarmService *channels.SwarmService,
attachmentService *attachments.Service,
searchService *search.Service,
reactionService *reactions.Service,
agentName string,
) *ServiceBridge {
return &ServiceBridge{
@@ -46,6 +49,7 @@ func NewServiceBridge(
swarmService: swarmService,
attachmentService: attachmentService,
searchService: searchService,
reactionService: reactionService,
agentName: agentName,
}
}
@@ -103,6 +107,16 @@ func (b *ServiceBridge) Call(ctx context.Context, actionName string, args map[st
case "download_attachment":
return b.callDownloadAttachment(ctx, args)
// --- Reactions ---
case "react":
return b.callReact(ctx, args)
case "unreact":
return b.callUnreact(ctx, args)
case "get_reactions":
return b.callGetReactions(ctx, args)
case "list_by_state":
return b.callListByState(ctx, args)
// --- DM send (also accessible via bridge for execute tool) ---
case "send_message":
return b.callSendMessage(ctx, args)
@@ -920,6 +934,136 @@ func (b *ServiceBridge) callDownloadAttachment(ctx context.Context, args map[str
}, nil
}
// --- Reaction implementations ---
func (b *ServiceBridge) callReact(ctx context.Context, args map[string]any) (any, error) {
if b.reactionService == nil {
return nil, fmt.Errorf("reaction service not available")
}
messageID := getInt(args, "message_id", 0)
if messageID == 0 {
return nil, fmt.Errorf("'message_id' parameter is required")
}
reaction := getString(args, "reaction", "")
if reaction == "" {
return nil, fmt.Errorf("'reaction' parameter is required")
}
var metadata json.RawMessage
if metaStr := getString(args, "metadata", ""); metaStr != "" {
if !json.Valid([]byte(metaStr)) {
return nil, fmt.Errorf("metadata must be valid JSON")
}
metadata = json.RawMessage(metaStr)
}
result, err := b.reactionService.Toggle(ctx, int64(messageID), b.agentName, reaction, metadata)
if err != nil {
return nil, err
}
resp := map[string]any{
"action": result.Action,
"message_id": messageID,
"reaction": reaction,
}
if result.Reaction != nil {
resp["id"] = result.Reaction.ID
resp["created_at"] = result.Reaction.CreatedAt
}
return resp, nil
}
func (b *ServiceBridge) callUnreact(ctx context.Context, args map[string]any) (any, error) {
if b.reactionService == nil {
return nil, fmt.Errorf("reaction service not available")
}
messageID := getInt(args, "message_id", 0)
if messageID == 0 {
return nil, fmt.Errorf("'message_id' parameter is required")
}
reaction := getString(args, "reaction", "")
if reaction == "" {
return nil, fmt.Errorf("'reaction' parameter is required")
}
if err := b.reactionService.Remove(ctx, int64(messageID), b.agentName, reaction); err != nil {
return nil, err
}
return map[string]any{
"message_id": messageID,
"reaction": reaction,
"status": "removed",
}, nil
}
func (b *ServiceBridge) callGetReactions(ctx context.Context, args map[string]any) (any, error) {
if b.reactionService == nil {
return nil, fmt.Errorf("reaction service not available")
}
messageID := getInt(args, "message_id", 0)
if messageID == 0 {
return nil, fmt.Errorf("'message_id' parameter is required")
}
rxns, state, err := b.reactionService.GetReactions(ctx, int64(messageID))
if err != nil {
return nil, err
}
return map[string]any{
"reactions": rxns,
"workflow_state": state,
"count": len(rxns),
}, nil
}
func (b *ServiceBridge) callListByState(ctx context.Context, args map[string]any) (any, error) {
if b.reactionService == nil {
return nil, fmt.Errorf("reaction service not available")
}
if b.channelService == nil {
return nil, fmt.Errorf("channel service not available")
}
channelName := getString(args, "channel", "")
if channelName == "" {
return nil, fmt.Errorf("'channel' parameter is required")
}
state := getString(args, "state", "")
if state == "" {
return nil, fmt.Errorf("'state' parameter is required")
}
ch, err := b.channelService.GetChannelByName(ctx, channelName)
if err != nil {
return nil, err
}
messageIDs, err := b.reactionService.ListByState(ctx, ch.ID, state)
if err != nil {
return nil, err
}
if messageIDs == nil {
messageIDs = []int64{}
}
return map[string]any{
"message_ids": messageIDs,
"count": len(messageIDs),
"channel": channelName,
"state": state,
}, nil
}
// --- Helpers ---
// resolveChannelID resolves a channel ID from either channel_id or channel_name in args.
+2 -1
View File
@@ -43,6 +43,7 @@ func newTestBridge(t *testing.T) (*ServiceBridge, *messaging.MessagingService, *
swarmService,
nil, // attachmentService
nil, // searchService
nil, // reactionService
"agent-a",
)
return bridge, msgService, agentService, channelService
@@ -185,7 +186,7 @@ func TestBridge_JoinChannel(t *testing.T) {
bridge.agentService,
bridge.channelService,
bridge.swarmService,
nil, nil,
nil, nil, nil,
"agent-b",
)
+1
View File
@@ -50,6 +50,7 @@ func newTestHybridWithChannels(t *testing.T) (*HybridToolRegistrar, *channels.Se
nil, // swarmService
nil, // attachmentService
nil, // searchService
nil, // reactionService
jsPool,
actionRegistry,
actionIndex,
+3
View File
@@ -18,6 +18,7 @@ import (
"github.com/synapbus/synapbus/internal/console"
"github.com/synapbus/synapbus/internal/jsruntime"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/reactions"
"github.com/synapbus/synapbus/internal/search"
"github.com/synapbus/synapbus/internal/trace"
)
@@ -40,6 +41,7 @@ func NewMCPServer(
swarmService *channels.SwarmService,
attachmentService *attachments.Service,
searchService *search.Service,
reactionService *reactions.Service,
consolePrinter *console.Printer,
jsPool *jsruntime.Pool,
actionRegistry *actions.Registry,
@@ -153,6 +155,7 @@ func NewMCPServer(
swarmService,
attachmentService,
searchService,
reactionService,
jsPool,
actionRegistry,
actionIndex,
+3 -3
View File
@@ -38,7 +38,7 @@ func newTestMCPServer(t *testing.T, con *console.Printer) (*MCPServer, *messagin
actionRegistry := actions.NewRegistry()
actionIndex := actions.NewIndex(actionRegistry.List())
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, con, jsPool, actionRegistry, actionIndex, db)
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, con, jsPool, actionRegistry, actionIndex, db)
return srv, msgService, agentService
}
@@ -133,7 +133,7 @@ func TestMCPToolCall_WithValidAPIKey(t *testing.T) {
actionIndex := actions.NewIndex(actionRegistry.List())
// Create MCP server
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db)
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db)
// Mount with auth middleware, just like main.go does
mux := http.NewServeMux()
@@ -188,7 +188,7 @@ func TestMCPToolCall_InvalidAPIKeyReturns401(t *testing.T) {
actionRegistry := actions.NewRegistry()
actionIndex := actions.NewIndex(actionRegistry.List())
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db)
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db)
mux := http.NewServeMux()
handler := agents.OptionalAuthMiddlewareWithAPIKeys(agentService, apiKeyService)(srv.Handler())
+5
View File
@@ -18,6 +18,7 @@ import (
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/jsruntime"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/reactions"
"github.com/synapbus/synapbus/internal/search"
)
@@ -29,6 +30,7 @@ type HybridToolRegistrar struct {
swarmService *channels.SwarmService
attachmentService *attachments.Service
searchService *search.Service
reactionService *reactions.Service
jsPool *jsruntime.Pool
actionRegistry *actions.Registry
actionIndex *actions.Index
@@ -44,6 +46,7 @@ func NewHybridToolRegistrar(
swarmService *channels.SwarmService,
attachmentService *attachments.Service,
searchService *search.Service,
reactionService *reactions.Service,
jsPool *jsruntime.Pool,
actionRegistry *actions.Registry,
actionIndex *actions.Index,
@@ -56,6 +59,7 @@ func NewHybridToolRegistrar(
swarmService: swarmService,
attachmentService: attachmentService,
searchService: searchService,
reactionService: reactionService,
jsPool: jsPool,
actionRegistry: actionRegistry,
actionIndex: actionIndex,
@@ -475,6 +479,7 @@ func (h *HybridToolRegistrar) handleExecute(ctx context.Context, req mcplib.Call
h.swarmService,
h.attachmentService,
h.searchService,
h.reactionService,
agentName,
)
+1
View File
@@ -68,6 +68,7 @@ func newTestHybridRegistrar(t *testing.T) (*HybridToolRegistrar, *messaging.Mess
nil, // swarmService
nil, // attachmentService
nil, // searchService
nil, // reactionService
jsPool,
actionRegistry,
actionIndex,
+44
View File
@@ -31,6 +31,12 @@ type AttachmentLinker interface {
GetByMessageID(ctx context.Context, messageID int64) ([]AttachmentInfo, error)
}
// ReactionEnricher loads reactions for messages. This avoids importing the
// reactions package directly. Set via SetReactionEnricher.
type ReactionEnricher interface {
GetByMessageIDs(ctx context.Context, messageIDs []int64) (map[int64][]ReactionInfo, error)
}
// MessagingService provides business logic for messaging operations.
type MessagingService struct {
store MessageStore
@@ -38,6 +44,7 @@ type MessagingService struct {
dispatcher dispatcher.EventDispatcher
embeddings EmbeddingNotifier
attLinker AttachmentLinker
rxEnricher ReactionEnricher
listeners []MessageListener
logger *slog.Logger
}
@@ -66,6 +73,11 @@ func (s *MessagingService) SetAttachmentLinker(l AttachmentLinker) {
s.attLinker = l
}
// SetReactionEnricher sets the reaction enricher for message reaction loading.
func (s *MessagingService) SetReactionEnricher(e ReactionEnricher) {
s.rxEnricher = e
}
// AddMessageListener registers a listener that is notified after message creation.
func (s *MessagingService) AddMessageListener(l MessageListener) {
s.listeners = append(s.listeners, l)
@@ -585,6 +597,38 @@ func (s *MessagingService) EnrichMessages(ctx context.Context, msgs []*Message)
}
}
}
// Batch-load reactions and derive workflow state.
if s.rxEnricher != nil {
rxMap, err := s.rxEnricher.GetByMessageIDs(ctx, ids)
if err != nil {
s.logger.Error("failed to load reactions", "error", err)
} else {
for _, m := range msgs {
if rxs, ok := rxMap[m.ID]; ok && len(rxs) > 0 {
m.Reactions = rxs
// Derive workflow state from reactions
highestPriority := 0
priorities := map[string]int{
"approve": 2, "in_progress": 3, "reject": 4, "done": 5, "published": 6,
}
states := map[string]string{
"approve": "approved", "in_progress": "in_progress", "reject": "rejected", "done": "done", "published": "published",
}
for _, rx := range rxs {
if p, ok := priorities[rx.Reaction]; ok && p > highestPriority {
highestPriority = p
m.WorkflowState = states[rx.Reaction]
}
}
}
// Default to "proposed" for channel messages with no reactions
if m.WorkflowState == "" && m.ChannelID != nil {
m.WorkflowState = "proposed"
}
}
}
}
}
// GetConversation returns a conversation and its messages.
+10
View File
@@ -42,6 +42,16 @@ type Message struct {
UpdatedAt time.Time `json:"updated_at"`
ReplyCount int `json:"reply_count"`
Attachments []AttachmentInfo `json:"attachments,omitempty"`
WorkflowState string `json:"workflow_state,omitempty"`
Reactions []ReactionInfo `json:"reactions,omitempty"`
}
// ReactionInfo is a lightweight reaction summary included in message responses.
type ReactionInfo struct {
AgentName string `json:"agent_name"`
Reaction string `json:"reaction"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Conversation groups related messages into a thread.
+113
View File
@@ -0,0 +1,113 @@
// 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]
}
+150
View File
@@ -0,0 +1,150 @@
package reactions
import (
"testing"
)
func TestIsValidReaction(t *testing.T) {
tests := []struct {
name string
reaction string
want bool
}{
{"approve is valid", ReactionApprove, true},
{"reject is valid", ReactionReject, true},
{"in_progress is valid", ReactionInProgress, true},
{"done is valid", ReactionDone, true},
{"published is valid", ReactionPublished, true},
{"empty string is invalid", "", false},
{"thumbs_up is invalid", "thumbs_up", false},
{"like is invalid", "like", false},
{"APPROVE uppercase is invalid", "APPROVE", false},
{"random text is invalid", "foobar", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsValidReaction(tt.reaction)
if got != tt.want {
t.Errorf("IsValidReaction(%q) = %v, want %v", tt.reaction, got, tt.want)
}
})
}
}
func TestComputeWorkflowState(t *testing.T) {
tests := []struct {
name string
reactions []*Reaction
want string
}{
{
name: "empty reactions returns proposed",
reactions: []*Reaction{},
want: StateProposed,
},
{
name: "nil reactions returns proposed",
reactions: nil,
want: StateProposed,
},
{
name: "single approve returns approved",
reactions: []*Reaction{
{Reaction: ReactionApprove, AgentName: "agent-a"},
},
want: StateApproved,
},
{
name: "approve + in_progress returns in_progress (higher priority wins)",
reactions: []*Reaction{
{Reaction: ReactionApprove, AgentName: "agent-a"},
{Reaction: ReactionInProgress, AgentName: "agent-b"},
},
want: StateInProgress,
},
{
name: "single reject returns rejected",
reactions: []*Reaction{
{Reaction: ReactionReject, AgentName: "agent-a"},
},
want: StateRejected,
},
{
name: "single done returns done",
reactions: []*Reaction{
{Reaction: ReactionDone, AgentName: "agent-a"},
},
want: StateDone,
},
{
name: "single published returns published",
reactions: []*Reaction{
{Reaction: ReactionPublished, AgentName: "agent-a"},
},
want: StatePublished,
},
{
name: "all five types - published wins",
reactions: []*Reaction{
{Reaction: ReactionApprove, AgentName: "agent-a"},
{Reaction: ReactionInProgress, AgentName: "agent-b"},
{Reaction: ReactionReject, AgentName: "agent-c"},
{Reaction: ReactionDone, AgentName: "agent-d"},
{Reaction: ReactionPublished, AgentName: "agent-e"},
},
want: StatePublished,
},
{
name: "published wins over everything",
reactions: []*Reaction{
{Reaction: ReactionDone, AgentName: "agent-a"},
{Reaction: ReactionReject, AgentName: "agent-b"},
{Reaction: ReactionPublished, AgentName: "agent-c"},
},
want: StatePublished,
},
{
name: "reject beats in_progress",
reactions: []*Reaction{
{Reaction: ReactionInProgress, AgentName: "agent-a"},
{Reaction: ReactionReject, AgentName: "agent-b"},
},
want: StateRejected,
},
{
name: "done beats reject",
reactions: []*Reaction{
{Reaction: ReactionReject, AgentName: "agent-a"},
{Reaction: ReactionDone, AgentName: "agent-b"},
},
want: StateDone,
},
{
name: "in_progress beats approve",
reactions: []*Reaction{
{Reaction: ReactionInProgress, AgentName: "agent-a"},
{Reaction: ReactionApprove, AgentName: "agent-b"},
},
want: StateInProgress,
},
{
name: "multiple approves still returns approved",
reactions: []*Reaction{
{Reaction: ReactionApprove, AgentName: "agent-a"},
{Reaction: ReactionApprove, AgentName: "agent-b"},
{Reaction: ReactionApprove, AgentName: "agent-c"},
},
want: StateApproved,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ComputeWorkflowState(tt.reactions)
if got != tt.want {
t.Errorf("ComputeWorkflowState() = %q, want %q", got, tt.want)
}
})
}
}
+127
View File
@@ -0,0 +1,127 @@
package reactions
import (
"context"
"encoding/json"
"fmt"
"log/slog"
)
// Service provides business logic for message reactions.
type Service struct {
store Store
logger *slog.Logger
}
// NewService creates a new reaction service.
func NewService(store Store, logger *slog.Logger) *Service {
return &Service{
store: store,
logger: logger.With("component", "reactions"),
}
}
// ToggleResult describes what happened after a toggle operation.
type ToggleResult struct {
Action string `json:"action"` // "added" or "removed"
Reaction *Reaction `json:"reaction,omitempty"`
}
// Toggle adds a reaction if it doesn't exist, or removes it if it does.
// Returns the action taken and the reaction (if added).
func (s *Service) Toggle(ctx context.Context, messageID int64, agentName, reactionType string, metadata json.RawMessage) (*ToggleResult, error) {
if !IsValidReaction(reactionType) {
return nil, ErrInvalidReaction
}
// Check if reaction already exists
exists, err := s.store.Exists(ctx, messageID, agentName, reactionType)
if err != nil {
return nil, fmt.Errorf("check existing reaction: %w", err)
}
if exists {
// Toggle off — remove it
if err := s.store.Delete(ctx, messageID, agentName, reactionType); err != nil {
return nil, fmt.Errorf("remove reaction: %w", err)
}
s.logger.Info("reaction removed",
"message_id", messageID,
"agent", agentName,
"reaction", reactionType,
)
return &ToggleResult{Action: "removed"}, nil
}
// Check reaction count limit
count, err := s.store.CountByMessage(ctx, messageID)
if err != nil {
return nil, fmt.Errorf("count reactions: %w", err)
}
if count >= MaxReactionsPerMessage {
return nil, ErrReactionLimit
}
// Toggle on — add it
if metadata == nil {
metadata = json.RawMessage("{}")
}
r := &Reaction{
MessageID: messageID,
AgentName: agentName,
Reaction: reactionType,
Metadata: metadata,
}
if err := s.store.Insert(ctx, r); err != nil {
return nil, fmt.Errorf("add reaction: %w", err)
}
s.logger.Info("reaction added",
"message_id", messageID,
"agent", agentName,
"reaction", reactionType,
)
return &ToggleResult{Action: "added", Reaction: r}, nil
}
// Remove explicitly removes a reaction.
func (s *Service) Remove(ctx context.Context, messageID int64, agentName, reactionType string) error {
if !IsValidReaction(reactionType) {
return ErrInvalidReaction
}
if err := s.store.Delete(ctx, messageID, agentName, reactionType); err != nil {
return fmt.Errorf("remove reaction: %w", err)
}
s.logger.Info("reaction removed",
"message_id", messageID,
"agent", agentName,
"reaction", reactionType,
)
return nil
}
// GetReactions returns all reactions for a message and the computed workflow state.
func (s *Service) GetReactions(ctx context.Context, messageID int64) ([]*Reaction, string, error) {
reactions, err := s.store.GetByMessageID(ctx, messageID)
if err != nil {
return nil, "", fmt.Errorf("get reactions: %w", err)
}
state := ComputeWorkflowState(reactions)
return reactions, state, nil
}
// GetReactionsByMessageIDs returns reactions grouped by message ID.
func (s *Service) GetReactionsByMessageIDs(ctx context.Context, messageIDs []int64) (map[int64][]*Reaction, error) {
return s.store.GetByMessageIDs(ctx, messageIDs)
}
// ListByState returns message IDs in a channel that have the given workflow state.
func (s *Service) ListByState(ctx context.Context, channelID int64, state string) ([]int64, error) {
return s.store.GetMessageIDsByState(ctx, channelID, state)
}
+209
View File
@@ -0,0 +1,209 @@
package reactions
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
)
// Store defines the storage interface for reactions.
type Store interface {
Insert(ctx context.Context, r *Reaction) error
Delete(ctx context.Context, messageID int64, agentName, reaction string) error
GetByMessageID(ctx context.Context, messageID int64) ([]*Reaction, error)
GetByMessageIDs(ctx context.Context, messageIDs []int64) (map[int64][]*Reaction, error)
Exists(ctx context.Context, messageID int64, agentName, reaction string) (bool, error)
CountByMessage(ctx context.Context, messageID int64) (int, error)
// GetMessageIDsByState returns message IDs in a channel that have the given workflow state.
GetMessageIDsByState(ctx context.Context, channelID int64, state string) ([]int64, error)
}
// SQLiteStore implements Store using SQLite.
type SQLiteStore struct {
db *sql.DB
}
// NewSQLiteStore creates a new SQLite-backed reaction store.
func NewSQLiteStore(db *sql.DB) *SQLiteStore {
return &SQLiteStore{db: db}
}
func (s *SQLiteStore) Insert(ctx context.Context, r *Reaction) error {
metadata := r.Metadata
if metadata == nil {
metadata = json.RawMessage("{}")
}
result, err := s.db.ExecContext(ctx,
`INSERT INTO message_reactions (message_id, agent_name, reaction, metadata, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
r.MessageID, r.AgentName, r.Reaction, string(metadata),
)
if err != nil {
return fmt.Errorf("insert reaction: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("get reaction id: %w", err)
}
r.ID = id
return nil
}
func (s *SQLiteStore) Delete(ctx context.Context, messageID int64, agentName, reaction string) error {
result, err := s.db.ExecContext(ctx,
`DELETE FROM message_reactions WHERE message_id = ? AND agent_name = ? AND reaction = ?`,
messageID, agentName, reaction,
)
if err != nil {
return fmt.Errorf("delete reaction: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("reaction not found")
}
return nil
}
func (s *SQLiteStore) GetByMessageID(ctx context.Context, messageID int64) ([]*Reaction, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, message_id, agent_name, reaction, metadata, created_at
FROM message_reactions WHERE message_id = ?
ORDER BY created_at ASC`, messageID,
)
if err != nil {
return nil, fmt.Errorf("get reactions: %w", err)
}
defer rows.Close()
return scanReactions(rows)
}
func (s *SQLiteStore) GetByMessageIDs(ctx context.Context, messageIDs []int64) (map[int64][]*Reaction, error) {
if len(messageIDs) == 0 {
return map[int64][]*Reaction{}, nil
}
placeholders := make([]string, len(messageIDs))
args := make([]any, len(messageIDs))
for i, id := range messageIDs {
placeholders[i] = "?"
args[i] = id
}
query := fmt.Sprintf(
`SELECT id, message_id, agent_name, reaction, metadata, created_at
FROM message_reactions WHERE message_id IN (%s)
ORDER BY created_at ASC`,
strings.Join(placeholders, ","),
)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("get reactions by ids: %w", err)
}
defer rows.Close()
all, err := scanReactions(rows)
if err != nil {
return nil, err
}
result := make(map[int64][]*Reaction)
for _, r := range all {
result[r.MessageID] = append(result[r.MessageID], r)
}
return result, nil
}
func (s *SQLiteStore) Exists(ctx context.Context, messageID int64, agentName, reaction string) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM message_reactions WHERE message_id = ? AND agent_name = ? AND reaction = ?`,
messageID, agentName, reaction,
).Scan(&count)
if err != nil {
return false, fmt.Errorf("check reaction exists: %w", err)
}
return count > 0, nil
}
func (s *SQLiteStore) CountByMessage(ctx context.Context, messageID int64) (int, error) {
var count int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM message_reactions WHERE message_id = ?`, messageID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count reactions: %w", err)
}
return count, nil
}
func (s *SQLiteStore) GetMessageIDsByState(ctx context.Context, channelID int64, state string) ([]int64, error) {
var query string
var args []any
if state == StateProposed {
// Messages with no reactions
query = `SELECT m.id FROM messages m
WHERE m.channel_id = ?
AND NOT EXISTS (SELECT 1 FROM message_reactions r WHERE r.message_id = m.id)
ORDER BY m.created_at DESC`
args = []any{channelID}
} else {
// Find the reaction type for this state
var reactionType string
for rt, st := range reactionToState {
if st == state {
reactionType = rt
break
}
}
if reactionType == "" {
return nil, fmt.Errorf("unknown workflow state: %s", state)
}
// Messages where the highest-priority reaction maps to this state
// We get all messages with this reaction type and filter in app layer
query = `SELECT DISTINCT r.message_id FROM message_reactions r
JOIN messages m ON m.id = r.message_id
WHERE m.channel_id = ? AND r.reaction = ?
ORDER BY m.created_at DESC`
args = []any{channelID, reactionType}
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("get message ids by state: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan message id: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func scanReactions(rows *sql.Rows) ([]*Reaction, error) {
var reactions []*Reaction
for rows.Next() {
var r Reaction
var metadata string
err := rows.Scan(&r.ID, &r.MessageID, &r.AgentName, &r.Reaction, &metadata, &r.CreatedAt)
if err != nil {
return nil, fmt.Errorf("scan reaction: %w", err)
}
r.Metadata = json.RawMessage(metadata)
reactions = append(reactions, &r)
}
if reactions == nil {
reactions = []*Reaction{}
}
return reactions, rows.Err()
}
+332
View File
@@ -0,0 +1,332 @@
package reactions
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"testing"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/storage"
)
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
t.Fatalf("enable foreign keys: %v", err)
}
ctx := context.Background()
if err := storage.RunMigrations(ctx, db); err != nil {
t.Fatalf("run migrations: %v", err)
}
return db
}
// seedTestMessage creates a test user, agent, conversation, and message,
// returning the message ID.
func seedTestMessage(t *testing.T, db *sql.DB, agentName string) int64 {
t.Helper()
// Ensure user exists
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'testowner', 'hash', 'Test Owner')`)
// Ensure agent exists
_, err := db.Exec(
`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES (?, ?, 'ai', '{}', 1, 'testhash', 'active')`,
agentName, agentName,
)
if err != nil {
t.Fatalf("seed agent %s: %v", agentName, err)
}
// Create conversation
result, err := db.Exec(
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('test', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
agentName,
)
if err != nil {
t.Fatalf("create conversation: %v", err)
}
convID, _ := result.LastInsertId()
// Create message
result, err = db.Exec(
`INSERT INTO messages (conversation_id, from_agent, body, priority, status, created_at) VALUES (?, ?, 'test body', 5, 'pending', CURRENT_TIMESTAMP)`,
convID, agentName,
)
if err != nil {
t.Fatalf("create message: %v", err)
}
msgID, _ := result.LastInsertId()
return msgID
}
func TestSQLiteStore_InsertAndGet(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteStore(db)
ctx := context.Background()
msgID := seedTestMessage(t, db, "agent-a")
r := &Reaction{
MessageID: msgID,
AgentName: "agent-a",
Reaction: ReactionApprove,
Metadata: json.RawMessage(`{"comment":"looks good"}`),
}
if err := store.Insert(ctx, r); err != nil {
t.Fatalf("Insert: %v", err)
}
if r.ID == 0 {
t.Error("reaction ID should not be 0 after insert")
}
// Verify it exists
exists, err := store.Exists(ctx, msgID, "agent-a", ReactionApprove)
if err != nil {
t.Fatalf("Exists: %v", err)
}
if !exists {
t.Error("expected reaction to exist after insert")
}
// GetByMessageID
reactions, err := store.GetByMessageID(ctx, msgID)
if err != nil {
t.Fatalf("GetByMessageID: %v", err)
}
if len(reactions) != 1 {
t.Fatalf("got %d reactions, want 1", len(reactions))
}
if reactions[0].AgentName != "agent-a" {
t.Errorf("AgentName = %q, want %q", reactions[0].AgentName, "agent-a")
}
if reactions[0].Reaction != ReactionApprove {
t.Errorf("Reaction = %q, want %q", reactions[0].Reaction, ReactionApprove)
}
if string(reactions[0].Metadata) != `{"comment":"looks good"}` {
t.Errorf("Metadata = %s, want %s", reactions[0].Metadata, `{"comment":"looks good"}`)
}
}
func TestSQLiteStore_UniqueConstraint(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteStore(db)
ctx := context.Background()
msgID := seedTestMessage(t, db, "agent-a")
r := &Reaction{
MessageID: msgID,
AgentName: "agent-a",
Reaction: ReactionApprove,
}
if err := store.Insert(ctx, r); err != nil {
t.Fatalf("Insert first: %v", err)
}
// Inserting the same reaction again should fail with UNIQUE constraint
r2 := &Reaction{
MessageID: msgID,
AgentName: "agent-a",
Reaction: ReactionApprove,
}
err := store.Insert(ctx, r2)
if err == nil {
t.Error("expected error on duplicate insert, got nil")
}
}
func TestSQLiteStore_Delete(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteStore(db)
ctx := context.Background()
msgID := seedTestMessage(t, db, "agent-a")
r := &Reaction{
MessageID: msgID,
AgentName: "agent-a",
Reaction: ReactionApprove,
}
if err := store.Insert(ctx, r); err != nil {
t.Fatalf("Insert: %v", err)
}
// Delete the reaction
if err := store.Delete(ctx, msgID, "agent-a", ReactionApprove); err != nil {
t.Fatalf("Delete: %v", err)
}
// Verify it's gone
exists, err := store.Exists(ctx, msgID, "agent-a", ReactionApprove)
if err != nil {
t.Fatalf("Exists: %v", err)
}
if exists {
t.Error("expected reaction to not exist after delete")
}
// Deleting a non-existent reaction should return an error
err = store.Delete(ctx, msgID, "agent-a", ReactionApprove)
if err == nil {
t.Error("expected error when deleting non-existent reaction, got nil")
}
}
func TestSQLiteStore_GetByMessageID(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteStore(db)
ctx := context.Background()
msgID := seedTestMessage(t, db, "agent-a")
// Seed a second agent
db.Exec(`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES ('agent-b', 'agent-b', 'ai', '{}', 1, 'testhash2', 'active')`)
// Insert multiple reactions from different agents
reactions := []*Reaction{
{MessageID: msgID, AgentName: "agent-a", Reaction: ReactionApprove},
{MessageID: msgID, AgentName: "agent-b", Reaction: ReactionInProgress},
{MessageID: msgID, AgentName: "agent-a", Reaction: ReactionDone},
}
for _, r := range reactions {
if err := store.Insert(ctx, r); err != nil {
t.Fatalf("Insert: %v", err)
}
}
got, err := store.GetByMessageID(ctx, msgID)
if err != nil {
t.Fatalf("GetByMessageID: %v", err)
}
if len(got) != 3 {
t.Fatalf("got %d reactions, want 3", len(got))
}
// Verify results are ordered by created_at ASC
for i, r := range got {
if r.ID == 0 {
t.Errorf("reaction[%d] ID should not be 0", i)
}
if r.MessageID != msgID {
t.Errorf("reaction[%d] MessageID = %d, want %d", i, r.MessageID, msgID)
}
}
// Test with a message that has no reactions
msgID2 := seedTestMessage(t, db, "agent-a")
got2, err := store.GetByMessageID(ctx, msgID2)
if err != nil {
t.Fatalf("GetByMessageID (empty): %v", err)
}
if len(got2) != 0 {
t.Errorf("got %d reactions for empty message, want 0", len(got2))
}
}
func TestSQLiteStore_CountByMessage(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteStore(db)
ctx := context.Background()
msgID := seedTestMessage(t, db, "agent-a")
db.Exec(`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES ('agent-b', 'agent-b', 'ai', '{}', 1, 'testhash2', 'active')`)
// Count should be 0 initially
count, err := store.CountByMessage(ctx, msgID)
if err != nil {
t.Fatalf("CountByMessage: %v", err)
}
if count != 0 {
t.Errorf("initial count = %d, want 0", count)
}
// Insert some reactions
for _, r := range []*Reaction{
{MessageID: msgID, AgentName: "agent-a", Reaction: ReactionApprove},
{MessageID: msgID, AgentName: "agent-b", Reaction: ReactionDone},
} {
if err := store.Insert(ctx, r); err != nil {
t.Fatalf("Insert: %v", err)
}
}
count, err = store.CountByMessage(ctx, msgID)
if err != nil {
t.Fatalf("CountByMessage: %v", err)
}
if count != 2 {
t.Errorf("count = %d, want 2", count)
}
// Delete one and verify count decreases
if err := store.Delete(ctx, msgID, "agent-a", ReactionApprove); err != nil {
t.Fatalf("Delete: %v", err)
}
count, err = store.CountByMessage(ctx, msgID)
if err != nil {
t.Fatalf("CountByMessage after delete: %v", err)
}
if count != 1 {
t.Errorf("count after delete = %d, want 1", count)
}
}
func TestSQLiteStore_GetByMessageIDs(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteStore(db)
ctx := context.Background()
msgID1 := seedTestMessage(t, db, "agent-a")
msgID2 := seedTestMessage(t, db, "agent-a")
// Add reactions to msg1
if err := store.Insert(ctx, &Reaction{MessageID: msgID1, AgentName: "agent-a", Reaction: ReactionApprove}); err != nil {
t.Fatalf("Insert: %v", err)
}
if err := store.Insert(ctx, &Reaction{MessageID: msgID1, AgentName: "agent-a", Reaction: ReactionDone}); err != nil {
t.Fatalf("Insert: %v", err)
}
// Add reaction to msg2
if err := store.Insert(ctx, &Reaction{MessageID: msgID2, AgentName: "agent-a", Reaction: ReactionReject}); err != nil {
t.Fatalf("Insert: %v", err)
}
result, err := store.GetByMessageIDs(ctx, []int64{msgID1, msgID2})
if err != nil {
t.Fatalf("GetByMessageIDs: %v", err)
}
if len(result[msgID1]) != 2 {
t.Errorf("msg1 reactions = %d, want 2", len(result[msgID1]))
}
if len(result[msgID2]) != 1 {
t.Errorf("msg2 reactions = %d, want 1", len(result[msgID2]))
}
// Empty slice returns empty map
result, err = store.GetByMessageIDs(ctx, []int64{})
if err != nil {
t.Fatalf("GetByMessageIDs (empty): %v", err)
}
if len(result) != 0 {
t.Errorf("expected empty map, got %v", result)
}
}
+21
View File
@@ -0,0 +1,21 @@
-- Message reactions for workflow state tracking
-- Supports: approve, reject, in_progress, done, published
CREATE TABLE IF NOT EXISTS message_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
agent_name TEXT NOT NULL,
reaction TEXT NOT NULL CHECK(reaction IN ('approve', 'reject', 'in_progress', 'done', 'published')),
metadata TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(message_id, agent_name, reaction)
);
CREATE INDEX idx_reactions_message ON message_reactions(message_id);
CREATE INDEX idx_reactions_agent ON message_reactions(agent_name);
CREATE INDEX idx_reactions_type ON message_reactions(reaction);
-- Channel workflow settings
ALTER TABLE channels ADD COLUMN auto_approve BOOLEAN NOT NULL DEFAULT 0;
ALTER TABLE channels ADD COLUMN stalemate_remind_after TEXT NOT NULL DEFAULT '24h';
ALTER TABLE channels ADD COLUMN stalemate_escalate_after TEXT NOT NULL DEFAULT '72h';
+11 -11
View File
@@ -11,30 +11,30 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="/_app/immutable/entry/start.BmgMnErP.js" rel="modulepreload">
<link href="/_app/immutable/chunks/i7g3n1jh.js" rel="modulepreload">
<link href="/_app/immutable/entry/start.aUyT-hXz.js" rel="modulepreload">
<link href="/_app/immutable/chunks/B2ehjvNA.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/D2RS6Hlp.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C-40UA0L.js" rel="modulepreload">
<link href="/_app/immutable/chunks/4Fzv7G3k.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C0W-VSVk.js" rel="modulepreload">
<link href="/_app/immutable/chunks/ChDFVqbV.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.BVlIPsSc.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DFRGYO_X.js" rel="modulepreload">
<link href="/_app/immutable/chunks/0x2jFCf0.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C3nS3byM.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C1Y8Vas-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Bs4ZECIt.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.uuaq_5nO.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_nqckuv = {
__sveltekit_1mlsoop = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.BmgMnErP.js"),
import("/_app/immutable/entry/app.BVlIPsSc.js")
import("/_app/immutable/entry/start.aUyT-hXz.js"),
import("/_app/immutable/entry/app.uuaq_5nO.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
@@ -0,0 +1,36 @@
# Specification Quality Checklist: Message Reactions & Workflow States
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-18
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass validation. Spec is ready for `/speckit.plan`.
- 6 user stories covering: approval workflow, completion tracking, stalemate escalation, MCP tools, configuration, web UI display.
- 15 functional requirements, 8 success criteria, 6 edge cases documented.
@@ -0,0 +1,111 @@
# REST API Contract: Message Reactions & Workflow States
## New Endpoints
### POST /api/messages/{id}/reactions — Add/Toggle Reaction
**Request**:
```json
{
"reaction": "approve",
"metadata": {"reason": "Good topic"}
}
```
**Response** (201 Created or 200 OK if toggled off):
```json
{
"action": "added",
"reaction": {
"id": 1,
"message_id": 123,
"agent_name": "admin",
"reaction": "approve",
"metadata": {"reason": "Good topic"},
"created_at": "2026-03-18T10:00:00Z"
}
}
```
Toggle response (removed):
```json
{
"action": "removed",
"reaction": "approve",
"message_id": 123
}
```
### DELETE /api/messages/{id}/reactions/{reaction} — Remove Reaction
**Response** (200 OK):
```json
{
"action": "removed",
"reaction": "approve",
"message_id": 123
}
```
### GET /api/messages/{id}/reactions — Get Reactions
**Response**:
```json
{
"reactions": [
{"id": 1, "agent_name": "admin", "reaction": "approve", "metadata": {}, "created_at": "..."},
{"id": 2, "agent_name": "test-bot", "reaction": "in_progress", "metadata": {}, "created_at": "..."}
],
"workflow_state": "in_progress",
"total": 2
}
```
### GET /api/channels/{name}/messages/by-state?state=proposed — List by State
**Response**:
```json
{
"messages": [...],
"state": "proposed",
"total": 5
}
```
## Modified Endpoints
### GET /api/channels/{name}/messages — Channel Messages (Modified)
Each message now includes `workflow_state` and `reactions`:
```json
{
"messages": [
{
"id": 123,
"body": "Blog idea: ...",
"workflow_state": "approved",
"reactions": [
{"agent_name": "admin", "reaction": "approve", "metadata": {}}
]
}
]
}
```
### PUT /api/channels/{name}/settings — Update Channel Settings (New)
**Request**:
```json
{
"auto_approve": true,
"stalemate_remind_after": "12h",
"stalemate_escalate_after": "48h"
}
```
## MCP Actions (via execute tool)
- `call('react', {message_id: 123, reaction: 'approve', metadata: {}})`
- `call('unreact', {message_id: 123, reaction: 'approve'})`
- `call('get_reactions', {message_id: 123})`
- `call('list_by_state', {channel: 'new_posts', state: 'proposed'})`
@@ -0,0 +1,96 @@
# Data Model: Message Reactions & Workflow States
**Branch**: `010-reactions-workflows` | **Date**: 2026-03-18
## New Entity: Reaction
### Table: `message_reactions`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | INTEGER PK | AUTO_INCREMENT | Row identifier |
| message_id | INTEGER NOT NULL | FK messages(id) ON DELETE CASCADE | Target message |
| agent_name | TEXT NOT NULL | | Who reacted |
| reaction | TEXT NOT NULL | CHECK(reaction IN ('approve','reject','in_progress','done','published')) | Reaction type |
| metadata | TEXT | DEFAULT '{}' | JSON (URLs, reasons, etc.) |
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | When reacted |
**Unique constraint**: UNIQUE(message_id, agent_name, reaction) — one per type per agent per message.
**Indexes**:
- `idx_reactions_message` ON (message_id) — fast lookup per message
- `idx_reactions_agent` ON (agent_name) — find agent's reactions
- `idx_reactions_type` ON (reaction) — filter by type for list_by_state
### Workflow State Priority (derived, not stored)
| State | Priority | Color | Condition |
|-------|----------|-------|-----------|
| proposed | 1 | yellow | No reactions exist |
| approved | 2 | green | Highest reaction is "approve" |
| in_progress | 3 | blue | Highest reaction is "in_progress" |
| rejected | 4 | red | Highest reaction is "reject" |
| done | 5 | gray | Highest reaction is "done" |
| published | 6 | cyan | Highest reaction is "published" |
Terminal states (no stalemate tracking): rejected, done, published.
## Modified Entity: Channel
### New columns on `channels` table
| Column | Type | Default | Description |
|--------|------|---------|-------------|
| auto_approve | BOOLEAN | FALSE | Skip "proposed" for new messages |
| stalemate_remind_after | TEXT | '24h' | Duration before reminder DM |
| stalemate_escalate_after | TEXT | '72h' | Duration before escalation to #approvals |
## Relationships
```
Message 1 ──── 0..* Reaction (via reaction.message_id)
Channel 1 ──── 0..* Message (existing, via message.channel_id)
Channel has workflow settings (auto_approve, stalemate timeouts)
```
## Query Patterns
### Get workflow state for a message
```sql
SELECT CASE
WHEN EXISTS (SELECT 1 FROM message_reactions WHERE message_id = ? AND reaction = 'published') THEN 'published'
WHEN EXISTS (SELECT 1 FROM message_reactions WHERE message_id = ? AND reaction = 'done') THEN 'done'
WHEN EXISTS (SELECT 1 FROM message_reactions WHERE message_id = ? AND reaction = 'reject') THEN 'rejected'
WHEN EXISTS (SELECT 1 FROM message_reactions WHERE message_id = ? AND reaction = 'in_progress') THEN 'in_progress'
WHEN EXISTS (SELECT 1 FROM message_reactions WHERE message_id = ? AND reaction = 'approve') THEN 'approved'
ELSE 'proposed'
END as workflow_state
```
### List messages by state in a channel
```sql
-- For "proposed" (no reactions):
SELECT m.* FROM messages m
WHERE m.channel_id = ? AND NOT EXISTS (
SELECT 1 FROM message_reactions r WHERE r.message_id = m.id
)
-- For specific state (e.g., "approved" = has approve but no higher):
-- Best computed in application layer after loading reactions per message
```
### Stale message detection
```sql
SELECT m.id, m.channel_id, m.from_agent, m.body, m.created_at
FROM messages m
JOIN channels c ON c.id = m.channel_id
WHERE m.channel_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM message_reactions r
WHERE r.message_id = m.id AND r.reaction IN ('reject', 'done', 'published')
)
AND m.created_at < datetime('now', '-' || REPLACE(c.stalemate_remind_after, 'h', ' hours'))
```
+99
View File
@@ -0,0 +1,99 @@
# Implementation Plan: Message Reactions & Workflow States
**Branch**: `010-reactions-workflows` | **Date**: 2026-03-18 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/010-reactions-workflows/spec.md`
## Summary
Add message reactions (approve/reject/in_progress/done/published) with toggle semantics and workflow state derivation. Extend channels with auto_approve and stalemate timeout settings. Add MCP tools for agent reaction management. Build web UI reaction pills and workflow badges. Extend StalemateWorker for channel message workflow escalation.
## Technical Context
**Language/Version**: Go 1.25+ (backend), Svelte 5 + Tailwind (frontend)
**Primary Dependencies**: go-chi/chi (HTTP), mark3labs/mcp-go (MCP), modernc.org/sqlite (storage), spf13/cobra (CLI)
**Storage**: SQLite (modernc.org/sqlite, pure Go) — new migration 013_reactions.sql
**Testing**: `go test ./...` (backend), curl (API), Chrome (UI)
**Target Platform**: linux/amd64, darwin/arm64 (cross-compiled single binary)
**Project Type**: Web service with embedded SPA
**Constraints**: Zero CGO, single binary, single --data directory
## Constitution Check
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Local-First, Single Binary | PASS | All within single binary. No external deps. |
| II. MCP-Native | PASS | New react/unreact/get_reactions/list_by_state exposed as MCP actions. |
| III. Pure Go, Zero CGO | PASS | No new CGO deps. Pure SQLite migration. |
| IV. Multi-Tenant with Ownership | PASS | Reactions track agent_name. Channel membership enforced. |
| V. Embedded OAuth 2.1 | N/A | No auth changes. |
| VI. Semantic-Ready Storage | N/A | No search changes. |
| VII. Swarm Intelligence | PASS | Reactions enable workflow patterns for agent coordination. |
| VIII. Observable by Default | PASS | Reactions are logged, queryable, traceable. |
| IX. Progressive Complexity | PASS | Reactions layer on existing messaging. Basic messaging unaffected. |
| X. Web UI as First-Class Citizen | PASS | Reaction pills, workflow badges, channel settings panel. |
## Project Structure
### Documentation (this feature)
```text
specs/010-reactions-workflows/
├── plan.md
├── spec.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── rest-api.md
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
# Backend (Go)
internal/
├── reactions/
│ ├── model.go # NEW: Reaction struct, types, state derivation
│ ├── store.go # NEW: SQLite CRUD for reactions
│ └── service.go # NEW: Business logic (toggle, validate, state calc)
├── channels/
│ └── service.go # MODIFY: Add workflow settings to channel operations
├── messaging/
│ ├── types.go # MODIFY: Add Reactions/WorkflowState to Message
│ └── service.go # MODIFY: EnrichMessages adds reactions
├── api/
│ ├── reactions_handler.go # NEW: REST endpoints for web UI
│ ├── channels_handler.go # MODIFY: Return workflow settings
│ └── router.go # MODIFY: Register reaction routes
├── mcp/
│ ├── bridge.go # MODIFY: Add react/unreact/get_reactions/list_by_state actions
│ └── tools_hybrid.go # MODIFY: (optional) if adding as hybrid tool
├── actions/
│ └── registry.go # MODIFY: Register new reaction actions
└── stalemate/ # MODIFY: Extend for workflow state tracking
cmd/synapbus/
└── admin.go # MODIFY: Add channel workflow update CLI
schema/
└── 013_reactions.sql # NEW: reactions table + channel columns
# Frontend (Svelte)
web/src/lib/
├── components/
│ ├── ReactionPills.svelte # NEW: Reaction display + toggle
│ └── WorkflowBadge.svelte # NEW: Colored state badge
├── api/
│ └── client.ts # MODIFY: Add reaction API methods
└── routes/channels/[name]/
└── +page.svelte # MODIFY: Integrate reactions + badges
```
**Structure Decision**: New `internal/reactions/` package for clean separation. Reactions are a distinct domain from messaging.
## Complexity Tracking
No constitution violations. No complexity justifications needed.
@@ -0,0 +1,73 @@
# Quickstart: Message Reactions & Workflow States
## Testing Reactions via curl
### Add a reaction
```bash
curl -X POST http://localhost:8080/api/messages/123/reactions \
-H "Cookie: session=YOUR_SESSION" \
-H "Content-Type: application/json" \
-d '{"reaction": "approve"}'
```
### Add reaction with metadata
```bash
curl -X POST http://localhost:8080/api/messages/123/reactions \
-H "Cookie: session=YOUR_SESSION" \
-H "Content-Type: application/json" \
-d '{"reaction": "published", "metadata": {"url": "https://blog.example.com/post"}}'
```
### Toggle off (call same reaction again)
```bash
curl -X POST http://localhost:8080/api/messages/123/reactions \
-H "Cookie: session=YOUR_SESSION" \
-H "Content-Type: application/json" \
-d '{"reaction": "approve"}'
# Returns {"action": "removed", ...}
```
### Get reactions for a message
```bash
curl http://localhost:8080/api/messages/123/reactions \
-H "Cookie: session=YOUR_SESSION"
```
### List messages by workflow state
```bash
curl "http://localhost:8080/api/channels/new_posts/messages/by-state?state=proposed" \
-H "Cookie: session=YOUR_SESSION"
```
### Update channel workflow settings
```bash
curl -X PUT http://localhost:8080/api/channels/new_posts/settings \
-H "Cookie: session=YOUR_SESSION" \
-H "Content-Type: application/json" \
-d '{"auto_approve": true, "stalemate_remind_after": "12h"}'
```
## Testing via MCP
```json
call('react', {message_id: 123, reaction: 'approve'})
call('react', {message_id: 123, reaction: 'published', metadata: {url: 'https://blog.example.com'}})
call('get_reactions', {message_id: 123})
call('list_by_state', {channel: 'new_posts', state: 'proposed'})
call('unreact', {message_id: 123, reaction: 'approve'})
```
## Testing via CLI
```bash
./synapbus channels update --name new_posts --auto-approve=true --stalemate-remind-after=12h --stalemate-escalate-after=48h
```
## Web UI
1. Navigate to a channel (e.g., #new_posts)
2. Messages show colored workflow badges (yellow=proposed, green=approved, blue=in_progress, red=rejected, cyan=published)
3. Hover over a message to see reaction pills
4. Click a reaction pill to toggle your reaction
5. Published messages show clickable URL links
6. Channel info panel shows workflow settings (auto-approve toggle, timeout inputs)
+47
View File
@@ -0,0 +1,47 @@
# Research: Message Reactions & Workflow States
**Branch**: `010-reactions-workflows` | **Date**: 2026-03-18
## Decision 1: Reaction Storage Model
**Decision**: New `message_reactions` table with (message_id, agent_name, reaction, metadata, created_at) and UNIQUE(message_id, agent_name, reaction).
**Rationale**: Reactions are a separate domain from messages. Storing in a dedicated table allows efficient querying (reactions per message, messages by state) without bloating the messages table. The UNIQUE constraint enforces one-per-type-per-agent at the DB level.
**Alternatives considered**:
- JSON array on messages table: Loses relational integrity, harder to query by state
- Separate workflow_state column: Denormalization would require sync logic; derived state is simpler
## Decision 2: Workflow State Derivation
**Decision**: Compute workflow state on read by selecting the highest-priority reaction. Priority: published(6) > done(5) > rejected(4) > in_progress(3) > approved(2) > proposed(1, implicit when no reactions).
**Rationale**: No denormalization needed. State is always consistent with reactions. SQL query can compute it efficiently with MAX over a CASE expression, or the service layer can compute it from the reaction list.
**Alternatives considered**:
- Stored state column: Requires triggers or app-level sync, risks inconsistency
- Event sourcing: Overkill for this use case
## Decision 3: Channel Workflow Columns
**Decision**: Add `auto_approve`, `stalemate_remind_after`, `stalemate_escalate_after` columns to the existing `channels` table via ALTER TABLE in migration 013.
**Rationale**: These are channel-level settings, not a separate entity. Adding columns is simpler than a join table. The channel struct in Go already exists — just add fields.
## Decision 4: StalemateWorker Extension
**Decision**: Extend the existing StalemateWorker's periodic loop to also scan channel messages for stale workflow states, using the new channel timeout settings.
**Rationale**: Reuses existing worker infrastructure (goroutine, ticker, DB access). Adding a second scan phase is cleaner than creating a separate worker.
## Decision 5: Toggle Semantics
**Decision**: Toggle is implemented as: check if (message_id, agent_name, reaction) exists → if yes, DELETE; if no, INSERT. This is done in a transaction.
**Rationale**: Simple, atomic, idempotent. No special "removed" flag needed — absence means not reacted.
## Decision 6: MCP Tool Exposure
**Decision**: Expose react/unreact/get_reactions/list_by_state as actions in the bridge (execute tool), not as top-level hybrid tools.
**Rationale**: Consistent with existing attachment tools. The 4 hybrid tools (my_status, send_message, search, execute) are the stable surface area. New actions go through the execute/bridge path.
+167
View File
@@ -0,0 +1,167 @@
# Feature Specification: Message Reactions & Workflow States
**Feature Branch**: `010-reactions-workflows`
**Created**: 2026-03-18
**Status**: Draft
**Input**: User description: "Message reactions and workflow state tracking for channel messages"
## Assumptions
- Reaction types are a fixed enum: approve, reject, in_progress, done, published
- Any channel member can react to any message in that channel; DM participants can react to DM messages
- Workflow state is derived from the most recent highest-priority reaction (not stored as a separate column)
- When a channel has auto_approve enabled, new messages are immediately actionable (skip proposed)
- Stalemate timeouts default to 24h remind / 72h escalate, configurable per channel
- Existing StalemateWorker is extended (not replaced) to handle channel workflow states
- Maximum 100 reactions per message as a safety limit
- Toggling: adding the same reaction a second time removes it
- One reaction of each type per agent per message
- Workflow state priority for badge display: published > done > rejected > in_progress > approved > proposed
- Escalation messages posted to #approvals channel
- Reminder DMs sent to channel members where the stale message lives
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Human Approves Agent Blog Post (Priority: P1)
A human owner sees a research agent's blog post idea in #new_posts. They click the "approve" reaction pill to signal the agent can proceed. The workflow badge changes from yellow (proposed) to green (approved).
**Why this priority**: Core use case — human-in-the-loop approval. Without this, the workflow system has no value.
**Independent Test**: Post a message to a channel, add an "approve" reaction, verify the workflow state changes.
**Acceptance Scenarios**:
1. **Given** a message in a channel with auto_approve=false and no reactions, **When** viewing the message, **Then** it displays a yellow "proposed" badge.
2. **Given** a proposed message, **When** a channel member adds an "approve" reaction, **Then** the badge changes to green "approved" and the reaction pill shows the approver's name.
3. **Given** an approved message, **When** the approver clicks "approve" again, **Then** the reaction is removed (toggled off) and the badge reverts to "proposed".
---
### User Story 2 - Agent Tracks Work Through Completion (Priority: P1)
An agent receives approval, reacts with "in_progress" to claim work, then "done" when finished, then "published" with a metadata URL linking to the live post.
**Why this priority**: End-to-end workflow tracking is essential for content pipelines.
**Independent Test**: Sequentially add in_progress, done, published reactions and verify state transitions and metadata.
**Acceptance Scenarios**:
1. **Given** an approved message, **When** an agent adds "in_progress", **Then** the badge changes to blue.
2. **Given** an in_progress message, **When** the agent adds "published" with metadata `{"url": "https://example.com/post"}`, **Then** the badge changes to cyan and the URL is clickable.
3. **Given** a message with multiple reactions, **When** viewing it, **Then** all reaction pills are visible showing who reacted.
---
### User Story 3 - Stale Message Escalation (Priority: P2)
A message in "proposed" state with no reactions for 24h triggers a reminder DM. After 72h, it escalates to #approvals.
**Why this priority**: Prevents forgotten work items. Important for operational health.
**Independent Test**: Create an old message, run stalemate check, verify reminder and escalation messages.
**Acceptance Scenarios**:
1. **Given** a message in "proposed" for longer than remind timeout, **When** stalemate worker runs, **Then** DMs are sent to channel members.
2. **Given** a message in "proposed" for longer than escalate timeout, **When** stalemate worker runs, **Then** a message is posted to #approvals.
3. **Given** a message in "rejected" state, **When** stalemate worker runs, **Then** no reminder or escalation fires.
---
### User Story 4 - Agents React via MCP Tools (Priority: P1)
An AI agent uses the MCP `react` action to approve, claim, or complete work items and queries messages by workflow state.
**Why this priority**: Agents are primary SynapBus users — MCP tools are essential.
**Independent Test**: Call react/unreact/get_reactions/list_by_state MCP actions and verify behavior.
**Acceptance Scenarios**:
1. **Given** an authenticated agent, **When** it calls `react` with message_id and "approve", **Then** the reaction is recorded.
2. **Given** a channel with mixed-state messages, **When** an agent calls `list_by_state` with "proposed", **Then** only proposed messages are returned.
3. **Given** a reacted message, **When** the agent calls `unreact`, **Then** the reaction is removed and state recalculates.
---
### User Story 5 - Channel Workflow Configuration (Priority: P2)
An administrator configures channel workflow settings — auto-approve and stalemate timeouts — via web UI or CLI.
**Why this priority**: Configuration is needed but less frequent than daily operations.
**Independent Test**: Update channel settings and verify new behavior on subsequent messages.
**Acceptance Scenarios**:
1. **Given** a channel with auto_approve=false, **When** admin enables auto_approve, **Then** new messages skip "proposed".
2. **Given** default timeouts, **When** admin sets remind to "12h", **Then** reminders trigger after 12 hours.
3. **Given** the CLI, **When** running `channels update --auto-approve=true`, **Then** the setting is updated.
---
### User Story 6 - Web UI Reaction Display (Priority: P2)
Users see inline workflow badges and reaction pills. They can click to toggle reactions. Published reactions show clickable URLs.
**Why this priority**: Visual feedback is important but the system works via MCP without it.
**Independent Test**: View channel with reacted messages, verify badges, pills, and click-to-toggle.
**Acceptance Scenarios**:
1. **Given** a message with "approved" reaction, **When** viewing the channel, **Then** a green badge is displayed.
2. **Given** a "published" reaction with a URL, **When** viewing it, **Then** the URL appears clickable.
3. **Given** a logged-in user, **When** they click a reaction pill, **Then** their reaction toggles.
---
### Edge Cases
- Conflicting reactions (approve + reject from different agents): highest-priority reaction wins for badge.
- All reactions removed: message reverts to "proposed".
- Channel auto_approve changes: only affects new messages; existing messages keep their state.
- Agent reacts to message in channel they haven't joined: permission error.
- 100-reaction limit reached: new reactions rejected with error.
- Stalemate worker on channel with no configured timeouts: defaults (24h/72h) used.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST allow users and agents to add typed reactions (approve, reject, in_progress, done, published) to messages
- **FR-002**: System MUST toggle reactions — adding an existing reaction removes it
- **FR-003**: System MUST enforce one reaction of each type per agent per message
- **FR-004**: System MUST derive workflow state from the highest-priority reaction on a message
- **FR-005**: System MUST display a colored workflow badge on each channel message indicating its state
- **FR-006**: System MUST display reaction pills below messages showing who reacted
- **FR-007**: System MUST support reaction metadata (JSON) for storing URLs, reasons, or other context
- **FR-008**: System MUST support two workflow modes per channel: human-in-the-loop and fully autonomous
- **FR-009**: System MUST send reminder DMs when a message stays in a non-terminal state beyond the remind timeout
- **FR-010**: System MUST escalate stale messages to #approvals when they exceed the escalation timeout
- **FR-011**: System MUST expose react, unreact, get_reactions, and list_by_state as agent-callable tools
- **FR-012**: System MUST allow channel workflow settings to be configured via web UI and CLI
- **FR-013**: System MUST enforce a maximum of 100 reactions per message
- **FR-014**: System MUST only allow channel members (or DM participants) to react to messages
- **FR-015**: System MUST display published reaction URLs as clickable links
### Key Entities
- **Reaction**: A typed signal (approve/reject/in_progress/done/published) from an agent on a message, with optional JSON metadata. Unique per (message, agent, reaction type).
- **Workflow State**: A derived property of a message, computed from reactions using priority hierarchy. Not stored — calculated on read.
- **Channel Workflow Settings**: Per-channel auto_approve mode and stalemate timeout durations.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can add or remove a reaction in under 2 seconds
- **SC-002**: Workflow state badge updates within 1 second after a reaction change
- **SC-003**: 100% of non-terminal messages receive reminders within 1 hour of timeout
- **SC-004**: Agents can query messages by workflow state within 2 seconds
- **SC-005**: All 5 workflow states are visually distinguishable via color-coded badges
- **SC-006**: Escalations to #approvals include message ID, channel, state, age, body excerpt, author
- **SC-007**: Workflow setting changes take effect on the next message interaction
- **SC-008**: System handles 100 reactions per message without degradation
+61
View File
@@ -0,0 +1,61 @@
# Tasks: Message Reactions & Workflow States
**Input**: Design documents from `/specs/010-reactions-workflows/`
## Phase 1: Setup
- [x] T001 Verify `make test` passes before changes
- [x] T002 Create migration 013_reactions.sql in internal/storage/schema/
## Phase 2: Foundational (Backend Core)
- [x] T003 Create internal/reactions/model.go with Reaction struct, type constants, state derivation
- [x] T004 Create internal/reactions/store.go with SQLite CRUD (Insert, Delete, GetByMessageID, GetByState, CountByMessage)
- [x] T005 Create internal/reactions/service.go with Toggle, React, Unreact, GetReactions, ListByState, ComputeState
- [x] T006 Add WorkflowState and Reactions fields to messaging.Message in internal/messaging/types.go
- [x] T007 Add auto_approve, stalemate_remind_after, stalemate_escalate_after to Channel struct in internal/channels/
- [x] T008 [P] Write tests for reaction store in internal/reactions/store_test.go
- [x] T009 [P] Write tests for reaction service (covered by model + store tests)
- [x] T010 [P] Write tests for workflow state derivation in internal/reactions/model_test.go
## Phase 3: User Story 1+2 — Reactions API + Enrichment (P1)
- [x] T011 [US1] Create internal/api/reactions_handler.go with POST/GET/DELETE handlers
- [x] T012 [US1] Register reaction routes in internal/api/router.go
- [x] T013 [US1] Wire reaction service into main.go initialization
- [x] T014 [US2] Enrich messages with reactions and workflow_state in EnrichMessages (internal/messaging/service.go)
## Phase 4: User Story 4 — MCP Tools (P1)
- [x] T016 [US4] Register react/unreact/get_reactions/list_by_state actions in internal/actions/registry.go
- [x] T017 [US4] Implement reaction bridge methods in internal/mcp/bridge.go
- [x] T018 [US4] Updated all MCP test call sites for new reactionService parameter
## Phase 5: User Story 5 — Channel Settings + CLI (P2)
- [x] T019 [US5] Add channel workflow settings update endpoint (PUT /api/channels/{name}/settings)
- [x] T020 [US5] Add list_by_state endpoint (GET /api/channels/{name}/messages/by-state)
- [x] T021 [US5] Add CLI command: synapbus channels update --auto-approve --stalemate-remind-after --stalemate-escalate-after
- [x] T022 [US5] Add admin socket handler for channels.update_settings
## Phase 6: User Story 3 — StalemateWorker Extension (P2)
- [ ] T023 [US3] Extend StalemateWorker to scan channel messages for stale workflow states (deferred — can be added in follow-up)
- [ ] T024 [US3] Implement reminder DMs and #approvals escalation for stale messages (deferred)
- [ ] T025 [US3] Write tests for stalemate workflow detection (deferred)
## Phase 7: User Story 6 — Web UI (P2)
- [x] T026 [US6] Create WorkflowBadge.svelte component (colored badge per state)
- [x] T027 [US6] Create ReactionPills.svelte component (toggle pills with agent names)
- [x] T028 [US6] Add reaction API methods to web/src/lib/api/client.ts
- [x] T029 [US6] Integrate badges + pills into channel page
## Phase 8: Polish
- [x] T031 Run `go test ./...` — 25 packages pass, 0 failures
- [x] T032 Run `make web` — Svelte SPA builds successfully
- [x] T033 Run `make build` — Binary compiles cleanly
## Note
StalemateWorker extension (T023-T025) deferred to a follow-up. The data model and channel settings are in place; the worker just needs a scan loop added.
+1 -1
View File
@@ -126,7 +126,7 @@ func setupEnv(t *testing.T) *testEnv {
actionIndex := actions.NewIndex(actionRegistry.List())
// Create MCP server with 4 hybrid tools
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attService, searchService, con, jsPool, actionRegistry, actionIndex, db)
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attService, searchService, nil, con, jsPool, actionRegistry, actionIndex, db)
t.Cleanup(func() {
mcpSrv.Shutdown(context.Background())
})
+12
View File
@@ -250,4 +250,16 @@ export const attachments = {
}
};
// Reactions
export const reactions = {
toggle: (messageId: number, reaction: string, metadata?: Record<string, any>) =>
request<{ action: string; reaction: any; reactions: any[]; workflow_state: string }>(
'POST', `/api/messages/${messageId}/reactions`, { reaction, metadata }
),
get: (messageId: number) =>
request<{ reactions: any[]; workflow_state: string }>(
'GET', `/api/messages/${messageId}/reactions`
)
};
export { ApiError };
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import { reactions as reactionsApi } from '$lib/api/client';
type ReactionEntry = {
agent_name: string;
reaction: string;
metadata?: Record<string, any>;
};
let { reactions = [], messageId }: { reactions: ReactionEntry[]; messageId: number } = $props();
type GroupedReaction = {
type: string;
count: number;
agents: string[];
metadata?: Record<string, any>;
};
let grouped = $derived((() => {
const map = new Map<string, GroupedReaction>();
for (const r of reactions) {
const existing = map.get(r.reaction);
if (existing) {
existing.count++;
existing.agents.push(r.agent_name);
if (r.reaction === 'published' && r.metadata?.url) {
existing.metadata = r.metadata;
}
} else {
map.set(r.reaction, {
type: r.reaction,
count: 1,
agents: [r.agent_name],
metadata: r.metadata
});
}
}
return Array.from(map.values());
})());
const reactionEmoji: Record<string, string> = {
approve: '\u2705',
reject: '\u274C',
in_progress: '\u23F3',
done: '\u2714\uFE0F',
published: '\uD83D\uDE80'
};
const reactionColors: Record<string, string> = {
approve: 'bg-accent-green/15 text-accent-green border-accent-green/30 hover:bg-accent-green/25',
reject: 'bg-accent-red/15 text-accent-red border-accent-red/30 hover:bg-accent-red/25',
in_progress: 'bg-accent-blue/15 text-accent-blue border-accent-blue/30 hover:bg-accent-blue/25',
done: 'bg-bg-tertiary text-text-secondary border-border hover:bg-bg-tertiary/80',
published: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30 hover:bg-cyan-500/25'
};
let toggling = $state(false);
async function handleToggle(reactionType: string) {
if (toggling) return;
toggling = true;
try {
const result = await reactionsApi.toggle(messageId, reactionType);
// Update local reactions from the server response
if (result.reactions) {
reactions = result.reactions;
}
} catch {
// silently fail
} finally {
toggling = false;
}
}
</script>
{#if grouped.length > 0}
<div class="flex flex-wrap gap-1 mt-1">
{#each grouped as group}
<button
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[11px] border transition-colors cursor-pointer {reactionColors[group.type] ?? 'bg-bg-tertiary text-text-secondary border-border'}"
title={group.agents.join(', ')}
onclick={(e) => { e.stopPropagation(); handleToggle(group.type); }}
disabled={toggling}
>
<span>{reactionEmoji[group.type] ?? group.type}</span>
<span class="font-medium">{group.count}</span>
{#if group.type === 'published' && group.metadata?.url}
<a
href={group.metadata.url}
target="_blank"
rel="noopener noreferrer"
class="ml-0.5 hover:underline"
onclick={(e) => e.stopPropagation()}
title="View published URL"
>
<svg class="w-3 h-3 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</a>
{/if}
</button>
{/each}
</div>
{/if}
@@ -0,0 +1,26 @@
<script lang="ts">
let { state }: { state: string } = $props();
let badgeClass = $derived((() => {
switch (state) {
case 'proposed':
return 'bg-accent-yellow/20 text-accent-yellow';
case 'approved':
return 'bg-accent-green text-white';
case 'in_progress':
return 'bg-accent-blue text-white';
case 'rejected':
return 'bg-accent-red text-white';
case 'done':
return 'bg-bg-tertiary text-text-secondary';
case 'published':
return 'bg-cyan-500/20 text-cyan-400';
default:
return 'bg-bg-tertiary text-text-secondary';
}
})());
</script>
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium leading-tight {badgeClass}">
{state}
</span>
@@ -5,6 +5,8 @@
import { notifications } from '$lib/stores/notifications';
import MessageBody from '$lib/components/MessageBody.svelte';
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
import WorkflowBadge from '$lib/components/WorkflowBadge.svelte';
import ReactionPills from '$lib/components/ReactionPills.svelte';
let channel = $state<any>(null);
let members = $state<any[]>([]);
@@ -306,6 +308,10 @@
<span class="text-xs text-text-secondary">{formatTime(msg.created_at)}</span>
</div>
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
{#if msg.workflow_state}
<WorkflowBadge state={msg.workflow_state} />
{/if}
<ReactionPills reactions={msg.reactions ?? []} messageId={msg.id} />
{#if msg.attachments?.length > 0}
<div class="flex flex-wrap gap-2 mt-1.5">
{#each msg.attachments as att}