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>
3.6 KiB
3.6 KiB
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_messageON (message_id) — fast lookup per messageidx_reactions_agentON (agent_name) — find agent's reactionsidx_reactions_typeON (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
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
-- 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
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'))