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:
co-authored by
Claude Opus 4.6
parent
6ed4ce931a
commit
e51adc376e
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ func newTestHybridWithChannels(t *testing.T) (*HybridToolRegistrar, *channels.Se
|
||||
nil, // swarmService
|
||||
nil, // attachmentService
|
||||
nil, // searchService
|
||||
nil, // reactionService
|
||||
jsPool,
|
||||
actionRegistry,
|
||||
actionIndex,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ func newTestHybridRegistrar(t *testing.T) (*HybridToolRegistrar, *messaging.Mess
|
||||
nil, // swarmService
|
||||
nil, // attachmentService
|
||||
nil, // searchService
|
||||
nil, // reactionService
|
||||
jsPool,
|
||||
actionRegistry,
|
||||
actionIndex,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
Vendored
+11
-11
@@ -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'))
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user