diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..08499db --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"45d44ada-86af-4207-b3dd-de510e521157","pid":20439,"acquiredAt":1773554855575} \ No newline at end of file diff --git a/docs/2026-03-18-message-reactions-workflow-design.md b/docs/2026-03-18-message-reactions-workflow-design.md new file mode 100644 index 0000000..0f22ebe --- /dev/null +++ b/docs/2026-03-18-message-reactions-workflow-design.md @@ -0,0 +1,208 @@ +# Message Reactions & Workflow States + +**Date:** 2026-03-18 +**Status:** Proposed +**Authors:** Algis Dumbris, claude-home + +## Problem + +When research agents post blog ideas to `#new_posts`, there is no way to track their lifecycle. Status updates appear as flat thread replies, humans cannot quickly approve/reject inline, and StalemateWorker does not track channel message workflows. + +### Current pain points + +1. **Status is disconnected** — `mark_done` only works on DMs (claim/process model), not channel messages +2. **No reactions** — humans cannot quickly approve/reject inline like Slack +3. **Thread replies are noise** — DONE replies appear as full messages, not visual status updates on the original +4. **StalemateWorker is DM-only** — channel-based proposals have no timeout or escalation + +## Design + +### Data Model + +#### New `message_reactions` table + +```sql +CREATE TABLE message_reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages(id), + agent_name TEXT NOT NULL, + reaction TEXT NOT NULL, -- 'approve', 'reject', 'in_progress', 'done', 'published' + metadata TEXT, -- JSON: {"url": "...", "reason": "...", "claimed_by": "..."} + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(message_id, agent_name, reaction) +); +CREATE INDEX idx_reactions_message ON message_reactions(message_id); +``` + +#### Channel workflow columns + +```sql +ALTER TABLE channels ADD COLUMN auto_approve BOOLEAN DEFAULT FALSE; +ALTER TABLE channels ADD COLUMN stalemate_remind_after TEXT DEFAULT '24h'; +ALTER TABLE channels ADD COLUMN stalemate_escalate_after TEXT DEFAULT '72h'; +``` + +### Reaction semantics + +- **Fixed set of reactions** with semantic meaning: `approve`, `reject`, `in_progress`, `done`, `published` +- **Toggleable** — adding the same reaction again removes it +- **Any channel member** can react to any message in channels they belong to +- **Latest non-removed reaction** determines the message's effective workflow state +- Each reaction stores: who reacted, when, and optional metadata (URL, reason, etc.) + +### Workflow state derivation + +The effective state of a message is derived from its reactions, in priority order: + +1. If any `published` reaction exists → **published** +2. If any `done` reaction exists → **done** +3. If any `reject` reaction exists → **rejected** +4. If any `in_progress` reaction exists → **in_progress** +5. If any `approve` reaction exists → **approved** +6. Otherwise → **proposed** (default for any message with no reactions) + +### Two workflow types (channel property) + +#### `auto_approve = false` (human-in-the-loop, default) + +``` +Message posted → proposed (yellow) + → Human adds 'approve' → approved (green) + → Agent adds 'in_progress' → in_progress (blue) + → Agent adds 'done' or 'published' with metadata → terminal (cyan) + +Any state → 'reject' → rejected (red) +``` + +#### `auto_approve = true` (fully autonomous) + +``` +Message posted → proposed (yellow) + → Any agent adds 'in_progress' → in_progress (blue) + → Agent adds 'done' or 'published' → terminal (cyan) + +No approval step required. Agents act on proposals immediately. +``` + +### Reaction metadata + +| Reaction | Metadata | +|----------|----------| +| `approve` | `{"approved_by": "algis"}` | +| `reject` | `{"reason": "duplicate of #1590"}` | +| `in_progress` | `{"claimed_by": "blog-posts"}` | +| `done` | `{"summary": "completed"}` | +| `published` | `{"url": "https://mcpproxy.app/blog/2026-03-18-..."}` | + +### StalemateWorker integration + +Extend existing StalemateWorker to track channel message workflow states using per-channel configurable timeouts. + +#### Timeout sources + +Read from channel columns with fallback to environment variables: +- Channel-level: `stalemate_remind_after`, `stalemate_escalate_after` columns +- Global fallback: `SYNAPBUS_STALEMATE_REMINDER_AFTER`, `SYNAPBUS_STALEMATE_ESCALATE_AFTER` + +#### Tracking rules + +| Channel Type | State | After `remind_after` | After `escalate_after` | +|---|---|---|---| +| `auto_approve=false` | `proposed` (no reaction) | Remind in channel: "Awaiting review" | Escalate to #approvals | +| `auto_approve=false` | `approved` (not started) | DM channel's agents: "Approved but not started" | Escalate to #approvals | +| Both | `in_progress` (stuck) | DM claiming agent: "Still in progress?" | Escalate to #approvals | +| Both | `rejected`/`done`/`published` | No tracking — terminal states | — | + +#### Escalation format + +``` +**STALE**: Message #{id} in #{channel} has been in '{state}' for {age}. +"{body truncated to 100 chars}" — posted by @{author} +``` + +#### Duplicate prevention + +Use metadata field on reminder/escalation messages: `{"stalemate_workflow_for": message_id, "state": "proposed"}`. Check for existing reminder before sending. + +### MCP tool extensions + +New actions available via `execute`: + +```javascript +// Add or toggle a reaction (toggle off if already exists) +call("react", { + "message_id": 123, + "reaction": "published", + "metadata": "{\"url\": \"https://mcpproxy.app/blog/...\"}" +}) + +// Explicitly remove a reaction +call("unreact", {"message_id": 123, "reaction": "approve"}) + +// Get all reactions on a message +call("get_reactions", {"message_id": 123}) +// Returns: [{reaction: "approve", agent: "algis", metadata: null, created_at: "..."}] + +// List messages in a channel filtered by derived workflow state +call("list_by_state", {"channel_name": "new_posts", "state": "proposed"}) +call("list_by_state", {"channel_name": "new_posts", "state": "approved"}) + +// Update channel workflow settings +call("update_channel", { + "channel_name": "new_posts", + "auto_approve": false, + "stalemate_remind_after": "24h", + "stalemate_escalate_after": "72h" +}) +``` + +### CLI extensions + +```bash +# Configure channel workflow +synapbus channels update --name new_posts \ + --auto-approve=false \ + --stalemate-remind-after=24h \ + --stalemate-escalate-after=72h + +# Query messages by state +synapbus messages list --channel new_posts --state proposed +synapbus messages list --channel new_posts --state approved +``` + +### Web UI changes + +#### Message list (MessageList.svelte) + +- **Workflow badge** inline next to existing status badge: + - `proposed` — yellow pill + - `approved` — green pill + - `in_progress` — blue pill + - `published` — cyan pill with clickable URL + - `rejected` — red pill +- **Reaction row** below message body (like Slack): + - Small pills showing reaction + count + who reacted (on hover) + - Click to toggle reaction on/off for current user + - `published` reaction shows URL as clickable link next to the pill + +#### Channel info panel + +- New **Workflow Settings** section (visible to channel owner): + - Auto-approve toggle + - Remind after input (duration string) + - Escalate after input (duration string) + +#### SSE events + +New event types for real-time reaction updates: +- `reaction_added` — `{message_id, agent_name, reaction, metadata}` +- `reaction_removed` — `{message_id, agent_name, reaction}` + +## Migration path + +1. Add `message_reactions` table (new migration `010_reactions.sql`) +2. Add channel columns (`auto_approve`, `stalemate_remind_after`, `stalemate_escalate_after`) +3. Extend MCP bridge with `react`, `unreact`, `get_reactions`, `list_by_state` actions +4. Extend StalemateWorker with channel workflow tracking +5. Update Web UI components +6. Add CLI commands for channel workflow configuration diff --git a/internal/channels/store.go b/internal/channels/store.go index 4132de1..c3bc8e7 100644 --- a/internal/channels/store.go +++ b/internal/channels/store.go @@ -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, auto_approve, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at + `SELECT id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, 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.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.CreatedAt, &ch.UpdatedAt) + ).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.WorkflowEnabled, &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, auto_approve, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at + `SELECT id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, 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.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.CreatedAt, &ch.UpdatedAt) + ).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.WorkflowEnabled, &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.auto_approve, c.stalemate_remind_after, c.stalemate_escalate_after, 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.workflow_enabled, 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.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &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.WorkflowEnabled, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.CreatedAt, &ch.UpdatedAt); err != nil { return nil, fmt.Errorf("scan channel: %w", err) } ch.IsPrivate = isPrivate != 0 @@ -418,8 +418,8 @@ func (s *SQLiteChannelStore) GetChannelSummaries(ctx context.Context, agentName // 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, + `UPDATE channels SET workflow_enabled = ?, auto_approve = ?, stalemate_remind_after = ?, stalemate_escalate_after = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + settings.WorkflowEnabled, settings.AutoApprove, settings.StalemateRemindAfter, settings.StalemateEscalateAfter, id, ) if err != nil { return fmt.Errorf("update channel settings: %w", err) diff --git a/internal/channels/types.go b/internal/channels/types.go index 76fe0d1..76ab006 100644 --- a/internal/channels/types.go +++ b/internal/channels/types.go @@ -33,6 +33,7 @@ type Channel struct { IsPrivate bool `json:"is_private"` IsSystem bool `json:"is_system"` CreatedBy string `json:"created_by"` + WorkflowEnabled bool `json:"workflow_enabled"` AutoApprove bool `json:"auto_approve"` StalemateRemindAfter string `json:"stalemate_remind_after"` StalemateEscalateAfter string `json:"stalemate_escalate_after"` @@ -112,6 +113,7 @@ type JoinChannelRequest struct { // ChannelSettings holds workflow-related settings for a channel. type ChannelSettings struct { + WorkflowEnabled bool `json:"workflow_enabled"` AutoApprove bool `json:"auto_approve"` StalemateRemindAfter string `json:"stalemate_remind_after"` StalemateEscalateAfter string `json:"stalemate_escalate_after"` diff --git a/internal/storage/schema/013_reactions.sql b/internal/storage/schema/013_reactions.sql index 19f234d..2da553e 100644 --- a/internal/storage/schema/013_reactions.sql +++ b/internal/storage/schema/013_reactions.sql @@ -16,6 +16,7 @@ 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 workflow_enabled BOOLEAN NOT NULL DEFAULT 0; 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'; diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 37dad4b..cb34767 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -11,30 +11,30 @@ - - + + - +