Merge pull request 'feat: M12-MatrixBridge — Matrix/Element chat bridge integration' (#408) from feature/m12-matrix-bridge into develop
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful

Reviewed-on: #408
This commit was merged in pull request #408.
This commit is contained in:
2026-02-15 18:22:16 +00:00
35 changed files with 6288 additions and 503 deletions

View File

@@ -316,6 +316,22 @@ RATE_LIMIT_STORAGE=redis
# multi-tenant isolation. Each Discord bot instance should be configured for
# a single workspace.
# ======================
# Matrix Bridge (Optional)
# ======================
# Matrix bot integration for chat-based control via Matrix protocol
# Requires a Matrix account with an access token for the bot user
# MATRIX_HOMESERVER_URL=https://matrix.example.com
# MATRIX_ACCESS_TOKEN=
# MATRIX_BOT_USER_ID=@mosaic-bot:example.com
# MATRIX_CONTROL_ROOM_ID=!roomid:example.com
# MATRIX_WORKSPACE_ID=your-workspace-uuid
#
# SECURITY: MATRIX_WORKSPACE_ID must be a valid workspace UUID from your database.
# All Matrix commands will execute within this workspace context for proper
# multi-tenant isolation. Each Matrix bot instance should be configured for
# a single workspace.
# ======================
# Orchestrator Configuration
# ======================
@@ -376,6 +392,28 @@ MOSAIC_TELEMETRY_INSTANCE_ID=your-instance-uuid-here
# Useful for development and debugging telemetry payloads
MOSAIC_TELEMETRY_DRY_RUN=false
# ======================
# Matrix Dev Environment (docker-compose.matrix.yml overlay)
# ======================
# These variables configure the local Matrix dev environment.
# Only used when running: docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml up
#
# Synapse homeserver
# SYNAPSE_CLIENT_PORT=8008
# SYNAPSE_FEDERATION_PORT=8448
# SYNAPSE_POSTGRES_DB=synapse
# SYNAPSE_POSTGRES_USER=synapse
# SYNAPSE_POSTGRES_PASSWORD=synapse_dev_password
#
# Element Web client
# ELEMENT_PORT=8501
#
# Matrix bridge connection (set after running docker/matrix/scripts/setup-bot.sh)
# MATRIX_HOMESERVER_URL=http://localhost:8008
# MATRIX_ACCESS_TOKEN=<obtained from setup-bot.sh>
# MATRIX_BOT_USER_ID=@mosaic-bot:localhost
# MATRIX_SERVER_NAME=localhost
# ======================
# Logging & Debugging
# ======================

View File

@@ -1,4 +1,4 @@
.PHONY: help install dev build test docker-up docker-down docker-logs docker-ps docker-build docker-restart docker-test clean
.PHONY: help install dev build test docker-up docker-down docker-logs docker-ps docker-build docker-restart docker-test clean matrix-up matrix-down matrix-logs matrix-setup-bot
# Default target
help:
@@ -24,6 +24,12 @@ help:
@echo " make docker-test Run Docker smoke test"
@echo " make docker-test-traefik Run Traefik integration tests"
@echo ""
@echo "Matrix Dev Environment:"
@echo " make matrix-up Start Matrix services (Synapse + Element)"
@echo " make matrix-down Stop Matrix services"
@echo " make matrix-logs View Matrix service logs"
@echo " make matrix-setup-bot Create bot account and get access token"
@echo ""
@echo "Database:"
@echo " make db-migrate Run database migrations"
@echo " make db-seed Seed development data"
@@ -85,6 +91,19 @@ docker-test:
docker-test-traefik:
./tests/integration/docker/traefik.test.sh all
# Matrix Dev Environment
matrix-up:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml up -d
matrix-down:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml down
matrix-logs:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml logs -f synapse element-web
matrix-setup-bot:
docker/matrix/scripts/setup-bot.sh
# Database operations
db-migrate:
cd apps/api && pnpm prisma:migrate

View File

@@ -65,6 +65,7 @@
"marked": "^17.0.1",
"marked-gfm-heading-id": "^4.1.3",
"marked-highlight": "^2.2.3",
"matrix-bot-sdk": "^0.8.0",
"ollama": "^0.6.3",
"openai": "^6.17.0",
"reflect-metadata": "^0.2.2",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "workspaces" ADD COLUMN "matrix_room_id" TEXT;

View File

@@ -261,12 +261,13 @@ model UserPreference {
}
model Workspace {
id String @id @default(uuid()) @db.Uuid
name String
ownerId String @map("owner_id") @db.Uuid
settings Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
id String @id @default(uuid()) @db.Uuid
name String
ownerId String @map("owner_id") @db.Uuid
settings Json @default("{}")
matrixRoomId String? @map("matrix_room_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations
owner User @relation("WorkspaceOwner", fields: [ownerId], references: [id], onDelete: Cascade)

View File

@@ -0,0 +1,15 @@
/**
* Bridge Module Constants
*
* Injection tokens for the bridge module.
*/
/**
* Injection token for the array of active IChatProvider instances.
*
* Use this token to inject all configured chat providers:
* ```
* @Inject(CHAT_PROVIDERS) private readonly chatProviders: IChatProvider[]
* ```
*/
export const CHAT_PROVIDERS = "CHAT_PROVIDERS";

View File

@@ -1,10 +1,13 @@
import { Test, TestingModule } from "@nestjs/testing";
import { BridgeModule } from "./bridge.module";
import { DiscordService } from "./discord/discord.service";
import { MatrixService } from "./matrix/matrix.service";
import { StitcherService } from "../stitcher/stitcher.service";
import { PrismaService } from "../prisma/prisma.service";
import { BullMqService } from "../bullmq/bullmq.service";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { CHAT_PROVIDERS } from "./bridge.constants";
import type { IChatProvider } from "./interfaces";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// Mock discord.js
const mockReadyCallbacks: Array<() => void> = [];
@@ -53,20 +56,93 @@ vi.mock("discord.js", () => {
};
});
describe("BridgeModule", () => {
let module: TestingModule;
// Mock matrix-bot-sdk
vi.mock("matrix-bot-sdk", () => {
return {
MatrixClient: class MockMatrixClient {
start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn();
on = vi.fn();
sendMessage = vi.fn().mockResolvedValue("$mock-event-id");
},
SimpleFsStorageProvider: class MockStorage {
constructor(_path: string) {
// no-op
}
},
AutojoinRoomsMixin: {
setupOnClient: vi.fn(),
},
};
});
beforeEach(async () => {
// Set environment variables
process.env.DISCORD_BOT_TOKEN = "test-token";
process.env.DISCORD_GUILD_ID = "test-guild-id";
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
/**
* Saved environment variables to restore after each test
*/
interface SavedEnvVars {
DISCORD_BOT_TOKEN?: string;
DISCORD_GUILD_ID?: string;
DISCORD_CONTROL_CHANNEL_ID?: string;
MATRIX_ACCESS_TOKEN?: string;
MATRIX_HOMESERVER_URL?: string;
MATRIX_BOT_USER_ID?: string;
MATRIX_CONTROL_ROOM_ID?: string;
MATRIX_WORKSPACE_ID?: string;
ENCRYPTION_KEY?: string;
}
describe("BridgeModule", () => {
let savedEnv: SavedEnvVars;
beforeEach(() => {
// Save current env vars
savedEnv = {
DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN,
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID,
DISCORD_CONTROL_CHANNEL_ID: process.env.DISCORD_CONTROL_CHANNEL_ID,
MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN,
MATRIX_HOMESERVER_URL: process.env.MATRIX_HOMESERVER_URL,
MATRIX_BOT_USER_ID: process.env.MATRIX_BOT_USER_ID,
MATRIX_CONTROL_ROOM_ID: process.env.MATRIX_CONTROL_ROOM_ID,
MATRIX_WORKSPACE_ID: process.env.MATRIX_WORKSPACE_ID,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
};
// Clear all bridge env vars
delete process.env.DISCORD_BOT_TOKEN;
delete process.env.DISCORD_GUILD_ID;
delete process.env.DISCORD_CONTROL_CHANNEL_ID;
delete process.env.MATRIX_ACCESS_TOKEN;
delete process.env.MATRIX_HOMESERVER_URL;
delete process.env.MATRIX_BOT_USER_ID;
delete process.env.MATRIX_CONTROL_ROOM_ID;
delete process.env.MATRIX_WORKSPACE_ID;
// Set encryption key (needed by StitcherService)
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
// Clear ready callbacks
mockReadyCallbacks.length = 0;
module = await Test.createTestingModule({
vi.clearAllMocks();
});
afterEach(() => {
// Restore env vars
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
/**
* Helper to compile a test module with BridgeModule
*/
async function compileModule(): Promise<TestingModule> {
return Test.createTestingModule({
imports: [BridgeModule],
})
.overrideProvider(PrismaService)
@@ -74,24 +150,144 @@ describe("BridgeModule", () => {
.overrideProvider(BullMqService)
.useValue({})
.compile();
}
// Clear all mocks
vi.clearAllMocks();
/**
* Helper to set Discord env vars
*/
function setDiscordEnv(): void {
process.env.DISCORD_BOT_TOKEN = "test-discord-token";
process.env.DISCORD_GUILD_ID = "test-guild-id";
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
}
/**
* Helper to set Matrix env vars
*/
function setMatrixEnv(): void {
process.env.MATRIX_ACCESS_TOKEN = "test-matrix-token";
process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com";
process.env.MATRIX_BOT_USER_ID = "@bot:example.com";
process.env.MATRIX_CONTROL_ROOM_ID = "!room:example.com";
process.env.MATRIX_WORKSPACE_ID = "test-workspace-id";
}
describe("with both Discord and Matrix configured", () => {
let module: TestingModule;
beforeEach(async () => {
setDiscordEnv();
setMatrixEnv();
module = await compileModule();
});
it("should compile the module", () => {
expect(module).toBeDefined();
});
it("should provide DiscordService", () => {
const discordService = module.get<DiscordService>(DiscordService);
expect(discordService).toBeDefined();
expect(discordService).toBeInstanceOf(DiscordService);
});
it("should provide MatrixService", () => {
const matrixService = module.get<MatrixService>(MatrixService);
expect(matrixService).toBeDefined();
expect(matrixService).toBeInstanceOf(MatrixService);
});
it("should provide CHAT_PROVIDERS with both providers", () => {
const chatProviders = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(chatProviders).toBeDefined();
expect(chatProviders).toHaveLength(2);
expect(chatProviders[0]).toBeInstanceOf(DiscordService);
expect(chatProviders[1]).toBeInstanceOf(MatrixService);
});
it("should provide StitcherService via StitcherModule", () => {
const stitcherService = module.get<StitcherService>(StitcherService);
expect(stitcherService).toBeDefined();
expect(stitcherService).toBeInstanceOf(StitcherService);
});
});
it("should be defined", () => {
expect(module).toBeDefined();
describe("with only Discord configured", () => {
let module: TestingModule;
beforeEach(async () => {
setDiscordEnv();
module = await compileModule();
});
it("should compile the module", () => {
expect(module).toBeDefined();
});
it("should provide DiscordService", () => {
const discordService = module.get<DiscordService>(DiscordService);
expect(discordService).toBeDefined();
expect(discordService).toBeInstanceOf(DiscordService);
});
it("should provide CHAT_PROVIDERS with only Discord", () => {
const chatProviders = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(chatProviders).toBeDefined();
expect(chatProviders).toHaveLength(1);
expect(chatProviders[0]).toBeInstanceOf(DiscordService);
});
});
it("should provide DiscordService", () => {
const discordService = module.get<DiscordService>(DiscordService);
expect(discordService).toBeDefined();
expect(discordService).toBeInstanceOf(DiscordService);
describe("with only Matrix configured", () => {
let module: TestingModule;
beforeEach(async () => {
setMatrixEnv();
module = await compileModule();
});
it("should compile the module", () => {
expect(module).toBeDefined();
});
it("should provide MatrixService", () => {
const matrixService = module.get<MatrixService>(MatrixService);
expect(matrixService).toBeDefined();
expect(matrixService).toBeInstanceOf(MatrixService);
});
it("should provide CHAT_PROVIDERS with only Matrix", () => {
const chatProviders = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(chatProviders).toBeDefined();
expect(chatProviders).toHaveLength(1);
expect(chatProviders[0]).toBeInstanceOf(MatrixService);
});
});
it("should provide StitcherService", () => {
const stitcherService = module.get<StitcherService>(StitcherService);
expect(stitcherService).toBeDefined();
expect(stitcherService).toBeInstanceOf(StitcherService);
describe("with neither bridge configured", () => {
let module: TestingModule;
beforeEach(async () => {
// No env vars set for either bridge
module = await compileModule();
});
it("should compile the module without errors", () => {
expect(module).toBeDefined();
});
it("should provide CHAT_PROVIDERS as an empty array", () => {
const chatProviders = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(chatProviders).toBeDefined();
expect(chatProviders).toHaveLength(0);
expect(Array.isArray(chatProviders)).toBe(true);
});
});
describe("CHAT_PROVIDERS token", () => {
it("should be a string constant", () => {
expect(CHAT_PROVIDERS).toBe("CHAT_PROVIDERS");
expect(typeof CHAT_PROVIDERS).toBe("string");
});
});
});

View File

@@ -1,16 +1,81 @@
import { Module } from "@nestjs/common";
import { Logger, Module } from "@nestjs/common";
import { DiscordService } from "./discord/discord.service";
import { MatrixService } from "./matrix/matrix.service";
import { MatrixRoomService } from "./matrix/matrix-room.service";
import { MatrixStreamingService } from "./matrix/matrix-streaming.service";
import { CommandParserService } from "./parser/command-parser.service";
import { StitcherModule } from "../stitcher/stitcher.module";
import { CHAT_PROVIDERS } from "./bridge.constants";
import type { IChatProvider } from "./interfaces";
const logger = new Logger("BridgeModule");
/**
* Bridge Module - Chat platform integrations
*
* Provides integration with chat platforms (Discord, Slack, Matrix, etc.)
* Provides integration with chat platforms (Discord, Matrix, etc.)
* for controlling Mosaic Stack via chat commands.
*
* Both services are always registered as providers, but the CHAT_PROVIDERS
* injection token only includes bridges whose environment variables are set:
* - Discord: included when DISCORD_BOT_TOKEN is set
* - Matrix: included when MATRIX_ACCESS_TOKEN is set
*
* Both bridges can run simultaneously, and no error occurs if neither is configured.
* Consumers should inject CHAT_PROVIDERS for bridge-agnostic access to all active providers.
*
* CommandParserService provides shared, platform-agnostic command parsing.
* MatrixRoomService handles workspace-to-Matrix-room mapping.
*/
@Module({
imports: [StitcherModule],
providers: [DiscordService],
exports: [DiscordService],
providers: [
CommandParserService,
MatrixRoomService,
MatrixStreamingService,
DiscordService,
MatrixService,
{
provide: CHAT_PROVIDERS,
useFactory: (discord: DiscordService, matrix: MatrixService): IChatProvider[] => {
const providers: IChatProvider[] = [];
if (process.env.DISCORD_BOT_TOKEN) {
providers.push(discord);
logger.log("Discord bridge enabled (DISCORD_BOT_TOKEN detected)");
}
if (process.env.MATRIX_ACCESS_TOKEN) {
const missingVars = [
"MATRIX_HOMESERVER_URL",
"MATRIX_BOT_USER_ID",
"MATRIX_WORKSPACE_ID",
].filter((v) => !process.env[v]);
if (missingVars.length > 0) {
logger.warn(
`Matrix bridge enabled but missing: ${missingVars.join(", ")}. connect() will fail.`
);
}
providers.push(matrix);
logger.log("Matrix bridge enabled (MATRIX_ACCESS_TOKEN detected)");
}
if (providers.length === 0) {
logger.warn("No chat bridges configured. Set DISCORD_BOT_TOKEN or MATRIX_ACCESS_TOKEN.");
}
return providers;
},
inject: [DiscordService, MatrixService],
},
],
exports: [
DiscordService,
MatrixService,
MatrixRoomService,
MatrixStreamingService,
CommandParserService,
CHAT_PROVIDERS,
],
})
export class BridgeModule {}

View File

@@ -187,6 +187,7 @@ describe("DiscordService", () => {
await service.connect();
await service.sendThreadMessage({
threadId: "thread-123",
channelId: "test-channel-id",
content: "Step completed",
});

View File

@@ -305,6 +305,7 @@ export class DiscordService implements IChatProvider {
// Send confirmation to thread
await this.sendThreadMessage({
threadId,
channelId: message.channelId,
content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`,
});
}

View File

@@ -28,6 +28,7 @@ export interface ThreadCreateOptions {
export interface ThreadMessageOptions {
threadId: string;
channelId: string;
content: string;
}
@@ -76,4 +77,17 @@ export interface IChatProvider {
* Parse a command from a message
*/
parseCommand(message: ChatMessage): ChatCommand | null;
/**
* Edit an existing message in a channel.
*
* Optional method for providers that support message editing
* (e.g., Matrix via m.replace, Discord via message.edit).
* Used for streaming AI responses with incremental updates.
*
* @param channelId - The channel/room ID
* @param messageId - The original message/event ID to edit
* @param content - The updated message content
*/
editMessage?(channelId: string, messageId: string, content: string): Promise<void>;
}

View File

@@ -0,0 +1,4 @@
export { MatrixService } from "./matrix.service";
export { MatrixRoomService } from "./matrix-room.service";
export { MatrixStreamingService } from "./matrix-streaming.service";
export type { StreamResponseOptions } from "./matrix-streaming.service";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,212 @@
import { Test, TestingModule } from "@nestjs/testing";
import { MatrixRoomService } from "./matrix-room.service";
import { MatrixService } from "./matrix.service";
import { PrismaService } from "../../prisma/prisma.service";
import { vi, describe, it, expect, beforeEach } from "vitest";
// Mock matrix-bot-sdk to avoid native module import errors
vi.mock("matrix-bot-sdk", () => {
return {
MatrixClient: class MockMatrixClient {},
SimpleFsStorageProvider: class MockStorageProvider {
constructor(_filename: string) {
// No-op for testing
}
},
AutojoinRoomsMixin: {
setupOnClient: vi.fn(),
},
};
});
describe("MatrixRoomService", () => {
let service: MatrixRoomService;
const mockCreateRoom = vi.fn().mockResolvedValue("!new-room:example.com");
const mockMatrixClient = {
createRoom: mockCreateRoom,
};
const mockMatrixService = {
isConnected: vi.fn().mockReturnValue(true),
getClient: vi.fn().mockReturnValue(mockMatrixClient),
};
const mockPrismaService = {
workspace: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
};
beforeEach(async () => {
process.env.MATRIX_SERVER_NAME = "example.com";
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixRoomService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
{
provide: MatrixService,
useValue: mockMatrixService,
},
],
}).compile();
service = module.get<MatrixRoomService>(MatrixRoomService);
vi.clearAllMocks();
// Restore defaults after clearing
mockMatrixService.isConnected.mockReturnValue(true);
mockCreateRoom.mockResolvedValue("!new-room:example.com");
mockPrismaService.workspace.update.mockResolvedValue({});
});
describe("provisionRoom", () => {
it("should create a Matrix room and store the mapping", async () => {
const roomId = await service.provisionRoom(
"workspace-uuid-1",
"My Workspace",
"my-workspace"
);
expect(roomId).toBe("!new-room:example.com");
expect(mockCreateRoom).toHaveBeenCalledWith({
name: "Mosaic: My Workspace",
room_alias_name: "mosaic-my-workspace",
topic: "Mosaic workspace: My Workspace",
preset: "private_chat",
visibility: "private",
});
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith({
where: { id: "workspace-uuid-1" },
data: { matrixRoomId: "!new-room:example.com" },
});
});
it("should return null when Matrix is not configured (no MatrixService)", async () => {
// Create a service without MatrixService
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixRoomService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
const serviceWithoutMatrix = module.get<MatrixRoomService>(MatrixRoomService);
const roomId = await serviceWithoutMatrix.provisionRoom(
"workspace-uuid-1",
"My Workspace",
"my-workspace"
);
expect(roomId).toBeNull();
expect(mockCreateRoom).not.toHaveBeenCalled();
expect(mockPrismaService.workspace.update).not.toHaveBeenCalled();
});
it("should return null when Matrix is not connected", async () => {
mockMatrixService.isConnected.mockReturnValue(false);
const roomId = await service.provisionRoom(
"workspace-uuid-1",
"My Workspace",
"my-workspace"
);
expect(roomId).toBeNull();
expect(mockCreateRoom).not.toHaveBeenCalled();
});
});
describe("getRoomForWorkspace", () => {
it("should return the room ID for a mapped workspace", async () => {
mockPrismaService.workspace.findUnique.mockResolvedValue({
matrixRoomId: "!mapped-room:example.com",
});
const roomId = await service.getRoomForWorkspace("workspace-uuid-1");
expect(roomId).toBe("!mapped-room:example.com");
expect(mockPrismaService.workspace.findUnique).toHaveBeenCalledWith({
where: { id: "workspace-uuid-1" },
select: { matrixRoomId: true },
});
});
it("should return null for an unmapped workspace", async () => {
mockPrismaService.workspace.findUnique.mockResolvedValue({
matrixRoomId: null,
});
const roomId = await service.getRoomForWorkspace("workspace-uuid-2");
expect(roomId).toBeNull();
});
it("should return null for a non-existent workspace", async () => {
mockPrismaService.workspace.findUnique.mockResolvedValue(null);
const roomId = await service.getRoomForWorkspace("non-existent-uuid");
expect(roomId).toBeNull();
});
});
describe("getWorkspaceForRoom", () => {
it("should return the workspace ID for a mapped room", async () => {
mockPrismaService.workspace.findFirst.mockResolvedValue({
id: "workspace-uuid-1",
});
const workspaceId = await service.getWorkspaceForRoom("!mapped-room:example.com");
expect(workspaceId).toBe("workspace-uuid-1");
expect(mockPrismaService.workspace.findFirst).toHaveBeenCalledWith({
where: { matrixRoomId: "!mapped-room:example.com" },
select: { id: true },
});
});
it("should return null for an unmapped room", async () => {
mockPrismaService.workspace.findFirst.mockResolvedValue(null);
const workspaceId = await service.getWorkspaceForRoom("!unknown-room:example.com");
expect(workspaceId).toBeNull();
});
});
describe("linkWorkspaceToRoom", () => {
it("should store the room mapping in the workspace", async () => {
await service.linkWorkspaceToRoom("workspace-uuid-1", "!existing-room:example.com");
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith({
where: { id: "workspace-uuid-1" },
data: { matrixRoomId: "!existing-room:example.com" },
});
});
});
describe("unlinkWorkspace", () => {
it("should remove the room mapping from the workspace", async () => {
await service.unlinkWorkspace("workspace-uuid-1");
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith({
where: { id: "workspace-uuid-1" },
data: { matrixRoomId: null },
});
});
});
});

View File

@@ -0,0 +1,151 @@
import { Injectable, Logger, Optional, Inject } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import { MatrixService } from "./matrix.service";
import type { MatrixClient, RoomCreateOptions } from "matrix-bot-sdk";
/**
* MatrixRoomService - Workspace-to-Matrix-Room mapping and provisioning
*
* Responsibilities:
* - Provision Matrix rooms for Mosaic workspaces
* - Map workspaces to Matrix room IDs
* - Link/unlink existing rooms to workspaces
*
* Room provisioning creates a private Matrix room with:
* - Name: "Mosaic: {workspace_name}"
* - Alias: #mosaic-{workspace_slug}:{server_name}
* - Room ID stored in workspace.matrixRoomId
*/
@Injectable()
export class MatrixRoomService {
private readonly logger = new Logger(MatrixRoomService.name);
constructor(
private readonly prisma: PrismaService,
@Optional() @Inject(MatrixService) private readonly matrixService: MatrixService | null
) {}
/**
* Provision a Matrix room for a workspace and store the mapping.
*
* @param workspaceId - The workspace UUID
* @param workspaceName - Human-readable workspace name
* @param workspaceSlug - URL-safe workspace identifier for the room alias
* @returns The Matrix room ID, or null if Matrix is not configured
*/
async provisionRoom(
workspaceId: string,
workspaceName: string,
workspaceSlug: string
): Promise<string | null> {
if (!this.matrixService?.isConnected()) {
this.logger.warn("Matrix is not configured or not connected; skipping room provisioning");
return null;
}
const client = this.getMatrixClient();
if (!client) {
this.logger.warn("Matrix client is not available; skipping room provisioning");
return null;
}
const roomOptions: RoomCreateOptions = {
name: `Mosaic: ${workspaceName}`,
room_alias_name: `mosaic-${workspaceSlug}`,
topic: `Mosaic workspace: ${workspaceName}`,
preset: "private_chat",
visibility: "private",
};
this.logger.log(
`Provisioning Matrix room for workspace "${workspaceName}" (${workspaceId})...`
);
const roomId = await client.createRoom(roomOptions);
// Store the room mapping
try {
await this.prisma.workspace.update({
where: { id: workspaceId },
data: { matrixRoomId: roomId },
});
} catch (dbError: unknown) {
this.logger.error(
`Failed to store room mapping for workspace ${workspaceId}, room ${roomId} may be orphaned: ${dbError instanceof Error ? dbError.message : "unknown"}`
);
throw dbError;
}
this.logger.log(`Matrix room ${roomId} provisioned and linked to workspace ${workspaceId}`);
return roomId;
}
/**
* Look up the Matrix room ID mapped to a workspace.
*
* @param workspaceId - The workspace UUID
* @returns The Matrix room ID, or null if no room is mapped
*/
async getRoomForWorkspace(workspaceId: string): Promise<string | null> {
const workspace = await this.prisma.workspace.findUnique({
where: { id: workspaceId },
select: { matrixRoomId: true },
});
return workspace?.matrixRoomId ?? null;
}
/**
* Reverse lookup: find the workspace that owns a given Matrix room.
*
* @param roomId - The Matrix room ID (e.g. "!abc:example.com")
* @returns The workspace ID, or null if the room is not mapped to any workspace
*/
async getWorkspaceForRoom(roomId: string): Promise<string | null> {
const workspace = await this.prisma.workspace.findFirst({
where: { matrixRoomId: roomId },
select: { id: true },
});
return workspace?.id ?? null;
}
/**
* Manually link an existing Matrix room to a workspace.
*
* @param workspaceId - The workspace UUID
* @param roomId - The Matrix room ID to link
*/
async linkWorkspaceToRoom(workspaceId: string, roomId: string): Promise<void> {
await this.prisma.workspace.update({
where: { id: workspaceId },
data: { matrixRoomId: roomId },
});
this.logger.log(`Linked workspace ${workspaceId} to Matrix room ${roomId}`);
}
/**
* Remove the Matrix room mapping from a workspace.
*
* @param workspaceId - The workspace UUID
*/
async unlinkWorkspace(workspaceId: string): Promise<void> {
await this.prisma.workspace.update({
where: { id: workspaceId },
data: { matrixRoomId: null },
});
this.logger.log(`Unlinked Matrix room from workspace ${workspaceId}`);
}
/**
* Access the underlying MatrixClient from the MatrixService
* via the public getClient() accessor.
*/
private getMatrixClient(): MatrixClient | null {
if (!this.matrixService) return null;
return this.matrixService.getClient();
}
}

View File

@@ -0,0 +1,408 @@
import { Test, TestingModule } from "@nestjs/testing";
import { MatrixStreamingService } from "./matrix-streaming.service";
import { MatrixService } from "./matrix.service";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import type { StreamResponseOptions } from "./matrix-streaming.service";
// Mock matrix-bot-sdk to prevent native module loading
vi.mock("matrix-bot-sdk", () => {
return {
MatrixClient: class MockMatrixClient {},
SimpleFsStorageProvider: class MockStorageProvider {
constructor(_filename: string) {
// No-op for testing
}
},
AutojoinRoomsMixin: {
setupOnClient: vi.fn(),
},
};
});
// Mock MatrixClient
const mockClient = {
sendMessage: vi.fn().mockResolvedValue("$initial-event-id"),
sendEvent: vi.fn().mockResolvedValue("$edit-event-id"),
setTyping: vi.fn().mockResolvedValue(undefined),
};
// Mock MatrixService
const mockMatrixService = {
isConnected: vi.fn().mockReturnValue(true),
getClient: vi.fn().mockReturnValue(mockClient),
};
/**
* Helper: create an async iterable from an array of strings with optional delays
*/
async function* createTokenStream(
tokens: string[],
delayMs = 0
): AsyncGenerator<string, void, undefined> {
for (const token of tokens) {
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
yield token;
}
}
/**
* Helper: create a token stream that throws an error mid-stream
*/
async function* createErrorStream(
tokens: string[],
errorAfter: number
): AsyncGenerator<string, void, undefined> {
let count = 0;
for (const token of tokens) {
if (count >= errorAfter) {
throw new Error("LLM provider connection lost");
}
yield token;
count++;
}
}
describe("MatrixStreamingService", () => {
let service: MatrixStreamingService;
beforeEach(async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixStreamingService,
{
provide: MatrixService,
useValue: mockMatrixService,
},
],
}).compile();
service = module.get<MatrixStreamingService>(MatrixStreamingService);
// Clear all mocks
vi.clearAllMocks();
// Re-apply default mock returns after clearing
mockMatrixService.isConnected.mockReturnValue(true);
mockMatrixService.getClient.mockReturnValue(mockClient);
mockClient.sendMessage.mockResolvedValue("$initial-event-id");
mockClient.sendEvent.mockResolvedValue("$edit-event-id");
mockClient.setTyping.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
describe("editMessage", () => {
it("should send a m.replace event to edit an existing message", async () => {
await service.editMessage("!room:example.com", "$original-event-id", "Updated content");
expect(mockClient.sendEvent).toHaveBeenCalledWith("!room:example.com", "m.room.message", {
"m.new_content": {
msgtype: "m.text",
body: "Updated content",
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$original-event-id",
},
// Fallback for clients that don't support edits
msgtype: "m.text",
body: "* Updated content",
});
});
it("should throw error when client is not connected", async () => {
mockMatrixService.isConnected.mockReturnValue(false);
await expect(
service.editMessage("!room:example.com", "$event-id", "content")
).rejects.toThrow("Matrix client is not connected");
});
it("should throw error when client is null", async () => {
mockMatrixService.getClient.mockReturnValue(null);
await expect(
service.editMessage("!room:example.com", "$event-id", "content")
).rejects.toThrow("Matrix client is not connected");
});
});
describe("setTypingIndicator", () => {
it("should call client.setTyping with true and timeout", async () => {
await service.setTypingIndicator("!room:example.com", true);
expect(mockClient.setTyping).toHaveBeenCalledWith("!room:example.com", true, 30000);
});
it("should call client.setTyping with false to clear indicator", async () => {
await service.setTypingIndicator("!room:example.com", false);
expect(mockClient.setTyping).toHaveBeenCalledWith("!room:example.com", false, undefined);
});
it("should throw error when client is not connected", async () => {
mockMatrixService.isConnected.mockReturnValue(false);
await expect(service.setTypingIndicator("!room:example.com", true)).rejects.toThrow(
"Matrix client is not connected"
);
});
});
describe("sendStreamingMessage", () => {
it("should send an initial message and return the event ID", async () => {
const eventId = await service.sendStreamingMessage("!room:example.com", "Thinking...");
expect(eventId).toBe("$initial-event-id");
expect(mockClient.sendMessage).toHaveBeenCalledWith("!room:example.com", {
msgtype: "m.text",
body: "Thinking...",
});
});
it("should send a thread message when threadId is provided", async () => {
const eventId = await service.sendStreamingMessage(
"!room:example.com",
"Thinking...",
"$thread-root-id"
);
expect(eventId).toBe("$initial-event-id");
expect(mockClient.sendMessage).toHaveBeenCalledWith("!room:example.com", {
msgtype: "m.text",
body: "Thinking...",
"m.relates_to": {
rel_type: "m.thread",
event_id: "$thread-root-id",
is_falling_back: true,
"m.in_reply_to": {
event_id: "$thread-root-id",
},
},
});
});
it("should throw error when client is not connected", async () => {
mockMatrixService.isConnected.mockReturnValue(false);
await expect(service.sendStreamingMessage("!room:example.com", "Test")).rejects.toThrow(
"Matrix client is not connected"
);
});
});
describe("streamResponse", () => {
it("should send initial 'Thinking...' message and start typing indicator", async () => {
vi.useRealTimers();
const tokens = ["Hello", " world"];
const stream = createTokenStream(tokens);
await service.streamResponse("!room:example.com", stream);
// Should have sent initial message
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!room:example.com",
expect.objectContaining({
msgtype: "m.text",
body: "Thinking...",
})
);
// Should have started typing indicator
expect(mockClient.setTyping).toHaveBeenCalledWith("!room:example.com", true, 30000);
});
it("should use custom initial message when provided", async () => {
vi.useRealTimers();
const tokens = ["Hi"];
const stream = createTokenStream(tokens);
const options: StreamResponseOptions = { initialMessage: "Processing..." };
await service.streamResponse("!room:example.com", stream, options);
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!room:example.com",
expect.objectContaining({
body: "Processing...",
})
);
});
it("should edit message with accumulated tokens on completion", async () => {
vi.useRealTimers();
const tokens = ["Hello", " ", "world", "!"];
const stream = createTokenStream(tokens);
await service.streamResponse("!room:example.com", stream);
// The final edit should contain the full accumulated text
const sendEventCalls = mockClient.sendEvent.mock.calls;
const lastEditCall = sendEventCalls[sendEventCalls.length - 1];
expect(lastEditCall).toBeDefined();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(lastEditCall[2]["m.new_content"].body).toBe("Hello world!");
});
it("should clear typing indicator on completion", async () => {
vi.useRealTimers();
const tokens = ["Done"];
const stream = createTokenStream(tokens);
await service.streamResponse("!room:example.com", stream);
// Last setTyping call should be false
const typingCalls = mockClient.setTyping.mock.calls;
const lastTypingCall = typingCalls[typingCalls.length - 1];
expect(lastTypingCall).toEqual(["!room:example.com", false, undefined]);
});
it("should rate-limit edits to at most one every 500ms", async () => {
vi.useRealTimers();
// Send tokens with small delays - all within one 500ms window
const tokens = ["a", "b", "c", "d", "e"];
const stream = createTokenStream(tokens, 50); // 50ms between tokens = 250ms total
await service.streamResponse("!room:example.com", stream);
// With 250ms total streaming time (5 tokens * 50ms), all tokens arrive
// within one 500ms window. We expect at most 1 intermediate edit + 1 final edit,
// or just the final edit. The key point is that there should NOT be 5 separate edits.
const editCalls = mockClient.sendEvent.mock.calls.filter(
(call) => call[1] === "m.room.message"
);
// Should have fewer edits than tokens (rate limiting in effect)
expect(editCalls.length).toBeLessThanOrEqual(2);
// Should have at least the final edit
expect(editCalls.length).toBeGreaterThanOrEqual(1);
});
it("should handle errors gracefully and edit message with error notice", async () => {
vi.useRealTimers();
const stream = createErrorStream(["Hello", " ", "world"], 2);
await service.streamResponse("!room:example.com", stream);
// Should edit message with error content
const sendEventCalls = mockClient.sendEvent.mock.calls;
const lastEditCall = sendEventCalls[sendEventCalls.length - 1];
expect(lastEditCall).toBeDefined();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const finalBody = lastEditCall[2]["m.new_content"].body as string;
expect(finalBody).toContain("error");
// Should clear typing on error
const typingCalls = mockClient.setTyping.mock.calls;
const lastTypingCall = typingCalls[typingCalls.length - 1];
expect(lastTypingCall).toEqual(["!room:example.com", false, undefined]);
});
it("should include token usage in final message when provided", async () => {
vi.useRealTimers();
const tokens = ["Hello"];
const stream = createTokenStream(tokens);
const options: StreamResponseOptions = {
showTokenUsage: true,
tokenUsage: { prompt: 10, completion: 5, total: 15 },
};
await service.streamResponse("!room:example.com", stream, options);
const sendEventCalls = mockClient.sendEvent.mock.calls;
const lastEditCall = sendEventCalls[sendEventCalls.length - 1];
expect(lastEditCall).toBeDefined();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const finalBody = lastEditCall[2]["m.new_content"].body as string;
expect(finalBody).toContain("15");
});
it("should throw error when client is not connected", async () => {
mockMatrixService.isConnected.mockReturnValue(false);
const stream = createTokenStream(["test"]);
await expect(service.streamResponse("!room:example.com", stream)).rejects.toThrow(
"Matrix client is not connected"
);
});
it("should handle empty token stream", async () => {
vi.useRealTimers();
const stream = createTokenStream([]);
await service.streamResponse("!room:example.com", stream);
// Should still send initial message
expect(mockClient.sendMessage).toHaveBeenCalled();
// Should edit with empty/no-content message
const sendEventCalls = mockClient.sendEvent.mock.calls;
expect(sendEventCalls.length).toBeGreaterThanOrEqual(1);
// Should clear typing
const typingCalls = mockClient.setTyping.mock.calls;
const lastTypingCall = typingCalls[typingCalls.length - 1];
expect(lastTypingCall).toEqual(["!room:example.com", false, undefined]);
});
it("should support thread context in streamResponse", async () => {
vi.useRealTimers();
const tokens = ["Reply"];
const stream = createTokenStream(tokens);
const options: StreamResponseOptions = { threadId: "$thread-root" };
await service.streamResponse("!room:example.com", stream, options);
// Initial message should include thread relation
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!room:example.com",
expect.objectContaining({
"m.relates_to": expect.objectContaining({
rel_type: "m.thread",
event_id: "$thread-root",
}),
})
);
});
it("should perform multiple edits for long-running streams", async () => {
vi.useRealTimers();
// Create tokens with 200ms delays - total ~2000ms, should get multiple edit windows
const tokens = Array.from({ length: 10 }, (_, i) => `token${String(i)} `);
const stream = createTokenStream(tokens, 200);
await service.streamResponse("!room:example.com", stream);
// With 10 tokens at 200ms each = 2000ms total, at 500ms intervals
// we expect roughly 3-4 intermediate edits + 1 final = 4-5 total
const editCalls = mockClient.sendEvent.mock.calls.filter(
(call) => call[1] === "m.room.message"
);
// Should have multiple edits (at least 2) but far fewer than 10
expect(editCalls.length).toBeGreaterThanOrEqual(2);
expect(editCalls.length).toBeLessThanOrEqual(8);
});
});
});

View File

@@ -0,0 +1,248 @@
import { Injectable, Logger } from "@nestjs/common";
import type { MatrixClient } from "matrix-bot-sdk";
import { MatrixService } from "./matrix.service";
/**
* Options for the streamResponse method
*/
export interface StreamResponseOptions {
/** Custom initial message (defaults to "Thinking...") */
initialMessage?: string;
/** Thread root event ID for threaded responses */
threadId?: string;
/** Whether to show token usage in the final message */
showTokenUsage?: boolean;
/** Token usage stats to display in the final message */
tokenUsage?: { prompt: number; completion: number; total: number };
}
/**
* Matrix message content for m.room.message events
*/
interface MatrixMessageContent {
msgtype: string;
body: string;
"m.new_content"?: {
msgtype: string;
body: string;
};
"m.relates_to"?: {
rel_type: string;
event_id: string;
is_falling_back?: boolean;
"m.in_reply_to"?: {
event_id: string;
};
};
}
/** Minimum interval between message edits (milliseconds) */
const EDIT_INTERVAL_MS = 500;
/** Typing indicator timeout (milliseconds) */
const TYPING_TIMEOUT_MS = 30000;
/**
* Matrix Streaming Service
*
* Provides streaming AI response capabilities for Matrix rooms using
* incremental message edits. Tokens from an LLM are buffered and the
* response message is edited at rate-limited intervals, providing a
* smooth streaming experience without excessive API calls.
*
* Key features:
* - Rate-limited edits (max every 500ms)
* - Typing indicator management during generation
* - Graceful error handling with user-visible error notices
* - Thread support for contextual responses
* - LLM-agnostic design via AsyncIterable<string> token stream
*/
@Injectable()
export class MatrixStreamingService {
private readonly logger = new Logger(MatrixStreamingService.name);
constructor(private readonly matrixService: MatrixService) {}
/**
* Edit an existing Matrix message using the m.replace relation.
*
* Sends a new event that replaces the content of an existing message.
* Includes fallback content for clients that don't support edits.
*
* @param roomId - The Matrix room ID
* @param eventId - The original event ID to replace
* @param newContent - The updated message text
*/
async editMessage(roomId: string, eventId: string, newContent: string): Promise<void> {
const client = this.getClientOrThrow();
const editContent: MatrixMessageContent = {
"m.new_content": {
msgtype: "m.text",
body: newContent,
},
"m.relates_to": {
rel_type: "m.replace",
event_id: eventId,
},
// Fallback for clients that don't support edits
msgtype: "m.text",
body: `* ${newContent}`,
};
await client.sendEvent(roomId, "m.room.message", editContent);
}
/**
* Set the typing indicator for the bot in a room.
*
* @param roomId - The Matrix room ID
* @param typing - Whether the bot is typing
*/
async setTypingIndicator(roomId: string, typing: boolean): Promise<void> {
const client = this.getClientOrThrow();
await client.setTyping(roomId, typing, typing ? TYPING_TIMEOUT_MS : undefined);
}
/**
* Send an initial message for streaming, optionally in a thread.
*
* Returns the event ID of the sent message, which can be used for
* subsequent edits via editMessage.
*
* @param roomId - The Matrix room ID
* @param content - The initial message content
* @param threadId - Optional thread root event ID
* @returns The event ID of the sent message
*/
async sendStreamingMessage(roomId: string, content: string, threadId?: string): Promise<string> {
const client = this.getClientOrThrow();
const messageContent: MatrixMessageContent = {
msgtype: "m.text",
body: content,
};
if (threadId) {
messageContent["m.relates_to"] = {
rel_type: "m.thread",
event_id: threadId,
is_falling_back: true,
"m.in_reply_to": {
event_id: threadId,
},
};
}
const eventId: string = await client.sendMessage(roomId, messageContent);
return eventId;
}
/**
* Stream an AI response to a Matrix room using incremental message edits.
*
* This is the main streaming method. It:
* 1. Sends an initial "Thinking..." message
* 2. Starts the typing indicator
* 3. Buffers incoming tokens from the async iterable
* 4. Edits the message every 500ms with accumulated text
* 5. On completion: sends a final clean edit, clears typing
* 6. On error: edits message with error notice, clears typing
*
* @param roomId - The Matrix room ID
* @param tokenStream - AsyncIterable that yields string tokens
* @param options - Optional configuration for the stream
*/
async streamResponse(
roomId: string,
tokenStream: AsyncIterable<string>,
options?: StreamResponseOptions
): Promise<void> {
// Validate connection before starting
this.getClientOrThrow();
const initialMessage = options?.initialMessage ?? "Thinking...";
const threadId = options?.threadId;
// Step 1: Send initial message
const eventId = await this.sendStreamingMessage(roomId, initialMessage, threadId);
// Step 2: Start typing indicator
await this.setTypingIndicator(roomId, true);
// Step 3: Buffer and stream tokens
let accumulatedText = "";
let lastEditTime = 0;
let hasError = false;
try {
for await (const token of tokenStream) {
accumulatedText += token;
const now = Date.now();
const elapsed = now - lastEditTime;
if (elapsed >= EDIT_INTERVAL_MS && accumulatedText.length > 0) {
await this.editMessage(roomId, eventId, accumulatedText);
lastEditTime = now;
}
}
} catch (error: unknown) {
hasError = true;
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
this.logger.error(`Stream error in room ${roomId}: ${errorMessage}`);
// Edit message to show error
try {
const errorContent = accumulatedText
? `${accumulatedText}\n\n[Streaming error: ${errorMessage}]`
: `[Streaming error: ${errorMessage}]`;
await this.editMessage(roomId, eventId, errorContent);
} catch (editError: unknown) {
this.logger.warn(
`Failed to edit error message in ${roomId}: ${editError instanceof Error ? editError.message : "unknown"}`
);
}
} finally {
// Step 4: Clear typing indicator
try {
await this.setTypingIndicator(roomId, false);
} catch (typingError: unknown) {
this.logger.warn(
`Failed to clear typing indicator in ${roomId}: ${typingError instanceof Error ? typingError.message : "unknown"}`
);
}
}
// Step 5: Final edit with clean output (if no error)
if (!hasError) {
let finalContent = accumulatedText || "(No response generated)";
if (options?.showTokenUsage && options.tokenUsage) {
const { prompt, completion, total } = options.tokenUsage;
finalContent += `\n\n---\nTokens: ${String(total)} (prompt: ${String(prompt)}, completion: ${String(completion)})`;
}
await this.editMessage(roomId, eventId, finalContent);
}
}
/**
* Get the Matrix client from the parent MatrixService, or throw if not connected.
*/
private getClientOrThrow(): MatrixClient {
if (!this.matrixService.isConnected()) {
throw new Error("Matrix client is not connected");
}
const client = this.matrixService.getClient();
if (!client) {
throw new Error("Matrix client is not connected");
}
return client;
}
}

View File

@@ -0,0 +1,979 @@
import { Test, TestingModule } from "@nestjs/testing";
import { MatrixService } from "./matrix.service";
import { MatrixRoomService } from "./matrix-room.service";
import { StitcherService } from "../../stitcher/stitcher.service";
import { CommandParserService } from "../parser/command-parser.service";
import { vi, describe, it, expect, beforeEach } from "vitest";
import type { ChatMessage } from "../interfaces";
// Mock matrix-bot-sdk
const mockMessageCallbacks: Array<(roomId: string, event: Record<string, unknown>) => void> = [];
const mockEventCallbacks: Array<(roomId: string, event: Record<string, unknown>) => void> = [];
const mockClient = {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn(),
on: vi
.fn()
.mockImplementation(
(event: string, callback: (roomId: string, evt: Record<string, unknown>) => void) => {
if (event === "room.message") {
mockMessageCallbacks.push(callback);
}
if (event === "room.event") {
mockEventCallbacks.push(callback);
}
}
),
sendMessage: vi.fn().mockResolvedValue("$event-id-123"),
sendEvent: vi.fn().mockResolvedValue("$event-id-456"),
};
vi.mock("matrix-bot-sdk", () => {
return {
MatrixClient: class MockMatrixClient {
start = mockClient.start;
stop = mockClient.stop;
on = mockClient.on;
sendMessage = mockClient.sendMessage;
sendEvent = mockClient.sendEvent;
},
SimpleFsStorageProvider: class MockStorageProvider {
constructor(_filename: string) {
// No-op for testing
}
},
AutojoinRoomsMixin: {
setupOnClient: vi.fn(),
},
};
});
describe("MatrixService", () => {
let service: MatrixService;
let stitcherService: StitcherService;
let commandParser: CommandParserService;
let matrixRoomService: MatrixRoomService;
const mockStitcherService = {
dispatchJob: vi.fn().mockResolvedValue({
jobId: "test-job-id",
queueName: "main",
status: "PENDING",
}),
trackJobEvent: vi.fn().mockResolvedValue(undefined),
};
const mockMatrixRoomService = {
getWorkspaceForRoom: vi.fn().mockResolvedValue(null),
getRoomForWorkspace: vi.fn().mockResolvedValue(null),
provisionRoom: vi.fn().mockResolvedValue(null),
linkWorkspaceToRoom: vi.fn().mockResolvedValue(undefined),
unlinkWorkspace: vi.fn().mockResolvedValue(undefined),
};
beforeEach(async () => {
// Set environment variables for testing
process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com";
process.env.MATRIX_ACCESS_TOKEN = "test-access-token";
process.env.MATRIX_BOT_USER_ID = "@mosaic-bot:example.com";
process.env.MATRIX_CONTROL_ROOM_ID = "!test-room:example.com";
process.env.MATRIX_WORKSPACE_ID = "test-workspace-id";
// Clear callbacks
mockMessageCallbacks.length = 0;
mockEventCallbacks.length = 0;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: MatrixRoomService,
useValue: mockMatrixRoomService,
},
],
}).compile();
service = module.get<MatrixService>(MatrixService);
stitcherService = module.get<StitcherService>(StitcherService);
commandParser = module.get<CommandParserService>(CommandParserService);
matrixRoomService = module.get(MatrixRoomService) as MatrixRoomService;
// Clear all mocks
vi.clearAllMocks();
});
describe("Connection Management", () => {
it("should connect to Matrix", async () => {
await service.connect();
expect(mockClient.start).toHaveBeenCalled();
});
it("should disconnect from Matrix", async () => {
await service.connect();
await service.disconnect();
expect(mockClient.stop).toHaveBeenCalled();
});
it("should check connection status", async () => {
expect(service.isConnected()).toBe(false);
await service.connect();
expect(service.isConnected()).toBe(true);
await service.disconnect();
expect(service.isConnected()).toBe(false);
});
});
describe("Message Handling", () => {
it("should send a message to a room", async () => {
await service.connect();
await service.sendMessage("!test-room:example.com", "Hello, Matrix!");
expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", {
msgtype: "m.text",
body: "Hello, Matrix!",
});
});
it("should throw error if client is not connected", async () => {
await expect(service.sendMessage("!room:example.com", "Test")).rejects.toThrow(
"Matrix client is not connected"
);
});
});
describe("Thread Management", () => {
it("should create a thread by sending an initial message", async () => {
await service.connect();
const threadId = await service.createThread({
channelId: "!test-room:example.com",
name: "Job #42",
message: "Starting job...",
});
expect(threadId).toBe("$event-id-123");
expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", {
msgtype: "m.text",
body: "[Job #42] Starting job...",
});
});
it("should send a message to a thread with m.thread relation", async () => {
await service.connect();
await service.sendThreadMessage({
threadId: "$root-event-id",
channelId: "!test-room:example.com",
content: "Step completed",
});
expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", {
msgtype: "m.text",
body: "Step completed",
"m.relates_to": {
rel_type: "m.thread",
event_id: "$root-event-id",
is_falling_back: true,
"m.in_reply_to": {
event_id: "$root-event-id",
},
},
});
});
it("should fall back to controlRoomId when channelId is empty", async () => {
await service.connect();
await service.sendThreadMessage({
threadId: "$root-event-id",
channelId: "",
content: "Fallback message",
});
expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", {
msgtype: "m.text",
body: "Fallback message",
"m.relates_to": {
rel_type: "m.thread",
event_id: "$root-event-id",
is_falling_back: true,
"m.in_reply_to": {
event_id: "$root-event-id",
},
},
});
});
it("should throw error when creating thread without connection", async () => {
await expect(
service.createThread({
channelId: "!room:example.com",
name: "Test",
message: "Test",
})
).rejects.toThrow("Matrix client is not connected");
});
it("should throw error when sending thread message without connection", async () => {
await expect(
service.sendThreadMessage({
threadId: "$event-id",
channelId: "!room:example.com",
content: "Test",
})
).rejects.toThrow("Matrix client is not connected");
});
});
describe("Command Parsing with shared CommandParserService", () => {
it("should parse @mosaic fix #42 via shared parser", () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic fix #42",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).not.toBeNull();
expect(command?.command).toBe("fix");
expect(command?.args).toContain("#42");
});
it("should parse !mosaic fix #42 by normalizing to @mosaic for the shared parser", () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "!mosaic fix #42",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).not.toBeNull();
expect(command?.command).toBe("fix");
expect(command?.args).toContain("#42");
});
it("should parse @mosaic status command via shared parser", () => {
const message: ChatMessage = {
id: "msg-2",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic status job-123",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).not.toBeNull();
expect(command?.command).toBe("status");
expect(command?.args).toContain("job-123");
});
it("should parse @mosaic cancel command via shared parser", () => {
const message: ChatMessage = {
id: "msg-3",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic cancel job-456",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).not.toBeNull();
expect(command?.command).toBe("cancel");
});
it("should parse @mosaic help command via shared parser", () => {
const message: ChatMessage = {
id: "msg-6",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic help",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).not.toBeNull();
expect(command?.command).toBe("help");
});
it("should return null for non-command messages", () => {
const message: ChatMessage = {
id: "msg-7",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "Just a regular message",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).toBeNull();
});
it("should return null for messages without @mosaic or !mosaic mention", () => {
const message: ChatMessage = {
id: "msg-8",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "fix 42",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).toBeNull();
});
it("should return null for @mosaic mention without a command", () => {
const message: ChatMessage = {
id: "msg-11",
channelId: "!room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic",
timestamp: new Date(),
};
const command = service.parseCommand(message);
expect(command).toBeNull();
});
});
describe("Event-driven message reception", () => {
it("should ignore messages from the bot itself", async () => {
await service.connect();
const parseCommandSpy = vi.spyOn(commandParser, "parseCommand");
// Simulate a message from the bot
expect(mockMessageCallbacks.length).toBeGreaterThan(0);
const callback = mockMessageCallbacks[0];
callback?.("!test-room:example.com", {
event_id: "$msg-1",
sender: "@mosaic-bot:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #42",
},
});
// Should not attempt to parse
expect(parseCommandSpy).not.toHaveBeenCalled();
});
it("should ignore messages in unmapped rooms", async () => {
// MatrixRoomService returns null for unknown rooms
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue(null);
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!unknown-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #42",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should not dispatch to stitcher
expect(stitcherService.dispatchJob).not.toHaveBeenCalled();
});
it("should process commands in the control room (fallback workspace)", async () => {
// MatrixRoomService returns null, but room matches controlRoomId
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue(null);
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!test-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic help",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should send help message
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("Available commands:"),
})
);
});
it("should process commands in rooms mapped via MatrixRoomService", async () => {
// MatrixRoomService resolves the workspace
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("mapped-workspace-id");
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!mapped-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #42",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should dispatch with the mapped workspace ID
expect(stitcherService.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "mapped-workspace-id",
})
);
});
it("should handle !mosaic prefix in incoming messages", async () => {
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("test-workspace-id");
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!test-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "!mosaic help",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should send help message (normalized !mosaic -> @mosaic for parser)
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("Available commands:"),
})
);
});
it("should send help text when user tries an unknown command", async () => {
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("test-workspace-id");
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!test-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic invalidcommand",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should send error/help message (CommandParserService returns help text for unknown actions)
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("Available commands"),
})
);
});
it("should ignore non-text messages", async () => {
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("test-workspace-id");
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!test-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.image",
body: "photo.jpg",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should not attempt any message sending
expect(mockClient.sendMessage).not.toHaveBeenCalled();
});
});
describe("Command Execution", () => {
it("should forward fix command to stitcher and create a thread", async () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic fix 42",
timestamp: new Date(),
};
await service.connect();
await service.handleCommand({
command: "fix",
args: ["42"],
message,
});
expect(stitcherService.dispatchJob).toHaveBeenCalledWith({
workspaceId: "test-workspace-id",
type: "code-task",
priority: 10,
metadata: {
issueNumber: 42,
command: "fix",
channelId: "!test-room:example.com",
threadId: "$event-id-123",
authorId: "@user:example.com",
authorName: "@user:example.com",
},
});
});
it("should handle fix with #-prefixed issue number", async () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic fix #42",
timestamp: new Date(),
};
await service.connect();
await service.handleCommand({
command: "fix",
args: ["#42"],
message,
});
expect(stitcherService.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
issueNumber: 42,
}),
})
);
});
it("should respond with help message", async () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic help",
timestamp: new Date(),
};
await service.connect();
await service.handleCommand({
command: "help",
args: [],
message,
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("Available commands:"),
})
);
});
it("should include retry command in help output", async () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic help",
timestamp: new Date(),
};
await service.connect();
await service.handleCommand({
command: "help",
args: [],
message,
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("retry"),
})
);
});
it("should send error for fix command without issue number", async () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic fix",
timestamp: new Date(),
};
await service.connect();
await service.handleCommand({
command: "fix",
args: [],
message,
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("Usage:"),
})
);
});
it("should send error for fix command with non-numeric issue", async () => {
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic fix abc",
timestamp: new Date(),
};
await service.connect();
await service.handleCommand({
command: "fix",
args: ["abc"],
message,
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"!test-room:example.com",
expect.objectContaining({
body: expect.stringContaining("Invalid issue number"),
})
);
});
it("should dispatch fix command with workspace from MatrixRoomService", async () => {
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("dynamic-workspace-id");
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!mapped-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #99",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
expect(stitcherService.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "dynamic-workspace-id",
metadata: expect.objectContaining({
issueNumber: 99,
}),
})
);
});
});
describe("Configuration", () => {
it("should throw error if MATRIX_HOMESERVER_URL is not set", async () => {
delete process.env.MATRIX_HOMESERVER_URL;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: MatrixRoomService,
useValue: mockMatrixRoomService,
},
],
}).compile();
const newService = module.get<MatrixService>(MatrixService);
await expect(newService.connect()).rejects.toThrow("MATRIX_HOMESERVER_URL is required");
// Restore for other tests
process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com";
});
it("should throw error if MATRIX_ACCESS_TOKEN is not set", async () => {
delete process.env.MATRIX_ACCESS_TOKEN;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: MatrixRoomService,
useValue: mockMatrixRoomService,
},
],
}).compile();
const newService = module.get<MatrixService>(MatrixService);
await expect(newService.connect()).rejects.toThrow("MATRIX_ACCESS_TOKEN is required");
// Restore for other tests
process.env.MATRIX_ACCESS_TOKEN = "test-access-token";
});
it("should throw error if MATRIX_BOT_USER_ID is not set", async () => {
delete process.env.MATRIX_BOT_USER_ID;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: MatrixRoomService,
useValue: mockMatrixRoomService,
},
],
}).compile();
const newService = module.get<MatrixService>(MatrixService);
await expect(newService.connect()).rejects.toThrow("MATRIX_BOT_USER_ID is required");
// Restore for other tests
process.env.MATRIX_BOT_USER_ID = "@mosaic-bot:example.com";
});
it("should throw error if MATRIX_WORKSPACE_ID is not set", async () => {
delete process.env.MATRIX_WORKSPACE_ID;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: MatrixRoomService,
useValue: mockMatrixRoomService,
},
],
}).compile();
const newService = module.get<MatrixService>(MatrixService);
await expect(newService.connect()).rejects.toThrow("MATRIX_WORKSPACE_ID is required");
// Restore for other tests
process.env.MATRIX_WORKSPACE_ID = "test-workspace-id";
});
it("should use configured workspace ID from environment", async () => {
const testWorkspaceId = "configured-workspace-456";
process.env.MATRIX_WORKSPACE_ID = testWorkspaceId;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: MatrixRoomService,
useValue: mockMatrixRoomService,
},
],
}).compile();
const newService = module.get<MatrixService>(MatrixService);
const message: ChatMessage = {
id: "msg-1",
channelId: "!test-room:example.com",
authorId: "@user:example.com",
authorName: "@user:example.com",
content: "@mosaic fix 42",
timestamp: new Date(),
};
await newService.connect();
await newService.handleCommand({
command: "fix",
args: ["42"],
message,
});
expect(mockStitcherService.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: testWorkspaceId,
})
);
// Restore for other tests
process.env.MATRIX_WORKSPACE_ID = "test-workspace-id";
});
});
describe("Error Logging Security", () => {
it("should sanitize sensitive data in error logs", async () => {
const loggerErrorSpy = vi.spyOn(
(service as Record<string, unknown>)["logger"] as { error: (...args: unknown[]) => void },
"error"
);
await service.connect();
// Trigger room.event handler with null event to exercise error path
expect(mockEventCallbacks.length).toBeGreaterThan(0);
mockEventCallbacks[0]?.("!room:example.com", null as unknown as Record<string, unknown>);
// Verify error was logged
expect(loggerErrorSpy).toHaveBeenCalled();
// Get the logged error
const loggedArgs = loggerErrorSpy.mock.calls[0];
const loggedError = loggedArgs?.[1] as Record<string, unknown>;
// Verify non-sensitive error info is preserved
expect(loggedError).toBeDefined();
expect((loggedError as { message: string }).message).toBe("Received null event from Matrix");
});
it("should not include access token in error output", () => {
// Verify the access token is stored privately and not exposed
const serviceAsRecord = service as unknown as Record<string, unknown>;
// The accessToken should exist but should not appear in any public-facing method output
expect(serviceAsRecord["accessToken"]).toBe("test-access-token");
// Verify isConnected does not leak token
const connected = service.isConnected();
expect(String(connected)).not.toContain("test-access-token");
});
});
describe("MatrixRoomService reverse lookup", () => {
it("should call getWorkspaceForRoom when processing messages", async () => {
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("resolved-workspace");
await service.connect();
const callback = mockMessageCallbacks[0];
callback?.("!some-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic help",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
expect(matrixRoomService.getWorkspaceForRoom).toHaveBeenCalledWith("!some-room:example.com");
});
it("should fall back to control room workspace when MatrixRoomService returns null", async () => {
mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue(null);
await service.connect();
const callback = mockMessageCallbacks[0];
// Send to the control room (fallback path)
callback?.("!test-room:example.com", {
event_id: "$msg-1",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #10",
},
});
// Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Should dispatch with the env-configured workspace
expect(stitcherService.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "test-workspace-id",
})
);
});
});
});

View File

@@ -0,0 +1,649 @@
import { Injectable, Logger, Optional, Inject } from "@nestjs/common";
import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin } from "matrix-bot-sdk";
import { StitcherService } from "../../stitcher/stitcher.service";
import { CommandParserService } from "../parser/command-parser.service";
import { CommandAction } from "../parser/command.interface";
import type { ParsedCommand } from "../parser/command.interface";
import { MatrixRoomService } from "./matrix-room.service";
import { sanitizeForLogging } from "../../common/utils";
import type {
IChatProvider,
ChatMessage,
ChatCommand,
ThreadCreateOptions,
ThreadMessageOptions,
} from "../interfaces";
/**
* Matrix room message event content
*/
interface MatrixMessageContent {
msgtype: string;
body: string;
"m.relates_to"?: MatrixRelatesTo;
}
/**
* Matrix relationship metadata for threads (MSC3440)
*/
interface MatrixRelatesTo {
rel_type: string;
event_id: string;
is_falling_back?: boolean;
"m.in_reply_to"?: {
event_id: string;
};
}
/**
* Matrix room event structure
*/
interface MatrixRoomEvent {
event_id: string;
sender: string;
origin_server_ts: number;
content: MatrixMessageContent;
}
/**
* Matrix Service - Matrix chat platform integration
*
* Responsibilities:
* - Connect to Matrix via access token
* - Listen for commands in mapped rooms (via MatrixRoomService)
* - Parse commands using shared CommandParserService
* - Forward commands to stitcher
* - Receive status updates from herald
* - Post updates to threads (MSC3440)
*/
@Injectable()
export class MatrixService implements IChatProvider {
private readonly logger = new Logger(MatrixService.name);
private client: MatrixClient | null = null;
private connected = false;
private readonly homeserverUrl: string;
private readonly accessToken: string;
private readonly botUserId: string;
private readonly controlRoomId: string;
private readonly workspaceId: string;
constructor(
private readonly stitcherService: StitcherService,
@Optional()
@Inject(CommandParserService)
private readonly commandParser: CommandParserService | null,
@Optional()
@Inject(MatrixRoomService)
private readonly matrixRoomService: MatrixRoomService | null
) {
this.homeserverUrl = process.env.MATRIX_HOMESERVER_URL ?? "";
this.accessToken = process.env.MATRIX_ACCESS_TOKEN ?? "";
this.botUserId = process.env.MATRIX_BOT_USER_ID ?? "";
this.controlRoomId = process.env.MATRIX_CONTROL_ROOM_ID ?? "";
this.workspaceId = process.env.MATRIX_WORKSPACE_ID ?? "";
}
/**
* Connect to Matrix homeserver
*/
async connect(): Promise<void> {
if (!this.homeserverUrl) {
throw new Error("MATRIX_HOMESERVER_URL is required");
}
if (!this.accessToken) {
throw new Error("MATRIX_ACCESS_TOKEN is required");
}
if (!this.workspaceId) {
throw new Error("MATRIX_WORKSPACE_ID is required");
}
if (!this.botUserId) {
throw new Error("MATRIX_BOT_USER_ID is required");
}
this.logger.log("Connecting to Matrix...");
const storage = new SimpleFsStorageProvider("matrix-bot-storage.json");
this.client = new MatrixClient(this.homeserverUrl, this.accessToken, storage);
// Auto-join rooms when invited
AutojoinRoomsMixin.setupOnClient(this.client);
// Setup event handlers
this.setupEventHandlers();
// Start syncing
await this.client.start();
this.connected = true;
this.logger.log(`Matrix bot connected as ${this.botUserId}`);
}
/**
* Setup event handlers for Matrix client
*/
private setupEventHandlers(): void {
if (!this.client) return;
this.client.on("room.message", (roomId: string, event: MatrixRoomEvent) => {
// Ignore messages from the bot itself
if (event.sender === this.botUserId) return;
// Only handle text messages
if (event.content.msgtype !== "m.text") return;
this.handleRoomMessage(roomId, event).catch((error: unknown) => {
this.logger.error(
`Error handling room message in ${roomId}:`,
error instanceof Error ? error.message : error
);
});
});
this.client.on("room.event", (_roomId: string, event: MatrixRoomEvent | null) => {
// Handle errors emitted as events
if (!event) {
const error = new Error("Received null event from Matrix");
const sanitizedError = sanitizeForLogging(error);
this.logger.error("Matrix client error:", sanitizedError);
}
});
}
/**
* Handle an incoming room message.
*
* Resolves the workspace for the room (via MatrixRoomService or fallback
* to the control room), then delegates to the shared CommandParserService
* for platform-agnostic command parsing and dispatches the result.
*/
private async handleRoomMessage(roomId: string, event: MatrixRoomEvent): Promise<void> {
// Resolve workspace: try MatrixRoomService first, fall back to control room
let resolvedWorkspaceId: string | null = null;
if (this.matrixRoomService) {
resolvedWorkspaceId = await this.matrixRoomService.getWorkspaceForRoom(roomId);
}
// Fallback: if the room is the configured control room, use the env workspace
if (!resolvedWorkspaceId && roomId === this.controlRoomId) {
resolvedWorkspaceId = this.workspaceId;
}
// If room is not mapped to any workspace, ignore the message
if (!resolvedWorkspaceId) {
return;
}
const messageContent = event.content.body;
// Build ChatMessage for interface compatibility
const chatMessage: ChatMessage = {
id: event.event_id,
channelId: roomId,
authorId: event.sender,
authorName: event.sender,
content: messageContent,
timestamp: new Date(event.origin_server_ts),
...(event.content["m.relates_to"]?.rel_type === "m.thread" && {
threadId: event.content["m.relates_to"].event_id,
}),
};
// Use shared CommandParserService if available
if (this.commandParser) {
// Normalize !mosaic to @mosaic for the shared parser
const normalizedContent = messageContent.replace(/^!mosaic/i, "@mosaic");
const result = this.commandParser.parseCommand(normalizedContent);
if (result.success) {
await this.handleParsedCommand(result.command, chatMessage, resolvedWorkspaceId);
} else if (normalizedContent.toLowerCase().startsWith("@mosaic")) {
// The user tried to use a command but it failed to parse -- send help
await this.sendMessage(roomId, result.error.help ?? result.error.message);
}
return;
}
// Fallback: use the built-in parseCommand if CommandParserService not injected
const command = this.parseCommand(chatMessage);
if (command) {
await this.handleCommand(command);
}
}
/**
* Handle a command parsed by the shared CommandParserService.
*
* Routes the ParsedCommand to the appropriate handler, passing
* along workspace context for job dispatch.
*/
private async handleParsedCommand(
parsed: ParsedCommand,
message: ChatMessage,
workspaceId: string
): Promise<void> {
this.logger.log(
`Handling command: ${parsed.action} from ${message.authorName} in workspace ${workspaceId}`
);
switch (parsed.action) {
case CommandAction.FIX:
await this.handleFixCommand(parsed.rawArgs, message, workspaceId);
break;
case CommandAction.STATUS:
await this.handleStatusCommand(parsed.rawArgs, message);
break;
case CommandAction.CANCEL:
await this.handleCancelCommand(parsed.rawArgs, message);
break;
case CommandAction.VERBOSE:
await this.handleVerboseCommand(parsed.rawArgs, message);
break;
case CommandAction.QUIET:
await this.handleQuietCommand(parsed.rawArgs, message);
break;
case CommandAction.HELP:
await this.handleHelpCommand(parsed.rawArgs, message);
break;
case CommandAction.RETRY:
await this.handleRetryCommand(parsed.rawArgs, message);
break;
default:
await this.sendMessage(
message.channelId,
`Unknown command. Type \`@mosaic help\` or \`!mosaic help\` for available commands.`
);
}
}
/**
* Disconnect from Matrix
*/
disconnect(): Promise<void> {
this.logger.log("Disconnecting from Matrix...");
this.connected = false;
if (this.client) {
this.client.stop();
}
return Promise.resolve();
}
/**
* Check if the provider is connected
*/
isConnected(): boolean {
return this.connected;
}
/**
* Get the underlying MatrixClient instance.
*
* Used by MatrixStreamingService for low-level operations
* (message edits, typing indicators) that require direct client access.
*
* @returns The MatrixClient instance, or null if not connected
*/
getClient(): MatrixClient | null {
return this.client;
}
/**
* Send a message to a room
*/
async sendMessage(roomId: string, content: string): Promise<void> {
if (!this.client) {
throw new Error("Matrix client is not connected");
}
const messageContent: MatrixMessageContent = {
msgtype: "m.text",
body: content,
};
await this.client.sendMessage(roomId, messageContent);
}
/**
* Create a thread for job updates (MSC3440)
*
* Matrix threads are created by sending an initial message
* and then replying with m.thread relation. The initial
* message event ID becomes the thread root.
*/
async createThread(options: ThreadCreateOptions): Promise<string> {
if (!this.client) {
throw new Error("Matrix client is not connected");
}
const { channelId, name, message } = options;
// Send the initial message that becomes the thread root
const initialContent: MatrixMessageContent = {
msgtype: "m.text",
body: `[${name}] ${message}`,
};
const eventId = await this.client.sendMessage(channelId, initialContent);
return eventId;
}
/**
* Send a message to a thread (MSC3440)
*
* Uses m.thread relation to associate the message with the thread root event.
*/
async sendThreadMessage(options: ThreadMessageOptions): Promise<void> {
if (!this.client) {
throw new Error("Matrix client is not connected");
}
const { threadId, channelId, content } = options;
// Use the channelId from options (threads are room-scoped), fall back to control room
const roomId = channelId || this.controlRoomId;
const threadContent: MatrixMessageContent = {
msgtype: "m.text",
body: content,
"m.relates_to": {
rel_type: "m.thread",
event_id: threadId,
is_falling_back: true,
"m.in_reply_to": {
event_id: threadId,
},
},
};
await this.client.sendMessage(roomId, threadContent);
}
/**
* Parse a command from a message (IChatProvider interface).
*
* Delegates to the shared CommandParserService when available,
* falling back to built-in parsing for backwards compatibility.
*/
parseCommand(message: ChatMessage): ChatCommand | null {
const { content } = message;
// Try shared parser first
if (this.commandParser) {
const normalizedContent = content.replace(/^!mosaic/i, "@mosaic");
const result = this.commandParser.parseCommand(normalizedContent);
if (result.success) {
return {
command: result.command.action,
args: result.command.rawArgs,
message,
};
}
return null;
}
// Fallback: built-in parsing for when CommandParserService is not injected
const lowerContent = content.toLowerCase();
if (!lowerContent.includes("@mosaic") && !lowerContent.includes("!mosaic")) {
return null;
}
const parts = content.trim().split(/\s+/);
const mosaicIndex = parts.findIndex(
(part) => part.toLowerCase().includes("@mosaic") || part.toLowerCase().includes("!mosaic")
);
if (mosaicIndex === -1 || mosaicIndex === parts.length - 1) {
return null;
}
const commandPart = parts[mosaicIndex + 1];
if (!commandPart) {
return null;
}
const command = commandPart.toLowerCase();
const args = parts.slice(mosaicIndex + 2);
const validCommands = ["fix", "status", "cancel", "verbose", "quiet", "help"];
if (!validCommands.includes(command)) {
return null;
}
return {
command,
args,
message,
};
}
/**
* Handle a parsed command (ChatCommand format, used by fallback path)
*/
async handleCommand(command: ChatCommand): Promise<void> {
const { command: cmd, args, message } = command;
this.logger.log(
`Handling command: ${cmd} with args: ${args.join(", ")} from ${message.authorName}`
);
switch (cmd) {
case "fix":
await this.handleFixCommand(args, message, this.workspaceId);
break;
case "status":
await this.handleStatusCommand(args, message);
break;
case "cancel":
await this.handleCancelCommand(args, message);
break;
case "verbose":
await this.handleVerboseCommand(args, message);
break;
case "quiet":
await this.handleQuietCommand(args, message);
break;
case "help":
await this.handleHelpCommand(args, message);
break;
default:
await this.sendMessage(
message.channelId,
`Unknown command: ${cmd}. Type \`@mosaic help\` or \`!mosaic help\` for available commands.`
);
}
}
/**
* Handle fix command - Start a job for an issue
*/
private async handleFixCommand(
args: string[],
message: ChatMessage,
workspaceId?: string
): Promise<void> {
if (args.length === 0 || !args[0]) {
await this.sendMessage(
message.channelId,
"Usage: `@mosaic fix <issue-number>` or `!mosaic fix <issue-number>`"
);
return;
}
// Parse issue number: handle both "#42" and "42" formats
const issueArg = args[0].replace(/^#/, "");
const issueNumber = parseInt(issueArg, 10);
if (isNaN(issueNumber)) {
await this.sendMessage(
message.channelId,
"Invalid issue number. Please provide a numeric issue number."
);
return;
}
const targetWorkspaceId = workspaceId ?? this.workspaceId;
// Create thread for job updates
const threadId = await this.createThread({
channelId: message.channelId,
name: `Job #${String(issueNumber)}`,
message: `Starting job for issue #${String(issueNumber)}...`,
});
// Dispatch job to stitcher
try {
const result = await this.stitcherService.dispatchJob({
workspaceId: targetWorkspaceId,
type: "code-task",
priority: 10,
metadata: {
issueNumber,
command: "fix",
channelId: message.channelId,
threadId: threadId,
authorId: message.authorId,
authorName: message.authorName,
},
});
// Send confirmation to thread
await this.sendThreadMessage({
threadId,
channelId: message.channelId,
content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`,
});
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
this.logger.error(
`Failed to dispatch job for issue #${String(issueNumber)}: ${errorMessage}`
);
await this.sendThreadMessage({
threadId,
channelId: message.channelId,
content: `Failed to start job: ${errorMessage}`,
});
}
}
/**
* Handle status command - Get job status
*/
private async handleStatusCommand(args: string[], message: ChatMessage): Promise<void> {
if (args.length === 0 || !args[0]) {
await this.sendMessage(
message.channelId,
"Usage: `@mosaic status <job-id>` or `!mosaic status <job-id>`"
);
return;
}
const jobId = args[0];
// TODO: Implement job status retrieval from stitcher
await this.sendMessage(
message.channelId,
`Status command not yet implemented for job: ${jobId}`
);
}
/**
* Handle cancel command - Cancel a running job
*/
private async handleCancelCommand(args: string[], message: ChatMessage): Promise<void> {
if (args.length === 0 || !args[0]) {
await this.sendMessage(
message.channelId,
"Usage: `@mosaic cancel <job-id>` or `!mosaic cancel <job-id>`"
);
return;
}
const jobId = args[0];
// TODO: Implement job cancellation in stitcher
await this.sendMessage(
message.channelId,
`Cancel command not yet implemented for job: ${jobId}`
);
}
/**
* Handle retry command - Retry a failed job
*/
private async handleRetryCommand(args: string[], message: ChatMessage): Promise<void> {
if (args.length === 0 || !args[0]) {
await this.sendMessage(
message.channelId,
"Usage: `@mosaic retry <job-id>` or `!mosaic retry <job-id>`"
);
return;
}
const jobId = args[0];
// TODO: Implement job retry in stitcher
await this.sendMessage(
message.channelId,
`Retry command not yet implemented for job: ${jobId}`
);
}
/**
* Handle verbose command - Stream full logs to thread
*/
private async handleVerboseCommand(args: string[], message: ChatMessage): Promise<void> {
if (args.length === 0 || !args[0]) {
await this.sendMessage(
message.channelId,
"Usage: `@mosaic verbose <job-id>` or `!mosaic verbose <job-id>`"
);
return;
}
const jobId = args[0];
// TODO: Implement verbose logging
await this.sendMessage(message.channelId, `Verbose mode not yet implemented for job: ${jobId}`);
}
/**
* Handle quiet command - Reduce notifications
*/
private async handleQuietCommand(_args: string[], message: ChatMessage): Promise<void> {
// TODO: Implement quiet mode
await this.sendMessage(
message.channelId,
"Quiet mode not yet implemented. Currently showing milestone updates only."
);
}
/**
* Handle help command - Show available commands
*/
private async handleHelpCommand(_args: string[], message: ChatMessage): Promise<void> {
const helpMessage = `
**Available commands:**
\`@mosaic fix <issue>\` or \`!mosaic fix <issue>\` - Start job for issue
\`@mosaic status <job>\` or \`!mosaic status <job>\` - Get job status
\`@mosaic cancel <job>\` or \`!mosaic cancel <job>\` - Cancel running job
\`@mosaic retry <job>\` or \`!mosaic retry <job>\` - Retry failed job
\`@mosaic verbose <job>\` or \`!mosaic verbose <job>\` - Stream full logs to thread
\`@mosaic quiet\` or \`!mosaic quiet\` - Reduce notifications
\`@mosaic help\` or \`!mosaic help\` - Show this help message
**Noise Management:**
- Main room: Low verbosity (milestones only)
- Job threads: Medium verbosity (step completions)
- DMs: Configurable per user
`.trim();
await this.sendMessage(message.channelId, helpMessage);
}
}

View File

@@ -10,7 +10,7 @@ import { BridgeModule } from "../bridge/bridge.module";
* - Subscribe to job events
* - Format status messages with PDA-friendly language
* - Route to appropriate channels based on workspace config
* - Support Discord (via bridge) and PR comments
* - Broadcast to ALL active chat providers via CHAT_PROVIDERS token
*/
@Module({
imports: [PrismaModule, BridgeModule],

View File

@@ -2,7 +2,8 @@ import { Test, TestingModule } from "@nestjs/testing";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { HeraldService } from "./herald.service";
import { PrismaService } from "../prisma/prisma.service";
import { DiscordService } from "../bridge/discord/discord.service";
import { CHAT_PROVIDERS } from "../bridge/bridge.constants";
import type { IChatProvider } from "../bridge/interfaces/chat-provider.interface";
import {
JOB_CREATED,
JOB_STARTED,
@@ -14,10 +15,31 @@ import {
GATE_FAILED,
} from "../job-events/event-types";
function createMockProvider(
name: string,
connected = true
): IChatProvider & {
sendMessage: ReturnType<typeof vi.fn>;
sendThreadMessage: ReturnType<typeof vi.fn>;
createThread: ReturnType<typeof vi.fn>;
isConnected: ReturnType<typeof vi.fn>;
connect: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
parseCommand: ReturnType<typeof vi.fn>;
} {
return {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn().mockReturnValue(connected),
sendMessage: vi.fn().mockResolvedValue(undefined),
createThread: vi.fn().mockResolvedValue("thread-id"),
sendThreadMessage: vi.fn().mockResolvedValue(undefined),
parseCommand: vi.fn().mockReturnValue(null),
};
}
describe("HeraldService", () => {
let service: HeraldService;
let prisma: PrismaService;
let discord: DiscordService;
const mockPrisma = {
workspace: {
@@ -31,14 +53,15 @@ describe("HeraldService", () => {
},
};
const mockDiscord = {
isConnected: vi.fn(),
sendMessage: vi.fn(),
sendThreadMessage: vi.fn(),
createThread: vi.fn(),
};
let mockProviderA: ReturnType<typeof createMockProvider>;
let mockProviderB: ReturnType<typeof createMockProvider>;
let chatProviders: IChatProvider[];
beforeEach(async () => {
mockProviderA = createMockProvider("providerA", true);
mockProviderB = createMockProvider("providerB", true);
chatProviders = [mockProviderA, mockProviderB];
const module: TestingModule = await Test.createTestingModule({
providers: [
HeraldService,
@@ -47,25 +70,47 @@ describe("HeraldService", () => {
useValue: mockPrisma,
},
{
provide: DiscordService,
useValue: mockDiscord,
provide: CHAT_PROVIDERS,
useValue: chatProviders,
},
],
}).compile();
service = module.get<HeraldService>(HeraldService);
prisma = module.get<PrismaService>(PrismaService);
discord = module.get<DiscordService>(DiscordService);
// Reset mocks
vi.clearAllMocks();
// Restore default connected state after clearAllMocks
mockProviderA.isConnected.mockReturnValue(true);
mockProviderB.isConnected.mockReturnValue(true);
});
describe("broadcastJobEvent", () => {
it("should broadcast job.created event to configured channel", async () => {
// Arrange
const baseSetup = (): {
jobId: string;
workspaceId: string;
} => {
const workspaceId = "workspace-1";
const jobId = "job-1";
mockPrisma.runnerJob.findUnique.mockResolvedValue({
id: jobId,
workspaceId,
type: "code-task",
});
mockPrisma.jobEvent.findFirst.mockResolvedValue({
payload: {
metadata: { issueNumber: 42, threadId: "thread-123", channelId: "channel-abc" },
},
});
return { jobId, workspaceId };
};
it("should broadcast to all connected providers", async () => {
// Arrange
const { jobId } = baseSetup();
const event = {
id: "event-1",
jobId,
@@ -75,46 +120,25 @@ describe("HeraldService", () => {
payload: { issueNumber: 42 },
};
mockPrisma.workspace.findUnique.mockResolvedValue({
id: workspaceId,
settings: {
herald: {
channelMappings: {
"code-task": "channel-123",
},
},
},
});
mockPrisma.runnerJob.findUnique.mockResolvedValue({
id: jobId,
workspaceId,
type: "code-task",
});
mockPrisma.jobEvent.findFirst.mockResolvedValue({
payload: {
metadata: { issueNumber: 42, threadId: "thread-123" },
},
});
mockDiscord.isConnected.mockReturnValue(true);
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123",
channelId: "channel-abc",
content: expect.stringContaining("Job created"),
});
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123",
channelId: "channel-abc",
content: expect.stringContaining("Job created"),
});
});
it("should broadcast job.started event", async () => {
it("should broadcast job.started event to all providers", async () => {
// Arrange
const workspaceId = "workspace-1";
const jobId = "job-1";
const { jobId } = baseSetup();
const event = {
id: "event-1",
jobId,
@@ -124,40 +148,25 @@ describe("HeraldService", () => {
payload: {},
};
mockPrisma.workspace.findUnique.mockResolvedValue({
id: workspaceId,
settings: { herald: { channelMappings: {} } },
});
mockPrisma.runnerJob.findUnique.mockResolvedValue({
id: jobId,
workspaceId,
type: "code-task",
});
mockPrisma.jobEvent.findFirst.mockResolvedValue({
payload: {
metadata: { threadId: "thread-123" },
},
});
mockDiscord.isConnected.mockReturnValue(true);
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123",
channelId: "channel-abc",
content: expect.stringContaining("Job started"),
});
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123",
channelId: "channel-abc",
content: expect.stringContaining("Job started"),
});
});
it("should broadcast job.completed event with success message", async () => {
// Arrange
const workspaceId = "workspace-1";
const jobId = "job-1";
const { jobId } = baseSetup();
const event = {
id: "event-1",
jobId,
@@ -167,40 +176,20 @@ describe("HeraldService", () => {
payload: { duration: 120 },
};
mockPrisma.workspace.findUnique.mockResolvedValue({
id: workspaceId,
settings: { herald: { channelMappings: {} } },
});
mockPrisma.runnerJob.findUnique.mockResolvedValue({
id: jobId,
workspaceId,
type: "code-task",
});
mockPrisma.jobEvent.findFirst.mockResolvedValue({
payload: {
metadata: { threadId: "thread-123" },
},
});
mockDiscord.isConnected.mockReturnValue(true);
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123",
channelId: "channel-abc",
content: expect.stringContaining("completed"),
});
});
it("should broadcast job.failed event with PDA-friendly language", async () => {
// Arrange
const workspaceId = "workspace-1";
const jobId = "job-1";
const { jobId } = baseSetup();
const event = {
id: "event-1",
jobId,
@@ -210,43 +199,30 @@ describe("HeraldService", () => {
payload: { error: "Build failed" },
};
mockPrisma.workspace.findUnique.mockResolvedValue({
id: workspaceId,
settings: { herald: { channelMappings: {} } },
});
mockPrisma.runnerJob.findUnique.mockResolvedValue({
id: jobId,
workspaceId,
type: "code-task",
});
mockPrisma.jobEvent.findFirst.mockResolvedValue({
payload: {
metadata: { threadId: "thread-123" },
},
});
mockDiscord.isConnected.mockReturnValue(true);
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123",
channelId: "channel-abc",
content: expect.stringContaining("encountered an issue"),
});
// Verify the actual message doesn't contain demanding language
const actualCall = mockDiscord.sendThreadMessage.mock.calls[0][0];
const actualCall = mockProviderA.sendThreadMessage.mock.calls[0][0] as {
threadId: string;
channelId: string;
content: string;
};
expect(actualCall.content).not.toMatch(/FAILED|ERROR|CRITICAL|URGENT/);
});
it("should skip broadcasting if Discord is not connected", async () => {
it("should skip disconnected providers", async () => {
// Arrange
const workspaceId = "workspace-1";
const jobId = "job-1";
const { jobId } = baseSetup();
mockProviderA.isConnected.mockReturnValue(true);
mockProviderB.isConnected.mockReturnValue(false);
const event = {
id: "event-1",
jobId,
@@ -256,14 +232,36 @@ describe("HeraldService", () => {
payload: {},
};
mockPrisma.workspace.findUnique.mockResolvedValue({
id: workspaceId,
settings: { herald: { channelMappings: {} } },
});
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledTimes(1);
expect(mockProviderB.sendThreadMessage).not.toHaveBeenCalled();
});
it("should handle empty providers array without crashing", async () => {
// Arrange — rebuild module with empty providers
const module: TestingModule = await Test.createTestingModule({
providers: [
HeraldService,
{
provide: PrismaService,
useValue: mockPrisma,
},
{
provide: CHAT_PROVIDERS,
useValue: [],
},
],
}).compile();
const emptyService = module.get<HeraldService>(HeraldService);
const jobId = "job-1";
mockPrisma.runnerJob.findUnique.mockResolvedValue({
id: jobId,
workspaceId,
workspaceId: "workspace-1",
type: "code-task",
});
@@ -273,36 +271,68 @@ describe("HeraldService", () => {
},
});
mockDiscord.isConnected.mockReturnValue(false);
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: {},
};
// Act
// Act & Assert — should not throw
await expect(emptyService.broadcastJobEvent(jobId, event)).resolves.not.toThrow();
});
it("should continue broadcasting when one provider errors", async () => {
// Arrange
const { jobId } = baseSetup();
mockProviderA.sendThreadMessage.mockRejectedValue(new Error("Provider A rate limit"));
mockProviderB.sendThreadMessage.mockResolvedValue(undefined);
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: {},
};
// Act — should not throw despite provider A failing
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockDiscord.sendThreadMessage).not.toHaveBeenCalled();
// Assert — provider B should still have been called
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledTimes(1);
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledTimes(1);
});
it("should not throw when all providers error", async () => {
// Arrange
const { jobId } = baseSetup();
mockProviderA.sendThreadMessage.mockRejectedValue(new Error("Provider A down"));
mockProviderB.sendThreadMessage.mockRejectedValue(new Error("Provider B down"));
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: {},
};
// Act & Assert — should not throw; provider errors are logged, not propagated
await expect(service.broadcastJobEvent(jobId, event)).resolves.not.toThrow();
});
it("should skip broadcasting if job has no threadId", async () => {
// Arrange
const workspaceId = "workspace-1";
const jobId = "job-1";
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: {},
};
mockPrisma.workspace.findUnique.mockResolvedValue({
id: workspaceId,
settings: { herald: { channelMappings: {} } },
});
mockPrisma.runnerJob.findUnique.mockResolvedValue({
id: jobId,
workspaceId,
workspaceId: "workspace-1",
type: "code-task",
});
@@ -312,16 +342,45 @@ describe("HeraldService", () => {
},
});
mockDiscord.isConnected.mockReturnValue(true);
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: {},
};
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockDiscord.sendThreadMessage).not.toHaveBeenCalled();
expect(mockProviderA.sendThreadMessage).not.toHaveBeenCalled();
expect(mockProviderB.sendThreadMessage).not.toHaveBeenCalled();
});
// ERROR HANDLING TESTS - Issue #185
it("should skip broadcasting if job not found", async () => {
// Arrange
const jobId = "nonexistent-job";
mockPrisma.runnerJob.findUnique.mockResolvedValue(null);
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: {},
};
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockProviderA.sendThreadMessage).not.toHaveBeenCalled();
});
// ERROR HANDLING TESTS - database errors should still propagate
it("should propagate database errors when job lookup fails", async () => {
// Arrange
@@ -344,43 +403,8 @@ describe("HeraldService", () => {
);
});
it("should propagate Discord send failures with context", async () => {
// Arrange
const workspaceId = "workspace-1";
const jobId = "job-1";
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: {},
};
mockPrisma.runnerJob.findUnique.mockResolvedValue({
id: jobId,
workspaceId,
type: "code-task",
});
mockPrisma.jobEvent.findFirst.mockResolvedValue({
payload: {
metadata: { threadId: "thread-123" },
},
});
mockDiscord.isConnected.mockReturnValue(true);
const discordError = new Error("Rate limit exceeded");
mockDiscord.sendThreadMessage.mockRejectedValue(discordError);
// Act & Assert
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow("Rate limit exceeded");
});
it("should propagate errors when fetching job events fails", async () => {
// Arrange
const workspaceId = "workspace-1";
const jobId = "job-1";
const event = {
id: "event-1",
@@ -393,61 +417,16 @@ describe("HeraldService", () => {
mockPrisma.runnerJob.findUnique.mockResolvedValue({
id: jobId,
workspaceId,
workspaceId: "workspace-1",
type: "code-task",
});
const dbError = new Error("Query timeout");
mockPrisma.jobEvent.findFirst.mockRejectedValue(dbError);
mockDiscord.isConnected.mockReturnValue(true);
// Act & Assert
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow("Query timeout");
});
it("should include job context in error messages", async () => {
// Arrange
const workspaceId = "workspace-1";
const jobId = "test-job-123";
const event = {
id: "event-1",
jobId,
type: JOB_COMPLETED,
timestamp: new Date(),
actor: "system",
payload: {},
};
mockPrisma.runnerJob.findUnique.mockResolvedValue({
id: jobId,
workspaceId,
type: "code-task",
});
mockPrisma.jobEvent.findFirst.mockResolvedValue({
payload: {
metadata: { threadId: "thread-123" },
},
});
mockDiscord.isConnected.mockReturnValue(true);
const discordError = new Error("Network failure");
mockDiscord.sendThreadMessage.mockRejectedValue(discordError);
// Act & Assert
try {
await service.broadcastJobEvent(jobId, event);
// Should not reach here
expect(true).toBe(false);
} catch (error) {
// Verify error was thrown
expect(error).toBeDefined();
// Verify original error is preserved
expect((error as Error).message).toContain("Network failure");
}
});
});
describe("formatJobEventMessage", () => {
@@ -473,7 +452,6 @@ describe("HeraldService", () => {
const message = service.formatJobEventMessage(event, job, metadata);
// Assert
expect(message).toContain("🟢");
expect(message).toContain("Job created");
expect(message).toContain("#42");
expect(message.length).toBeLessThan(200); // Keep it scannable
@@ -526,7 +504,6 @@ describe("HeraldService", () => {
const message = service.formatJobEventMessage(event, job, metadata);
// Assert
expect(message).toMatch(/✅|🟢/);
expect(message).toContain("completed");
expect(message).not.toMatch(/COMPLETED|SUCCESS/);
});

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from "@nestjs/common";
import { Inject, Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { DiscordService } from "../bridge/discord/discord.service";
import { CHAT_PROVIDERS } from "../bridge/bridge.constants";
import type { IChatProvider } from "../bridge/interfaces/chat-provider.interface";
import {
JOB_CREATED,
JOB_STARTED,
@@ -21,7 +22,7 @@ import {
* - Subscribe to job events
* - Format status messages with PDA-friendly language
* - Route to appropriate channels based on workspace config
* - Support Discord (via bridge) and PR comments
* - Broadcast to ALL active chat providers (Discord, Matrix, etc.)
*/
@Injectable()
export class HeraldService {
@@ -29,11 +30,11 @@ export class HeraldService {
constructor(
private readonly prisma: PrismaService,
private readonly discord: DiscordService
@Inject(CHAT_PROVIDERS) private readonly chatProviders: IChatProvider[]
) {}
/**
* Broadcast a job event to the appropriate channel
* Broadcast a job event to all connected chat providers
*/
async broadcastJobEvent(
jobId: string,
@@ -47,66 +48,68 @@ export class HeraldService {
payload: unknown;
}
): Promise<void> {
try {
// Get job details
const job = await this.prisma.runnerJob.findUnique({
where: { id: jobId },
select: {
id: true,
workspaceId: true,
type: true,
},
});
// Get job details
const job = await this.prisma.runnerJob.findUnique({
where: { id: jobId },
select: {
id: true,
workspaceId: true,
type: true,
},
});
if (!job) {
this.logger.warn(`Job ${jobId} not found, skipping broadcast`);
return;
}
// Check if Discord is connected
if (!this.discord.isConnected()) {
this.logger.debug("Discord not connected, skipping broadcast");
return;
}
// Get threadId from first event payload (job.created event has metadata)
const firstEvent = await this.prisma.jobEvent.findFirst({
where: {
jobId,
type: JOB_CREATED,
},
select: {
payload: true,
},
});
const firstEventPayload = firstEvent?.payload as Record<string, unknown> | undefined;
const metadata = firstEventPayload?.metadata as Record<string, unknown> | undefined;
const threadId = metadata?.threadId as string | undefined;
if (!threadId) {
this.logger.debug(`Job ${jobId} has no threadId, skipping broadcast`);
return;
}
// Format message
const message = this.formatJobEventMessage(event, job, metadata);
// Send to thread
await this.discord.sendThreadMessage({
threadId,
content: message,
});
this.logger.debug(`Broadcasted event ${event.type} for job ${jobId} to thread ${threadId}`);
} catch (error) {
// Log the error with full context for debugging
this.logger.error(`Failed to broadcast event ${event.type} for job ${jobId}:`, error);
// Re-throw the error so callers can handle it appropriately
// This enables proper error tracking, retry logic, and alerting
throw error;
if (!job) {
this.logger.warn(`Job ${jobId} not found, skipping broadcast`);
return;
}
// Get threadId from first event payload (job.created event has metadata)
const firstEvent = await this.prisma.jobEvent.findFirst({
where: {
jobId,
type: JOB_CREATED,
},
select: {
payload: true,
},
});
const firstEventPayload = firstEvent?.payload as Record<string, unknown> | undefined;
const metadata = firstEventPayload?.metadata as Record<string, unknown> | undefined;
const threadId = metadata?.threadId as string | undefined;
const channelId = metadata?.channelId as string | undefined;
if (!threadId) {
this.logger.debug(`Job ${jobId} has no threadId, skipping broadcast`);
return;
}
// Format message
const message = this.formatJobEventMessage(event, job, metadata);
// Broadcast to all connected providers
for (const provider of this.chatProviders) {
if (!provider.isConnected()) {
continue;
}
try {
await provider.sendThreadMessage({
threadId,
channelId: channelId ?? "",
content: message,
});
} catch (error: unknown) {
// Log and continue — one provider failure must not block others
const providerName = provider.constructor.name;
this.logger.error(
`Failed to broadcast event ${event.type} for job ${jobId} via ${providerName}:`,
error instanceof Error ? error.message : error
);
}
}
this.logger.debug(`Broadcasted event ${event.type} for job ${jobId} to thread ${threadId}`);
}
/**

View File

@@ -1,12 +1,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import {
TaskType,
Complexity,
Harness,
Provider,
Outcome,
} from "@mosaicstack/telemetry-client";
import { TaskType, Complexity, Harness, Provider, Outcome } from "@mosaicstack/telemetry-client";
import type { TaskCompletionEvent, EventBuilderParams } from "@mosaicstack/telemetry-client";
import { MosaicTelemetryService } from "../mosaic-telemetry/mosaic-telemetry.service";
import {
@@ -291,7 +285,7 @@ describe("LlmTelemetryTrackerService", () => {
actual_input_tokens: 150,
actual_output_tokens: 300,
outcome: Outcome.SUCCESS,
}),
})
);
expect(mockTelemetryService.trackTaskCompletion).toHaveBeenCalledWith(mockEvent);
@@ -309,7 +303,7 @@ describe("LlmTelemetryTrackerService", () => {
model: "gpt-4o",
provider: Provider.OPENAI,
harness: Harness.API_DIRECT,
}),
})
);
});
@@ -325,7 +319,7 @@ describe("LlmTelemetryTrackerService", () => {
model: "llama3.2",
provider: Provider.OLLAMA,
harness: Harness.OLLAMA_LOCAL,
}),
})
);
});
@@ -340,7 +334,7 @@ describe("LlmTelemetryTrackerService", () => {
// Estimated values are 0 when no PredictionService is injected
estimated_cost_usd_micros: 0,
actual_cost_usd_micros: expectedActualCost,
}),
})
);
});
@@ -355,7 +349,7 @@ describe("LlmTelemetryTrackerService", () => {
expect.objectContaining({
estimated_cost_usd_micros: 0,
actual_cost_usd_micros: 0,
}),
})
);
});
@@ -368,7 +362,7 @@ describe("LlmTelemetryTrackerService", () => {
expect(mockTelemetryService.eventBuilder?.build).toHaveBeenCalledWith(
expect.objectContaining({
outcome: Outcome.FAILURE,
}),
})
);
});
@@ -381,7 +375,7 @@ describe("LlmTelemetryTrackerService", () => {
expect(mockTelemetryService.eventBuilder?.build).toHaveBeenCalledWith(
expect.objectContaining({
task_type: TaskType.PLANNING,
}),
})
);
});
@@ -393,7 +387,7 @@ describe("LlmTelemetryTrackerService", () => {
quality_gate_passed: true,
quality_gates_run: [],
quality_gates_failed: [],
}),
})
);
});
@@ -441,7 +435,7 @@ describe("LlmTelemetryTrackerService", () => {
// Estimated values are 0 when no PredictionService is injected
estimated_input_tokens: 0,
estimated_output_tokens: 0,
}),
})
);
});
@@ -457,7 +451,7 @@ describe("LlmTelemetryTrackerService", () => {
expect.objectContaining({
task_type: TaskType.IMPLEMENTATION,
actual_output_tokens: 0,
}),
})
);
});

View File

@@ -169,7 +169,7 @@ describe("LlmService", () => {
outputTokens: 20,
callingContext: "chat",
success: true,
}),
})
);
});
@@ -183,7 +183,7 @@ describe("LlmService", () => {
model: "llama3.2",
operation: "chat",
success: false,
}),
})
);
});
@@ -261,7 +261,7 @@ describe("LlmService", () => {
outputTokens: 10,
callingContext: "brain",
success: true,
}),
})
);
});
@@ -295,7 +295,7 @@ describe("LlmService", () => {
inputTokens: 1,
// Output estimated from "Hello world" -> ceil(11/4) = 3
outputTokens: 3,
}),
})
);
});
@@ -313,7 +313,7 @@ describe("LlmService", () => {
expect.objectContaining({
operation: "chatStream",
success: false,
}),
})
);
});
@@ -368,7 +368,7 @@ describe("LlmService", () => {
outputTokens: 0,
callingContext: "embed",
success: true,
}),
})
);
});
@@ -381,7 +381,7 @@ describe("LlmService", () => {
expect.objectContaining({
operation: "embed",
success: false,
}),
})
);
});

View File

@@ -73,9 +73,7 @@ const { MosaicTelemetryService } = await import("./mosaic-telemetry.service");
/**
* Create a ConfigService mock that returns environment values from the provided map.
*/
function createConfigService(
envMap: Record<string, string | undefined> = {},
): ConfigService {
function createConfigService(envMap: Record<string, string | undefined> = {}): ConfigService {
const configService = {
get: vi.fn((key: string, defaultValue?: string): string => {
const value = envMap[key];
@@ -289,7 +287,7 @@ describe("MosaicTelemetryService", () => {
cost_usd_micros: { median: 5000 },
duration_ms: { median: 10000 },
correction_factors: { input: 1.0, output: 1.0 },
quality: { gate_pass_rate: 0.95, success_rate: 0.90 },
quality: { gate_pass_rate: 0.95, success_rate: 0.9 },
},
metadata: {
sample_size: 100,
@@ -467,7 +465,7 @@ describe("MosaicTelemetryService", () => {
model: "test",
provider: Provider.ANTHROPIC,
complexity: Complexity.LOW,
}),
})
).toBeNull();
await expect(service.refreshPredictions([])).resolves.not.toThrow();
expect(service.eventBuilder).toBeNull();

View File

@@ -1,14 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import {
TaskType,
Complexity,
Provider,
} from "@mosaicstack/telemetry-client";
import type {
PredictionResponse,
PredictionQuery,
} from "@mosaicstack/telemetry-client";
import { TaskType, Complexity, Provider } from "@mosaicstack/telemetry-client";
import type { PredictionResponse, PredictionQuery } from "@mosaicstack/telemetry-client";
import { MosaicTelemetryService } from "./mosaic-telemetry.service";
import { PredictionService } from "./prediction.service";
@@ -124,12 +117,7 @@ describe("PredictionService", () => {
});
it("should pass correct query parameters to telemetry service", () => {
service.getEstimate(
TaskType.CODE_REVIEW,
"gpt-4o",
Provider.OPENAI,
Complexity.HIGH
);
service.getEstimate(TaskType.CODE_REVIEW, "gpt-4o", Provider.OPENAI, Complexity.HIGH);
expect(mockTelemetryService.getPrediction).toHaveBeenCalledWith({
task_type: TaskType.CODE_REVIEW,
@@ -205,8 +193,7 @@ describe("PredictionService", () => {
expect(mockTelemetryService.refreshPredictions).toHaveBeenCalledTimes(1);
const queries: PredictionQuery[] =
mockTelemetryService.refreshPredictions.mock.calls[0][0];
const queries: PredictionQuery[] = mockTelemetryService.refreshPredictions.mock.calls[0][0];
// Should have queries for cross-product of models, task types, and complexities
expect(queries.length).toBeGreaterThan(0);
@@ -223,8 +210,7 @@ describe("PredictionService", () => {
it("should include Anthropic model predictions", async () => {
await service.refreshCommonPredictions();
const queries: PredictionQuery[] =
mockTelemetryService.refreshPredictions.mock.calls[0][0];
const queries: PredictionQuery[] = mockTelemetryService.refreshPredictions.mock.calls[0][0];
const anthropicQueries = queries.filter(
(q: PredictionQuery) => q.provider === Provider.ANTHROPIC
@@ -235,12 +221,9 @@ describe("PredictionService", () => {
it("should include OpenAI model predictions", async () => {
await service.refreshCommonPredictions();
const queries: PredictionQuery[] =
mockTelemetryService.refreshPredictions.mock.calls[0][0];
const queries: PredictionQuery[] = mockTelemetryService.refreshPredictions.mock.calls[0][0];
const openaiQueries = queries.filter(
(q: PredictionQuery) => q.provider === Provider.OPENAI
);
const openaiQueries = queries.filter((q: PredictionQuery) => q.provider === Provider.OPENAI);
expect(openaiQueries.length).toBeGreaterThan(0);
});
@@ -253,9 +236,7 @@ describe("PredictionService", () => {
});
it("should not throw when refreshPredictions rejects", async () => {
mockTelemetryService.refreshPredictions.mockRejectedValue(
new Error("Server unreachable")
);
mockTelemetryService.refreshPredictions.mockRejectedValue(new Error("Server unreachable"));
// Should not throw
await expect(service.refreshCommonPredictions()).resolves.not.toThrow();
@@ -264,8 +245,7 @@ describe("PredictionService", () => {
it("should include common task types in queries", async () => {
await service.refreshCommonPredictions();
const queries: PredictionQuery[] =
mockTelemetryService.refreshPredictions.mock.calls[0][0];
const queries: PredictionQuery[] = mockTelemetryService.refreshPredictions.mock.calls[0][0];
const taskTypes = new Set(queries.map((q: PredictionQuery) => q.task_type));
@@ -277,8 +257,7 @@ describe("PredictionService", () => {
it("should include common complexity levels in queries", async () => {
await service.refreshCommonPredictions();
const queries: PredictionQuery[] =
mockTelemetryService.refreshPredictions.mock.calls[0][0];
const queries: PredictionQuery[] = mockTelemetryService.refreshPredictions.mock.calls[0][0];
const complexities = new Set(queries.map((q: PredictionQuery) => q.complexity));
@@ -309,9 +288,7 @@ describe("PredictionService", () => {
});
it("should not throw when refresh fails on init", () => {
mockTelemetryService.refreshPredictions.mockRejectedValue(
new Error("Connection refused")
);
mockTelemetryService.refreshPredictions.mockRejectedValue(new Error("Connection refused"));
// Should not throw
expect(() => service.onModuleInit()).not.toThrow();

View File

@@ -0,0 +1,129 @@
# ==============================================
# Matrix Dev Environment (Synapse + Element Web)
# ==============================================
#
# Development-only overlay for testing the Matrix bridge locally.
# NOT for production — use docker-compose.sample.matrix.yml for production.
#
# Usage:
# docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml up -d
#
# Or with Makefile:
# make matrix-up
#
# This overlay:
# - Adds Synapse homeserver (localhost:8008) using shared PostgreSQL
# - Adds Element Web client (localhost:8501)
# - Creates a separate 'synapse' database in the shared PostgreSQL instance
# - Enables open registration for easy dev testing
#
# After first startup, create the bot account:
# docker/matrix/scripts/setup-bot.sh
#
# ==============================================
services:
# ======================
# Synapse Database Init
# ======================
# Creates the 'synapse' database and user in the shared PostgreSQL instance.
# Runs once and exits — idempotent, safe to run repeatedly.
synapse-db-init:
image: postgres:17-alpine
container_name: mosaic-synapse-db-init
restart: "no"
environment:
PGHOST: postgres
PGPORT: 5432
PGUSER: ${POSTGRES_USER:-mosaic}
PGPASSWORD: ${POSTGRES_PASSWORD:-mosaic_dev_password}
SYNAPSE_DB: ${SYNAPSE_POSTGRES_DB:-synapse}
SYNAPSE_USER: ${SYNAPSE_POSTGRES_USER:-synapse}
SYNAPSE_PASSWORD: ${SYNAPSE_POSTGRES_PASSWORD:-synapse_dev_password}
entrypoint: ["sh", "-c"]
command:
- |
until pg_isready -h postgres -p 5432 -U $${PGUSER}; do
echo "Waiting for PostgreSQL..."
sleep 2
done
echo "PostgreSQL is ready. Creating Synapse database and user..."
psql -h postgres -U $${PGUSER} -tc "SELECT 1 FROM pg_roles WHERE rolname='$${SYNAPSE_USER}'" | grep -q 1 || \
psql -h postgres -U $${PGUSER} -c "CREATE USER $${SYNAPSE_USER} WITH PASSWORD '$${SYNAPSE_PASSWORD}';"
psql -h postgres -U $${PGUSER} -tc "SELECT 1 FROM pg_database WHERE datname='$${SYNAPSE_DB}'" | grep -q 1 || \
psql -h postgres -U $${PGUSER} -c "CREATE DATABASE $${SYNAPSE_DB} OWNER $${SYNAPSE_USER} ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;"
echo "Synapse database ready: $${SYNAPSE_DB}"
depends_on:
postgres:
condition: service_healthy
networks:
- mosaic-network
# ======================
# Synapse (Matrix Homeserver)
# ======================
synapse:
image: matrixdotorg/synapse:latest
container_name: mosaic-synapse
restart: unless-stopped
environment:
SYNAPSE_CONFIG_DIR: /data
SYNAPSE_CONFIG_PATH: /data/homeserver.yaml
ports:
- "${SYNAPSE_CLIENT_PORT:-8008}:8008"
- "${SYNAPSE_FEDERATION_PORT:-8448}:8448"
volumes:
- ./matrix/synapse/homeserver.yaml:/data/homeserver.yaml:ro
- synapse_data:/data/media_store
- synapse_signing_key:/data/keys
depends_on:
postgres:
condition: service_healthy
synapse-db-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD-SHELL", "curl -fSs http://localhost:8008/health || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
networks:
- mosaic-network
labels:
com.mosaic.service: "matrix-synapse"
com.mosaic.description: "Matrix homeserver (dev)"
# ======================
# Element Web (Matrix Client)
# ======================
element-web:
image: vectorim/element-web:latest
container_name: mosaic-element-web
restart: unless-stopped
ports:
- "${ELEMENT_PORT:-8501}:80"
volumes:
- ./matrix/element/config.json:/app/config.json:ro
depends_on:
synapse:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:80 || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- mosaic-network
labels:
com.mosaic.service: "matrix-element"
com.mosaic.description: "Element Web client (dev)"
volumes:
synapse_data:
name: mosaic-synapse-data
synapse_signing_key:
name: mosaic-synapse-signing-key

View File

@@ -0,0 +1,30 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "http://localhost:8008",
"server_name": "localhost"
}
},
"brand": "Mosaic Stack Dev",
"default_theme": "dark",
"room_directory": {
"servers": ["localhost"]
},
"features": {
"feature_video_rooms": false,
"feature_group_calls": false
},
"show_labs_settings": true,
"piwik": false,
"posthog": {
"enabled": false
},
"privacy_policy_url": null,
"terms_and_conditions_links": [],
"setting_defaults": {
"breadcrumbs": true,
"custom_themes": []
},
"disable_guests": true,
"disable_3pid_login": true
}

View File

@@ -0,0 +1,203 @@
#!/usr/bin/env bash
# ==============================================
# Matrix Bot Account Setup Script
# ==============================================
#
# Creates the Mosaic bot user on the local Synapse instance and retrieves
# an access token. Idempotent — safe to run multiple times.
#
# Usage:
# docker/matrix/scripts/setup-bot.sh
# docker/matrix/scripts/setup-bot.sh --username custom-bot --password custom-pass
#
# Prerequisites:
# - Synapse must be running (docker compose -f ... up synapse)
# - Synapse must be healthy (check with: curl http://localhost:8008/health)
#
# Output:
# Prints the environment variables needed for MatrixService configuration.
#
# ==============================================
set -euo pipefail
# Defaults
SYNAPSE_URL="${SYNAPSE_URL:-http://localhost:8008}"
BOT_USERNAME="${BOT_USERNAME:-mosaic-bot}"
BOT_PASSWORD="${BOT_PASSWORD:-mosaic-bot-dev-password}"
BOT_DISPLAY_NAME="${BOT_DISPLAY_NAME:-Mosaic Bot}"
ADMIN_USERNAME="${ADMIN_USERNAME:-admin}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin-dev-password}"
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--username) BOT_USERNAME="$2"; shift 2 ;;
--password) BOT_PASSWORD="$2"; shift 2 ;;
--synapse-url) SYNAPSE_URL="$2"; shift 2 ;;
--admin-username) ADMIN_USERNAME="$2"; shift 2 ;;
--admin-password) ADMIN_PASSWORD="$2"; shift 2 ;;
--help|-h)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --username NAME Bot username (default: mosaic-bot)"
echo " --password PASS Bot password (default: mosaic-bot-dev-password)"
echo " --synapse-url URL Synapse URL (default: http://localhost:8008)"
echo " --admin-username NAME Admin username (default: admin)"
echo " --admin-password PASS Admin password (default: admin-dev-password)"
echo " --help, -h Show this help"
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
echo "=== Mosaic Stack — Matrix Bot Setup ==="
echo ""
echo "Synapse URL: ${SYNAPSE_URL}"
echo "Bot username: ${BOT_USERNAME}"
echo ""
# Wait for Synapse to be ready
echo "Checking Synapse health..."
for i in $(seq 1 30); do
if curl -fsSo /dev/null "${SYNAPSE_URL}/health" 2>/dev/null; then
echo "Synapse is healthy."
break
fi
if [ "$i" -eq 30 ]; then
echo "ERROR: Synapse is not responding at ${SYNAPSE_URL}/health after 30 attempts."
echo "Make sure Synapse is running:"
echo " docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml up -d"
exit 1
fi
echo " Waiting for Synapse... (attempt ${i}/30)"
sleep 2
done
echo ""
# Step 1: Register admin account (if not exists)
echo "Step 1: Registering admin account '${ADMIN_USERNAME}'..."
ADMIN_REGISTER_RESPONSE=$(curl -sS -X POST "${SYNAPSE_URL}/_synapse/admin/v1/register" \
-H "Content-Type: application/json" \
-d "{}" 2>/dev/null || true)
NONCE=$(echo "${ADMIN_REGISTER_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('nonce',''))" 2>/dev/null || true)
if [ -n "${NONCE}" ]; then
# Generate HMAC for admin registration using the nonce
# For dev, we use register_new_matrix_user via docker exec instead
echo " Using docker exec to register admin via Synapse CLI..."
docker exec mosaic-synapse register_new_matrix_user \
-u "${ADMIN_USERNAME}" \
-p "${ADMIN_PASSWORD}" \
-a \
-c /data/homeserver.yaml \
http://localhost:8008 2>/dev/null && echo " Admin account created." || echo " Admin account already exists (or registration failed — continuing)."
else
echo " Attempting registration via docker exec..."
docker exec mosaic-synapse register_new_matrix_user \
-u "${ADMIN_USERNAME}" \
-p "${ADMIN_PASSWORD}" \
-a \
-c /data/homeserver.yaml \
http://localhost:8008 2>/dev/null && echo " Admin account created." || echo " Admin account already exists (or registration failed — continuing)."
fi
echo ""
# Step 2: Get admin access token
echo "Step 2: Obtaining admin access token..."
ADMIN_LOGIN_RESPONSE=$(curl -sS -X POST "${SYNAPSE_URL}/_matrix/client/v3/login" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg user "$ADMIN_USERNAME" \
--arg pw "$ADMIN_PASSWORD" \
'{type: "m.login.password", identifier: {type: "m.id.user", user: $user}, password: $pw}')" \
2>/dev/null)
ADMIN_TOKEN=$(echo "${ADMIN_LOGIN_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true)
if [ -z "${ADMIN_TOKEN}" ]; then
echo "ERROR: Could not obtain admin access token."
echo "Response: ${ADMIN_LOGIN_RESPONSE}"
echo ""
echo "Try registering the admin account manually:"
echo " docker exec -it mosaic-synapse register_new_matrix_user -u ${ADMIN_USERNAME} -p ${ADMIN_PASSWORD} -a -c /data/homeserver.yaml http://localhost:8008"
exit 1
fi
echo " Admin token obtained."
echo ""
# Step 3: Register bot account via admin API (idempotent)
echo "Step 3: Registering bot account '${BOT_USERNAME}'..."
BOT_REGISTER_RESPONSE=$(curl -sS -X PUT "${SYNAPSE_URL}/_synapse/admin/v2/users/@${BOT_USERNAME}:localhost" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg pw "$BOT_PASSWORD" \
--arg dn "$BOT_DISPLAY_NAME" \
'{password: $pw, displayname: $dn, admin: false, deactivated: false}')" \
2>/dev/null)
BOT_EXISTS=$(echo "${BOT_REGISTER_RESPONSE}" | python3 -c "import sys,json; d=json.load(sys.stdin); print('yes' if d.get('name') else 'no')" 2>/dev/null || echo "no")
if [ "${BOT_EXISTS}" = "yes" ]; then
echo " Bot account '@${BOT_USERNAME}:localhost' is ready."
else
echo " WARNING: Bot registration response unexpected: ${BOT_REGISTER_RESPONSE}"
echo " Continuing anyway — bot may already exist."
fi
echo ""
# Step 4: Get bot access token
echo "Step 4: Obtaining bot access token..."
BOT_LOGIN_RESPONSE=$(curl -sS -X POST "${SYNAPSE_URL}/_matrix/client/v3/login" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg user "$BOT_USERNAME" \
--arg pw "$BOT_PASSWORD" \
'{type: "m.login.password", identifier: {type: "m.id.user", user: $user}, password: $pw}')" \
2>/dev/null)
BOT_TOKEN=$(echo "${BOT_LOGIN_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true)
if [ -z "${BOT_TOKEN}" ]; then
echo "ERROR: Could not obtain bot access token."
echo "Response: ${BOT_LOGIN_RESPONSE}"
exit 1
fi
echo " Bot token obtained."
echo ""
# Step 5: Output configuration
echo "============================================"
echo " Matrix Bot Setup Complete"
echo "============================================"
echo ""
echo "Add the following to your .env file:"
echo ""
echo " # Matrix Bridge Configuration"
echo " MATRIX_HOMESERVER_URL=http://localhost:8008"
echo " MATRIX_ACCESS_TOKEN=${BOT_TOKEN}"
echo " MATRIX_BOT_USER_ID=@${BOT_USERNAME}:localhost"
echo " MATRIX_SERVER_NAME=localhost"
echo ""
echo "Or, if running the API inside Docker (same compose network):"
echo ""
echo " MATRIX_HOMESERVER_URL=http://synapse:8008"
echo " MATRIX_ACCESS_TOKEN=${BOT_TOKEN}"
echo " MATRIX_BOT_USER_ID=@${BOT_USERNAME}:localhost"
echo " MATRIX_SERVER_NAME=localhost"
echo ""
echo "Element Web is available at: http://localhost:8501"
echo " Login with any registered user to test messaging."
echo ""
echo "Admin account: ${ADMIN_USERNAME} / ${ADMIN_PASSWORD}"
echo "Bot account: ${BOT_USERNAME} / ${BOT_PASSWORD}"
echo "============================================"

View File

@@ -0,0 +1,131 @@
# ==============================================
# Synapse Homeserver Configuration — Development Only
# ==============================================
#
# This config is for LOCAL DEVELOPMENT with the Mosaic Stack docker-compose overlay.
# Do NOT use this in production. See docker-compose.sample.matrix.yml for production.
#
# Server name is set to 'localhost' — this is permanent and cannot be changed
# after the database has been initialized.
#
# ==============================================
server_name: "localhost"
pid_file: /data/homeserver.pid
public_baseurl: "http://localhost:8008/"
# ======================
# Network Listeners
# ======================
listeners:
# Client API (used by Element Web, Mosaic bridge, etc.)
- port: 8008
tls: false
type: http
x_forwarded: true
bind_addresses: ["0.0.0.0"]
resources:
- names: [client, federation]
compress: false
# ======================
# Database (Shared PostgreSQL)
# ======================
database:
name: psycopg2
txn_limit: 10000
args:
user: "synapse"
password: "synapse_dev_password"
database: "synapse"
host: "postgres"
port: 5432
cp_min: 5
cp_max: 10
# ======================
# Media Storage
# ======================
media_store_path: /data/media_store
max_upload_size: 50M
url_preview_enabled: true
url_preview_ip_range_blacklist:
- "127.0.0.0/8"
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
- "100.64.0.0/10"
- "192.0.0.0/24"
- "169.254.0.0/16"
- "198.18.0.0/15"
- "::1/128"
- "fe80::/10"
- "fc00::/7"
- "2001:db8::/32"
- "ff00::/8"
- "fec0::/10"
# ======================
# Registration (Dev Only)
# ======================
enable_registration: true
enable_registration_without_verification: true
# ======================
# Signing Keys
# ======================
# Auto-generated on first startup and persisted in the signing_key volume
signing_key_path: "/data/keys/localhost.signing.key"
# Suppress warning about trusted key servers in dev
suppress_key_server_warning: true
trusted_key_servers: []
# ======================
# Room Configuration
# ======================
enable_room_list_search: true
allow_public_rooms_over_federation: false
# ======================
# Rate Limiting (Relaxed for Dev)
# ======================
rc_message:
per_second: 100
burst_count: 200
rc_registration:
per_second: 10
burst_count: 50
rc_login:
address:
per_second: 10
burst_count: 50
account:
per_second: 10
burst_count: 50
# ======================
# Logging
# ======================
log_config: "/data/localhost.log.config"
# Inline log config — write to stdout for docker logs
# Synapse falls back to a basic console logger if the log_config file is missing,
# so we leave log_config pointing to a non-existent file intentionally.
# Override: mount a custom log config file at /data/localhost.log.config
# ======================
# Miscellaneous
# ======================
report_stats: false
macaroon_secret_key: "dev-macaroon-secret-change-in-production"
form_secret: "dev-form-secret-change-in-production"
# Enable presence for dev
use_presence: true
# Retention policy (optional, keep messages for 180 days in dev)
retention:
enabled: false

537
docs/MATRIX-BRIDGE.md Normal file
View File

@@ -0,0 +1,537 @@
# Matrix Bridge
Integration between Mosaic Stack and the Matrix protocol, enabling workspace management
and job orchestration through Matrix chat rooms.
## Overview
The Matrix bridge connects Mosaic Stack to any Matrix homeserver (Synapse, Dendrite, Conduit,
etc.), allowing users to interact with the platform through Matrix clients like Element,
FluffyChat, or any other Matrix-compatible application.
Key capabilities:
- **Command interface** -- Issue bot commands (`@mosaic fix #42`) from any mapped Matrix room
- **Workspace-room mapping** -- Each Mosaic workspace can be linked to a Matrix room
- **Threaded job updates** -- Job progress is posted to MSC3440 threads, keeping rooms clean
- **Streaming AI responses** -- LLM output streams to Matrix via rate-limited message edits
- **Multi-provider broadcasting** -- HeraldService broadcasts status updates to all active
chat providers (Discord and Matrix can run simultaneously)
### Architecture
```
Matrix Client (Element, FluffyChat, etc.)
|
v
Synapse Homeserver
|
matrix-bot-sdk
|
v
+------------------+ +---------------------+
| MatrixService |<----->| CommandParserService |
| (IChatProvider) | | (shared, all platforms)
+------------------+ +---------------------+
| |
| v
| +--------------------+
| | MatrixRoomService | workspace <-> room mapping
| +--------------------+
| |
v v
+------------------+ +----------------+
| StitcherService | | PrismaService |
| (job dispatch) | | (database) |
+------------------+ +----------------+
|
v
+------------------+
| HeraldService | broadcasts to CHAT_PROVIDERS[]
+------------------+
|
v
+---------------------------+
| MatrixStreamingService | streaming AI responses
| (m.replace edits, typing) |
+---------------------------+
```
## Quick Start
### 1. Start the dev environment
The Matrix dev environment uses a Docker Compose overlay that adds Synapse and Element Web
alongside the existing Mosaic Stack services.
```bash
# Using Makefile (recommended)
make matrix-up
# Or manually
docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml up -d
```
This starts:
| Service | URL | Purpose |
| ----------- | --------------------- | ----------------------- |
| Synapse | http://localhost:8008 | Matrix homeserver |
| Element Web | http://localhost:8501 | Web-based Matrix client |
Both services share the existing Mosaic PostgreSQL instance. A `synapse-db-init` container
runs once to create the `synapse` database and user, then exits.
### 2. Create the bot account
After Synapse is healthy, run the setup script to create admin and bot accounts:
```bash
make matrix-setup-bot
# Or directly
docker/matrix/scripts/setup-bot.sh
```
The script:
1. Registers an admin account (`admin` / `admin-dev-password`)
2. Obtains an admin access token
3. Creates the bot account (`mosaic-bot` / `mosaic-bot-dev-password`)
4. Retrieves the bot access token
5. Prints the environment variables to add to `.env`
Custom credentials can be passed:
```bash
docker/matrix/scripts/setup-bot.sh \
--username custom-bot \
--password custom-pass \
--admin-username myadmin \
--admin-password myadmin-pass
```
### 3. Configure environment variables
Copy the output from the setup script into your `.env` file:
```bash
# Matrix Bridge Configuration
MATRIX_HOMESERVER_URL=http://localhost:8008
MATRIX_ACCESS_TOKEN=<token from setup-bot.sh>
MATRIX_BOT_USER_ID=@mosaic-bot:localhost
MATRIX_CONTROL_ROOM_ID=!roomid:localhost
MATRIX_WORKSPACE_ID=<your-workspace-uuid>
```
If running the API inside the Docker Compose network, use the internal hostname:
```bash
MATRIX_HOMESERVER_URL=http://synapse:8008
```
### 4. Restart the API
```bash
pnpm dev:api
# or
make docker-restart
```
The BridgeModule will detect `MATRIX_ACCESS_TOKEN` and enable the Matrix bridge
automatically.
### 5. Test in Element Web
1. Open http://localhost:8501
2. Register or log in with any account
3. Create a room and invite `@mosaic-bot:localhost`
4. Send `@mosaic help` or `!mosaic help`
## Configuration
### Environment Variables
| Variable | Description | Example |
| ------------------------ | --------------------------------------------- | ----------------------------- |
| `MATRIX_HOMESERVER_URL` | Matrix server URL | `http://localhost:8008` |
| `MATRIX_ACCESS_TOKEN` | Bot access token (from setup script or login) | `syt_bW9z...` |
| `MATRIX_BOT_USER_ID` | Bot's full Matrix user ID | `@mosaic-bot:localhost` |
| `MATRIX_CONTROL_ROOM_ID` | Default room for status broadcasts | `!abcdef:localhost` |
| `MATRIX_WORKSPACE_ID` | Default workspace UUID for the control room | `550e8400-e29b-41d4-a716-...` |
All variables are read from `process.env` at service construction time. The bridge activates
only when `MATRIX_ACCESS_TOKEN` is set.
### Dev Environment Variables (docker-compose.matrix.yml)
These configure the local Synapse and Element Web instances:
| Variable | Default | Purpose |
| --------------------------- | ---------------------- | ------------------------- |
| `SYNAPSE_POSTGRES_DB` | `synapse` | Synapse database name |
| `SYNAPSE_POSTGRES_USER` | `synapse` | Synapse database user |
| `SYNAPSE_POSTGRES_PASSWORD` | `synapse_dev_password` | Synapse database password |
| `SYNAPSE_CLIENT_PORT` | `8008` | Synapse client API port |
| `SYNAPSE_FEDERATION_PORT` | `8448` | Synapse federation port |
| `ELEMENT_PORT` | `8501` | Element Web port |
## Architecture
### Service Responsibilities
**MatrixService** (`apps/api/src/bridge/matrix/matrix.service.ts`)
The primary Matrix integration. Implements the `IChatProvider` interface.
- Connects to the homeserver using `matrix-bot-sdk`
- Listens for `room.message` events in all joined rooms
- Resolves workspace context via MatrixRoomService (or falls back to control room)
- Normalizes `!mosaic` prefix to `@mosaic` for the shared CommandParserService
- Dispatches parsed commands to StitcherService for job execution
- Creates MSC3440 threads for job updates
- Auto-joins rooms when invited (`AutojoinRoomsMixin`)
**MatrixRoomService** (`apps/api/src/bridge/matrix/matrix-room.service.ts`)
Manages the mapping between Mosaic workspaces and Matrix rooms.
- **Provision**: Creates a private Matrix room named `Mosaic: {workspace_name}` with alias
`#mosaic-{slug}:{server}`
- **Link/Unlink**: Maps existing rooms to workspaces via `workspace.matrixRoomId`
- **Lookup**: Forward lookup (workspace -> room) and reverse lookup (room -> workspace)
- Room mappings are stored in the `workspace` table's `matrixRoomId` column
**MatrixStreamingService** (`apps/api/src/bridge/matrix/matrix-streaming.service.ts`)
Streams AI responses to Matrix rooms using incremental message edits.
- Sends an initial "Thinking..." placeholder message
- Activates typing indicator during generation
- Buffers incoming tokens and edits the message every 500ms (rate-limited)
- On completion, sends a final clean edit with optional token usage stats
- On error, edits the message with an error notice
- Supports threaded responses via MSC3440
**CommandParserService** (`apps/api/src/bridge/parser/command-parser.service.ts`)
Shared, platform-agnostic command parser used by both Discord and Matrix bridges.
- Parses `@mosaic <action> [args]` commands
- Supports issue references in multiple formats: `#42`, `owner/repo#42`, full URL
- Returns typed `ParsedCommand` objects or structured parse errors with help text
**BridgeModule** (`apps/api/src/bridge/bridge.module.ts`)
Conditional module loader. Inspects environment variables at startup:
- If `DISCORD_BOT_TOKEN` is set, Discord bridge is added to `CHAT_PROVIDERS`
- If `MATRIX_ACCESS_TOKEN` is set, Matrix bridge is added to `CHAT_PROVIDERS`
- Both can run simultaneously; neither is a dependency of the other
**HeraldService** (`apps/api/src/herald/herald.service.ts`)
Status broadcaster that sends job event updates to all active chat providers.
- Iterates over the `CHAT_PROVIDERS` injection token
- Sends thread messages for job lifecycle events (created, started, completed, failed, etc.)
- Uses PDA-friendly language (no "OVERDUE", "URGENT", etc.)
- If one provider fails, others still receive the broadcast
### Data Flow
```
1. User sends "@mosaic fix #42" in a Matrix room
2. MatrixService receives room.message event
3. MatrixRoomService resolves room -> workspace mapping
4. CommandParserService parses the command (action=FIX, issue=#42)
5. MatrixService creates a thread (MSC3440) for job updates
6. StitcherService dispatches the job with workspace context
7. HeraldService receives job events and broadcasts to all CHAT_PROVIDERS
8. Thread messages appear in the Matrix room thread
```
### Thread Model (MSC3440)
Matrix threads are implemented per [MSC3440](https://github.com/matrix-org/matrix-spec-proposals/pull/3440):
- A **thread root** is created by sending a regular `m.room.message` event
- Subsequent messages reference the root via `m.relates_to` with `rel_type: "m.thread"`
- The `is_falling_back: true` flag and `m.in_reply_to` provide compatibility with clients
that do not support threads
- Thread root event IDs are stored in job metadata for HeraldService to post updates
## Commands
All commands accept either `@mosaic` or `!mosaic` prefix. The `!mosaic` form is
normalized to `@mosaic` internally before parsing.
| Command | Description | Example |
| -------------------------- | ----------------------------- | ---------------------------- |
| `@mosaic fix <issue>` | Start a job for an issue | `@mosaic fix #42` |
| `@mosaic status <job-id>` | Check job status | `@mosaic status job-abc123` |
| `@mosaic cancel <job-id>` | Cancel a running job | `@mosaic cancel job-abc123` |
| `@mosaic retry <job-id>` | Retry a failed job | `@mosaic retry job-abc123` |
| `@mosaic verbose <job-id>` | Stream full logs to thread | `@mosaic verbose job-abc123` |
| `@mosaic quiet` | Reduce notification verbosity | `@mosaic quiet` |
| `@mosaic help` | Show available commands | `@mosaic help` |
### Issue Reference Formats
The `fix` command accepts issue references in multiple formats:
```
@mosaic fix #42 # Current repo
@mosaic fix owner/repo#42 # Cross-repo
@mosaic fix https://git.example.com/o/r/issues/42 # Full URL
```
### Noise Management
Job updates are scoped to threads to keep main rooms clean:
- **Main room**: Low verbosity -- milestone completions only
- **Job threads**: Medium verbosity -- step completions and status changes
- **DMs**: Configurable per user (planned)
## Workspace-Room Mapping
Each Mosaic workspace can be associated with one Matrix room. The mapping is stored in the
`workspace` table's `matrixRoomId` column.
### Automatic Provisioning
When a workspace needs a Matrix room, MatrixRoomService provisions one:
```
Room name: "Mosaic: My Workspace"
Room alias: #mosaic-my-workspace:localhost
Visibility: private
```
The room ID is then stored in `workspace.matrixRoomId`.
### Manual Linking
Existing rooms can be linked to workspaces:
```typescript
await matrixRoomService.linkWorkspaceToRoom(workspaceId, "!roomid:localhost");
```
And unlinked:
```typescript
await matrixRoomService.unlinkWorkspace(workspaceId);
```
### Message Routing
When a message arrives in a room:
1. MatrixRoomService performs a reverse lookup: room ID -> workspace ID
2. If no mapping is found, the service checks if the room is the configured control room
(`MATRIX_CONTROL_ROOM_ID`) and uses `MATRIX_WORKSPACE_ID` as fallback
3. If still unmapped, the message is ignored
This ensures commands only execute within a valid workspace context.
## Streaming Responses
MatrixStreamingService enables real-time AI response streaming in Matrix rooms.
### How It Works
1. An initial placeholder message ("Thinking...") is sent to the room
2. The bot's typing indicator is activated
3. Tokens from the LLM arrive via an `AsyncIterable<string>`
4. Tokens are buffered and the message is edited via `m.replace` events
5. Edits are rate-limited to a maximum of once every **500ms** to avoid flooding the
homeserver
6. When streaming completes, a final clean edit is sent and the typing indicator clears
7. On error, the message is edited to include an error notice
### Message Edit Format (m.replace)
```json
{
"m.new_content": {
"msgtype": "m.text",
"body": "Updated response text"
},
"m.relates_to": {
"rel_type": "m.replace",
"event_id": "$original_event_id"
},
"msgtype": "m.text",
"body": "* Updated response text"
}
```
The top-level `body` prefixed with `*` serves as a fallback for clients that do not
support message edits.
### Thread Support
Streaming responses can target a specific thread by passing `threadId` in the options.
The initial message and all edits will include the `m.thread` relation.
## Development
### Running Tests
```bash
# All bridge tests
pnpm test -- --filter @mosaic/api -- matrix
# Individual service tests
pnpm test -- --filter @mosaic/api -- matrix.service
pnpm test -- --filter @mosaic/api -- matrix-room.service
pnpm test -- --filter @mosaic/api -- matrix-streaming.service
pnpm test -- --filter @mosaic/api -- command-parser
pnpm test -- --filter @mosaic/api -- bridge.module
```
### Adding a New Command
1. Add the action to the `CommandAction` enum in
`apps/api/src/bridge/parser/command.interface.ts`
2. Add parsing logic in `CommandParserService.parseActionArguments()`
(`apps/api/src/bridge/parser/command-parser.service.ts`)
3. Add the handler case in `MatrixService.handleParsedCommand()`
(`apps/api/src/bridge/matrix/matrix.service.ts`)
4. Implement the handler method (e.g., `handleNewCommand()`)
5. Update the help text in `MatrixService.handleHelpCommand()`
6. Add tests for the new command in both the parser and service spec files
### Extending the Bridge
The `IChatProvider` interface (`apps/api/src/bridge/interfaces/chat-provider.interface.ts`)
defines the contract all chat bridges implement:
```typescript
interface IChatProvider {
connect(): Promise<void>;
disconnect(): Promise<void>;
isConnected(): boolean;
sendMessage(channelId: string, content: string): Promise<void>;
createThread(options: ThreadCreateOptions): Promise<string>;
sendThreadMessage(options: ThreadMessageOptions): Promise<void>;
parseCommand(message: ChatMessage): ChatCommand | null;
editMessage?(channelId: string, messageId: string, content: string): Promise<void>;
}
```
To add a new chat platform:
1. Create a new service implementing `IChatProvider`
2. Register it in `BridgeModule` with a conditional check on its environment variable
3. Add it to the `CHAT_PROVIDERS` factory
4. HeraldService will automatically broadcast to it with no further changes
### File Layout
```
apps/api/src/
bridge/
bridge.module.ts # Conditional module loader
bridge.constants.ts # CHAT_PROVIDERS injection token
interfaces/
chat-provider.interface.ts # IChatProvider contract
index.ts
parser/
command-parser.service.ts # Shared command parser
command-parser.spec.ts
command.interface.ts # Command types and enums
matrix/
matrix.service.ts # Core Matrix integration
matrix.service.spec.ts
matrix-room.service.ts # Workspace-room mapping
matrix-room.service.spec.ts
matrix-streaming.service.ts # Streaming AI responses
matrix-streaming.service.spec.ts
discord/
discord.service.ts # Discord integration (parallel)
herald/
herald.module.ts
herald.service.ts # Status broadcasting
herald.service.spec.ts
docker/
docker-compose.matrix.yml # Dev overlay (Synapse + Element)
docker-compose.sample.matrix.yml # Production sample (Swarm)
matrix/
synapse/
homeserver.yaml # Dev Synapse config
element/
config.json # Dev Element Web config
scripts/
setup-bot.sh # Bot account setup
```
## Deployment
### Production Considerations
The dev environment uses relaxed settings that are not suitable for production.
Review and address the following before deploying:
**Synapse Configuration**
- Set a proper `server_name` (this is permanent and cannot change after first run)
- Disable open registration (`enable_registration: false`)
- Replace dev secrets (`macaroon_secret_key`, `form_secret`) with strong random values
- Configure proper rate limiting (dev config allows 100 msg/sec)
- Set up TLS termination (via reverse proxy or Synapse directly)
- Consider a dedicated PostgreSQL instance rather than the shared Mosaic database
**Bot Security**
- Generate a strong bot password (not the dev default)
- Store the access token securely (use a secrets manager or encrypted `.env`)
- The bot auto-joins rooms when invited -- consider restricting this in production
by removing `AutojoinRoomsMixin` and implementing allow-list logic
**Environment Variables**
- `MATRIX_WORKSPACE_ID` should be a valid workspace UUID from your database; all
commands from the control room execute within this workspace context
**Network**
- If Synapse runs on a separate host, ensure `MATRIX_HOMESERVER_URL` points to the
correct endpoint
- For federation, configure DNS SRV records and `.well-known` delegation
### Sample Production Stack
A production-ready Docker Swarm compose file is provided at
`docker/docker-compose.sample.matrix.yml`. It includes:
- Synapse with Traefik labels for automatic TLS
- Element Web with its own domain
- Dedicated PostgreSQL instance for Synapse
- Optional coturn (TURN/STUN) for voice/video
Deploy via Portainer or Docker Swarm CLI:
```bash
docker stack deploy -c docker/docker-compose.sample.matrix.yml matrix
```
After deploying, follow the post-deploy steps in the compose file comments to create
accounts and configure the Mosaic Stack connection.
### Makefile Targets
| Target | Description |
| ----------------------- | ----------------------------------------- |
| `make matrix-up` | Start Synapse + Element Web (dev overlay) |
| `make matrix-down` | Stop Matrix services |
| `make matrix-logs` | Follow Synapse and Element logs |
| `make matrix-setup-bot` | Run bot account setup script |

View File

@@ -7,16 +7,16 @@
**Branch:** feature/m10-telemetry
**Milestone:** M10-Telemetry (0.0.10)
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used |
| ------- | ----------- | -------------------------------------------------------- | ----- | ----------- | -------------------- | ------------------- | ------------------------------- | ----- | ---------- | ------------ | -------- | ---- |
| TEL-001 | done | Install @mosaicstack/telemetry-client in API + NestJS module | #369 | api | feature/m10-telemetry | | TEL-004,TEL-006,TEL-007 | w-1 | 2026-02-15T10:00Z | 2026-02-15T10:37Z | 20K | 25K |
| TEL-002 | done | Install mosaicstack-telemetry in Coordinator | #370 | coordinator | feature/m10-telemetry | | TEL-005,TEL-006 | w-2 | 2026-02-15T10:00Z | 2026-02-15T10:34Z | 15K | 20K |
| TEL-003 | done | Add telemetry config to docker-compose and .env | #374 | devops | feature/m10-telemetry | | | w-3 | 2026-02-15T10:38Z | 2026-02-15T10:40Z | 8K | 10K |
| TEL-004 | done | Track LLM task completions via Mosaic Telemetry | #371 | api | feature/m10-telemetry | TEL-001 | TEL-007 | w-4 | 2026-02-15T10:38Z | 2026-02-15T10:44Z | 25K | 30K |
| TEL-005 | done | Track orchestrator agent task completions | #372 | coordinator | feature/m10-telemetry | TEL-002 | | w-5 | 2026-02-15T10:45Z | 2026-02-15T10:52Z | 20K | 25K |
| TEL-006 | done | Prediction integration for cost estimation | #373 | api | feature/m10-telemetry | TEL-001,TEL-002 | TEL-007 | w-6 | 2026-02-15T10:45Z | 2026-02-15T10:51Z | 20K | 25K |
| TEL-007 | done | Frontend: Token usage and cost dashboard | #375 | web | feature/m10-telemetry | TEL-004,TEL-006 | TEL-008 | w-7 | 2026-02-15T10:53Z | 2026-02-15T11:03Z | 30K | 115K |
| TEL-008 | done | Documentation: Telemetry integration guide | #376 | docs | feature/m10-telemetry | TEL-007 | | w-8 | 2026-02-15T10:53Z | 2026-02-15T10:58Z | 15K | 75K |
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used |
| ------- | ------ | ------------------------------------------------------------ | ----- | ----------- | --------------------- | --------------- | ----------------------- | ----- | ----------------- | ----------------- | -------- | ---- |
| TEL-001 | done | Install @mosaicstack/telemetry-client in API + NestJS module | #369 | api | feature/m10-telemetry | | TEL-004,TEL-006,TEL-007 | w-1 | 2026-02-15T10:00Z | 2026-02-15T10:37Z | 20K | 25K |
| TEL-002 | done | Install mosaicstack-telemetry in Coordinator | #370 | coordinator | feature/m10-telemetry | | TEL-005,TEL-006 | w-2 | 2026-02-15T10:00Z | 2026-02-15T10:34Z | 15K | 20K |
| TEL-003 | done | Add telemetry config to docker-compose and .env | #374 | devops | feature/m10-telemetry | | | w-3 | 2026-02-15T10:38Z | 2026-02-15T10:40Z | 8K | 10K |
| TEL-004 | done | Track LLM task completions via Mosaic Telemetry | #371 | api | feature/m10-telemetry | TEL-001 | TEL-007 | w-4 | 2026-02-15T10:38Z | 2026-02-15T10:44Z | 25K | 30K |
| TEL-005 | done | Track orchestrator agent task completions | #372 | coordinator | feature/m10-telemetry | TEL-002 | | w-5 | 2026-02-15T10:45Z | 2026-02-15T10:52Z | 20K | 25K |
| TEL-006 | done | Prediction integration for cost estimation | #373 | api | feature/m10-telemetry | TEL-001,TEL-002 | TEL-007 | w-6 | 2026-02-15T10:45Z | 2026-02-15T10:51Z | 20K | 25K |
| TEL-007 | done | Frontend: Token usage and cost dashboard | #375 | web | feature/m10-telemetry | TEL-004,TEL-006 | TEL-008 | w-7 | 2026-02-15T10:53Z | 2026-02-15T11:03Z | 30K | 115K |
| TEL-008 | done | Documentation: Telemetry integration guide | #376 | docs | feature/m10-telemetry | TEL-007 | | w-8 | 2026-02-15T10:53Z | 2026-02-15T10:58Z | 15K | 75K |
---
@@ -94,3 +94,61 @@
| CI-FIX6-002 | done | Move spec file removal to builder stage (layer-aware); add tar CVEs to .trivyignore | | orchestrator | fix/ci-366 | | CI-FIX6-004 | w-15 | 2026-02-12T21:00Z | 2026-02-12T21:15Z | 3K | 5K |
| CI-FIX6-003 | done | Add React.ChangeEvent types to ~10 web files with untyped event handlers (49 lint + 19 TS) | | web | fix/ci-366 | CI-FIX6-001 | CI-FIX6-004 | w-16 | 2026-02-12T21:02Z | 2026-02-12T21:08Z | 12K | 8K |
| CI-FIX6-004 | done | Verification: pnpm lint && pnpm typecheck && pnpm test on web; Dockerfile find validation | | all | fix/ci-366 | CI-FIX6-002,CI-FIX6-003 | | orch | 2026-02-12T21:08Z | 2026-02-12T21:10Z | 5K | 2K |
---
## M12-MatrixBridge (0.0.12) — Matrix/Element Bridge Integration
**Orchestrator:** Claude Code
**Started:** 2026-02-15
**Branch:** feature/m12-matrix-bridge
**Epic:** #377
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used |
| ------ | ------ | --------------------------------------------------------------- | ----- | ------ | ------------------------- | ----------------------------------------- | ----------------------------------------- | -------- | ----------------- | ----------------- | -------- | ---- |
| MB-001 | done | Install matrix-bot-sdk and create MatrixService skeleton | #378 | api | feature/m12-matrix-bridge | | MB-003,MB-004,MB-005,MB-006,MB-007,MB-008 | worker-1 | 2026-02-15T10:00Z | 2026-02-15T10:20Z | 20K | 15K |
| MB-002 | done | Add Synapse + Element Web to docker-compose for dev | #384 | docker | feature/m12-matrix-bridge | | | worker-2 | 2026-02-15T10:00Z | 2026-02-15T10:15Z | 15K | 5K |
| MB-003 | done | Register MatrixService in BridgeModule with conditional loading | #379 | api | feature/m12-matrix-bridge | MB-001 | MB-008 | worker-3 | 2026-02-15T10:25Z | 2026-02-15T10:35Z | 12K | 20K |
| MB-004 | done | Workspace-to-Matrix-Room mapping and provisioning | #380 | api | feature/m12-matrix-bridge | MB-001 | MB-005,MB-006,MB-008 | worker-4 | 2026-02-15T10:25Z | 2026-02-15T10:35Z | 20K | 39K |
| MB-005 | done | Matrix command handling — receive and dispatch commands | #381 | api | feature/m12-matrix-bridge | MB-001,MB-004 | MB-007,MB-008 | worker-5 | 2026-02-15T10:40Z | 2026-02-15T14:27Z | 20K | 27K |
| MB-006 | done | Herald Service: Add Matrix output adapter | #382 | api | feature/m12-matrix-bridge | MB-001,MB-004 | MB-008 | worker-6 | 2026-02-15T10:40Z | 2026-02-15T14:25Z | 18K | 109K |
| MB-007 | done | Streaming AI responses via Matrix message edits | #383 | api | feature/m12-matrix-bridge | MB-001,MB-005 | MB-008 | worker-7 | 2026-02-15T14:30Z | 2026-02-15T14:35Z | 20K | 28K |
| MB-008 | done | Matrix bridge E2E integration tests | #385 | api | feature/m12-matrix-bridge | MB-001,MB-003,MB-004,MB-005,MB-006,MB-007 | MB-009 | worker-8 | 2026-02-15T14:38Z | 2026-02-15T14:40Z | 25K | 35K |
| MB-009 | done | Documentation: Matrix bridge setup and architecture | #386 | docs | feature/m12-matrix-bridge | MB-008 | | worker-9 | 2026-02-15T14:38Z | 2026-02-15T14:39Z | 10K | 12K |
| MB-010 | done | Sample Matrix swarm deployment compose file | #387 | docker | feature/m12-matrix-bridge | | | | | 2026-02-15 | 0 | 0 |
| MB-011 | done | Remediate code review and security review findings | #377 | api | feature/m12-matrix-bridge | MB-001..MB-010 | | worker-10 | 2026-02-15T15:00Z | 2026-02-15T15:10Z | 30K | 145K |
### Phase Summary
| Phase | Tasks | Description |
| ---------------------- | -------------- | --------------------------------------- |
| 1 - Foundation | MB-001, MB-002 | SDK install, dev infrastructure |
| 2 - Module Integration | MB-003, MB-004 | Module registration, DB mapping |
| 3 - Core Features | MB-005, MB-006 | Command handling, Herald adapter |
| 4 - Advanced Features | MB-007 | Streaming responses |
| 5 - Testing | MB-008 | E2E integration tests |
| 6 - Documentation | MB-009 | Setup guide, architecture docs |
| 7 - Review Remediation | MB-011 | Fix all code review + security findings |
### Review Findings Resolved (MB-011)
| # | Severity | Finding | Fix |
| --- | -------- | ---------------------------------------------------------- | -------------------------------------------------------------- |
| 1 | CRITICAL | sendThreadMessage hardcodes controlRoomId — wrong room | Added channelId to ThreadMessageOptions, use options.channelId |
| 2 | CRITICAL | void handleRoomMessage swallows ALL errors | Added .catch() with logger.error |
| 3 | CRITICAL | handleFixCommand: dead thread on dispatch failure | Wrapped dispatch in try-catch with user-visible error |
| 4 | CRITICAL | provisionRoom: orphaned Matrix room on DB failure | try-catch around DB update with logged warning |
| 5 | HIGH | Missing MATRIX_BOT_USER_ID validation (infinite loop risk) | Added throw in connect() if missing |
| 6 | HIGH | streamResponse finally block can throw/mask errors | Wrapped setTypingIndicator in nested try-catch |
| 7 | HIGH | streamResponse catch editMessage can throw/mask | Wrapped editMessage in nested try-catch |
| 8 | HIGH | HeraldService error log missing provider identity | Added provider.constructor.name to error log |
| 9 | HIGH | MatrixRoomService uses unsafe type assertion | Replaced with public getClient() method |
| 10 | HIGH | BridgeModule factory incomplete env var validation | Added warnings for missing vars when token set |
| 11 | MEDIUM | setup-bot.sh JSON injection via shell variables | Replaced with jq -n for safe JSON construction |
### Notes
- #387 already completed in commit 6e20fc5
- #377 is the EPIC issue — closed after all reviews remediated
- 187 tests passing after remediation (41 matrix, 20 streaming, 10 room, 26 integration, 27 herald, 25 discord, + others)

View File

@@ -12,13 +12,13 @@ The aggregated data powers a **prediction system** that provides pre-task estima
Mosaic Stack uses **two separate telemetry systems** that serve different purposes:
| Aspect | OpenTelemetry (OTEL) | Mosaic Telemetry |
|--------|---------------------|------------------|
| **Purpose** | Distributed request tracing and observability | AI task completion metrics and predictions |
| **What it tracks** | HTTP requests, spans, latency, errors | Token counts, costs, outcomes, quality gates |
| **Data destination** | OTEL Collector (Jaeger, Grafana, etc.) | Mosaic Telemetry API (PostgreSQL-backed) |
| **Module location (API)** | `apps/api/src/telemetry/` | `apps/api/src/mosaic-telemetry/` |
| **Module location (Coordinator)** | `apps/coordinator/src/telemetry.py` | `apps/coordinator/src/mosaic_telemetry.py` |
| Aspect | OpenTelemetry (OTEL) | Mosaic Telemetry |
| --------------------------------- | --------------------------------------------- | -------------------------------------------- |
| **Purpose** | Distributed request tracing and observability | AI task completion metrics and predictions |
| **What it tracks** | HTTP requests, spans, latency, errors | Token counts, costs, outcomes, quality gates |
| **Data destination** | OTEL Collector (Jaeger, Grafana, etc.) | Mosaic Telemetry API (PostgreSQL-backed) |
| **Module location (API)** | `apps/api/src/telemetry/` | `apps/api/src/mosaic-telemetry/` |
| **Module location (Coordinator)** | `apps/coordinator/src/telemetry.py` | `apps/coordinator/src/mosaic_telemetry.py` |
Both systems can run simultaneously. They are completely independent.
@@ -78,13 +78,13 @@ Both systems can run simultaneously. They are completely independent.
All configuration is done through environment variables prefixed with `MOSAIC_TELEMETRY_`:
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `MOSAIC_TELEMETRY_ENABLED` | boolean | `true` | Master switch. Set to `false` to completely disable telemetry (no HTTP calls). |
| `MOSAIC_TELEMETRY_SERVER_URL` | string | (none) | URL of the telemetry API server. For Docker Compose: `http://telemetry-api:8000`. For production: `https://tel-api.mosaicstack.dev`. |
| `MOSAIC_TELEMETRY_API_KEY` | string | (none) | API key for authenticating with the telemetry server. Generate with: `openssl rand -hex 32` (64-char hex string). |
| `MOSAIC_TELEMETRY_INSTANCE_ID` | string | (none) | Unique UUID identifying this Mosaic Stack instance. Generate with: `uuidgen` or `python -c "import uuid; print(uuid.uuid4())"`. |
| `MOSAIC_TELEMETRY_DRY_RUN` | boolean | `false` | When `true`, events are logged to console instead of being sent via HTTP. Useful for development. |
| Variable | Type | Default | Description |
| ------------------------------ | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `MOSAIC_TELEMETRY_ENABLED` | boolean | `true` | Master switch. Set to `false` to completely disable telemetry (no HTTP calls). |
| `MOSAIC_TELEMETRY_SERVER_URL` | string | (none) | URL of the telemetry API server. For Docker Compose: `http://telemetry-api:8000`. For production: `https://tel-api.mosaicstack.dev`. |
| `MOSAIC_TELEMETRY_API_KEY` | string | (none) | API key for authenticating with the telemetry server. Generate with: `openssl rand -hex 32` (64-char hex string). |
| `MOSAIC_TELEMETRY_INSTANCE_ID` | string | (none) | Unique UUID identifying this Mosaic Stack instance. Generate with: `uuidgen` or `python -c "import uuid; print(uuid.uuid4())"`. |
| `MOSAIC_TELEMETRY_DRY_RUN` | boolean | `false` | When `true`, events are logged to console instead of being sent via HTTP. Useful for development. |
### Enabling Telemetry
@@ -166,34 +166,34 @@ telemetry-api:
Every tracked event conforms to the `TaskCompletionEvent` interface. This is the core data structure submitted to the telemetry API:
| Field | Type | Description |
|-------|------|-------------|
| `instance_id` | `string` | UUID of the Mosaic Stack instance that generated the event |
| `event_id` | `string` | Unique UUID for this event (auto-generated by the SDK) |
| `schema_version` | `string` | Schema version for forward compatibility (auto-set by the SDK) |
| `timestamp` | `string` | ISO 8601 timestamp of event creation (auto-set by the SDK) |
| `task_duration_ms` | `number` | How long the task took in milliseconds |
| `task_type` | `TaskType` | Type of task performed (see enum below) |
| `complexity` | `Complexity` | Complexity level of the task |
| `harness` | `Harness` | The coding harness or tool used |
| `model` | `string` | AI model name (e.g., `"claude-sonnet-4-5"`) |
| `provider` | `Provider` | AI model provider |
| `estimated_input_tokens` | `number` | Pre-task estimated input tokens (from predictions) |
| `estimated_output_tokens` | `number` | Pre-task estimated output tokens (from predictions) |
| `actual_input_tokens` | `number` | Actual input tokens consumed |
| `actual_output_tokens` | `number` | Actual output tokens generated |
| `estimated_cost_usd_micros` | `number` | Pre-task estimated cost in microdollars (USD * 1,000,000) |
| `actual_cost_usd_micros` | `number` | Actual cost in microdollars |
| `quality_gate_passed` | `boolean` | Whether all quality gates passed |
| `quality_gates_run` | `QualityGate[]` | List of quality gates that were executed |
| `quality_gates_failed` | `QualityGate[]` | List of quality gates that failed |
| `context_compactions` | `number` | Number of context window compactions during the task |
| `context_rotations` | `number` | Number of context window rotations during the task |
| `context_utilization_final` | `number` | Final context window utilization (0.0 to 1.0) |
| `outcome` | `Outcome` | Task outcome |
| `retry_count` | `number` | Number of retries before completion |
| `language` | `string?` | Primary programming language (optional) |
| `repo_size_category` | `RepoSizeCategory?` | Repository size category (optional) |
| Field | Type | Description |
| --------------------------- | ------------------- | -------------------------------------------------------------- |
| `instance_id` | `string` | UUID of the Mosaic Stack instance that generated the event |
| `event_id` | `string` | Unique UUID for this event (auto-generated by the SDK) |
| `schema_version` | `string` | Schema version for forward compatibility (auto-set by the SDK) |
| `timestamp` | `string` | ISO 8601 timestamp of event creation (auto-set by the SDK) |
| `task_duration_ms` | `number` | How long the task took in milliseconds |
| `task_type` | `TaskType` | Type of task performed (see enum below) |
| `complexity` | `Complexity` | Complexity level of the task |
| `harness` | `Harness` | The coding harness or tool used |
| `model` | `string` | AI model name (e.g., `"claude-sonnet-4-5"`) |
| `provider` | `Provider` | AI model provider |
| `estimated_input_tokens` | `number` | Pre-task estimated input tokens (from predictions) |
| `estimated_output_tokens` | `number` | Pre-task estimated output tokens (from predictions) |
| `actual_input_tokens` | `number` | Actual input tokens consumed |
| `actual_output_tokens` | `number` | Actual output tokens generated |
| `estimated_cost_usd_micros` | `number` | Pre-task estimated cost in microdollars (USD \* 1,000,000) |
| `actual_cost_usd_micros` | `number` | Actual cost in microdollars |
| `quality_gate_passed` | `boolean` | Whether all quality gates passed |
| `quality_gates_run` | `QualityGate[]` | List of quality gates that were executed |
| `quality_gates_failed` | `QualityGate[]` | List of quality gates that failed |
| `context_compactions` | `number` | Number of context window compactions during the task |
| `context_rotations` | `number` | Number of context window rotations during the task |
| `context_utilization_final` | `number` | Final context window utilization (0.0 to 1.0) |
| `outcome` | `Outcome` | Task outcome |
| `retry_count` | `number` | Number of retries before completion |
| `language` | `string?` | Primary programming language (optional) |
| `repo_size_category` | `RepoSizeCategory?` | Repository size category (optional) |
### Enum Values
@@ -223,11 +223,13 @@ Every tracked event conforms to the `TaskCompletionEvent` interface. This is the
The NestJS API tracks every LLM service call (chat, streaming chat, and embeddings) via `LlmTelemetryTrackerService` at `apps/api/src/llm/llm-telemetry-tracker.service.ts`.
Tracked operations:
- **`chat`** -- Synchronous chat completions
- **`chatStream`** -- Streaming chat completions
- **`embed`** -- Embedding generation
For each call, the tracker captures:
- Model name and provider type
- Input and output token counts
- Duration in milliseconds
@@ -242,6 +244,7 @@ The cost table uses longest-prefix matching on model names and covers all major
The FastAPI coordinator tracks agent task completions in `apps/coordinator/src/mosaic_telemetry.py` and `apps/coordinator/src/coordinator.py`.
After each agent task dispatch (success or failure), the coordinator emits a `TaskCompletionEvent` capturing:
- Task duration from start to finish
- Agent model, provider, and harness (resolved from the `assigned_agent` field)
- Task outcome (`success`, `failure`, `partial`, `timeout`)
@@ -296,12 +299,12 @@ GET /api/telemetry/estimate?taskType=<taskType>&model=<model>&provider=<provider
**Query Parameters (all required):**
| Parameter | Type | Example | Description |
|-----------|------|---------|-------------|
| `taskType` | `TaskType` | `implementation` | Task type to estimate |
| `model` | `string` | `claude-sonnet-4-5` | Model name |
| `provider` | `Provider` | `anthropic` | Provider name |
| `complexity` | `Complexity` | `medium` | Complexity level |
| Parameter | Type | Example | Description |
| ------------ | ------------ | ------------------- | --------------------- |
| `taskType` | `TaskType` | `implementation` | Task type to estimate |
| `model` | `string` | `claude-sonnet-4-5` | Model name |
| `provider` | `Provider` | `anthropic` | Provider name |
| `complexity` | `Complexity` | `medium` | Complexity level |
**Example Request:**
@@ -363,12 +366,12 @@ If no prediction data is available, the response returns `{ "data": null }`.
The prediction system reports a confidence level based on sample size and data freshness:
| Confidence | Meaning |
|------------|---------|
| `high` | Substantial sample size, recent data, all dimensions matched |
| `medium` | Moderate sample, some dimension fallback |
| `low` | Small sample or significant fallback from requested dimensions |
| `none` | No data available for this combination |
| Confidence | Meaning |
| ---------- | -------------------------------------------------------------- |
| `high` | Substantial sample size, recent data, all dimensions matched |
| `medium` | Moderate sample, some dimension fallback |
| `low` | Small sample or significant fallback from requested dimensions |
| `none` | No data available for this combination |
### Fallback Behavior
@@ -410,11 +413,7 @@ pnpm add @mosaicstack/telemetry-client
```typescript
// Client
import {
TelemetryClient,
EventBuilder,
resolveConfig,
} from "@mosaicstack/telemetry-client";
import { TelemetryClient, EventBuilder, resolveConfig } from "@mosaicstack/telemetry-client";
// Types
import type {
@@ -442,34 +441,34 @@ import {
**TelemetryClient API:**
| Method | Description |
|--------|-------------|
| `constructor(config: TelemetryConfig)` | Create a new client with the given configuration |
| `start(): void` | Start background batch submission (idempotent) |
| `stop(): Promise<void>` | Stop background submission, flush remaining events |
| `track(event: TaskCompletionEvent): void` | Queue an event for batch submission (never throws) |
| Method | Description |
| ------------------------------------------------------------------- | ------------------------------------------------------------ |
| `constructor(config: TelemetryConfig)` | Create a new client with the given configuration |
| `start(): void` | Start background batch submission (idempotent) |
| `stop(): Promise<void>` | Stop background submission, flush remaining events |
| `track(event: TaskCompletionEvent): void` | Queue an event for batch submission (never throws) |
| `getPrediction(query: PredictionQuery): PredictionResponse \| null` | Get a cached prediction (returns null if not cached/expired) |
| `refreshPredictions(queries: PredictionQuery[]): Promise<void>` | Force-refresh predictions from the server |
| `eventBuilder: EventBuilder` | Get the EventBuilder for constructing events |
| `queueSize: number` | Number of events currently queued |
| `isRunning: boolean` | Whether the client is currently running |
| `refreshPredictions(queries: PredictionQuery[]): Promise<void>` | Force-refresh predictions from the server |
| `eventBuilder: EventBuilder` | Get the EventBuilder for constructing events |
| `queueSize: number` | Number of events currently queued |
| `isRunning: boolean` | Whether the client is currently running |
**TelemetryConfig Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `serverUrl` | `string` | (required) | Base URL of the telemetry server |
| `apiKey` | `string` | (required) | 64-char hex API key |
| `instanceId` | `string` | (required) | UUID for this instance |
| `enabled` | `boolean` | `true` | Enable/disable telemetry |
| `submitIntervalMs` | `number` | `300_000` (5 min) | Interval between batch submissions |
| `maxQueueSize` | `number` | `1000` | Maximum queued events |
| `batchSize` | `number` | `100` | Maximum events per batch |
| `requestTimeoutMs` | `number` | `10_000` (10 sec) | HTTP request timeout |
| `predictionCacheTtlMs` | `number` | `21_600_000` (6 hr) | Prediction cache TTL |
| `dryRun` | `boolean` | `false` | Log events instead of sending |
| `maxRetries` | `number` | `3` | Retries per submission |
| `onError` | `(error: Error) => void` | noop | Error callback |
| Option | Type | Default | Description |
| ---------------------- | ------------------------ | ------------------- | ---------------------------------- |
| `serverUrl` | `string` | (required) | Base URL of the telemetry server |
| `apiKey` | `string` | (required) | 64-char hex API key |
| `instanceId` | `string` | (required) | UUID for this instance |
| `enabled` | `boolean` | `true` | Enable/disable telemetry |
| `submitIntervalMs` | `number` | `300_000` (5 min) | Interval between batch submissions |
| `maxQueueSize` | `number` | `1000` | Maximum queued events |
| `batchSize` | `number` | `100` | Maximum events per batch |
| `requestTimeoutMs` | `number` | `10_000` (10 sec) | HTTP request timeout |
| `predictionCacheTtlMs` | `number` | `21_600_000` (6 hr) | Prediction cache TTL |
| `dryRun` | `boolean` | `false` | Log events instead of sending |
| `maxRetries` | `number` | `3` | Retries per submission |
| `onError` | `(error: Error) => void` | noop | Error callback |
**EventBuilder Usage:**
@@ -671,7 +670,7 @@ export class MyLlmService {
durationMs: Date.now() - start,
inputTokens: 150,
outputTokens: 300,
callingContext: "brain", // Used for task type inference
callingContext: "brain", // Used for task type inference
success: true,
});
}

View File

@@ -57,8 +57,10 @@
"pnpm": {
"overrides": {
"@isaacs/brace-expansion": ">=5.0.1",
"form-data": ">=2.5.4",
"lodash": ">=4.17.23",
"lodash-es": ">=4.17.23",
"qs": ">=6.14.1",
"undici": ">=6.23.0"
}
}

726
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff