feat: add mute/unmute channel support with full test coverage#100
Conversation
Add mute/unmute endpoints, engine logic, DO fanout filtering, SDK methods, event types, and comprehensive route + SDK tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Mute filter now only suppresses message events (message.created, message) System/control events (channel.updated, member.joined, member.left, mute/unmute confirmations) are delivered to all members including muted. 2. Added mute/unmute endpoints to openapi.yaml per AGENTS.md mandate. 3. E2E: Handle 'agent already exists' by deregistering and re-registering. Fixes Deploy Preview CI failure when preview workspace has stale agents from previous runs.
1. E2E: Use relay.agents.delete() instead of non-existent deregister(). Fixes 'already exists' error in Deploy Preview when stale agents remain. 2. OpenAPI mute/unmute fixes (Devin review BUG_0002/0003/0004): - Response wrapped in 'data' envelope matching implementation - Security: agentToken only (removed workspaceKey — impl uses requireAgentToken) - Removed requestBody (impl derives agent from token, not body) - Added 403 for not-a-member error
1. Local Rust server: add mute_channel/unmute_channel handlers + routes. Adds channel_muted HashSet to Store. Parity check now passes (73/73). 2. E2E: use agents.delete() (not deregister which doesn't exist), fall back to agents.release(delete_agent: true), add 500ms propagation delay, and log cleanup failures for debugging.
D1 replication lag means delete may succeed but register still sees the old record. Retry up to 3 times with 1s/2s/4s backoff between delete and re-register attempts.
…ncy) Root cause: registerAgent did SELECT-then-INSERT. On D1 with read replicas, the SELECT could hit a stale replica that still sees a deleted agent, while the INSERT goes to the primary. This caused false 'already exists' errors when re-registering agents after deletion. Fix: INSERT directly and let the unique index (workspace_id, name) enforce uniqueness. Catch UNIQUE constraint violations and convert to the same 409 error. This is atomic and immune to read replica lag. Also simplified E2E: fallback to registerOrRotate (the server-supported idempotent path) instead of delete+retry loops.
Server: 1. DB migration 0003 for is_muted column on channel_members 2. Clear mute cache on channel leave (prevents mute-leave-rejoin bug) 3. Suppress thread.reply events for muted members (not just message.created) 4. registerAgent: INSERT-first with unique constraint catch (D1 TOCTOU fix) Local Rust server: 5. Fix double-wrapped response envelope in mute/unmute handlers 6. Add #[serde(default)] on channel_muted (backward compat with old snapshots) 7. Enforce mute in WebSocket delivery path (check channel_muted for message events) E2E: 8. Use registerOrRotate as fallback (server-supported idempotent path)
The is_muted column (migration 0003) doesn't exist on staging D1 because preview deployments skipped migrations. This caused ALL agent registration to fail since drizzle includes is_muted in channel_members INSERT statements. Added apply-d1-migrations step to preview workflow. The migration action is idempotent (wrangler tracks applied state) so safe to run on every preview deploy.
Migration 0003: ALTER TABLE channel_members ADD COLUMN is_muted INTEGER NOT NULL DEFAULT 0 Prepares the schema for the mute/unmute feature (PR #100). No code references this column yet — purely additive migration.
There was a problem hiding this comment.
Devin Review found 1 new potential issue.
🐛 1 issue in files not directly in the diff
🐛 Missing transformForClient cases for mute/unmute events delivers malformed WebSocket events (packages/server/src/engine/wsTransform.ts:214-217)
The wsTransform.ts transformForClient function has no explicit cases for member.channel_muted or member.channel_unmuted. These events fall through to the default case (packages/server/src/engine/wsTransform.ts:214-216) which spreads raw data fields as-is. The route sends { channel_name: name, agent_name: agent!.name } as event data (packages/server/src/routes/channel.ts:579), so clients receive channel_name instead of channel. But the Zod schema (packages/types/src/events.ts:152-156) and the SDK type ChannelMutedEvent expect channel, not channel_name. The analogous member.joined and member.left events (packages/server/src/engine/wsTransform.ts:159-171) have explicit transform cases that map d.channel_name → channel — the new events need the same treatment. Without this, all WebSocket mute/unmute events fail schema validation and SDK subscribers will see undefined for the channel field.
View 15 additional findings in Devin Review.
Migration 0003: ALTER TABLE channel_members ADD COLUMN is_muted INTEGER NOT NULL DEFAULT 0 Prepares the schema for the mute/unmute feature (PR #100). No code references this column yet — purely additive migration.
There was a problem hiding this comment.
Devin Review found 2 new potential issues.
🐛 1 issue in files not directly in the diff
🐛 Missing transformForClient cases for mute/unmute events delivers malformed WebSocket events (packages/server/src/engine/wsTransform.ts:214-217)
The wsTransform.ts transformForClient function has no explicit cases for member.channel_muted or member.channel_unmuted. These events fall through to the default case (packages/server/src/engine/wsTransform.ts:214-216) which spreads raw data fields as-is. The route sends { channel_name: name, agent_name: agent!.name } as event data (packages/server/src/routes/channel.ts:579), so clients receive channel_name instead of channel. But the Zod schema (packages/types/src/events.ts:152-156) and the SDK type ChannelMutedEvent expect channel, not channel_name. The analogous member.joined and member.left events (packages/server/src/engine/wsTransform.ts:159-171) have explicit transform cases that map d.channel_name → channel — the new events need the same treatment. Without this, all WebSocket mute/unmute events fail schema validation and SDK subscribers will see undefined for the channel field.
View 17 additional findings in Devin Review.
| if ((await this.getMutedMembers()).length === 0 && body.channelId && body.workspaceId) { | ||
| try { | ||
| const muted = await this.loadMutedFromDb(body.channelId); | ||
| if (muted.length > 0) { | ||
| this.mutedMembers = muted; | ||
| await this.state.storage.put('muted_members', muted); | ||
| } | ||
| } catch (err) { | ||
| this.logger.error('Failed to load muted members from D1', { | ||
| workspace_id: body.workspaceId, | ||
| channel_id: body.channelId, | ||
| ...toErrorDetails(err), | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
🔴 ChannelDO queries D1 for muted members on every broadcast when nobody is muted
In handleBroadcast, the muted-member cold-start fallback checks (await this.getMutedMembers()).length === 0 to decide whether to load from D1. Unlike the analogous member-loading check (which only fires for 0-member channels that rarely receive broadcasts), a channel with zero muted members is the overwhelmingly common case. After the first broadcast sets this.mutedMembers = [], subsequent broadcasts still see length === 0 and re-query D1 via loadMutedFromDb on every single broadcast — adding an unnecessary DB round-trip per message for every active channel. The fix is to use a separate boolean flag (e.g., mutedMembersLoaded) to distinguish "not yet loaded" from "loaded and empty", consistent with the null vs [] pattern already used by getMutedMembers() for the storage layer.
Was this helpful? React with 👍 or 👎 to provide feedback.
D1 puts constraint errors in .code ('SQLITE_CONSTRAINT_UNIQUE'),
not in the message string. Match both D1 and SQLite error formats
so duplicate registration returns 409 with 'agent_already_exists'
instead of a raw transport_error.
There was a problem hiding this comment.
Devin Review found 1 new potential issue.
🐛 1 issue in files not directly in the diff
🐛 Missing transformForClient cases for mute/unmute events delivers malformed WebSocket events (packages/server/src/engine/wsTransform.ts:214-217)
The wsTransform.ts transformForClient function has no explicit cases for member.channel_muted or member.channel_unmuted. These events fall through to the default case (packages/server/src/engine/wsTransform.ts:214-216) which spreads raw data fields as-is. The route sends { channel_name: name, agent_name: agent!.name } as event data (packages/server/src/routes/channel.ts:579), so clients receive channel_name instead of channel. But the Zod schema (packages/types/src/events.ts:152-156) and the SDK type ChannelMutedEvent expect channel, not channel_name. The analogous member.joined and member.left events (packages/server/src/engine/wsTransform.ts:159-171) have explicit transform cases that map d.channel_name → channel — the new events need the same treatment. Without this, all WebSocket mute/unmute events fail schema validation and SDK subscribers will see undefined for the channel field.
View 17 additional findings in Devin Review.
D1 and drizzle wrap SQLite constraint errors in different ways: - D1: .code = 'SQLITE_CONSTRAINT_UNIQUE' - D1 message: 'D1_ERROR: UNIQUE constraint failed: ...' - Drizzle: may nest original error as .cause Added isUniqueConstraintError() that checks .code, .message (case- insensitive), and recursively checks .cause for drizzle wrapping.
|
Preview deployed!
This preview shares the staging database and will be cleaned up when the PR is merged or closed. Run E2E testsnpm run e2e -- https://pr100-api.relaycast.dev --ciOpen observer dashboard |
There was a problem hiding this comment.
Devin Review found 1 new potential issue.
🐛 1 issue in files not directly in the diff
🐛 Missing transformForClient cases for mute/unmute events delivers malformed WebSocket events (packages/server/src/engine/wsTransform.ts:214-217)
The wsTransform.ts transformForClient function has no explicit cases for member.channel_muted or member.channel_unmuted. These events fall through to the default case (packages/server/src/engine/wsTransform.ts:214-216) which spreads raw data fields as-is. The route sends { channel_name: name, agent_name: agent!.name } as event data (packages/server/src/routes/channel.ts:579), so clients receive channel_name instead of channel. But the Zod schema (packages/types/src/events.ts:152-156) and the SDK type ChannelMutedEvent expect channel, not channel_name. The analogous member.joined and member.left events (packages/server/src/engine/wsTransform.ts:159-171) have explicit transform cases that map d.channel_name → channel — the new events need the same treatment. Without this, all WebSocket mute/unmute events fail schema validation and SDK subscribers will see undefined for the channel field.
View 17 additional findings in Devin Review.
1. Local leave_channel clears muted state (prevents mute-leave-rejoin bug) 2. ChannelDO fanOut short-circuits mute filtering when nobody is muted (avoids Set creation and filter on every broadcast) 3. Robust unique constraint detection with recursive .cause checking
There was a problem hiding this comment.
Devin Review found 1 new potential issue.
🐛 1 issue in files not directly in the diff
🐛 Missing transformForClient cases for mute/unmute events delivers malformed WebSocket events (packages/server/src/engine/wsTransform.ts:214-217)
The wsTransform.ts transformForClient function has no explicit cases for member.channel_muted or member.channel_unmuted. These events fall through to the default case (packages/server/src/engine/wsTransform.ts:214-216) which spreads raw data fields as-is. The route sends { channel_name: name, agent_name: agent!.name } as event data (packages/server/src/routes/channel.ts:579), so clients receive channel_name instead of channel. But the Zod schema (packages/types/src/events.ts:152-156) and the SDK type ChannelMutedEvent expect channel, not channel_name. The analogous member.joined and member.left events (packages/server/src/engine/wsTransform.ts:159-171) have explicit transform cases that map d.channel_name → channel — the new events need the same treatment. Without this, all WebSocket mute/unmute events fail schema validation and SDK subscribers will see undefined for the channel field.
View 17 additional findings in Devin Review.
Summary
POST /v1/channels/:name/muteandPOST /v1/channels/:name/unmuteendpoints with agent-token authmuteChannel,unmuteChannel,getMutedMemberIdsengine functions with membership validationisMutedcolumn tochannel_membersschema, muted member filtering in ChannelDO fanoutupdateChannelMutedfanout helper to sync DO muted cachechannels.mute()/channels.unmute()SDK methods andMuteChannelResponsetypemember.channel_muted/member.channel_unmutedevent types and webhook queue eventsTest plan
turbo testsuite passes (13/13 tasks)🤖 Generated with Claude Code