Merge pull request 'feat: M12-MatrixBridge — Matrix/Element chat bridge integration' (#408) from feature/m12-matrix-bridge into develop
All checks were successful
All checks were successful
Reviewed-on: #408
This commit was merged in pull request #408.
This commit is contained in:
38
.env.example
38
.env.example
@@ -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
|
||||
# ======================
|
||||
|
||||
21
Makefile
21
Makefile
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspaces" ADD COLUMN "matrix_room_id" TEXT;
|
||||
@@ -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)
|
||||
|
||||
15
apps/api/src/bridge/bridge.constants.ts
Normal file
15
apps/api/src/bridge/bridge.constants.ts
Normal 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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -187,6 +187,7 @@ describe("DiscordService", () => {
|
||||
await service.connect();
|
||||
await service.sendThreadMessage({
|
||||
threadId: "thread-123",
|
||||
channelId: "test-channel-id",
|
||||
content: "Step completed",
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
4
apps/api/src/bridge/matrix/index.ts
Normal file
4
apps/api/src/bridge/matrix/index.ts
Normal 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";
|
||||
1065
apps/api/src/bridge/matrix/matrix-bridge.integration.spec.ts
Normal file
1065
apps/api/src/bridge/matrix/matrix-bridge.integration.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
212
apps/api/src/bridge/matrix/matrix-room.service.spec.ts
Normal file
212
apps/api/src/bridge/matrix/matrix-room.service.spec.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
151
apps/api/src/bridge/matrix/matrix-room.service.ts
Normal file
151
apps/api/src/bridge/matrix/matrix-room.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
408
apps/api/src/bridge/matrix/matrix-streaming.service.spec.ts
Normal file
408
apps/api/src/bridge/matrix/matrix-streaming.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
248
apps/api/src/bridge/matrix/matrix-streaming.service.ts
Normal file
248
apps/api/src/bridge/matrix/matrix-streaming.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
979
apps/api/src/bridge/matrix/matrix.service.spec.ts
Normal file
979
apps/api/src/bridge/matrix/matrix.service.spec.ts
Normal 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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
649
apps/api/src/bridge/matrix/matrix.service.ts
Normal file
649
apps/api/src/bridge/matrix/matrix.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
129
docker/docker-compose.matrix.yml
Normal file
129
docker/docker-compose.matrix.yml
Normal 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
|
||||
30
docker/matrix/element/config.json
Normal file
30
docker/matrix/element/config.json
Normal 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
|
||||
}
|
||||
203
docker/matrix/scripts/setup-bot.sh
Executable file
203
docker/matrix/scripts/setup-bot.sh
Executable 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 "============================================"
|
||||
131
docker/matrix/synapse/homeserver.yaml
Normal file
131
docker/matrix/synapse/homeserver.yaml
Normal 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
537
docs/MATRIX-BRIDGE.md
Normal 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 |
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
726
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user