feat(M6-006,M6-007,M7-001,M7-002): admin jobs API, job event logging, channel adapter interface, message protocol (#325)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #325.
This commit is contained in:
333
docs/architecture/channel-protocol.md
Normal file
333
docs/architecture/channel-protocol.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Channel Protocol Architecture
|
||||
|
||||
**Status:** Draft
|
||||
**Authors:** Mosaic Core Team
|
||||
**Last Updated:** 2026-03-22
|
||||
**Covers:** M7-001 (IChannelAdapter interface) and M7-002 (ChannelMessage protocol)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The channel protocol defines a unified abstraction layer between Mosaic's core messaging infrastructure and the external communication channels it supports (Matrix, Discord, Telegram, TUI, WebUI, and future channels).
|
||||
|
||||
The protocol consists of two main contracts:
|
||||
|
||||
1. `IChannelAdapter` — the interface each channel driver must implement.
|
||||
2. `ChannelMessage` — the canonical message format that flows through the system.
|
||||
|
||||
All channel-specific translation logic lives inside the adapter implementation. The rest of Mosaic works exclusively with `ChannelMessage` objects.
|
||||
|
||||
---
|
||||
|
||||
## M7-001: IChannelAdapter Interface
|
||||
|
||||
```typescript
|
||||
interface IChannelAdapter {
|
||||
/**
|
||||
* Stable, lowercase identifier for this channel (e.g. "matrix", "discord").
|
||||
* Used as a namespace key in registry lookups and log metadata.
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* Establish a connection to the external channel backend.
|
||||
* Called once at application startup. Must be idempotent (safe to call
|
||||
* when already connected).
|
||||
*/
|
||||
connect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Gracefully disconnect from the channel backend.
|
||||
* Must flush in-flight sends and release resources before resolving.
|
||||
*/
|
||||
disconnect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Return the current health of the adapter connection.
|
||||
* Used by the admin health endpoint and alerting.
|
||||
*
|
||||
* - "connected" — fully operational
|
||||
* - "degraded" — partial connectivity (e.g. read-only, rate-limited)
|
||||
* - "disconnected" — no connection to channel backend
|
||||
*/
|
||||
health(): Promise<{ status: 'connected' | 'degraded' | 'disconnected' }>;
|
||||
|
||||
/**
|
||||
* Register an inbound message handler.
|
||||
* The adapter calls `handler` for every message received from the channel.
|
||||
* Multiple calls replace the previous handler (last-write-wins).
|
||||
* The handler is async; the adapter must not deliver new messages until
|
||||
* the previous handler promise resolves (back-pressure).
|
||||
*/
|
||||
onMessage(handler: (msg: ChannelMessage) => Promise<void>): void;
|
||||
|
||||
/**
|
||||
* Send a ChannelMessage to the given channel/room/conversation.
|
||||
* `channelId` is the channel-native identifier (e.g. Matrix room ID,
|
||||
* Discord channel snowflake, Telegram chat ID).
|
||||
*/
|
||||
sendMessage(channelId: string, msg: ChannelMessage): Promise<void>;
|
||||
|
||||
/**
|
||||
* Map a channel-native user identifier to the Mosaic internal userId.
|
||||
* Returns null when no matching Mosaic account exists for the given
|
||||
* channelUserId (anonymous or unlinked user).
|
||||
*/
|
||||
mapIdentity(channelUserId: string): Promise<string | null>;
|
||||
}
|
||||
```
|
||||
|
||||
### Adapter Registration
|
||||
|
||||
Adapters are registered with the `ChannelRegistry` service at startup. The registry calls `connect()` on each adapter and monitors `health()` on a configurable interval (default: 30 s).
|
||||
|
||||
```
|
||||
ChannelRegistry
|
||||
└── register(adapter: IChannelAdapter): void
|
||||
└── getAdapter(name: string): IChannelAdapter | null
|
||||
└── listAdapters(): IChannelAdapter[]
|
||||
└── healthAll(): Promise<Record<string, AdapterHealth>>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## M7-002: ChannelMessage Protocol
|
||||
|
||||
### Canonical Message Format
|
||||
|
||||
```typescript
|
||||
interface ChannelMessage {
|
||||
/**
|
||||
* Globally unique message ID.
|
||||
* Format: UUID v4. Generated by the adapter when receiving, or by Mosaic
|
||||
* when sending. Channel-native IDs are stored in metadata.channelMessageId.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Channel-native room/conversation/channel identifier.
|
||||
* The adapter populates this from the inbound message.
|
||||
* For outbound messages, the caller supplies the target channel.
|
||||
*/
|
||||
channelId: string;
|
||||
|
||||
/**
|
||||
* Channel-native identifier of the message sender.
|
||||
* For Mosaic-originated messages this is the Mosaic userId or agentId.
|
||||
*/
|
||||
senderId: string;
|
||||
|
||||
/** Sender classification. */
|
||||
senderType: 'user' | 'agent' | 'system';
|
||||
|
||||
/**
|
||||
* Textual content of the message.
|
||||
* For non-text content types (image, file) this may be an empty string
|
||||
* or an alt-text description; the actual payload is in `attachments`.
|
||||
*/
|
||||
content: string;
|
||||
|
||||
/**
|
||||
* Hint for how `content` should be interpreted and rendered.
|
||||
* - "text" — plain text, no special rendering
|
||||
* - "markdown" — CommonMark markdown
|
||||
* - "code" — code block (use metadata.language for the language tag)
|
||||
* - "image" — binary image; content is empty, see attachments
|
||||
* - "file" — binary file; content is empty, see attachments
|
||||
*/
|
||||
contentType: 'text' | 'markdown' | 'code' | 'image' | 'file';
|
||||
|
||||
/**
|
||||
* Arbitrary key-value metadata for channel-specific extension fields.
|
||||
* Examples: { channelMessageId, language, reactionEmoji, channelType }.
|
||||
* Adapters should store channel-native IDs here so round-trip correlation
|
||||
* is possible without altering the canonical fields.
|
||||
*/
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Optional thread or reply-chain identifier.
|
||||
* For threaded channels (Matrix, Discord threads, Telegram topics) this
|
||||
* groups messages into a logical thread scoped to the same channelId.
|
||||
*/
|
||||
threadId?: string;
|
||||
|
||||
/**
|
||||
* The canonical message ID this message is a reply to.
|
||||
* Maps to channel-native reply/quote mechanisms in each adapter.
|
||||
*/
|
||||
replyToId?: string;
|
||||
|
||||
/**
|
||||
* Binary or URI-referenced attachments.
|
||||
* Each attachment carries its MIME type and a URL or base64 payload.
|
||||
*/
|
||||
attachments?: ChannelAttachment[];
|
||||
|
||||
/** Wall-clock timestamp when the message was sent/received. */
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface ChannelAttachment {
|
||||
/** Filename or identifier. */
|
||||
name: string;
|
||||
|
||||
/** MIME type (e.g. "image/png", "application/pdf"). */
|
||||
mimeType: string;
|
||||
|
||||
/**
|
||||
* URL pointing to the attachment, OR a `data:` URI with base64 payload.
|
||||
* Adapters that receive file uploads SHOULD store to object storage and
|
||||
* populate a stable URL here rather than embedding the raw bytes.
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/** Size in bytes, if known. */
|
||||
sizeBytes?: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Channel Translation Reference
|
||||
|
||||
The following sections document how each supported channel maps its native message format to and from `ChannelMessage`.
|
||||
|
||||
### Matrix
|
||||
|
||||
| ChannelMessage field | Matrix equivalent |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) |
|
||||
| `channelId` | Matrix room ID (`!roomid:homeserver`) |
|
||||
| `senderId` | Matrix user ID (`@user:homeserver`) |
|
||||
| `senderType` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
|
||||
| `content` | `event.content.body` |
|
||||
| `contentType` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` |
|
||||
| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` |
|
||||
| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` |
|
||||
| `attachments` | Populated from `url` in `m.image` / `m.file` events |
|
||||
| `timestamp` | `new Date(event.origin_server_ts)` |
|
||||
| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` |
|
||||
|
||||
**Outbound:** Adapter sends `m.room.message` with `msgtype = m.text` (or `m.notice` for system messages). Markdown content is sent with `format = org.matrix.custom.html` and a rendered HTML body.
|
||||
|
||||
---
|
||||
|
||||
### Discord
|
||||
|
||||
| ChannelMessage field | Discord equivalent |
|
||||
| -------------------- | ----------------------------------------------------------------------- |
|
||||
| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake |
|
||||
| `channelId` | Discord channel ID (snowflake string) |
|
||||
| `senderId` | Discord user ID (snowflake) |
|
||||
| `senderType` | `"user"` for human members; `"agent"` for bot messages |
|
||||
| `content` | `message.content` |
|
||||
| `contentType` | `"markdown"` (Discord uses a markdown-like syntax natively) |
|
||||
| `threadId` | `message.thread.id` when the message is inside a thread channel |
|
||||
| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` |
|
||||
| `attachments` | `message.attachments` mapped to `ChannelAttachment` |
|
||||
| `timestamp` | `new Date(message.timestamp)` |
|
||||
| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` |
|
||||
|
||||
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
|
||||
|
||||
---
|
||||
|
||||
### Telegram
|
||||
|
||||
| ChannelMessage field | Telegram equivalent |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) |
|
||||
| `channelId` | Telegram `chat_id` (integer as string) |
|
||||
| `senderId` | Telegram `from.id` (integer as string) |
|
||||
| `senderType` | `"user"` for human senders; `"agent"` for bot-originated messages |
|
||||
| `content` | `message.text` or `message.caption` |
|
||||
| `contentType` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
|
||||
| `threadId` | `message.message_thread_id` (for supergroup topics) |
|
||||
| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` |
|
||||
| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachment` |
|
||||
| `timestamp` | `new Date(message.date * 1000)` |
|
||||
| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` |
|
||||
|
||||
**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentType = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
|
||||
|
||||
---
|
||||
|
||||
### TUI (Terminal UI)
|
||||
|
||||
The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the channel protocol so that TUI sessions can be treated as a first-class channel.
|
||||
|
||||
| ChannelMessage field | TUI equivalent |
|
||||
| -------------------- | ------------------------------------------------------------------ |
|
||||
| `id` | Generated UUID (TUI has no native message IDs) |
|
||||
| `channelId` | `"tui:<conversationId>"` — the active conversation ID |
|
||||
| `senderId` | Authenticated Mosaic `userId` |
|
||||
| `senderType` | `"user"` for human input; `"agent"` for agent replies |
|
||||
| `content` | Raw text from stdin / agent output |
|
||||
| `contentType` | `"text"` for input; `"markdown"` for agent responses |
|
||||
| `threadId` | Not used (TUI sessions are linear) |
|
||||
| `replyToId` | Not used |
|
||||
| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs |
|
||||
| `timestamp` | `new Date()` at the moment of send |
|
||||
| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` |
|
||||
|
||||
**Outbound:** The adapter writes rendered content to stdout. Markdown is rendered via a terminal markdown renderer (e.g. `marked-terminal`). Code blocks are syntax-highlighted when `metadata.colorSupport = true`.
|
||||
|
||||
---
|
||||
|
||||
### WebUI
|
||||
|
||||
The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`).
|
||||
|
||||
| ChannelMessage field | WebUI equivalent |
|
||||
| -------------------- | ------------------------------------------------------------ |
|
||||
| `id` | Generated UUID; echoed back in the WebSocket event |
|
||||
| `channelId` | `"webui:<conversationId>"` |
|
||||
| `senderId` | Authenticated Mosaic `userId` |
|
||||
| `senderType` | `"user"` for browser input; `"agent"` for agent responses |
|
||||
| `content` | Message text from the input field |
|
||||
| `contentType` | `"text"` or `"markdown"` |
|
||||
| `threadId` | Not used (conversation model handles threading) |
|
||||
| `replyToId` | Message ID the user replied to (UI reply affordance) |
|
||||
| `attachments` | Files uploaded via the file picker; stored to object storage |
|
||||
| `timestamp` | `new Date()` at send, or server timestamp from event |
|
||||
| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` |
|
||||
|
||||
**Outbound:** Adapter emits a `chat:message` Socket.IO event. The WebUI React component receives it and appends to the conversation list. Markdown content is rendered client-side via the existing markdown renderer component.
|
||||
|
||||
---
|
||||
|
||||
## Identity Mapping
|
||||
|
||||
`mapIdentity(channelUserId)` resolves a channel-native user identifier to a Mosaic `userId`. This is required to attribute inbound messages to authenticated Mosaic accounts.
|
||||
|
||||
The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context).
|
||||
|
||||
```
|
||||
channel_identities
|
||||
channel_name TEXT -- e.g. "matrix", "discord"
|
||||
channel_user_id TEXT -- channel-native user identifier
|
||||
mosaic_user_id TEXT -- FK to users.id
|
||||
linked_at TIMESTAMP
|
||||
PRIMARY KEY (channel_name, channel_user_id)
|
||||
```
|
||||
|
||||
Identity linking flows (OAuth dance, deep-link verification token, etc.) are out of scope for this document and will be specified in a separate identity-linking protocol document.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Conventions
|
||||
|
||||
- `connect()` must throw a structured error (subclass of `ChannelConnectError`) if the initial connection cannot be established within a reasonable timeout (default: 10 s).
|
||||
- `sendMessage()` must throw `ChannelSendError` on terminal failures (auth revoked, channel not found). Transient failures (rate limit, network blip) should be retried internally with exponential backoff before throwing.
|
||||
- `health()` must never throw — it returns `{ status: 'disconnected' }` on error.
|
||||
- Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability.
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
|
||||
The `ChannelMessage` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide.
|
||||
|
||||
Current version: **1.0.0**
|
||||
Reference in New Issue
Block a user