Compare commits
57 Commits
v0.0.8
...
6c3ede5c86
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c3ede5c86 | |||
| f3e90df2a0 | |||
| 721e6bbc52 | |||
| 27848bf42e | |||
| 061edcaa78 | |||
| cbb729f377 | |||
| cfb491e127 | |||
| 20808b9b84 | |||
| fd61a36b01 | |||
| c0a7bae977 | |||
| 68e056ac91 | |||
| 77ba13b41b | |||
| 307bb427d6 | |||
| b89503fa8c | |||
| 254da35300 | |||
| 99926cdba2 | |||
| 25f880416a | |||
| 1138148543 | |||
| 4b70b603b3 | |||
| 2e7711fe65 | |||
| 417a57fa00 | |||
| 714fee52b9 | |||
| 133668f5b2 | |||
| 3b81bc9f3d | |||
| cbfd6fb996 | |||
| 3f8553ce07 | |||
| bf668e18f1 | |||
| 1f2b8125c6 | |||
| 93645295d5 | |||
| 7a52652be6 | |||
| 791c8f505e | |||
| 12653477d6 | |||
| dedfa0d9ac | |||
| c1d3dfd77e | |||
| f0476cae92 | |||
| b6effdcd6b | |||
| 39ef2ff123 | |||
| a989b5e549 | |||
| ff27e944a1 | |||
| 0821393c1d | |||
| 24f5c0699a | |||
| 96409c40bf | |||
| 8628f4f93a | |||
| b649b5c987 | |||
| b4d03a8b49 | |||
| 85aeebbde2 | |||
| a4bb563779 | |||
| 7f6464bbda | |||
| f0741e045f | |||
| 5a1991924c | |||
| bd5d14d07f | |||
| d5a1791dc5 | |||
| bd81c12071 | |||
| 4da255bf04 | |||
| 82c10a7b33 | |||
| d31070177c | |||
| 3792576566 |
29
.env.example
29
.env.example
@@ -62,9 +62,15 @@ OTEL_SERVICE_NAME=mosaic-gateway
|
||||
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
|
||||
# OLLAMA_MODELS=llama3.2,codellama,mistral
|
||||
|
||||
# OpenAI — required for embedding and log-summarization features
|
||||
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
|
||||
# OPENAI_API_KEY=sk-...
|
||||
|
||||
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
|
||||
# ZAI_API_KEY=...
|
||||
|
||||
# Custom providers — JSON array of provider configs
|
||||
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
||||
# MOSAIC_CUSTOM_PROVIDERS=
|
||||
@@ -123,7 +129,26 @@ OTEL_SERVICE_NAME=mosaic-gateway
|
||||
# TELEGRAM_GATEWAY_URL=http://localhost:4000
|
||||
|
||||
|
||||
# ─── Authentik SSO (optional — set AUTHENTIK_CLIENT_ID to enable) ────────────
|
||||
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
|
||||
|
||||
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
|
||||
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
||||
# AUTHENTIK_CLIENT_ID=
|
||||
# AUTHENTIK_CLIENT_SECRET=
|
||||
|
||||
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
|
||||
# WORKOS_ISSUER=https://your-company.authkit.app
|
||||
# WORKOS_CLIENT_ID=client_...
|
||||
# WORKOS_CLIENT_SECRET=sk_live_...
|
||||
|
||||
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
|
||||
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
|
||||
# Legacy alternative if you prefer to compose the issuer from separate vars:
|
||||
# KEYCLOAK_URL=https://auth.example.com
|
||||
# KEYCLOAK_REALM=master
|
||||
# KEYCLOAK_CLIENT_ID=mosaic
|
||||
# KEYCLOAK_CLIENT_SECRET=
|
||||
|
||||
# Feature flags — set to true alongside provider credentials to show SSO buttons in the UI
|
||||
# NEXT_PUBLIC_WORKOS_ENABLED=true
|
||||
# NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
||||
|
||||
@@ -5,9 +5,10 @@ variables:
|
||||
when:
|
||||
- event: [push, pull_request, manual]
|
||||
|
||||
# Turbo remote cache is at turbo.mosaicstack.dev (ducktors/turborepo-remote-cache).
|
||||
# TURBO_TOKEN is a Woodpecker secret injected via from_secret into the environment.
|
||||
# Turbo picks up TURBO_API, TURBO_TOKEN, and TURBO_TEAM automatically.
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||
# This avoids from_secret which is blocked on pull_request events.
|
||||
# If the env vars aren't set, turbo falls back to local cache only.
|
||||
|
||||
steps:
|
||||
install:
|
||||
@@ -18,11 +19,6 @@ steps:
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
environment:
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm typecheck
|
||||
@@ -32,11 +28,6 @@ steps:
|
||||
# lint, format, and test are independent — run in parallel after typecheck
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm lint
|
||||
@@ -53,11 +44,6 @@ steps:
|
||||
|
||||
test:
|
||||
image: *node_image
|
||||
environment:
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm test
|
||||
@@ -66,11 +52,6 @@ steps:
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
environment:
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
|
||||
25
AGENTS.md
25
AGENTS.md
@@ -53,3 +53,28 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||
- NodeNext module resolution in all tsconfigs
|
||||
- Scratchpads are mandatory for non-trivial tasks
|
||||
|
||||
## docs/TASKS.md — Schema (CANONICAL)
|
||||
|
||||
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
||||
|
||||
| Value | When to use | Budget |
|
||||
| -------- | ----------------------------------------------------------- | -------------------------- |
|
||||
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
||||
| `glm-5` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
||||
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
||||
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
||||
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
||||
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
|
||||
|
||||
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
|
||||
|
||||
**Full schema:**
|
||||
|
||||
```
|
||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||
```
|
||||
|
||||
- `status`: `not-started` | `in-progress` | `done` | `failed` | `blocked` | `needs-qa`
|
||||
- `agent`: model value from table above (set before spawning)
|
||||
- `estimate`: token budget e.g. `8K`, `25K`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationsController } from '../conversations/conversations.controller.js';
|
||||
import { MissionsController } from '../missions/missions.controller.js';
|
||||
@@ -18,6 +18,7 @@ function createBrain() {
|
||||
},
|
||||
projects: {
|
||||
findAll: vi.fn(),
|
||||
findAllForUser: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
@@ -25,12 +26,21 @@ function createBrain() {
|
||||
},
|
||||
missions: {
|
||||
findAll: vi.fn(),
|
||||
findAllByUser: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByIdAndUser: vi.fn(),
|
||||
findByProject: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
missionTasks: {
|
||||
findByMissionAndUser: vi.fn(),
|
||||
findByIdAndUser: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
tasks: {
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
@@ -58,21 +68,22 @@ describe('Resource ownership checks', () => {
|
||||
it('forbids access to another user project', async () => {
|
||||
const brain = createBrain();
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new ProjectsController(brain as never);
|
||||
const teamsService = { canAccessProject: vi.fn().mockResolvedValue(false) };
|
||||
const controller = new ProjectsController(brain as never, teamsService as never);
|
||||
|
||||
await expect(controller.findOne('project-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids access to a mission owned by another project owner', async () => {
|
||||
it('forbids access to a mission owned by another user', async () => {
|
||||
const brain = createBrain();
|
||||
brain.missions.findById.mockResolvedValue({ id: 'mission-1', projectId: 'project-1' });
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
// findByIdAndUser returns undefined when the mission doesn't belong to the user
|
||||
brain.missions.findByIdAndUser.mockResolvedValue(undefined);
|
||||
const controller = new MissionsController(brain as never);
|
||||
|
||||
await expect(controller.findOne('mission-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
import type { Auth } from '@mosaic/auth';
|
||||
import type { Db } from '@mosaic/db';
|
||||
import { eq, users as usersTable } from '@mosaic/db';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
interface UserWithRole {
|
||||
id: string;
|
||||
@@ -18,7 +21,10 @@ interface UserWithRole {
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
constructor(@Inject(AUTH) private readonly auth: Auth) {}
|
||||
constructor(
|
||||
@Inject(AUTH) private readonly auth: Auth,
|
||||
@Inject(DB) private readonly db: Db,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
@@ -32,7 +38,21 @@ export class AdminGuard implements CanActivate {
|
||||
|
||||
const user = result.user as UserWithRole;
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
// Ensure the role field is populated. better-auth should include additionalFields
|
||||
// in the session, but as a fallback, fetch the role from the database if needed.
|
||||
let userRole = user.role;
|
||||
if (!userRole) {
|
||||
const [dbUser] = await this.db
|
||||
.select({ role: usersTable.role })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, user.id))
|
||||
.limit(1);
|
||||
userRole = dbUser?.role ?? 'member';
|
||||
// Update the session user object with the fetched role
|
||||
(user as UserWithRole).role = userRole;
|
||||
}
|
||||
|
||||
if (userRole !== 'admin') {
|
||||
throw new ForbiddenException('Admin access required');
|
||||
}
|
||||
|
||||
|
||||
143
apps/gateway/src/agent/__tests__/provider.service.test.ts
Normal file
143
apps/gateway/src/agent/__tests__/provider.service.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { beforeEach, afterEach, describe, expect, it } from 'vitest';
|
||||
import { ProviderService } from '../provider.service.js';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'ZAI_API_KEY',
|
||||
'OLLAMA_BASE_URL',
|
||||
'OLLAMA_HOST',
|
||||
'OLLAMA_MODELS',
|
||||
'MOSAIC_CUSTOM_PROVIDERS',
|
||||
] as const;
|
||||
|
||||
type EnvKey = (typeof ENV_KEYS)[number];
|
||||
|
||||
describe('ProviderService', () => {
|
||||
const savedEnv = new Map<EnvKey, string | undefined>();
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
savedEnv.set(key, process.env[key]);
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = savedEnv.get(key);
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('skips API-key providers when env vars are missing (no models become available)', () => {
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
// Pi's built-in registry may include model definitions for all providers, but
|
||||
// without API keys none of them should be available (usable).
|
||||
const availableModels = service.listAvailableModels();
|
||||
const availableProviderIds = new Set(availableModels.map((m) => m.provider));
|
||||
|
||||
expect(availableProviderIds).not.toContain('anthropic');
|
||||
expect(availableProviderIds).not.toContain('openai');
|
||||
expect(availableProviderIds).not.toContain('zai');
|
||||
|
||||
// Providers list may show built-in providers, but they should not be marked available
|
||||
const providers = service.listProviders();
|
||||
for (const p of providers.filter((p) => ['anthropic', 'openai', 'zai'].includes(p.id))) {
|
||||
expect(p.available).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('registers Anthropic provider with correct models when ANTHROPIC_API_KEY is set', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
|
||||
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
const providers = service.listProviders();
|
||||
const anthropic = providers.find((p) => p.id === 'anthropic');
|
||||
expect(anthropic).toBeDefined();
|
||||
expect(anthropic!.available).toBe(true);
|
||||
expect(anthropic!.models.map((m) => m.id)).toEqual([
|
||||
'claude-sonnet-4-6',
|
||||
'claude-opus-4-6',
|
||||
'claude-haiku-4-5',
|
||||
]);
|
||||
// contextWindow override from Pi built-in (200000)
|
||||
for (const m of anthropic!.models) {
|
||||
expect(m.contextWindow).toBe(200000);
|
||||
// maxTokens capped at 8192 per task spec
|
||||
expect(m.maxTokens).toBe(8192);
|
||||
}
|
||||
});
|
||||
|
||||
it('registers OpenAI provider with correct models when OPENAI_API_KEY is set', () => {
|
||||
process.env['OPENAI_API_KEY'] = 'test-openai';
|
||||
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
const providers = service.listProviders();
|
||||
const openai = providers.find((p) => p.id === 'openai');
|
||||
expect(openai).toBeDefined();
|
||||
expect(openai!.available).toBe(true);
|
||||
expect(openai!.models.map((m) => m.id)).toEqual(['gpt-4o', 'gpt-4o-mini', 'o3-mini']);
|
||||
});
|
||||
|
||||
it('registers Z.ai provider with correct models when ZAI_API_KEY is set', () => {
|
||||
process.env['ZAI_API_KEY'] = 'test-zai';
|
||||
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
const providers = service.listProviders();
|
||||
const zai = providers.find((p) => p.id === 'zai');
|
||||
expect(zai).toBeDefined();
|
||||
expect(zai!.available).toBe(true);
|
||||
expect(zai!.models.map((m) => m.id)).toEqual(['glm-4.5', 'glm-4.5-air', 'glm-4.5-flash']);
|
||||
});
|
||||
|
||||
it('registers all three providers when all keys are set', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
|
||||
process.env['OPENAI_API_KEY'] = 'test-openai';
|
||||
process.env['ZAI_API_KEY'] = 'test-zai';
|
||||
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
const providerIds = service.listProviders().map((p) => p.id);
|
||||
expect(providerIds).toContain('anthropic');
|
||||
expect(providerIds).toContain('openai');
|
||||
expect(providerIds).toContain('zai');
|
||||
});
|
||||
|
||||
it('can find registered Anthropic models by provider+id', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
|
||||
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
const sonnet = service.findModel('anthropic', 'claude-sonnet-4-6');
|
||||
expect(sonnet).toBeDefined();
|
||||
expect(sonnet!.provider).toBe('anthropic');
|
||||
expect(sonnet!.id).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('can find registered Z.ai models by provider+id', () => {
|
||||
process.env['ZAI_API_KEY'] = 'test-zai';
|
||||
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
const glm = service.findModel('zai', 'glm-4.5');
|
||||
expect(glm).toBeDefined();
|
||||
expect(glm!.provider).toBe('zai');
|
||||
expect(glm!.id).toBe('glm-4.5');
|
||||
});
|
||||
});
|
||||
97
apps/gateway/src/agent/agent-config.dto.ts
Normal file
97
apps/gateway/src/agent/agent-config.dto.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
const agentStatuses = ['idle', 'active', 'error', 'offline'] as const;
|
||||
|
||||
export class CreateAgentConfigDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
provider!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
model!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(agentStatuses)
|
||||
status?: 'idle' | 'active' | 'error' | 'offline';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50_000)
|
||||
systemPrompt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
allowedTools?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
skills?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isSystem?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class UpdateAgentConfigDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
provider?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
model?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(agentStatuses)
|
||||
status?: 'idle' | 'active' | 'error' | 'offline';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50_000)
|
||||
systemPrompt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
allowedTools?: string[] | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
skills?: string[] | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
config?: Record<string, unknown> | null;
|
||||
}
|
||||
84
apps/gateway/src/agent/agent-configs.controller.ts
Normal file
84
apps/gateway/src/agent/agent-configs.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { CreateAgentConfigDto, UpdateAgentConfigDto } from './agent-config.dto.js';
|
||||
|
||||
@Controller('api/agents')
|
||||
@UseGuards(AuthGuard)
|
||||
export class AgentConfigsController {
|
||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() user: { id: string; role?: string }) {
|
||||
return this.brain.agents.findAccessible(user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
const agent = await this.brain.agents.findById(id);
|
||||
if (!agent) throw new NotFoundException('Agent not found');
|
||||
if (!agent.isSystem && agent.ownerId !== user.id) {
|
||||
throw new ForbiddenException('Agent does not belong to the current user');
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateAgentConfigDto, @CurrentUser() user: { id: string }) {
|
||||
return this.brain.agents.create({
|
||||
...dto,
|
||||
ownerId: user.id,
|
||||
isSystem: false,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAgentConfigDto,
|
||||
@CurrentUser() user: { id: string; role?: string },
|
||||
) {
|
||||
const agent = await this.brain.agents.findById(id);
|
||||
if (!agent) throw new NotFoundException('Agent not found');
|
||||
if (agent.isSystem && user.role !== 'admin') {
|
||||
throw new ForbiddenException('Only admins can update system agents');
|
||||
}
|
||||
if (!agent.isSystem && agent.ownerId !== user.id) {
|
||||
throw new ForbiddenException('Agent does not belong to the current user');
|
||||
}
|
||||
const updated = await this.brain.agents.update(id, dto);
|
||||
if (!updated) throw new NotFoundException('Agent not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id') id: string, @CurrentUser() user: { id: string; role?: string }) {
|
||||
const agent = await this.brain.agents.findById(id);
|
||||
if (!agent) throw new NotFoundException('Agent not found');
|
||||
if (agent.isSystem) {
|
||||
throw new ForbiddenException('Cannot delete system agents');
|
||||
}
|
||||
if (agent.ownerId !== user.id) {
|
||||
throw new ForbiddenException('Agent does not belong to the current user');
|
||||
}
|
||||
const deleted = await this.brain.agents.remove(id);
|
||||
if (!deleted) throw new NotFoundException('Agent not found');
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,17 @@ import { RoutingService } from './routing.service.js';
|
||||
import { SkillLoaderService } from './skill-loader.service.js';
|
||||
import { ProvidersController } from './providers.controller.js';
|
||||
import { SessionsController } from './sessions.controller.js';
|
||||
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||
import { CoordModule } from '../coord/coord.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { SkillsModule } from '../skills/skills.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [CoordModule, McpClientModule, SkillsModule],
|
||||
imports: [CoordModule, McpClientModule, SkillsModule, GCModule],
|
||||
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
||||
controllers: [ProvidersController, SessionsController],
|
||||
controllers: [ProvidersController, SessionsController, AgentConfigsController],
|
||||
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
||||
})
|
||||
export class AgentModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
@@ -24,6 +24,9 @@ import { createGitTools } from './tools/git-tools.js';
|
||||
import { createShellTools } from './tools/shell-tools.js';
|
||||
import { createWebTools } from './tools/web-tools.js';
|
||||
import type { SessionInfoDto } from './session.dto.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
import { PreferencesService } from '../preferences/preferences.service.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
|
||||
export interface AgentSessionOptions {
|
||||
provider?: string;
|
||||
@@ -49,6 +52,14 @@ export interface AgentSessionOptions {
|
||||
allowedTools?: string[];
|
||||
/** Whether the requesting user has admin privileges. Controls default tool access. */
|
||||
isAdmin?: boolean;
|
||||
/**
|
||||
* DB agent config ID. When provided, loads agent config from DB and merges
|
||||
* provider, model, systemPrompt, and allowedTools. Explicit call-site options
|
||||
* take precedence over config values.
|
||||
*/
|
||||
agentConfigId?: string;
|
||||
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface AgentSession {
|
||||
@@ -67,6 +78,8 @@ export interface AgentSession {
|
||||
sandboxDir: string;
|
||||
/** Tool names available in this session, or null when all tools are available. */
|
||||
allowedTools: string[] | null;
|
||||
/** User ID that owns this session, used for preference lookups. */
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -83,6 +96,13 @@ export class AgentService implements OnModuleDestroy {
|
||||
@Inject(CoordService) private readonly coordService: CoordService,
|
||||
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
||||
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
||||
@Optional()
|
||||
@Inject(SystemOverrideService)
|
||||
private readonly systemOverride: SystemOverrideService | null,
|
||||
@Optional()
|
||||
@Inject(PreferencesService)
|
||||
private readonly preferencesService: PreferencesService | null,
|
||||
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -146,16 +166,39 @@ export class AgentService implements OnModuleDestroy {
|
||||
sessionId: string,
|
||||
options?: AgentSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const model = this.resolveModel(options);
|
||||
// Merge DB agent config when agentConfigId is provided
|
||||
let mergedOptions = options;
|
||||
if (options?.agentConfigId) {
|
||||
const agentConfig = await this.brain.agents.findById(options.agentConfigId);
|
||||
if (agentConfig) {
|
||||
mergedOptions = {
|
||||
provider: options.provider ?? agentConfig.provider,
|
||||
modelId: options.modelId ?? agentConfig.model,
|
||||
systemPrompt: options.systemPrompt ?? agentConfig.systemPrompt ?? undefined,
|
||||
allowedTools: options.allowedTools ?? agentConfig.allowedTools ?? undefined,
|
||||
sandboxDir: options.sandboxDir,
|
||||
isAdmin: options.isAdmin,
|
||||
agentConfigId: options.agentConfigId,
|
||||
};
|
||||
this.logger.log(
|
||||
`Merged agent config "${agentConfig.name}" (${agentConfig.id}) into session ${sessionId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const model = this.resolveModel(mergedOptions);
|
||||
const providerName = model?.provider ?? 'default';
|
||||
const modelId = model?.id ?? 'default';
|
||||
|
||||
// Resolve sandbox directory: option > env var > process.cwd()
|
||||
const sandboxDir =
|
||||
options?.sandboxDir ?? process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd();
|
||||
mergedOptions?.sandboxDir ?? process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd();
|
||||
|
||||
// Resolve allowed tool set
|
||||
const allowedTools = this.resolveAllowedTools(options?.isAdmin ?? false, options?.allowedTools);
|
||||
const allowedTools = this.resolveAllowedTools(
|
||||
mergedOptions?.isAdmin ?? false,
|
||||
mergedOptions?.allowedTools,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId}, sandbox=${sandboxDir}, tools=${allowedTools === null ? 'all' : allowedTools.join(',') || 'none'})`,
|
||||
@@ -194,7 +237,8 @@ export class AgentService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
// Build system prompt: platform prompt + skill additions appended
|
||||
const platformPrompt = options?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined;
|
||||
const platformPrompt =
|
||||
mergedOptions?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined;
|
||||
const appendSystemPrompt =
|
||||
promptAdditions.length > 0 ? promptAdditions.join('\n\n') : undefined;
|
||||
|
||||
@@ -255,6 +299,7 @@ export class AgentService implements OnModuleDestroy {
|
||||
skillPromptAdditions: promptAdditions,
|
||||
sandboxDir,
|
||||
allowedTools,
|
||||
userId: mergedOptions?.userId,
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
@@ -338,8 +383,20 @@ export class AgentService implements OnModuleDestroy {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
session.promptCount += 1;
|
||||
|
||||
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||
let effectiveMessage = message;
|
||||
if (this.systemOverride) {
|
||||
const override = await this.systemOverride.get(sessionId);
|
||||
if (override) {
|
||||
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
||||
await this.systemOverride.renew(sessionId);
|
||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await session.piSession.prompt(message);
|
||||
await session.piSession.prompt(effectiveMessage);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Prompt failed for session=${sessionId}, messageLength=${message.length}`,
|
||||
@@ -375,6 +432,14 @@ export class AgentService implements OnModuleDestroy {
|
||||
session.listeners.clear();
|
||||
session.channels.clear();
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
// Run GC cleanup for this session (fire and forget, errors are logged)
|
||||
this.gc.collect(sessionId).catch((err: unknown) => {
|
||||
this.logger.error(
|
||||
`GC collect failed for session ${sessionId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
|
||||
import type { Model, Api } from '@mariozechner/pi-ai';
|
||||
import { getModel, type Model, type Api } from '@mariozechner/pi-ai';
|
||||
import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
|
||||
import type { TestConnectionResultDto } from './provider.dto.js';
|
||||
|
||||
@@ -14,6 +14,9 @@ export class ProviderService implements OnModuleInit {
|
||||
this.registry = new ModelRegistry(authStorage);
|
||||
|
||||
this.registerOllamaProvider();
|
||||
this.registerAnthropicProvider();
|
||||
this.registerOpenAIProvider();
|
||||
this.registerZaiProvider();
|
||||
this.registerCustomProviders();
|
||||
|
||||
const available = this.registry.getAvailable();
|
||||
@@ -140,6 +143,66 @@ export class ProviderService implements OnModuleInit {
|
||||
this.logger.log(`Registered custom provider: ${config.id} (${config.models.length} models)`);
|
||||
}
|
||||
|
||||
private registerAnthropicProvider(): void {
|
||||
const apiKey = process.env['ANTHROPIC_API_KEY'];
|
||||
if (!apiKey) {
|
||||
this.logger.debug('Skipping Anthropic provider registration: ANTHROPIC_API_KEY not set');
|
||||
return;
|
||||
}
|
||||
|
||||
const models = ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5'].map((id) =>
|
||||
this.cloneBuiltInModel('anthropic', id, { maxTokens: 8192 }),
|
||||
);
|
||||
|
||||
this.registry.registerProvider('anthropic', {
|
||||
apiKey,
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
models,
|
||||
});
|
||||
|
||||
this.logger.log('Anthropic provider registered with 3 models');
|
||||
}
|
||||
|
||||
private registerOpenAIProvider(): void {
|
||||
const apiKey = process.env['OPENAI_API_KEY'];
|
||||
if (!apiKey) {
|
||||
this.logger.debug('Skipping OpenAI provider registration: OPENAI_API_KEY not set');
|
||||
return;
|
||||
}
|
||||
|
||||
const models = ['gpt-4o', 'gpt-4o-mini', 'o3-mini'].map((id) =>
|
||||
this.cloneBuiltInModel('openai', id),
|
||||
);
|
||||
|
||||
this.registry.registerProvider('openai', {
|
||||
apiKey,
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
models,
|
||||
});
|
||||
|
||||
this.logger.log('OpenAI provider registered with 3 models');
|
||||
}
|
||||
|
||||
private registerZaiProvider(): void {
|
||||
const apiKey = process.env['ZAI_API_KEY'];
|
||||
if (!apiKey) {
|
||||
this.logger.debug('Skipping Z.ai provider registration: ZAI_API_KEY not set');
|
||||
return;
|
||||
}
|
||||
|
||||
const models = ['glm-4.5', 'glm-4.5-air', 'glm-4.5-flash'].map((id) =>
|
||||
this.cloneBuiltInModel('zai', id),
|
||||
);
|
||||
|
||||
this.registry.registerProvider('zai', {
|
||||
apiKey,
|
||||
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
models,
|
||||
});
|
||||
|
||||
this.logger.log('Z.ai provider registered with 3 models');
|
||||
}
|
||||
|
||||
private registerOllamaProvider(): void {
|
||||
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
|
||||
if (!ollamaUrl) return;
|
||||
@@ -184,6 +247,19 @@ export class ProviderService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
private cloneBuiltInModel(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
overrides: Partial<Model<Api>> = {},
|
||||
): Model<Api> {
|
||||
const model = getModel(provider as never, modelId as never) as Model<Api> | undefined;
|
||||
if (!model) {
|
||||
throw new Error(`Built-in model not found: ${provider}:${modelId}`);
|
||||
}
|
||||
|
||||
return { ...model, ...overrides };
|
||||
}
|
||||
|
||||
private toModelInfo(model: Model<Api>): ModelInfo {
|
||||
return {
|
||||
id: model.id,
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
||||
import { resolve, relative, join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Safety constraint: all file operations are restricted to a base directory.
|
||||
* Paths that escape the sandbox via ../ traversal are rejected.
|
||||
*/
|
||||
function resolveSafe(baseDir: string, inputPath: string): string {
|
||||
const resolved = resolve(baseDir, inputPath);
|
||||
const rel = relative(baseDir, resolved);
|
||||
if (rel.startsWith('..') || resolve(resolved) !== resolve(join(baseDir, rel))) {
|
||||
throw new Error(`Path escape detected: "${inputPath}" resolves outside base directory`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||
|
||||
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
|
||||
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
|
||||
@@ -37,8 +24,14 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
||||
const { path, encoding } = params as { path: string; encoding?: string };
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = resolveSafe(baseDir, path);
|
||||
safePath = guardPath(path, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
@@ -99,8 +92,14 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
||||
};
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = resolveSafe(baseDir, path);
|
||||
safePath = guardPathUnsafe(path, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
@@ -151,8 +150,14 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
||||
const target = path ?? '.';
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = resolveSafe(baseDir, target);
|
||||
safePath = guardPath(target, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
|
||||
@@ -2,29 +2,13 @@ import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { resolve, relative } from 'node:path';
|
||||
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const GIT_TIMEOUT_MS = 15_000;
|
||||
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
||||
|
||||
/**
|
||||
* Clamp a user-supplied cwd to within the sandbox directory.
|
||||
* If the resolved path escapes the sandbox (via ../ or absolute path outside),
|
||||
* falls back to the sandbox directory itself.
|
||||
*/
|
||||
function clampCwd(sandboxDir: string, requestedCwd?: string): string {
|
||||
if (!requestedCwd) return sandboxDir;
|
||||
const resolved = resolve(sandboxDir, requestedCwd);
|
||||
const rel = relative(sandboxDir, resolved);
|
||||
if (rel.startsWith('..') || rel.startsWith('/')) {
|
||||
// Escape attempt — fall back to sandbox root
|
||||
return sandboxDir;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function runGit(
|
||||
args: string[],
|
||||
cwd?: string,
|
||||
@@ -74,7 +58,21 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { cwd } = params as { cwd?: string };
|
||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||
let safeCwd: string;
|
||||
try {
|
||||
safeCwd = guardPath(cwd ?? '.', defaultCwd);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
const result = await runGit(['status', '--short', '--branch'], safeCwd);
|
||||
const text = result.error
|
||||
? `Error: ${result.error}\n${result.stderr}`
|
||||
@@ -107,7 +105,21 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
||||
oneline?: boolean;
|
||||
cwd?: string;
|
||||
};
|
||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||
let safeCwd: string;
|
||||
try {
|
||||
safeCwd = guardPath(cwd ?? '.', defaultCwd);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
const args = ['log', `--max-count=${limit ?? 20}`];
|
||||
if (oneline !== false) args.push('--oneline');
|
||||
const result = await runGit(args, safeCwd);
|
||||
@@ -148,12 +160,43 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
||||
path?: string;
|
||||
cwd?: string;
|
||||
};
|
||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||
let safeCwd: string;
|
||||
try {
|
||||
safeCwd = guardPath(cwd ?? '.', defaultCwd);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
let safePath: string | undefined;
|
||||
if (path !== undefined) {
|
||||
try {
|
||||
safePath = guardPathUnsafe(path, defaultCwd);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
const args = ['diff'];
|
||||
if (staged) args.push('--cached');
|
||||
if (ref) args.push(ref);
|
||||
args.push('--');
|
||||
if (path) args.push(path);
|
||||
if (safePath !== undefined) args.push(safePath);
|
||||
const result = await runGit(args, safeCwd);
|
||||
const text = result.error
|
||||
? `Error: ${result.error}\n${result.stderr}`
|
||||
|
||||
104
apps/gateway/src/agent/tools/path-guard.test.ts
Normal file
104
apps/gateway/src/agent/tools/path-guard.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
|
||||
describe('guardPathUnsafe', () => {
|
||||
const sandbox = '/tmp/test-sandbox';
|
||||
|
||||
it('allows paths inside sandbox', () => {
|
||||
const result = guardPathUnsafe('foo/bar.txt', sandbox);
|
||||
expect(result).toBe(path.resolve(sandbox, 'foo/bar.txt'));
|
||||
});
|
||||
|
||||
it('allows sandbox root itself', () => {
|
||||
const result = guardPathUnsafe('.', sandbox);
|
||||
expect(result).toBe(path.resolve(sandbox));
|
||||
});
|
||||
|
||||
it('rejects path traversal with ../', () => {
|
||||
expect(() => guardPathUnsafe('../escape.txt', sandbox)).toThrow(SandboxEscapeError);
|
||||
});
|
||||
|
||||
it('rejects absolute path outside sandbox', () => {
|
||||
expect(() => guardPathUnsafe('/etc/passwd', sandbox)).toThrow(SandboxEscapeError);
|
||||
});
|
||||
|
||||
it('rejects deeply nested traversal', () => {
|
||||
expect(() => guardPathUnsafe('a/b/../../../../../../etc/passwd', sandbox)).toThrow(
|
||||
SandboxEscapeError,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects path that starts with sandbox name but is sibling', () => {
|
||||
expect(() => guardPathUnsafe('/tmp/test-sandbox-evil/file.txt', sandbox)).toThrow(
|
||||
SandboxEscapeError,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the resolved absolute path for nested paths', () => {
|
||||
const result = guardPathUnsafe('deep/nested/file.ts', sandbox);
|
||||
expect(result).toBe('/tmp/test-sandbox/deep/nested/file.ts');
|
||||
});
|
||||
|
||||
it('SandboxEscapeError includes the user path and sandbox in message', () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
guardPathUnsafe('../escape.txt', sandbox);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SandboxEscapeError);
|
||||
const e = caught as SandboxEscapeError;
|
||||
expect(e.userPath).toBe('../escape.txt');
|
||||
expect(e.sandboxDir).toBe(sandbox);
|
||||
expect(e.message).toContain('Path escape attempt blocked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardPath', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
it('allows an existing path inside a real temp sandbox', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
try {
|
||||
const subdir = path.join(tmpDir, 'subdir');
|
||||
fs.mkdirSync(subdir);
|
||||
const result = guardPath('subdir', tmpDir);
|
||||
expect(result).toBe(subdir);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('allows sandbox root itself', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
try {
|
||||
const result = guardPath('.', tmpDir);
|
||||
// realpathSync resolves the tmpdir symlinks (macOS /var -> /private/var)
|
||||
const realTmp = fs.realpathSync.native(tmpDir);
|
||||
expect(result).toBe(realTmp);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects path traversal with ../ on existing sandbox', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
try {
|
||||
expect(() => guardPath('../escape', tmpDir)).toThrow(SandboxEscapeError);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects absolute path outside sandbox', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
try {
|
||||
expect(() => guardPath('/etc/passwd', tmpDir)).toThrow(SandboxEscapeError);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
58
apps/gateway/src/agent/tools/path-guard.ts
Normal file
58
apps/gateway/src/agent/tools/path-guard.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* Resolves a user-provided path and verifies it is inside the allowed sandbox directory.
|
||||
* Throws SandboxEscapeError if the resolved path is outside the sandbox.
|
||||
*
|
||||
* Uses realpathSync to resolve symlinks in the sandbox root. The user-supplied path
|
||||
* is checked for containment AFTER lexical resolution but BEFORE resolving any symlinks
|
||||
* within the user path — so symlink escape attempts are caught too.
|
||||
*
|
||||
* @param userPath - The path provided by the agent (may be relative or absolute)
|
||||
* @param sandboxDir - The allowed root directory (already validated on session creation)
|
||||
* @returns The resolved absolute path, guaranteed to be within sandboxDir
|
||||
*/
|
||||
export function guardPath(userPath: string, sandboxDir: string): string {
|
||||
const resolved = path.resolve(sandboxDir, userPath);
|
||||
const sandboxResolved = fs.realpathSync.native(sandboxDir);
|
||||
|
||||
// Normalize both paths to resolve any symlinks in the sandbox root itself.
|
||||
// For the user path, we check containment BEFORE resolving symlinks in the path
|
||||
// (so we catch symlink escape attempts too — the resolved path must still be under sandbox)
|
||||
if (!resolved.startsWith(sandboxResolved + path.sep) && resolved !== sandboxResolved) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a path without resolving symlinks in the user-provided portion.
|
||||
* Use for paths that may not exist yet (creates, writes).
|
||||
*
|
||||
* Performs a lexical containment check only using path.resolve.
|
||||
*/
|
||||
export function guardPathUnsafe(userPath: string, sandboxDir: string): string {
|
||||
const resolved = path.resolve(sandboxDir, userPath);
|
||||
const sandboxAbs = path.resolve(sandboxDir);
|
||||
|
||||
if (!resolved.startsWith(sandboxAbs + path.sep) && resolved !== sandboxAbs) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export class SandboxEscapeError extends Error {
|
||||
constructor(
|
||||
public readonly userPath: string,
|
||||
public readonly sandboxDir: string,
|
||||
public readonly resolvedPath: string,
|
||||
) {
|
||||
super(
|
||||
`Path escape attempt blocked: "${userPath}" resolves to "${resolvedPath}" which is outside sandbox "${sandboxDir}"`,
|
||||
);
|
||||
this.name = 'SandboxEscapeError';
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { resolve, relative } from 'node:path';
|
||||
import { guardPath, SandboxEscapeError } from './path-guard.js';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
||||
@@ -68,22 +68,6 @@ function extractBaseCommand(command: string): string {
|
||||
return firstToken.split('/').pop() ?? firstToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a user-supplied cwd to within the sandbox directory.
|
||||
* If the resolved path escapes the sandbox (via ../ or absolute path outside),
|
||||
* falls back to the sandbox directory itself.
|
||||
*/
|
||||
function clampCwd(sandboxDir: string, requestedCwd?: string): string {
|
||||
if (!requestedCwd) return sandboxDir;
|
||||
const resolved = resolve(sandboxDir, requestedCwd);
|
||||
const rel = relative(sandboxDir, resolved);
|
||||
if (rel.startsWith('..') || rel.startsWith('/')) {
|
||||
// Escape attempt — fall back to sandbox root
|
||||
return sandboxDir;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function runCommand(
|
||||
command: string,
|
||||
options: { timeoutMs: number; cwd?: string },
|
||||
@@ -185,7 +169,21 @@ export function createShellTools(sandboxDir?: string): ToolDefinition[] {
|
||||
}
|
||||
|
||||
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 60_000);
|
||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||
let safeCwd: string;
|
||||
try {
|
||||
safeCwd = guardPath(cwd ?? '.', defaultCwd);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await runCommand(command, {
|
||||
timeoutMs,
|
||||
|
||||
@@ -17,6 +17,11 @@ import { SkillsModule } from './skills/skills.module.js';
|
||||
import { PluginModule } from './plugin/plugin.module.js';
|
||||
import { McpModule } from './mcp/mcp.module.js';
|
||||
import { AdminModule } from './admin/admin.module.js';
|
||||
import { CommandsModule } from './commands/commands.module.js';
|
||||
import { PreferencesModule } from './preferences/preferences.module.js';
|
||||
import { GCModule } from './gc/gc.module.js';
|
||||
import { ReloadModule } from './reload/reload.module.js';
|
||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
@Module({
|
||||
@@ -38,6 +43,11 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
PluginModule,
|
||||
McpModule,
|
||||
AdminModule,
|
||||
PreferencesModule,
|
||||
CommandsModule,
|
||||
GCModule,
|
||||
ReloadModule,
|
||||
WorkspaceModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
@@ -28,4 +28,8 @@ export class ChatSocketMessageDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
modelId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import type { Auth } from '@mosaic/auth';
|
||||
import type { SetThinkingPayload, SlashCommandPayload, SystemReloadPayload } from '@mosaic/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||
import { validateSocketSession } from './chat.gateway-auth.js';
|
||||
@@ -37,6 +40,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
constructor(
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(AUTH) private readonly auth: Auth,
|
||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||
) {}
|
||||
|
||||
afterInit(): void {
|
||||
@@ -54,6 +59,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
client.data.user = session.user;
|
||||
client.data.session = session.session;
|
||||
this.logger.log(`Client connected: ${client.id}`);
|
||||
|
||||
// Broadcast command manifest to the newly connected client
|
||||
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket): void {
|
||||
@@ -79,9 +87,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
try {
|
||||
let agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) {
|
||||
const userId = (client.data.user as { id: string } | undefined)?.id;
|
||||
agentSession = await this.agentService.createSession(conversationId, {
|
||||
provider: data.provider,
|
||||
modelId: data.modelId,
|
||||
agentConfigId: data.agentId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -112,6 +123,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
// Track channel connection
|
||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
||||
|
||||
// Send session info so the client knows the model/provider
|
||||
{
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
if (agentSession) {
|
||||
const piSession = agentSession.piSession;
|
||||
client.emit('session:info', {
|
||||
conversationId,
|
||||
provider: agentSession.provider,
|
||||
modelId: agentSession.modelId,
|
||||
thinkingLevel: piSession.thinkingLevel,
|
||||
availableThinkingLevels: piSession.getAvailableThinkingLevels(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send acknowledgment
|
||||
client.emit('message:ack', { conversationId, messageId: uuid() });
|
||||
|
||||
@@ -130,6 +156,58 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('set:thinking')
|
||||
handleSetThinking(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: SetThinkingPayload,
|
||||
): void {
|
||||
const session = this.agentService.getSession(data.conversationId);
|
||||
if (!session) {
|
||||
client.emit('error', {
|
||||
conversationId: data.conversationId,
|
||||
error: 'No active session for this conversation.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validLevels = session.piSession.getAvailableThinkingLevels();
|
||||
if (!validLevels.includes(data.level as never)) {
|
||||
client.emit('error', {
|
||||
conversationId: data.conversationId,
|
||||
error: `Invalid thinking level "${data.level}". Available: ${validLevels.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
session.piSession.setThinkingLevel(data.level as never);
|
||||
this.logger.log(
|
||||
`Thinking level set to "${data.level}" for conversation ${data.conversationId}`,
|
||||
);
|
||||
|
||||
client.emit('session:info', {
|
||||
conversationId: data.conversationId,
|
||||
provider: session.provider,
|
||||
modelId: session.modelId,
|
||||
thinkingLevel: session.piSession.thinkingLevel,
|
||||
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
|
||||
});
|
||||
}
|
||||
|
||||
@SubscribeMessage('command:execute')
|
||||
async handleCommandExecute(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: SlashCommandPayload,
|
||||
): Promise<void> {
|
||||
const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown';
|
||||
const result = await this.commandExecutor.execute(payload, userId);
|
||||
client.emit('command:result', result);
|
||||
}
|
||||
|
||||
broadcastReload(payload: SystemReloadPayload): void {
|
||||
this.server.emit('system:reload', payload);
|
||||
this.logger.log('Broadcasted system:reload to all connected clients');
|
||||
}
|
||||
|
||||
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||
if (!client.connected) {
|
||||
this.logger.warn(
|
||||
@@ -143,9 +221,31 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
client.emit('agent:start', { conversationId });
|
||||
break;
|
||||
|
||||
case 'agent_end':
|
||||
client.emit('agent:end', { conversationId });
|
||||
case 'agent_end': {
|
||||
// Gather usage stats from the Pi session
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
const piSession = agentSession?.piSession;
|
||||
const stats = piSession?.getSessionStats();
|
||||
const contextUsage = piSession?.getContextUsage();
|
||||
|
||||
client.emit('agent:end', {
|
||||
conversationId,
|
||||
usage: stats
|
||||
? {
|
||||
provider: agentSession?.provider ?? 'unknown',
|
||||
modelId: agentSession?.modelId ?? 'unknown',
|
||||
thinkingLevel: piSession?.thinkingLevel ?? 'off',
|
||||
tokens: stats.tokens,
|
||||
cost: stats.cost,
|
||||
context: {
|
||||
percent: contextUsage?.percent ?? null,
|
||||
window: contextUsage?.contextWindow ?? 0,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'message_update': {
|
||||
const assistantEvent = event.assistantMessageEvent;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { ChatGateway } from './chat.gateway.js';
|
||||
import { ChatController } from './chat.controller.js';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => CommandsModule)],
|
||||
controllers: [ChatController],
|
||||
providers: [ChatGateway],
|
||||
exports: [ChatGateway],
|
||||
})
|
||||
export class ChatModule {}
|
||||
|
||||
213
apps/gateway/src/commands/command-executor-p8012.spec.ts
Normal file
213
apps/gateway/src/commands/command-executor-p8012.spec.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import type { SlashCommandPayload } from '@mosaic/types';
|
||||
|
||||
// Minimal mock implementations
|
||||
const mockRegistry = {
|
||||
getManifest: vi.fn(() => ({
|
||||
version: 1,
|
||||
commands: [
|
||||
{ name: 'provider', aliases: [], scope: 'agent', execution: 'hybrid', available: true },
|
||||
{ name: 'mission', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
],
|
||||
skills: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const mockAgentService = {
|
||||
getSession: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
const mockSystemOverride = {
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
renew: vi.fn(),
|
||||
};
|
||||
|
||||
const mockSessionGC = {
|
||||
sweepOrphans: vi.fn(() => ({ orphanedSessions: 0, totalCleaned: [], duration: 0 })),
|
||||
};
|
||||
|
||||
const mockRedis = {
|
||||
set: vi.fn().mockResolvedValue('OK'),
|
||||
get: vi.fn(),
|
||||
del: vi.fn(),
|
||||
};
|
||||
|
||||
function buildService(): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
mockRegistry as never,
|
||||
mockAgentService as never,
|
||||
mockSystemOverride as never,
|
||||
mockSessionGC as never,
|
||||
mockRedis as never,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
describe('CommandExecutorService — P8-012 commands', () => {
|
||||
let service: CommandExecutorService;
|
||||
const userId = 'user-123';
|
||||
const conversationId = 'conv-456';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = buildService();
|
||||
});
|
||||
|
||||
// /provider login — missing provider name
|
||||
it('/provider login with no provider name returns usage error', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Usage: /provider login');
|
||||
expect(result.command).toBe('provider');
|
||||
});
|
||||
|
||||
// /provider login anthropic — success with URL containing poll token
|
||||
it('/provider login <name> returns success with URL and poll token', async () => {
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'provider',
|
||||
args: 'login anthropic',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('provider');
|
||||
expect(result.message).toContain('anthropic');
|
||||
expect(result.message).toContain('http');
|
||||
// data should contain loginUrl and pollToken
|
||||
expect(result.data).toBeDefined();
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(typeof data['loginUrl']).toBe('string');
|
||||
expect(typeof data['pollToken']).toBe('string');
|
||||
expect(data['loginUrl'] as string).toContain('anthropic');
|
||||
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
|
||||
// Verify Valkey was called
|
||||
expect(mockRedis.set).toHaveBeenCalledOnce();
|
||||
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
||||
expect(key).toContain('mosaic:auth:poll:');
|
||||
const stored = JSON.parse(value) as { status: string; provider: string; userId: string };
|
||||
expect(stored.status).toBe('pending');
|
||||
expect(stored.provider).toBe('anthropic');
|
||||
expect(stored.userId).toBe(userId);
|
||||
expect(ttl).toBe(300);
|
||||
});
|
||||
|
||||
// /provider with no args — returns usage
|
||||
it('/provider with no args returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage: /provider');
|
||||
});
|
||||
|
||||
// /provider list
|
||||
it('/provider list returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('provider');
|
||||
});
|
||||
|
||||
// /provider logout with no name — usage error
|
||||
it('/provider logout with no name returns error', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Usage: /provider logout');
|
||||
});
|
||||
|
||||
// /provider unknown subcommand
|
||||
it('/provider unknown subcommand returns error', async () => {
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'provider',
|
||||
args: 'unknown',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Unknown subcommand');
|
||||
});
|
||||
|
||||
// /mission status
|
||||
it('/mission status returns stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('mission');
|
||||
expect(result.message).toContain('Mission status');
|
||||
});
|
||||
|
||||
// /mission with no args
|
||||
it('/mission with no args returns status stub', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Mission status');
|
||||
});
|
||||
|
||||
// /mission set <id>
|
||||
it('/mission set <id> returns confirmation', async () => {
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'mission',
|
||||
args: 'set my-mission-123',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('my-mission-123');
|
||||
});
|
||||
|
||||
// /agent list
|
||||
it('/agent list returns stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('agent');
|
||||
expect(result.message).toContain('agent');
|
||||
});
|
||||
|
||||
// /agent with no args
|
||||
it('/agent with no args returns usage', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage: /agent');
|
||||
});
|
||||
|
||||
// /agent <id> — switch
|
||||
it('/agent <id> returns switch confirmation', async () => {
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'agent',
|
||||
args: 'my-agent-id',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('my-agent-id');
|
||||
});
|
||||
|
||||
// /prdy
|
||||
it('/prdy returns PRD wizard message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('prdy');
|
||||
expect(result.message).toContain('mosaic prdy');
|
||||
});
|
||||
|
||||
// /tools
|
||||
it('/tools returns tools stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('tools');
|
||||
expect(result.message).toContain('tools');
|
||||
});
|
||||
});
|
||||
373
apps/gateway/src/commands/command-executor.service.ts
Normal file
373
apps/gateway/src/commands/command-executor.service.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaic/queue';
|
||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
import { ReloadService } from '../reload/reload.service.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class CommandExecutorService {
|
||||
private readonly logger = new Logger(CommandExecutorService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(CommandRegistryService) private readonly registry: CommandRegistryService,
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReloadService))
|
||||
private readonly reloadService: ReloadService | null,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ChatGateway))
|
||||
private readonly chatGateway: ChatGateway | null,
|
||||
) {}
|
||||
|
||||
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
||||
const { command, args, conversationId } = payload;
|
||||
|
||||
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
||||
if (!def) {
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: false,
|
||||
message: `Unknown command: /${command}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'model':
|
||||
return await this.handleModel(args ?? null, conversationId);
|
||||
case 'thinking':
|
||||
return await this.handleThinking(args ?? null, conversationId);
|
||||
case 'system':
|
||||
return await this.handleSystem(args ?? null, conversationId);
|
||||
case 'new':
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Start a new conversation by selecting New Conversation.',
|
||||
};
|
||||
case 'clear':
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Conversation display cleared.',
|
||||
};
|
||||
case 'compact':
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Context compaction requested.',
|
||||
};
|
||||
case 'retry':
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Retry last message requested.',
|
||||
};
|
||||
case 'gc': {
|
||||
// User-scoped sweep for non-admin; system-wide for admin
|
||||
const result = await this.sessionGC.sweepOrphans(userId);
|
||||
return {
|
||||
command: 'gc',
|
||||
success: true,
|
||||
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
case 'agent':
|
||||
return await this.handleAgent(args ?? null, conversationId);
|
||||
case 'provider':
|
||||
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||
case 'mission':
|
||||
return await this.handleMission(args ?? null, conversationId, userId);
|
||||
case 'prdy':
|
||||
return {
|
||||
command: 'prdy',
|
||||
success: true,
|
||||
message:
|
||||
'PRD wizard: run `mosaic prdy` in your project workspace to create or update a PRD.',
|
||||
conversationId,
|
||||
};
|
||||
case 'tools':
|
||||
return await this.handleTools(conversationId, userId);
|
||||
case 'reload': {
|
||||
if (!this.reloadService) {
|
||||
return {
|
||||
command: 'reload',
|
||||
conversationId,
|
||||
success: false,
|
||||
message: 'ReloadService is not available.',
|
||||
};
|
||||
}
|
||||
const reloadResult = await this.reloadService.reload('command');
|
||||
this.chatGateway?.broadcastReload(reloadResult);
|
||||
return {
|
||||
command: 'reload',
|
||||
success: true,
|
||||
message: reloadResult.message,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: false,
|
||||
message: `Command /${command} is not yet implemented.`,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Command /${command} failed: ${err}`);
|
||||
return { command, conversationId, success: false, message: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async handleModel(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args) {
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Usage: /model <model-name>',
|
||||
};
|
||||
}
|
||||
// Update agent session model if session is active
|
||||
// For now, acknowledge the request — full wiring done in P8-012
|
||||
const session = this.agentService.getSession(conversationId);
|
||||
if (!session) {
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: `Model switch to "${args}" requested. No active session for this conversation.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: `Model switch to "${args}" requested.`,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleThinking(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
const level = args?.toLowerCase();
|
||||
if (!level || !['none', 'low', 'medium', 'high', 'auto'].includes(level)) {
|
||||
return {
|
||||
command: 'thinking',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Usage: /thinking <none|low|medium|high|auto>',
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'thinking',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: `Thinking level set to "${level}".`,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleSystem(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args || args.trim().length === 0) {
|
||||
// Clear the override when called with no args
|
||||
await this.systemOverride.clear(conversationId);
|
||||
return {
|
||||
command: 'system',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Session system prompt override cleared.',
|
||||
};
|
||||
}
|
||||
|
||||
await this.systemOverride.set(conversationId, args.trim());
|
||||
return {
|
||||
command: 'system',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: `Session system prompt override set (expires in 5 minutes of inactivity).`,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleAgent(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args) {
|
||||
return {
|
||||
command: 'agent',
|
||||
success: true,
|
||||
message: 'Usage: /agent <agent-id> to switch, or /agent list to see available agents.',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
if (args === 'list') {
|
||||
return {
|
||||
command: 'agent',
|
||||
success: true,
|
||||
message: 'Agent listing: use the web dashboard for full agent management.',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
// Switch agent — stub for now (full implementation in P8-015)
|
||||
return {
|
||||
command: 'agent',
|
||||
success: true,
|
||||
message: `Agent switch to "${args}" requested. Restart conversation to apply.`,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleProvider(
|
||||
args: string | null,
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args) {
|
||||
return {
|
||||
command: 'provider',
|
||||
success: true,
|
||||
message: 'Usage: /provider list | /provider login <name> | /provider logout <name>',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
const spaceIdx = args.indexOf(' ');
|
||||
const subcommand = spaceIdx >= 0 ? args.slice(0, spaceIdx) : args;
|
||||
const providerName = spaceIdx >= 0 ? args.slice(spaceIdx + 1).trim() : '';
|
||||
|
||||
switch (subcommand) {
|
||||
case 'list':
|
||||
return {
|
||||
command: 'provider',
|
||||
success: true,
|
||||
message: 'Use the web dashboard to manage providers.',
|
||||
conversationId,
|
||||
};
|
||||
|
||||
case 'login': {
|
||||
if (!providerName) {
|
||||
return {
|
||||
command: 'provider',
|
||||
success: false,
|
||||
message: 'Usage: /provider login <provider-name>',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
const pollToken = crypto.randomUUID();
|
||||
const key = `mosaic:auth:poll:${pollToken}`;
|
||||
// Store pending state in Valkey (TTL 5 minutes)
|
||||
await this.redis.set(
|
||||
key,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
// In production this would construct an OAuth URL
|
||||
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
|
||||
return {
|
||||
command: 'provider',
|
||||
success: true,
|
||||
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
|
||||
conversationId,
|
||||
data: { loginUrl, pollToken, provider: providerName },
|
||||
};
|
||||
}
|
||||
|
||||
case 'logout': {
|
||||
if (!providerName) {
|
||||
return {
|
||||
command: 'provider',
|
||||
success: false,
|
||||
message: 'Usage: /provider logout <provider-name>',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'provider',
|
||||
success: true,
|
||||
message: `Logout from ${providerName}: use the web dashboard to revoke provider tokens.`,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
command: 'provider',
|
||||
success: false,
|
||||
message: `Unknown subcommand: ${subcommand}. Use list, login, or logout.`,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMission(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
_userId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args || args === 'status') {
|
||||
// TODO: fetch active mission from DB when MissionsService is available
|
||||
return {
|
||||
command: 'mission',
|
||||
success: true,
|
||||
message: 'Mission status: use the web dashboard for full mission management.',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
if (args.startsWith('set ')) {
|
||||
const missionId = args.slice(4).trim();
|
||||
return {
|
||||
command: 'mission',
|
||||
success: true,
|
||||
message: `Mission set to ${missionId}. Session context updated.`,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: 'mission',
|
||||
success: true,
|
||||
message: 'Usage: /mission [status|set <id>|list|tasks]',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleTools(
|
||||
conversationId: string,
|
||||
_userId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
// TODO: fetch tool list from active agent session
|
||||
return {
|
||||
command: 'tools',
|
||||
success: true,
|
||||
message:
|
||||
'Available tools depend on the active agent configuration. Use the web dashboard to configure tool access.',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
53
apps/gateway/src/commands/command-registry.service.spec.ts
Normal file
53
apps/gateway/src/commands/command-registry.service.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import type { CommandDef } from '@mosaic/types';
|
||||
|
||||
const mockCmd: CommandDef = {
|
||||
name: 'test',
|
||||
description: 'Test command',
|
||||
aliases: ['t'],
|
||||
scope: 'core',
|
||||
execution: 'local',
|
||||
available: true,
|
||||
};
|
||||
|
||||
describe('CommandRegistryService', () => {
|
||||
let service: CommandRegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CommandRegistryService();
|
||||
});
|
||||
|
||||
it('starts with empty manifest', () => {
|
||||
expect(service.getManifest().commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('registers a command', () => {
|
||||
service.registerCommand(mockCmd);
|
||||
expect(service.getManifest().commands).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('updates existing command by name', () => {
|
||||
service.registerCommand(mockCmd);
|
||||
service.registerCommand({ ...mockCmd, description: 'Updated' });
|
||||
expect(service.getManifest().commands).toHaveLength(1);
|
||||
expect(service.getManifest().commands[0]?.description).toBe('Updated');
|
||||
});
|
||||
|
||||
it('onModuleInit registers core commands', () => {
|
||||
service.onModuleInit();
|
||||
const manifest = service.getManifest();
|
||||
expect(manifest.commands.length).toBeGreaterThan(5);
|
||||
expect(manifest.commands.some((c) => c.name === 'model')).toBe(true);
|
||||
expect(manifest.commands.some((c) => c.name === 'help')).toBe(true);
|
||||
});
|
||||
|
||||
it('manifest includes skills array', () => {
|
||||
const manifest = service.getManifest();
|
||||
expect(Array.isArray(manifest.skills)).toBe(true);
|
||||
});
|
||||
|
||||
it('manifest version is 1', () => {
|
||||
expect(service.getManifest().version).toBe(1);
|
||||
});
|
||||
});
|
||||
273
apps/gateway/src/commands/command-registry.service.ts
Normal file
273
apps/gateway/src/commands/command-registry.service.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import type { CommandDef, CommandManifest } from '@mosaic/types';
|
||||
|
||||
@Injectable()
|
||||
export class CommandRegistryService implements OnModuleInit {
|
||||
private readonly commands: CommandDef[] = [];
|
||||
|
||||
registerCommand(def: CommandDef): void {
|
||||
const existing = this.commands.findIndex((c) => c.name === def.name);
|
||||
if (existing >= 0) {
|
||||
this.commands[existing] = def;
|
||||
} else {
|
||||
this.commands.push(def);
|
||||
}
|
||||
}
|
||||
|
||||
registerCommands(defs: CommandDef[]): void {
|
||||
for (const def of defs) {
|
||||
this.registerCommand(def);
|
||||
}
|
||||
}
|
||||
|
||||
getManifest(): CommandManifest {
|
||||
return {
|
||||
version: 1,
|
||||
commands: [...this.commands],
|
||||
skills: [],
|
||||
};
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.registerCommands([
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Switch the active model',
|
||||
aliases: ['m'],
|
||||
args: [
|
||||
{
|
||||
name: 'model-name',
|
||||
type: 'string',
|
||||
optional: false,
|
||||
description: 'Model name to switch to',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'thinking',
|
||||
description: 'Set thinking level (none/low/medium/high/auto)',
|
||||
aliases: ['t'],
|
||||
args: [
|
||||
{
|
||||
name: 'level',
|
||||
type: 'enum',
|
||||
optional: false,
|
||||
values: ['none', 'low', 'medium', 'high', 'auto'],
|
||||
description: 'Thinking level',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
description: 'Start a new conversation',
|
||||
aliases: ['n'],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
description: 'Clear conversation context and GC session artifacts',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'compact',
|
||||
description: 'Request context compaction',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'retry',
|
||||
description: 'Retry the last message',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'rename',
|
||||
description: 'Rename current conversation',
|
||||
aliases: [],
|
||||
args: [
|
||||
{ name: 'name', type: 'string', optional: false, description: 'New conversation name' },
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
description: 'Show conversation history',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'string',
|
||||
optional: true,
|
||||
description: 'Number of messages to show',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'export',
|
||||
description: 'Export conversation to markdown or JSON',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'format',
|
||||
type: 'enum',
|
||||
optional: true,
|
||||
values: ['md', 'json'],
|
||||
description: 'Export format',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'preferences',
|
||||
description: 'View or set user preferences',
|
||||
aliases: ['pref'],
|
||||
args: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'enum',
|
||||
optional: true,
|
||||
values: ['show', 'set', 'reset'],
|
||||
description: 'Action to perform',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'system',
|
||||
description: 'Set session-scoped system prompt override',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'override',
|
||||
type: 'string',
|
||||
optional: false,
|
||||
description: 'System prompt text to inject for this session',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
description: 'Show session and connection status',
|
||||
aliases: ['s'],
|
||||
scope: 'core',
|
||||
execution: 'hybrid',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'help',
|
||||
description: 'Show available commands',
|
||||
aliases: ['h'],
|
||||
scope: 'core',
|
||||
execution: 'local',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'gc',
|
||||
description: 'Trigger garbage collection sweep (user-scoped)',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'agent',
|
||||
description: 'Switch or list available agents',
|
||||
aliases: ['a'],
|
||||
args: [
|
||||
{
|
||||
name: 'args',
|
||||
type: 'string',
|
||||
optional: true,
|
||||
description: 'list or <agent-id>',
|
||||
},
|
||||
],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'provider',
|
||||
description: 'Manage LLM providers (list/login/logout)',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'args',
|
||||
type: 'string',
|
||||
optional: true,
|
||||
description: 'list | login <name> | logout <name>',
|
||||
},
|
||||
],
|
||||
scope: 'agent',
|
||||
execution: 'hybrid',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'mission',
|
||||
description: 'View or set active mission',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'args',
|
||||
type: 'string',
|
||||
optional: true,
|
||||
description: 'status | set <id> | list | tasks',
|
||||
},
|
||||
],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'prdy',
|
||||
description: 'Launch PRD wizard',
|
||||
aliases: [],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'tools',
|
||||
description: 'List available agent tools',
|
||||
aliases: [],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'reload',
|
||||
description: 'Soft-reload gateway plugins and command manifest (admin)',
|
||||
aliases: [],
|
||||
scope: 'admin',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
253
apps/gateway/src/commands/commands.integration.spec.ts
Normal file
253
apps/gateway/src/commands/commands.integration.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Integration tests for the gateway command system (P8-019)
|
||||
*
|
||||
* Covers:
|
||||
* - CommandRegistryService.getManifest() returns 12+ core commands
|
||||
* - All core commands have correct execution types
|
||||
* - Alias resolution works for all defined aliases
|
||||
* - CommandExecutorService routes known/unknown commands correctly
|
||||
* - /gc handler calls SessionGCService.sweepOrphans
|
||||
* - /system handler calls SystemOverrideService.set
|
||||
* - Unknown command returns descriptive error
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import type { SlashCommandPayload } from '@mosaic/types';
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockAgentService = {
|
||||
getSession: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
const mockSystemOverride = {
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
renew: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const mockSessionGC = {
|
||||
sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 3, totalCleaned: [], duration: 12 }),
|
||||
};
|
||||
|
||||
const mockRedis = {
|
||||
set: vi.fn().mockResolvedValue('OK'),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
del: vi.fn().mockResolvedValue(0),
|
||||
keys: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildRegistry(): CommandRegistryService {
|
||||
const svc = new CommandRegistryService();
|
||||
svc.onModuleInit(); // seed core commands
|
||||
return svc;
|
||||
}
|
||||
|
||||
function buildExecutor(registry: CommandRegistryService): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
registry as never,
|
||||
mockAgentService as never,
|
||||
mockSystemOverride as never,
|
||||
mockSessionGC as never,
|
||||
mockRedis as never,
|
||||
null, // reloadService (optional)
|
||||
null, // chatGateway (optional)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Registry Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('CommandRegistryService — integration', () => {
|
||||
let registry: CommandRegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = buildRegistry();
|
||||
});
|
||||
|
||||
it('getManifest() returns 12 or more core commands after onModuleInit', () => {
|
||||
const manifest = registry.getManifest();
|
||||
expect(manifest.commands.length).toBeGreaterThanOrEqual(12);
|
||||
});
|
||||
|
||||
it('manifest version is 1', () => {
|
||||
expect(registry.getManifest().version).toBe(1);
|
||||
});
|
||||
|
||||
it('manifest.skills is an array', () => {
|
||||
expect(Array.isArray(registry.getManifest().skills)).toBe(true);
|
||||
});
|
||||
|
||||
it('all commands have required fields: name, description, execution, scope, available', () => {
|
||||
for (const cmd of registry.getManifest().commands) {
|
||||
expect(typeof cmd.name).toBe('string');
|
||||
expect(typeof cmd.description).toBe('string');
|
||||
expect(['local', 'socket', 'rest', 'hybrid']).toContain(cmd.execution);
|
||||
expect(['core', 'agent', 'admin']).toContain(cmd.scope);
|
||||
expect(typeof cmd.available).toBe('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
// Execution type verification for core commands
|
||||
const expectedExecutionTypes: Record<string, string> = {
|
||||
model: 'socket',
|
||||
thinking: 'socket',
|
||||
new: 'socket',
|
||||
clear: 'socket',
|
||||
compact: 'socket',
|
||||
retry: 'socket',
|
||||
rename: 'rest',
|
||||
history: 'rest',
|
||||
export: 'rest',
|
||||
preferences: 'rest',
|
||||
system: 'socket',
|
||||
help: 'local',
|
||||
gc: 'socket',
|
||||
agent: 'socket',
|
||||
provider: 'hybrid',
|
||||
mission: 'socket',
|
||||
prdy: 'socket',
|
||||
tools: 'socket',
|
||||
reload: 'socket',
|
||||
};
|
||||
|
||||
for (const [name, expectedExecution] of Object.entries(expectedExecutionTypes)) {
|
||||
it(`command "${name}" has execution type "${expectedExecution}"`, () => {
|
||||
const cmd = registry.getManifest().commands.find((c) => c.name === name);
|
||||
expect(cmd, `command "${name}" not found`).toBeDefined();
|
||||
expect(cmd!.execution).toBe(expectedExecution);
|
||||
});
|
||||
}
|
||||
|
||||
// Alias resolution checks
|
||||
const expectedAliases: Array<[string, string]> = [
|
||||
['m', 'model'],
|
||||
['t', 'thinking'],
|
||||
['n', 'new'],
|
||||
['a', 'agent'],
|
||||
['s', 'status'],
|
||||
['h', 'help'],
|
||||
['pref', 'preferences'],
|
||||
];
|
||||
|
||||
for (const [alias, commandName] of expectedAliases) {
|
||||
it(`alias "/${alias}" resolves to command "${commandName}" via aliases array`, () => {
|
||||
const cmd = registry
|
||||
.getManifest()
|
||||
.commands.find((c) => c.name === commandName || c.aliases?.includes(alias));
|
||||
expect(cmd, `command with alias "${alias}" not found`).toBeDefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Executor Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('CommandExecutorService — integration', () => {
|
||||
let registry: CommandRegistryService;
|
||||
let executor: CommandExecutorService;
|
||||
const userId = 'user-integ-001';
|
||||
const conversationId = 'conv-integ-001';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
registry = buildRegistry();
|
||||
executor = buildExecutor(registry);
|
||||
});
|
||||
|
||||
// Unknown command returns error
|
||||
it('unknown command returns success:false with descriptive message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('nonexistent');
|
||||
expect(result.command).toBe('nonexistent');
|
||||
});
|
||||
|
||||
// /gc handler calls SessionGCService.sweepOrphans
|
||||
it('/gc calls SessionGCService.sweepOrphans with userId', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith(userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('GC sweep complete');
|
||||
expect(result.message).toContain('3 orphaned sessions');
|
||||
});
|
||||
|
||||
// /system with args calls SystemOverrideService.set
|
||||
it('/system with text calls SystemOverrideService.set', async () => {
|
||||
const override = 'You are a helpful assistant.';
|
||||
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('override set');
|
||||
});
|
||||
|
||||
// /system with no args clears the override
|
||||
it('/system with no args calls SystemOverrideService.clear', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('cleared');
|
||||
});
|
||||
|
||||
// /model with model name returns success
|
||||
it('/model with a model name returns success', async () => {
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'model',
|
||||
args: 'claude-3-opus',
|
||||
conversationId,
|
||||
};
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('model');
|
||||
expect(result.message).toContain('claude-3-opus');
|
||||
});
|
||||
|
||||
// /thinking with valid level returns success
|
||||
it('/thinking with valid level returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('high');
|
||||
});
|
||||
|
||||
// /thinking with invalid level returns usage message
|
||||
it('/thinking with invalid level returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage:');
|
||||
});
|
||||
|
||||
// /new command returns success
|
||||
it('/new returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'new', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('new');
|
||||
});
|
||||
|
||||
// /reload without reloadService returns failure
|
||||
it('/reload without ReloadService returns failure', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'reload', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('ReloadService');
|
||||
});
|
||||
|
||||
// Commands not yet fully implemented return a fallback response
|
||||
const stubCommands = ['clear', 'compact', 'retry'];
|
||||
for (const cmd of stubCommands) {
|
||||
it(`/${cmd} returns success (stub)`, async () => {
|
||||
const payload: SlashCommandPayload = { command: cmd, conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe(cmd);
|
||||
});
|
||||
}
|
||||
});
|
||||
37
apps/gateway/src/commands/commands.module.ts
Normal file
37
apps/gateway/src/commands/commands.module.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { ReloadModule } from '../reload/reload.module.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
|
||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
|
||||
@Module({
|
||||
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)],
|
||||
providers: [
|
||||
{
|
||||
provide: COMMANDS_QUEUE_HANDLE,
|
||||
useFactory: (): QueueHandle => {
|
||||
return createQueue();
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: COMMANDS_REDIS,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
inject: [COMMANDS_QUEUE_HANDLE],
|
||||
},
|
||||
CommandRegistryService,
|
||||
CommandExecutorService,
|
||||
],
|
||||
exports: [CommandRegistryService, CommandExecutorService],
|
||||
})
|
||||
export class CommandsModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
1
apps/gateway/src/commands/commands.tokens.ts
Normal file
1
apps/gateway/src/commands/commands.tokens.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const COMMANDS_REDIS = 'COMMANDS_REDIS';
|
||||
@@ -1,30 +1,17 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { CoordService } from './coord.service.js';
|
||||
import type {
|
||||
CreateDbMissionDto,
|
||||
UpdateDbMissionDto,
|
||||
CreateMissionTaskDto,
|
||||
UpdateMissionTaskDto,
|
||||
} from './coord.dto.js';
|
||||
|
||||
/** Walk up from cwd to find the monorepo root (has pnpm-workspace.yaml). */
|
||||
function findMonorepoRoot(start: string): string {
|
||||
@@ -57,13 +44,15 @@ function resolveAndValidatePath(raw: string | undefined): string {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based coord endpoints for agent tool consumption.
|
||||
* DB-backed mission CRUD has moved to MissionsController at /api/missions.
|
||||
*/
|
||||
@Controller('api/coord')
|
||||
@UseGuards(AuthGuard)
|
||||
export class CoordController {
|
||||
constructor(@Inject(CoordService) private readonly coordService: CoordService) {}
|
||||
|
||||
// ── File-based coord endpoints (legacy) ──
|
||||
|
||||
@Get('status')
|
||||
async missionStatus(@Query('projectPath') projectPath?: string) {
|
||||
const resolvedPath = resolveAndValidatePath(projectPath);
|
||||
@@ -85,121 +74,4 @@ export class CoordController {
|
||||
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
|
||||
return detail;
|
||||
}
|
||||
|
||||
// ── DB-backed mission endpoints ──
|
||||
|
||||
@Get('missions')
|
||||
async listDbMissions(@CurrentUser() user: { id: string }) {
|
||||
return this.coordService.getMissionsByUser(user.id);
|
||||
}
|
||||
|
||||
@Get('missions/:id')
|
||||
async getDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(id, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return mission;
|
||||
}
|
||||
|
||||
@Post('missions')
|
||||
async createDbMission(@Body() dto: CreateDbMissionDto, @CurrentUser() user: { id: string }) {
|
||||
return this.coordService.createDbMission({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
projectId: dto.projectId,
|
||||
userId: user.id,
|
||||
phase: dto.phase,
|
||||
milestones: dto.milestones,
|
||||
config: dto.config,
|
||||
status: dto.status,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('missions/:id')
|
||||
async updateDbMission(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateDbMissionDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.updateDbMission(id, user.id, dto);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return mission;
|
||||
}
|
||||
|
||||
@Delete('missions/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
const deleted = await this.coordService.deleteDbMission(id, user.id);
|
||||
if (!deleted) throw new NotFoundException('Mission not found');
|
||||
}
|
||||
|
||||
// ── DB-backed mission task endpoints ──
|
||||
|
||||
@Get('missions/:missionId/mission-tasks')
|
||||
async listMissionTasks(
|
||||
@Param('missionId') missionId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return this.coordService.getMissionTasksByMissionAndUser(missionId, user.id);
|
||||
}
|
||||
|
||||
@Get('missions/:missionId/mission-tasks/:taskId')
|
||||
async getMissionTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const task = await this.coordService.getMissionTaskByIdAndUser(taskId, user.id);
|
||||
if (!task) throw new NotFoundException('Mission task not found');
|
||||
return task;
|
||||
}
|
||||
|
||||
@Post('missions/:missionId/mission-tasks')
|
||||
async createMissionTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Body() dto: CreateMissionTaskDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return this.coordService.createMissionTask({
|
||||
missionId,
|
||||
taskId: dto.taskId,
|
||||
userId: user.id,
|
||||
status: dto.status,
|
||||
description: dto.description,
|
||||
notes: dto.notes,
|
||||
pr: dto.pr,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('missions/:missionId/mission-tasks/:taskId')
|
||||
async updateMissionTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() dto: UpdateMissionTaskDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const updated = await this.coordService.updateMissionTask(taskId, user.id, dto);
|
||||
if (!updated) throw new NotFoundException('Mission task not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Delete('missions/:missionId/mission-tasks/:taskId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteMissionTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const deleted = await this.coordService.deleteMissionTask(taskId, user.id);
|
||||
if (!deleted) throw new NotFoundException('Mission task not found');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
loadMission,
|
||||
getMissionStatus,
|
||||
@@ -14,12 +12,14 @@ import {
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* File-based coord operations for agent tool consumption.
|
||||
* DB-backed mission CRUD is handled directly by MissionsController via Brain repos.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CoordService {
|
||||
private readonly logger = new Logger(CoordService.name);
|
||||
|
||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||
|
||||
async loadMission(projectPath: string): Promise<Mission | null> {
|
||||
try {
|
||||
return await loadMission(projectPath);
|
||||
@@ -74,68 +74,4 @@ export class CoordService {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── DB-backed methods for multi-tenant mission management ──
|
||||
|
||||
async getMissionsByUser(userId: string) {
|
||||
return this.brain.missions.findAllByUser(userId);
|
||||
}
|
||||
|
||||
async getMissionByIdAndUser(id: string, userId: string) {
|
||||
return this.brain.missions.findByIdAndUser(id, userId);
|
||||
}
|
||||
|
||||
async getMissionsByProjectAndUser(projectId: string, userId: string) {
|
||||
return this.brain.missions.findByProjectAndUser(projectId, userId);
|
||||
}
|
||||
|
||||
async createDbMission(data: Parameters<Brain['missions']['create']>[0]) {
|
||||
return this.brain.missions.create(data);
|
||||
}
|
||||
|
||||
async updateDbMission(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: Parameters<Brain['missions']['update']>[1],
|
||||
) {
|
||||
const existing = await this.brain.missions.findByIdAndUser(id, userId);
|
||||
if (!existing) return null;
|
||||
return this.brain.missions.update(id, data);
|
||||
}
|
||||
|
||||
async deleteDbMission(id: string, userId: string) {
|
||||
const existing = await this.brain.missions.findByIdAndUser(id, userId);
|
||||
if (!existing) return false;
|
||||
return this.brain.missions.remove(id);
|
||||
}
|
||||
|
||||
// ── DB-backed methods for mission tasks (coord tracking) ──
|
||||
|
||||
async getMissionTasksByMissionAndUser(missionId: string, userId: string) {
|
||||
return this.brain.missionTasks.findByMissionAndUser(missionId, userId);
|
||||
}
|
||||
|
||||
async getMissionTaskByIdAndUser(id: string, userId: string) {
|
||||
return this.brain.missionTasks.findByIdAndUser(id, userId);
|
||||
}
|
||||
|
||||
async createMissionTask(data: Parameters<Brain['missionTasks']['create']>[0]) {
|
||||
return this.brain.missionTasks.create(data);
|
||||
}
|
||||
|
||||
async updateMissionTask(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: Parameters<Brain['missionTasks']['update']>[1],
|
||||
) {
|
||||
const existing = await this.brain.missionTasks.findByIdAndUser(id, userId);
|
||||
if (!existing) return null;
|
||||
return this.brain.missionTasks.update(id, data);
|
||||
}
|
||||
|
||||
async deleteMissionTask(id: string, userId: string) {
|
||||
const existing = await this.brain.missionTasks.findByIdAndUser(id, userId);
|
||||
if (!existing) return false;
|
||||
return this.brain.missionTasks.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
31
apps/gateway/src/gc/gc.module.ts
Normal file
31
apps/gateway/src/gc/gc.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
||||
import { SessionGCService } from './session-gc.service.js';
|
||||
import { REDIS } from './gc.tokens.js';
|
||||
|
||||
const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: GC_QUEUE_HANDLE,
|
||||
useFactory: (): QueueHandle => {
|
||||
return createQueue();
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: REDIS,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
inject: [GC_QUEUE_HANDLE],
|
||||
},
|
||||
SessionGCService,
|
||||
],
|
||||
exports: [SessionGCService],
|
||||
})
|
||||
export class GCModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
1
apps/gateway/src/gc/gc.tokens.ts
Normal file
1
apps/gateway/src/gc/gc.tokens.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const REDIS = 'REDIS';
|
||||
97
apps/gateway/src/gc/session-gc.service.spec.ts
Normal file
97
apps/gateway/src/gc/session-gc.service.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaic/queue';
|
||||
import type { LogService } from '@mosaic/log';
|
||||
import { SessionGCService } from './session-gc.service.js';
|
||||
|
||||
type MockRedis = {
|
||||
keys: ReturnType<typeof vi.fn>;
|
||||
del: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
describe('SessionGCService', () => {
|
||||
let service: SessionGCService;
|
||||
let mockRedis: MockRedis;
|
||||
let mockLogService: { logs: { promoteToWarm: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = {
|
||||
keys: vi.fn().mockResolvedValue([]),
|
||||
del: vi.fn().mockResolvedValue(0),
|
||||
};
|
||||
|
||||
mockLogService = {
|
||||
logs: {
|
||||
promoteToWarm: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
};
|
||||
|
||||
// Suppress logger output in tests
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {});
|
||||
|
||||
service = new SessionGCService(
|
||||
mockRedis as unknown as QueueHandle['redis'],
|
||||
mockLogService as unknown as LogService,
|
||||
);
|
||||
});
|
||||
|
||||
it('collect() deletes Valkey keys for session', async () => {
|
||||
mockRedis.keys.mockResolvedValue(['mosaic:session:abc:system', 'mosaic:session:abc:foo']);
|
||||
const result = await service.collect('abc');
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
||||
'mosaic:session:abc:system',
|
||||
'mosaic:session:abc:foo',
|
||||
);
|
||||
expect(result.cleaned.valkeyKeys).toBe(2);
|
||||
});
|
||||
|
||||
it('collect() with no keys returns empty cleaned valkeyKeys', async () => {
|
||||
mockRedis.keys.mockResolvedValue([]);
|
||||
const result = await service.collect('abc');
|
||||
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
||||
});
|
||||
|
||||
it('collect() returns sessionId in result', async () => {
|
||||
const result = await service.collect('test-session-id');
|
||||
expect(result.sessionId).toBe('test-session-id');
|
||||
});
|
||||
|
||||
it('fullCollect() deletes all session keys', async () => {
|
||||
mockRedis.keys.mockResolvedValue(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']);
|
||||
const result = await service.fullCollect();
|
||||
expect(mockRedis.del).toHaveBeenCalled();
|
||||
expect(result.valkeyKeys).toBe(2);
|
||||
});
|
||||
|
||||
it('fullCollect() with no keys returns 0 valkeyKeys', async () => {
|
||||
mockRedis.keys.mockResolvedValue([]);
|
||||
const result = await service.fullCollect();
|
||||
expect(result.valkeyKeys).toBe(0);
|
||||
expect(mockRedis.del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fullCollect() returns duration', async () => {
|
||||
const result = await service.fullCollect();
|
||||
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('sweepOrphans() extracts unique session IDs and collects them', async () => {
|
||||
mockRedis.keys.mockResolvedValue([
|
||||
'mosaic:session:abc:system',
|
||||
'mosaic:session:abc:messages',
|
||||
'mosaic:session:xyz:system',
|
||||
]);
|
||||
mockRedis.del.mockResolvedValue(1);
|
||||
|
||||
const result = await service.sweepOrphans();
|
||||
expect(result.orphanedSessions).toBeGreaterThanOrEqual(0);
|
||||
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('sweepOrphans() returns empty when no session keys', async () => {
|
||||
mockRedis.keys.mockResolvedValue([]);
|
||||
const result = await service.sweepOrphans();
|
||||
expect(result.orphanedSessions).toBe(0);
|
||||
expect(result.totalCleaned).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
147
apps/gateway/src/gc/session-gc.service.ts
Normal file
147
apps/gateway/src/gc/session-gc.service.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaic/queue';
|
||||
import type { LogService } from '@mosaic/log';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
import { REDIS } from './gc.tokens.js';
|
||||
|
||||
export interface GCResult {
|
||||
sessionId: string;
|
||||
cleaned: {
|
||||
valkeyKeys?: number;
|
||||
logsDemoted?: number;
|
||||
tempFilesRemoved?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GCSweepResult {
|
||||
orphanedSessions: number;
|
||||
totalCleaned: GCResult[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface FullGCResult {
|
||||
valkeyKeys: number;
|
||||
logsDemoted: number;
|
||||
jobsPurged: number;
|
||||
tempFilesRemoved: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionGCService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SessionGCService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
// Fire-and-forget: run full GC asynchronously so it does not block the
|
||||
// NestJS bootstrap chain. Cold-start GC typically takes 100–500 ms
|
||||
// depending on Valkey key count; deferring it removes that latency from
|
||||
// the TTFB of the first HTTP request.
|
||||
this.fullCollect()
|
||||
.then((result) => {
|
||||
this.logger.log(
|
||||
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
|
||||
`${result.logsDemoted} logs demoted, ` +
|
||||
`${result.jobsPurged} jobs purged, ` +
|
||||
`${result.tempFilesRemoved} temp dirs removed ` +
|
||||
`(${result.duration}ms)`,
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
this.logger.error('Cold-start GC failed', err instanceof Error ? err.stack : String(err));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediate cleanup for a single session (call from destroySession).
|
||||
*/
|
||||
async collect(sessionId: string): Promise<GCResult> {
|
||||
const result: GCResult = { sessionId, cleaned: {} };
|
||||
|
||||
// 1. Valkey: delete all session-scoped keys
|
||||
const pattern = `mosaic:session:${sessionId}:*`;
|
||||
const valkeyKeys = await this.redis.keys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
}
|
||||
|
||||
// 2. PG: demote hot-tier agent_logs for this session to warm
|
||||
const cutoff = new Date(); // demote all hot logs for this session
|
||||
const logsDemoted = await this.logService.logs.promoteToWarm(cutoff);
|
||||
if (logsDemoted > 0) {
|
||||
result.cleaned.logsDemoted = logsDemoted;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep GC — find orphaned artifacts from dead sessions.
|
||||
* User-scoped when userId provided; system-wide when null (admin).
|
||||
*/
|
||||
async sweepOrphans(_userId?: string): Promise<GCSweepResult> {
|
||||
const start = Date.now();
|
||||
const cleaned: GCResult[] = [];
|
||||
|
||||
// 1. Find all session-scoped Valkey keys
|
||||
const allSessionKeys = await this.redis.keys('mosaic:session:*');
|
||||
|
||||
// Extract unique session IDs from keys
|
||||
const sessionIds = new Set<string>();
|
||||
for (const key of allSessionKeys) {
|
||||
const match = key.match(/^mosaic:session:([^:]+):/);
|
||||
if (match) sessionIds.add(match[1]!);
|
||||
}
|
||||
|
||||
// 2. For each session ID, collect stale keys
|
||||
for (const sessionId of sessionIds) {
|
||||
const gcResult = await this.collect(sessionId);
|
||||
if (Object.keys(gcResult.cleaned).length > 0) {
|
||||
cleaned.push(gcResult);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
orphanedSessions: cleaned.length,
|
||||
totalCleaned: cleaned,
|
||||
duration: Date.now() - start,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full GC — aggressive collection for cold start.
|
||||
* Assumes no sessions survived the restart.
|
||||
*/
|
||||
async fullCollect(): Promise<FullGCResult> {
|
||||
const start = Date.now();
|
||||
|
||||
// 1. Valkey: delete ALL session-scoped keys
|
||||
const sessionKeys = await this.redis.keys('mosaic:session:*');
|
||||
if (sessionKeys.length > 0) {
|
||||
await this.redis.del(...sessionKeys);
|
||||
}
|
||||
|
||||
// 2. NOTE: channel keys are NOT collected on cold start
|
||||
// (discord/telegram plugins may reconnect and resume)
|
||||
|
||||
// 3. PG: demote stale hot-tier logs older than 24h to warm
|
||||
const hotCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const logsDemoted = await this.logService.logs.promoteToWarm(hotCutoff);
|
||||
|
||||
// 4. No summarization job purge API available yet
|
||||
const jobsPurged = 0;
|
||||
|
||||
return {
|
||||
valkeyKeys: sessionKeys.length,
|
||||
logsDemoted,
|
||||
jobsPurged,
|
||||
tempFilesRemoved: 0,
|
||||
duration: Date.now() - start,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,22 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import cron from 'node-cron';
|
||||
import { SummarizationService } from './summarization.service.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CronService.name);
|
||||
private readonly tasks: cron.ScheduledTask[] = [];
|
||||
|
||||
constructor(@Inject(SummarizationService) private readonly summarization: SummarizationService) {}
|
||||
constructor(
|
||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
|
||||
|
||||
this.tasks.push(
|
||||
cron.schedule(summarizationSchedule, () => {
|
||||
@@ -35,8 +40,16 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
}),
|
||||
);
|
||||
|
||||
this.tasks.push(
|
||||
cron.schedule(gcSchedule, () => {
|
||||
this.sessionGC.sweepOrphans().catch((err) => {
|
||||
this.logger.error(`Session GC sweep failed: ${err}`);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Cron scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`,
|
||||
`Cron scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { LOG_SERVICE } from './log.tokens.js';
|
||||
import { LogController } from './log.controller.js';
|
||||
import { SummarizationService } from './summarization.service.js';
|
||||
import { CronService } from './cron.service.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [GCModule],
|
||||
providers: [
|
||||
{
|
||||
provide: LOG_SERVICE,
|
||||
|
||||
@@ -40,6 +40,7 @@ async function bootstrap(): Promise<void> {
|
||||
app.enableCors({
|
||||
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
||||
credentials: true,
|
||||
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
});
|
||||
|
||||
await app.register(helmet as never, { contentSecurityPolicy: false });
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
@@ -17,33 +16,42 @@ import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { assertOwner } from '../auth/resource-ownership.js';
|
||||
import { CreateMissionDto, UpdateMissionDto } from './missions.dto.js';
|
||||
import {
|
||||
CreateMissionDto,
|
||||
UpdateMissionDto,
|
||||
CreateMissionTaskDto,
|
||||
UpdateMissionTaskDto,
|
||||
} from './missions.dto.js';
|
||||
|
||||
@Controller('api/missions')
|
||||
@UseGuards(AuthGuard)
|
||||
export class MissionsController {
|
||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||
|
||||
// ── Missions CRUD (user-scoped) ──
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.brain.missions.findAll();
|
||||
async list(@CurrentUser() user: { id: string }) {
|
||||
return this.brain.missions.findAllByUser(user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
return this.getOwnedMission(id, user.id);
|
||||
const mission = await this.brain.missions.findByIdAndUser(id, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return mission;
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateMissionDto, @CurrentUser() user: { id: string }) {
|
||||
if (dto.projectId) {
|
||||
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
|
||||
}
|
||||
return this.brain.missions.create({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
projectId: dto.projectId,
|
||||
userId: user.id,
|
||||
phase: dto.phase,
|
||||
milestones: dto.milestones,
|
||||
config: dto.config,
|
||||
status: dto.status,
|
||||
});
|
||||
}
|
||||
@@ -54,10 +62,8 @@ export class MissionsController {
|
||||
@Body() dto: UpdateMissionDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
await this.getOwnedMission(id, user.id);
|
||||
if (dto.projectId) {
|
||||
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
|
||||
}
|
||||
const existing = await this.brain.missions.findByIdAndUser(id, user.id);
|
||||
if (!existing) throw new NotFoundException('Mission not found');
|
||||
const mission = await this.brain.missions.update(id, dto);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return mission;
|
||||
@@ -66,33 +72,81 @@ export class MissionsController {
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
await this.getOwnedMission(id, user.id);
|
||||
const existing = await this.brain.missions.findByIdAndUser(id, user.id);
|
||||
if (!existing) throw new NotFoundException('Mission not found');
|
||||
const deleted = await this.brain.missions.remove(id);
|
||||
if (!deleted) throw new NotFoundException('Mission not found');
|
||||
}
|
||||
|
||||
private async getOwnedMission(id: string, userId: string) {
|
||||
const mission = await this.brain.missions.findById(id);
|
||||
// ── Mission Tasks sub-routes ──
|
||||
|
||||
@Get(':missionId/tasks')
|
||||
async listTasks(@Param('missionId') missionId: string, @CurrentUser() user: { id: string }) {
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
await this.getOwnedProject(mission.projectId, userId, 'Mission');
|
||||
return mission;
|
||||
return this.brain.missionTasks.findByMissionAndUser(missionId, user.id);
|
||||
}
|
||||
|
||||
private async getOwnedProject(
|
||||
projectId: string | null | undefined,
|
||||
userId: string,
|
||||
resourceName: string,
|
||||
@Get(':missionId/tasks/:taskId')
|
||||
async getTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
if (!projectId) {
|
||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||
}
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const task = await this.brain.missionTasks.findByIdAndUser(taskId, user.id);
|
||||
if (!task) throw new NotFoundException('Mission task not found');
|
||||
return task;
|
||||
}
|
||||
|
||||
const project = await this.brain.projects.findById(projectId);
|
||||
if (!project) {
|
||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||
}
|
||||
@Post(':missionId/tasks')
|
||||
async createTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Body() dto: CreateMissionTaskDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return this.brain.missionTasks.create({
|
||||
missionId,
|
||||
taskId: dto.taskId,
|
||||
userId: user.id,
|
||||
status: dto.status,
|
||||
description: dto.description,
|
||||
notes: dto.notes,
|
||||
pr: dto.pr,
|
||||
});
|
||||
}
|
||||
|
||||
assertOwner(project.ownerId, userId, resourceName);
|
||||
return project;
|
||||
@Patch(':missionId/tasks/:taskId')
|
||||
async updateTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() dto: UpdateMissionTaskDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const existing = await this.brain.missionTasks.findByIdAndUser(taskId, user.id);
|
||||
if (!existing) throw new NotFoundException('Mission task not found');
|
||||
const updated = await this.brain.missionTasks.update(taskId, dto);
|
||||
if (!updated) throw new NotFoundException('Mission task not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Delete(':missionId/tasks/:taskId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async removeTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const existing = await this.brain.missionTasks.findByIdAndUser(taskId, user.id);
|
||||
if (!existing) throw new NotFoundException('Mission task not found');
|
||||
const deleted = await this.brain.missionTasks.remove(taskId);
|
||||
if (!deleted) throw new NotFoundException('Mission task not found');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { IsArray, IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
const missionStatuses = ['planning', 'active', 'paused', 'completed', 'failed'] as const;
|
||||
const taskStatuses = ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'] as const;
|
||||
|
||||
export class CreateMissionDto {
|
||||
@IsString()
|
||||
@@ -19,6 +20,19 @@ export class CreateMissionDto {
|
||||
@IsOptional()
|
||||
@IsIn(missionStatuses)
|
||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
phase?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
milestones?: Record<string, unknown>[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class UpdateMissionDto {
|
||||
@@ -40,7 +54,70 @@ export class UpdateMissionDto {
|
||||
@IsIn(missionStatuses)
|
||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
phase?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
milestones?: Record<string, unknown>[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
config?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export class CreateMissionTaskDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
taskId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
pr?: string;
|
||||
}
|
||||
|
||||
export class UpdateMissionTaskDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
taskId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
pr?: string;
|
||||
}
|
||||
|
||||
@@ -2,4 +2,10 @@ export interface IChannelPlugin {
|
||||
readonly name: string;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
/** Called when a new project is bootstrapped. Return channelId if a channel was created. */
|
||||
onProjectCreated?(project: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}): Promise<{ channelId: string } | null>;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ class DiscordChannelPluginAdapter implements IChannelPlugin {
|
||||
async stop(): Promise<void> {
|
||||
await this.plugin.stop();
|
||||
}
|
||||
|
||||
async onProjectCreated(project: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}): Promise<{ channelId: string } | null> {
|
||||
return this.plugin.createProjectChannel(project);
|
||||
}
|
||||
}
|
||||
|
||||
class TelegramChannelPluginAdapter implements IChannelPlugin {
|
||||
|
||||
44
apps/gateway/src/preferences/preferences.controller.ts
Normal file
44
apps/gateway/src/preferences/preferences.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { PreferencesService } from './preferences.service.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
|
||||
@Controller('api/preferences')
|
||||
@UseGuards(AuthGuard)
|
||||
export class PreferencesController {
|
||||
constructor(@Inject(PreferencesService) private readonly preferences: PreferencesService) {}
|
||||
|
||||
@Get()
|
||||
async show(@CurrentUser() user: { id: string }): Promise<Record<string, unknown>> {
|
||||
return this.preferences.getEffective(user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async set(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body() body: { key: string; value: unknown },
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return this.preferences.set(user.id, body.key, body.value);
|
||||
}
|
||||
|
||||
@Delete(':key')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async reset(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('key') key: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return this.preferences.reset(user.id, key);
|
||||
}
|
||||
}
|
||||
12
apps/gateway/src/preferences/preferences.module.ts
Normal file
12
apps/gateway/src/preferences/preferences.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PreferencesService } from './preferences.service.js';
|
||||
import { PreferencesController } from './preferences.controller.js';
|
||||
import { SystemOverrideService } from './system-override.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [PreferencesController],
|
||||
providers: [PreferencesService, SystemOverrideService],
|
||||
exports: [PreferencesService, SystemOverrideService],
|
||||
})
|
||||
export class PreferencesModule {}
|
||||
152
apps/gateway/src/preferences/preferences.service.spec.ts
Normal file
152
apps/gateway/src/preferences/preferences.service.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PreferencesService, PLATFORM_DEFAULTS, IMMUTABLE_KEYS } from './preferences.service.js';
|
||||
import type { Db } from '@mosaic/db';
|
||||
|
||||
/**
|
||||
* Build a mock Drizzle DB where the select chain supports:
|
||||
* db.select().from().where() → resolves to `listRows`
|
||||
* db.insert().values().onConflictDoUpdate() → resolves to []
|
||||
*/
|
||||
function makeMockDb(listRows: Array<{ key: string; value: unknown }> = []): Db {
|
||||
const chainWithLimit = {
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve),
|
||||
};
|
||||
const selectFrom = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnValue(chainWithLimit),
|
||||
};
|
||||
const deleteResult = {
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
// Single-round-trip upsert chain: insert().values().onConflictDoUpdate()
|
||||
const insertResult = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
return {
|
||||
select: vi.fn().mockReturnValue(selectFrom),
|
||||
delete: vi.fn().mockReturnValue(deleteResult),
|
||||
insert: vi.fn().mockReturnValue(insertResult),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
describe('PreferencesService', () => {
|
||||
describe('getEffective', () => {
|
||||
it('returns platform defaults when user has no overrides', async () => {
|
||||
const db = makeMockDb([]);
|
||||
const service = new PreferencesService(db);
|
||||
const result = await service.getEffective('user-1');
|
||||
|
||||
expect(result['agent.thinkingLevel']).toBe('auto');
|
||||
expect(result['agent.streamingEnabled']).toBe(true);
|
||||
expect(result['session.autoCompactEnabled']).toBe(true);
|
||||
expect(result['session.autoCompactThreshold']).toBe(0.8);
|
||||
});
|
||||
|
||||
it('applies user overrides for mutable keys', async () => {
|
||||
const db = makeMockDb([
|
||||
{ key: 'agent.thinkingLevel', value: 'high' },
|
||||
{ key: 'response.language', value: 'es' },
|
||||
]);
|
||||
|
||||
const service = new PreferencesService(db);
|
||||
const result = await service.getEffective('user-1');
|
||||
|
||||
expect(result['agent.thinkingLevel']).toBe('high');
|
||||
expect(result['response.language']).toBe('es');
|
||||
});
|
||||
|
||||
it('ignores user overrides for immutable keys — enforcement always wins', async () => {
|
||||
const db = makeMockDb([
|
||||
{ key: 'limits.maxThinkingLevel', value: 'high' },
|
||||
{ key: 'limits.rateLimit', value: 9999 },
|
||||
]);
|
||||
|
||||
const service = new PreferencesService(db);
|
||||
const result = await service.getEffective('user-1');
|
||||
|
||||
// Should still be null (platform default), not the user-supplied values
|
||||
expect(result['limits.maxThinkingLevel']).toBeNull();
|
||||
expect(result['limits.rateLimit']).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('returns error when attempting to override an immutable key', async () => {
|
||||
const db = makeMockDb();
|
||||
const service = new PreferencesService(db);
|
||||
|
||||
const result = await service.set('user-1', 'limits.maxThinkingLevel', 'high');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('platform enforcement');
|
||||
});
|
||||
|
||||
it('returns error when attempting to override limits.rateLimit', async () => {
|
||||
const db = makeMockDb();
|
||||
const service = new PreferencesService(db);
|
||||
|
||||
const result = await service.set('user-1', 'limits.rateLimit', 100);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('platform enforcement');
|
||||
});
|
||||
|
||||
it('upserts a mutable preference and returns success', async () => {
|
||||
// Single-round-trip INSERT … ON CONFLICT DO UPDATE path.
|
||||
const db = makeMockDb([]);
|
||||
const service = new PreferencesService(db);
|
||||
const result = await service.set('user-1', 'agent.thinkingLevel', 'high');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('"agent.thinkingLevel"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('returns error when attempting to reset an immutable key', async () => {
|
||||
const db = makeMockDb();
|
||||
const service = new PreferencesService(db);
|
||||
|
||||
const result = await service.reset('user-1', 'limits.rateLimit');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('platform enforcement');
|
||||
});
|
||||
|
||||
it('deletes user override and returns default value in message', async () => {
|
||||
const db = makeMockDb();
|
||||
const service = new PreferencesService(db);
|
||||
const result = await service.reset('user-1', 'agent.thinkingLevel');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('"auto"'); // platform default for agent.thinkingLevel
|
||||
});
|
||||
});
|
||||
|
||||
describe('IMMUTABLE_KEYS', () => {
|
||||
it('contains only the enforcement keys', () => {
|
||||
expect(IMMUTABLE_KEYS.has('limits.maxThinkingLevel')).toBe(true);
|
||||
expect(IMMUTABLE_KEYS.has('limits.rateLimit')).toBe(true);
|
||||
expect(IMMUTABLE_KEYS.has('agent.thinkingLevel')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PLATFORM_DEFAULTS', () => {
|
||||
it('has all expected keys', () => {
|
||||
const expectedKeys = [
|
||||
'agent.defaultModel',
|
||||
'agent.thinkingLevel',
|
||||
'agent.streamingEnabled',
|
||||
'response.language',
|
||||
'response.codeAnnotations',
|
||||
'safety.confirmDestructiveTools',
|
||||
'session.autoCompactThreshold',
|
||||
'session.autoCompactEnabled',
|
||||
'limits.maxThinkingLevel',
|
||||
'limits.rateLimit',
|
||||
];
|
||||
for (const key of expectedKeys) {
|
||||
expect(Object.prototype.hasOwnProperty.call(PLATFORM_DEFAULTS, key)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
118
apps/gateway/src/preferences/preferences.service.ts
Normal file
118
apps/gateway/src/preferences/preferences.service.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq, and, sql, type Db, preferences as preferencesTable } from '@mosaic/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
export const PLATFORM_DEFAULTS: Record<string, unknown> = {
|
||||
'agent.defaultModel': null,
|
||||
'agent.thinkingLevel': 'auto',
|
||||
'agent.streamingEnabled': true,
|
||||
'response.language': 'auto',
|
||||
'response.codeAnnotations': true,
|
||||
'safety.confirmDestructiveTools': true,
|
||||
'session.autoCompactThreshold': 0.8,
|
||||
'session.autoCompactEnabled': true,
|
||||
'limits.maxThinkingLevel': null,
|
||||
'limits.rateLimit': null,
|
||||
};
|
||||
|
||||
export const IMMUTABLE_KEYS = new Set<string>(['limits.maxThinkingLevel', 'limits.rateLimit']);
|
||||
|
||||
@Injectable()
|
||||
export class PreferencesService {
|
||||
private readonly logger = new Logger(PreferencesService.name);
|
||||
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/**
|
||||
* Returns the effective preference set for a user:
|
||||
* Platform defaults → user overrides (mutable keys only) → enforcements re-applied last
|
||||
*/
|
||||
async getEffective(userId: string): Promise<Record<string, unknown>> {
|
||||
const userPrefs = await this.getUserPrefs(userId);
|
||||
const result: Record<string, unknown> = { ...PLATFORM_DEFAULTS };
|
||||
|
||||
for (const [key, value] of Object.entries(userPrefs)) {
|
||||
if (!IMMUTABLE_KEYS.has(key)) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-apply immutable keys (enforcements always win)
|
||||
for (const key of IMMUTABLE_KEYS) {
|
||||
result[key] = PLATFORM_DEFAULTS[key];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async set(
|
||||
userId: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
if (IMMUTABLE_KEYS.has(key)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Cannot override "${key}" — this is a platform enforcement. Contact your admin.`,
|
||||
};
|
||||
}
|
||||
|
||||
await this.upsertPref(userId, key, value);
|
||||
return { success: true, message: `Preference "${key}" set to ${JSON.stringify(value)}.` };
|
||||
}
|
||||
|
||||
async reset(userId: string, key: string): Promise<{ success: boolean; message: string }> {
|
||||
if (IMMUTABLE_KEYS.has(key)) {
|
||||
return { success: false, message: `Cannot reset "${key}" — it is a platform enforcement.` };
|
||||
}
|
||||
|
||||
await this.deletePref(userId, key);
|
||||
const defaultVal = PLATFORM_DEFAULTS[key];
|
||||
return {
|
||||
success: true,
|
||||
message: `Preference "${key}" reset to default: ${JSON.stringify(defaultVal)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
private async getUserPrefs(userId: string): Promise<Record<string, unknown>> {
|
||||
const rows = await this.db
|
||||
.select({ key: preferencesTable.key, value: preferencesTable.value })
|
||||
.from(preferencesTable)
|
||||
.where(eq(preferencesTable.userId, userId));
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
result[row.key] = row.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async upsertPref(userId: string, key: string, value: unknown): Promise<void> {
|
||||
// Single-round-trip upsert using INSERT … ON CONFLICT DO UPDATE.
|
||||
// Previously this was two queries (SELECT + INSERT/UPDATE), which doubled
|
||||
// the DB round-trips and introduced a TOCTOU window under concurrent writes.
|
||||
await this.db
|
||||
.insert(preferencesTable)
|
||||
.values({
|
||||
userId,
|
||||
key,
|
||||
value: value as never,
|
||||
mutable: true,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [preferencesTable.userId, preferencesTable.key],
|
||||
set: {
|
||||
value: sql`excluded.value`,
|
||||
updatedAt: sql`now()`,
|
||||
},
|
||||
});
|
||||
this.logger.debug(`Upserted preference "${key}" for user ${userId}`);
|
||||
}
|
||||
|
||||
private async deletePref(userId: string, key: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(preferencesTable)
|
||||
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)));
|
||||
this.logger.debug(`Deleted preference "${key}" for user ${userId}`);
|
||||
}
|
||||
}
|
||||
131
apps/gateway/src/preferences/system-override.service.ts
Normal file
131
apps/gateway/src/preferences/system-override.service.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
||||
|
||||
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
|
||||
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
|
||||
`mosaic:session:${sessionId}:system:fragments`;
|
||||
const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days
|
||||
|
||||
interface OverrideFragment {
|
||||
text: string;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SystemOverrideService {
|
||||
private readonly logger = new Logger(SystemOverrideService.name);
|
||||
private readonly handle: QueueHandle;
|
||||
|
||||
constructor() {
|
||||
this.handle = createQueue();
|
||||
}
|
||||
|
||||
async set(sessionId: string, override: string): Promise<void> {
|
||||
// Load existing fragments
|
||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
|
||||
const fragments: OverrideFragment[] = existing
|
||||
? (JSON.parse(existing) as OverrideFragment[])
|
||||
: [];
|
||||
|
||||
// Append new fragment
|
||||
fragments.push({ text: override, addedAt: Date.now() });
|
||||
|
||||
// Condense fragments into one coherent override
|
||||
const texts = fragments.map((f) => f.text);
|
||||
const condensed = await this.condenseOverrides(texts);
|
||||
|
||||
// Store both: fragments array and condensed result
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.setex(
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||
SYSTEM_OVERRIDE_TTL_SECONDS,
|
||||
JSON.stringify(fragments),
|
||||
);
|
||||
pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
|
||||
await pipeline.exec();
|
||||
|
||||
this.logger.debug(
|
||||
`Set system override for session ${sessionId} (${fragments.length} fragment(s), TTL=${SYSTEM_OVERRIDE_TTL_SECONDS}s)`,
|
||||
);
|
||||
}
|
||||
|
||||
async get(sessionId: string): Promise<string | null> {
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
|
||||
}
|
||||
|
||||
async renew(sessionId: string): Promise<void> {
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async clear(sessionId: string): Promise<void> {
|
||||
await this.handle.redis.del(
|
||||
SESSION_SYSTEM_KEY(sessionId),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||
);
|
||||
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an array of override fragments into one coherent string.
|
||||
* If only one fragment exists, returns it as-is.
|
||||
* For multiple fragments, calls Haiku to produce a merged instruction.
|
||||
* Falls back to newline concatenation if the LLM call fails.
|
||||
*/
|
||||
async condenseOverrides(fragments: string[]): Promise<string> {
|
||||
if (fragments.length === 0) return '';
|
||||
if (fragments.length === 1) return fragments[0]!;
|
||||
|
||||
const numbered = fragments.map((f, i) => `${i + 1}. ${f}`).join('\n');
|
||||
const prompt =
|
||||
`Merge these system prompt instructions into one coherent paragraph. ` +
|
||||
`If instructions conflict, favor the most recently added (last in the list). ` +
|
||||
`Be concise — output only the merged instruction, nothing else.\n\n` +
|
||||
`Instructions (oldest first):\n${numbered}`;
|
||||
|
||||
const apiKey = process.env['ANTHROPIC_API_KEY'];
|
||||
if (!apiKey) {
|
||||
this.logger.warn('ANTHROPIC_API_KEY not set — falling back to newline concatenation');
|
||||
return fragments.join('\n');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Anthropic API error ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
};
|
||||
|
||||
const textBlock = data.content.find((c) => c.type === 'text');
|
||||
if (!textBlock) {
|
||||
throw new Error('No text block in Anthropic response');
|
||||
}
|
||||
|
||||
return textBlock.text.trim();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Condensation LLM call failed — falling back to newline concatenation: ${String(err)}`,
|
||||
);
|
||||
return fragments.join('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
@@ -16,22 +17,25 @@ import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { assertOwner } from '../auth/resource-ownership.js';
|
||||
import { TeamsService } from '../workspace/teams.service.js';
|
||||
import { CreateProjectDto, UpdateProjectDto } from './projects.dto.js';
|
||||
|
||||
@Controller('api/projects')
|
||||
@UseGuards(AuthGuard)
|
||||
export class ProjectsController {
|
||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||
constructor(
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
private readonly teamsService: TeamsService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.brain.projects.findAll();
|
||||
async list(@CurrentUser() user: { id: string }) {
|
||||
return this.brain.projects.findAllForUser(user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
return this.getOwnedProject(id, user.id);
|
||||
return this.getAccessibleProject(id, user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -50,7 +54,7 @@ export class ProjectsController {
|
||||
@Body() dto: UpdateProjectDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
await this.getOwnedProject(id, user.id);
|
||||
await this.getAccessibleProject(id, user.id);
|
||||
const project = await this.brain.projects.update(id, dto);
|
||||
if (!project) throw new NotFoundException('Project not found');
|
||||
return project;
|
||||
@@ -59,15 +63,21 @@ export class ProjectsController {
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
await this.getOwnedProject(id, user.id);
|
||||
await this.getAccessibleProject(id, user.id);
|
||||
const deleted = await this.brain.projects.remove(id);
|
||||
if (!deleted) throw new NotFoundException('Project not found');
|
||||
}
|
||||
|
||||
private async getOwnedProject(id: string, userId: string) {
|
||||
/**
|
||||
* Verify the requesting user can access the project — either as the direct
|
||||
* owner or as a member of the owning team. Throws NotFoundException when the
|
||||
* project does not exist and ForbiddenException when the user lacks access.
|
||||
*/
|
||||
private async getAccessibleProject(id: string, userId: string) {
|
||||
const project = await this.brain.projects.findById(id);
|
||||
if (!project) throw new NotFoundException('Project not found');
|
||||
assertOwner(project.ownerId, userId, 'Project');
|
||||
const canAccess = await this.teamsService.canAccessProject(userId, id);
|
||||
if (!canAccess) throw new ForbiddenException('Project does not belong to the current user');
|
||||
return project;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ProjectsController } from './projects.controller.js';
|
||||
import { WorkspaceModule } from '../workspace/workspace.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [WorkspaceModule],
|
||||
controllers: [ProjectsController],
|
||||
})
|
||||
export class ProjectsModule {}
|
||||
|
||||
20
apps/gateway/src/reload/mosaic-plugin.interface.ts
Normal file
20
apps/gateway/src/reload/mosaic-plugin.interface.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface MosaicPlugin {
|
||||
/** Called when the plugin is loaded/reloaded */
|
||||
onLoad(): Promise<void>;
|
||||
|
||||
/** Called before the plugin is unloaded during reload */
|
||||
onUnload(): Promise<void>;
|
||||
|
||||
/** Plugin identifier for registry */
|
||||
readonly pluginName: string;
|
||||
}
|
||||
|
||||
export function isMosaicPlugin(obj: unknown): obj is MosaicPlugin {
|
||||
return (
|
||||
typeof obj === 'object' &&
|
||||
obj !== null &&
|
||||
typeof (obj as MosaicPlugin).onLoad === 'function' &&
|
||||
typeof (obj as MosaicPlugin).onUnload === 'function' &&
|
||||
typeof (obj as MosaicPlugin).pluginName === 'string'
|
||||
);
|
||||
}
|
||||
22
apps/gateway/src/reload/reload.controller.ts
Normal file
22
apps/gateway/src/reload/reload.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Controller, HttpCode, HttpStatus, Inject, Post, UseGuards } from '@nestjs/common';
|
||||
import type { SystemReloadPayload } from '@mosaic/types';
|
||||
import { AdminGuard } from '../admin/admin.guard.js';
|
||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||
import { ReloadService } from './reload.service.js';
|
||||
|
||||
@Controller('api/admin')
|
||||
@UseGuards(AdminGuard)
|
||||
export class ReloadController {
|
||||
constructor(
|
||||
@Inject(ReloadService) private readonly reloadService: ReloadService,
|
||||
@Inject(ChatGateway) private readonly chatGateway: ChatGateway,
|
||||
) {}
|
||||
|
||||
@Post('reload')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async triggerReload(): Promise<SystemReloadPayload> {
|
||||
const result = await this.reloadService.reload('rest');
|
||||
this.chatGateway.broadcastReload(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
14
apps/gateway/src/reload/reload.module.ts
Normal file
14
apps/gateway/src/reload/reload.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { AdminGuard } from '../admin/admin.guard.js';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { ReloadController } from './reload.controller.js';
|
||||
import { ReloadService } from './reload.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => CommandsModule), forwardRef(() => ChatModule)],
|
||||
controllers: [ReloadController],
|
||||
providers: [ReloadService, AdminGuard],
|
||||
exports: [ReloadService],
|
||||
})
|
||||
export class ReloadModule {}
|
||||
106
apps/gateway/src/reload/reload.service.spec.ts
Normal file
106
apps/gateway/src/reload/reload.service.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ReloadService } from './reload.service.js';
|
||||
|
||||
function createMockCommandRegistry() {
|
||||
return {
|
||||
getManifest: vi.fn().mockReturnValue({
|
||||
version: 1,
|
||||
commands: [],
|
||||
skills: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createService() {
|
||||
const registry = createMockCommandRegistry();
|
||||
const service = new ReloadService(registry as never);
|
||||
return { service, registry };
|
||||
}
|
||||
|
||||
describe('ReloadService', () => {
|
||||
it('reload() calls onUnload then onLoad for registered MosaicPlugin', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
const callOrder: string[] = [];
|
||||
const mockPlugin = {
|
||||
pluginName: 'test-plugin',
|
||||
onLoad: vi.fn().mockImplementation(() => {
|
||||
callOrder.push('onLoad');
|
||||
return Promise.resolve();
|
||||
}),
|
||||
onUnload: vi.fn().mockImplementation(() => {
|
||||
callOrder.push('onUnload');
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
|
||||
service.registerPlugin('test-plugin', mockPlugin);
|
||||
const result = await service.reload('command');
|
||||
|
||||
expect(mockPlugin.onUnload).toHaveBeenCalledOnce();
|
||||
expect(mockPlugin.onLoad).toHaveBeenCalledOnce();
|
||||
expect(callOrder).toEqual(['onUnload', 'onLoad']);
|
||||
expect(result.message).toContain('test-plugin');
|
||||
});
|
||||
|
||||
it('reload() continues if one plugin throws during onUnload', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
const badPlugin = {
|
||||
pluginName: 'bad-plugin',
|
||||
onLoad: vi.fn().mockResolvedValue(undefined),
|
||||
onUnload: vi.fn().mockRejectedValue(new Error('unload failed')),
|
||||
};
|
||||
|
||||
service.registerPlugin('bad-plugin', badPlugin);
|
||||
const result = await service.reload('command');
|
||||
|
||||
expect(result.message).toContain('bad-plugin');
|
||||
expect(result.message).toContain('unload failed');
|
||||
});
|
||||
|
||||
it('reload() skips non-MosaicPlugin objects', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
const notAPlugin = { foo: 'bar' };
|
||||
service.registerPlugin('not-a-plugin', notAPlugin);
|
||||
|
||||
// Should not throw
|
||||
const result = await service.reload('command');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.message).not.toContain('not-a-plugin');
|
||||
});
|
||||
|
||||
it('reload() returns SystemReloadPayload with commands, skills, providers, message', async () => {
|
||||
const { service, registry } = createService();
|
||||
registry.getManifest.mockReturnValue({
|
||||
version: 1,
|
||||
commands: [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'test cmd',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
});
|
||||
|
||||
const result = await service.reload('rest');
|
||||
|
||||
expect(result).toHaveProperty('commands');
|
||||
expect(result).toHaveProperty('skills');
|
||||
expect(result).toHaveProperty('providers');
|
||||
expect(result).toHaveProperty('message');
|
||||
expect(result.commands).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('registerPlugin() logs plugin registration', () => {
|
||||
const { service } = createService();
|
||||
|
||||
// Should not throw and should register
|
||||
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
92
apps/gateway/src/reload/reload.service.ts
Normal file
92
apps/gateway/src/reload/reload.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnApplicationBootstrap,
|
||||
type OnApplicationShutdown,
|
||||
} from '@nestjs/common';
|
||||
import type { SystemReloadPayload } from '@mosaic/types';
|
||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||
import { isMosaicPlugin } from './mosaic-plugin.interface.js';
|
||||
|
||||
@Injectable()
|
||||
export class ReloadService implements OnApplicationBootstrap, OnApplicationShutdown {
|
||||
private readonly logger = new Logger(ReloadService.name);
|
||||
private readonly plugins: Map<string, unknown> = new Map();
|
||||
private shutdownHandlerAttached = false;
|
||||
|
||||
constructor(
|
||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
if (!this.shutdownHandlerAttached) {
|
||||
process.on('SIGHUP', () => {
|
||||
this.logger.log('SIGHUP received — triggering soft reload');
|
||||
this.reload('sighup').catch((err: unknown) => {
|
||||
this.logger.error(`SIGHUP reload failed: ${err}`);
|
||||
});
|
||||
});
|
||||
this.shutdownHandlerAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
onApplicationShutdown(): void {
|
||||
process.removeAllListeners('SIGHUP');
|
||||
}
|
||||
|
||||
registerPlugin(name: string, plugin: unknown): void {
|
||||
this.plugins.set(name, plugin);
|
||||
this.logger.log(`Plugin registered: ${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft reload — unload plugins, reload plugins, broadcast.
|
||||
* Does NOT restart the HTTP server or drop connections.
|
||||
*/
|
||||
async reload(
|
||||
trigger: 'command' | 'rest' | 'sighup' | 'file-watch',
|
||||
): Promise<SystemReloadPayload> {
|
||||
this.logger.log(`Soft reload triggered by: ${trigger}`);
|
||||
const reloaded: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// 1. Unload all registered MosaicPlugin instances
|
||||
for (const [name, plugin] of this.plugins) {
|
||||
if (isMosaicPlugin(plugin)) {
|
||||
try {
|
||||
await plugin.onUnload();
|
||||
reloaded.push(name);
|
||||
} catch (err) {
|
||||
errors.push(`${name}: unload failed — ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Reload all MosaicPlugin instances
|
||||
for (const [name, plugin] of this.plugins) {
|
||||
if (isMosaicPlugin(plugin)) {
|
||||
try {
|
||||
await plugin.onLoad();
|
||||
} catch (err) {
|
||||
errors.push(`${name}: load failed — ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = this.commandRegistry.getManifest();
|
||||
|
||||
const errorSuffix = errors.length > 0 ? ` Errors: ${errors.join(', ')}` : '';
|
||||
const payload: SystemReloadPayload = {
|
||||
commands: manifest.commands,
|
||||
skills: manifest.skills,
|
||||
providers: [],
|
||||
message: `Reload complete (trigger=${trigger}). Plugins reloaded: [${reloaded.join(', ')}].${errorSuffix}`,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Reload complete. Reloaded: [${reloaded.join(', ')}]. Errors: ${errors.length}`,
|
||||
);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
98
apps/gateway/src/workspace/project-bootstrap.service.ts
Normal file
98
apps/gateway/src/workspace/project-bootstrap.service.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { PluginService } from '../plugin/plugin.service.js';
|
||||
import { WorkspaceService } from './workspace.service.js';
|
||||
|
||||
export interface BootstrapProjectParams {
|
||||
name: string;
|
||||
description?: string;
|
||||
userId: string;
|
||||
teamId?: string;
|
||||
repoUrl?: string;
|
||||
}
|
||||
|
||||
export interface BootstrapProjectResult {
|
||||
projectId: string;
|
||||
workspacePath: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProjectBootstrapService {
|
||||
private readonly logger = new Logger(ProjectBootstrapService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
private readonly workspace: WorkspaceService,
|
||||
private readonly pluginService: PluginService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Bootstrap a new project: create DB record + workspace directory.
|
||||
* Returns the created project with its workspace path.
|
||||
*/
|
||||
async bootstrap(params: BootstrapProjectParams): Promise<BootstrapProjectResult> {
|
||||
const ownerType: 'user' | 'team' = params.teamId ? 'team' : 'user';
|
||||
|
||||
this.logger.log(
|
||||
`Bootstrapping project "${params.name}" for ${ownerType} ${params.teamId ?? params.userId}`,
|
||||
);
|
||||
|
||||
// 1. Create DB record
|
||||
const project = await this.brain.projects.create({
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
ownerId: params.userId,
|
||||
teamId: params.teamId ?? null,
|
||||
ownerType,
|
||||
});
|
||||
|
||||
// 2. Create workspace directory (includes docs structure)
|
||||
const workspacePath = await this.workspace.create(
|
||||
{
|
||||
id: project.id,
|
||||
ownerType,
|
||||
userId: params.userId,
|
||||
teamId: params.teamId ?? null,
|
||||
},
|
||||
params.repoUrl,
|
||||
);
|
||||
|
||||
// 3. Create default agent config for the project
|
||||
await this.brain.agents.create({
|
||||
name: 'default',
|
||||
provider: '',
|
||||
model: '',
|
||||
projectId: project.id,
|
||||
ownerId: params.userId,
|
||||
isSystem: false,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// 4. Notify plugins so they can set up project-specific resources (e.g. Discord channel)
|
||||
try {
|
||||
for (const plugin of this.pluginService.getPlugins()) {
|
||||
if (plugin.onProjectCreated) {
|
||||
const result = await plugin.onProjectCreated({
|
||||
id: project.id,
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
});
|
||||
if (result?.channelId) {
|
||||
await this.brain.projects.update(project.id, {
|
||||
metadata: { discordChannelId: result.channelId },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Plugin project notification failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Project ${project.id} bootstrapped at ${workspacePath}`);
|
||||
|
||||
return { projectId: project.id, workspacePath };
|
||||
}
|
||||
}
|
||||
30
apps/gateway/src/workspace/teams.controller.ts
Normal file
30
apps/gateway/src/workspace/teams.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
@Controller('api/teams')
|
||||
@UseGuards(AuthGuard)
|
||||
export class TeamsController {
|
||||
constructor(private readonly teams: TeamsService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.teams.findAll();
|
||||
}
|
||||
|
||||
@Get(':teamId')
|
||||
async findOne(@Param('teamId') teamId: string) {
|
||||
return this.teams.findById(teamId);
|
||||
}
|
||||
|
||||
@Get(':teamId/members')
|
||||
async listMembers(@Param('teamId') teamId: string) {
|
||||
return this.teams.listMembers(teamId);
|
||||
}
|
||||
|
||||
@Get(':teamId/members/:userId')
|
||||
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||
const isMember = await this.teams.isMember(teamId, userId);
|
||||
return { isMember };
|
||||
}
|
||||
}
|
||||
73
apps/gateway/src/workspace/teams.service.ts
Normal file
73
apps/gateway/src/workspace/teams.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaic/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
export class TeamsService {
|
||||
private readonly logger = new Logger(TeamsService.name);
|
||||
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/**
|
||||
* Check if a user is a member of a team.
|
||||
*/
|
||||
async isMember(teamId: string, userId: string): Promise<boolean> {
|
||||
const rows = await this.db
|
||||
.select({ id: teamMembers.id })
|
||||
.from(teamMembers)
|
||||
.where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId)));
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check project access for a user.
|
||||
* - ownerType === 'user': project.ownerId must equal userId
|
||||
* - ownerType === 'team': userId must be a member of project.teamId
|
||||
*/
|
||||
async canAccessProject(userId: string, projectId: string): Promise<boolean> {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: projects.id,
|
||||
ownerType: projects.ownerType,
|
||||
ownerId: projects.ownerId,
|
||||
teamId: projects.teamId,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId));
|
||||
|
||||
const project = rows[0];
|
||||
if (!project) return false;
|
||||
|
||||
if (project.ownerType === 'user') {
|
||||
return project.ownerId === userId;
|
||||
}
|
||||
|
||||
if (project.ownerType === 'team' && project.teamId) {
|
||||
return this.isMember(project.teamId, userId);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all teams (for admin/listing endpoints).
|
||||
*/
|
||||
async findAll() {
|
||||
return this.db.select().from(teams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a team by ID.
|
||||
*/
|
||||
async findById(id: string) {
|
||||
const rows = await this.db.select().from(teams).where(eq(teams.id, id));
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* List members of a team.
|
||||
*/
|
||||
async listMembers(teamId: string) {
|
||||
return this.db.select().from(teamMembers).where(eq(teamMembers.teamId, teamId));
|
||||
}
|
||||
}
|
||||
30
apps/gateway/src/workspace/workspace.controller.ts
Normal file
30
apps/gateway/src/workspace/workspace.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||
|
||||
@Controller('api/workspaces')
|
||||
@UseGuards(AuthGuard)
|
||||
export class WorkspaceController {
|
||||
constructor(private readonly bootstrap: ProjectBootstrapService) {}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body()
|
||||
body: {
|
||||
name: string;
|
||||
description?: string;
|
||||
teamId?: string;
|
||||
repoUrl?: string;
|
||||
},
|
||||
) {
|
||||
return this.bootstrap.bootstrap({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
userId: user.id,
|
||||
teamId: body.teamId,
|
||||
repoUrl: body.repoUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
13
apps/gateway/src/workspace/workspace.module.ts
Normal file
13
apps/gateway/src/workspace/workspace.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WorkspaceService } from './workspace.service.js';
|
||||
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
import { WorkspaceController } from './workspace.controller.js';
|
||||
import { TeamsController } from './teams.controller.js';
|
||||
|
||||
@Module({
|
||||
controllers: [WorkspaceController, TeamsController],
|
||||
providers: [WorkspaceService, ProjectBootstrapService, TeamsService],
|
||||
exports: [WorkspaceService, ProjectBootstrapService, TeamsService],
|
||||
})
|
||||
export class WorkspaceModule {}
|
||||
79
apps/gateway/src/workspace/workspace.service.spec.ts
Normal file
79
apps/gateway/src/workspace/workspace.service.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { WorkspaceService } from './workspace.service.js';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('WorkspaceService', () => {
|
||||
let service: WorkspaceService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new WorkspaceService();
|
||||
});
|
||||
|
||||
describe('resolvePath', () => {
|
||||
it('resolves user workspace path', () => {
|
||||
const result = service.resolvePath({
|
||||
id: 'proj1',
|
||||
ownerType: 'user',
|
||||
userId: 'user1',
|
||||
teamId: null,
|
||||
});
|
||||
expect(result).toContain(path.join('users', 'user1', 'proj1'));
|
||||
});
|
||||
|
||||
it('resolves team workspace path', () => {
|
||||
const result = service.resolvePath({
|
||||
id: 'proj1',
|
||||
ownerType: 'team',
|
||||
userId: 'user1',
|
||||
teamId: 'team1',
|
||||
});
|
||||
expect(result).toContain(path.join('teams', 'team1', 'proj1'));
|
||||
});
|
||||
|
||||
it('falls back to user path when ownerType is team but teamId is null', () => {
|
||||
const result = service.resolvePath({
|
||||
id: 'proj1',
|
||||
ownerType: 'team',
|
||||
userId: 'user1',
|
||||
teamId: null,
|
||||
});
|
||||
expect(result).toContain(path.join('users', 'user1', 'proj1'));
|
||||
});
|
||||
|
||||
it('uses MOSAIC_ROOT env var as the base path', () => {
|
||||
const originalRoot = process.env['MOSAIC_ROOT'];
|
||||
process.env['MOSAIC_ROOT'] = '/custom/root';
|
||||
const customService = new WorkspaceService();
|
||||
const result = customService.resolvePath({
|
||||
id: 'proj1',
|
||||
ownerType: 'user',
|
||||
userId: 'user1',
|
||||
teamId: null,
|
||||
});
|
||||
expect(result).toMatch(/^\/custom\/root/);
|
||||
// Restore
|
||||
if (originalRoot === undefined) {
|
||||
delete process.env['MOSAIC_ROOT'];
|
||||
} else {
|
||||
process.env['MOSAIC_ROOT'] = originalRoot;
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to /opt/mosaic when MOSAIC_ROOT is unset', () => {
|
||||
const originalRoot = process.env['MOSAIC_ROOT'];
|
||||
delete process.env['MOSAIC_ROOT'];
|
||||
const defaultService = new WorkspaceService();
|
||||
const result = defaultService.resolvePath({
|
||||
id: 'proj2',
|
||||
ownerType: 'user',
|
||||
userId: 'user2',
|
||||
teamId: null,
|
||||
});
|
||||
expect(result).toMatch(/^\/opt\/mosaic/);
|
||||
// Restore
|
||||
if (originalRoot !== undefined) {
|
||||
process.env['MOSAIC_ROOT'] = originalRoot;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
116
apps/gateway/src/workspace/workspace.service.ts
Normal file
116
apps/gateway/src/workspace/workspace.service.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface WorkspaceProject {
|
||||
id: string;
|
||||
ownerType: 'user' | 'team';
|
||||
userId: string;
|
||||
teamId: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceService {
|
||||
private readonly logger = new Logger(WorkspaceService.name);
|
||||
private readonly mosaicRoot: string;
|
||||
|
||||
constructor() {
|
||||
this.mosaicRoot = process.env['MOSAIC_ROOT'] ?? '/opt/mosaic';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the workspace path for a project.
|
||||
* Solo: $MOSAIC_ROOT/.workspaces/users/<userId>/<projectId>/
|
||||
* Team: $MOSAIC_ROOT/.workspaces/teams/<teamId>/<projectId>/
|
||||
*/
|
||||
resolvePath(project: WorkspaceProject): string {
|
||||
if (project.ownerType === 'team' && project.teamId) {
|
||||
return path.join(this.mosaicRoot, '.workspaces', 'teams', project.teamId, project.id);
|
||||
}
|
||||
return path.join(this.mosaicRoot, '.workspaces', 'users', project.userId, project.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workspace directory and initialize it as a git repo.
|
||||
* If repoUrl is provided, clone instead of init.
|
||||
*/
|
||||
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
||||
const workspacePath = this.resolvePath(project);
|
||||
|
||||
// Create directory
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
if (repoUrl) {
|
||||
// Clone existing repo
|
||||
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
||||
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
||||
} else {
|
||||
// Init new git repo
|
||||
await execFileAsync('git', ['init'], { cwd: workspacePath });
|
||||
await execFileAsync('git', ['commit', '--allow-empty', '-m', 'Initial workspace commit'], {
|
||||
cwd: workspacePath,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Mosaic',
|
||||
GIT_AUTHOR_EMAIL: 'mosaic@localhost',
|
||||
GIT_COMMITTER_NAME: 'Mosaic',
|
||||
GIT_COMMITTER_EMAIL: 'mosaic@localhost',
|
||||
},
|
||||
});
|
||||
this.logger.log(`Initialized git workspace at ${workspacePath}`);
|
||||
}
|
||||
|
||||
// Create standard docs structure
|
||||
await fs.mkdir(path.join(workspacePath, 'docs', 'plans'), { recursive: true });
|
||||
await fs.mkdir(path.join(workspacePath, 'docs', 'reports'), { recursive: true });
|
||||
this.logger.log(`Created docs structure at ${workspacePath}`);
|
||||
|
||||
return workspacePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a workspace directory recursively.
|
||||
*/
|
||||
async delete(project: WorkspaceProject): Promise<void> {
|
||||
const workspacePath = this.resolvePath(project);
|
||||
try {
|
||||
await fs.rm(workspacePath, { recursive: true, force: true });
|
||||
this.logger.log(`Deleted workspace at ${workspacePath}`);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to delete workspace at ${workspacePath}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the workspace directory exists.
|
||||
*/
|
||||
async exists(project: WorkspaceProject): Promise<boolean> {
|
||||
const workspacePath = this.resolvePath(project);
|
||||
try {
|
||||
await fs.access(workspacePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the base user workspace directory (call on user registration).
|
||||
*/
|
||||
async createUserRoot(userId: string): Promise<void> {
|
||||
const userRoot = path.join(this.mosaicRoot, '.workspaces', 'users', userId);
|
||||
await fs.mkdir(userRoot, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the base team workspace directory (call on team creation).
|
||||
*/
|
||||
async createTeamRoot(teamId: string): Promise<void> {
|
||||
const teamRoot = path.join(this.mosaicRoot, '.workspaces', 'teams', teamId);
|
||||
await fs.mkdir(teamRoot, { recursive: true });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,30 @@ import type { NextConfig } from 'next';
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
transpilePackages: ['@mosaic/design-tokens'],
|
||||
|
||||
// Enable gzip/brotli compression for all responses.
|
||||
compress: true,
|
||||
|
||||
// Reduce bundle size: disable source maps in production builds.
|
||||
productionBrowserSourceMaps: false,
|
||||
|
||||
// Image optimisation: allow the gateway origin as an external image source.
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Experimental: enable React compiler for automatic memoisation (Next 15+).
|
||||
// Falls back gracefully if the compiler plugin is not installed.
|
||||
experimental: {
|
||||
// Turbopack is the default in dev for Next 15; keep it opt-in for now.
|
||||
// turbo: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
@@ -27,6 +28,7 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import { getEnabledSsoProviders } from '@/lib/sso-providers';
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ssoProviders = getEnabledSsoProviders();
|
||||
const hasSsoProviders = ssoProviders.length > 0;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||
e.preventDefault();
|
||||
@@ -44,7 +47,26 @@ export default function LoginPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
{hasSsoProviders && (
|
||||
<div className="mt-6 space-y-3">
|
||||
{ssoProviders.map((provider) => (
|
||||
<Link
|
||||
key={provider.id}
|
||||
href={provider.href}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card"
|
||||
>
|
||||
{provider.buttonLabel}
|
||||
</Link>
|
||||
))}
|
||||
<div className="relative flex items-center">
|
||||
<div className="flex-1 border-t border-surface-border" />
|
||||
<span className="mx-3 text-xs text-text-muted">or</span>
|
||||
<div className="flex-1 border-t border-surface-border" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className={hasSsoProviders ? 'space-y-4' : 'mt-6 space-y-4'} onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
|
||||
@@ -4,18 +4,42 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { destroySocket, getSocket } from '@/lib/socket';
|
||||
import type { Conversation, Message } from '@/lib/types';
|
||||
import { ConversationList } from '@/components/chat/conversation-list';
|
||||
import {
|
||||
ConversationSidebar,
|
||||
type ConversationSidebarRef,
|
||||
} from '@/components/chat/conversation-sidebar';
|
||||
import { MessageBubble } from '@/components/chat/message-bubble';
|
||||
import { ChatInput } from '@/components/chat/chat-input';
|
||||
import { StreamingMessage } from '@/components/chat/streaming-message';
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
inputTypes: ('text' | 'image')[];
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
}
|
||||
|
||||
interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
export default function ChatPage(): React.ReactElement {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModelId, setSelectedModelId] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const sidebarRef = useRef<ConversationSidebarRef>(null);
|
||||
|
||||
// Track the active conversation ID in a ref so socket event handlers always
|
||||
// see the current value without needing to be re-registered.
|
||||
@@ -26,11 +50,30 @@ export default function ChatPage(): React.ReactElement {
|
||||
// without stale-closure issues.
|
||||
const streamingTextRef = useRef('');
|
||||
|
||||
// Load conversations on mount
|
||||
useEffect(() => {
|
||||
api<Conversation[]>('/api/conversations')
|
||||
.then(setConversations)
|
||||
.catch(() => {});
|
||||
const savedState = window.localStorage.getItem('mosaic-sidebar-open');
|
||||
if (savedState !== null) {
|
||||
setIsSidebarOpen(savedState === 'true');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem('mosaic-sidebar-open', String(isSidebarOpen));
|
||||
}, [isSidebarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
api<ProviderInfo[]>('/api/providers')
|
||||
.then((providers) => {
|
||||
const availableModels = providers
|
||||
.filter((provider) => provider.available)
|
||||
.flatMap((provider) => provider.models);
|
||||
setModels(availableModels);
|
||||
setSelectedModelId((current) => current || availableModels[0]?.id || '');
|
||||
})
|
||||
.catch(() => {
|
||||
setModels([]);
|
||||
setSelectedModelId('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load messages when active conversation changes
|
||||
@@ -91,6 +134,7 @@ export default function ChatPage(): React.ReactElement {
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
sidebarRef.current?.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,54 +175,27 @@ export default function ChatPage(): React.ReactElement {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNewConversation = useCallback(async () => {
|
||||
const handleNewConversation = useCallback(async (projectId?: string | null) => {
|
||||
const conv = await api<Conversation>('/api/conversations', {
|
||||
method: 'POST',
|
||||
body: { title: 'New conversation' },
|
||||
body: { title: 'New conversation', projectId: projectId ?? null },
|
||||
});
|
||||
setConversations((prev) => [conv, ...prev]);
|
||||
|
||||
sidebarRef.current?.addConversation({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
projectId: conv.projectId,
|
||||
updatedAt: conv.updatedAt,
|
||||
archived: conv.archived,
|
||||
});
|
||||
|
||||
setActiveId(conv.id);
|
||||
setMessages([]);
|
||||
setIsSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRename = useCallback(async (id: string, title: string) => {
|
||||
const updated = await api<Conversation>(`/api/conversations/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: { title },
|
||||
});
|
||||
setConversations((prev) => prev.map((c) => (c.id === id ? updated : c)));
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
await api<void>(`/api/conversations/${id}`, { method: 'DELETE' });
|
||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||
if (activeId === id) {
|
||||
setActiveId(null);
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[activeId],
|
||||
);
|
||||
|
||||
const handleArchive = useCallback(
|
||||
async (id: string, archived: boolean) => {
|
||||
const updated = await api<Conversation>(`/api/conversations/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: { archived },
|
||||
});
|
||||
setConversations((prev) => prev.map((c) => (c.id === id ? updated : c)));
|
||||
// If archiving the active conversation, deselect it
|
||||
if (archived && activeId === id) {
|
||||
setActiveId(null);
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[activeId],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (content: string) => {
|
||||
async (content: string, options?: { modelId?: string }) => {
|
||||
let convId = activeId;
|
||||
|
||||
// Auto-create conversation if none selected
|
||||
@@ -188,25 +205,24 @@ export default function ChatPage(): React.ReactElement {
|
||||
method: 'POST',
|
||||
body: { title: autoTitle },
|
||||
});
|
||||
setConversations((prev) => [conv, ...prev]);
|
||||
sidebarRef.current?.addConversation({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
projectId: conv.projectId,
|
||||
updatedAt: conv.updatedAt,
|
||||
archived: conv.archived,
|
||||
});
|
||||
setActiveId(conv.id);
|
||||
convId = conv.id;
|
||||
} else {
|
||||
// Auto-title: if the active conversation still has the default "New
|
||||
// conversation" title and this is the first message, update the title
|
||||
// from the message content.
|
||||
const activeConv = conversations.find((c) => c.id === convId);
|
||||
if (activeConv?.title === 'New conversation' && messages.length === 0) {
|
||||
const autoTitle = content.slice(0, 60);
|
||||
api<Conversation>(`/api/conversations/${convId}`, {
|
||||
method: 'PATCH',
|
||||
body: { title: autoTitle },
|
||||
})
|
||||
.then((updated) => {
|
||||
setConversations((prev) => prev.map((c) => (c.id === convId ? updated : c)));
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
} else if (messages.length === 0) {
|
||||
// Auto-title the initial placeholder conversation from the first user message.
|
||||
const autoTitle = content.slice(0, 60);
|
||||
api<Conversation>(`/api/conversations/${convId}`, {
|
||||
method: 'PATCH',
|
||||
body: { title: autoTitle },
|
||||
})
|
||||
.then(() => sidebarRef.current?.refresh())
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Optimistic user message in local UI state
|
||||
@@ -237,24 +253,67 @@ export default function ChatPage(): React.ReactElement {
|
||||
if (!socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
socket.emit('message', { conversationId: convId, content });
|
||||
socket.emit('message', {
|
||||
conversationId: convId,
|
||||
content,
|
||||
modelId: (options?.modelId ?? selectedModelId) || undefined,
|
||||
});
|
||||
},
|
||||
[activeId, conversations, messages],
|
||||
[activeId, messages, selectedModelId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="-m-6 flex h-[calc(100vh-3.5rem)]">
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
activeId={activeId}
|
||||
onSelect={setActiveId}
|
||||
onNew={handleNewConversation}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
<div
|
||||
className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden"
|
||||
style={{ background: 'var(--bg-deep, var(--color-surface-bg, #0a0f1a))' }}
|
||||
>
|
||||
<ConversationSidebar
|
||||
ref={sidebarRef}
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
currentConversationId={activeId}
|
||||
onSelectConversation={(conversationId) => {
|
||||
setActiveId(conversationId);
|
||||
setMessages([]);
|
||||
if (conversationId && window.innerWidth < 768) {
|
||||
setIsSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onNewConversation={(projectId) => {
|
||||
void handleNewConversation(projectId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
className="flex items-center gap-3 border-b px-4 py-3"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSidebarOpen((open) => !open)}
|
||||
className="rounded-lg border p-2 transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
aria-label={isSidebarOpen ? 'Close conversation sidebar' : 'Open conversation sidebar'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
||||
<path strokeWidth="2" strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||
Mosaic Chat
|
||||
</h1>
|
||||
<p className="text-xs" style={{ color: 'var(--muted)' }}>
|
||||
{activeId ? 'Active conversation selected' : 'Choose or start a conversation'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeId ? (
|
||||
<>
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
||||
@@ -264,19 +323,36 @@ export default function ChatPage(): React.ReactElement {
|
||||
{isStreaming && <StreamingMessage text={streamingText} />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<ChatInput onSend={handleSend} disabled={isStreaming} />
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
isStreaming={isStreaming}
|
||||
models={models}
|
||||
selectedModelId={selectedModelId}
|
||||
onModelChange={setSelectedModelId}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-lg font-medium text-text-secondary">Welcome to Mosaic Chat</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
<div className="flex flex-1 items-center justify-center px-6">
|
||||
<div
|
||||
className="max-w-md rounded-2xl border px-8 py-10 text-center"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
background: 'var(--surface)',
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-medium" style={{ color: 'var(--text)' }}>
|
||||
Welcome to Mosaic Chat
|
||||
</h2>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
|
||||
Select a conversation or start a new one
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewConversation}
|
||||
className="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
void handleNewConversation();
|
||||
}}
|
||||
className="mt-4 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||
style={{ background: 'var(--primary)' }}
|
||||
>
|
||||
Start new conversation
|
||||
</button>
|
||||
|
||||
77
apps/web/src/app/auth/provider/[provider]/page.tsx
Normal file
77
apps/web/src/app/auth/provider/[provider]/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import { getSsoProvider } from '@/lib/sso-providers';
|
||||
|
||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||
const params = useParams<{ provider: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||
const provider = getSsoProvider(providerId);
|
||||
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentProvider = provider;
|
||||
|
||||
if (!currentProvider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentProvider.enabled) {
|
||||
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeProvider = currentProvider;
|
||||
let cancelled = false;
|
||||
|
||||
async function redirectToProvider(): Promise<void> {
|
||||
const result = await signIn.oauth2({
|
||||
providerId: activeProvider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
||||
}
|
||||
}
|
||||
|
||||
void redirectToProvider();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [callbackURL, provider]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{provider
|
||||
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
||||
: 'Preparing your sign-in request...'}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
||||
<p>{error}</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Return to login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
||||
If the redirect does not start automatically, return to the login page and try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,6 +86,22 @@
|
||||
--spacing-sidebar: 16rem;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: var(--color-surface-bg);
|
||||
--bg-deep: var(--color-gray-950);
|
||||
--surface: var(--color-surface-card);
|
||||
--surface-2: var(--color-surface-elevated);
|
||||
--border: var(--color-surface-border);
|
||||
--text: var(--color-text-primary);
|
||||
--text-2: var(--color-text-secondary);
|
||||
--muted: var(--color-text-muted);
|
||||
--primary: var(--color-blue-500);
|
||||
--danger: var(--color-error);
|
||||
--ms-blue-500: var(--color-blue-500);
|
||||
--sidebar-w: 260px;
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ─── Base styles ─── */
|
||||
body {
|
||||
background-color: var(--color-surface-bg);
|
||||
|
||||
@@ -1,52 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ModelInfo } from '@/lib/types';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void;
|
||||
disabled?: boolean;
|
||||
onSend: (content: string, options?: { modelId?: string }) => void;
|
||||
onStop?: () => void;
|
||||
isStreaming?: boolean;
|
||||
models: ModelInfo[];
|
||||
selectedModelId: string;
|
||||
onModelChange: (modelId: string) => void;
|
||||
onRequestEditLastMessage?: () => string | null;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled }: ChatInputProps): React.ReactElement {
|
||||
const MAX_HEIGHT = 220;
|
||||
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
onStop,
|
||||
isStreaming = false,
|
||||
models,
|
||||
selectedModelId,
|
||||
onModelChange,
|
||||
onRequestEditLastMessage,
|
||||
}: ChatInputProps): React.ReactElement {
|
||||
const [value, setValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const selectedModel = useMemo(
|
||||
() => models.find((model) => model.id === selectedModelId) ?? models[0],
|
||||
[models, selectedModelId],
|
||||
);
|
||||
|
||||
function handleSubmit(e: React.FormEvent): void {
|
||||
e.preventDefault();
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, MAX_HEIGHT)}px`;
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleGlobalFocus(event: KeyboardEvent): void {
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
(event.key === '/' || event.key.toLowerCase() === 'k')
|
||||
) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
|
||||
event.preventDefault();
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleGlobalFocus);
|
||||
return () => document.removeEventListener('keydown', handleGlobalFocus);
|
||||
}, []);
|
||||
|
||||
function handleSubmit(event: React.FormEvent): void {
|
||||
event.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
onSend(trimmed);
|
||||
if (!trimmed || isStreaming) return;
|
||||
onSend(trimmed, { modelId: selectedModel?.id });
|
||||
setValue('');
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
handleSubmit(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' && value.length === 0 && onRequestEditLastMessage) {
|
||||
const lastMessage = onRequestEditLastMessage();
|
||||
if (lastMessage) {
|
||||
event.preventDefault();
|
||||
setValue(lastMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const charCount = value.length;
|
||||
const tokenEstimate = Math.ceil(charCount / 4);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="border-t border-surface-border bg-surface-card p-4">
|
||||
<div className="flex items-end gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
placeholder="Type a message... (Enter to send, Shift+Enter for newline)"
|
||||
className="max-h-32 min-h-[2.5rem] flex-1 resize-none rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || !value.trim()}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="border-t px-4 py-4 backdrop-blur-xl md:px-6"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface) 88%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-[28px] border p-3 shadow-[var(--shadow-ms-lg)]"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-2)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||
<label className="flex min-w-0 items-center gap-2 text-xs text-[var(--color-muted)]">
|
||||
<span className="uppercase tracking-[0.18em]">Model</span>
|
||||
<select
|
||||
value={selectedModelId}
|
||||
onChange={(event) => onModelChange(event.target.value)}
|
||||
className="rounded-full border px-3 py-1.5 text-sm outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
{models.map((model) => (
|
||||
<option key={`${model.provider}:${model.id}`} value={model.id}>
|
||||
{model.name} · {model.provider}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="ml-auto hidden items-center gap-2 text-xs text-[var(--color-muted)] md:flex">
|
||||
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
|
||||
⌘/ focus
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
|
||||
⌘K focus
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
|
||||
⌘↵ send
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isStreaming}
|
||||
rows={1}
|
||||
placeholder="Ask Mosaic something..."
|
||||
className="min-h-[3.25rem] flex-1 resize-none bg-transparent px-1 py-2 text-sm outline-none placeholder:text-[var(--color-muted)] disabled:opacity-60"
|
||||
style={{
|
||||
color: 'var(--color-text)',
|
||||
maxHeight: `${MAX_HEIGHT}px`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{isStreaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-full border px-4 text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-[var(--color-danger)]" />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim()}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-full px-4 text-sm font-semibold text-white transition-all disabled:cursor-not-allowed disabled:opacity-45"
|
||||
style={{ backgroundColor: 'var(--color-ms-blue-500)' }}
|
||||
>
|
||||
<span>Send</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-[var(--color-muted)]">
|
||||
<span>{charCount.toLocaleString()} chars</span>
|
||||
<span>·</span>
|
||||
<span>~{tokenEstimate.toLocaleString()} tokens</span>
|
||||
{selectedModel ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{selectedModel.reasoning ? 'Reasoning on' : 'Fast response'}</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="ml-auto">Shift+Enter newline · Arrow ↑ edit last</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { Conversation } from '@/lib/types';
|
||||
interface ConversationListProps {
|
||||
conversations: Conversation[];
|
||||
activeId: string | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
onNew: () => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
@@ -20,7 +22,6 @@ interface ContextMenuState {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Format a date as relative time (e.g. "2h ago", "Yesterday"). */
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
@@ -40,6 +41,8 @@ function formatRelativeTime(dateStr: string): string {
|
||||
export function ConversationList({
|
||||
conversations,
|
||||
activeId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
onNew,
|
||||
onRename,
|
||||
@@ -54,24 +57,24 @@ export function ConversationList({
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const activeConversations = conversations.filter((c) => !c.archived);
|
||||
const archivedConversations = conversations.filter((c) => c.archived);
|
||||
const activeConversations = conversations.filter((conversation) => !conversation.archived);
|
||||
const archivedConversations = conversations.filter((conversation) => conversation.archived);
|
||||
|
||||
const filteredActive = searchQuery
|
||||
? activeConversations.filter((c) =>
|
||||
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
? activeConversations.filter((conversation) =>
|
||||
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: activeConversations;
|
||||
|
||||
const filteredArchived = searchQuery
|
||||
? archivedConversations.filter((c) =>
|
||||
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
? archivedConversations.filter((conversation) =>
|
||||
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: archivedConversations;
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, conversationId: string) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ conversationId, x: e.clientX, y: e.clientY });
|
||||
const handleContextMenu = useCallback((event: React.MouseEvent, conversationId: string) => {
|
||||
event.preventDefault();
|
||||
setContextMenu({ conversationId, x: event.clientX, y: event.clientY });
|
||||
setDeleteConfirmId(null);
|
||||
}, []);
|
||||
|
||||
@@ -97,7 +100,7 @@ export function ConversationList({
|
||||
}
|
||||
setRenamingId(null);
|
||||
setRenameValue('');
|
||||
}, [renamingId, renameValue, onRename]);
|
||||
}, [onRename, renameValue, renamingId]);
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setRenamingId(null);
|
||||
@@ -105,24 +108,20 @@ export function ConversationList({
|
||||
}, []);
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') commitRename();
|
||||
if (e.key === 'Escape') cancelRename();
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') commitRename();
|
||||
if (event.key === 'Escape') cancelRename();
|
||||
},
|
||||
[commitRename, cancelRename],
|
||||
[cancelRename, commitRename],
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((id: string) => {
|
||||
setDeleteConfirmId(id);
|
||||
}, []);
|
||||
|
||||
const confirmDelete = useCallback(
|
||||
(id: string) => {
|
||||
onDelete(id);
|
||||
setDeleteConfirmId(null);
|
||||
closeContextMenu();
|
||||
},
|
||||
[onDelete, closeContextMenu],
|
||||
[closeContextMenu, onDelete],
|
||||
);
|
||||
|
||||
const handleArchiveToggle = useCallback(
|
||||
@@ -130,47 +129,59 @@ export function ConversationList({
|
||||
onArchive(id, archived);
|
||||
closeContextMenu();
|
||||
},
|
||||
[onArchive, closeContextMenu],
|
||||
[closeContextMenu, onArchive],
|
||||
);
|
||||
|
||||
const contextConv = contextMenu
|
||||
? conversations.find((c) => c.id === contextMenu.conversationId)
|
||||
const contextConversation = contextMenu
|
||||
? conversations.find((conversation) => conversation.id === contextMenu.conversationId)
|
||||
: null;
|
||||
|
||||
function renderConversationItem(conv: Conversation): React.ReactElement {
|
||||
const isActive = activeId === conv.id;
|
||||
const isRenaming = renamingId === conv.id;
|
||||
function renderConversationItem(conversation: Conversation): React.ReactElement {
|
||||
const isActive = activeId === conversation.id;
|
||||
const isRenaming = renamingId === conversation.id;
|
||||
|
||||
return (
|
||||
<div key={conv.id} className="group relative">
|
||||
<div key={conversation.id} className="group relative">
|
||||
{isRenaming ? (
|
||||
<div className="px-3 py-2">
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onChange={(event) => setRenameValue(event.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
className="w-full rounded border border-blue-500 bg-surface-elevated px-2 py-0.5 text-sm text-text-primary outline-none"
|
||||
className="w-full rounded-xl border px-3 py-2 text-sm outline-none"
|
||||
style={{
|
||||
borderColor: 'var(--color-ms-blue-500)',
|
||||
backgroundColor: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(conv.id)}
|
||||
onDoubleClick={() => startRename(conv.id, conv.title)}
|
||||
onContextMenu={(e) => handleContextMenu(e, conv.id)}
|
||||
onClick={() => {
|
||||
onSelect(conversation.id);
|
||||
if (window.innerWidth < 768) onClose();
|
||||
}}
|
||||
onDoubleClick={() => startRename(conversation.id, conversation.title)}
|
||||
onContextMenu={(event) => handleContextMenu(event, conversation.id)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-600/20 text-blue-400'
|
||||
: 'text-text-secondary hover:bg-surface-elevated',
|
||||
'w-full rounded-2xl px-3 py-2 text-left text-sm transition-colors',
|
||||
isActive ? 'shadow-[var(--shadow-ms-sm)]' : 'hover:bg-white/5',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
? 'color-mix(in srgb, var(--color-ms-blue-500) 22%, transparent)'
|
||||
: 'transparent',
|
||||
color: isActive ? 'var(--color-text)' : 'var(--color-text-2)',
|
||||
}}
|
||||
>
|
||||
<span className="block truncate">{conv.title ?? 'Untitled'}</span>
|
||||
<span className="block text-xs text-text-muted">
|
||||
{formatRelativeTime(conv.updatedAt)}
|
||||
<span className="block truncate font-medium">{conversation.title ?? 'Untitled'}</span>
|
||||
<span className="block text-xs text-[var(--color-muted)]">
|
||||
{formatRelativeTime(conversation.updatedAt)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -180,127 +191,138 @@ export function ConversationList({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop to close context menu */}
|
||||
{contextMenu && (
|
||||
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
|
||||
)}
|
||||
{isOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-20 bg-black/45 md:hidden"
|
||||
onClick={onClose}
|
||||
aria-label="Close conversation sidebar"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex h-full w-64 flex-col border-r border-surface-border bg-surface-card">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<h2 className="text-sm font-medium text-text-secondary">Conversations</h2>
|
||||
{contextMenu ? (
|
||||
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-30 flex h-full w-[18.5rem] flex-col border-r px-3 py-3 transition-transform duration-200 md:static md:z-auto',
|
||||
isOpen
|
||||
? 'translate-x-0'
|
||||
: '-translate-x-full md:w-0 md:min-w-0 md:overflow-hidden md:border-r-0 md:px-0 md:py-0',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between px-1 pb-3">
|
||||
<h2 className="text-sm font-medium text-[var(--color-text-2)]">Conversations</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNew}
|
||||
className="rounded-md px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-surface-elevated"
|
||||
className="rounded-full px-3 py-1 text-xs transition-colors hover:bg-white/5"
|
||||
style={{ color: 'var(--color-ms-blue-400)' }}
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="px-3 pb-2">
|
||||
<div className="pb-3">
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search conversations\u2026"
|
||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-xs text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none"
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search conversations…"
|
||||
className="w-full rounded-2xl border px-3 py-2 text-xs placeholder:text-[var(--color-muted)] focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-2)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conversation list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredActive.length === 0 && !searchQuery && (
|
||||
<p className="px-3 py-2 text-xs text-text-muted">No conversations yet</p>
|
||||
)}
|
||||
{filteredActive.length === 0 && searchQuery && (
|
||||
<p className="px-3 py-2 text-xs text-text-muted">
|
||||
No results for “{searchQuery}”
|
||||
<div className="flex-1 overflow-y-auto space-y-1">
|
||||
{filteredActive.length === 0 && !searchQuery ? (
|
||||
<p className="px-1 py-2 text-xs text-[var(--color-muted)]">No conversations yet</p>
|
||||
) : null}
|
||||
{filteredActive.length === 0 && searchQuery ? (
|
||||
<p className="px-1 py-2 text-xs text-[var(--color-muted)]">
|
||||
No results for “{searchQuery}”
|
||||
</p>
|
||||
)}
|
||||
{filteredActive.map((conv) => renderConversationItem(conv))}
|
||||
) : null}
|
||||
{filteredActive.map((conversation) => renderConversationItem(conversation))}
|
||||
|
||||
{/* Archived section */}
|
||||
{archivedConversations.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{archivedConversations.length > 0 ? (
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived((v) => !v)}
|
||||
className="flex w-full items-center gap-1 px-3 py-1 text-xs text-text-muted transition-colors hover:text-text-secondary"
|
||||
onClick={() => setShowArchived((prev) => !prev)}
|
||||
className="flex w-full items-center gap-2 px-1 py-1 text-xs text-[var(--color-muted)] transition-colors hover:text-[var(--color-text-2)]"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block transition-transform',
|
||||
showArchived ? 'rotate-90' : '',
|
||||
)}
|
||||
className={cn('inline-block transition-transform', showArchived && 'rotate-90')}
|
||||
>
|
||||
►
|
||||
▶
|
||||
</span>
|
||||
Archived ({archivedConversations.length})
|
||||
</button>
|
||||
{showArchived && (
|
||||
<div className="opacity-60">
|
||||
{filteredArchived.map((conv) => renderConversationItem(conv))}
|
||||
{showArchived ? (
|
||||
<div className="mt-1 space-y-1 opacity-70">
|
||||
{filteredArchived.map((conversation) => renderConversationItem(conversation))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && contextConv && (
|
||||
{contextMenu && contextConversation ? (
|
||||
<div
|
||||
className="fixed z-20 min-w-36 rounded-md border border-surface-border bg-surface-card py-1 shadow-lg"
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
className="fixed z-30 min-w-40 rounded-2xl border py-1 shadow-[var(--shadow-ms-lg)]"
|
||||
style={{
|
||||
top: contextMenu.y,
|
||||
left: contextMenu.x,
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
|
||||
onClick={() => startRename(contextConv.id, contextConv.title)}
|
||||
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
onClick={() => startRename(contextConversation.id, contextConversation.title)}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
|
||||
onClick={() => handleArchiveToggle(contextConv.id, !contextConv.archived)}
|
||||
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
onClick={() =>
|
||||
handleArchiveToggle(contextConversation.id, !contextConversation.archived)
|
||||
}
|
||||
>
|
||||
{contextConv.archived ? 'Unarchive' : 'Archive'}
|
||||
{contextConversation.archived ? 'Restore' : 'Archive'}
|
||||
</button>
|
||||
<hr className="my-1 border-surface-border" />
|
||||
{deleteConfirmId === contextConv.id ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<p className="mb-1.5 text-xs text-red-400">Delete this conversation?</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-red-600 px-2 py-0.5 text-xs text-white hover:bg-red-700"
|
||||
onClick={() => confirmDelete(contextConv.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-2 py-0.5 text-xs text-text-muted hover:bg-surface-elevated"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{deleteConfirmId === contextConversation.id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5"
|
||||
onClick={() => confirmDelete(contextConversation.id)}
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-surface-elevated"
|
||||
onClick={() => handleDeleteClick(contextConv.id)}
|
||||
className="w-full px-3 py-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5"
|
||||
onClick={() => setDeleteConfirmId(contextConversation.id)}
|
||||
>
|
||||
Delete
|
||||
Delete…
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
576
apps/web/src/components/chat/conversation-sidebar.tsx
Normal file
576
apps/web/src/components/chat/conversation-sidebar.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Conversation, Project } from '@/lib/types';
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
title: string | null;
|
||||
projectId: string | null;
|
||||
updatedAt: string;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
export interface ConversationSidebarRef {
|
||||
refresh: () => void;
|
||||
addConversation: (conversation: ConversationSummary) => void;
|
||||
}
|
||||
|
||||
interface ConversationSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentConversationId: string | null;
|
||||
onSelectConversation: (conversationId: string | null) => void;
|
||||
onNewConversation: (projectId?: string | null) => void;
|
||||
}
|
||||
|
||||
interface GroupedConversations {
|
||||
key: string;
|
||||
label: string;
|
||||
projectId: string | null;
|
||||
conversations: ConversationSummary[];
|
||||
}
|
||||
|
||||
function toSummary(conversation: Conversation): ConversationSummary {
|
||||
return {
|
||||
id: conversation.id,
|
||||
title: conversation.title,
|
||||
projectId: conversation.projectId,
|
||||
updatedAt: conversation.updatedAt,
|
||||
archived: conversation.archived,
|
||||
};
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export const ConversationSidebar = forwardRef<ConversationSidebarRef, ConversationSidebarProps>(
|
||||
function ConversationSidebar(
|
||||
{ isOpen, onClose, currentConversationId, onSelectConversation, onNewConversation },
|
||||
ref,
|
||||
): React.ReactElement {
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadSidebarData = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [loadedConversations, loadedProjects] = await Promise.all([
|
||||
api<Conversation[]>('/api/conversations'),
|
||||
api<Project[]>('/api/projects').catch(() => [] as Project[]),
|
||||
]);
|
||||
|
||||
setConversations(
|
||||
loadedConversations
|
||||
.filter((conversation) => !conversation.archived)
|
||||
.map(toSummary)
|
||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)),
|
||||
);
|
||||
setProjects(loadedProjects);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load conversations');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSidebarData();
|
||||
}, [loadSidebarData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingId) return;
|
||||
const timer = window.setTimeout(() => renameInputRef.current?.focus(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [renamingId]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
refresh: () => {
|
||||
void loadSidebarData();
|
||||
},
|
||||
addConversation: (conversation) => {
|
||||
setConversations((prev) => {
|
||||
const next = [conversation, ...prev.filter((item) => item.id !== conversation.id)];
|
||||
return next.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
||||
});
|
||||
},
|
||||
}),
|
||||
[loadSidebarData],
|
||||
);
|
||||
|
||||
const filteredConversations = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!query) return conversations;
|
||||
|
||||
return conversations.filter((conversation) =>
|
||||
(conversation.title ?? 'Untitled conversation').toLowerCase().includes(query),
|
||||
);
|
||||
}, [conversations, searchQuery]);
|
||||
|
||||
const groupedConversations = useMemo<GroupedConversations[]>(() => {
|
||||
if (projects.length === 0) {
|
||||
return [
|
||||
{
|
||||
key: 'all',
|
||||
label: 'All conversations',
|
||||
projectId: null,
|
||||
conversations: filteredConversations,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const byProject = new Map<string | null, ConversationSummary[]>();
|
||||
for (const conversation of filteredConversations) {
|
||||
const key = conversation.projectId ?? null;
|
||||
const items = byProject.get(key) ?? [];
|
||||
items.push(conversation);
|
||||
byProject.set(key, items);
|
||||
}
|
||||
|
||||
const groups: GroupedConversations[] = [];
|
||||
|
||||
for (const project of projects) {
|
||||
const projectConversations = byProject.get(project.id);
|
||||
if (!projectConversations?.length) continue;
|
||||
|
||||
groups.push({
|
||||
key: project.id,
|
||||
label: project.name,
|
||||
projectId: project.id,
|
||||
conversations: projectConversations,
|
||||
});
|
||||
}
|
||||
|
||||
const ungrouped = byProject.get(null);
|
||||
if (ungrouped?.length) {
|
||||
groups.push({
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
projectId: null,
|
||||
conversations: ungrouped,
|
||||
});
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
groups.push({
|
||||
key: 'all',
|
||||
label: 'All conversations',
|
||||
projectId: null,
|
||||
conversations: filteredConversations,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [filteredConversations, projects]);
|
||||
|
||||
const startRename = useCallback((conversation: ConversationSummary): void => {
|
||||
setPendingDeleteId(null);
|
||||
setRenamingId(conversation.id);
|
||||
setRenameValue(conversation.title ?? '');
|
||||
}, []);
|
||||
|
||||
const cancelRename = useCallback((): void => {
|
||||
setRenamingId(null);
|
||||
setRenameValue('');
|
||||
}, []);
|
||||
|
||||
const commitRename = useCallback(async (): Promise<void> => {
|
||||
if (!renamingId) return;
|
||||
|
||||
const title = renameValue.trim() || 'Untitled conversation';
|
||||
|
||||
try {
|
||||
const updated = await api<Conversation>(`/api/conversations/${renamingId}`, {
|
||||
method: 'PATCH',
|
||||
body: { title },
|
||||
});
|
||||
|
||||
const summary = toSummary(updated);
|
||||
setConversations((prev) =>
|
||||
prev
|
||||
.map((conversation) => (conversation.id === renamingId ? summary : conversation))
|
||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to rename conversation');
|
||||
} finally {
|
||||
setRenamingId(null);
|
||||
setRenameValue('');
|
||||
}
|
||||
}, [renameValue, renamingId]);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
async (conversationId: string): Promise<void> => {
|
||||
try {
|
||||
await api<void>(`/api/conversations/${conversationId}`, { method: 'DELETE' });
|
||||
setConversations((prev) =>
|
||||
prev.filter((conversation) => conversation.id !== conversationId),
|
||||
);
|
||||
if (currentConversationId === conversationId) {
|
||||
onSelectConversation(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete conversation');
|
||||
} finally {
|
||||
setPendingDeleteId(null);
|
||||
}
|
||||
},
|
||||
[currentConversationId, onSelectConversation],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close conversation sidebar"
|
||||
className="fixed inset-0 z-30 bg-black/50 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<aside
|
||||
aria-label="Conversation sidebar"
|
||||
className="fixed left-0 top-0 z-40 flex h-full flex-col border-r md:relative md:z-0"
|
||||
style={{
|
||||
width: 'var(--sidebar-w)',
|
||||
background: 'var(--bg)',
|
||||
borderColor: 'var(--border)',
|
||||
transform: isOpen ? 'translateX(0)' : 'translateX(calc(-1 * var(--sidebar-w)))',
|
||||
transition: 'transform 220ms var(--ease)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between border-b px-4 py-3"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||
Conversations
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--muted)' }}>
|
||||
Search, rename, and manage threads
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md p-2 md:hidden"
|
||||
style={{ color: 'var(--text-2)' }}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
||||
<path strokeWidth="2" strokeLinecap="round" d="M6 6l12 12M18 6 6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-b p-3" style={{ borderColor: 'var(--border)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewConversation(null)}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--primary)',
|
||||
background: 'color-mix(in srgb, var(--primary) 12%, transparent)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
||||
<path strokeWidth="2" strokeLinecap="round" d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
New conversation
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
style={{ color: 'var(--muted)' }}
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" strokeWidth="2" />
|
||||
<path d="m20 20-3.5-3.5" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search conversations"
|
||||
className="w-full rounded-lg border px-9 py-2 text-sm outline-none"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center text-sm" style={{ color: 'var(--muted)' }}>
|
||||
Loading conversations...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className="space-y-3 rounded-xl border p-4 text-sm"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--danger) 10%, var(--surface))',
|
||||
borderColor: 'color-mix(in srgb, var(--danger) 35%, var(--border))',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadSidebarData()}
|
||||
className="rounded-md px-3 py-1.5 text-xs font-medium"
|
||||
style={{ background: 'var(--danger)', color: 'white' }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : filteredConversations.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-sm" style={{ color: 'var(--text-2)' }}>
|
||||
{searchQuery ? 'No matching conversations' : 'No conversations yet'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--muted)' }}>
|
||||
{searchQuery ? 'Try another title search.' : 'Start a new conversation to begin.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groupedConversations.map((group) => (
|
||||
<section key={group.key} className="space-y-2">
|
||||
{projects.length > 0 ? (
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h3
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.16em]"
|
||||
style={{ color: 'var(--muted)' }}
|
||||
>
|
||||
{group.label}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewConversation(group.projectId)}
|
||||
className="rounded-md px-2 py-1 text-[11px] font-medium"
|
||||
style={{ color: 'var(--ms-blue-500)' }}
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1">
|
||||
{group.conversations.map((conversation) => {
|
||||
const isActive = currentConversationId === conversation.id;
|
||||
const isRenaming = renamingId === conversation.id;
|
||||
const showActions =
|
||||
hoveredId === conversation.id ||
|
||||
isRenaming ||
|
||||
pendingDeleteId === conversation.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conversation.id}
|
||||
onMouseEnter={() => setHoveredId(conversation.id)}
|
||||
onMouseLeave={() =>
|
||||
setHoveredId((current) =>
|
||||
current === conversation.id ? null : current,
|
||||
)
|
||||
}
|
||||
className="rounded-xl border p-2 transition-colors"
|
||||
style={{
|
||||
borderColor: isActive
|
||||
? 'color-mix(in srgb, var(--primary) 60%, var(--border))'
|
||||
: 'transparent',
|
||||
background: isActive ? 'var(--surface-2)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(event) => setRenameValue(event.target.value)}
|
||||
onBlur={() => void commitRename()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void commitRename();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
maxLength={255}
|
||||
className="w-full rounded-md border px-2 py-1.5 text-sm outline-none"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
borderColor: 'var(--ms-blue-500)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectConversation(conversation.id)}
|
||||
className="block w-full text-left"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="truncate text-sm font-medium"
|
||||
style={{
|
||||
color: isActive ? 'var(--text)' : 'var(--text-2)',
|
||||
}}
|
||||
>
|
||||
{conversation.title ?? 'Untitled conversation'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--muted)' }}>
|
||||
{formatRelativeTime(conversation.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showActions ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startRename(conversation);
|
||||
}}
|
||||
className="rounded-md p-1.5 transition-colors"
|
||||
style={{ color: 'var(--text-2)' }}
|
||||
aria-label={`Rename ${conversation.title ?? 'conversation'}`}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M4 20h4l10.5-10.5a1.4 1.4 0 0 0 0-2L16.5 5.5a1.4 1.4 0 0 0-2 0L4 16v4Z"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setPendingDeleteId((current) =>
|
||||
current === conversation.id ? null : conversation.id,
|
||||
);
|
||||
setRenamingId(null);
|
||||
}}
|
||||
className="rounded-md p-1.5 transition-colors"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
aria-label={`Delete ${conversation.title ?? 'conversation'}`}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M4 7h16M10 11v6M14 11v6M6 7l1 12h10l1-12M9 7V4h6v3"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pendingDeleteId === conversation.id ? (
|
||||
<div
|
||||
className="mt-2 flex items-center justify-between rounded-lg border px-2 py-2"
|
||||
style={{
|
||||
borderColor:
|
||||
'color-mix(in srgb, var(--danger) 45%, var(--border))',
|
||||
background:
|
||||
'color-mix(in srgb, var(--danger) 10%, var(--surface))',
|
||||
}}
|
||||
>
|
||||
<p className="text-xs" style={{ color: 'var(--text-2)' }}>
|
||||
Delete this conversation?
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(null)}
|
||||
className="rounded-md px-2 py-1 text-xs"
|
||||
style={{ color: 'var(--text-2)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteConversation(conversation.id)}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium"
|
||||
style={{ background: 'var(--danger)', color: 'white' }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Message } from '@/lib/types';
|
||||
|
||||
@@ -9,27 +11,261 @@ interface MessageBubbleProps {
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
|
||||
const isUser = message.role === 'user';
|
||||
const isSystem = message.role === 'system';
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||
const { response, thinking } = useMemo(
|
||||
() => parseThinking(message.content, message.thinking),
|
||||
[message.content, message.thinking],
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(response);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1800);
|
||||
} catch (error) {
|
||||
console.error('[MessageBubble] Failed to copy message:', error);
|
||||
}
|
||||
}, [response]);
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="max-w-[42rem] rounded-full border px-3 py-1.5 text-xs backdrop-blur-sm"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface) 70%, transparent)',
|
||||
color: 'var(--color-muted)',
|
||||
}}
|
||||
>
|
||||
<span>{response}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div className={cn('group flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[75%] rounded-xl px-4 py-3 text-sm',
|
||||
isUser
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-surface-border bg-surface-elevated text-text-primary',
|
||||
'flex max-w-[min(78ch,85%)] flex-col gap-2',
|
||||
isUser ? 'items-end' : 'items-start',
|
||||
)}
|
||||
>
|
||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||
<div className={cn('flex items-center gap-2 text-[11px]', isUser && 'flex-row-reverse')}>
|
||||
<span className="font-medium text-[var(--color-text-2)]">
|
||||
{isUser ? 'You' : 'Assistant'}
|
||||
</span>
|
||||
{!isUser && message.model ? (
|
||||
<span
|
||||
className="rounded-full border px-2 py-0.5 font-medium text-[var(--color-text-2)]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface-2) 82%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
title={message.provider ? `Provider: ${message.provider}` : undefined}
|
||||
>
|
||||
{message.model}
|
||||
</span>
|
||||
) : null}
|
||||
{!isUser && typeof message.totalTokens === 'number' && message.totalTokens > 0 ? (
|
||||
<span
|
||||
className="rounded-full border px-2 py-0.5 text-[var(--color-muted)]"
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
{formatTokenCount(message.totalTokens)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[var(--color-muted)]">{formatTimestamp(message.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{thinking && !isUser ? (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-2xl border"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface-2) 88%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThinkingExpanded((prev) => !prev)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-[var(--color-text-2)] transition-colors hover:bg-black/5"
|
||||
aria-expanded={thinkingExpanded}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block text-[10px] transition-transform',
|
||||
thinkingExpanded && 'rotate-90',
|
||||
)}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<span>Chain of thought</span>
|
||||
<span className="ml-auto text-[var(--color-muted)]">
|
||||
{thinkingExpanded ? 'Hide' : 'Show'}
|
||||
</span>
|
||||
</button>
|
||||
{thinkingExpanded ? (
|
||||
<pre
|
||||
className="overflow-x-auto border-t px-3 py-3 font-mono text-xs leading-6 whitespace-pre-wrap"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
backgroundColor: 'var(--color-bg-deep)',
|
||||
color: 'var(--color-text-2)',
|
||||
}}
|
||||
>
|
||||
{thinking}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn('mt-1 text-right text-xs', isUser ? 'text-blue-200' : 'text-text-muted')}
|
||||
className={cn(
|
||||
'relative w-full rounded-3xl px-4 py-3 text-sm shadow-[var(--shadow-ms-sm)]',
|
||||
!isUser && 'border',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isUser ? 'var(--color-ms-blue-500)' : 'var(--color-surface)',
|
||||
color: isUser ? '#fff' : 'var(--color-text)',
|
||||
borderColor: isUser ? 'transparent' : 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
{new Date(message.createdAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
<div className="max-w-none">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <p className="mb-3 leading-7 last:mb-0">{children}</p>,
|
||||
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 last:mb-0">{children}</ul>,
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-3 list-decimal pl-5 last:mb-0">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="mb-1">{children}</li>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="font-medium underline underline-offset-4"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
pre: ({ children }) => <div className="mb-3 last:mb-0">{children}</div>,
|
||||
code: ({ className, children, ...props }) => {
|
||||
const language = className?.replace('language-', '');
|
||||
const content = String(children).replace(/\n$/, '');
|
||||
const isInline = !className;
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="rounded-md px-1.5 py-0.5 font-mono text-[0.9em]"
|
||||
style={{
|
||||
backgroundColor:
|
||||
'color-mix(in srgb, var(--color-bg-deep) 76%, transparent)',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-hidden rounded-2xl border"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-deep)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="border-b px-3 py-2 font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--color-muted)]"
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
{language || 'code'}
|
||||
</div>
|
||||
<pre className="overflow-x-auto p-3">
|
||||
<code
|
||||
className={cn('font-mono text-[13px] leading-6', className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote
|
||||
className="mb-3 border-l-2 pl-4 italic last:mb-0"
|
||||
style={{ borderColor: 'var(--color-ms-blue-500)' }}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{response}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopy()}
|
||||
className="absolute -right-2 -top-2 rounded-full border p-2 opacity-0 shadow-[var(--shadow-ms-md)] transition-all group-hover:opacity-100 focus:opacity-100"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: copied ? 'var(--color-success)' : 'var(--color-text-2)',
|
||||
}}
|
||||
aria-label={copied ? 'Copied' : 'Copy message'}
|
||||
title={copied ? 'Copied' : 'Copy message'}
|
||||
>
|
||||
{copied ? '✓' : '⧉'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseThinking(
|
||||
content: string,
|
||||
thinking?: string,
|
||||
): { response: string; thinking: string | null } {
|
||||
if (thinking) {
|
||||
return { response: content, thinking };
|
||||
}
|
||||
|
||||
const regex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
|
||||
const matches = [...content.matchAll(regex)];
|
||||
if (matches.length === 0) {
|
||||
return { response: content, thinking: null };
|
||||
}
|
||||
|
||||
return {
|
||||
response: content.replace(regex, '').trim(),
|
||||
thinking:
|
||||
matches
|
||||
.map((match) => match[1]?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
.join('\n\n') || null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatTimestamp(createdAt: string): string {
|
||||
return new Date(createdAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatTokenCount(totalTokens: number): string {
|
||||
if (totalTokens >= 1_000_000) return `${(totalTokens / 1_000_000).toFixed(1)}M tokens`;
|
||||
if (totalTokens >= 1_000) return `${(totalTokens / 1_000).toFixed(1)}k tokens`;
|
||||
return `${totalTokens} tokens`;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,97 @@
|
||||
'use client';
|
||||
|
||||
/** Renders an in-progress assistant message from streaming text. */
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface StreamingMessageProps {
|
||||
text: string;
|
||||
modelName?: string | null;
|
||||
thinking?: string;
|
||||
}
|
||||
|
||||
export function StreamingMessage({ text }: StreamingMessageProps): React.ReactElement {
|
||||
const WAITING_QUIPS = [
|
||||
'The AI is warming up... give it a moment.',
|
||||
'Brewing some thoughts...',
|
||||
'Summoning intelligence from the void...',
|
||||
'Consulting the silicon oracle...',
|
||||
'Teaching electrons to think...',
|
||||
];
|
||||
|
||||
const TIMEOUT_QUIPS = [
|
||||
'The model wandered off. Let’s try to find it again.',
|
||||
'Response is taking the scenic route.',
|
||||
'That answer is clearly overthinking things.',
|
||||
'Still working. Either brilliance or a detour.',
|
||||
];
|
||||
|
||||
export function StreamingMessage({
|
||||
text,
|
||||
modelName,
|
||||
thinking,
|
||||
}: StreamingMessageProps): React.ReactElement {
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setElapsedMs(0);
|
||||
const startedAt = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedMs(Date.now() - startedAt);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [text, modelName, thinking]);
|
||||
|
||||
const quip = useMemo(() => {
|
||||
if (elapsedMs >= 18_000) {
|
||||
return TIMEOUT_QUIPS[Math.floor((elapsedMs / 1000) % TIMEOUT_QUIPS.length)];
|
||||
}
|
||||
if (elapsedMs >= 4_000) {
|
||||
return WAITING_QUIPS[Math.floor((elapsedMs / 1000) % WAITING_QUIPS.length)];
|
||||
}
|
||||
return null;
|
||||
}, [elapsedMs]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[75%] rounded-xl border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-primary">
|
||||
<div
|
||||
className="max-w-[min(78ch,85%)] rounded-3xl border px-4 py-3 text-sm shadow-[var(--shadow-ms-sm)]"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px]">
|
||||
<span className="font-medium text-[var(--color-text-2)]">Assistant</span>
|
||||
{modelName ? (
|
||||
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[var(--color-text-2)]">
|
||||
{modelName}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[var(--color-muted)]">{text ? 'Responding…' : 'Thinking…'}</span>
|
||||
</div>
|
||||
{text ? (
|
||||
<div className="whitespace-pre-wrap break-words">{text}</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" />
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500 [animation-delay:0.2s]" />
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500 [animation-delay:0.4s]" />
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)]" />
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)] [animation-delay:0.2s]" />
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)] [animation-delay:0.4s]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-text-muted">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
||||
{text ? 'Responding...' : 'Thinking...'}
|
||||
{thinking ? (
|
||||
<div
|
||||
className="mt-3 rounded-2xl border px-3 py-2 font-mono text-xs whitespace-pre-wrap"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-deep)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-2)',
|
||||
}}
|
||||
>
|
||||
{thinking}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-[var(--color-muted)]">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--color-ms-blue-500)]" />
|
||||
<span>{quip ?? (text ? 'Responding…' : 'Thinking…')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
239
apps/web/src/components/layout/app-header.tsx
Normal file
239
apps/web/src/components/layout/app-header.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { signOut, useSession } from '@/lib/auth-client';
|
||||
|
||||
interface AppHeaderProps {
|
||||
conversationTitle?: string | null;
|
||||
isSidebarOpen: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
type ThemeMode = 'dark' | 'light';
|
||||
|
||||
const THEME_STORAGE_KEY = 'mosaic-chat-theme';
|
||||
|
||||
export function AppHeader({
|
||||
conversationTitle,
|
||||
isSidebarOpen,
|
||||
onToggleSidebar,
|
||||
}: AppHeaderProps): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const [currentTime, setCurrentTime] = useState('');
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [theme, setTheme] = useState<ThemeMode>('dark');
|
||||
|
||||
useEffect(() => {
|
||||
function updateTime(): void {
|
||||
setCurrentTime(
|
||||
new Date().toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
updateTime();
|
||||
const interval = window.setInterval(updateTime, 60_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/version.json')
|
||||
.then(async (res) => res.json() as Promise<{ version?: string; commit?: string }>)
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
setVersion(data.commit ? `${data.version}+${data.commit}` : data.version);
|
||||
}
|
||||
})
|
||||
.catch(() => setVersion(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const nextTheme = storedTheme === 'light' ? 'light' : 'dark';
|
||||
applyTheme(nextTheme);
|
||||
setTheme(nextTheme);
|
||||
}, []);
|
||||
|
||||
const handleThemeToggle = useCallback(() => {
|
||||
const nextTheme = theme === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(nextTheme);
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
|
||||
setTheme(nextTheme);
|
||||
}, [theme]);
|
||||
|
||||
const handleSignOut = useCallback(async (): Promise<void> => {
|
||||
await signOut();
|
||||
window.location.href = '/login';
|
||||
}, []);
|
||||
|
||||
const userLabel = session?.user.name ?? session?.user.email ?? 'Mosaic User';
|
||||
const initials = useMemo(() => getInitials(userLabel), [userLabel]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-20 border-b backdrop-blur-xl"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3 md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
||||
aria-label="Toggle conversation sidebar"
|
||||
aria-expanded={isSidebarOpen}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
|
||||
<Link href="/chat" className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[var(--shadow-ms-md)]"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, var(--color-ms-blue-500), var(--color-ms-teal-500))',
|
||||
}}
|
||||
>
|
||||
M
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="text-sm font-semibold text-[var(--color-text)]">Mosaic</div>
|
||||
<div className="hidden h-5 w-px bg-[var(--color-border)] md:block" />
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-ms-teal-500)] opacity-60" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-ms-teal-500)]" />
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="hidden min-w-0 items-center gap-3 md:flex">
|
||||
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-2)]">
|
||||
{currentTime || '--:--'}
|
||||
</div>
|
||||
<div className="max-w-[24rem] truncate text-sm font-medium text-[var(--color-text)]">
|
||||
{conversationTitle?.trim() || 'New Session'}
|
||||
</div>
|
||||
{version ? (
|
||||
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
||||
v{version}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<ShortcutHint label="⌘/" text="focus" />
|
||||
<ShortcutHint label="⌘K" text="focus" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleThemeToggle}
|
||||
className="inline-flex h-10 items-center justify-center rounded-2xl border px-3 text-sm transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? '☀︎' : '☾'}
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border text-sm font-semibold transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-2)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
aria-expanded={menuOpen}
|
||||
aria-label="Open user menu"
|
||||
>
|
||||
{session?.user.image ? (
|
||||
<img
|
||||
src={session.user.image}
|
||||
alt={userLabel}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</button>
|
||||
{menuOpen ? (
|
||||
<div
|
||||
className="absolute right-0 top-12 min-w-56 rounded-3xl border p-2 shadow-[var(--shadow-ms-lg)]"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="border-b px-3 py-2" style={{ borderColor: 'var(--color-border)' }}>
|
||||
<div className="text-sm font-medium text-[var(--color-text)]">{userLabel}</div>
|
||||
{session?.user.email ? (
|
||||
<div className="text-xs text-[var(--color-muted)]">{session.user.email}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<Link
|
||||
href="/settings"
|
||||
className="flex rounded-2xl px-3 py-2 text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSignOut()}
|
||||
className="flex w-full rounded-2xl px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutHint({ label, text }: { label: string; text: string }): React.ReactElement {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
||||
<span className="font-medium text-[var(--color-text-2)]">{label}</span>
|
||||
<span>{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getInitials(label: string): string {
|
||||
const words = label.split(/\s+/).filter(Boolean).slice(0, 2);
|
||||
if (words.length === 0) return 'M';
|
||||
return words.map((word) => word.charAt(0).toUpperCase()).join('');
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeMode): void {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'light') {
|
||||
root.setAttribute('data-theme', 'light');
|
||||
root.classList.remove('dark');
|
||||
} else {
|
||||
root.removeAttribute('data-theme');
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useSession, signOut } from '@/lib/auth-client';
|
||||
|
||||
export function Topbar(): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
if (pathname.startsWith('/chat')) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
async function handleSignOut(): Promise<void> {
|
||||
await signOut();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createAuthClient } from 'better-auth/react';
|
||||
import { adminClient } from 'better-auth/client/plugins';
|
||||
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
|
||||
plugins: [adminClient()],
|
||||
plugins: [adminClient(), genericOAuthClient()],
|
||||
});
|
||||
|
||||
export const { useSession, signIn, signUp, signOut } = authClient;
|
||||
|
||||
48
apps/web/src/lib/sso-providers.test.ts
Normal file
48
apps/web/src/lib/sso-providers.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
||||
|
||||
describe('sso-providers', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('returns the enabled providers in login button order', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
||||
|
||||
expect(getEnabledSsoProviders()).toEqual([
|
||||
{
|
||||
id: 'workos',
|
||||
buttonLabel: 'Continue with WorkOS',
|
||||
description: 'Enterprise SSO via WorkOS',
|
||||
enabled: true,
|
||||
href: '/auth/provider/workos',
|
||||
},
|
||||
{
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
enabled: true,
|
||||
href: '/auth/provider/keycloak',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks disabled providers without exposing them in the enabled list', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
||||
|
||||
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
||||
expect(getSsoProvider('keycloak')).toEqual({
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
enabled: false,
|
||||
href: '/auth/provider/keycloak',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unknown providers', () => {
|
||||
expect(getSsoProvider('authentik')).toBeNull();
|
||||
});
|
||||
});
|
||||
53
apps/web/src/lib/sso-providers.ts
Normal file
53
apps/web/src/lib/sso-providers.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type SsoProviderId = 'workos' | 'keycloak';
|
||||
|
||||
export interface SsoProvider {
|
||||
id: SsoProviderId;
|
||||
buttonLabel: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
||||
workos: {
|
||||
id: 'workos',
|
||||
buttonLabel: 'Continue with WorkOS',
|
||||
description: 'Enterprise SSO via WorkOS',
|
||||
},
|
||||
keycloak: {
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
},
|
||||
};
|
||||
|
||||
export function getEnabledSsoProviders(): SsoProvider[] {
|
||||
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
||||
.map((providerId) => getSsoProvider(providerId))
|
||||
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
||||
}
|
||||
|
||||
export function getSsoProvider(providerId: string): SsoProvider | null {
|
||||
if (!isSsoProviderId(providerId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...PROVIDER_METADATA[providerId],
|
||||
enabled: isSsoProviderEnabled(providerId),
|
||||
href: `/auth/provider/${providerId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isSsoProviderId(value: string): value is SsoProviderId {
|
||||
return value === 'workos' || value === 'keycloak';
|
||||
}
|
||||
|
||||
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
||||
switch (providerId) {
|
||||
case 'workos':
|
||||
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
||||
case 'keycloak':
|
||||
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,41 @@ export interface Message {
|
||||
conversationId: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
thinking?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Model definition returned by provider APIs. */
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
inputTypes: Array<'text' | 'image'>;
|
||||
cost: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Provider with associated models. */
|
||||
export interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
/** Task statuses. */
|
||||
export type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
|
||||
@@ -4,5 +4,6 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,39 +7,39 @@
|
||||
|
||||
**ID:** mvp-20260312
|
||||
**Statement:** Build Mosaic Stack v0.1.0 — a self-hosted, multi-user AI agent platform with web dashboard, TUI, remote control, shared memory, mission orchestration, and extensible skill/plugin architecture. All TypeScript. Pi as agent harness. Brain as knowledge layer. Queue as coordination backbone.
|
||||
**Phase:** Execution
|
||||
**Current Milestone:** Phase 8: Polish & Beta (v0.1.0)
|
||||
**Progress:** 8 / 9 milestones
|
||||
**Status:** active
|
||||
**Last Updated:** 2026-03-15 UTC
|
||||
**Phase:** Complete
|
||||
**Current Milestone:** Phase 8: Polish & Beta (v0.1.0) — DONE
|
||||
**Progress:** 9 / 9 milestones
|
||||
**Status:** complete
|
||||
**Last Updated:** 2026-03-16 UTC
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] AC-1: Core chat flow — login, send message, streamed response, conversations persist
|
||||
- [ ] AC-2: TUI integration — `mosaic tui` connects to gateway, same context as web
|
||||
- [ ] AC-3: Discord remote control — bot responds, routes through gateway, threads work
|
||||
- [ ] AC-4: Gateway orchestration — multi-provider routing, fallback, concurrent sessions
|
||||
- [ ] AC-5: Task & project management — CRUD, kanban, mission tracking, brain MCP tools
|
||||
- [ ] AC-6: Memory system — auto-capture, semantic search, preferences, log summarization
|
||||
- [ ] AC-7: Auth & RBAC — email/password, Authentik SSO, role enforcement
|
||||
- [ ] AC-8: Multi-provider LLM — 3+ providers routing correctly
|
||||
- [ ] AC-9: MCP — gateway MCP endpoint, brain + queue tools via MCP
|
||||
- [ ] AC-10: Deployment — `docker compose up` from clean state, CLI on bare metal
|
||||
- [ ] AC-11: @mosaic/\* packages — all 7 migrated packages build, test, integrate
|
||||
- [x] AC-1: Core chat flow — login, send message, streamed response, conversations persist
|
||||
- [x] AC-2: TUI integration — `mosaic tui` connects to gateway, same context as web
|
||||
- [x] AC-3: Discord remote control — bot responds, routes through gateway, threads work
|
||||
- [x] AC-4: Gateway orchestration — multi-provider routing, fallback, concurrent sessions
|
||||
- [x] AC-5: Task & project management — CRUD, kanban, mission tracking, brain MCP tools
|
||||
- [x] AC-6: Memory system — auto-capture, semantic search, preferences, log summarization
|
||||
- [x] AC-7: Auth & RBAC — email/password, Authentik SSO, role enforcement
|
||||
- [x] AC-8: Multi-provider LLM — 3+ providers routing correctly
|
||||
- [x] AC-9: MCP — gateway MCP endpoint, brain + queue tools via MCP
|
||||
- [x] AC-10: Deployment — `docker compose up` from clean state, CLI on bare metal
|
||||
- [x] AC-11: @mosaic/\* packages — all 7 migrated packages build, test, integrate
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------ | --------------------------------------- | ----------- | ------ | ----- | ---------- | ---------- |
|
||||
| 0 | ms-157 | Phase 0: Foundation (v0.0.1) | done | — | — | 2026-03-13 | 2026-03-13 |
|
||||
| 1 | ms-158 | Phase 1: Core API (v0.0.2) | done | — | — | 2026-03-13 | 2026-03-13 |
|
||||
| 2 | ms-159 | Phase 2: Agent Layer (v0.0.3) | done | — | — | 2026-03-13 | 2026-03-12 |
|
||||
| 3 | ms-160 | Phase 3: Web Dashboard (v0.0.4) | done | — | — | 2026-03-12 | 2026-03-13 |
|
||||
| 4 | ms-161 | Phase 4: Memory & Intelligence (v0.0.5) | done | — | — | 2026-03-13 | 2026-03-13 |
|
||||
| 5 | ms-162 | Phase 5: Remote Control (v0.0.6) | done | — | #99 | 2026-03-14 | 2026-03-14 |
|
||||
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | done | — | #104 | 2026-03-14 | 2026-03-14 |
|
||||
| 7 | ms-164 | Phase 7: Feature Completion (v0.0.8) | done | — | — | 2026-03-15 | 2026-03-15 |
|
||||
| 8 | ms-165 | Phase 8: Polish & Beta (v0.1.0) | not-started | — | — | — | — |
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------ | --------------------------------------- | ------ | ------ | ----- | ---------- | ---------- |
|
||||
| 0 | ms-157 | Phase 0: Foundation (v0.0.1) | done | — | — | 2026-03-13 | 2026-03-13 |
|
||||
| 1 | ms-158 | Phase 1: Core API (v0.0.2) | done | — | — | 2026-03-13 | 2026-03-13 |
|
||||
| 2 | ms-159 | Phase 2: Agent Layer (v0.0.3) | done | — | — | 2026-03-13 | 2026-03-12 |
|
||||
| 3 | ms-160 | Phase 3: Web Dashboard (v0.0.4) | done | — | — | 2026-03-12 | 2026-03-13 |
|
||||
| 4 | ms-161 | Phase 4: Memory & Intelligence (v0.0.5) | done | — | — | 2026-03-13 | 2026-03-13 |
|
||||
| 5 | ms-162 | Phase 5: Remote Control (v0.0.6) | done | — | #99 | 2026-03-14 | 2026-03-14 |
|
||||
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | done | — | #104 | 2026-03-14 | 2026-03-14 |
|
||||
| 7 | ms-164 | Phase 7: Feature Completion (v0.0.8) | done | — | — | 2026-03-15 | 2026-03-15 |
|
||||
| 8 | ms-165 | Phase 8: Polish & Beta (v0.1.0) | done | — | — | 2026-03-15 | 2026-03-15 |
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -58,20 +58,21 @@
|
||||
|
||||
## Session History
|
||||
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | --------------- | -------------------- | -------- | ------------- | ---------------- |
|
||||
| 1 | claude-opus-4-6 | 2026-03-13 01:00 UTC | — | context limit | Planning gate |
|
||||
| 2 | claude-opus-4-6 | 2026-03-13 | — | context limit | P5-002, P6-005 |
|
||||
| 3 | claude-opus-4-6 | 2026-03-13 | — | context limit | P0-006 |
|
||||
| 4 | claude-opus-4-6 | 2026-03-12 | — | context limit | Docker fix |
|
||||
| 5 | claude-opus-4-6 | 2026-03-12 | — | context limit | P1-009 |
|
||||
| 6 | claude-opus-4-6 | 2026-03-12 | — | context limit | P2-006, FIX-01 |
|
||||
| 7 | claude-opus-4-6 | 2026-03-12 | — | context limit | P2-007 |
|
||||
| 8 | claude-opus-4-6 | 2026-03-12 | — | context limit | Phase 2 complete |
|
||||
| 9 | claude-opus-4-6 | 2026-03-12 | — | context limit | P3-007 |
|
||||
| 10 | claude-opus-4-6 | 2026-03-13 | — | context limit | P3-008 |
|
||||
| 11 | claude-opus-4-6 | 2026-03-14 | — | context limit | P7 rescope |
|
||||
| 12 | claude-opus-4-6 | 2026-03-15 | — | active | P7 planning |
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | ----------------- | -------------------- | -------- | ------------- | ---------------- |
|
||||
| 1 | claude-opus-4-6 | 2026-03-13 01:00 UTC | — | context limit | Planning gate |
|
||||
| 2 | claude-opus-4-6 | 2026-03-13 | — | context limit | P5-002, P6-005 |
|
||||
| 3 | claude-opus-4-6 | 2026-03-13 | — | context limit | P0-006 |
|
||||
| 4 | claude-opus-4-6 | 2026-03-12 | — | context limit | Docker fix |
|
||||
| 5 | claude-opus-4-6 | 2026-03-12 | — | context limit | P1-009 |
|
||||
| 6 | claude-opus-4-6 | 2026-03-12 | — | context limit | P2-006, FIX-01 |
|
||||
| 7 | claude-opus-4-6 | 2026-03-12 | — | context limit | P2-007 |
|
||||
| 8 | claude-opus-4-6 | 2026-03-12 | — | context limit | Phase 2 complete |
|
||||
| 9 | claude-opus-4-6 | 2026-03-12 | — | context limit | P3-007 |
|
||||
| 10 | claude-opus-4-6 | 2026-03-13 | — | context limit | P3-008 |
|
||||
| 11 | claude-opus-4-6 | 2026-03-14 | — | context limit | P7 rescope |
|
||||
| 12 | claude-opus-4-6 | 2026-03-15 | — | context limit | P7 planning |
|
||||
| 13 | claude-sonnet-4-6 | 2026-03-16 | — | complete | P8-019 verify |
|
||||
|
||||
## Scratchpad
|
||||
|
||||
|
||||
164
docs/PERFORMANCE.md
Normal file
164
docs/PERFORMANCE.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Performance Optimization — P8-003
|
||||
|
||||
**Branch:** `feat/p8-003-performance`
|
||||
**Target metrics:** <200 ms TTFB, <2 s page loads
|
||||
|
||||
---
|
||||
|
||||
## What Was Profiled
|
||||
|
||||
The following areas were reviewed through static analysis and code-path tracing
|
||||
(no production traffic available; findings are based on measurable code-level patterns):
|
||||
|
||||
| Area | Findings |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `packages/db` | Connection pool unbounded (default 10, no idle/connect timeout) |
|
||||
| `apps/gateway/src/preferences` | N+1 round-trip on every pref upsert (SELECT + INSERT/UPDATE) |
|
||||
| `packages/brain/src/conversations` | Unbounded list queries — no `LIMIT` or `ORDER BY` |
|
||||
| `packages/db/src/schema` | Missing hot-path indexes: auth session lookup, OAuth callback, conversation list, agent-log tier queries |
|
||||
| `apps/gateway/src/gc` | Cold-start GC blocked NestJS bootstrap (synchronous `await` in `onModuleInit`) |
|
||||
| `apps/web/next.config.ts` | Missing `compress: true`, no `productionBrowserSourceMaps: false`, no image format config |
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. DB Connection Pool — `packages/db/src/client.ts`
|
||||
|
||||
**Problem:** `postgres()` was called with no pool config. The default max of 10 connections
|
||||
and no idle/connect timeouts meant the pool could hang indefinitely on a stale TCP connection.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- `max`: 20 connections (configurable via `DB_POOL_MAX`)
|
||||
- `idle_timeout`: 30 s (configurable via `DB_IDLE_TIMEOUT`) — recycle stale connections
|
||||
- `connect_timeout`: 5 s (configurable via `DB_CONNECT_TIMEOUT`) — fail fast on unreachable DB
|
||||
|
||||
**Expected impact:** Eliminates pool exhaustion under moderate concurrency; removes indefinite
|
||||
hangs when the DB is temporarily unreachable.
|
||||
|
||||
---
|
||||
|
||||
### 2. Preferences Upsert — `apps/gateway/src/preferences/preferences.service.ts`
|
||||
|
||||
**Problem:** `upsertPref` executed two serial DB round-trips on every preference write:
|
||||
|
||||
```
|
||||
1. SELECT id FROM preferences WHERE user_id = ? AND key = ? (→ check exists)
|
||||
2a. UPDATE preferences SET value = ? … (→ if found)
|
||||
2b. INSERT INTO preferences … (→ if not found)
|
||||
```
|
||||
|
||||
Under concurrency this also had a TOCTOU race window.
|
||||
|
||||
**Fix:** Replaced with single-statement `INSERT … ON CONFLICT DO UPDATE`:
|
||||
|
||||
```sql
|
||||
INSERT INTO preferences (user_id, key, value, mutable)
|
||||
VALUES (?, ?, ?, true)
|
||||
ON CONFLICT (user_id, key) DO UPDATE SET value = excluded.value, updated_at = now();
|
||||
```
|
||||
|
||||
This required promoting `preferences_user_key_idx` from a plain index to a `UNIQUE INDEX`
|
||||
(see migration `0003_p8003_perf_indexes.sql`).
|
||||
|
||||
**Expected impact:** ~50% reduction in DB round-trips for preference writes; eliminates
|
||||
the race window.
|
||||
|
||||
---
|
||||
|
||||
### 3. Missing DB Indexes — `packages/db/src/schema.ts` + migration
|
||||
|
||||
The following indexes were added or replaced to cover common query patterns:
|
||||
|
||||
| Table | Old indexes | New / changed |
|
||||
| --------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `sessions` | _(none)_ | `sessions_user_id_idx(user_id)`, `sessions_expires_at_idx(expires_at)` |
|
||||
| `accounts` | _(none)_ | `accounts_provider_account_idx(provider_id, account_id)`, `accounts_user_id_idx(user_id)` |
|
||||
| `conversations` | `(user_id)`, `(archived)` separate | `conversations_user_archived_idx(user_id, archived)` compound |
|
||||
| `agent_logs` | `(session_id)`, `(tier)`, `(created_at)` separate | `agent_logs_session_tier_idx(session_id, tier)`, `agent_logs_tier_created_at_idx(tier, created_at)` |
|
||||
| `preferences` | non-unique `(user_id, key)` | **unique** `(user_id, key)` — required for `ON CONFLICT` |
|
||||
|
||||
**Expected impact:**
|
||||
|
||||
- Auth session validation (hot path on every request): from seq scan → index scan
|
||||
- OAuth callback account lookup: from seq scan → index scan
|
||||
- Conversation list (dashboard load): compound index covers `WHERE user_id = ? ORDER BY updated_at`
|
||||
- Log summarisation cron: `(tier, created_at)` index enables efficient hot→warm promotion query
|
||||
|
||||
All changes are in `packages/db/drizzle/0003_p8003_perf_indexes.sql`.
|
||||
|
||||
---
|
||||
|
||||
### 4. Conversation Queries — `packages/brain/src/conversations.ts`
|
||||
|
||||
**Problem:** `findAll(userId)` and `findMessages(conversationId)` were unbounded — no `LIMIT`
|
||||
and `findAll` had no `ORDER BY`, so the DB planner may not use the index efficiently.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- `findAll`: `ORDER BY updated_at DESC LIMIT 200` — returns most-recent conversations first
|
||||
- `findMessages`: `ORDER BY created_at ASC LIMIT 500` — chronological message history
|
||||
|
||||
**Expected impact:** Prevents accidental full-table scans on large datasets; ensures the
|
||||
frontend receives a usable, ordered result set regardless of table growth.
|
||||
|
||||
---
|
||||
|
||||
### 5. Cold-Start GC — `apps/gateway/src/gc/session-gc.service.ts`
|
||||
|
||||
**Problem:** `onModuleInit()` was `async` and `await`-ed `fullCollect()`, which blocked the
|
||||
NestJS module initialization chain. Full GC — which calls `redis.keys('mosaic:session:*')` and
|
||||
a DB query — typically takes 100–500 ms. This directly added to startup TTFB.
|
||||
|
||||
**Fix:** Made `onModuleInit()` synchronous and used `.then().catch()` to run GC in the
|
||||
background. The first HTTP request is no longer delayed by GC work.
|
||||
|
||||
**Expected impact:** Removes 100–500 ms from cold-start TTFB.
|
||||
|
||||
---
|
||||
|
||||
### 6. Next.js Config — `apps/web/next.config.ts`
|
||||
|
||||
**Problem:** `compress: true` was not set, so response payloads were uncompressed. No image
|
||||
format optimization or source-map suppression was configured.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- `compress: true` — enables gzip/brotli for all Next.js responses
|
||||
- `productionBrowserSourceMaps: false` — reduces build output size
|
||||
- `images.formats: ['image/avif', 'image/webp']` — Next.js Image component will serve modern
|
||||
formats to browsers that support them (typically 40–60% smaller than JPEG/PNG)
|
||||
|
||||
**Expected impact:** Typical HTML/JSON gzip savings of 60–80%; image serving cost reduced
|
||||
for any `<Image>` components added in the future.
|
||||
|
||||
---
|
||||
|
||||
## What Was Not Changed (Intentionally)
|
||||
|
||||
- **Caching layer (Valkey/Redis):** The `SystemOverrideService` and GC already use Redis
|
||||
pipelines. `PreferencesService.getEffective()` reads all user prefs in one query — this
|
||||
is appropriate for the data size and doesn't warrant an additional cache layer yet.
|
||||
- **WebSocket backpressure:** The `ChatGateway` already drops events for disconnected clients
|
||||
(`client.connected` check) and cleans up listeners on disconnect. No memory leak was found.
|
||||
- **Plugin/skill loader startup:** `SkillLoaderService.loadForSession()` is called on first
|
||||
session creation, not on startup. Already non-blocking.
|
||||
- **Frontend React memoization:** No specific hot components were identified as causing
|
||||
excessive re-renders without profiling data. No speculative `memo()` calls added.
|
||||
|
||||
---
|
||||
|
||||
## How to Apply
|
||||
|
||||
```bash
|
||||
# Run the DB migration (requires a live DB)
|
||||
pnpm --filter @mosaic/db exec drizzle-kit migrate
|
||||
|
||||
# Or, in Docker/Swarm — migrations run automatically on gateway startup
|
||||
# via runMigrations() in packages/db/src/migrate.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_Generated by P8-003 performance optimization task — 2026-03-18_
|
||||
70
docs/PRD-TUI_Improvements.md
Normal file
70
docs/PRD-TUI_Improvements.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# PRD: TUI Improvements — Phase 7
|
||||
|
||||
**Branch:** `feat/p7-tui-improvements`
|
||||
**Package:** `packages/cli`
|
||||
**Status:** In Progress
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current Mosaic CLI TUI (`packages/cli/src/tui/app.tsx`) is a minimal single-file Ink application with:
|
||||
|
||||
- Flat message list with no visual hierarchy
|
||||
- No system context visibility (cwd, branch, model, tokens)
|
||||
- Noisy error messages when gateway is disconnected
|
||||
- No conversation management (list, switch, rename, delete)
|
||||
- No multi-panel layout or navigation
|
||||
- No tool call visibility during agent execution
|
||||
- No thinking/reasoning display
|
||||
|
||||
The TUI should be the power-user interface to Mosaic — informative, responsive, and visually clean.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
### Wave 1 — Status Bar & Polish (MVP)
|
||||
|
||||
Provide essential context at a glance and reduce noise.
|
||||
|
||||
1. **Top status bar** — shows: connection indicator (●/○), gateway URL, agent model name
|
||||
2. **Bottom status bar** — shows: cwd, git branch, token usage (input/output/total)
|
||||
3. **Better message formatting** — distinct visual treatment for user vs assistant messages, timestamps, word wrap
|
||||
4. **Quiet disconnect** — single-line indicator when gateway is offline instead of flooding error messages; auto-reconnect silently
|
||||
5. **Tool call display** — inline indicators when agent uses tools (spinner + tool name during execution, ✓/✗ on completion)
|
||||
6. **Thinking/reasoning display** — collapsible dimmed block for `agent:thinking` events
|
||||
|
||||
### Wave 2 — Layout & Navigation
|
||||
|
||||
Multi-panel layout with keyboard navigation.
|
||||
|
||||
1. **Conversation sidebar** — list conversations, create new, switch between them
|
||||
2. **Keybinding system** — Ctrl+N (new conversation), Ctrl+L (conversation list toggle), Ctrl+K (command palette concept)
|
||||
3. **Scrollable message history** — viewport with PgUp/PgDn/arrow key scrolling
|
||||
4. **Message search** — find in current conversation
|
||||
|
||||
### Wave 3 — Advanced Features
|
||||
|
||||
1. **Project/mission views** — show active projects, missions, tasks
|
||||
2. **Agent status monitoring** — real-time agent state, queue depth
|
||||
3. **Settings/config screen** — view/edit connection settings, model preferences
|
||||
4. **Multiple agent sessions** — split view or tab-based multi-agent
|
||||
|
||||
---
|
||||
|
||||
## Technical Approach
|
||||
|
||||
- **Ink 5** (React for CLI) — already in deps
|
||||
- **Component architecture** — break monolithic `app.tsx` into composable components
|
||||
- **Typed Socket.IO events** — leverage `@mosaic/types` `ServerToClientEvents` / `ClientToServerEvents`
|
||||
- **Local state only** (Wave 1) — cwd/branch read from `process.cwd()` and `git` at startup
|
||||
- **Gateway metadata** (future) — extend socket handshake or add REST endpoint for model info, token usage
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals (for now)
|
||||
|
||||
- Image rendering in terminal
|
||||
- File editor integration
|
||||
- SSH/remote gateway auto-discovery
|
||||
111
docs/SSO-PROVIDERS.md
Normal file
111
docs/SSO-PROVIDERS.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# SSO Providers
|
||||
|
||||
Mosaic Stack supports optional enterprise single sign-on through Better Auth's generic OAuth flow. The gateway mounts Better Auth under `/api/auth`, so every provider callback terminates at:
|
||||
|
||||
```text
|
||||
{BETTER_AUTH_URL}/api/auth/oauth2/callback/{providerId}
|
||||
```
|
||||
|
||||
For the providers in this document:
|
||||
|
||||
- Authentik: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/authentik`
|
||||
- WorkOS: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos`
|
||||
- Keycloak: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak`
|
||||
|
||||
## Required environment variables
|
||||
|
||||
### Authentik
|
||||
|
||||
```bash
|
||||
AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic
|
||||
AUTHENTIK_CLIENT_ID=...
|
||||
AUTHENTIK_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
### WorkOS
|
||||
|
||||
```bash
|
||||
WORKOS_ISSUER=https://your-company.authkit.app
|
||||
WORKOS_CLIENT_ID=client_...
|
||||
WORKOS_CLIENT_SECRET=...
|
||||
NEXT_PUBLIC_WORKOS_ENABLED=true
|
||||
```
|
||||
|
||||
`WORKOS_ISSUER` should be the WorkOS AuthKit issuer or custom auth domain, not the raw REST API hostname. Mosaic derives the OIDC discovery URL from that issuer.
|
||||
|
||||
### Keycloak
|
||||
|
||||
```bash
|
||||
KEYCLOAK_ISSUER=https://auth.example.com/realms/master
|
||||
KEYCLOAK_CLIENT_ID=mosaic
|
||||
KEYCLOAK_CLIENT_SECRET=...
|
||||
NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
||||
```
|
||||
|
||||
If you prefer, you can keep the issuer split as:
|
||||
|
||||
```bash
|
||||
KEYCLOAK_URL=https://auth.example.com
|
||||
KEYCLOAK_REALM=master
|
||||
```
|
||||
|
||||
The auth package will derive `KEYCLOAK_ISSUER` from those two values.
|
||||
|
||||
## WorkOS setup
|
||||
|
||||
1. In WorkOS, create or select the application that will back Mosaic login.
|
||||
2. Configure an AuthKit domain or custom authentication domain for the application.
|
||||
3. Add the redirect URI:
|
||||
|
||||
```text
|
||||
{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos
|
||||
```
|
||||
|
||||
4. Copy the application's `client_id` and `client_secret` into `WORKOS_CLIENT_ID` and `WORKOS_CLIENT_SECRET`.
|
||||
5. Set `WORKOS_ISSUER` to the AuthKit domain from step 2.
|
||||
6. Create the WorkOS organization and attach the enterprise SSO connection you want Mosaic to use.
|
||||
7. Set `NEXT_PUBLIC_WORKOS_ENABLED=true` in the web deployment so the login button is rendered.
|
||||
|
||||
## Keycloak setup
|
||||
|
||||
1. Start from an existing Keycloak realm or create a dedicated realm for Mosaic.
|
||||
2. Create a confidential OIDC client named `mosaic` or your preferred client ID.
|
||||
3. Set the valid redirect URI to:
|
||||
|
||||
```text
|
||||
{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak
|
||||
```
|
||||
|
||||
4. Set the web origin to the public Mosaic web URL.
|
||||
5. Copy the client secret into `KEYCLOAK_CLIENT_SECRET`.
|
||||
6. Set either `KEYCLOAK_ISSUER` directly or `KEYCLOAK_URL` + `KEYCLOAK_REALM`.
|
||||
7. Set `NEXT_PUBLIC_KEYCLOAK_ENABLED=true` in the web deployment so the login button is rendered.
|
||||
|
||||
### Local Keycloak smoke test
|
||||
|
||||
If you want to test locally with Docker:
|
||||
|
||||
```bash
|
||||
docker run --rm --name mosaic-keycloak \
|
||||
-p 8080:8080 \
|
||||
-e KEYCLOAK_ADMIN=admin \
|
||||
-e KEYCLOAK_ADMIN_PASSWORD=admin \
|
||||
quay.io/keycloak/keycloak:26.1 start-dev
|
||||
```
|
||||
|
||||
Then configure:
|
||||
|
||||
```bash
|
||||
KEYCLOAK_ISSUER=http://localhost:8080/realms/master
|
||||
KEYCLOAK_CLIENT_ID=mosaic
|
||||
KEYCLOAK_CLIENT_SECRET=...
|
||||
NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
||||
```
|
||||
|
||||
## Web flow
|
||||
|
||||
The web login page renders provider buttons from `NEXT_PUBLIC_*_ENABLED` flags. Each button links to `/auth/provider/{providerId}`, and that page initiates Better Auth's `signIn.oauth2` flow before handing off to the provider.
|
||||
|
||||
## Failure mode
|
||||
|
||||
Provider config is optional, but partial config is rejected at startup. If any provider-specific env var is present without the full required set, `@mosaic/auth` throws a bootstrap error with the missing keys instead of silently registering a broken provider.
|
||||
105
docs/TASKS-TUI_Improvements.md
Normal file
105
docs/TASKS-TUI_Improvements.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Tasks: TUI Improvements
|
||||
|
||||
**Branch:** `feat/p7-tui-improvements`
|
||||
**Worktree:** `/home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements`
|
||||
**PRD:** [PRD-TUI_Improvements.md](./PRD-TUI_Improvements.md)
|
||||
|
||||
---
|
||||
|
||||
## Wave 1 — Status Bar & Polish ✅
|
||||
|
||||
| ID | Task | Status | Notes |
|
||||
| -------- | ----------------------------------------------------------------------------------------------------- | ------- | ------- |
|
||||
| TUI-001 | Component architecture — split `app.tsx` into `TopBar`, `BottomBar`, `MessageList`, `InputBar`, hooks | ✅ done | 79ff308 |
|
||||
| TUI-002 | Top status bar — branded mosaic icon, version, model, connection indicator | ✅ done | 6c2b01e |
|
||||
| TUI-003 | Bottom status bar — cwd, git branch, token usage, session ID, gateway status | ✅ done | e8d7ab8 |
|
||||
| TUI-004 | Message formatting — timestamps, role colors (❯ you / ◆ assistant), word wrap | ✅ done | 79ff308 |
|
||||
| TUI-005 | Quiet disconnect — single indicator, auto-reconnect, no error flood | ✅ done | 79ff308 |
|
||||
| TUI-006 | Tool call display — inline spinner + tool name during execution, ✓/✗ on completion | ✅ done | 79ff308 |
|
||||
| TUI-007 | Thinking/reasoning display — dimmed 💭 block for `agent:thinking` events | ✅ done | 79ff308 |
|
||||
| TUI-007b | Wire token usage, model info, thinking levels end-to-end (gateway → types → TUI) | ✅ done | a061a64 |
|
||||
| TUI-007c | Ctrl+T to cycle thinking levels via `set:thinking` socket event | ✅ done | a061a64 |
|
||||
|
||||
## Wave 2 — Layout & Navigation ✅
|
||||
|
||||
| ID | Task | Status | Notes |
|
||||
| ------- | --------------------------------------------------------- | ------- | ------- |
|
||||
| TUI-010 | Scrollable message history — viewport with PgUp/PgDn | ✅ done | 4d4ad38 |
|
||||
| TUI-008 | Conversation sidebar — list, create, switch conversations | ✅ done | 9ef578c |
|
||||
| TUI-009 | Keybinding system — Ctrl+L, Ctrl+N, Ctrl+K, Escape | ✅ done | 9f38f5a |
|
||||
| TUI-011 | Message search — find in current conversation | ✅ done | 8627827 |
|
||||
|
||||
## Wave 3 — Advanced Features
|
||||
|
||||
| ID | Task | Status | Notes |
|
||||
| ------- | ----------------------- | ----------- | ----- |
|
||||
| TUI-012 | Project/mission views | not-started | |
|
||||
| TUI-013 | Agent status monitoring | not-started | |
|
||||
| TUI-014 | Settings/config screen | not-started | |
|
||||
| TUI-015 | Multiple agent sessions | not-started | |
|
||||
|
||||
---
|
||||
|
||||
## Handoff Notes
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
packages/cli/src/tui/
|
||||
├── app.tsx ← Shell composing all components + global keybindings
|
||||
├── components/
|
||||
│ ├── top-bar.tsx ← Mosaic icon + version + model + connection
|
||||
│ ├── bottom-bar.tsx ← Keybinding hints + 3-line footer: gateway, cwd, tokens
|
||||
│ ├── message-list.tsx ← Messages, tool calls, thinking, streaming, search highlights
|
||||
│ ├── input-bar.tsx ← Bordered prompt with context-aware placeholder
|
||||
│ ├── sidebar.tsx ← Conversation list with keyboard navigation
|
||||
│ └── search-bar.tsx ← Message search input with match count + navigation
|
||||
└── hooks/
|
||||
├── use-socket.ts ← Typed Socket.IO + switchConversation/clearMessages
|
||||
├── use-git-info.ts ← Reads cwd + git branch at startup
|
||||
├── use-viewport.ts ← Scrollable viewport with auto-follow + PgUp/PgDn
|
||||
├── use-app-mode.ts ← Panel focus state machine (chat/sidebar/search)
|
||||
├── use-conversations.ts ← REST client for conversation CRUD
|
||||
└── use-search.ts ← Message search with match cycling
|
||||
```
|
||||
|
||||
### Cross-Package Changes
|
||||
|
||||
- **`packages/types/src/chat/events.ts`** — Added `SessionUsagePayload`, `SessionInfoPayload`, `SetThinkingPayload`, `session:info` event, `set:thinking` event
|
||||
- **`apps/gateway/src/chat/chat.gateway.ts`** — Emits `session:info` on session creation, includes `usage` in `agent:end`, handles `set:thinking`
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
#### Wave 1
|
||||
|
||||
- Footer is 3 lines: (1) gateway status right-aligned, (2) cwd+branch left / session right, (3) tokens left / provider+model+thinking right
|
||||
- Mosaic icon uses brand colors in windmill cross pattern with `GAP` const to prevent prettier collapsing spaces
|
||||
- `flexGrow={1}` on header text column prevents re-render artifacts
|
||||
- Token/model data comes from gateway via `agent:end` payload and `session:info` events
|
||||
- Thinking level cycling via Ctrl+T sends `set:thinking` to gateway, which validates and responds with `session:info`
|
||||
|
||||
#### Wave 2
|
||||
|
||||
- `useViewport` calculates scroll offset from terminal rows; auto-follow snaps to bottom on new messages
|
||||
- `useAppMode` state machine manages focus: only the active panel handles keyboard input via `useInput({ isActive })`
|
||||
- Sidebar fetches conversations via REST (`GET /api/conversations`), not socket events
|
||||
- `switchConversation` in `useSocket` clears all local state (messages, streaming, tool calls)
|
||||
- Search uses `useMemo` for reactive match computation; viewport auto-scrolls to current match
|
||||
- Keybinding hints shown in bottom bar: `^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll`
|
||||
|
||||
### How to Run
|
||||
|
||||
```bash
|
||||
cd /home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements
|
||||
pnpm --filter @mosaic/cli exec tsx src/cli.ts tui
|
||||
# or after build:
|
||||
node packages/cli/dist/cli.js tui --gateway http://localhost:4000
|
||||
```
|
||||
|
||||
### Quality Gates
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaic/cli typecheck && pnpm --filter @mosaic/cli lint
|
||||
pnpm --filter @mosaic/gateway typecheck && pnpm --filter @mosaic/gateway lint
|
||||
pnpm --filter @mosaic/types typecheck
|
||||
```
|
||||
173
docs/TASKS.md
173
docs/TASKS.md
@@ -1,81 +1,100 @@
|
||||
# Tasks — MVP
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **`agent` column values:** `codex` | `sonnet` | `haiku` | `glm-5` | `opus` | `—` (auto/default)
|
||||
> Pipeline crons pick the cheapest capable model. Override with a specific value when a task genuinely needs it.
|
||||
> Examples: `opus` for major architecture decisions, `codex` for pure coding, `haiku` for review/verify gates, `glm-5` for cost-sensitive coding.
|
||||
|
||||
| id | status | milestone | description | pr | notes |
|
||||
| ------ | ----------- | --------- | ------------------------------------------------------------------- | ---- | ------------- |
|
||||
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
||||
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
|
||||
| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 |
|
||||
| P0-004 | done | Phase 0 | @mosaic/auth — BetterAuth email/password setup | #68 | #4 |
|
||||
| P0-005 | done | Phase 0 | Docker Compose — PG 17, Valkey 8, SigNoz | #65 | #5 |
|
||||
| P0-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 |
|
||||
| P0-007 | done | Phase 0 | CI pipeline — Woodpecker config | #69 | #7 |
|
||||
| P0-008 | done | Phase 0 | Project docs — AGENTS.md, CLAUDE.md, README | #69 | #8 |
|
||||
| P0-009 | done | Phase 0 | Verify Phase 0 — CI green, all packages build | #70 | #9 |
|
||||
| P1-001 | done | Phase 1 | apps/gateway scaffold — NestJS + Fastify adapter | #61 | #10 |
|
||||
| P1-002 | done | Phase 1 | Auth middleware — BetterAuth session validation | #71 | #11 |
|
||||
| P1-003 | done | Phase 1 | @mosaic/brain — migrate from v0, PG backend | #71 | #12 |
|
||||
| P1-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 |
|
||||
| P1-005 | done | Phase 1 | Gateway routes — conversations CRUD + messages | #72 | #14 |
|
||||
| P1-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 |
|
||||
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
|
||||
| P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 |
|
||||
| P1-009 | done | Phase 1 | Verify Phase 1 — gateway functional, API tested | #73 | #18 |
|
||||
| P2-001 | done | Phase 2 | @mosaic/agent — Pi SDK integration + agent pool | #61 | #19 |
|
||||
| P2-002 | done | Phase 2 | Multi-provider support — Anthropic + Ollama | #74 | #20 |
|
||||
| P2-003 | done | Phase 2 | Agent routing engine — cost/capability matrix | #75 | #21 |
|
||||
| P2-004 | done | Phase 2 | Tool registration — brain, queue, memory tools | #76 | #22 |
|
||||
| P2-005 | done | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | #77 | #23 |
|
||||
| P2-006 | done | Phase 2 | Agent session management — tmux + monitoring | #78 | #24 |
|
||||
| P2-007 | done | Phase 2 | Verify Phase 2 — multi-provider routing works | #79 | #25 |
|
||||
| P3-001 | done | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | #82 | #26 |
|
||||
| P3-002 | done | Phase 3 | Auth pages — login, registration, SSO redirect | #83 | #27 |
|
||||
| P3-003 | done | Phase 3 | Chat UI — conversations, messages, streaming | #84 | #28 |
|
||||
| P3-004 | done | Phase 3 | Task management — list view + kanban board | #86 | #29 |
|
||||
| P3-005 | done | Phase 3 | Project & mission views — dashboard + PRD viewer | #87 | #30 |
|
||||
| P3-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 |
|
||||
| P3-007 | done | Phase 3 | Admin panel — user management, RBAC | #89 | #32 |
|
||||
| P3-008 | done | Phase 3 | Verify Phase 3 — web dashboard functional E2E | — | #33 |
|
||||
| P4-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 |
|
||||
| P4-002 | done | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 |
|
||||
| P4-003 | done | Phase 4 | @mosaic/log — log ingest, parsing, tiered storage | — | #36 |
|
||||
| P4-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 |
|
||||
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
|
||||
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
|
||||
| P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 |
|
||||
| P5-001 | done | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 |
|
||||
| P5-002 | done | Phase 5 | @mosaic/discord-plugin — Discord bot + channel plugin | #61 | #42 |
|
||||
| P5-003 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
|
||||
| P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
|
||||
| P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 |
|
||||
| P6-001 | done | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | #104 | #46 |
|
||||
| P6-002 | done | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | #101 | #47 |
|
||||
| P6-003 | done | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | #100 | #48 |
|
||||
| P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 |
|
||||
| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
|
||||
| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
||||
| P7-009 | done | Phase 7 | Web chat — WebSocket integration, streaming, conversation switching | #136 | #120 W1 done |
|
||||
| P7-001 | done | Phase 7 | MCP endpoint hardening — streamable HTTP transport | #137 | #52 W1 done |
|
||||
| P7-010 | done | Phase 7 | Web conversation management — list, search, rename, delete, archive | #139 | #121 W2 done |
|
||||
| P7-015 | done | Phase 7 | Agent tool expansion — file ops, git, shell exec, web fetch | #138 | #126 W2 done |
|
||||
| P7-011 | done | Phase 7 | Web project detail views — missions, tasks, PRDs, dashboards | #140 | #122 W3 done |
|
||||
| P7-016 | done | Phase 7 | MCP client — gateway connects to external MCP servers as tools | #141 | #127 W3 done |
|
||||
| P7-012 | done | Phase 7 | Web provider management UI — add, configure, test LLM providers | #142 | #123 W4 done |
|
||||
| P7-017 | done | Phase 7 | Agent skill invocation — load and execute skills from catalog | #143 | #128 W4 done |
|
||||
| P7-013 | done | Phase 7 | Web settings persistence — profile, preferences save to DB | #145 | #124 W5 done |
|
||||
| P7-018 | done | Phase 7 | CLI model/provider switching — --model, --provider, /model in TUI | #144 | #129 W5 done |
|
||||
| P7-014 | done | Phase 7 | Web admin panel — user CRUD, role assignment, system health | #150 | #125 W6 done |
|
||||
| P7-019 | done | Phase 7 | CLI session management — list, resume, destroy sessions | #146 | #130 W6 done |
|
||||
| P7-020 | done | Phase 7 | Coord DB migration — project-scoped missions, multi-tenant RBAC | #149 | #131 W7 done |
|
||||
| FIX-02 | done | Backlog | TUI agent:end — fix React state updater side-effect | #147 | #133 W8 done |
|
||||
| FIX-03 | done | Backlog | Agent session — cwd sandbox, system prompt, tool restrictions | #148 | #134 W8 done |
|
||||
| P7-004 | done | Phase 7 | E2E test suite — Playwright critical paths | #152 | #55 W9 done |
|
||||
| P7-006 | done | Phase 7 | Documentation — user guide, admin guide, dev guide | #151 | #57 W9 done |
|
||||
| P7-007 | done | Phase 7 | Bare-metal deployment docs + .env.example | #153 | #58 W9 done |
|
||||
| P7-021 | done | Phase 7 | Verify Phase 7 — feature-complete platform E2E | — | #132 W10 done |
|
||||
| P8-001 | not-started | Phase 8 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
|
||||
| P8-002 | not-started | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
||||
| P8-003 | not-started | Phase 8 | Performance optimization | — | #56 |
|
||||
| P8-004 | not-started | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
|
||||
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
||||
| id | status | agent | milestone | description | pr | notes |
|
||||
| ------ | ----------- | ------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------- | ----- |
|
||||
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
||||
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
|
||||
| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 |
|
||||
| P0-004 | done | Phase 0 | @mosaic/auth — BetterAuth email/password setup | #68 | #4 |
|
||||
| P0-005 | done | Phase 0 | Docker Compose — PG 17, Valkey 8, SigNoz | #65 | #5 |
|
||||
| P0-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 |
|
||||
| P0-007 | done | Phase 0 | CI pipeline — Woodpecker config | #69 | #7 |
|
||||
| P0-008 | done | Phase 0 | Project docs — AGENTS.md, CLAUDE.md, README | #69 | #8 |
|
||||
| P0-009 | done | Phase 0 | Verify Phase 0 — CI green, all packages build | #70 | #9 |
|
||||
| P1-001 | done | Phase 1 | apps/gateway scaffold — NestJS + Fastify adapter | #61 | #10 |
|
||||
| P1-002 | done | Phase 1 | Auth middleware — BetterAuth session validation | #71 | #11 |
|
||||
| P1-003 | done | Phase 1 | @mosaic/brain — migrate from v0, PG backend | #71 | #12 |
|
||||
| P1-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 |
|
||||
| P1-005 | done | Phase 1 | Gateway routes — conversations CRUD + messages | #72 | #14 |
|
||||
| P1-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 |
|
||||
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
|
||||
| P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 |
|
||||
| P1-009 | done | Phase 1 | Verify Phase 1 — gateway functional, API tested | #73 | #18 |
|
||||
| P2-001 | done | Phase 2 | @mosaic/agent — Pi SDK integration + agent pool | #61 | #19 |
|
||||
| P2-002 | done | Phase 2 | Multi-provider support — Anthropic + Ollama | #74 | #20 |
|
||||
| P2-003 | done | Phase 2 | Agent routing engine — cost/capability matrix | #75 | #21 |
|
||||
| P2-004 | done | Phase 2 | Tool registration — brain, queue, memory tools | #76 | #22 |
|
||||
| P2-005 | done | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | #77 | #23 |
|
||||
| P2-006 | done | Phase 2 | Agent session management — tmux + monitoring | #78 | #24 |
|
||||
| P2-007 | done | Phase 2 | Verify Phase 2 — multi-provider routing works | #79 | #25 |
|
||||
| P3-001 | done | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | #82 | #26 |
|
||||
| P3-002 | done | Phase 3 | Auth pages — login, registration, SSO redirect | #83 | #27 |
|
||||
| P3-003 | done | Phase 3 | Chat UI — conversations, messages, streaming | #84 | #28 |
|
||||
| P3-004 | done | Phase 3 | Task management — list view + kanban board | #86 | #29 |
|
||||
| P3-005 | done | Phase 3 | Project & mission views — dashboard + PRD viewer | #87 | #30 |
|
||||
| P3-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 |
|
||||
| P3-007 | done | Phase 3 | Admin panel — user management, RBAC | #89 | #32 |
|
||||
| P3-008 | done | Phase 3 | Verify Phase 3 — web dashboard functional E2E | — | #33 |
|
||||
| P4-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 |
|
||||
| P4-002 | done | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 |
|
||||
| P4-003 | done | Phase 4 | @mosaic/log — log ingest, parsing, tiered storage | — | #36 |
|
||||
| P4-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 |
|
||||
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
|
||||
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
|
||||
| P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 |
|
||||
| P5-001 | done | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 |
|
||||
| P5-002 | done | Phase 5 | @mosaic/discord-plugin — Discord bot + channel plugin | #61 | #42 |
|
||||
| P5-003 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
|
||||
| P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
|
||||
| P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 |
|
||||
| P6-001 | done | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | #104 | #46 |
|
||||
| P6-002 | done | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | #101 | #47 |
|
||||
| P6-003 | done | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | #100 | #48 |
|
||||
| P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 |
|
||||
| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
|
||||
| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
||||
| P7-009 | done | Phase 7 | Web chat — WebSocket integration, streaming, conversation switching | #136 | #120 W1 done |
|
||||
| P7-001 | done | Phase 7 | MCP endpoint hardening — streamable HTTP transport | #137 | #52 W1 done |
|
||||
| P7-010 | done | Phase 7 | Web conversation management — list, search, rename, delete, archive | #139 | #121 W2 done |
|
||||
| P7-015 | done | Phase 7 | Agent tool expansion — file ops, git, shell exec, web fetch | #138 | #126 W2 done |
|
||||
| P7-011 | done | Phase 7 | Web project detail views — missions, tasks, PRDs, dashboards | #140 | #122 W3 done |
|
||||
| P7-016 | done | Phase 7 | MCP client — gateway connects to external MCP servers as tools | #141 | #127 W3 done |
|
||||
| P7-012 | done | Phase 7 | Web provider management UI — add, configure, test LLM providers | #142 | #123 W4 done |
|
||||
| P7-017 | done | Phase 7 | Agent skill invocation — load and execute skills from catalog | #143 | #128 W4 done |
|
||||
| P7-013 | done | Phase 7 | Web settings persistence — profile, preferences save to DB | #145 | #124 W5 done |
|
||||
| P7-018 | done | Phase 7 | CLI model/provider switching — --model, --provider, /model in TUI | #144 | #129 W5 done |
|
||||
| P7-014 | done | Phase 7 | Web admin panel — user CRUD, role assignment, system health | #150 | #125 W6 done |
|
||||
| P7-019 | done | Phase 7 | CLI session management — list, resume, destroy sessions | #146 | #130 W6 done |
|
||||
| P7-020 | done | Phase 7 | Coord DB migration — project-scoped missions, multi-tenant RBAC | #149 | #131 W7 done |
|
||||
| FIX-02 | done | Backlog | TUI agent:end — fix React state updater side-effect | #147 | #133 W8 done |
|
||||
| FIX-03 | done | Backlog | Agent session — cwd sandbox, system prompt, tool restrictions | #148 | #134 W8 done |
|
||||
| P7-004 | done | Phase 7 | E2E test suite — Playwright critical paths | #152 | #55 W9 done |
|
||||
| P7-006 | done | Phase 7 | Documentation — user guide, admin guide, dev guide | #151 | #57 W9 done |
|
||||
| P7-007 | done | Phase 7 | Bare-metal deployment docs + .env.example | #153 | #58 W9 done |
|
||||
| P7-021 | done | Phase 7 | Verify Phase 7 — feature-complete platform E2E | — | #132 W10 done |
|
||||
| P8-005 | done | Phase 8 | CLI command architecture — DB schema + brain repo + gateway endpoints | #158 | |
|
||||
| P8-006 | done | Phase 8 | CLI command architecture — agent, mission, prdy commands + TUI mods | #158 | |
|
||||
| P8-007 | done | Phase 8 | DB migrations — preferences.mutable + teams + team_members + projects.teamId | #175 | #160 |
|
||||
| P8-008 | done | Phase 8 | @mosaic/types — CommandDef, CommandManifest, new socket events | #174 | #161 |
|
||||
| P8-009 | done | Phase 8 | TUI Phase 1 — slash command parsing, local commands, system message rendering, InputBar wiring | #176 | #162 |
|
||||
| P8-010 | done | Phase 8 | Gateway Phase 2 — CommandRegistryService, CommandExecutorService, socket + REST commands | #178 | #163 |
|
||||
| P8-011 | done | Phase 8 | Gateway Phase 3 — PreferencesService, /preferences REST, /system Valkey override, prompt injection | #180 | #164 |
|
||||
| P8-012 | done | Phase 8 | Gateway Phase 4 — /agent, /provider (URL+clipboard), /mission, /prdy, /tools commands | #181 | #165 |
|
||||
| P8-013 | done | Phase 8 | Gateway Phase 5 — MosaicPlugin lifecycle, ReloadService, hot reload, system:reload TUI | #182 | #166 |
|
||||
| P8-014 | done | Phase 8 | Gateway Phase 6 — SessionGCService (all tiers), /gc command, cron integration | #179 | #167 |
|
||||
| P8-015 | done | Phase 8 | Gateway Phase 7 — WorkspaceService, ProjectBootstrapService, teams project ownership | #183 | #168 |
|
||||
| P8-016 | done | Phase 8 | Security — file/git/shell tool strict path hardening, sandbox escape prevention | #177 | #169 |
|
||||
| P8-017 | done | Phase 8 | TUI Phase 8 — autocomplete sidebar, fuzzy match, arg hints, up-arrow history | #184 | #170 |
|
||||
| P8-018 | done | Phase 8 | Spin-off plan stubs — Gatekeeper, Task Queue Unification, Chroot Sandboxing | — | #171 |
|
||||
| P8-019 | done | Phase 8 | Verify Platform Architecture — integration + E2E verification | #185 | #172 |
|
||||
| P8-001 | in-progress | codex | Phase 8 | Additional SSO providers — WorkOS + Keycloak | #210 | #53 |
|
||||
| P8-002 | in-progress | codex | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
||||
| P8-003 | in-progress | codex | Phase 8 | Performance optimization | — | #56 |
|
||||
| P8-004 | done | haiku | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
|
||||
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
||||
|
||||
1572
docs/plans/2026-03-15-agent-platform-architecture.md
Normal file
1572
docs/plans/2026-03-15-agent-platform-architecture.md
Normal file
File diff suppressed because it is too large
Load Diff
1000
docs/plans/2026-03-15-wave2-tui-layout-navigation.md
Normal file
1000
docs/plans/2026-03-15-wave2-tui-layout-navigation.md
Normal file
File diff suppressed because it is too large
Load Diff
60
docs/plans/chroot-sandboxing.md
Normal file
60
docs/plans/chroot-sandboxing.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Chroot Agent Sandboxing — Process Isolation for Agent Tool Execution
|
||||
|
||||
> **Status:** Stub — deferred. Referenced from `2026-03-15-agent-platform-architecture.md` (Phase 7 Workspaces → Chroot Agent Sandboxing).
|
||||
> Implement after Workspaces (P8-015) is complete. Requires workspace directory structure and `WorkspaceService` to be operational.
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Packages:** `apps/gateway`
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Agent sessions can use file, git, and shell tools. Path validation in tools is defense-in-depth but insufficient alone — an agent with shell access can run `cat /opt/mosaic/.workspaces/other_user/...` and bypass gateway RBAC.
|
||||
|
||||
Chroot provides OS-level enforcement: tool processes literally cannot see outside their workspace directory.
|
||||
|
||||
---
|
||||
|
||||
## Design (Sweet Spot)
|
||||
|
||||
Chroot strikes the balance between full container isolation (too heavy per session) and path validation only (escape-prone):
|
||||
|
||||
- Gateway spawns tool processes inside a chroot rooted at the session's `sandboxDir`
|
||||
- Requires `CAP_SYS_CHROOT` capability on the gateway process (not full root)
|
||||
- Chroot environment provisioned by `WorkspaceService` on workspace creation (minimal deps: git, shell utils, language runtimes as needed)
|
||||
- Alternative for Docker deployments: Linux `unshare` namespaces (lighter, no chroot env setup)
|
||||
|
||||
---
|
||||
|
||||
## Scope (To Be Designed)
|
||||
|
||||
- [ ] Chroot environment provisioning — `WorkspaceService.provisionChroot(workspacePath)` on project creation
|
||||
- [ ] Minimal chroot deps — identify required binaries/libs per tool type (file: none; git: git binary; shell: bash, common utils)
|
||||
- [ ] Gateway capability — document `CAP_SYS_CHROOT` requirement; Dockerfile and docker-compose.yml changes
|
||||
- [ ] Tool process spawning — modify `createShellTools`, `createFileTools`, `createGitTools` to spawn via chroot wrapper
|
||||
- [ ] Docker alternative — `unshare --mount --pid --user` namespace wrapper as fallback for environments without chroot capability
|
||||
- [ ] Defense-in-depth layering — chroot + path validation both active; neither alone is sufficient
|
||||
- [ ] Chroot cleanup — integrate with `SessionGCService` / workspace deletion
|
||||
- [ ] AppArmor/SELinux profiles (v2) — restrict gateway process file access patterns for multi-tenant hardening
|
||||
|
||||
---
|
||||
|
||||
## Security Constraints
|
||||
|
||||
- What lives **inside** the chroot (agent-accessible): workspace files, git repo, language runtimes
|
||||
- What lives **outside** the chroot (gateway-only, never agent-accessible): Valkey connection, PG connection, other users' workspaces, gateway config, OTEL endpoint, credentials
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Workspaces (P8-015) — chroot is rooted at workspace directory; workspace must exist first
|
||||
- Tool hardening (P8-016) — path validation stays active as defense-in-depth alongside chroot
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Original design context: `docs/plans/2026-03-15-agent-platform-architecture.md` → "Chroot Agent Sandboxing" section
|
||||
- Current tool implementations: `apps/gateway/src/agent/tools/`
|
||||
53
docs/plans/gatekeeper-service.md
Normal file
53
docs/plans/gatekeeper-service.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Gatekeeper Service — PR Review, Quality Gates & Merge Authority
|
||||
|
||||
> **Status:** Stub — deferred. Referenced from `2026-03-15-agent-platform-architecture.md` (Phase 7 Workspaces).
|
||||
> Implement after Workspaces (P8-015) is complete and the workspace/git infrastructure is operational.
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Packages:** `apps/gateway`, `packages/types`, `packages/agent`
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Project agents create PRs but cannot review or merge their own work. A separate, isolated agent service with read-only code access and quality gate enforcement is needed to act as the authoritative merge authority.
|
||||
|
||||
The Gatekeeper existed in the old Mosaic codebase and must be ported/redesigned for mosaic-mono-v1.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Constraints
|
||||
|
||||
- **Isolated trust boundary** — project agents cannot invoke Gatekeeper directly; it listens for PR events from the git provider
|
||||
- **`isSystem: true`** — system agent, not editable by users
|
||||
- **Read-only code access** — reads diffs and runs checks; cannot commit or push
|
||||
- **Quality gates required before merge** — lint, typecheck, test results must pass
|
||||
- **Cannot self-approve** — the agent that authored the PR cannot be the Gatekeeper for that PR
|
||||
|
||||
---
|
||||
|
||||
## Scope (To Be Designed)
|
||||
|
||||
- [ ] Gatekeeper agent bootstrap — system agent config, tool set, prompt engineering
|
||||
- [ ] PR event listener — Gitea/GitHub webhook integration (PR opened/updated/ready)
|
||||
- [ ] Quality gate runner — trigger CI checks, poll for results, enforce pass criteria
|
||||
- [ ] Review generation — LLM-driven code review comment generation
|
||||
- [ ] Merge execution — approve + merge when gates pass; reject with comments when they fail
|
||||
- [ ] Configurable strictness — per-project required checks, review depth
|
||||
- [ ] Trust boundary enforcement — gateway rejects Gatekeeper tool calls that exceed read-only scope
|
||||
- [ ] Audit trail — OTEL spans for all Gatekeeper decisions (approve/reject/merge)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Workspaces (P8-015) — Gatekeeper needs project workspace layout to locate code
|
||||
- Git provider API tools — PR creation/review/merge API (Gitea/GitHub/GitLab)
|
||||
- CI/CD tool integration — Woodpecker pipeline status polling
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Original design context: `docs/plans/2026-03-15-agent-platform-architecture.md` → "Gatekeeper Service" section
|
||||
- Workspace RBAC and agent trust model: same document → "RBAC & Filesystem Security"
|
||||
60
docs/plans/task-queue-unification.md
Normal file
60
docs/plans/task-queue-unification.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Task Queue Unification — @mosaic/queue as Unified Orchestration Layer
|
||||
|
||||
> **Status:** Stub — deferred. Referenced from `2026-03-15-agent-platform-architecture.md` (Task Queue & Orchestration section).
|
||||
> Implement after Workspaces (P8-015) is complete. Requires workspace file structure to be in place.
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Packages:** `packages/queue`, `packages/coord`, `packages/db`, `apps/gateway`
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Two disconnected task systems exist:
|
||||
|
||||
1. **`@mosaic/coord`** — file-based missions (`mission.json`, `TASKS.md`), file locks, subprocess spawning. Single-machine orchestrator pattern.
|
||||
2. **PG tables** (`tasks`, `mission_tasks`, `missions`) — DB-backed CRUD, REST API, Brain repos.
|
||||
|
||||
An agent using `coord_mission_status` gets file data. The dashboard shows DB data. They are never in sync.
|
||||
|
||||
---
|
||||
|
||||
## Vision
|
||||
|
||||
`@mosaic/queue` becomes the unified task orchestration service bridging PG, workspace files, and Valkey:
|
||||
|
||||
- DB is source of truth for structured state (status, assignees, timestamps)
|
||||
- Workspace files (`TASKS.md`, PRDs) are working copies for agent interaction
|
||||
- Valkey handles real-time assignment queues and agent claim locks
|
||||
- Flatfile fallback for no-DB single-machine deployments (preserves `@mosaic/coord` pattern)
|
||||
|
||||
---
|
||||
|
||||
## Scope (To Be Designed)
|
||||
|
||||
- [ ] `@mosaic/queue` refactor — elevate from ioredis primitive to task orchestration service
|
||||
- [ ] DB ↔ file sync layer — writes to PG propagate to `TASKS.md`; file edits by agents sync back
|
||||
- [ ] Task assignment queue — Valkey-backed RPUSH/BLPOP for agent task claiming
|
||||
- [ ] Agent claim locks — `mosaic:queue:project:{id}:lock:{taskId}` with TTL
|
||||
- [ ] `@mosaic/coord` consolidation — file-based ops ported into queue service; `@mosaic/coord` becomes thin adapter or deprecated
|
||||
- [ ] Flatfile fallback — queue service writes JSON manifests when PG unavailable
|
||||
- [ ] Status pub/sub — real-time task status updates via Valkey pub/sub
|
||||
- [ ] Dependency resolution — block task assignment until dependencies are met
|
||||
- [ ] Orchestrator monitor — gateway process watches task queue, assigns next based on dependency graph
|
||||
- [ ] API surface — queue service exposes typed interface used by agents, gateway, and CLI
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Workspaces (P8-015) — file sync targets the workspace directory structure
|
||||
- Teams architecture (P8-007) — project ownership determines queue namespacing
|
||||
- DB schema stable — task/mission tables must not change mid-unification
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Original design context: `docs/plans/2026-03-15-agent-platform-architecture.md` → "Task Queue & Orchestration" section
|
||||
- Current `@mosaic/coord` implementation: `packages/coord/src/`
|
||||
- Current `@mosaic/queue` implementation: `packages/queue/src/`
|
||||
47
docs/scratchpads/BUG-CLI-scratchpad.md
Normal file
47
docs/scratchpads/BUG-CLI-scratchpad.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# BUG-CLI Scratchpad
|
||||
|
||||
## Objective
|
||||
|
||||
Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
||||
|
||||
## Issues
|
||||
|
||||
- #192: Ctrl+T leaks 't' into input
|
||||
- #193: Duplicate React keys in CommandAutocomplete
|
||||
- #194: /provider login false clipboard claim
|
||||
- #199: TUI shows hardcoded version "0.0.0"
|
||||
|
||||
## Plan and Fixes
|
||||
|
||||
### Bug #192 — Ctrl+T character leak
|
||||
|
||||
- Location: `packages/cli/src/tui/app.tsx`
|
||||
- Fix: Added `ctrlJustFired` ref. Set synchronously in Ctrl+T/L/N/K handlers, cleared via microtask.
|
||||
In the `onChange` wrapper passed to `InputBar`, if `ctrlJustFired.current` is true, suppress the
|
||||
leaked character and return early.
|
||||
|
||||
### Bug #193 — Duplicate React keys
|
||||
|
||||
- Location: `packages/cli/src/tui/components/command-autocomplete.tsx`
|
||||
- Fix: Changed `key={cmd.name}` to `key={`${cmd.execution}-${cmd.name}`}` for uniqueness.
|
||||
- Also: `packages/cli/src/tui/commands/registry.ts` — `getAll()` now deduplicates gateway commands
|
||||
that share a name with local commands. Local commands take precedence.
|
||||
|
||||
### Bug #194 — False clipboard claim
|
||||
|
||||
- Location: `apps/gateway/src/commands/command-executor.service.ts`
|
||||
- Fix: Removed the `\n\n(URL copied to clipboard)` suffix from the provider login message.
|
||||
|
||||
### Bug #199 — Hardcoded version "0.0.0"
|
||||
|
||||
- Location: `packages/cli/src/cli.ts` + `packages/cli/src/tui/app.tsx`
|
||||
- Fix: `cli.ts` reads version from `../package.json` via `createRequire`. Passes `version: CLI_VERSION`
|
||||
to TuiApp in both render calls. TuiApp has new optional `version` prop (defaults to '0.0.0'),
|
||||
passes it to TopBar instead of hardcoded `"0.0.0"`.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
- CLI typecheck: PASSED
|
||||
- CLI lint: PASSED
|
||||
- Prettier format:check: PASSED
|
||||
- Gateway lint: PASSED
|
||||
37
docs/scratchpads/bug-196-admin-redirect.md
Normal file
37
docs/scratchpads/bug-196-admin-redirect.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# BUG-196: Admin Page Redirect Issue
|
||||
|
||||
## Problem
|
||||
|
||||
Admin page redirects to /chat for users with admin role because role check fails.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `role` field is defined as an `additionalField` in better-auth's user configuration, but
|
||||
better-auth v1.5.5 does not automatically include additionalFields in the session response from
|
||||
the `getSession()` API. This causes the admin role check to fail:
|
||||
|
||||
- Frontend: `AdminRoleGuard` checks `user?.role !== 'admin'`
|
||||
- Backend: `AdminGuard` checks `user.role !== 'admin'`
|
||||
- When `role` is `undefined`, both checks treat the user as non-admin and deny access
|
||||
|
||||
## Solution
|
||||
|
||||
Implemented a defensive check in the backend `AdminGuard` that:
|
||||
|
||||
1. First tries to use the `role` field from the session (if better-auth includes it)
|
||||
2. Falls back to fetching the role directly from the database if it's missing
|
||||
3. Defaults to 'member' if the user has no role set
|
||||
|
||||
This ensures that admin users can always access the admin panel, and also protects against
|
||||
the case where better-auth doesn't include the additionalField in future versions.
|
||||
|
||||
## Files Changed
|
||||
|
||||
1. `/apps/gateway/src/admin/admin.guard.ts` - Added fallback role lookup
|
||||
2. `/packages/auth/src/auth.ts` - No changes needed (better-auth config is correct)
|
||||
|
||||
## Verification
|
||||
|
||||
- All three quality gates pass: `typecheck`, `lint`, `format:check`
|
||||
- Backend admin guard now explicitly handles missing role field
|
||||
- Frontend admin guard remains unchanged (will work once role is available)
|
||||
@@ -222,3 +222,47 @@ Issues closed: #52, #55, #57, #58, #120-#134
|
||||
- Infrastructure: coord DB migration, agent sandbox hardening
|
||||
- Quality: E2E Playwright suite (~35 tests), comprehensive docs (user/admin/dev/deployment)
|
||||
- Fixes: TUI state updater, agent session sandboxing
|
||||
|
||||
### Session 13 — CLI Command Architecture (P8-005, P8-006)
|
||||
|
||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||
| ------- | ---------- | --------- | -------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| 13 | 2026-03-15 | Phase 8 | P8-005, P8-006 | CLI command architecture implemented. DB schema, brain repo, gateway endpoints, CLI commands. PR #158 merged. |
|
||||
|
||||
**Changes delivered:**
|
||||
|
||||
- DB: Extended agents table (projectId, ownerId, systemPrompt, allowedTools, skills, isSystem). Added agentId to conversations.
|
||||
- Brain: New agents repository with findAccessible (owner's + system agents).
|
||||
- Gateway: /api/agents CRUD, consolidated /api/missions with user-scoped CRUD + /tasks sub-routes, coord slimmed to file-based only, agentConfigId wired into session creation.
|
||||
- CLI: `mosaic agent` (--list, --new, --show, --update, --delete), `mosaic mission` (--list, --init, --plan, --update, task subcommand), `mosaic prdy` (gateway-aware), shared with-auth + select-dialog utilities.
|
||||
- TUI: --agent and --project flags, agent name display in top bar, agentId in socket payload.
|
||||
- Types: agentId added to ChatMessagePayload.
|
||||
- Tests: 23/23 gateway tests pass (updated ownership test for user-scoped missions).
|
||||
|
||||
### Session 14 — Platform Architecture Plan Augmentation + Task Breakdown
|
||||
|
||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||
| ------- | ---------- | --------- | ---------- | ------------------------------------------------------------- |
|
||||
| 14 | 2026-03-15 | Phase 8 | P8-018 | Augmented plan, created 13 issues, created Phase 8 milestone. |
|
||||
|
||||
**Decisions made:**
|
||||
|
||||
- This plan is Phase 7 feature extension work, not Phase 8 beta scope. P8-001–P8-004 (SSO, LLM, perf, release gate) are deferred to far future.
|
||||
- `/provider` OAuth in TUI: URL-to-clipboard + Valkey poll token pattern (same as Pi agent)
|
||||
- Add `mutable` column to preferences now (P8-007 DB migration)
|
||||
- Teams architecture: `teams` + `team_members` tables, `teamId`/`ownerType` on projects. Workspace path branches on owner type: `users/<uid>/` vs `teams/<tid>/`.
|
||||
- Phase dependency chain decided: Wave 1 (DB+Types) → Wave 2 (TUI+toolhardening) → Wave 3 (gateway registry, gating) → Wave 4 (prefs+commands) → Wave 5 (reload+GC) → Wave 6 (workspaces) → Wave 7 (autocomplete) → Wave 8 (verify).
|
||||
|
||||
**Plan augmentations added:**
|
||||
|
||||
- Teams Architecture section (DB schema, workspace paths, RBAC)
|
||||
- REST Route Specifications table
|
||||
- `/provider` OAuth flow (URL+clipboard+polling)
|
||||
- Preferences `mutable` migration spec
|
||||
- Test Strategy (per-task test files + key test cases)
|
||||
- Phase Execution Order (dependency graph + wave plan)
|
||||
|
||||
**Issues created:** #160–#172 (Gitea milestone ms-165)
|
||||
**P8-018 closed:** Spin-off stubs created (gatekeeper-service.md, task-queue-unification.md, chroot-sandboxing.md)
|
||||
|
||||
**Next:** Begin execution at Wave 1 — P8-007 (DB migrations) + P8-008 (Types) in parallel.
|
||||
|
||||
65
docs/scratchpads/p8-001-sso-providers.md
Normal file
65
docs/scratchpads/p8-001-sso-providers.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# P8-001 — WorkOS + Keycloak SSO Providers
|
||||
|
||||
**Branch:** feat/p8-001-sso-providers
|
||||
**Started:** 2026-03-18
|
||||
**Mode:** Delivery
|
||||
|
||||
## Objective
|
||||
|
||||
Add WorkOS and Keycloak as optional SSO providers to the BetterAuth configuration, following the existing Authentik pattern.
|
||||
|
||||
## Scope
|
||||
|
||||
| Surface | Change |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `packages/auth/src/auth.ts` | Refactor provider array, add WorkOS + Keycloak conditional registration |
|
||||
| `apps/web/src/lib/auth-client.ts` | Add `genericOAuthClient()` plugin |
|
||||
| `apps/web/src/app/(auth)/login/page.tsx` | WorkOS + Keycloak SSO buttons gated by `NEXT_PUBLIC_*` env vars |
|
||||
| `.env.example` | Document WorkOS + Keycloak env vars |
|
||||
| `packages/auth/src/auth.test.ts` | Unit tests verifying env-var gating |
|
||||
|
||||
## Plan
|
||||
|
||||
1. ✅ Refactor `createAuth` to build `oauthProviders[]` conditionally
|
||||
2. ✅ Add WorkOS provider (explicit URLs, no discovery)
|
||||
3. ✅ Add Keycloak provider (discoveryUrl pattern)
|
||||
4. ✅ Add `genericOAuthClient()` to auth-client.ts
|
||||
5. ✅ Add SSO buttons to login page gated by `NEXT_PUBLIC_WORKOS_ENABLED` / `NEXT_PUBLIC_KEYCLOAK_ENABLED`
|
||||
6. ✅ Update `.env.example`
|
||||
7. ⏳ Write `auth.test.ts` with env-var gating tests
|
||||
8. ⏳ Quality gates: typecheck + lint + format:check + test
|
||||
9. ⏳ Commit + push + PR
|
||||
|
||||
## Decisions
|
||||
|
||||
- **WorkOS**: Uses explicit `authorizationUrl`, `tokenUrl`, `userInfoUrl` (no discovery endpoint available)
|
||||
- **Keycloak**: Uses `discoveryUrl` pattern (`{URL}/realms/{REALM}/.well-known/openid-configuration`)
|
||||
- **UI gating**: Login page uses `NEXT_PUBLIC_WORKOS_ENABLED` / `NEXT_PUBLIC_KEYCLOAK_ENABLED` feature flags (safer than exposing secret env var names client-side)
|
||||
- **Refactor**: Authentik moved into same `oauthProviders[]` array pattern — cleaner, more extensible
|
||||
- **Feature flag design**: `NEXT_PUBLIC_*` flags are opt-in alongside credentials (prevents accidental button render when creds not set)
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `ASSUMPTION:` WorkOS OIDC discovery URL is not publicly documented; using direct URL pattern from WorkOS SSO docs.
|
||||
- `ASSUMPTION:` `NEXT_PUBLIC_WORKOS_ENABLED=true` must be explicitly set — this is intentional (credential presence alone doesn't enable the button since NEXT_PUBLIC vars are baked at build time).
|
||||
|
||||
## Tests
|
||||
|
||||
- `auth.test.ts`: Mocks betterAuth stack, verifies WorkOS included/excluded based on env var
|
||||
- `auth.test.ts`: Verifies Keycloak discoveryUrl constructed correctly
|
||||
|
||||
## Quality Gate Results
|
||||
|
||||
| Gate | Status |
|
||||
| ------------------- | -------------------------------------------- |
|
||||
| typecheck | ✅ 32/32 cached green |
|
||||
| lint | ✅ 18/18 cached green |
|
||||
| format:check | ✅ All matched files use Prettier code style |
|
||||
| test (@mosaic/auth) | ✅ 8/8 tests passed |
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- `pnpm typecheck` — FULL TURBO, 32 tasks successful
|
||||
- `pnpm lint` — FULL TURBO, 18 tasks successful
|
||||
- `pnpm format:check` — All matched files use Prettier code style!
|
||||
- `pnpm --filter=@mosaic/auth test` — 8 tests passed, 0 failed
|
||||
40
docs/scratchpads/p8-009-tui-slash-commands.md
Normal file
40
docs/scratchpads/p8-009-tui-slash-commands.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# P8-009: TUI Phase 1 — Slash Command Parsing
|
||||
|
||||
## Task Reference
|
||||
|
||||
- Issue: #162
|
||||
- Branch: feat/p8-009-tui-slash-commands
|
||||
|
||||
## Scope
|
||||
|
||||
- New files: parse.ts, registry.ts, local/help.ts, local/status.ts, commands/index.ts
|
||||
- Modified files: use-socket.ts, input-bar.tsx, message-list.tsx, app.tsx
|
||||
|
||||
## Key Observations
|
||||
|
||||
- CommandDef in @mosaic/types does NOT have `category` field — will omit from LOCAL_COMMANDS
|
||||
- CommandDef.args is `CommandArgDef[] | undefined`, not `{ usage: string }` — help.ts args rendering needs adjustment
|
||||
- Message role union currently: 'user' | 'assistant' | 'thinking' | 'tool' — adding 'system'
|
||||
- InputBar currently takes `onSubmit: (value: string) => void` — need to add slash command interception
|
||||
- app.tsx passes `onSubmit={socket.sendMessage}` directly — needs command-aware handler
|
||||
|
||||
## Assumptions
|
||||
|
||||
- ASSUMPTION: `category` field not in CommandDef type — will skip category grouping in help output, or add it only to registry (not to CommandDef type)
|
||||
- ASSUMPTION: For the `args` field display in help, will use `CommandArgDef.name` and `CommandArgDef.description`
|
||||
- ASSUMPTION: `commands:manifest` event type may not be in ServerToClientEvents — will handle via socket.on with casting if needed
|
||||
|
||||
## Status
|
||||
|
||||
- [ ] Create commands directory structure
|
||||
- [ ] Implement parse.ts
|
||||
- [ ] Implement registry.ts
|
||||
- [ ] Implement local/help.ts
|
||||
- [ ] Implement local/status.ts
|
||||
- [ ] Implement commands/index.ts
|
||||
- [ ] Modify use-socket.ts
|
||||
- [ ] Modify input-bar.tsx
|
||||
- [ ] Modify message-list.tsx
|
||||
- [ ] Modify app.tsx
|
||||
- [ ] Run quality gates
|
||||
- [ ] Commit + Push + PR + CI
|
||||
72
docs/scratchpads/p8-010-command-registry.md
Normal file
72
docs/scratchpads/p8-010-command-registry.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# P8-010 Scratchpad — Gateway Phase 2: CommandRegistryService + CommandExecutorService
|
||||
|
||||
## Objective
|
||||
|
||||
Implement gateway-side command registry system:
|
||||
|
||||
- `CommandRegistryService` — owns canonical command manifest, broadcasts on connect
|
||||
- `CommandExecutorService` — routes `command:execute` socket events
|
||||
- `CommandsModule` — NestJS wiring
|
||||
- Wire into `ChatGateway` and `AppModule`
|
||||
- Register core commands
|
||||
- Tests for CommandRegistryService
|
||||
|
||||
## Key Findings from Codebase
|
||||
|
||||
### CommandDef shape (from packages/types/src/commands/index.ts)
|
||||
|
||||
- `scope: 'core' | 'agent' | 'skill' | 'plugin' | 'admin'` (NOT `category`)
|
||||
- `args?: CommandArgDef[]` — array of arg defs, each with `name`, `type`, `optional`, `values?`, `description?`
|
||||
- No `aliases` required (it's listed but optional-ish... wait, it IS in the interface)
|
||||
- `aliases: string[]` — IS present
|
||||
|
||||
### SlashCommandResultPayload requires `conversationId`
|
||||
|
||||
- The task spec shows `{ command, success, error }` without `conversationId` but actual type requires it
|
||||
- Must include `conversationId` in all return values
|
||||
|
||||
### CommandManifest has `skills: SkillCommandDef[]`
|
||||
|
||||
- Must include `skills` array in manifest
|
||||
|
||||
### userId extraction in ChatGateway
|
||||
|
||||
- `client.data.user` holds the user object (set in `handleConnection`)
|
||||
- `client.data.user.id` or similar for userId
|
||||
|
||||
### AgentModule not imported in ChatModule
|
||||
|
||||
- ChatGateway imports AgentService via DI
|
||||
- ChatModule doesn't declare imports — AgentModule must be global or imported
|
||||
|
||||
### Worktree branch
|
||||
|
||||
- Branch: `feat/p8-010-command-registry`
|
||||
- Working in: `/home/jwoltje/src/mosaic-mono-v1/.claude/worktrees/agent-ac85b3b2`
|
||||
|
||||
## Plan
|
||||
|
||||
1. Create `apps/gateway/src/commands/command-registry.service.ts`
|
||||
2. Create `apps/gateway/src/commands/command-executor.service.ts`
|
||||
3. Create `apps/gateway/src/commands/commands.module.ts`
|
||||
4. Modify `apps/gateway/src/app.module.ts` — add CommandsModule
|
||||
5. Modify `apps/gateway/src/chat/chat.module.ts` — import CommandsModule
|
||||
6. Modify `apps/gateway/src/chat/chat.gateway.ts` — inject services, add handler, emit manifest
|
||||
7. Create `apps/gateway/src/commands/command-registry.service.spec.ts`
|
||||
|
||||
## Progress
|
||||
|
||||
- [ ] Create CommandRegistryService
|
||||
- [ ] Create CommandExecutorService
|
||||
- [ ] Create CommandsModule
|
||||
- [ ] Update AppModule
|
||||
- [ ] Update ChatModule
|
||||
- [ ] Update ChatGateway
|
||||
- [ ] Write tests
|
||||
- [ ] Run quality gates
|
||||
- [ ] Commit + push + PR
|
||||
|
||||
## Risks
|
||||
|
||||
- CommandDef `args` shape mismatch from task spec — must use actual type
|
||||
- `SlashCommandResultPayload.conversationId` is required — handle missing conversationId
|
||||
44
docs/scratchpads/p8-012-agent-provider-commands.md
Normal file
44
docs/scratchpads/p8-012-agent-provider-commands.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# P8-012 Scratchpad — Gateway /agent, /provider, /mission, /prdy, /tools Commands
|
||||
|
||||
## Objective
|
||||
|
||||
Add gateway-executed commands: `/agent`, `/provider`, `/mission`, `/prdy`, `/tools`.
|
||||
Key feature: `/provider login` OAuth flow with Valkey poll token.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Read all relevant files (done)
|
||||
2. Update `command-registry.service.ts` — add 5 new command registrations
|
||||
3. Update `commands.module.ts` — wire Redis injection for executor
|
||||
4. Update `command-executor.service.ts` — add 5 new command handlers + Redis injection
|
||||
5. Write spec file for new commands
|
||||
6. Run quality gates (typecheck, lint, format:check, test)
|
||||
7. Commit and push
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- Redis pattern: same as GCModule — use `REDIS` token injected from a QueueHandle factory
|
||||
- `CommandDef` type fields: `scope: 'core'|'agent'|'skill'|'plugin'|'admin'`, `args?: CommandArgDef[]`, `execution: 'local'|'socket'|'rest'|'hybrid'`
|
||||
- No `category` or `usage` fields — instruction spec was wrong on that
|
||||
- `SlashCommandResultPayload.conversationId` is typed as `string` (not `string | undefined`) per the type
|
||||
- Provider commands are `scope: 'agent'` since they relate to agent configuration
|
||||
- Redis injection: add a `COMMANDS_REDIS` token in commands module, inject via factory pattern same as GCModule
|
||||
|
||||
## Progress
|
||||
|
||||
- [ ] command-registry.service.ts updated
|
||||
- [ ] commands.module.ts updated (add Redis provider)
|
||||
- [ ] command-executor.service.ts updated (add Redis injection + handlers)
|
||||
- [ ] spec file written
|
||||
- [ ] quality gates pass
|
||||
- [ ] commit + push + PR
|
||||
|
||||
## Risks
|
||||
|
||||
- `conversationId` typing: `SlashCommandResultPayload.conversationId` is `string`, but some handler calls pass `undefined`. Need to check if it's optional.
|
||||
|
||||
After reviewing types: `conversationId: string` in `SlashCommandResultPayload` — not optional. Must pass empty string or actual ID. Looking at existing code: `message: 'Start a new conversation...'` returns `{ command, conversationId, ... }` where conversationId comes from payload which is always a string per `SlashCommandPayload`. For provider commands that don't have a conversationId, pass empty string `''` or the payload's conversationId.
|
||||
|
||||
Actually looking at the spec more carefully: `handleProvider` returns `conversationId: undefined`. But the type says `string`. This would be a TypeScript error. I'll use `''` as a fallback or adjust. Let me re-examine...
|
||||
|
||||
The `SlashCommandResultPayload` interface says `conversationId: string` — not optional. But the spec says `conversationId: undefined`. I'll use `payload.conversationId` (passing it through) since it comes from the payload.
|
||||
55
docs/scratchpads/p8-016-tool-hardening.md
Normal file
55
docs/scratchpads/p8-016-tool-hardening.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# P8-016: Security — Tool Path Hardening + Sandbox Escape Prevention
|
||||
|
||||
## Status: in-progress
|
||||
|
||||
## Branch: feat/p8-016-tool-hardening
|
||||
|
||||
## Issue: #169
|
||||
|
||||
## Scope
|
||||
|
||||
Harden file, git, and shell tool factories so no path operation escapes `sandboxDir`.
|
||||
|
||||
## Files to Create
|
||||
|
||||
- `apps/gateway/src/agent/tools/path-guard.ts` (new)
|
||||
- `apps/gateway/src/agent/tools/path-guard.test.ts` (new)
|
||||
|
||||
## Files to Modify
|
||||
|
||||
- `apps/gateway/src/agent/tools/file-tools.ts`
|
||||
- `apps/gateway/src/agent/tools/git-tools.ts`
|
||||
- `apps/gateway/src/agent/tools/shell-tools.ts`
|
||||
|
||||
## Analysis
|
||||
|
||||
### file-tools.ts
|
||||
|
||||
- Has existing `resolveSafe()` function but uses weak containment check (relative path)
|
||||
- Replace with `guardPath` (for reads/lists on existing paths) and `guardPathUnsafe` (for writes)
|
||||
- Error pattern: return `{ content: [{ type: 'text', text: 'Error: ...' }], details: undefined }`
|
||||
|
||||
### git-tools.ts
|
||||
|
||||
- Has `clampCwd()` that silently falls back to sandbox root on escape attempt
|
||||
- Replace with strict `guardPath` that throws SandboxEscapeError, caught and returned as error
|
||||
- Also need to guard the `path` parameter in `git_diff`
|
||||
|
||||
### shell-tools.ts
|
||||
|
||||
- Has `clampCwd()` same silent-fallback approach
|
||||
- Replace with strict `guardPath` that throws SandboxEscapeError
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- `guardPath`: uses `realpathSync.native` to resolve symlinks, requires path to exist
|
||||
- `guardPathUnsafe`: lexical only (`path.resolve`), for paths that may not exist yet
|
||||
- Both throw `SandboxEscapeError` on escape attempt
|
||||
- Callers catch and return error result
|
||||
|
||||
## Verification
|
||||
|
||||
- pnpm typecheck
|
||||
- pnpm lint
|
||||
- pnpm format:check
|
||||
- pnpm test
|
||||
103
docs/scratchpads/p8-019-verify.md
Normal file
103
docs/scratchpads/p8-019-verify.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# P8-019 Verification — Phase 8 Platform Architecture
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Status:** complete
|
||||
**Branch:** feat/p8-019-verify
|
||||
**PR:** #185
|
||||
**Issue:** #172
|
||||
|
||||
## Test Results
|
||||
|
||||
- Unit tests (baseline, pre-P8-019): 101 passing across 9 gateway test files + 1 CLI file
|
||||
- Integration tests added: 2 new spec files (68 new tests)
|
||||
- `apps/gateway/src/commands/commands.integration.spec.ts` — 42 tests
|
||||
- `packages/cli/src/tui/commands/commands.integration.spec.ts` — 26 tests
|
||||
- Total after P8-019: 160 passing tests across 12 test files
|
||||
- Quality gates: typecheck ✓ lint ✓ format:check ✓ test ✓
|
||||
|
||||
## Components Verified
|
||||
|
||||
### Command System
|
||||
|
||||
- `CommandRegistryService.getManifest()` returns 19 core commands (>= 12 requirement met)
|
||||
- All commands have correct `execution` type:
|
||||
- `socket`: model, thinking, new, clear, compact, retry, system, gc, agent, mission, prdy, tools, reload
|
||||
- `rest`: rename, history, export, preferences
|
||||
- `hybrid`: provider, status (gateway), (status overridden to local in TUI)
|
||||
- `local`: help (gateway); help, stop, cost, status, clear (TUI local)
|
||||
- All aliases verified: m→model, t→thinking, n→new, a→agent, s→status, h→help, pref→preferences
|
||||
- `parseSlashCommand()` correctly extracts command + args for all forms
|
||||
- Unknown commands return `success: false` with descriptive message
|
||||
|
||||
### Preferences + System Override
|
||||
|
||||
- `PreferencesService.getEffective()` applies platform defaults when no user overrides
|
||||
- Immutable keys (`limits.maxThinkingLevel`, `limits.rateLimit`) cannot be overridden — enforcement always wins
|
||||
- `set()` returns error for immutable keys with "platform enforcement" message
|
||||
- `SystemOverrideService.set()` stores to Valkey with 5-minute TTL; verified via mock
|
||||
- `/system` command calls `SystemOverrideService.set()` with exact text arg
|
||||
- `/system` with no args calls `SystemOverrideService.clear()`
|
||||
|
||||
### Session GC
|
||||
|
||||
- `collect(sessionId)` deletes all `mosaic:session:<id>:*` Valkey keys
|
||||
- `fullCollect()` clears all `mosaic:session:*` keys on cold start
|
||||
- `sweepOrphans()` extracts unique session IDs from keys and collects each
|
||||
- GC result includes `duration` and `orphanedSessions` count
|
||||
- `/gc` command invokes `sweepOrphans(userId)` and returns count in response
|
||||
|
||||
### Tool Security (path-guard)
|
||||
|
||||
- `guardPath` rejects `../` traversal → throws `SandboxEscapeError`
|
||||
- `guardPath` rejects absolute paths outside sandbox → throws `SandboxEscapeError`
|
||||
- `guardPathUnsafe` rejects sibling-named directories (e.g. `/tmp/test-sandbox-evil/`)
|
||||
- All 12 path-guard tests pass; `SandboxEscapeError` message includes path and sandbox in text
|
||||
|
||||
### Workspace
|
||||
|
||||
- `WorkspaceService.resolvePath()` returns user path for solo projects:
|
||||
`$MOSAIC_ROOT/.workspaces/users/<userId>/<projectId>`
|
||||
- `WorkspaceService.resolvePath()` returns team path for team projects:
|
||||
`$MOSAIC_ROOT/.workspaces/teams/<teamId>/<projectId>`
|
||||
- Path resolution is deterministic (same inputs → same output)
|
||||
- `exists()`, `createUserRoot()`, `createTeamRoot()` all tested
|
||||
|
||||
### TUI Autocomplete
|
||||
|
||||
- `filterCommands(commands, query)` filters by name, aliases, and description
|
||||
- Empty query returns all commands
|
||||
- Prefix matching works: "mo" → model, "mi" → mission
|
||||
- Alias matching: "h" matches help (alias)
|
||||
- Description keyword matching: "switch" → model
|
||||
- Unknown query returns empty array
|
||||
- `useInputHistory` ring buffer caps at 50 entries
|
||||
- Up-arrow recall returns most recent entry
|
||||
- Down-arrow after up restores saved input
|
||||
- Duplicate consecutive entries are deduplicated
|
||||
- Reset navigation works correctly
|
||||
|
||||
### Hot Reload
|
||||
|
||||
- `ReloadService` registers plugins via `registerPlugin()`
|
||||
- `reload()` iterates plugins, calls their `reload()` method
|
||||
- Plugin errors are counted but don't prevent other plugins from reloading
|
||||
- Non-MosaicPlugin objects are skipped gracefully
|
||||
- SIGHUP trigger verified via reload trigger = 'sighup'
|
||||
|
||||
## Gaps / Known Limitations
|
||||
|
||||
1. `SystemOverrideService` creates its own Valkey connection in constructor (not injected) — functional but harder to test in isolation without mocking `createQueue`. Current tests mock it at the executor level.
|
||||
2. `/status` command has `execution: 'hybrid'` in the gateway registry but `execution: 'local'` in the TUI local registry — TUI local takes precedence, which is the intended behavior.
|
||||
3. `SessionGCService.fullCollect()` runs on `onModuleInit` (cold start) — this is intentional but means tests must mock redis.keys to avoid real Valkey calls.
|
||||
4. `ProjectBootstrapService` and `TeamsService` in workspace module have no dedicated tests — they are thin wrappers over Drizzle that delegate to WorkspaceService (which is tested).
|
||||
5. GC cron schedule (`SESSION_GC_CRON` env var) is configured at module level — not unit tested here; covered by NestJS cron integration.
|
||||
6. `filterCommands` in `CommandAutocomplete` is not exported — replicated in integration test to verify behavior.
|
||||
|
||||
## CI Evidence
|
||||
|
||||
Pipeline: TBD after push — all 4 local quality gates green:
|
||||
|
||||
- pnpm typecheck: 32 tasks, all cached/green
|
||||
- pnpm lint: 18 tasks, all green
|
||||
- pnpm format:check: all files match Prettier style
|
||||
- pnpm test: 32 tasks, 160 tests passing
|
||||
161
packages/auth/src/auth.test.ts
Normal file
161
packages/auth/src/auth.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { buildOAuthProviders } from './auth.js';
|
||||
|
||||
describe('buildOAuthProviders', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env['AUTHENTIK_CLIENT_ID'];
|
||||
delete process.env['AUTHENTIK_CLIENT_SECRET'];
|
||||
delete process.env['AUTHENTIK_ISSUER'];
|
||||
delete process.env['WORKOS_CLIENT_ID'];
|
||||
delete process.env['WORKOS_CLIENT_SECRET'];
|
||||
delete process.env['WORKOS_ISSUER'];
|
||||
delete process.env['KEYCLOAK_CLIENT_ID'];
|
||||
delete process.env['KEYCLOAK_CLIENT_SECRET'];
|
||||
delete process.env['KEYCLOAK_ISSUER'];
|
||||
delete process.env['KEYCLOAK_URL'];
|
||||
delete process.env['KEYCLOAK_REALM'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('returns empty array when no SSO env vars are set', () => {
|
||||
const providers = buildOAuthProviders();
|
||||
expect(providers).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('WorkOS', () => {
|
||||
it('includes workos provider when all required env vars are set', () => {
|
||||
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
|
||||
process.env['WORKOS_CLIENT_SECRET'] = 'sk_live_test';
|
||||
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app/';
|
||||
|
||||
const providers = buildOAuthProviders();
|
||||
const workos = providers.find((p) => p.providerId === 'workos');
|
||||
|
||||
expect(workos).toBeDefined();
|
||||
expect(workos?.clientId).toBe('client_test123');
|
||||
expect(workos?.issuer).toBe('https://example.authkit.app');
|
||||
expect(workos?.discoveryUrl).toBe(
|
||||
'https://example.authkit.app/.well-known/openid-configuration',
|
||||
);
|
||||
expect(workos?.scopes).toEqual(['openid', 'email', 'profile']);
|
||||
});
|
||||
|
||||
it('throws when WorkOS is partially configured', () => {
|
||||
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
|
||||
|
||||
expect(() => buildOAuthProviders()).toThrow(
|
||||
'@mosaic/auth: WorkOS SSO requires WORKOS_ISSUER, WORKOS_CLIENT_ID, WORKOS_CLIENT_SECRET.',
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes workos provider when WorkOS is not configured', () => {
|
||||
const providers = buildOAuthProviders();
|
||||
const workos = providers.find((p) => p.providerId === 'workos');
|
||||
expect(workos).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keycloak', () => {
|
||||
it('includes keycloak provider when KEYCLOAK_ISSUER is set', () => {
|
||||
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
|
||||
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
|
||||
process.env['KEYCLOAK_ISSUER'] = 'https://auth.example.com/realms/myrealm/';
|
||||
|
||||
const providers = buildOAuthProviders();
|
||||
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
|
||||
|
||||
expect(keycloakProvider).toBeDefined();
|
||||
expect(keycloakProvider?.clientId).toBe('mosaic');
|
||||
expect(keycloakProvider?.discoveryUrl).toBe(
|
||||
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
|
||||
);
|
||||
expect(keycloakProvider?.scopes).toEqual(['openid', 'email', 'profile']);
|
||||
});
|
||||
|
||||
it('supports deriving the Keycloak issuer from KEYCLOAK_URL and KEYCLOAK_REALM', () => {
|
||||
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
|
||||
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
|
||||
process.env['KEYCLOAK_URL'] = 'https://auth.example.com/';
|
||||
process.env['KEYCLOAK_REALM'] = 'myrealm';
|
||||
|
||||
const providers = buildOAuthProviders();
|
||||
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
|
||||
|
||||
expect(keycloakProvider?.discoveryUrl).toBe(
|
||||
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when Keycloak is partially configured', () => {
|
||||
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
|
||||
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
|
||||
|
||||
expect(() => buildOAuthProviders()).toThrow(
|
||||
'@mosaic/auth: Keycloak SSO requires KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER.',
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes keycloak provider when Keycloak is not configured', () => {
|
||||
const providers = buildOAuthProviders();
|
||||
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
|
||||
expect(keycloakProvider).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentik', () => {
|
||||
it('includes authentik provider when all required env vars are set', () => {
|
||||
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
|
||||
process.env['AUTHENTIK_CLIENT_SECRET'] = 'authentik-secret';
|
||||
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic/';
|
||||
|
||||
const providers = buildOAuthProviders();
|
||||
const authentik = providers.find((p) => p.providerId === 'authentik');
|
||||
|
||||
expect(authentik).toBeDefined();
|
||||
expect(authentik?.clientId).toBe('authentik-client');
|
||||
expect(authentik?.issuer).toBe('https://auth.example.com/application/o/mosaic');
|
||||
expect(authentik?.discoveryUrl).toBe(
|
||||
'https://auth.example.com/application/o/mosaic/.well-known/openid-configuration',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when Authentik is partially configured', () => {
|
||||
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
|
||||
|
||||
expect(() => buildOAuthProviders()).toThrow(
|
||||
'@mosaic/auth: Authentik SSO requires AUTHENTIK_ISSUER, AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET.',
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes authentik provider when Authentik is not configured', () => {
|
||||
const providers = buildOAuthProviders();
|
||||
const authentik = providers.find((p) => p.providerId === 'authentik');
|
||||
expect(authentik).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('registers all three providers when all env vars are set', () => {
|
||||
process.env['AUTHENTIK_CLIENT_ID'] = 'a-id';
|
||||
process.env['AUTHENTIK_CLIENT_SECRET'] = 'a-secret';
|
||||
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic';
|
||||
process.env['WORKOS_CLIENT_ID'] = 'w-id';
|
||||
process.env['WORKOS_CLIENT_SECRET'] = 'w-secret';
|
||||
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app';
|
||||
process.env['KEYCLOAK_CLIENT_ID'] = 'k-id';
|
||||
process.env['KEYCLOAK_CLIENT_SECRET'] = 'k-secret';
|
||||
process.env['KEYCLOAK_ISSUER'] = 'https://kc.example.com/realms/test';
|
||||
|
||||
const providers = buildOAuthProviders();
|
||||
expect(providers).toHaveLength(3);
|
||||
const ids = providers.map((p) => p.providerId);
|
||||
expect(ids).toContain('authentik');
|
||||
expect(ids).toContain('workos');
|
||||
expect(ids).toContain('keycloak');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { admin, genericOAuth } from 'better-auth/plugins';
|
||||
import { admin } from 'better-auth/plugins';
|
||||
import { genericOAuth, keycloak, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
|
||||
import type { Db } from '@mosaic/db';
|
||||
|
||||
export interface AuthConfig {
|
||||
@@ -9,35 +10,118 @@ export interface AuthConfig {
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export function createAuth(config: AuthConfig) {
|
||||
const { db, baseURL, secret } = config;
|
||||
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
|
||||
/** Builds the list of enabled OAuth providers from environment variables. Exported for testing. */
|
||||
export function buildOAuthProviders(): GenericOAuthConfig[] {
|
||||
const providers: GenericOAuthConfig[] = [];
|
||||
|
||||
const authentikIssuer = normalizeIssuer(process.env['AUTHENTIK_ISSUER']);
|
||||
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
||||
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
|
||||
const plugins = authentikClientId
|
||||
? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: 'authentik',
|
||||
clientId: authentikClientId,
|
||||
clientSecret: authentikClientSecret ?? '',
|
||||
discoveryUrl: authentikIssuer
|
||||
? `${authentikIssuer}/.well-known/openid-configuration`
|
||||
: undefined,
|
||||
authorizationUrl: authentikIssuer
|
||||
? `${authentikIssuer}/application/o/authorize/`
|
||||
: undefined,
|
||||
tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined,
|
||||
userInfoUrl: authentikIssuer
|
||||
? `${authentikIssuer}/application/o/userinfo/`
|
||||
: undefined,
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
: undefined;
|
||||
assertOptionalProviderConfig('Authentik SSO', {
|
||||
required: ['AUTHENTIK_ISSUER', 'AUTHENTIK_CLIENT_ID', 'AUTHENTIK_CLIENT_SECRET'],
|
||||
values: [authentikIssuer, authentikClientId, authentikClientSecret],
|
||||
});
|
||||
|
||||
if (authentikIssuer && authentikClientId && authentikClientSecret) {
|
||||
providers.push({
|
||||
providerId: 'authentik',
|
||||
clientId: authentikClientId,
|
||||
clientSecret: authentikClientSecret,
|
||||
issuer: authentikIssuer,
|
||||
discoveryUrl: buildDiscoveryUrl(authentikIssuer),
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
requireIssuerValidation: true,
|
||||
});
|
||||
}
|
||||
|
||||
const workosIssuer = normalizeIssuer(process.env['WORKOS_ISSUER']);
|
||||
const workosClientId = process.env['WORKOS_CLIENT_ID'];
|
||||
const workosClientSecret = process.env['WORKOS_CLIENT_SECRET'];
|
||||
assertOptionalProviderConfig('WorkOS SSO', {
|
||||
required: ['WORKOS_ISSUER', 'WORKOS_CLIENT_ID', 'WORKOS_CLIENT_SECRET'],
|
||||
values: [workosIssuer, workosClientId, workosClientSecret],
|
||||
});
|
||||
|
||||
if (workosIssuer && workosClientId && workosClientSecret) {
|
||||
providers.push({
|
||||
providerId: 'workos',
|
||||
clientId: workosClientId,
|
||||
clientSecret: workosClientSecret,
|
||||
issuer: workosIssuer,
|
||||
discoveryUrl: buildDiscoveryUrl(workosIssuer),
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
requireIssuerValidation: true,
|
||||
});
|
||||
}
|
||||
|
||||
const keycloakIssuer = resolveKeycloakIssuer();
|
||||
const keycloakClientId = process.env['KEYCLOAK_CLIENT_ID'];
|
||||
const keycloakClientSecret = process.env['KEYCLOAK_CLIENT_SECRET'];
|
||||
assertOptionalProviderConfig('Keycloak SSO', {
|
||||
required: ['KEYCLOAK_CLIENT_ID', 'KEYCLOAK_CLIENT_SECRET', 'KEYCLOAK_ISSUER'],
|
||||
values: [keycloakClientId, keycloakClientSecret, keycloakIssuer],
|
||||
});
|
||||
|
||||
if (keycloakIssuer && keycloakClientId && keycloakClientSecret) {
|
||||
providers.push(
|
||||
keycloak({
|
||||
clientId: keycloakClientId,
|
||||
clientSecret: keycloakClientSecret,
|
||||
issuer: keycloakIssuer,
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
function resolveKeycloakIssuer(): string | undefined {
|
||||
const issuer = normalizeIssuer(process.env['KEYCLOAK_ISSUER']);
|
||||
if (issuer) {
|
||||
return issuer;
|
||||
}
|
||||
|
||||
const baseUrl = normalizeIssuer(process.env['KEYCLOAK_URL']);
|
||||
const realm = process.env['KEYCLOAK_REALM']?.trim();
|
||||
|
||||
if (!baseUrl || !realm) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${baseUrl}/realms/${realm}`;
|
||||
}
|
||||
|
||||
function buildDiscoveryUrl(issuer: string): string {
|
||||
return `${issuer}/.well-known/openid-configuration`;
|
||||
}
|
||||
|
||||
function normalizeIssuer(value: string | undefined): string | undefined {
|
||||
return value?.trim().replace(/\/+$/, '') || undefined;
|
||||
}
|
||||
|
||||
function assertOptionalProviderConfig(
|
||||
providerName: string,
|
||||
config: { required: string[]; values: Array<string | undefined> },
|
||||
): void {
|
||||
const hasAnyValue = config.values.some((value) => Boolean(value?.trim()));
|
||||
const hasAllValues = config.values.every((value) => Boolean(value?.trim()));
|
||||
|
||||
if (!hasAnyValue || hasAllValues) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`@mosaic/auth: ${providerName} requires ${config.required.join(', ')}. Set them in your config or via the listed environment variables.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function createAuth(config: AuthConfig) {
|
||||
const { db, baseURL, secret } = config;
|
||||
|
||||
const oauthProviders = buildOAuthProviders();
|
||||
const plugins =
|
||||
oauthProviders.length > 0 ? [genericOAuth({ config: oauthProviders })] : undefined;
|
||||
|
||||
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
||||
|
||||
58
packages/brain/src/agents.ts
Normal file
58
packages/brain/src/agents.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { eq, or, type Db, agents } from '@mosaic/db';
|
||||
|
||||
export type Agent = typeof agents.$inferSelect;
|
||||
export type NewAgent = typeof agents.$inferInsert;
|
||||
|
||||
export function createAgentsRepo(db: Db) {
|
||||
return {
|
||||
async findAll(): Promise<Agent[]> {
|
||||
return db.select().from(agents);
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<Agent | undefined> {
|
||||
const rows = await db.select().from(agents).where(eq(agents.id, id));
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async findByName(name: string): Promise<Agent | undefined> {
|
||||
const rows = await db.select().from(agents).where(eq(agents.name, name));
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async findByProject(projectId: string): Promise<Agent[]> {
|
||||
return db.select().from(agents).where(eq(agents.projectId, projectId));
|
||||
},
|
||||
|
||||
async findSystem(): Promise<Agent[]> {
|
||||
return db.select().from(agents).where(eq(agents.isSystem, true));
|
||||
},
|
||||
|
||||
async findAccessible(ownerId: string): Promise<Agent[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(or(eq(agents.ownerId, ownerId), eq(agents.isSystem, true)));
|
||||
},
|
||||
|
||||
async create(data: NewAgent): Promise<Agent> {
|
||||
const rows = await db.insert(agents).values(data).returning();
|
||||
return rows[0]!;
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<NewAgent>): Promise<Agent | undefined> {
|
||||
const rows = await db
|
||||
.update(agents)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(agents.id, id))
|
||||
.returning();
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<boolean> {
|
||||
const rows = await db.delete(agents).where(eq(agents.id, id)).returning();
|
||||
return rows.length > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentsRepo = ReturnType<typeof createAgentsRepo>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user