Skip to content

feat: add mute/unmute channel support with full test coverage#100

Merged
khaliqgant merged 12 commits into
mainfrom
mute-channel
Mar 24, 2026
Merged

feat: add mute/unmute channel support with full test coverage#100
khaliqgant merged 12 commits into
mainfrom
mute-channel

Conversation

@khaliqgant
Copy link
Copy Markdown
Member

@khaliqgant khaliqgant commented Mar 24, 2026

Summary

  • Add POST /v1/channels/:name/mute and POST /v1/channels/:name/unmute endpoints with agent-token auth
  • Add muteChannel, unmuteChannel, getMutedMemberIds engine functions with membership validation
  • Add isMuted column to channel_members schema, muted member filtering in ChannelDO fanout
  • Add updateChannelMuted fanout helper to sync DO muted cache
  • Add channels.mute() / channels.unmute() SDK methods and MuteChannelResponse type
  • Add member.channel_muted / member.channel_unmuted event types and webhook queue events
  • Add 8 route tests (mute: success, not-a-member 403, not-found 404, ws-key rejection 401; same for unmute)
  • Add 2 SDK tests (mute/unmute URL and method verification)

Test plan

  • All 23 channel route tests pass (8 new)
  • All 29 SDK agent-features tests pass (2 new)
  • Full turbo test suite passes (13/13 tasks)

🤖 Generated with Claude Code


Open with Devin

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>
devin-ai-integration[bot]

This comment was marked as resolved.

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.
devin-ai-integration[bot]

This comment was marked as resolved.

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
devin-ai-integration[bot]

This comment was marked as resolved.

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.
devin-ai-integration[bot]

This comment was marked as resolved.

…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.
devin-ai-integration[bot]

This comment was marked as resolved.

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.
khaliqgant added a commit that referenced this pull request Mar 24, 2026
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.
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_namechannel — 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.

Open in Devin Review

khaliqgant added a commit that referenced this pull request Mar 24, 2026
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.
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_namechannel — 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.

Open in Devin Review

Comment on lines +185 to +199
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),
});
}
}
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Open in Devin Review

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.
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_namechannel — 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.

Open 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.
@github-actions
Copy link
Copy Markdown

Preview deployed!

Environment URL
API https://pr100-api.relaycast.dev
Health https://pr100-api.relaycast.dev/health
Observer https://pr100-observer.relaycast.dev/observer

This preview shares the staging database and will be cleaned up when the PR is merged or closed.

Run E2E tests

npm run e2e -- https://pr100-api.relaycast.dev --ci

Open observer dashboard

https://pr100-observer.relaycast.dev/observer

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_namechannel — 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.

Open 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
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_namechannel — 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.

Open in Devin Review

@khaliqgant khaliqgant merged commit 9268408 into main Mar 24, 2026
4 checks passed
@khaliqgant khaliqgant deleted the mute-channel branch March 24, 2026 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant