Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd57c75e41 | |||
| 237a863dfd | |||
| cb92ba16c1 | |||
| 70e9f2c6bc | |||
| a760401407 | |||
| 22a5e9791c | |||
| d1bef49b4e | |||
| 76abf11eba | |||
| c4850fe6c1 | |||
| 0809f4e787 | |||
| 6a4c020179 | |||
| 3bb401641e | |||
| 54b821d8bd | |||
| 09e649fc7e | |||
| f208f72dc0 | |||
| d42cd68ea4 | |||
| 07647c8382 | |||
| 8633823257 | |||
| d0999a8e37 | |||
| ea800e3f14 | |||
| 5d2e6fae63 | |||
| fcd22c788a | |||
| ab61a15edc | |||
| 2c60459851 | |||
| ea524a6ba1 | |||
| 997a6d134f | |||
| 8aaf229483 | |||
| 049bb719e8 | |||
| 014ebdacda | |||
| 72a73c859c | |||
| 6d2b81f6e4 | |||
| 9d01a0d484 | |||
| d5102f62fa | |||
| a881e707e2 | |||
| 7d04874f3c | |||
| 9f036242fa | |||
| c4e52085e3 | |||
| 84e1868028 | |||
| f94f9f672b | |||
| cd29fc8708 | |||
| 6e22c0fdeb | |||
| 1f4d54e474 | |||
| b7a39b45d7 | |||
| 1bfdc91f90 | |||
| 58a90ac9d7 | |||
| 684dbdc6a4 | |||
| e92de12cf9 | |||
| 1f784a6a04 | |||
| ab37c2e69f | |||
| c8f3e0db44 | |||
| 02772a3910 | |||
| 85a25fd995 | |||
| 20f302367c | |||
| 54c6bfded0 | |||
| ca5472bc31 | |||
| 55b5a31c3c | |||
| 01e9891243 | |||
| 446a424c1f | |||
| 02a0d515d9 | |||
| 2bf3816efc | |||
| 96902bab44 | |||
| 280c5351e2 |
129
.env.example
129
.env.example
@@ -1,20 +1,129 @@
|
|||||||
# Database (port 5433 avoids conflict with host PostgreSQL)
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Mosaic — Environment Variables Reference
|
||||||
|
# Copy this file to .env and fill in the values for your deployment.
|
||||||
|
# Lines beginning with # are comments; optional vars are commented out.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Database (PostgreSQL 17 + pgvector) ─────────────────────────────────────
|
||||||
|
# Full connection string used by the gateway, ORM, and migration runner.
|
||||||
|
# Port 5433 avoids conflict with a host-side PostgreSQL instance.
|
||||||
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
|
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
|
||||||
|
|
||||||
# Valkey (Redis-compatible, port 6380 avoids conflict with host Redis/Valkey)
|
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
|
||||||
|
# PG_HOST_PORT=5433
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Queue (Valkey 8 / Redis-compatible) ─────────────────────────────────────
|
||||||
|
# Port 6380 avoids conflict with a host-side Redis/Valkey instance.
|
||||||
VALKEY_URL=redis://localhost:6380
|
VALKEY_URL=redis://localhost:6380
|
||||||
|
|
||||||
# Docker Compose host port overrides (optional)
|
# Docker Compose host-port override for the Valkey container (default: 6380)
|
||||||
# PG_HOST_PORT=5433
|
|
||||||
# VALKEY_HOST_PORT=6380
|
# VALKEY_HOST_PORT=6380
|
||||||
|
|
||||||
# OpenTelemetry
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
|
||||||
OTEL_SERVICE_NAME=mosaic-gateway
|
|
||||||
|
|
||||||
# Auth (BetterAuth)
|
# ─── Gateway ─────────────────────────────────────────────────────────────────
|
||||||
|
# TCP port the NestJS/Fastify gateway listens on (default: 4000)
|
||||||
|
GATEWAY_PORT=4000
|
||||||
|
|
||||||
|
# Comma-separated list of allowed CORS origins.
|
||||||
|
# Must include the web app origin in production.
|
||||||
|
GATEWAY_CORS_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Auth (BetterAuth) ───────────────────────────────────────────────────────
|
||||||
|
# REQUIRED — random secret used to sign sessions and tokens.
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
||||||
|
|
||||||
|
# Public base URL of the gateway (used by BetterAuth for callback URLs)
|
||||||
BETTER_AUTH_URL=http://localhost:4000
|
BETTER_AUTH_URL=http://localhost:4000
|
||||||
|
|
||||||
# Gateway
|
|
||||||
GATEWAY_PORT=4000
|
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
|
||||||
|
# Public gateway URL — accessible from the browser, not just the server.
|
||||||
|
NEXT_PUBLIC_GATEWAY_URL=http://localhost:4000
|
||||||
|
|
||||||
|
|
||||||
|
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
||||||
|
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||||
|
|
||||||
|
# Service name shown in traces
|
||||||
|
OTEL_SERVICE_NAME=mosaic-gateway
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AI Providers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Ollama (local models — set OLLAMA_BASE_URL to enable)
|
||||||
|
# OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
# OLLAMA_HOST is a legacy alias for OLLAMA_BASE_URL
|
||||||
|
# OLLAMA_HOST=http://localhost:11434
|
||||||
|
# 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
|
||||||
|
# OPENAI_API_KEY=sk-...
|
||||||
|
|
||||||
|
# Custom providers — JSON array of provider configs
|
||||||
|
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
||||||
|
# MOSAIC_CUSTOM_PROVIDERS=
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Embedding Service ───────────────────────────────────────────────────────
|
||||||
|
# OpenAI-compatible embeddings endpoint (default: OpenAI)
|
||||||
|
# EMBEDDING_API_URL=https://api.openai.com/v1
|
||||||
|
# EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Log Summarization Service ───────────────────────────────────────────────
|
||||||
|
# OpenAI-compatible chat completions endpoint for log summarization (default: OpenAI)
|
||||||
|
# SUMMARIZATION_API_URL=https://api.openai.com/v1
|
||||||
|
# SUMMARIZATION_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
|
# Cron schedule for summarization job (default: every 6 hours)
|
||||||
|
# SUMMARIZATION_CRON=0 */6 * * *
|
||||||
|
|
||||||
|
# Cron schedule for log tier management (default: daily at 03:00)
|
||||||
|
# TIER_MANAGEMENT_CRON=0 3 * * *
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Agent ───────────────────────────────────────────────────────────────────
|
||||||
|
# Filesystem sandbox root for agent file tools (default: process.cwd())
|
||||||
|
# AGENT_FILE_SANDBOX_DIR=/var/lib/mosaic/sandbox
|
||||||
|
|
||||||
|
# Comma-separated list of tool names available to non-admin users.
|
||||||
|
# Leave unset to allow all tools for all authenticated users.
|
||||||
|
# AGENT_USER_TOOLS=read_file,list_directory,search_files
|
||||||
|
|
||||||
|
# System prompt injected into every agent session (optional)
|
||||||
|
# AGENT_SYSTEM_PROMPT=You are a helpful assistant.
|
||||||
|
|
||||||
|
|
||||||
|
# ─── MCP Servers ─────────────────────────────────────────────────────────────
|
||||||
|
# JSON array of MCP server configs — set to enable MCP tool integration.
|
||||||
|
# Each entry: {"name":"<id>","url":"<http-or-sse-url>"}
|
||||||
|
# MCP_SERVERS=[{"name":"my-mcp","url":"http://localhost:3100/sse"}]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Coordinator ─────────────────────────────────────────────────────────────
|
||||||
|
# Root directory used to scope coordinator (worktree/repo) operations.
|
||||||
|
# Defaults to the monorepo root auto-detected from process.cwd().
|
||||||
|
# MOSAIC_WORKSPACE_ROOT=/home/user/projects/mosaic
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
|
||||||
|
# DISCORD_BOT_TOKEN=
|
||||||
|
# DISCORD_GUILD_ID=
|
||||||
|
# DISCORD_GATEWAY_URL=http://localhost:4000
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
|
||||||
|
# TELEGRAM_BOT_TOKEN=
|
||||||
|
# TELEGRAM_GATEWAY_URL=http://localhost:4000
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Authentik SSO (optional — set AUTHENTIK_CLIENT_ID to enable) ────────────
|
||||||
|
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
||||||
|
# AUTHENTIK_CLIENT_ID=
|
||||||
|
# AUTHENTIK_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -1,4 +1 @@
|
|||||||
#!/bin/sh
|
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
npx lint-staged
|
npx lint-staged
|
||||||
|
|||||||
@@ -1,4 +1 @@
|
|||||||
#!/bin/sh
|
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
pnpm typecheck && pnpm lint && pnpm format:check
|
pnpm typecheck && pnpm lint && pnpm format:check
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ pnpm-lock.yaml
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
**/drizzle
|
**/drizzle
|
||||||
**/.next
|
**/.next
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -1,57 +1,80 @@
|
|||||||
variables:
|
variables:
|
||||||
- &node_image 'node:22-alpine'
|
- &node_image 'node:22-alpine'
|
||||||
- &install_deps |
|
- &enable_pnpm 'corepack enable'
|
||||||
corepack enable
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- event: [push, pull_request, manual]
|
- 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.
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
install:
|
install:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- *install_deps
|
- corepack enable
|
||||||
|
- pnpm install --frozen-lockfile
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
|
environment:
|
||||||
|
TURBO_API: https://turbo.mosaicstack.dev
|
||||||
|
TURBO_TEAM: mosaic
|
||||||
|
TURBO_TOKEN:
|
||||||
|
from_secret: turbo_token
|
||||||
commands:
|
commands:
|
||||||
- *install_deps
|
- *enable_pnpm
|
||||||
- pnpm typecheck
|
- pnpm typecheck
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
|
# lint, format, and test are independent — run in parallel after typecheck
|
||||||
lint:
|
lint:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
|
environment:
|
||||||
|
TURBO_API: https://turbo.mosaicstack.dev
|
||||||
|
TURBO_TEAM: mosaic
|
||||||
|
TURBO_TOKEN:
|
||||||
|
from_secret: turbo_token
|
||||||
commands:
|
commands:
|
||||||
- *install_deps
|
- *enable_pnpm
|
||||||
- pnpm lint
|
- pnpm lint
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- typecheck
|
||||||
|
|
||||||
format:
|
format:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- *install_deps
|
- *enable_pnpm
|
||||||
- pnpm format:check
|
- pnpm format:check
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- typecheck
|
||||||
|
|
||||||
test:
|
test:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
|
environment:
|
||||||
|
TURBO_API: https://turbo.mosaicstack.dev
|
||||||
|
TURBO_TEAM: mosaic
|
||||||
|
TURBO_TOKEN:
|
||||||
|
from_secret: turbo_token
|
||||||
commands:
|
commands:
|
||||||
- *install_deps
|
- *enable_pnpm
|
||||||
- pnpm test
|
- pnpm test
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- typecheck
|
||||||
|
|
||||||
build:
|
build:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
|
environment:
|
||||||
|
TURBO_API: https://turbo.mosaicstack.dev
|
||||||
|
TURBO_TEAM: mosaic
|
||||||
|
TURBO_TOKEN:
|
||||||
|
from_secret: turbo_token
|
||||||
commands:
|
commands:
|
||||||
- *install_deps
|
- *enable_pnpm
|
||||||
- pnpm build
|
- pnpm build
|
||||||
depends_on:
|
depends_on:
|
||||||
- typecheck
|
|
||||||
- lint
|
- lint
|
||||||
- format
|
- format
|
||||||
- test
|
- test
|
||||||
|
|||||||
@@ -8,23 +8,29 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsx watch src/main.ts",
|
"dev": "tsx watch src/main.ts",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@mariozechner/pi-ai": "~0.57.1",
|
"@mariozechner/pi-ai": "~0.57.1",
|
||||||
"@mariozechner/pi-coding-agent": "~0.57.1",
|
"@mariozechner/pi-coding-agent": "~0.57.1",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@mosaic/auth": "workspace:^",
|
"@mosaic/auth": "workspace:^",
|
||||||
|
"@mosaic/queue": "workspace:^",
|
||||||
"@mosaic/brain": "workspace:^",
|
"@mosaic/brain": "workspace:^",
|
||||||
"@mosaic/coord": "workspace:^",
|
"@mosaic/coord": "workspace:^",
|
||||||
"@mosaic/db": "workspace:^",
|
"@mosaic/db": "workspace:^",
|
||||||
|
"@mosaic/discord-plugin": "workspace:^",
|
||||||
"@mosaic/log": "workspace:^",
|
"@mosaic/log": "workspace:^",
|
||||||
"@mosaic/memory": "workspace:^",
|
"@mosaic/memory": "workspace:^",
|
||||||
|
"@mosaic/telegram-plugin": "workspace:^",
|
||||||
"@mosaic/types": "workspace:^",
|
"@mosaic/types": "workspace:^",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/platform-fastify": "^11.0.0",
|
"@nestjs/platform-fastify": "^11.0.0",
|
||||||
"@nestjs/platform-socket.io": "^11.0.0",
|
"@nestjs/platform-socket.io": "^11.0.0",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.0.0",
|
"@nestjs/websockets": "^11.0.0",
|
||||||
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
|
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
|
||||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
|
"@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
|
||||||
@@ -35,12 +41,16 @@
|
|||||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||||
"@sinclair/typebox": "^0.34.48",
|
"@sinclair/typebox": "^0.34.48",
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"socket.io": "^4.8.0",
|
"socket.io": "^4.8.0",
|
||||||
"uuid": "^11.0.0"
|
"uuid": "^11.0.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
137
apps/gateway/src/__tests__/resource-ownership.test.ts
Normal file
137
apps/gateway/src/__tests__/resource-ownership.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ConversationsController } from '../conversations/conversations.controller.js';
|
||||||
|
import { MissionsController } from '../missions/missions.controller.js';
|
||||||
|
import { ProjectsController } from '../projects/projects.controller.js';
|
||||||
|
import { TasksController } from '../tasks/tasks.controller.js';
|
||||||
|
|
||||||
|
function createBrain() {
|
||||||
|
return {
|
||||||
|
conversations: {
|
||||||
|
findAll: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
findMessages: vi.fn(),
|
||||||
|
addMessage: vi.fn(),
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
findAll: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
},
|
||||||
|
missions: {
|
||||||
|
findAll: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByProject: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
findAll: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByProject: vi.fn(),
|
||||||
|
findByMission: vi.fn(),
|
||||||
|
findByStatus: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Resource ownership checks', () => {
|
||||||
|
it('forbids access to another user conversation', async () => {
|
||||||
|
const brain = createBrain();
|
||||||
|
brain.conversations.findById.mockResolvedValue({ id: 'conv-1', userId: 'user-2' });
|
||||||
|
const controller = new ConversationsController(brain as never);
|
||||||
|
|
||||||
|
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
await expect(controller.findOne('project-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids access to a mission owned by another project owner', async () => {
|
||||||
|
const brain = createBrain();
|
||||||
|
brain.missions.findById.mockResolvedValue({ id: 'mission-1', projectId: 'project-1' });
|
||||||
|
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||||
|
const controller = new MissionsController(brain as never);
|
||||||
|
|
||||||
|
await expect(controller.findOne('mission-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids access to a task owned by another project owner', async () => {
|
||||||
|
const brain = createBrain();
|
||||||
|
brain.tasks.findById.mockResolvedValue({ id: 'task-1', projectId: 'project-1' });
|
||||||
|
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||||
|
const controller = new TasksController(brain as never);
|
||||||
|
|
||||||
|
await expect(controller.findOne('task-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids creating a task with an unowned project', async () => {
|
||||||
|
const brain = createBrain();
|
||||||
|
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||||
|
const controller = new TasksController(brain as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.create(
|
||||||
|
{
|
||||||
|
title: 'Task',
|
||||||
|
projectId: 'project-1',
|
||||||
|
},
|
||||||
|
{ id: 'user-1' },
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids listing tasks for an unowned project', async () => {
|
||||||
|
const brain = createBrain();
|
||||||
|
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||||
|
const controller = new TasksController(brain as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.list({ id: 'user-1' }, 'project-1', undefined, undefined),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists only tasks for the current user owned projects when no filter is provided', async () => {
|
||||||
|
const brain = createBrain();
|
||||||
|
brain.projects.findAll.mockResolvedValue([
|
||||||
|
{ id: 'project-1', ownerId: 'user-1' },
|
||||||
|
{ id: 'project-2', ownerId: 'user-2' },
|
||||||
|
]);
|
||||||
|
brain.missions.findAll.mockResolvedValue([{ id: 'mission-1', projectId: 'project-1' }]);
|
||||||
|
brain.tasks.findAll.mockResolvedValue([
|
||||||
|
{ id: 'task-1', projectId: 'project-1' },
|
||||||
|
{ id: 'task-2', missionId: 'mission-1' },
|
||||||
|
{ id: 'task-3', projectId: 'project-2' },
|
||||||
|
]);
|
||||||
|
const controller = new TasksController(brain as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.list({ id: 'user-1' }, undefined, undefined, undefined),
|
||||||
|
).resolves.toEqual([
|
||||||
|
{ id: 'task-1', projectId: 'project-1' },
|
||||||
|
{ id: 'task-2', missionId: 'mission-1' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
73
apps/gateway/src/admin/admin-health.controller.ts
Normal file
73
apps/gateway/src/admin/admin-health.controller.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||||
|
import { sql, type Db } from '@mosaic/db';
|
||||||
|
import { createQueue } from '@mosaic/queue';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
|
import { ProviderService } from '../agent/provider.service.js';
|
||||||
|
import { AdminGuard } from './admin.guard.js';
|
||||||
|
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||||
|
|
||||||
|
@Controller('api/admin/health')
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
export class AdminHealthController {
|
||||||
|
constructor(
|
||||||
|
@Inject(DB) private readonly db: Db,
|
||||||
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
|
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async check(): Promise<HealthStatusDto> {
|
||||||
|
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
||||||
|
|
||||||
|
const sessions = this.agentService.listSessions();
|
||||||
|
const providers = this.providerService.listProviders();
|
||||||
|
|
||||||
|
const allOk = database.status === 'ok' && cache.status === 'ok';
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: allOk ? 'ok' : 'degraded',
|
||||||
|
database,
|
||||||
|
cache,
|
||||||
|
agentPool: { activeSessions: sessions.length },
|
||||||
|
providers: providers.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
available: p.available,
|
||||||
|
modelCount: p.models.length,
|
||||||
|
})),
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkDatabase(): Promise<ServiceStatusDto> {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
await this.db.execute(sql`SELECT 1`);
|
||||||
|
return { status: 'ok', latencyMs: Date.now() - start };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
latencyMs: Date.now() - start,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkCache(): Promise<ServiceStatusDto> {
|
||||||
|
const start = Date.now();
|
||||||
|
const handle = createQueue();
|
||||||
|
try {
|
||||||
|
await handle.redis.ping();
|
||||||
|
return { status: 'ok', latencyMs: Date.now() - start };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
latencyMs: Date.now() - start,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await handle.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
apps/gateway/src/admin/admin.controller.ts
Normal file
146
apps/gateway/src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { eq, type Db, users as usersTable } from '@mosaic/db';
|
||||||
|
import type { Auth } from '@mosaic/auth';
|
||||||
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
import { AdminGuard } from './admin.guard.js';
|
||||||
|
import type {
|
||||||
|
BanUserDto,
|
||||||
|
CreateUserDto,
|
||||||
|
UpdateUserRoleDto,
|
||||||
|
UserDto,
|
||||||
|
UserListDto,
|
||||||
|
} from './admin.dto.js';
|
||||||
|
|
||||||
|
type UserRow = typeof usersTable.$inferSelect;
|
||||||
|
|
||||||
|
function toUserDto(u: UserRow): UserDto {
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
email: u.email,
|
||||||
|
role: u.role,
|
||||||
|
banned: u.banned ?? false,
|
||||||
|
banReason: u.banReason ?? null,
|
||||||
|
createdAt: u.createdAt.toISOString(),
|
||||||
|
updatedAt: u.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireUpdated(
|
||||||
|
db: Db,
|
||||||
|
id: string,
|
||||||
|
update: Partial<Omit<UserRow, 'id' | 'createdAt'>>,
|
||||||
|
): Promise<UserDto> {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(usersTable)
|
||||||
|
.set({ ...update, updatedAt: new Date() })
|
||||||
|
.where(eq(usersTable.id, id))
|
||||||
|
.returning();
|
||||||
|
if (!updated) throw new InternalServerErrorException('Update returned no rows');
|
||||||
|
return toUserDto(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('api/admin/users')
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
export class AdminController {
|
||||||
|
constructor(
|
||||||
|
@Inject(DB) private readonly db: Db,
|
||||||
|
@Inject(AUTH) private readonly auth: Auth,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async listUsers(): Promise<UserListDto> {
|
||||||
|
const rows = await this.db.select().from(usersTable).orderBy(usersTable.createdAt);
|
||||||
|
const userList: UserDto[] = rows.map(toUserDto);
|
||||||
|
return { users: userList, total: userList.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getUser(@Param('id') id: string): Promise<UserDto> {
|
||||||
|
const [user] = await this.db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1);
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
return toUserDto(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createUser(@Body() body: CreateUserDto): Promise<UserDto> {
|
||||||
|
// Use auth API to create user so password is properly hashed
|
||||||
|
const authApi = this.auth.api as unknown as {
|
||||||
|
createUser: (opts: {
|
||||||
|
body: { name: string; email: string; password: string; role?: string };
|
||||||
|
}) => Promise<{
|
||||||
|
user: { id: string; name: string; email: string; createdAt: unknown; updatedAt: unknown };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await authApi.createUser({
|
||||||
|
body: {
|
||||||
|
name: body.name,
|
||||||
|
email: body.email,
|
||||||
|
password: body.password,
|
||||||
|
role: body.role ?? 'member',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-fetch from DB to get full row with our schema
|
||||||
|
const [user] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(usersTable)
|
||||||
|
.where(eq(usersTable.id, result.user.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) throw new InternalServerErrorException('User created but not found in DB');
|
||||||
|
return toUserDto(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/role')
|
||||||
|
async setRole(@Param('id') id: string, @Body() body: UpdateUserRoleDto): Promise<UserDto> {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
return requireUpdated(this.db, id, { role: body.role });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/ban')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async banUser(@Param('id') id: string, @Body() body: BanUserDto): Promise<UserDto> {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
return requireUpdated(this.db, id, { banned: true, banReason: body.reason ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/unban')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async unbanUser(@Param('id') id: string): Promise<UserDto> {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
return requireUpdated(this.db, id, { banned: false, banReason: null, banExpires: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async deleteUser(@Param('id') id: string): Promise<void> {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
await this.db.delete(usersTable).where(eq(usersTable.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureExists(id: string): Promise<void> {
|
||||||
|
const [existing] = await this.db
|
||||||
|
.select({ id: usersTable.id })
|
||||||
|
.from(usersTable)
|
||||||
|
.where(eq(usersTable.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!existing) throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
56
apps/gateway/src/admin/admin.dto.ts
Normal file
56
apps/gateway/src/admin/admin.dto.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export interface UserDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
banned: boolean;
|
||||||
|
banReason: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserListDto {
|
||||||
|
users: UserDto[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserDto {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserRoleDto {
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BanUserDto {
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthStatusDto {
|
||||||
|
status: 'ok' | 'degraded' | 'error';
|
||||||
|
database: ServiceStatusDto;
|
||||||
|
cache: ServiceStatusDto;
|
||||||
|
agentPool: AgentPoolStatusDto;
|
||||||
|
providers: ProviderStatusDto[];
|
||||||
|
checkedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceStatusDto {
|
||||||
|
status: 'ok' | 'error';
|
||||||
|
latencyMs?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentPoolStatusDto {
|
||||||
|
activeSessions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderStatusDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
available: boolean;
|
||||||
|
modelCount: number;
|
||||||
|
}
|
||||||
44
apps/gateway/src/admin/admin.guard.ts
Normal file
44
apps/gateway/src/admin/admin.guard.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
|
import type { Auth } from '@mosaic/auth';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
|
|
||||||
|
interface UserWithRole {
|
||||||
|
id: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminGuard implements CanActivate {
|
||||||
|
constructor(@Inject(AUTH) private readonly auth: Auth) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||||
|
const headers = fromNodeHeaders(request.raw.headers);
|
||||||
|
|
||||||
|
const result = await this.auth.api.getSession({ headers });
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired session');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.user as UserWithRole;
|
||||||
|
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
throw new ForbiddenException('Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
(request as FastifyRequest & { user: unknown; session: unknown }).user = result.user;
|
||||||
|
(request as FastifyRequest & { user: unknown; session: unknown }).session = result.session;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/gateway/src/admin/admin.module.ts
Normal file
10
apps/gateway/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AdminController } from './admin.controller.js';
|
||||||
|
import { AdminHealthController } from './admin-health.controller.js';
|
||||||
|
import { AdminGuard } from './admin.guard.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AdminController, AdminHealthController],
|
||||||
|
providers: [AdminGuard],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
@@ -2,15 +2,18 @@ import { Global, Module } from '@nestjs/common';
|
|||||||
import { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { RoutingService } from './routing.service.js';
|
import { RoutingService } from './routing.service.js';
|
||||||
|
import { SkillLoaderService } from './skill-loader.service.js';
|
||||||
import { ProvidersController } from './providers.controller.js';
|
import { ProvidersController } from './providers.controller.js';
|
||||||
import { SessionsController } from './sessions.controller.js';
|
import { SessionsController } from './sessions.controller.js';
|
||||||
import { CoordModule } from '../coord/coord.module.js';
|
import { CoordModule } from '../coord/coord.module.js';
|
||||||
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoordModule],
|
imports: [CoordModule, McpClientModule, SkillsModule],
|
||||||
providers: [ProviderService, RoutingService, AgentService],
|
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
||||||
controllers: [ProvidersController, SessionsController],
|
controllers: [ProvidersController, SessionsController],
|
||||||
exports: [AgentService, ProviderService, RoutingService],
|
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
export class AgentModule {}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
|
DefaultResourceLoader,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
type AgentSession as PiAgentSession,
|
type AgentSession as PiAgentSession,
|
||||||
type AgentSessionEvent,
|
type AgentSessionEvent,
|
||||||
@@ -13,14 +14,41 @@ import { MEMORY } from '../memory/memory.tokens.js';
|
|||||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||||
import { CoordService } from '../coord/coord.service.js';
|
import { CoordService } from '../coord/coord.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
|
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
||||||
|
import { SkillLoaderService } from './skill-loader.service.js';
|
||||||
import { createBrainTools } from './tools/brain-tools.js';
|
import { createBrainTools } from './tools/brain-tools.js';
|
||||||
import { createCoordTools } from './tools/coord-tools.js';
|
import { createCoordTools } from './tools/coord-tools.js';
|
||||||
import { createMemoryTools } from './tools/memory-tools.js';
|
import { createMemoryTools } from './tools/memory-tools.js';
|
||||||
|
import { createFileTools } from './tools/file-tools.js';
|
||||||
|
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 type { SessionInfoDto } from './session.dto.js';
|
||||||
|
|
||||||
export interface AgentSessionOptions {
|
export interface AgentSessionOptions {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
|
/**
|
||||||
|
* Sandbox working directory for the session.
|
||||||
|
* File, git, and shell tools will be restricted to this directory.
|
||||||
|
* Falls back to AGENT_FILE_SANDBOX_DIR env var or process.cwd().
|
||||||
|
*/
|
||||||
|
sandboxDir?: string;
|
||||||
|
/**
|
||||||
|
* Platform-level system prompt for this session.
|
||||||
|
* Merged with skill prompt additions (platform prompt first, then skills).
|
||||||
|
* Falls back to AGENT_SYSTEM_PROMPT env var when omitted.
|
||||||
|
*/
|
||||||
|
systemPrompt?: string;
|
||||||
|
/**
|
||||||
|
* Explicit allowlist of tool names available in this session.
|
||||||
|
* When set, only listed tools are registered with the agent.
|
||||||
|
* When omitted for non-admin users, falls back to AGENT_USER_TOOLS env var.
|
||||||
|
* Admins (isAdmin=true) always receive the full tool set unless explicitly restricted.
|
||||||
|
*/
|
||||||
|
allowedTools?: string[];
|
||||||
|
/** Whether the requesting user has admin privileges. Controls default tool access. */
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSession {
|
export interface AgentSession {
|
||||||
@@ -33,6 +61,12 @@ export interface AgentSession {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
promptCount: number;
|
promptCount: number;
|
||||||
channels: Set<string>;
|
channels: Set<string>;
|
||||||
|
/** System prompt additions injected from enabled prompt-type skills. */
|
||||||
|
skillPromptAdditions: string[];
|
||||||
|
/** Resolved sandbox directory for this session. */
|
||||||
|
sandboxDir: string;
|
||||||
|
/** Tool names available in this session, or null when all tools are available. */
|
||||||
|
allowedTools: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -41,21 +75,57 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
private readonly sessions = new Map<string, AgentSession>();
|
private readonly sessions = new Map<string, AgentSession>();
|
||||||
private readonly creating = new Map<string, Promise<AgentSession>>();
|
private readonly creating = new Map<string, Promise<AgentSession>>();
|
||||||
|
|
||||||
private readonly customTools: ToolDefinition[];
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||||
@Inject(BRAIN) private readonly brain: Brain,
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
@Inject(MEMORY) private readonly memory: Memory,
|
@Inject(MEMORY) private readonly memory: Memory,
|
||||||
@Inject(EmbeddingService) private readonly embeddingService: EmbeddingService,
|
@Inject(EmbeddingService) private readonly embeddingService: EmbeddingService,
|
||||||
@Inject(CoordService) private readonly coordService: CoordService,
|
@Inject(CoordService) private readonly coordService: CoordService,
|
||||||
) {
|
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
||||||
this.customTools = [
|
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
||||||
...createBrainTools(brain),
|
) {}
|
||||||
...createCoordTools(coordService),
|
|
||||||
...createMemoryTools(memory, embeddingService.available ? embeddingService : null),
|
/**
|
||||||
|
* Build the full set of custom tools scoped to the given sandbox directory.
|
||||||
|
* Brain/coord/memory/web tools are stateless with respect to cwd; file/git/shell
|
||||||
|
* tools receive the resolved sandboxDir so they operate within the sandbox.
|
||||||
|
*/
|
||||||
|
private buildToolsForSandbox(sandboxDir: string): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
...createBrainTools(this.brain),
|
||||||
|
...createCoordTools(this.coordService),
|
||||||
|
...createMemoryTools(
|
||||||
|
this.memory,
|
||||||
|
this.embeddingService.available ? this.embeddingService : null,
|
||||||
|
),
|
||||||
|
...createFileTools(sandboxDir),
|
||||||
|
...createGitTools(sandboxDir),
|
||||||
|
...createShellTools(sandboxDir),
|
||||||
|
...createWebTools(),
|
||||||
];
|
];
|
||||||
this.logger.log(`Registered ${this.customTools.length} custom tools`);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the tool allowlist for a session.
|
||||||
|
* - Admin users: all tools unless an explicit allowedTools list is passed.
|
||||||
|
* - Regular users: use allowedTools if provided, otherwise parse AGENT_USER_TOOLS env var.
|
||||||
|
* Returns null when all tools should be available.
|
||||||
|
*/
|
||||||
|
private resolveAllowedTools(isAdmin: boolean, allowedTools?: string[]): string[] | null {
|
||||||
|
if (allowedTools !== undefined) {
|
||||||
|
return allowedTools.length === 0 ? [] : allowedTools;
|
||||||
|
}
|
||||||
|
if (isAdmin) {
|
||||||
|
return null; // admins get everything
|
||||||
|
}
|
||||||
|
const envTools = process.env['AGENT_USER_TOOLS'];
|
||||||
|
if (!envTools) {
|
||||||
|
return null; // no restriction configured
|
||||||
|
}
|
||||||
|
return envTools
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => t.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
|
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
|
||||||
@@ -80,18 +150,76 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
const providerName = model?.provider ?? 'default';
|
const providerName = model?.provider ?? 'default';
|
||||||
const modelId = model?.id ?? '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();
|
||||||
|
|
||||||
|
// Resolve allowed tool set
|
||||||
|
const allowedTools = this.resolveAllowedTools(options?.isAdmin ?? false, options?.allowedTools);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId})`,
|
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId}, sandbox=${sandboxDir}, tools=${allowedTools === null ? 'all' : allowedTools.join(',') || 'none'})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Load skill tools from the catalog
|
||||||
|
const { metaTools: skillMetaTools, promptAdditions } =
|
||||||
|
await this.skillLoaderService.loadForSession();
|
||||||
|
if (skillMetaTools.length > 0) {
|
||||||
|
this.logger.log(`Attaching ${skillMetaTools.length} skill tool(s) to session ${sessionId}`);
|
||||||
|
}
|
||||||
|
if (promptAdditions.length > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Injecting ${promptAdditions.length} skill prompt addition(s) into session ${sessionId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build per-session tools scoped to the sandbox directory
|
||||||
|
const sandboxTools = this.buildToolsForSandbox(sandboxDir);
|
||||||
|
|
||||||
|
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
||||||
|
const mcpTools = this.mcpClientService.getToolDefinitions();
|
||||||
|
let allCustomTools = [...sandboxTools, ...skillMetaTools, ...mcpTools];
|
||||||
|
if (mcpTools.length > 0) {
|
||||||
|
this.logger.log(`Attaching ${mcpTools.length} MCP client tool(s) to session ${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter tools by allowlist when a restriction is in effect
|
||||||
|
if (allowedTools !== null) {
|
||||||
|
const allowedSet = new Set(allowedTools);
|
||||||
|
const before = allCustomTools.length;
|
||||||
|
allCustomTools = allCustomTools.filter((t) => allowedSet.has(t.name));
|
||||||
|
this.logger.log(
|
||||||
|
`Tool restriction applied: ${allCustomTools.length}/${before} tools allowed for session ${sessionId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build system prompt: platform prompt + skill additions appended
|
||||||
|
const platformPrompt = options?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined;
|
||||||
|
const appendSystemPrompt =
|
||||||
|
promptAdditions.length > 0 ? promptAdditions.join('\n\n') : undefined;
|
||||||
|
|
||||||
|
// Construct a resource loader that injects the configured system prompt
|
||||||
|
const resourceLoader = new DefaultResourceLoader({
|
||||||
|
cwd: sandboxDir,
|
||||||
|
noExtensions: true,
|
||||||
|
noSkills: true,
|
||||||
|
noPromptTemplates: true,
|
||||||
|
noThemes: true,
|
||||||
|
systemPrompt: platformPrompt,
|
||||||
|
appendSystemPrompt: appendSystemPrompt,
|
||||||
|
});
|
||||||
|
await resourceLoader.reload();
|
||||||
|
|
||||||
let piSession: PiAgentSession;
|
let piSession: PiAgentSession;
|
||||||
try {
|
try {
|
||||||
const result = await createAgentSession({
|
const result = await createAgentSession({
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
modelRegistry: this.providerService.getRegistry(),
|
modelRegistry: this.providerService.getRegistry(),
|
||||||
model: model ?? undefined,
|
model: model ?? undefined,
|
||||||
|
cwd: sandboxDir,
|
||||||
tools: [],
|
tools: [],
|
||||||
customTools: this.customTools,
|
customTools: allCustomTools,
|
||||||
|
resourceLoader,
|
||||||
});
|
});
|
||||||
piSession = result.session;
|
piSession = result.session;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -124,6 +252,9 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
promptCount: 0,
|
promptCount: 0,
|
||||||
channels: new Set(),
|
channels: new Set(),
|
||||||
|
skillPromptAdditions: promptAdditions,
|
||||||
|
sandboxDir,
|
||||||
|
allowedTools,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.sessions.set(sessionId, session);
|
this.sessions.set(sessionId, session);
|
||||||
|
|||||||
17
apps/gateway/src/agent/provider.dto.ts
Normal file
17
apps/gateway/src/agent/provider.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export interface TestConnectionDto {
|
||||||
|
/** Provider identifier to test (e.g. 'ollama', custom provider id) */
|
||||||
|
providerId: string;
|
||||||
|
/** Optional base URL override for ad-hoc testing */
|
||||||
|
baseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestConnectionResultDto {
|
||||||
|
providerId: string;
|
||||||
|
reachable: boolean;
|
||||||
|
/** Round-trip latency in milliseconds (present when reachable) */
|
||||||
|
latencyMs?: number;
|
||||||
|
/** Human-readable error when unreachable */
|
||||||
|
error?: string;
|
||||||
|
/** Model ids discovered at the remote endpoint (present when reachable) */
|
||||||
|
discoveredModels?: string[];
|
||||||
|
}
|
||||||
@@ -2,14 +2,15 @@ import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
|||||||
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
|
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
|
||||||
import type { Model, Api } from '@mariozechner/pi-ai';
|
import type { Model, Api } from '@mariozechner/pi-ai';
|
||||||
import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
|
import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
|
||||||
|
import type { TestConnectionResultDto } from './provider.dto.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProviderService implements OnModuleInit {
|
export class ProviderService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(ProviderService.name);
|
private readonly logger = new Logger(ProviderService.name);
|
||||||
private registry!: ModelRegistry;
|
private registry!: ModelRegistry;
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
onModuleInit(): void {
|
||||||
const authStorage = AuthStorage.create();
|
const authStorage = AuthStorage.inMemory();
|
||||||
this.registry = new ModelRegistry(authStorage);
|
this.registry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
this.registerOllamaProvider();
|
this.registerOllamaProvider();
|
||||||
@@ -64,6 +65,63 @@ export class ProviderService implements OnModuleInit {
|
|||||||
return this.registry.getAvailable().map((m) => this.toModelInfo(m));
|
return this.registry.getAvailable().map((m) => this.toModelInfo(m));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async testConnection(providerId: string, baseUrl?: string): Promise<TestConnectionResultDto> {
|
||||||
|
// Resolve baseUrl: explicit override > registered provider > ollama env
|
||||||
|
let resolvedUrl = baseUrl;
|
||||||
|
|
||||||
|
if (!resolvedUrl) {
|
||||||
|
const allModels = this.registry.getAll();
|
||||||
|
const providerModels = allModels.filter((m) => m.provider === providerId);
|
||||||
|
if (providerModels.length === 0) {
|
||||||
|
return { providerId, reachable: false, error: `Provider '${providerId}' not found` };
|
||||||
|
}
|
||||||
|
// For Ollama, derive the base URL from environment
|
||||||
|
if (providerId === 'ollama') {
|
||||||
|
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
|
||||||
|
if (!ollamaUrl) {
|
||||||
|
return { providerId, reachable: false, error: 'OLLAMA_BASE_URL not configured' };
|
||||||
|
}
|
||||||
|
resolvedUrl = `${ollamaUrl}/v1/models`;
|
||||||
|
} else {
|
||||||
|
// For other providers, we can only do a basic check
|
||||||
|
return { providerId, reachable: true, discoveredModels: providerModels.map((m) => m.id) };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolvedUrl = resolvedUrl.replace(/\/?$/, '') + '/models';
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await fetch(resolvedUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const latencyMs = Date.now() - start;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { providerId, reachable: false, latencyMs, error: `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
let discoveredModels: string[] | undefined;
|
||||||
|
try {
|
||||||
|
const json = (await res.json()) as { models?: Array<{ id?: string; name?: string }> };
|
||||||
|
if (Array.isArray(json.models)) {
|
||||||
|
discoveredModels = json.models.map((m) => m.id ?? m.name ?? '').filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors — endpoint was reachable
|
||||||
|
}
|
||||||
|
|
||||||
|
return { providerId, reachable: true, latencyMs, discoveredModels };
|
||||||
|
} catch (err) {
|
||||||
|
const latencyMs = Date.now() - start;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { providerId, reachable: false, latencyMs, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
registerCustomProvider(config: CustomProviderConfig): void {
|
registerCustomProvider(config: CustomProviderConfig): void {
|
||||||
this.registry.registerProvider(config.id, {
|
this.registry.registerProvider(config.id, {
|
||||||
baseUrl: config.baseUrl,
|
baseUrl: config.baseUrl,
|
||||||
@@ -89,17 +147,19 @@ export class ProviderService implements OnModuleInit {
|
|||||||
const modelsEnv = process.env['OLLAMA_MODELS'] ?? 'llama3.2,codellama,mistral';
|
const modelsEnv = process.env['OLLAMA_MODELS'] ?? 'llama3.2,codellama,mistral';
|
||||||
const modelIds = modelsEnv
|
const modelIds = modelsEnv
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((m) => m.trim())
|
.map((modelId: string) => modelId.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
this.registerCustomProvider({
|
this.registry.registerProvider('ollama', {
|
||||||
id: 'ollama',
|
|
||||||
name: 'Ollama',
|
|
||||||
baseUrl: `${ollamaUrl}/v1`,
|
baseUrl: `${ollamaUrl}/v1`,
|
||||||
|
apiKey: 'ollama',
|
||||||
|
api: 'openai-completions' as never,
|
||||||
models: modelIds.map((id) => ({
|
models: modelIds.map((id) => ({
|
||||||
id,
|
id,
|
||||||
name: id,
|
name: id,
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
|
input: ['text'] as ('text' | 'image')[],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
contextWindow: 8192,
|
contextWindow: 8192,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RoutingCriteria } from '@mosaic/types';
|
|||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { RoutingService } from './routing.service.js';
|
import { RoutingService } from './routing.service.js';
|
||||||
|
import type { TestConnectionDto, TestConnectionResultDto } from './provider.dto.js';
|
||||||
|
|
||||||
@Controller('api/providers')
|
@Controller('api/providers')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -22,6 +23,11 @@ export class ProvidersController {
|
|||||||
return this.providerService.listAvailableModels();
|
return this.providerService.listAvailableModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('test')
|
||||||
|
testConnection(@Body() body: TestConnectionDto): Promise<TestConnectionResultDto> {
|
||||||
|
return this.providerService.testConnection(body.providerId, body.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('route')
|
@Post('route')
|
||||||
route(@Body() criteria: RoutingCriteria) {
|
route(@Body() criteria: RoutingCriteria) {
|
||||||
return this.routingService.route(criteria);
|
return this.routingService.route(criteria);
|
||||||
|
|||||||
@@ -145,8 +145,11 @@ export class RoutingService {
|
|||||||
|
|
||||||
private classifyTier(model: ModelInfo): CostTier {
|
private classifyTier(model: ModelInfo): CostTier {
|
||||||
const cost = model.cost.input;
|
const cost = model.cost.input;
|
||||||
if (cost <= COST_TIER_THRESHOLDS.cheap.maxInput) return 'cheap';
|
const cheapThreshold = COST_TIER_THRESHOLDS['cheap'];
|
||||||
if (cost <= COST_TIER_THRESHOLDS.standard.maxInput) return 'standard';
|
const standardThreshold = COST_TIER_THRESHOLDS['standard'];
|
||||||
|
|
||||||
|
if (cost <= cheapThreshold.maxInput) return 'cheap';
|
||||||
|
if (cost <= standardThreshold.maxInput) return 'standard';
|
||||||
return 'premium';
|
return 'premium';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,33 @@ export interface SessionListDto {
|
|||||||
sessions: SessionInfoDto[];
|
sessions: SessionInfoDto[];
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options accepted when creating an agent session.
|
||||||
|
* All fields are optional; omitting them falls back to env-var or process defaults.
|
||||||
|
*/
|
||||||
|
export interface CreateSessionOptionsDto {
|
||||||
|
/** Provider name (e.g. "anthropic", "openai"). */
|
||||||
|
provider?: string;
|
||||||
|
/** Model ID to use for this session. */
|
||||||
|
modelId?: string;
|
||||||
|
/**
|
||||||
|
* Sandbox working directory for the session.
|
||||||
|
* File, git, and shell tools will be restricted to this directory.
|
||||||
|
* Defaults to AGENT_FILE_SANDBOX_DIR env var or process.cwd().
|
||||||
|
*/
|
||||||
|
sandboxDir?: string;
|
||||||
|
/**
|
||||||
|
* Platform-level system prompt for this session.
|
||||||
|
* Merged with skill prompt additions (platform prompt first, then skills).
|
||||||
|
* Falls back to AGENT_SYSTEM_PROMPT env var when omitted.
|
||||||
|
*/
|
||||||
|
systemPrompt?: string;
|
||||||
|
/**
|
||||||
|
* Explicit allowlist of tool names available in this session.
|
||||||
|
* When provided, only listed tools are registered with the agent.
|
||||||
|
* Admins receive all tools; regular users fall back to AGENT_USER_TOOLS
|
||||||
|
* env var (comma-separated) when this field is not supplied.
|
||||||
|
*/
|
||||||
|
allowedTools?: string[];
|
||||||
|
}
|
||||||
|
|||||||
59
apps/gateway/src/agent/skill-loader.service.ts
Normal file
59
apps/gateway/src/agent/skill-loader.service.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { SkillsService } from '../skills/skills.service.js';
|
||||||
|
import { createSkillTools } from './tools/skill-tools.js';
|
||||||
|
|
||||||
|
export interface LoadedSkills {
|
||||||
|
/** Meta-tools: skill_list + skill_invoke */
|
||||||
|
metaTools: ToolDefinition[];
|
||||||
|
/**
|
||||||
|
* System prompt additions from enabled prompt-type skills.
|
||||||
|
* Callers may prepend these to the session system prompt.
|
||||||
|
*/
|
||||||
|
promptAdditions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SkillLoaderService is responsible for:
|
||||||
|
* 1. Providing the skill meta-tools (skill_list, skill_invoke) to agent sessions.
|
||||||
|
* 2. Collecting system-prompt additions from enabled prompt-type skills.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SkillLoaderService {
|
||||||
|
private readonly logger = new Logger(SkillLoaderService.name);
|
||||||
|
|
||||||
|
constructor(@Inject(SkillsService) private readonly skillsService: SkillsService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load enabled skills and return tools + prompt additions for a new session.
|
||||||
|
*/
|
||||||
|
async loadForSession(): Promise<LoadedSkills> {
|
||||||
|
const metaTools = createSkillTools(this.skillsService);
|
||||||
|
|
||||||
|
let promptAdditions: string[] = [];
|
||||||
|
try {
|
||||||
|
const enabledSkills = await this.skillsService.findEnabled();
|
||||||
|
promptAdditions = enabledSkills.flatMap((skill) => {
|
||||||
|
const config = (skill.config ?? {}) as Record<string, unknown>;
|
||||||
|
const skillType = (config['type'] as string | undefined) ?? 'prompt';
|
||||||
|
if (skillType === 'prompt') {
|
||||||
|
const addition = (config['prompt'] as string | undefined) ?? skill.description;
|
||||||
|
return addition ? [addition] : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Loaded ${enabledSkills.length} enabled skill(s), ` +
|
||||||
|
`${promptAdditions.length} prompt addition(s)`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Non-fatal: log and continue without prompt additions
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to load skill prompt additions: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { metaTools, promptAdditions };
|
||||||
|
}
|
||||||
|
}
|
||||||
189
apps/gateway/src/agent/tools/file-tools.ts
Normal file
189
apps/gateway/src/agent/tools/file-tools.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
|
||||||
|
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
|
||||||
|
|
||||||
|
export function createFileTools(baseDir: string): ToolDefinition[] {
|
||||||
|
const readFileTool: ToolDefinition = {
|
||||||
|
name: 'fs_read_file',
|
||||||
|
label: 'Read File',
|
||||||
|
description:
|
||||||
|
'Read the contents of a file. Path is resolved relative to the sandbox base directory.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
path: Type.String({
|
||||||
|
description: 'File path (relative to sandbox base or absolute within it)',
|
||||||
|
}),
|
||||||
|
encoding: Type.Optional(
|
||||||
|
Type.String({ description: 'Encoding: utf8 (default), base64, hex' }),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const { path, encoding } = params as { path: string; encoding?: string };
|
||||||
|
let safePath: string;
|
||||||
|
try {
|
||||||
|
safePath = resolveSafe(baseDir, path);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await stat(safePath);
|
||||||
|
if (!info.isFile()) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: path is not a file: ${path}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (info.size > MAX_READ_BYTES) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Error: file too large (${info.size} bytes, limit ${MAX_READ_BYTES} bytes)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const enc = (encoding ?? 'utf8') as BufferEncoding;
|
||||||
|
const content = await readFile(safePath, { encoding: enc });
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: String(content) }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeFileTool: ToolDefinition = {
|
||||||
|
name: 'fs_write_file',
|
||||||
|
label: 'Write File',
|
||||||
|
description:
|
||||||
|
'Write content to a file. Path is resolved relative to the sandbox base directory. Overwrites existing file.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
path: Type.String({
|
||||||
|
description: 'File path (relative to sandbox base or absolute within it)',
|
||||||
|
}),
|
||||||
|
content: Type.String({ description: 'Content to write' }),
|
||||||
|
encoding: Type.Optional(Type.String({ description: 'Encoding: utf8 (default), base64' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const { path, content, encoding } = params as {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
encoding?: string;
|
||||||
|
};
|
||||||
|
let safePath: string;
|
||||||
|
try {
|
||||||
|
safePath = resolveSafe(baseDir, path);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.byteLength(content, 'utf8') > MAX_WRITE_BYTES) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Error: content too large (limit ${MAX_WRITE_BYTES} bytes)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const enc = (encoding ?? 'utf8') as BufferEncoding;
|
||||||
|
await writeFile(safePath, content, { encoding: enc });
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `File written successfully: ${path}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error writing file: ${String(err)}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const listDirectoryTool: ToolDefinition = {
|
||||||
|
name: 'fs_list_directory',
|
||||||
|
label: 'List Directory',
|
||||||
|
description: 'List files and directories at a given path within the sandbox base directory.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
path: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description: 'Directory path (relative to sandbox base). Defaults to base directory.',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const { path } = params as { path?: string };
|
||||||
|
const target = path ?? '.';
|
||||||
|
let safePath: string;
|
||||||
|
try {
|
||||||
|
safePath = resolveSafe(baseDir, target);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await stat(safePath);
|
||||||
|
if (!info.isDirectory()) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: path is not a directory: ${target}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const entries = await readdir(safePath, { withFileTypes: true });
|
||||||
|
const items = entries.map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
type: e.isDirectory() ? 'directory' : e.isSymbolicLink() ? 'symlink' : 'file',
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(items, null, 2) }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error listing directory: ${String(err)}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [readFileTool, writeFileTool, listDirectoryTool];
|
||||||
|
}
|
||||||
169
apps/gateway/src/agent/tools/git-tools.ts
Normal file
169
apps/gateway/src/agent/tools/git-tools.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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,
|
||||||
|
): Promise<{ stdout: string; stderr: string; error?: string }> {
|
||||||
|
// Only allow specific safe read-only git subcommands
|
||||||
|
const allowedSubcommands = ['status', 'log', 'diff', 'show', 'branch', 'tag', 'ls-files'];
|
||||||
|
const subcommand = args[0];
|
||||||
|
if (!subcommand || !allowedSubcommands.includes(subcommand)) {
|
||||||
|
return {
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
error: `Blocked: git subcommand "${subcommand}" is not allowed. Permitted: ${allowedSubcommands.join(', ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd = `git ${args.map((a) => JSON.stringify(a)).join(' ')}`;
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execAsync(cmd, {
|
||||||
|
cwd,
|
||||||
|
timeout: GIT_TIMEOUT_MS,
|
||||||
|
maxBuffer: MAX_OUTPUT_BYTES,
|
||||||
|
});
|
||||||
|
return { stdout, stderr };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { stdout?: string; stderr?: string; message?: string };
|
||||||
|
return {
|
||||||
|
stdout: e.stdout ?? '',
|
||||||
|
stderr: e.stderr ?? '',
|
||||||
|
error: e.message ?? String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
||||||
|
const defaultCwd = sandboxDir ?? process.cwd();
|
||||||
|
|
||||||
|
const gitStatus: ToolDefinition = {
|
||||||
|
name: 'git_status',
|
||||||
|
label: 'Git Status',
|
||||||
|
description: 'Show the working tree status (staged, unstaged, untracked files).',
|
||||||
|
parameters: Type.Object({
|
||||||
|
cwd: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description: 'Repository working directory (relative to sandbox or absolute within it).',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const { cwd } = params as { cwd?: string };
|
||||||
|
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||||
|
const result = await runGit(['status', '--short', '--branch'], safeCwd);
|
||||||
|
const text = result.error
|
||||||
|
? `Error: ${result.error}\n${result.stderr}`
|
||||||
|
: result.stdout || '(no output)';
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: text }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const gitLog: ToolDefinition = {
|
||||||
|
name: 'git_log',
|
||||||
|
label: 'Git Log',
|
||||||
|
description: 'Show recent commit history.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
limit: Type.Optional(Type.Number({ description: 'Number of commits to show (default 20)' })),
|
||||||
|
oneline: Type.Optional(
|
||||||
|
Type.Boolean({ description: 'Compact one-line format (default true)' }),
|
||||||
|
),
|
||||||
|
cwd: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description: 'Repository working directory (relative to sandbox or absolute within it).',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const { limit, oneline, cwd } = params as {
|
||||||
|
limit?: number;
|
||||||
|
oneline?: boolean;
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||||
|
const args = ['log', `--max-count=${limit ?? 20}`];
|
||||||
|
if (oneline !== false) args.push('--oneline');
|
||||||
|
const result = await runGit(args, safeCwd);
|
||||||
|
const text = result.error
|
||||||
|
? `Error: ${result.error}\n${result.stderr}`
|
||||||
|
: result.stdout || '(no commits)';
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: text }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const gitDiff: ToolDefinition = {
|
||||||
|
name: 'git_diff',
|
||||||
|
label: 'Git Diff',
|
||||||
|
description: 'Show changes between commits, working tree, or staged changes.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
staged: Type.Optional(
|
||||||
|
Type.Boolean({ description: 'Show staged (cached) changes instead of unstaged' }),
|
||||||
|
),
|
||||||
|
ref: Type.Optional(
|
||||||
|
Type.String({ description: 'Compare against this ref (commit SHA, branch, or tag)' }),
|
||||||
|
),
|
||||||
|
path: Type.Optional(
|
||||||
|
Type.String({ description: 'Limit diff to a specific file or directory' }),
|
||||||
|
),
|
||||||
|
cwd: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description: 'Repository working directory (relative to sandbox or absolute within it).',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const { staged, ref, path, cwd } = params as {
|
||||||
|
staged?: boolean;
|
||||||
|
ref?: string;
|
||||||
|
path?: string;
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||||
|
const args = ['diff'];
|
||||||
|
if (staged) args.push('--cached');
|
||||||
|
if (ref) args.push(ref);
|
||||||
|
args.push('--');
|
||||||
|
if (path) args.push(path);
|
||||||
|
const result = await runGit(args, safeCwd);
|
||||||
|
const text = result.error
|
||||||
|
? `Error: ${result.error}\n${result.stderr}`
|
||||||
|
: result.stdout || '(no diff)';
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: text }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [gitStatus, gitLog, gitDiff];
|
||||||
|
}
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
export { createBrainTools } from './brain-tools.js';
|
export { createBrainTools } from './brain-tools.js';
|
||||||
export { createCoordTools } from './coord-tools.js';
|
export { createCoordTools } from './coord-tools.js';
|
||||||
|
export { createFileTools } from './file-tools.js';
|
||||||
|
export { createGitTools } from './git-tools.js';
|
||||||
|
export { createShellTools } from './shell-tools.js';
|
||||||
|
export { createWebTools } from './web-tools.js';
|
||||||
|
export { createSkillTools } from './skill-tools.js';
|
||||||
|
|||||||
220
apps/gateway/src/agent/tools/shell-tools.ts
Normal file
220
apps/gateway/src/agent/tools/shell-tools.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||||
|
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commands that are outright blocked for safety.
|
||||||
|
* This is a denylist; the agent should be instructed to use
|
||||||
|
* the least-privilege command necessary.
|
||||||
|
*/
|
||||||
|
const BLOCKED_COMMANDS = new Set([
|
||||||
|
'rm',
|
||||||
|
'rmdir',
|
||||||
|
'mkfs',
|
||||||
|
'dd',
|
||||||
|
'format',
|
||||||
|
'fdisk',
|
||||||
|
'parted',
|
||||||
|
'shred',
|
||||||
|
'wipefs',
|
||||||
|
'sudo',
|
||||||
|
'su',
|
||||||
|
'chown',
|
||||||
|
'chmod',
|
||||||
|
'passwd',
|
||||||
|
'useradd',
|
||||||
|
'userdel',
|
||||||
|
'groupadd',
|
||||||
|
'shutdown',
|
||||||
|
'reboot',
|
||||||
|
'halt',
|
||||||
|
'poweroff',
|
||||||
|
'kill',
|
||||||
|
'killall',
|
||||||
|
'pkill',
|
||||||
|
'curl',
|
||||||
|
'wget',
|
||||||
|
'nc',
|
||||||
|
'netcat',
|
||||||
|
'ncat',
|
||||||
|
'ssh',
|
||||||
|
'scp',
|
||||||
|
'sftp',
|
||||||
|
'rsync',
|
||||||
|
'iptables',
|
||||||
|
'ip6tables',
|
||||||
|
'nft',
|
||||||
|
'ufw',
|
||||||
|
'firewall-cmd',
|
||||||
|
'docker',
|
||||||
|
'podman',
|
||||||
|
'kubectl',
|
||||||
|
'helm',
|
||||||
|
'terraform',
|
||||||
|
'ansible',
|
||||||
|
'crontab',
|
||||||
|
'at',
|
||||||
|
'batch',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function extractBaseCommand(command: string): string {
|
||||||
|
// Extract the first word (the binary name), stripping path
|
||||||
|
const trimmed = command.trim();
|
||||||
|
const firstToken = trimmed.split(/\s+/)[0] ?? '';
|
||||||
|
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 },
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn('sh', ['-c', command], {
|
||||||
|
cwd: options.cwd,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let timedOut = false;
|
||||||
|
let totalBytes = 0;
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
child.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
if (truncated) return;
|
||||||
|
totalBytes += chunk.length;
|
||||||
|
if (totalBytes > MAX_OUTPUT_BYTES) {
|
||||||
|
stdout += chunk.subarray(0, MAX_OUTPUT_BYTES - (totalBytes - chunk.length)).toString();
|
||||||
|
stdout += '\n[output truncated at 100 KB limit]';
|
||||||
|
truncated = true;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
} else {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
if (stderr.length < MAX_OUTPUT_BYTES) {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
} catch {
|
||||||
|
// already exited
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}, options.timeoutMs);
|
||||||
|
|
||||||
|
child.on('close', (exitCode) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ stdout, stderr, exitCode, timedOut });
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ stdout, stderr: stderr + String(err), exitCode: null, timedOut: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createShellTools(sandboxDir?: string): ToolDefinition[] {
|
||||||
|
const defaultCwd = sandboxDir ?? process.cwd();
|
||||||
|
|
||||||
|
const shellExec: ToolDefinition = {
|
||||||
|
name: 'shell_exec',
|
||||||
|
label: 'Shell Execute',
|
||||||
|
description:
|
||||||
|
'Execute a shell command with timeout and output limits. Dangerous commands (rm, sudo, docker, etc.) are blocked. Working directory is restricted to the session sandbox.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
command: Type.String({ description: 'Shell command to execute' }),
|
||||||
|
cwd: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description:
|
||||||
|
'Working directory for the command (relative to sandbox or absolute within it).',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
timeout: Type.Optional(
|
||||||
|
Type.Number({ description: 'Timeout in milliseconds (default 30000, max 60000)' }),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const { command, cwd, timeout } = params as {
|
||||||
|
command: string;
|
||||||
|
cwd?: string;
|
||||||
|
timeout?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const base = extractBaseCommand(command);
|
||||||
|
if (BLOCKED_COMMANDS.has(base)) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Error: command "${base}" is blocked for safety reasons.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 60_000);
|
||||||
|
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||||
|
|
||||||
|
const result = await runCommand(command, {
|
||||||
|
timeoutMs,
|
||||||
|
cwd: safeCwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.timedOut) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Command timed out after ${timeoutMs}ms.\nPartial stdout:\n${result.stdout}\nPartial stderr:\n${result.stderr}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (result.stdout) parts.push(`stdout:\n${result.stdout}`);
|
||||||
|
if (result.stderr) parts.push(`stderr:\n${result.stderr}`);
|
||||||
|
parts.push(`exit code: ${result.exitCode ?? 'null'}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: parts.join('\n') }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [shellExec];
|
||||||
|
}
|
||||||
180
apps/gateway/src/agent/tools/skill-tools.ts
Normal file
180
apps/gateway/src/agent/tools/skill-tools.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import type { SkillsService } from '../../skills/skills.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates meta-tools that allow agents to list and invoke skills from the catalog.
|
||||||
|
*
|
||||||
|
* skill_list — list all enabled skills
|
||||||
|
* skill_invoke — execute a skill by name with parameters
|
||||||
|
*/
|
||||||
|
export function createSkillTools(skillsService: SkillsService): ToolDefinition[] {
|
||||||
|
const skillList: ToolDefinition = {
|
||||||
|
name: 'skill_list',
|
||||||
|
label: 'List Skills',
|
||||||
|
description:
|
||||||
|
'List all enabled skills available in the catalog. Returns name, description, type, and config for each skill.',
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
async execute() {
|
||||||
|
const skills = await skillsService.findEnabled();
|
||||||
|
const summary = skills.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
version: s.version,
|
||||||
|
source: s.source,
|
||||||
|
config: s.config,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text:
|
||||||
|
summary.length > 0
|
||||||
|
? JSON.stringify(summary, null, 2)
|
||||||
|
: 'No enabled skills found in catalog.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const skillInvoke: ToolDefinition = {
|
||||||
|
name: 'skill_invoke',
|
||||||
|
label: 'Invoke Skill',
|
||||||
|
description:
|
||||||
|
'Invoke a skill from the catalog by name. For prompt skills, returns the prompt addition. ' +
|
||||||
|
'For tool skills, executes the embedded logic. For workflow skills, returns the workflow steps.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
name: Type.String({ description: 'Skill name to invoke' }),
|
||||||
|
params: Type.Optional(
|
||||||
|
Type.Record(Type.String(), Type.Unknown(), {
|
||||||
|
description: 'Parameters to pass to the skill (if applicable)',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, rawParams) {
|
||||||
|
const { name, params } = rawParams as {
|
||||||
|
name: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const skill = await skillsService.findByName(name);
|
||||||
|
if (!skill) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Skill not found: ${name}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skill.enabled) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Skill is disabled: ${name}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = (skill.config ?? {}) as Record<string, unknown>;
|
||||||
|
const skillType = (config['type'] as string | undefined) ?? 'prompt';
|
||||||
|
|
||||||
|
switch (skillType) {
|
||||||
|
case 'prompt': {
|
||||||
|
const promptAddition =
|
||||||
|
(config['prompt'] as string | undefined) ?? skill.description ?? '';
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: promptAddition
|
||||||
|
? `[Skill: ${name}] ${promptAddition}`
|
||||||
|
: `[Skill: ${name}] No prompt content defined.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool': {
|
||||||
|
const toolLogic = config['logic'] as string | undefined;
|
||||||
|
if (!toolLogic) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `[Skill: ${name}] Tool skill has no logic defined.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Inline tool skill execution: the logic field holds a JS expression or template
|
||||||
|
// For safety, treat it as a template that can reference params
|
||||||
|
const result = renderTemplate(toolLogic, { params: params ?? {}, skill });
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `[Skill: ${name}]\n${result}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'workflow': {
|
||||||
|
const steps = config['steps'] as unknown[] | undefined;
|
||||||
|
if (!steps || steps.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `[Skill: ${name}] Workflow has no steps defined.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `[Skill: ${name}] Workflow steps:\n${JSON.stringify(steps, null, 2)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Unknown type — return full config so the agent can decide what to do
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `[Skill: ${name}] (type: ${skillType})\n${JSON.stringify(config, null, 2)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [skillList, skillInvoke];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal template renderer — replaces {{key}} with values from the context.
|
||||||
|
* Used for tool skill logic templates.
|
||||||
|
*/
|
||||||
|
function renderTemplate(template: string, context: Record<string, unknown>): string {
|
||||||
|
return template.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (_match, path: string) => {
|
||||||
|
const parts = path.split('.');
|
||||||
|
let value: unknown = context;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (value != null && typeof value === 'object') {
|
||||||
|
value = (value as Record<string, unknown>)[part];
|
||||||
|
} else {
|
||||||
|
value = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value !== undefined && value !== null ? String(value) : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
225
apps/gateway/src/agent/tools/web-tools.ts
Normal file
225
apps/gateway/src/agent/tools/web-tools.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||||
|
const MAX_RESPONSE_BYTES = 512 * 1024; // 512 KB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocked URL patterns (private IP ranges, localhost, link-local).
|
||||||
|
*/
|
||||||
|
const BLOCKED_HOSTNAMES = [
|
||||||
|
/^localhost$/i,
|
||||||
|
/^127\./,
|
||||||
|
/^10\./,
|
||||||
|
/^172\.(1[6-9]|2\d|3[01])\./,
|
||||||
|
/^192\.168\./,
|
||||||
|
/^::1$/,
|
||||||
|
/^fc[0-9a-f][0-9a-f]:/i,
|
||||||
|
/^fe80:/i,
|
||||||
|
/^0\.0\.0\.0$/,
|
||||||
|
/^169\.254\./,
|
||||||
|
];
|
||||||
|
|
||||||
|
function isBlockedUrl(urlString: string): string | null {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(urlString);
|
||||||
|
} catch {
|
||||||
|
return `Invalid URL: ${urlString}`;
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return `Unsupported protocol: ${parsed.protocol}. Only http and https are allowed.`;
|
||||||
|
}
|
||||||
|
const hostname = parsed.hostname;
|
||||||
|
for (const pattern of BLOCKED_HOSTNAMES) {
|
||||||
|
if (pattern.test(hostname)) {
|
||||||
|
return `Blocked: requests to "${hostname}" are not allowed (private/local addresses).`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithLimit(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<{ text: string; status: number; contentType: string }> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { ...options, signal: controller.signal });
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
|
||||||
|
// Stream response and enforce size limit
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
return { text: '', status: response.status, contentType };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
totalBytes += value.length;
|
||||||
|
if (totalBytes > MAX_RESPONSE_BYTES) {
|
||||||
|
const remaining = MAX_RESPONSE_BYTES - (totalBytes - value.length);
|
||||||
|
chunks.push(value.subarray(0, remaining));
|
||||||
|
truncated = true;
|
||||||
|
reader.cancel();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0));
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
combined.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = new TextDecoder().decode(combined);
|
||||||
|
if (truncated) {
|
||||||
|
text += '\n[response truncated at 512 KB limit]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text, status: response.status, contentType };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWebTools(): ToolDefinition[] {
|
||||||
|
const webGet: ToolDefinition = {
|
||||||
|
name: 'web_get',
|
||||||
|
label: 'HTTP GET',
|
||||||
|
description:
|
||||||
|
'Perform an HTTP GET request and return the response body. Private/local addresses are blocked.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
url: Type.String({ description: 'URL to fetch (http/https only)' }),
|
||||||
|
headers: Type.Optional(
|
||||||
|
Type.Record(Type.String(), Type.String(), {
|
||||||
|
description: 'Optional request headers as key-value pairs',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
timeout: Type.Optional(
|
||||||
|
Type.Number({ description: 'Timeout in milliseconds (default 15000, max 30000)' }),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const { url, headers, timeout } = params as {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
timeout?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const blocked = isBlockedUrl(url);
|
||||||
|
if (blocked) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${blocked}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 30_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchWithLimit(
|
||||||
|
url,
|
||||||
|
{ method: 'GET', headers: headers ?? {} },
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `HTTP ${result.status} (${result.contentType})\n\n${result.text}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error fetching URL: ${msg}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const webPost: ToolDefinition = {
|
||||||
|
name: 'web_post',
|
||||||
|
label: 'HTTP POST',
|
||||||
|
description:
|
||||||
|
'Perform an HTTP POST request with a JSON or text body. Private/local addresses are blocked.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
url: Type.String({ description: 'URL to POST to (http/https only)' }),
|
||||||
|
body: Type.String({ description: 'Request body (JSON string or plain text)' }),
|
||||||
|
contentType: Type.Optional(
|
||||||
|
Type.String({ description: 'Content-Type header (default: application/json)' }),
|
||||||
|
),
|
||||||
|
headers: Type.Optional(
|
||||||
|
Type.Record(Type.String(), Type.String(), {
|
||||||
|
description: 'Optional additional request headers',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
timeout: Type.Optional(
|
||||||
|
Type.Number({ description: 'Timeout in milliseconds (default 15000, max 30000)' }),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const { url, body, contentType, headers, timeout } = params as {
|
||||||
|
url: string;
|
||||||
|
body: string;
|
||||||
|
contentType?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
timeout?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const blocked = isBlockedUrl(url);
|
||||||
|
if (blocked) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${blocked}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 30_000);
|
||||||
|
const ct = contentType ?? 'application/json';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchWithLimit(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': ct, ...(headers ?? {}) },
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `HTTP ${result.status} (${result.contentType})\n\n${result.text}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error posting to URL: ${msg}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [webGet, webPost];
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { HealthController } from './health/health.controller.js';
|
import { HealthController } from './health/health.controller.js';
|
||||||
import { DatabaseModule } from './database/database.module.js';
|
import { DatabaseModule } from './database/database.module.js';
|
||||||
import { AuthModule } from './auth/auth.module.js';
|
import { AuthModule } from './auth/auth.module.js';
|
||||||
@@ -13,9 +14,14 @@ import { CoordModule } from './coord/coord.module.js';
|
|||||||
import { MemoryModule } from './memory/memory.module.js';
|
import { MemoryModule } from './memory/memory.module.js';
|
||||||
import { LogModule } from './log/log.module.js';
|
import { LogModule } from './log/log.module.js';
|
||||||
import { SkillsModule } from './skills/skills.module.js';
|
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 { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 60 }]),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
BrainModule,
|
BrainModule,
|
||||||
@@ -29,7 +35,16 @@ import { SkillsModule } from './skills/skills.module.js';
|
|||||||
MemoryModule,
|
MemoryModule,
|
||||||
LogModule,
|
LogModule,
|
||||||
SkillsModule,
|
SkillsModule,
|
||||||
|
PluginModule,
|
||||||
|
McpModule,
|
||||||
|
AdminModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -7,16 +7,17 @@ import { AUTH } from './auth.tokens.js';
|
|||||||
export function mountAuthHandler(app: NestFastifyApplication): void {
|
export function mountAuthHandler(app: NestFastifyApplication): void {
|
||||||
const auth = app.get<Auth>(AUTH);
|
const auth = app.get<Auth>(AUTH);
|
||||||
const nodeHandler = toNodeHandler(auth);
|
const nodeHandler = toNodeHandler(auth);
|
||||||
|
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||||
|
|
||||||
const fastify = app.getHttpAdapter().getInstance();
|
const fastify = app.getHttpAdapter().getInstance();
|
||||||
|
|
||||||
// Use Fastify's addHook to intercept auth requests at the raw HTTP level,
|
// BetterAuth is mounted at the raw HTTP level via Fastify's onRequest hook,
|
||||||
// before Fastify's body parser runs. This avoids conflicts with NestJS's
|
// bypassing NestJS middleware (including CORS). We must set CORS headers
|
||||||
// custom content-type parser.
|
// manually on the raw response before handing off to BetterAuth.
|
||||||
fastify.addHook(
|
fastify.addHook(
|
||||||
'onRequest',
|
'onRequest',
|
||||||
(
|
(
|
||||||
req: { raw: IncomingMessage; url: string },
|
req: { raw: IncomingMessage; url: string; method: string },
|
||||||
reply: { raw: ServerResponse; hijack: () => void },
|
reply: { raw: ServerResponse; hijack: () => void },
|
||||||
done: () => void,
|
done: () => void,
|
||||||
) => {
|
) => {
|
||||||
@@ -25,6 +26,27 @@ export function mountAuthHandler(app: NestFastifyApplication): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const origin = req.raw.headers.origin;
|
||||||
|
const allowed = corsOrigin.split(',').map((o) => o.trim());
|
||||||
|
|
||||||
|
if (origin && allowed.includes(origin)) {
|
||||||
|
reply.raw.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
|
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
reply.raw.setHeader(
|
||||||
|
'Access-Control-Allow-Methods',
|
||||||
|
'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
||||||
|
);
|
||||||
|
reply.raw.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Cookie');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle preflight
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
reply.hijack();
|
||||||
|
reply.raw.writeHead(204);
|
||||||
|
reply.raw.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
reply.hijack();
|
reply.hijack();
|
||||||
nodeHandler(req.raw as IncomingMessage, reply.raw as ServerResponse)
|
nodeHandler(req.raw as IncomingMessage, reply.raw as ServerResponse)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
11
apps/gateway/src/auth/resource-ownership.ts
Normal file
11
apps/gateway/src/auth/resource-ownership.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
|
||||||
|
export function assertOwner(
|
||||||
|
ownerId: string | null | undefined,
|
||||||
|
userId: string,
|
||||||
|
resourceName: string,
|
||||||
|
): void {
|
||||||
|
if (!ownerId || ownerId !== userId) {
|
||||||
|
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
apps/gateway/src/chat/__tests__/chat-security.test.ts
Normal file
80
apps/gateway/src/chat/__tests__/chat-security.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { validateSync } from 'class-validator';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { SendMessageDto } from '../../conversations/conversations.dto.js';
|
||||||
|
import { ChatRequestDto } from '../chat.dto.js';
|
||||||
|
import { validateSocketSession } from '../chat.gateway-auth.js';
|
||||||
|
|
||||||
|
describe('Chat controller source hardening', () => {
|
||||||
|
it('applies AuthGuard and reads the current user', () => {
|
||||||
|
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||||
|
|
||||||
|
expect(source).toContain('@UseGuards(AuthGuard)');
|
||||||
|
expect(source).toContain('@CurrentUser() user: { id: string }');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WebSocket session authentication', () => {
|
||||||
|
it('returns null when the handshake does not resolve to a session', async () => {
|
||||||
|
const result = await validateSocketSession(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
api: {
|
||||||
|
getSession: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the resolved session when Better Auth accepts the headers', async () => {
|
||||||
|
const session = { user: { id: 'user-1' }, session: { id: 'session-1' } };
|
||||||
|
|
||||||
|
const result = await validateSocketSession(
|
||||||
|
{ cookie: 'session=abc' },
|
||||||
|
{
|
||||||
|
api: {
|
||||||
|
getSession: vi.fn().mockResolvedValue(session),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(session);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Chat DTO validation', () => {
|
||||||
|
it('rejects unsupported message roles', () => {
|
||||||
|
const dto = Object.assign(new SendMessageDto(), {
|
||||||
|
content: 'hello',
|
||||||
|
role: 'moderator',
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects oversized conversation message content above 10000 characters', () => {
|
||||||
|
const dto = Object.assign(new SendMessageDto(), {
|
||||||
|
content: 'x'.repeat(10_001),
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects oversized chat content above 10000 characters', () => {
|
||||||
|
const dto = Object.assign(new ChatRequestDto(), {
|
||||||
|
content: 'x'.repeat(10_001),
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
import { Controller, Post, Body, Logger, HttpException, HttpStatus, Inject } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Logger,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { ChatRequestDto } from './chat.dto.js';
|
||||||
interface ChatRequest {
|
|
||||||
conversationId?: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatResponse {
|
interface ChatResponse {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
@@ -14,13 +22,18 @@ interface ChatResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Controller('api/chat')
|
@Controller('api/chat')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
export class ChatController {
|
export class ChatController {
|
||||||
private readonly logger = new Logger(ChatController.name);
|
private readonly logger = new Logger(ChatController.name);
|
||||||
|
|
||||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async chat(@Body() body: ChatRequest): Promise<ChatResponse> {
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
|
async chat(
|
||||||
|
@Body() body: ChatRequestDto,
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
): Promise<ChatResponse> {
|
||||||
const conversationId = body.conversationId ?? uuid();
|
const conversationId = body.conversationId ?? uuid();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -36,6 +49,8 @@ export class ChatController {
|
|||||||
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
|
||||||
|
|
||||||
let responseText = '';
|
let responseText = '';
|
||||||
|
|
||||||
const done = new Promise<void>((resolve, reject) => {
|
const done = new Promise<void>((resolve, reject) => {
|
||||||
|
|||||||
31
apps/gateway/src/chat/chat.dto.ts
Normal file
31
apps/gateway/src/chat/chat.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class ChatRequestDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
conversationId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10_000)
|
||||||
|
content!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatSocketMessageDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
conversationId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10_000)
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
provider?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
modelId?: string;
|
||||||
|
}
|
||||||
30
apps/gateway/src/chat/chat.gateway-auth.ts
Normal file
30
apps/gateway/src/chat/chat.gateway-auth.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { IncomingHttpHeaders } from 'node:http';
|
||||||
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
|
|
||||||
|
export interface SocketSessionResult {
|
||||||
|
session: unknown;
|
||||||
|
user: { id: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionAuth {
|
||||||
|
api: {
|
||||||
|
getSession(context: { headers: Headers }): Promise<SocketSessionResult | null>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateSocketSession(
|
||||||
|
headers: IncomingHttpHeaders,
|
||||||
|
auth: SessionAuth,
|
||||||
|
): Promise<SocketSessionResult | null> {
|
||||||
|
const sessionHeaders = fromNodeHeaders(headers);
|
||||||
|
const result = await auth.api.getSession({ headers: sessionHeaders });
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: result.session,
|
||||||
|
user: { id: result.user.id },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,18 +11,17 @@ import {
|
|||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
|
import type { Auth } from '@mosaic/auth';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||||
interface ChatMessage {
|
import { validateSocketSession } from './chat.gateway-auth.js';
|
||||||
conversationId?: string;
|
|
||||||
content: string;
|
|
||||||
provider?: string;
|
|
||||||
modelId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: { origin: '*' },
|
cors: {
|
||||||
|
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
||||||
|
},
|
||||||
namespace: '/chat',
|
namespace: '/chat',
|
||||||
})
|
})
|
||||||
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||||
@@ -35,13 +34,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
{ conversationId: string; cleanup: () => void }
|
{ conversationId: string; cleanup: () => void }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
constructor(
|
||||||
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
|
@Inject(AUTH) private readonly auth: Auth,
|
||||||
|
) {}
|
||||||
|
|
||||||
afterInit(): void {
|
afterInit(): void {
|
||||||
this.logger.log('Chat WebSocket gateway initialized');
|
this.logger.log('Chat WebSocket gateway initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConnection(client: Socket): void {
|
async handleConnection(client: Socket): Promise<void> {
|
||||||
|
const session = await validateSocketSession(client.handshake.headers, this.auth);
|
||||||
|
if (!session) {
|
||||||
|
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.data.user = session.user;
|
||||||
|
client.data.session = session.session;
|
||||||
this.logger.log(`Client connected: ${client.id}`);
|
this.logger.log(`Client connected: ${client.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +69,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@SubscribeMessage('message')
|
@SubscribeMessage('message')
|
||||||
async handleMessage(
|
async handleMessage(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() data: ChatMessage,
|
@MessageBody() data: ChatSocketMessageDto,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const conversationId = data.conversationId ?? uuid();
|
const conversationId = data.conversationId ?? uuid();
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import type { Brain } from '@mosaic/brain';
|
|||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import type {
|
import { assertOwner } from '../auth/resource-ownership.js';
|
||||||
|
import {
|
||||||
CreateConversationDto,
|
CreateConversationDto,
|
||||||
UpdateConversationDto,
|
UpdateConversationDto,
|
||||||
SendMessageDto,
|
SendMessageDto,
|
||||||
@@ -33,10 +34,8 @@ export class ConversationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(@Param('id') id: string) {
|
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||||
const conversation = await this.brain.conversations.findById(id);
|
return this.getOwnedConversation(id, user.id);
|
||||||
if (!conversation) throw new NotFoundException('Conversation not found');
|
|
||||||
return conversation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -49,7 +48,12 @@ export class ConversationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
async update(@Param('id') id: string, @Body() dto: UpdateConversationDto) {
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateConversationDto,
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
) {
|
||||||
|
await this.getOwnedConversation(id, user.id);
|
||||||
const conversation = await this.brain.conversations.update(id, dto);
|
const conversation = await this.brain.conversations.update(id, dto);
|
||||||
if (!conversation) throw new NotFoundException('Conversation not found');
|
if (!conversation) throw new NotFoundException('Conversation not found');
|
||||||
return conversation;
|
return conversation;
|
||||||
@@ -57,22 +61,25 @@ export class ConversationsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async remove(@Param('id') id: string) {
|
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||||
|
await this.getOwnedConversation(id, user.id);
|
||||||
const deleted = await this.brain.conversations.remove(id);
|
const deleted = await this.brain.conversations.remove(id);
|
||||||
if (!deleted) throw new NotFoundException('Conversation not found');
|
if (!deleted) throw new NotFoundException('Conversation not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/messages')
|
@Get(':id/messages')
|
||||||
async listMessages(@Param('id') id: string) {
|
async listMessages(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||||
const conversation = await this.brain.conversations.findById(id);
|
await this.getOwnedConversation(id, user.id);
|
||||||
if (!conversation) throw new NotFoundException('Conversation not found');
|
|
||||||
return this.brain.conversations.findMessages(id);
|
return this.brain.conversations.findMessages(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/messages')
|
@Post(':id/messages')
|
||||||
async addMessage(@Param('id') id: string, @Body() dto: SendMessageDto) {
|
async addMessage(
|
||||||
const conversation = await this.brain.conversations.findById(id);
|
@Param('id') id: string,
|
||||||
if (!conversation) throw new NotFoundException('Conversation not found');
|
@Body() dto: SendMessageDto,
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
) {
|
||||||
|
await this.getOwnedConversation(id, user.id);
|
||||||
return this.brain.conversations.addMessage({
|
return this.brain.conversations.addMessage({
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
role: dto.role,
|
role: dto.role,
|
||||||
@@ -80,4 +87,11 @@ export class ConversationsController {
|
|||||||
metadata: dto.metadata,
|
metadata: dto.metadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getOwnedConversation(id: string, userId: string) {
|
||||||
|
const conversation = await this.brain.conversations.findById(id);
|
||||||
|
if (!conversation) throw new NotFoundException('Conversation not found');
|
||||||
|
assertOwner(conversation.userId, userId, 'Conversation');
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,48 @@
|
|||||||
export interface CreateConversationDto {
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateConversationDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateConversationDto {
|
export class UpdateConversationDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
archived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageDto {
|
export class SendMessageDto {
|
||||||
role: 'user' | 'assistant' | 'system';
|
@IsIn(['user', 'assistant', 'system'])
|
||||||
content: string;
|
role!: 'user' | 'assistant' | 'system';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10_000)
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Inject,
|
Inject,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { CoordService } from './coord.service.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). */
|
/** Walk up from cwd to find the monorepo root (has pnpm-workspace.yaml). */
|
||||||
function findMonorepoRoot(start: string): string {
|
function findMonorepoRoot(start: string): string {
|
||||||
@@ -49,6 +62,8 @@ function resolveAndValidatePath(raw: string | undefined): string {
|
|||||||
export class CoordController {
|
export class CoordController {
|
||||||
constructor(@Inject(CoordService) private readonly coordService: CoordService) {}
|
constructor(@Inject(CoordService) private readonly coordService: CoordService) {}
|
||||||
|
|
||||||
|
// ── File-based coord endpoints (legacy) ──
|
||||||
|
|
||||||
@Get('status')
|
@Get('status')
|
||||||
async missionStatus(@Query('projectPath') projectPath?: string) {
|
async missionStatus(@Query('projectPath') projectPath?: string) {
|
||||||
const resolvedPath = resolveAndValidatePath(projectPath);
|
const resolvedPath = resolveAndValidatePath(projectPath);
|
||||||
@@ -70,4 +85,121 @@ export class CoordController {
|
|||||||
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
|
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
|
||||||
return detail;
|
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,3 +1,5 @@
|
|||||||
|
// ── File-based coord DTOs (legacy file-system backed) ──
|
||||||
|
|
||||||
export interface CoordMissionStatusDto {
|
export interface CoordMissionStatusDto {
|
||||||
mission: {
|
mission: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -47,3 +49,42 @@ export interface CoordTaskDetailDto {
|
|||||||
startedAt: string;
|
startedAt: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── DB-backed coord DTOs ──
|
||||||
|
|
||||||
|
export interface CreateDbMissionDto {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
projectId?: string;
|
||||||
|
phase?: string;
|
||||||
|
milestones?: Record<string, unknown>[];
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDbMissionDto {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
projectId?: string;
|
||||||
|
phase?: string;
|
||||||
|
milestones?: Record<string, unknown>[];
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMissionTaskDto {
|
||||||
|
missionId: string;
|
||||||
|
taskId?: string;
|
||||||
|
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
pr?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMissionTaskDto {
|
||||||
|
taskId?: string;
|
||||||
|
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
pr?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
|
import type { Brain } from '@mosaic/brain';
|
||||||
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import {
|
import {
|
||||||
loadMission,
|
loadMission,
|
||||||
getMissionStatus,
|
getMissionStatus,
|
||||||
@@ -16,6 +18,8 @@ import path from 'node:path';
|
|||||||
export class CoordService {
|
export class CoordService {
|
||||||
private readonly logger = new Logger(CoordService.name);
|
private readonly logger = new Logger(CoordService.name);
|
||||||
|
|
||||||
|
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||||
|
|
||||||
async loadMission(projectPath: string): Promise<Mission | null> {
|
async loadMission(projectPath: string): Promise<Mission | null> {
|
||||||
try {
|
try {
|
||||||
return await loadMission(projectPath);
|
return await loadMission(projectPath);
|
||||||
@@ -70,4 +74,68 @@ export class CoordService {
|
|||||||
return [];
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Injectable, Logger, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common';
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
type OnModuleInit,
|
||||||
|
type OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { SummarizationService } from './summarization.service.js';
|
import { SummarizationService } from './summarization.service.js';
|
||||||
|
|
||||||
@@ -7,7 +13,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly logger = new Logger(CronService.name);
|
private readonly logger = new Logger(CronService.name);
|
||||||
private readonly tasks: cron.ScheduledTask[] = [];
|
private readonly tasks: cron.ScheduledTask[] = [];
|
||||||
|
|
||||||
constructor(private readonly summarization: SummarizationService) {}
|
constructor(@Inject(SummarizationService) private readonly summarization: SummarizationService) {}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class SummarizationService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||||
@Inject(MEMORY) private readonly memory: Memory,
|
@Inject(MEMORY) private readonly memory: Memory,
|
||||||
private readonly embeddings: EmbeddingService,
|
@Inject(EmbeddingService) private readonly embeddings: EmbeddingService,
|
||||||
@Inject(DB) private readonly db: Db,
|
@Inject(DB) private readonly db: Db,
|
||||||
) {
|
) {
|
||||||
this.apiKey = process.env['OPENAI_API_KEY'];
|
this.apiKey = process.env['OPENAI_API_KEY'];
|
||||||
|
|||||||
@@ -1,19 +1,61 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
||||||
|
config({ path: resolve(process.cwd(), '../../.env') });
|
||||||
|
config(); // Also load apps/gateway/.env if present (overrides)
|
||||||
|
|
||||||
import './tracing.js';
|
import './tracing.js';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import helmet from '@fastify/helmet';
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
import { mountAuthHandler } from './auth/auth.controller.js';
|
import { mountAuthHandler } from './auth/auth.controller.js';
|
||||||
|
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||||
|
import { McpService } from './mcp/mcp.service.js';
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
const logger = new Logger('Bootstrap');
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
|
|
||||||
|
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||||
|
throw new Error('BETTER_AUTH_SECRET is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env['AUTHENTIK_CLIENT_ID'] &&
|
||||||
|
(!process.env['AUTHENTIK_CLIENT_SECRET'] || !process.env['AUTHENTIK_ISSUER'])
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
'[warn] AUTHENTIK_CLIENT_ID is set but AUTHENTIK_CLIENT_SECRET or AUTHENTIK_ISSUER is missing — Authentik SSO will not work',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
AppModule,
|
||||||
|
new FastifyAdapter({ bodyLimit: 1_048_576 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(helmet as never, { contentSecurityPolicy: false });
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
mountAuthHandler(app);
|
mountAuthHandler(app);
|
||||||
|
mountMcpHandler(app, app.get(McpService));
|
||||||
|
|
||||||
const port = process.env['GATEWAY_PORT'] ?? 4000;
|
const port = Number(process.env['GATEWAY_PORT'] ?? 4000);
|
||||||
await app.listen(port as number, '0.0.0.0');
|
await app.listen(port, '0.0.0.0');
|
||||||
logger.log(`Gateway listening on port ${port}`);
|
logger.log(`Gateway listening on port ${port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
apps/gateway/src/mcp-client/mcp-client.dto.ts
Normal file
33
apps/gateway/src/mcp-client/mcp-client.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* DTOs for MCP client configuration and tool discovery.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface McpServerConfigDto {
|
||||||
|
/** Unique name identifying this MCP server */
|
||||||
|
name: string;
|
||||||
|
/** URL of the MCP server (streamable HTTP or SSE endpoint) */
|
||||||
|
url: string;
|
||||||
|
/** Optional HTTP headers to send with requests (e.g., Authorization) */
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpToolDto {
|
||||||
|
/** Namespaced tool name: "<serverName>__<toolName>" */
|
||||||
|
name: string;
|
||||||
|
/** Human-readable description of the tool */
|
||||||
|
description: string;
|
||||||
|
/** JSON Schema for tool input parameters */
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
/** MCP server this tool belongs to */
|
||||||
|
serverName: string;
|
||||||
|
/** Original tool name on the remote server */
|
||||||
|
remoteName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServerStatusDto {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
connected: boolean;
|
||||||
|
toolCount: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
8
apps/gateway/src/mcp-client/mcp-client.module.ts
Normal file
8
apps/gateway/src/mcp-client/mcp-client.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { McpClientService } from './mcp-client.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [McpClientService],
|
||||||
|
exports: [McpClientService],
|
||||||
|
})
|
||||||
|
export class McpClientModule {}
|
||||||
331
apps/gateway/src/mcp-client/mcp-client.service.ts
Normal file
331
apps/gateway/src/mcp-client/mcp-client.service.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
import type { McpServerConfigDto, McpToolDto, McpServerStatusDto } from './mcp-client.dto.js';
|
||||||
|
|
||||||
|
interface ConnectedServer {
|
||||||
|
config: McpServerConfigDto;
|
||||||
|
client: Client;
|
||||||
|
tools: McpToolDto[];
|
||||||
|
connected: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* McpClientService connects to external MCP servers, discovers their tools,
|
||||||
|
* and bridges them into Pi SDK ToolDefinition format for agent sessions.
|
||||||
|
*
|
||||||
|
* Configuration is read from the MCP_SERVERS environment variable:
|
||||||
|
* MCP_SERVERS='[{"name":"my-server","url":"http://localhost:3001/mcp","headers":{"Authorization":"Bearer token"}}]'
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class McpClientService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(McpClientService.name);
|
||||||
|
private readonly servers = new Map<string, ConnectedServer>();
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
const configs = this.loadConfigs();
|
||||||
|
if (configs.length === 0) {
|
||||||
|
this.logger.log('No external MCP servers configured (MCP_SERVERS not set)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Connecting to ${configs.length} external MCP server(s)`);
|
||||||
|
await Promise.allSettled(configs.map((cfg) => this.connectServer(cfg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
this.logger.log(`Disconnecting from ${this.servers.size} MCP server(s)`);
|
||||||
|
const disconnects = Array.from(this.servers.values()).map((s) => this.disconnectServer(s));
|
||||||
|
await Promise.allSettled(disconnects);
|
||||||
|
this.servers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all bridged Pi SDK ToolDefinitions from all connected MCP servers.
|
||||||
|
*/
|
||||||
|
getToolDefinitions(): ToolDefinition[] {
|
||||||
|
const tools: ToolDefinition[] = [];
|
||||||
|
for (const server of this.servers.values()) {
|
||||||
|
if (!server.connected) continue;
|
||||||
|
for (const mcpTool of server.tools) {
|
||||||
|
tools.push(this.bridgeTool(server.client, mcpTool));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns status information for all configured MCP servers.
|
||||||
|
*/
|
||||||
|
getServerStatuses(): McpServerStatusDto[] {
|
||||||
|
return Array.from(this.servers.values()).map((s) => ({
|
||||||
|
name: s.config.name,
|
||||||
|
url: s.config.url,
|
||||||
|
connected: s.connected,
|
||||||
|
toolCount: s.tools.length,
|
||||||
|
error: s.error,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to reconnect a server that has been disconnected.
|
||||||
|
*/
|
||||||
|
async reconnectServer(serverName: string): Promise<void> {
|
||||||
|
const existing = this.servers.get(serverName);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`MCP server not found: ${serverName}`);
|
||||||
|
}
|
||||||
|
if (existing.connected) return;
|
||||||
|
|
||||||
|
this.logger.log(`Reconnecting to MCP server: ${serverName}`);
|
||||||
|
await this.connectServer(existing.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private loadConfigs(): McpServerConfigDto[] {
|
||||||
|
const raw = process.env['MCP_SERVERS'];
|
||||||
|
if (!raw) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
this.logger.warn('MCP_SERVERS must be a JSON array — ignoring');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const configs: McpServerConfigDto[] = [];
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (
|
||||||
|
typeof item === 'object' &&
|
||||||
|
item !== null &&
|
||||||
|
'name' in item &&
|
||||||
|
typeof (item as Record<string, unknown>)['name'] === 'string' &&
|
||||||
|
'url' in item &&
|
||||||
|
typeof (item as Record<string, unknown>)['url'] === 'string'
|
||||||
|
) {
|
||||||
|
const cfg = item as McpServerConfigDto;
|
||||||
|
configs.push({
|
||||||
|
name: cfg.name,
|
||||||
|
url: cfg.url,
|
||||||
|
headers: cfg.headers,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Skipping invalid MCP server config entry: ${JSON.stringify(item)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to parse MCP_SERVERS: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connectServer(config: McpServerConfigDto): Promise<void> {
|
||||||
|
const serverEntry: ConnectedServer = {
|
||||||
|
config,
|
||||||
|
client: new Client({ name: 'mosaic-gateway', version: '1.0.0' }),
|
||||||
|
tools: [],
|
||||||
|
connected: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preserve existing entry if reconnecting
|
||||||
|
this.servers.set(config.name, serverEntry);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(config.url);
|
||||||
|
const headers = config.headers ?? {};
|
||||||
|
|
||||||
|
// Attempt StreamableHTTP first, fall back to SSE
|
||||||
|
let connected = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers } });
|
||||||
|
await serverEntry.client.connect(transport);
|
||||||
|
connected = true;
|
||||||
|
this.logger.log(`Connected to MCP server "${config.name}" via StreamableHTTP`);
|
||||||
|
} catch (streamErr) {
|
||||||
|
this.logger.warn(
|
||||||
|
`StreamableHTTP failed for "${config.name}", trying SSE: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset client for SSE attempt
|
||||||
|
serverEntry.client = new Client({ name: 'mosaic-gateway', version: '1.0.0' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transport = new SSEClientTransport(url, { requestInit: { headers } });
|
||||||
|
await serverEntry.client.connect(transport);
|
||||||
|
connected = true;
|
||||||
|
this.logger.log(`Connected to MCP server "${config.name}" via SSE`);
|
||||||
|
} catch (sseErr) {
|
||||||
|
throw new Error(
|
||||||
|
`Both transports failed for "${config.name}": SSE error: ${sseErr instanceof Error ? sseErr.message : String(sseErr)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connected) return;
|
||||||
|
|
||||||
|
// Discover tools
|
||||||
|
const toolsResult = await serverEntry.client.listTools();
|
||||||
|
serverEntry.tools = toolsResult.tools.map((t) => ({
|
||||||
|
name: `${config.name}__${t.name}`,
|
||||||
|
description: t.description ?? `Tool ${t.name} from MCP server ${config.name}`,
|
||||||
|
inputSchema: (t.inputSchema as Record<string, unknown>) ?? {},
|
||||||
|
serverName: config.name,
|
||||||
|
remoteName: t.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
serverEntry.connected = true;
|
||||||
|
this.logger.log(
|
||||||
|
`Discovered ${serverEntry.tools.length} tool(s) from MCP server "${config.name}"`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
serverEntry.error = message;
|
||||||
|
serverEntry.connected = false;
|
||||||
|
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async disconnectServer(server: ConnectedServer): Promise<void> {
|
||||||
|
try {
|
||||||
|
await server.client.close();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Error closing MCP client for "${server.config.name}": ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges a single McpToolDto into a Pi SDK ToolDefinition.
|
||||||
|
* The MCP inputSchema is converted to a TypeBox schema representation.
|
||||||
|
*/
|
||||||
|
private bridgeTool(client: Client, mcpTool: McpToolDto): ToolDefinition {
|
||||||
|
const schema = this.inputSchemaToTypeBox(mcpTool.inputSchema);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: mcpTool.name,
|
||||||
|
label: mcpTool.remoteName,
|
||||||
|
description: mcpTool.description,
|
||||||
|
parameters: schema,
|
||||||
|
execute: async (_toolCallId: string, params: unknown) => {
|
||||||
|
try {
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: mcpTool.remoteName,
|
||||||
|
arguments: (params as Record<string, unknown>) ?? {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// MCP callTool returns { content: [...], isError?: boolean }
|
||||||
|
const content = Array.isArray(result.content) ? result.content : [];
|
||||||
|
const textParts = content
|
||||||
|
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
if (result.isError) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `MCP tool error from "${mcpTool.serverName}/${mcpTool.remoteName}": ${textParts || 'Unknown error'}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content:
|
||||||
|
content.length > 0
|
||||||
|
? (content as { type: 'text'; text: string }[])
|
||||||
|
: [{ type: 'text' as const, text: '' }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(
|
||||||
|
`MCP tool call failed: ${mcpTool.serverName}/${mcpTool.remoteName}: ${message}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Failed to call MCP tool "${mcpTool.name}": ${message}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a JSON Schema object to a TypeBox-compatible schema.
|
||||||
|
* For simplicity, maps the inputSchema properties to TypeBox Type.Object.
|
||||||
|
* Unknown/complex schemas fall back to Type.Object with Type.Unknown values.
|
||||||
|
*/
|
||||||
|
private inputSchemaToTypeBox(
|
||||||
|
inputSchema: Record<string, unknown>,
|
||||||
|
): ReturnType<typeof Type.Object> {
|
||||||
|
const properties = inputSchema['properties'];
|
||||||
|
|
||||||
|
if (!properties || typeof properties !== 'object') {
|
||||||
|
return Type.Object({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const required: string[] = Array.isArray(inputSchema['required'])
|
||||||
|
? (inputSchema['required'] as string[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const tbProps: Record<string, ReturnType<typeof Type.String>> = {};
|
||||||
|
|
||||||
|
for (const [key, schemaDef] of Object.entries(properties as Record<string, unknown>)) {
|
||||||
|
const def = schemaDef as Record<string, unknown>;
|
||||||
|
const desc = typeof def['description'] === 'string' ? def['description'] : undefined;
|
||||||
|
const isOptional = !required.includes(key);
|
||||||
|
const base = this.jsonSchemaToTypeBox(def);
|
||||||
|
tbProps[key] = isOptional
|
||||||
|
? (Type.Optional(base) as unknown as ReturnType<typeof Type.String>)
|
||||||
|
: (base as unknown as ReturnType<typeof Type.String>);
|
||||||
|
if (desc && tbProps[key]) {
|
||||||
|
// Attach description via metadata
|
||||||
|
(tbProps[key] as Record<string, unknown>)['description'] = desc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Type.Object(tbProps as Parameters<typeof Type.Object>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private jsonSchemaToTypeBox(
|
||||||
|
def: Record<string, unknown>,
|
||||||
|
):
|
||||||
|
| ReturnType<typeof Type.String>
|
||||||
|
| ReturnType<typeof Type.Number>
|
||||||
|
| ReturnType<typeof Type.Boolean>
|
||||||
|
| ReturnType<typeof Type.Unknown> {
|
||||||
|
const type = def['type'];
|
||||||
|
const desc = typeof def['description'] === 'string' ? { description: def['description'] } : {};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'string':
|
||||||
|
return Type.String(desc);
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
return Type.Number(desc);
|
||||||
|
case 'boolean':
|
||||||
|
return Type.Boolean(desc);
|
||||||
|
default:
|
||||||
|
return Type.Unknown(desc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/gateway/src/mcp-client/mcp-client.tokens.ts
Normal file
1
apps/gateway/src/mcp-client/mcp-client.tokens.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const MCP_CLIENT_SERVICE = 'MCP_CLIENT_SERVICE';
|
||||||
142
apps/gateway/src/mcp/mcp.controller.ts
Normal file
142
apps/gateway/src/mcp/mcp.controller.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
|
import type { Auth } from '@mosaic/auth';
|
||||||
|
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import type { McpService } from './mcp.service.js';
|
||||||
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts the MCP streamable HTTP transport endpoint at /mcp on the Fastify instance.
|
||||||
|
*
|
||||||
|
* This follows the same low-level Fastify hook pattern used by the auth controller,
|
||||||
|
* bypassing NestJS routing to directly delegate to the MCP SDK transport handlers.
|
||||||
|
*
|
||||||
|
* Endpoint: POST /mcp (and GET /mcp for SSE stream reconnect)
|
||||||
|
* Auth: Requires a valid BetterAuth session (cookie or Authorization header).
|
||||||
|
* Session: Stateful — each initialized client gets a session ID via Mcp-Session-Id header.
|
||||||
|
*/
|
||||||
|
export function mountMcpHandler(app: NestFastifyApplication, mcpService: McpService): void {
|
||||||
|
const auth = app.get<Auth>(AUTH);
|
||||||
|
const logger = new Logger('McpController');
|
||||||
|
const fastify = app.getHttpAdapter().getInstance();
|
||||||
|
|
||||||
|
fastify.addHook(
|
||||||
|
'onRequest',
|
||||||
|
(
|
||||||
|
req: { raw: IncomingMessage; url: string; method: string },
|
||||||
|
reply: { raw: ServerResponse; hijack: () => void },
|
||||||
|
done: () => void,
|
||||||
|
) => {
|
||||||
|
if (!req.url.startsWith('/mcp')) {
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.hijack();
|
||||||
|
|
||||||
|
handleMcpRequest(req, reply, auth, mcpService, logger).catch((err: unknown) => {
|
||||||
|
logger.error(
|
||||||
|
`MCP request handler error: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
if (!reply.raw.headersSent) {
|
||||||
|
reply.raw.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
}
|
||||||
|
if (!reply.raw.writableEnded) {
|
||||||
|
reply.raw.end(JSON.stringify({ error: 'Internal server error' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMcpRequest(
|
||||||
|
req: { raw: IncomingMessage; url: string; method: string },
|
||||||
|
reply: { raw: ServerResponse; hijack: () => void },
|
||||||
|
auth: Auth,
|
||||||
|
mcpService: McpService,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
// ─── Authentication ─────────────────────────────────────────────────────
|
||||||
|
const headers = fromNodeHeaders(req.raw.headers);
|
||||||
|
const result = await auth.api.getSession({ headers });
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
reply.raw.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
|
reply.raw.end(JSON.stringify({ error: 'Unauthorized: valid session required' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = result.user.id;
|
||||||
|
|
||||||
|
// ─── Session routing ─────────────────────────────────────────────────────
|
||||||
|
const sessionId = req.raw.headers['mcp-session-id'];
|
||||||
|
|
||||||
|
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||||
|
// Existing session request
|
||||||
|
const transport = mcpService.getSession(sessionId);
|
||||||
|
if (!transport) {
|
||||||
|
logger.warn(`MCP session not found: ${sessionId}`);
|
||||||
|
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
reply.raw.end(JSON.stringify({ error: 'Session not found' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await transport.handleRequest(req.raw, reply.raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Initialize new session ───────────────────────────────────────────────
|
||||||
|
// Only POST requests can initialize a new session (must be initialize message)
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
reply.raw.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
reply.raw.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'New session must be established via POST with initialize message',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse body to verify this is an initialize request before creating a session
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await readRequestBody(req.raw);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to parse MCP request body: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
reply.raw.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
reply.raw.end(JSON.stringify({ error: 'Invalid request body' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new session and handle this initializing request
|
||||||
|
const { transport } = mcpService.createSession(userId);
|
||||||
|
logger.log(`New MCP session created for user ${userId}`);
|
||||||
|
|
||||||
|
await transport.handleRequest(req.raw, reply.raw, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and parses the JSON body from a Node.js IncomingMessage.
|
||||||
|
*/
|
||||||
|
function readRequestBody(req: IncomingMessage): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
req.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
if (!raw) {
|
||||||
|
resolve(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(raw));
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
19
apps/gateway/src/mcp/mcp.dto.ts
Normal file
19
apps/gateway/src/mcp/mcp.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* MCP (Model Context Protocol) DTOs
|
||||||
|
*
|
||||||
|
* Defines the data transfer objects for the MCP streamable HTTP transport.
|
||||||
|
* See: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface McpToolDescriptor {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServerInfo {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
protocolVersion: string;
|
||||||
|
tools: McpToolDescriptor[];
|
||||||
|
}
|
||||||
10
apps/gateway/src/mcp/mcp.module.ts
Normal file
10
apps/gateway/src/mcp/mcp.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { McpService } from './mcp.service.js';
|
||||||
|
import { CoordModule } from '../coord/coord.module.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [CoordModule],
|
||||||
|
providers: [McpService],
|
||||||
|
exports: [McpService],
|
||||||
|
})
|
||||||
|
export class McpModule {}
|
||||||
429
apps/gateway/src/mcp/mcp.service.ts
Normal file
429
apps/gateway/src/mcp/mcp.service.ts
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import { Injectable, Logger, Inject, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Brain } from '@mosaic/brain';
|
||||||
|
import type { Memory } from '@mosaic/memory';
|
||||||
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
|
import { MEMORY } from '../memory/memory.tokens.js';
|
||||||
|
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||||
|
import { CoordService } from '../coord/coord.service.js';
|
||||||
|
|
||||||
|
interface SessionEntry {
|
||||||
|
server: McpServer;
|
||||||
|
transport: StreamableHTTPServerTransport;
|
||||||
|
createdAt: Date;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class McpService implements OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(McpService.name);
|
||||||
|
private readonly sessions = new Map<string, SessionEntry>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
|
@Inject(MEMORY) private readonly memory: Memory,
|
||||||
|
@Inject(EmbeddingService) private readonly embeddings: EmbeddingService,
|
||||||
|
@Inject(CoordService) private readonly coordService: CoordService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new MCP session with its own server + transport pair.
|
||||||
|
* Returns the transport for use by the controller.
|
||||||
|
*/
|
||||||
|
createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } {
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: () => sessionId,
|
||||||
|
onsessioninitialized: (id) => {
|
||||||
|
this.logger.log(`MCP session initialized: ${id} for user ${userId}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = new McpServer(
|
||||||
|
{ name: 'mosaic-gateway', version: '1.0.0' },
|
||||||
|
{ capabilities: { tools: {} } },
|
||||||
|
);
|
||||||
|
|
||||||
|
this.registerTools(server, userId);
|
||||||
|
|
||||||
|
transport.onclose = () => {
|
||||||
|
this.logger.log(`MCP session closed: ${sessionId}`);
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
server.connect(transport).catch((err: unknown) => {
|
||||||
|
this.logger.error(
|
||||||
|
`MCP server connect error for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId });
|
||||||
|
return { sessionId, transport };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the transport for an existing session, or null if not found.
|
||||||
|
*/
|
||||||
|
getSession(sessionId: string): StreamableHTTPServerTransport | null {
|
||||||
|
return this.sessions.get(sessionId)?.transport ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers all platform tools on the given McpServer instance.
|
||||||
|
*/
|
||||||
|
private registerTools(server: McpServer, _userId: string): void {
|
||||||
|
// ─── Brain: Project tools ────────────────────────────────────────────
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'brain_list_projects',
|
||||||
|
{
|
||||||
|
description: 'List all projects in the brain.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const projects = await this.brain.projects.findAll();
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'brain_get_project',
|
||||||
|
{
|
||||||
|
description: 'Get a project by ID.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
id: z.string().describe('Project ID (UUID)'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
const project = await this.brain.projects.findById(id);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Brain: Task tools ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'brain_list_tasks',
|
||||||
|
{
|
||||||
|
description: 'List tasks, optionally filtered by project, mission, or status.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
projectId: z.string().optional().describe('Filter by project ID'),
|
||||||
|
missionId: z.string().optional().describe('Filter by mission ID'),
|
||||||
|
status: z.string().optional().describe('Filter by status'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ projectId, missionId, status }) => {
|
||||||
|
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
|
let tasks;
|
||||||
|
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
|
||||||
|
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
|
||||||
|
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
|
||||||
|
else tasks = await this.brain.tasks.findAll();
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'brain_create_task',
|
||||||
|
{
|
||||||
|
description: 'Create a new task in the brain.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
title: z.string().describe('Task title'),
|
||||||
|
description: z.string().optional().describe('Task description'),
|
||||||
|
projectId: z.string().optional().describe('Project ID'),
|
||||||
|
missionId: z.string().optional().describe('Mission ID'),
|
||||||
|
priority: z.string().optional().describe('Priority: low, medium, high, critical'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
const task = await this.brain.tasks.create({
|
||||||
|
...params,
|
||||||
|
priority: params.priority as Priority | undefined,
|
||||||
|
});
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(task, null, 2) }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'brain_update_task',
|
||||||
|
{
|
||||||
|
description: 'Update an existing task.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
id: z.string().describe('Task ID'),
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
status: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('not-started, in-progress, blocked, done, cancelled'),
|
||||||
|
priority: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ id, ...updates }) => {
|
||||||
|
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
|
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
const task = await this.brain.tasks.update(id, {
|
||||||
|
...updates,
|
||||||
|
status: updates.status as TaskStatus | undefined,
|
||||||
|
priority: updates.priority as Priority | undefined,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: task ? JSON.stringify(task, null, 2) : `Task not found: ${id}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Brain: Mission tools ────────────────────────────────────────────
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'brain_list_missions',
|
||||||
|
{
|
||||||
|
description: 'List all missions, optionally filtered by project.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
projectId: z.string().optional().describe('Filter by project ID'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ projectId }) => {
|
||||||
|
const missions = projectId
|
||||||
|
? await this.brain.missions.findByProject(projectId)
|
||||||
|
: await this.brain.missions.findAll();
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'brain_list_conversations',
|
||||||
|
{
|
||||||
|
description: 'List conversations for a user.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ userId }) => {
|
||||||
|
const conversations = await this.brain.conversations.findAll(userId);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Memory tools ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'memory_search',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID to search memory for'),
|
||||||
|
query: z.string().describe('Natural language search query'),
|
||||||
|
limit: z.number().optional().describe('Max results (default 5)'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ userId, query, limit }) => {
|
||||||
|
if (!this.embeddings.available) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: 'Semantic search unavailable — no embedding provider configured',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const embedding = await this.embeddings.embed(query);
|
||||||
|
const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'memory_get_preferences',
|
||||||
|
{
|
||||||
|
description: 'Retrieve stored preferences for a user.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID'),
|
||||||
|
category: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Filter by category: communication, coding, workflow, appearance, general'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ userId, category }) => {
|
||||||
|
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||||
|
const prefs = category
|
||||||
|
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat)
|
||||||
|
: await this.memory.preferences.findByUser(userId);
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'memory_save_preference',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
||||||
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID'),
|
||||||
|
key: z.string().describe('Preference key'),
|
||||||
|
value: z.string().describe('Preference value (JSON string)'),
|
||||||
|
category: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Category: communication, coding, workflow, appearance, general'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ userId, key, value, category }) => {
|
||||||
|
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||||
|
let parsedValue: unknown;
|
||||||
|
try {
|
||||||
|
parsedValue = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
parsedValue = value;
|
||||||
|
}
|
||||||
|
const pref = await this.memory.preferences.upsert({
|
||||||
|
userId,
|
||||||
|
key,
|
||||||
|
value: parsedValue,
|
||||||
|
category: (category as Cat) ?? 'general',
|
||||||
|
source: 'agent',
|
||||||
|
});
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(pref, null, 2) }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'memory_save_insight',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID'),
|
||||||
|
content: z.string().describe('The insight or knowledge to store'),
|
||||||
|
category: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Category: decision, learning, preference, fact, pattern, general'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ userId, content, category }) => {
|
||||||
|
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||||
|
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
|
||||||
|
const insight = await this.memory.insights.create({
|
||||||
|
userId,
|
||||||
|
content,
|
||||||
|
embedding,
|
||||||
|
source: 'agent',
|
||||||
|
category: (category as Cat) ?? 'learning',
|
||||||
|
});
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Coord tools ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'coord_mission_status',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
projectPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Project path. Defaults to gateway working directory.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ projectPath }) => {
|
||||||
|
const resolvedPath = projectPath ?? process.cwd();
|
||||||
|
const status = await this.coordService.getMissionStatus(resolvedPath);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: status ? JSON.stringify(status, null, 2) : 'No active coord mission found.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'coord_list_tasks',
|
||||||
|
{
|
||||||
|
description: 'List all tasks from the orchestration TASKS.md file.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
projectPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Project path. Defaults to gateway working directory.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ projectPath }) => {
|
||||||
|
const resolvedPath = projectPath ?? process.cwd();
|
||||||
|
const tasks = await this.coordService.listTasks(resolvedPath);
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'coord_task_detail',
|
||||||
|
{
|
||||||
|
description: 'Get detailed status for a specific orchestration task.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
taskId: z.string().describe('Task ID (e.g. P2-005)'),
|
||||||
|
projectPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Project path. Defaults to gateway working directory.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ taskId, projectPath }) => {
|
||||||
|
const resolvedPath = projectPath ?? process.cwd();
|
||||||
|
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: detail
|
||||||
|
? JSON.stringify(detail, null, 2)
|
||||||
|
: `Task ${taskId} not found in coord mission.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
this.logger.log(`Closing ${this.sessions.size} MCP sessions on shutdown`);
|
||||||
|
const closePromises = Array.from(this.sessions.values()).map(({ transport }) =>
|
||||||
|
transport.close().catch((err: unknown) => {
|
||||||
|
this.logger.warn(
|
||||||
|
`Error closing MCP transport: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await Promise.all(closePromises);
|
||||||
|
this.sessions.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/gateway/src/mcp/mcp.tokens.ts
Normal file
1
apps/gateway/src/mcp/mcp.tokens.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const MCP_SERVICE = 'MCP_SERVICE';
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import type { Memory } from '@mosaic/memory';
|
import type { Memory } from '@mosaic/memory';
|
||||||
import { MEMORY } from './memory.tokens.js';
|
import { MEMORY } from './memory.tokens.js';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { EmbeddingService } from './embedding.service.js';
|
import { EmbeddingService } from './embedding.service.js';
|
||||||
import type { UpsertPreferenceDto, CreateInsightDto, SearchMemoryDto } from './memory.dto.js';
|
import type { UpsertPreferenceDto, CreateInsightDto, SearchMemoryDto } from './memory.dto.js';
|
||||||
|
|
||||||
@@ -23,33 +24,33 @@ import type { UpsertPreferenceDto, CreateInsightDto, SearchMemoryDto } from './m
|
|||||||
export class MemoryController {
|
export class MemoryController {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(MEMORY) private readonly memory: Memory,
|
@Inject(MEMORY) private readonly memory: Memory,
|
||||||
private readonly embeddings: EmbeddingService,
|
@Inject(EmbeddingService) private readonly embeddings: EmbeddingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── Preferences ────────────────────────────────────────────────────
|
// ─── Preferences ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Get('preferences')
|
@Get('preferences')
|
||||||
async listPreferences(@Query('userId') userId: string, @Query('category') category?: string) {
|
async listPreferences(@CurrentUser() user: { id: string }, @Query('category') category?: string) {
|
||||||
if (category) {
|
if (category) {
|
||||||
return this.memory.preferences.findByUserAndCategory(
|
return this.memory.preferences.findByUserAndCategory(
|
||||||
userId,
|
user.id,
|
||||||
category as Parameters<typeof this.memory.preferences.findByUserAndCategory>[1],
|
category as Parameters<typeof this.memory.preferences.findByUserAndCategory>[1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.memory.preferences.findByUser(userId);
|
return this.memory.preferences.findByUser(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('preferences/:key')
|
@Get('preferences/:key')
|
||||||
async getPreference(@Query('userId') userId: string, @Param('key') key: string) {
|
async getPreference(@CurrentUser() user: { id: string }, @Param('key') key: string) {
|
||||||
const pref = await this.memory.preferences.findByUserAndKey(userId, key);
|
const pref = await this.memory.preferences.findByUserAndKey(user.id, key);
|
||||||
if (!pref) throw new NotFoundException('Preference not found');
|
if (!pref) throw new NotFoundException('Preference not found');
|
||||||
return pref;
|
return pref;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('preferences')
|
@Post('preferences')
|
||||||
async upsertPreference(@Query('userId') userId: string, @Body() dto: UpsertPreferenceDto) {
|
async upsertPreference(@CurrentUser() user: { id: string }, @Body() dto: UpsertPreferenceDto) {
|
||||||
return this.memory.preferences.upsert({
|
return this.memory.preferences.upsert({
|
||||||
userId,
|
userId: user.id,
|
||||||
key: dto.key,
|
key: dto.key,
|
||||||
value: dto.value,
|
value: dto.value,
|
||||||
category: dto.category,
|
category: dto.category,
|
||||||
@@ -59,16 +60,16 @@ export class MemoryController {
|
|||||||
|
|
||||||
@Delete('preferences/:key')
|
@Delete('preferences/:key')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async removePreference(@Query('userId') userId: string, @Param('key') key: string) {
|
async removePreference(@CurrentUser() user: { id: string }, @Param('key') key: string) {
|
||||||
const deleted = await this.memory.preferences.remove(userId, key);
|
const deleted = await this.memory.preferences.remove(user.id, key);
|
||||||
if (!deleted) throw new NotFoundException('Preference not found');
|
if (!deleted) throw new NotFoundException('Preference not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Insights ───────────────────────────────────────────────────────
|
// ─── Insights ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Get('insights')
|
@Get('insights')
|
||||||
async listInsights(@Query('userId') userId: string, @Query('limit') limit?: string) {
|
async listInsights(@CurrentUser() user: { id: string }, @Query('limit') limit?: string) {
|
||||||
return this.memory.insights.findByUser(userId, limit ? Number(limit) : undefined);
|
return this.memory.insights.findByUser(user.id, limit ? Number(limit) : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('insights/:id')
|
@Get('insights/:id')
|
||||||
@@ -79,13 +80,13 @@ export class MemoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('insights')
|
@Post('insights')
|
||||||
async createInsight(@Query('userId') userId: string, @Body() dto: CreateInsightDto) {
|
async createInsight(@CurrentUser() user: { id: string }, @Body() dto: CreateInsightDto) {
|
||||||
const embedding = this.embeddings.available
|
const embedding = this.embeddings.available
|
||||||
? await this.embeddings.embed(dto.content)
|
? await this.embeddings.embed(dto.content)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return this.memory.insights.create({
|
return this.memory.insights.create({
|
||||||
userId,
|
userId: user.id,
|
||||||
content: dto.content,
|
content: dto.content,
|
||||||
source: dto.source,
|
source: dto.source,
|
||||||
category: dto.category,
|
category: dto.category,
|
||||||
@@ -104,7 +105,7 @@ export class MemoryController {
|
|||||||
// ─── Search ─────────────────────────────────────────────────────────
|
// ─── Search ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Post('search')
|
@Post('search')
|
||||||
async searchMemory(@Query('userId') userId: string, @Body() dto: SearchMemoryDto) {
|
async searchMemory(@CurrentUser() user: { id: string }, @Body() dto: SearchMemoryDto) {
|
||||||
if (!this.embeddings.available) {
|
if (!this.embeddings.available) {
|
||||||
return {
|
return {
|
||||||
query: dto.query,
|
query: dto.query,
|
||||||
@@ -115,7 +116,7 @@ export class MemoryController {
|
|||||||
|
|
||||||
const queryEmbedding = await this.embeddings.embed(dto.query);
|
const queryEmbedding = await this.embeddings.embed(dto.query);
|
||||||
const results = await this.memory.insights.searchByEmbedding(
|
const results = await this.memory.insights.searchByEmbedding(
|
||||||
userId,
|
user.id,
|
||||||
queryEmbedding,
|
queryEmbedding,
|
||||||
dto.limit ?? 10,
|
dto.limit ?? 10,
|
||||||
dto.maxDistance ?? 0.8,
|
dto.maxDistance ?? 0.8,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
@@ -15,7 +16,9 @@ import {
|
|||||||
import type { Brain } from '@mosaic/brain';
|
import type { Brain } from '@mosaic/brain';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import type { CreateMissionDto, UpdateMissionDto } from './missions.dto.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { assertOwner } from '../auth/resource-ownership.js';
|
||||||
|
import { CreateMissionDto, UpdateMissionDto } from './missions.dto.js';
|
||||||
|
|
||||||
@Controller('api/missions')
|
@Controller('api/missions')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -28,14 +31,15 @@ export class MissionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(@Param('id') id: string) {
|
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||||
const mission = await this.brain.missions.findById(id);
|
return this.getOwnedMission(id, user.id);
|
||||||
if (!mission) throw new NotFoundException('Mission not found');
|
|
||||||
return mission;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() dto: CreateMissionDto) {
|
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({
|
return this.brain.missions.create({
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
@@ -45,7 +49,15 @@ export class MissionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
async update(@Param('id') id: string, @Body() dto: UpdateMissionDto) {
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@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 mission = await this.brain.missions.update(id, dto);
|
const mission = await this.brain.missions.update(id, dto);
|
||||||
if (!mission) throw new NotFoundException('Mission not found');
|
if (!mission) throw new NotFoundException('Mission not found');
|
||||||
return mission;
|
return mission;
|
||||||
@@ -53,8 +65,34 @@ export class MissionsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async remove(@Param('id') id: string) {
|
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||||
|
await this.getOwnedMission(id, user.id);
|
||||||
const deleted = await this.brain.missions.remove(id);
|
const deleted = await this.brain.missions.remove(id);
|
||||||
if (!deleted) throw new NotFoundException('Mission not found');
|
if (!deleted) throw new NotFoundException('Mission not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getOwnedMission(id: string, userId: string) {
|
||||||
|
const mission = await this.brain.missions.findById(id);
|
||||||
|
if (!mission) throw new NotFoundException('Mission not found');
|
||||||
|
await this.getOwnedProject(mission.projectId, userId, 'Mission');
|
||||||
|
return mission;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOwnedProject(
|
||||||
|
projectId: string | null | undefined,
|
||||||
|
userId: string,
|
||||||
|
resourceName: string,
|
||||||
|
) {
|
||||||
|
if (!projectId) {
|
||||||
|
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await this.brain.projects.findById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertOwner(project.ownerId, userId, resourceName);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,46 @@
|
|||||||
export interface CreateMissionDto {
|
import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||||
name: string;
|
|
||||||
|
const missionStatuses = ['planning', 'active', 'paused', 'completed', 'failed'] as const;
|
||||||
|
|
||||||
|
export class CreateMissionDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10_000)
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(missionStatuses)
|
||||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMissionDto {
|
export class UpdateMissionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10_000)
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(missionStatuses)
|
||||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|||||||
5
apps/gateway/src/plugin/plugin.interface.ts
Normal file
5
apps/gateway/src/plugin/plugin.interface.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface IChannelPlugin {
|
||||||
|
readonly name: string;
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
}
|
||||||
109
apps/gateway/src/plugin/plugin.module.ts
Normal file
109
apps/gateway/src/plugin/plugin.module.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
Global,
|
||||||
|
Inject,
|
||||||
|
Logger,
|
||||||
|
Module,
|
||||||
|
type OnModuleDestroy,
|
||||||
|
type OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DiscordPlugin } from '@mosaic/discord-plugin';
|
||||||
|
import { TelegramPlugin } from '@mosaic/telegram-plugin';
|
||||||
|
import { PluginService } from './plugin.service.js';
|
||||||
|
import type { IChannelPlugin } from './plugin.interface.js';
|
||||||
|
import { PLUGIN_REGISTRY } from './plugin.tokens.js';
|
||||||
|
|
||||||
|
class DiscordChannelPluginAdapter implements IChannelPlugin {
|
||||||
|
readonly name = 'discord';
|
||||||
|
|
||||||
|
constructor(private readonly plugin: DiscordPlugin) {}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
await this.plugin.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
await this.plugin.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TelegramChannelPluginAdapter implements IChannelPlugin {
|
||||||
|
readonly name = 'telegram';
|
||||||
|
|
||||||
|
constructor(private readonly plugin: TelegramPlugin) {}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
await this.plugin.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
await this.plugin.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_GATEWAY_URL = 'http://localhost:4000';
|
||||||
|
|
||||||
|
function createPluginRegistry(): IChannelPlugin[] {
|
||||||
|
const plugins: IChannelPlugin[] = [];
|
||||||
|
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
||||||
|
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
||||||
|
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
||||||
|
|
||||||
|
if (discordToken) {
|
||||||
|
plugins.push(
|
||||||
|
new DiscordChannelPluginAdapter(
|
||||||
|
new DiscordPlugin({
|
||||||
|
token: discordToken,
|
||||||
|
guildId: discordGuildId,
|
||||||
|
gatewayUrl: discordGatewayUrl,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const telegramToken = process.env['TELEGRAM_BOT_TOKEN'];
|
||||||
|
const telegramGatewayUrl = process.env['TELEGRAM_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
||||||
|
|
||||||
|
if (telegramToken) {
|
||||||
|
plugins.push(
|
||||||
|
new TelegramChannelPluginAdapter(
|
||||||
|
new TelegramPlugin({
|
||||||
|
token: telegramToken,
|
||||||
|
gatewayUrl: telegramGatewayUrl,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: PLUGIN_REGISTRY,
|
||||||
|
useFactory: (): IChannelPlugin[] => createPluginRegistry(),
|
||||||
|
},
|
||||||
|
PluginService,
|
||||||
|
],
|
||||||
|
exports: [PluginService, PLUGIN_REGISTRY],
|
||||||
|
})
|
||||||
|
export class PluginModule implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(PluginModule.name);
|
||||||
|
|
||||||
|
constructor(@Inject(PLUGIN_REGISTRY) private readonly plugins: IChannelPlugin[]) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
for (const plugin of this.plugins) {
|
||||||
|
this.logger.log(`Starting plugin: ${plugin.name}`);
|
||||||
|
await plugin.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
for (const plugin of [...this.plugins].reverse()) {
|
||||||
|
this.logger.log(`Stopping plugin: ${plugin.name}`);
|
||||||
|
await plugin.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/gateway/src/plugin/plugin.service.ts
Normal file
16
apps/gateway/src/plugin/plugin.service.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { PLUGIN_REGISTRY } from './plugin.tokens.js';
|
||||||
|
import type { IChannelPlugin } from './plugin.interface.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PluginService {
|
||||||
|
constructor(@Inject(PLUGIN_REGISTRY) private readonly plugins: IChannelPlugin[]) {}
|
||||||
|
|
||||||
|
getPlugins(): IChannelPlugin[] {
|
||||||
|
return this.plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlugin(name: string): IChannelPlugin | undefined {
|
||||||
|
return this.plugins.find((plugin: IChannelPlugin) => plugin.name === name);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/gateway/src/plugin/plugin.tokens.ts
Normal file
1
apps/gateway/src/plugin/plugin.tokens.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const PLUGIN_REGISTRY = Symbol('PLUGIN_REGISTRY');
|
||||||
@@ -16,7 +16,8 @@ import type { Brain } from '@mosaic/brain';
|
|||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import type { CreateProjectDto, UpdateProjectDto } from './projects.dto.js';
|
import { assertOwner } from '../auth/resource-ownership.js';
|
||||||
|
import { CreateProjectDto, UpdateProjectDto } from './projects.dto.js';
|
||||||
|
|
||||||
@Controller('api/projects')
|
@Controller('api/projects')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -29,10 +30,8 @@ export class ProjectsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(@Param('id') id: string) {
|
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||||
const project = await this.brain.projects.findById(id);
|
return this.getOwnedProject(id, user.id);
|
||||||
if (!project) throw new NotFoundException('Project not found');
|
|
||||||
return project;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -46,7 +45,12 @@ export class ProjectsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
async update(@Param('id') id: string, @Body() dto: UpdateProjectDto) {
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateProjectDto,
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
) {
|
||||||
|
await this.getOwnedProject(id, user.id);
|
||||||
const project = await this.brain.projects.update(id, dto);
|
const project = await this.brain.projects.update(id, dto);
|
||||||
if (!project) throw new NotFoundException('Project not found');
|
if (!project) throw new NotFoundException('Project not found');
|
||||||
return project;
|
return project;
|
||||||
@@ -54,8 +58,16 @@ export class ProjectsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async remove(@Param('id') id: string) {
|
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||||
|
await this.getOwnedProject(id, user.id);
|
||||||
const deleted = await this.brain.projects.remove(id);
|
const deleted = await this.brain.projects.remove(id);
|
||||||
if (!deleted) throw new NotFoundException('Project not found');
|
if (!deleted) throw new NotFoundException('Project not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getOwnedProject(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');
|
||||||
|
return project;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,38 @@
|
|||||||
export interface CreateProjectDto {
|
import { IsIn, IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
name: string;
|
|
||||||
|
const projectStatuses = ['active', 'paused', 'completed', 'archived'] as const;
|
||||||
|
|
||||||
|
export class CreateProjectDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10_000)
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(projectStatuses)
|
||||||
status?: 'active' | 'paused' | 'completed' | 'archived';
|
status?: 'active' | 'paused' | 'completed' | 'archived';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateProjectDto {
|
export class UpdateProjectDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10_000)
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(projectStatuses)
|
||||||
status?: 'active' | 'paused' | 'completed' | 'archived';
|
status?: 'active' | 'paused' | 'completed' | 'archived';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
@@ -18,7 +19,7 @@ import type { CreateSkillDto, UpdateSkillDto } from './skills.dto.js';
|
|||||||
@Controller('api/skills')
|
@Controller('api/skills')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class SkillsController {
|
export class SkillsController {
|
||||||
constructor(private readonly skills: SkillsService) {}
|
constructor(@Inject(SkillsService) private readonly skills: SkillsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list() {
|
async list() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
@@ -16,7 +17,9 @@ import {
|
|||||||
import type { Brain } from '@mosaic/brain';
|
import type { Brain } from '@mosaic/brain';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import type { CreateTaskDto, UpdateTaskDto } from './tasks.dto.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { assertOwner } from '../auth/resource-ownership.js';
|
||||||
|
import { CreateTaskDto, UpdateTaskDto } from './tasks.dto.js';
|
||||||
|
|
||||||
@Controller('api/tasks')
|
@Controller('api/tasks')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -25,28 +28,63 @@ export class TasksController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(
|
async list(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
@Query('projectId') projectId?: string,
|
@Query('projectId') projectId?: string,
|
||||||
@Query('missionId') missionId?: string,
|
@Query('missionId') missionId?: string,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
) {
|
) {
|
||||||
if (projectId) return this.brain.tasks.findByProject(projectId);
|
if (projectId) {
|
||||||
if (missionId) return this.brain.tasks.findByMission(missionId);
|
await this.getOwnedProject(projectId, user.id, 'Task');
|
||||||
if (status)
|
return this.brain.tasks.findByProject(projectId);
|
||||||
return this.brain.tasks.findByStatus(
|
}
|
||||||
status as Parameters<typeof this.brain.tasks.findByStatus>[0],
|
if (missionId) {
|
||||||
);
|
await this.getOwnedMission(missionId, user.id, 'Task');
|
||||||
return this.brain.tasks.findAll();
|
return this.brain.tasks.findByMission(missionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [projects, missions, tasks] = await Promise.all([
|
||||||
|
this.brain.projects.findAll(),
|
||||||
|
this.brain.missions.findAll(),
|
||||||
|
status
|
||||||
|
? this.brain.tasks.findByStatus(
|
||||||
|
status as Parameters<typeof this.brain.tasks.findByStatus>[0],
|
||||||
|
)
|
||||||
|
: this.brain.tasks.findAll(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ownedProjectIds = new Set(
|
||||||
|
projects.filter((project) => project.ownerId === user.id).map((project) => project.id),
|
||||||
|
);
|
||||||
|
const ownedMissionIds = new Set(
|
||||||
|
missions
|
||||||
|
.filter(
|
||||||
|
(ownedMission) =>
|
||||||
|
typeof ownedMission.projectId === 'string' &&
|
||||||
|
ownedProjectIds.has(ownedMission.projectId),
|
||||||
|
)
|
||||||
|
.map((ownedMission) => ownedMission.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
return tasks.filter(
|
||||||
|
(task) =>
|
||||||
|
(task.projectId ? ownedProjectIds.has(task.projectId) : false) ||
|
||||||
|
(task.missionId ? ownedMissionIds.has(task.missionId) : false),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(@Param('id') id: string) {
|
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||||
const task = await this.brain.tasks.findById(id);
|
return this.getOwnedTask(id, user.id);
|
||||||
if (!task) throw new NotFoundException('Task not found');
|
|
||||||
return task;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() dto: CreateTaskDto) {
|
async create(@Body() dto: CreateTaskDto, @CurrentUser() user: { id: string }) {
|
||||||
|
if (dto.projectId) {
|
||||||
|
await this.getOwnedProject(dto.projectId, user.id, 'Task');
|
||||||
|
}
|
||||||
|
if (dto.missionId) {
|
||||||
|
await this.getOwnedMission(dto.missionId, user.id, 'Task');
|
||||||
|
}
|
||||||
return this.brain.tasks.create({
|
return this.brain.tasks.create({
|
||||||
title: dto.title,
|
title: dto.title,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
@@ -61,7 +99,18 @@ export class TasksController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
async update(@Param('id') id: string, @Body() dto: UpdateTaskDto) {
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateTaskDto,
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
) {
|
||||||
|
await this.getOwnedTask(id, user.id);
|
||||||
|
if (dto.projectId) {
|
||||||
|
await this.getOwnedProject(dto.projectId, user.id, 'Task');
|
||||||
|
}
|
||||||
|
if (dto.missionId) {
|
||||||
|
await this.getOwnedMission(dto.missionId, user.id, 'Task');
|
||||||
|
}
|
||||||
const task = await this.brain.tasks.update(id, {
|
const task = await this.brain.tasks.update(id, {
|
||||||
...dto,
|
...dto,
|
||||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
|
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
|
||||||
@@ -72,8 +121,46 @@ export class TasksController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async remove(@Param('id') id: string) {
|
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||||
|
await this.getOwnedTask(id, user.id);
|
||||||
const deleted = await this.brain.tasks.remove(id);
|
const deleted = await this.brain.tasks.remove(id);
|
||||||
if (!deleted) throw new NotFoundException('Task not found');
|
if (!deleted) throw new NotFoundException('Task not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getOwnedTask(id: string, userId: string) {
|
||||||
|
const task = await this.brain.tasks.findById(id);
|
||||||
|
if (!task) throw new NotFoundException('Task not found');
|
||||||
|
|
||||||
|
if (task.projectId) {
|
||||||
|
await this.getOwnedProject(task.projectId, userId, 'Task');
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.missionId) {
|
||||||
|
await this.getOwnedMission(task.missionId, userId, 'Task');
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException('Task does not belong to the current user');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOwnedMission(missionId: string, userId: string, resourceName: string) {
|
||||||
|
const mission = await this.brain.missions.findById(missionId);
|
||||||
|
if (!mission?.projectId) {
|
||||||
|
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.getOwnedProject(mission.projectId, userId, resourceName);
|
||||||
|
return mission;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOwnedProject(projectId: string, userId: string, resourceName: string) {
|
||||||
|
const project = await this.brain.projects.findById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertOwner(project.ownerId, userId, resourceName);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,103 @@
|
|||||||
export interface CreateTaskDto {
|
import {
|
||||||
title: string;
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsISO8601,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
const taskStatuses = ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'] as const;
|
||||||
|
const taskPriorities = ['critical', 'high', 'medium', 'low'] as const;
|
||||||
|
|
||||||
|
export class CreateTaskDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10_000)
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(taskStatuses)
|
||||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(taskPriorities)
|
||||||
priority?: 'critical' | 'high' | 'medium' | 'low';
|
priority?: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
missionId?: string;
|
missionId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
assignee?: string;
|
assignee?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(50)
|
||||||
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601()
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTaskDto {
|
export class UpdateTaskDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10_000)
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(taskStatuses)
|
||||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(taskPriorities)
|
||||||
priority?: 'critical' | 'high' | 'medium' | 'low';
|
priority?: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
missionId?: string | null;
|
missionId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
assignee?: string | null;
|
assignee?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(50)
|
||||||
|
@IsString({ each: true })
|
||||||
tags?: string[] | null;
|
tags?: string[] | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601()
|
||||||
dueDate?: string | null;
|
dueDate?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|||||||
18
apps/gateway/tsconfig.typecheck.json
Normal file
18
apps/gateway/tsconfig.typecheck.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "../..",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@mosaic/auth": ["../../packages/auth/src/index.ts"],
|
||||||
|
"@mosaic/brain": ["../../packages/brain/src/index.ts"],
|
||||||
|
"@mosaic/coord": ["../../packages/coord/src/index.ts"],
|
||||||
|
"@mosaic/db": ["../../packages/db/src/index.ts"],
|
||||||
|
"@mosaic/log": ["../../packages/log/src/index.ts"],
|
||||||
|
"@mosaic/memory": ["../../packages/memory/src/index.ts"],
|
||||||
|
"@mosaic/types": ["../../packages/types/src/index.ts"],
|
||||||
|
"@mosaic/discord-plugin": ["../../plugins/discord/src/index.ts"],
|
||||||
|
"@mosaic/telegram-plugin": ["../../plugins/telegram/src/index.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
apps/web/e2e/admin.spec.ts
Normal file
72
apps/web/e2e/admin.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { loginAs, ADMIN_USER, TEST_USER } from './helpers/auth.js';
|
||||||
|
|
||||||
|
test.describe('Admin page — admin user', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAs(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
|
const url = page.url();
|
||||||
|
test.skip(!url.includes('/chat'), 'No seeded admin user — skipping admin tests');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin page loads with the Admin Panel heading', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await expect(page.getByRole('heading', { name: /admin panel/i })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows User Management and System Health tabs', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await expect(page.getByRole('button', { name: /user management/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /system health/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('User Management tab is active by default', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
// The users tab shows a "+ New User" button
|
||||||
|
await expect(page.getByRole('button', { name: /new user/i })).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking System Health tab switches to health view', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('button', { name: /system health/i }).click();
|
||||||
|
// Health cards or loading indicator should appear
|
||||||
|
const hasLoading = await page
|
||||||
|
.getByText(/loading health/i)
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
const hasCard = await page
|
||||||
|
.getByText(/database/i)
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
expect(hasLoading || hasCard).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Admin page — non-admin user', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||||
|
const url = page.url();
|
||||||
|
test.skip(!url.includes('/chat'), 'No seeded test user — skipping non-admin tests');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-admin visiting /admin sees access denied or is redirected', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
// Either redirected away or shown an access-denied message
|
||||||
|
const onAdmin = page.url().includes('/admin');
|
||||||
|
if (onAdmin) {
|
||||||
|
// Should show some access-denied content rather than the full admin panel
|
||||||
|
const hasPanel = await page
|
||||||
|
.getByRole('heading', { name: /admin panel/i })
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
// If heading is visible, the guard allowed access (user may have admin role in this env)
|
||||||
|
// — not a failure, just informational
|
||||||
|
if (!hasPanel) {
|
||||||
|
// access denied message, redirect, or guard placeholder
|
||||||
|
const url = page.url();
|
||||||
|
expect(url).toBeTruthy(); // environment-dependent — no hard assertion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
119
apps/web/e2e/auth.spec.ts
Normal file
119
apps/web/e2e/auth.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { TEST_USER } from './helpers/auth.js';
|
||||||
|
|
||||||
|
// ── Login page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Login page', () => {
|
||||||
|
test('loads and shows the sign-in heading', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page).toHaveTitle(/mosaic/i);
|
||||||
|
await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows email and password fields', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page.getByLabel('Email')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Password')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows submit button', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows link to registration page', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
const signUpLink = page.getByRole('link', { name: /sign up/i });
|
||||||
|
await expect(signUpLink).toBeVisible();
|
||||||
|
await signUpLink.click();
|
||||||
|
await expect(page).toHaveURL(/\/register/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows an error alert for invalid credentials', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel('Email').fill('nobody@nowhere.invalid');
|
||||||
|
await page.getByLabel('Password').fill('wrongpassword');
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
// The error banner should appear; it has role="alert"
|
||||||
|
await expect(page.getByRole('alert')).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email field requires valid format (HTML5 validation)', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
// Fill a non-email value — browser prevents submission
|
||||||
|
await page.getByLabel('Email').fill('notanemail');
|
||||||
|
await page.getByLabel('Password').fill('somepass');
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
// Still on the login page
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirects to /chat after successful login', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||||
|
await page.getByLabel('Password').fill(TEST_USER.password);
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
// Either reaches /chat or shows an error (if credentials are wrong in this env).
|
||||||
|
// We assert a navigation away from /login, or the alert is shown.
|
||||||
|
await Promise.race([
|
||||||
|
expect(page).toHaveURL(/\/chat/, { timeout: 10_000 }),
|
||||||
|
expect(page.getByRole('alert')).toBeVisible({ timeout: 10_000 }),
|
||||||
|
]).catch(() => {
|
||||||
|
// Acceptable — environment may not have seeded credentials
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Registration page ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Registration page', () => {
|
||||||
|
test('loads and shows the create account heading', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
await expect(page.getByRole('heading', { name: /create account/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows name, email and password fields', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
await expect(page.getByLabel('Name')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Email')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Password')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows submit button', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
await expect(page.getByRole('button', { name: /create account/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows link to login page', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
const signInLink = page.getByRole('link', { name: /sign in/i });
|
||||||
|
await expect(signInLink).toBeVisible();
|
||||||
|
await signInLink.click();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('name field is required — empty form stays on page', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
// Submit with nothing filled in — browser required validation blocks it
|
||||||
|
await page.getByRole('button', { name: /create account/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/register/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all required fields must be filled (HTML5 validation)', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
await page.getByLabel('Name').fill('Test User');
|
||||||
|
// Do NOT fill email or password — still on page
|
||||||
|
await page.getByRole('button', { name: /create account/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/register/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Root redirect ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Root route', () => {
|
||||||
|
test('visiting / redirects to /login or /chat', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// Unauthenticated users should land on /login; authenticated on /chat
|
||||||
|
await expect(page).toHaveURL(/\/(login|chat)/, { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
50
apps/web/e2e/chat.spec.ts
Normal file
50
apps/web/e2e/chat.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { loginAs, TEST_USER } from './helpers/auth.js';
|
||||||
|
|
||||||
|
test.describe('Chat page', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||||
|
// If login failed (no seeded user in env) we may be on /login — skip
|
||||||
|
const url = page.url();
|
||||||
|
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chat page loads and shows the welcome message or conversation list', async ({ page }) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
// Either there are conversations listed or the welcome empty-state is shown
|
||||||
|
const hasWelcome = await page
|
||||||
|
.getByRole('heading', { name: /welcome to mosaic chat/i })
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
const hasConversationPanel = await page
|
||||||
|
.locator('[data-testid="conversation-list"], nav, aside')
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
expect(hasWelcome || hasConversationPanel).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new conversation button is visible', async ({ page }) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
// "Start new conversation" button or a "+" button in the sidebar
|
||||||
|
const newConvButton = page.getByRole('button', { name: /new conversation|start new/i }).first();
|
||||||
|
await expect(newConvButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking new conversation shows a chat input area', async ({ page }) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
// Find any button that creates a new conversation
|
||||||
|
const newBtn = page.getByRole('button', { name: /new conversation|start new/i }).first();
|
||||||
|
await newBtn.click();
|
||||||
|
// After creating, a text input for sending messages should appear
|
||||||
|
const chatInput = page.getByRole('textbox').or(page.locator('textarea')).first();
|
||||||
|
await expect(chatInput).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar navigation is present on chat page', async ({ page }) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
// The app-shell sidebar should be visible
|
||||||
|
await expect(page.getByRole('link', { name: /chat/i }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
apps/web/e2e/helpers/auth.ts
Normal file
23
apps/web/e2e/helpers/auth.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export const TEST_USER = {
|
||||||
|
email: process.env['E2E_USER_EMAIL'] ?? 'e2e@example.com',
|
||||||
|
password: process.env['E2E_USER_PASSWORD'] ?? 'password123',
|
||||||
|
name: 'E2E Test User',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ADMIN_USER = {
|
||||||
|
email: process.env['E2E_ADMIN_EMAIL'] ?? 'admin@example.com',
|
||||||
|
password: process.env['E2E_ADMIN_PASSWORD'] ?? 'adminpass123',
|
||||||
|
name: 'E2E Admin User',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill the login form and submit. Waits for navigation after success.
|
||||||
|
*/
|
||||||
|
export async function loginAs(page: Page, email: string, password: string): Promise<void> {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password').fill(password);
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
}
|
||||||
86
apps/web/e2e/navigation.spec.ts
Normal file
86
apps/web/e2e/navigation.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { loginAs, TEST_USER } from './helpers/auth.js';
|
||||||
|
|
||||||
|
test.describe('Sidebar navigation', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||||
|
const url = page.url();
|
||||||
|
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar shows Mosaic brand link', async ({ page }) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
await expect(page.getByRole('link', { name: /mosaic/i }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Chat nav link navigates to /chat', async ({ page }) => {
|
||||||
|
await page.goto('/settings');
|
||||||
|
await page
|
||||||
|
.getByRole('link', { name: /^chat$/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page).toHaveURL(/\/chat/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Projects nav link navigates to /projects', async ({ page }) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
await page
|
||||||
|
.getByRole('link', { name: /projects/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page).toHaveURL(/\/projects/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Settings nav link navigates to /settings', async ({ page }) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
await page
|
||||||
|
.getByRole('link', { name: /settings/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page).toHaveURL(/\/settings/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tasks nav link navigates to /tasks', async ({ page }) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
await page.getByRole('link', { name: /tasks/i }).first().click();
|
||||||
|
await expect(page).toHaveURL(/\/tasks/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('active link is visually highlighted', async ({ page }) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
// The active link should have a distinct class — check that the Chat link
|
||||||
|
// has the active style class (bg-blue-600/20 text-blue-400)
|
||||||
|
const chatLink = page.getByRole('link', { name: /^chat$/i }).first();
|
||||||
|
const cls = await chatLink.getAttribute('class');
|
||||||
|
expect(cls).toContain('blue');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Route transitions', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||||
|
const url = page.url();
|
||||||
|
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigating chat → projects → settings → chat works without errors', async ({ page }) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
await expect(page).toHaveURL(/\/chat/);
|
||||||
|
|
||||||
|
await page.goto('/projects');
|
||||||
|
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/settings');
|
||||||
|
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/chat');
|
||||||
|
await expect(page).toHaveURL(/\/chat/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('back-button navigation works between pages', async ({ page }) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
await page.goto('/projects');
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page).toHaveURL(/\/chat/);
|
||||||
|
});
|
||||||
|
});
|
||||||
44
apps/web/e2e/projects.spec.ts
Normal file
44
apps/web/e2e/projects.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { loginAs, TEST_USER } from './helpers/auth.js';
|
||||||
|
|
||||||
|
test.describe('Projects page', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||||
|
const url = page.url();
|
||||||
|
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projects page loads with heading', async ({ page }) => {
|
||||||
|
await page.goto('/projects');
|
||||||
|
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows empty state or project cards when loaded', async ({ page }) => {
|
||||||
|
await page.goto('/projects');
|
||||||
|
// Wait for loading state to clear
|
||||||
|
await expect(page.getByText(/loading projects/i)).not.toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
const hasProjects = await page
|
||||||
|
.locator('[class*="grid"]')
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
const hasEmpty = await page
|
||||||
|
.getByText(/no projects yet/i)
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
expect(hasProjects || hasEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows Active Mission section', async ({ page }) => {
|
||||||
|
await page.goto('/projects');
|
||||||
|
await expect(page.getByRole('heading', { name: /active mission/i })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar navigation is present', async ({ page }) => {
|
||||||
|
await page.goto('/projects');
|
||||||
|
await expect(page.getByRole('link', { name: /projects/i }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
56
apps/web/e2e/settings.spec.ts
Normal file
56
apps/web/e2e/settings.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { loginAs, TEST_USER } from './helpers/auth.js';
|
||||||
|
|
||||||
|
test.describe('Settings page', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||||
|
const url = page.url();
|
||||||
|
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings page loads with heading', async ({ page }) => {
|
||||||
|
await page.goto('/settings');
|
||||||
|
await expect(page.getByRole('heading', { name: /^settings$/i })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows the four settings tabs', async ({ page }) => {
|
||||||
|
await page.goto('/settings');
|
||||||
|
await expect(page.getByRole('button', { name: /profile/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /appearance/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /notifications/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /providers/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile tab is active by default', async ({ page }) => {
|
||||||
|
await page.goto('/settings');
|
||||||
|
await expect(page.getByRole('heading', { name: /^profile$/i })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking Appearance tab switches content', async ({ page }) => {
|
||||||
|
await page.goto('/settings');
|
||||||
|
await page.getByRole('button', { name: /appearance/i }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /appearance/i })).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking Notifications tab switches content', async ({ page }) => {
|
||||||
|
await page.goto('/settings');
|
||||||
|
await page.getByRole('button', { name: /notifications/i }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /notifications/i })).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking Providers tab switches content', async ({ page }) => {
|
||||||
|
await page.goto('/settings');
|
||||||
|
await page.getByRole('button', { name: /providers/i }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /llm providers/i })).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
32
apps/web/playwright.config.ts
Normal file
32
apps/web/playwright.config.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright E2E configuration for Mosaic web app.
|
||||||
|
*
|
||||||
|
* Assumes:
|
||||||
|
* - Next.js web app running on http://localhost:3000
|
||||||
|
* - NestJS gateway running on http://localhost:4000
|
||||||
|
*
|
||||||
|
* Run with: pnpm --filter @mosaic/web test:e2e
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env['CI'],
|
||||||
|
retries: process.env['CI'] ? 2 : 0,
|
||||||
|
workers: process.env['CI'] ? 1 : undefined,
|
||||||
|
reporter: 'html',
|
||||||
|
use: {
|
||||||
|
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Do NOT auto-start the dev server — tests assume it is already running.
|
||||||
|
// webServer is intentionally omitted so tests can run against a live env.
|
||||||
|
});
|
||||||
@@ -1,99 +1,531 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { AdminRoleGuard } from '@/components/admin-role-guard';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
interface SessionInfo {
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface UserDto {
|
||||||
id: string;
|
id: string;
|
||||||
provider: string;
|
name: string;
|
||||||
modelId: string;
|
email: string;
|
||||||
|
role: string;
|
||||||
|
banned: boolean;
|
||||||
|
banReason: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
promptCount: number;
|
updatedAt: string;
|
||||||
channels: string[];
|
|
||||||
durationMs: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionsResponse {
|
interface UserListDto {
|
||||||
sessions: SessionInfo[];
|
users: UserDto[];
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage(): React.ReactElement {
|
interface ServiceStatusDto {
|
||||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
status: 'ok' | 'error';
|
||||||
const [loading, setLoading] = useState(true);
|
latencyMs?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
interface ProviderStatusDto {
|
||||||
api<SessionsResponse>('/api/sessions')
|
id: string;
|
||||||
.then((res) => setSessions(res.sessions))
|
name: string;
|
||||||
.catch(() => {})
|
available: boolean;
|
||||||
.finally(() => setLoading(false));
|
modelCount: number;
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
|
interface HealthStatusDto {
|
||||||
|
status: 'ok' | 'degraded' | 'error';
|
||||||
|
database: ServiceStatusDto;
|
||||||
|
cache: ServiceStatusDto;
|
||||||
|
agentPool: { activeSessions: number };
|
||||||
|
providers: ProviderStatusDto[];
|
||||||
|
checkedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin Page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function AdminPage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<AdminRoleGuard>
|
||||||
|
<AdminContent />
|
||||||
|
</AdminRoleGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminContent(): React.ReactElement {
|
||||||
|
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl space-y-8">
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
<h1 className="text-2xl font-semibold">Admin</h1>
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-semibold text-text-primary">Admin Panel</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* User Management placeholder */}
|
<div className="flex gap-1 border-b border-surface-border">
|
||||||
<section>
|
{(['users', 'health'] as const).map((tab) => (
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">User Management</h2>
|
<button
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
key={tab}
|
||||||
<p className="text-sm text-text-muted">
|
type="button"
|
||||||
User management will be available when the admin API is implemented
|
onClick={() => setActiveTab(tab)}
|
||||||
</p>
|
className={cn(
|
||||||
</div>
|
'px-4 py-2 text-sm font-medium capitalize transition-colors',
|
||||||
</section>
|
activeTab === tab
|
||||||
|
? 'border-b-2 border-blue-500 text-blue-400'
|
||||||
|
: 'text-text-secondary hover:text-text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab === 'users' ? 'User Management' : 'System Health'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Active Agent Sessions */}
|
{activeTab === 'users' ? <UsersTab /> : <HealthTab />}
|
||||||
<section>
|
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Active Agent Sessions</h2>
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-text-muted">Loading sessions...</p>
|
|
||||||
) : sessions.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<p className="text-sm text-text-muted">No active sessions</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-hidden rounded-lg border border-surface-border">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
|
||||||
<th className="px-4 py-2 font-medium">Session ID</th>
|
|
||||||
<th className="px-4 py-2 font-medium">Provider</th>
|
|
||||||
<th className="px-4 py-2 font-medium">Model</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Prompts</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Duration</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sessions.map((s) => (
|
|
||||||
<tr key={s.id} className="border-b border-surface-border last:border-b-0">
|
|
||||||
<td className="max-w-[200px] truncate px-4 py-2 font-mono text-xs text-text-primary">
|
|
||||||
{s.id}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-text-muted">{s.provider}</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-text-muted">{s.modelId}</td>
|
|
||||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
||||||
{s.promptCount}
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
||||||
{formatDuration(s.durationMs)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
// ── Users Tab ──────────────────────────────────────────────────────────────────
|
||||||
const seconds = Math.floor(ms / 1000);
|
|
||||||
if (seconds < 60) return `${seconds}s`;
|
function UsersTab(): React.ReactElement {
|
||||||
const minutes = Math.floor(seconds / 60);
|
const [users, setUsers] = useState<UserDto[]>([]);
|
||||||
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
const [loading, setLoading] = useState(true);
|
||||||
const hours = Math.floor(minutes / 60);
|
const [error, setError] = useState<string | null>(null);
|
||||||
return `${hours}h ${minutes % 60}m`;
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await api<UserListDto>('/api/admin/users');
|
||||||
|
setUsers(data.users);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load users');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadUsers();
|
||||||
|
}, [loadUsers]);
|
||||||
|
|
||||||
|
async function handleRoleToggle(user: UserDto): Promise<void> {
|
||||||
|
const newRole = user.role === 'admin' ? 'member' : 'admin';
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/users/${user.id}/role`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { role: newRole },
|
||||||
|
});
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to update role');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBanToggle(user: UserDto): Promise<void> {
|
||||||
|
const endpoint = user.banned ? 'unban' : 'ban';
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/users/${user.id}/${endpoint}`, { method: 'POST' });
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to update ban status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(user: UserDto): Promise<void> {
|
||||||
|
if (!confirm(`Delete user ${user.email}? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/users/${user.id}`, { method: 'DELETE' });
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to delete user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="text-sm text-text-muted">Loading users...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadUsers()}
|
||||||
|
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-text-muted">{users.length} user(s)</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
+ New User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<CreateUserForm
|
||||||
|
onCancel={() => setShowCreate(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
setShowCreate(false);
|
||||||
|
void loadUsers();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
||||||
|
<p className="text-sm text-text-muted">No users found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-surface-border">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
||||||
|
<th className="px-4 py-2 font-medium">Name / Email</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Role</th>
|
||||||
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Status</th>
|
||||||
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Created</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id} className="border-b border-surface-border last:border-b-0">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-sm font-medium text-text-primary">{user.name}</div>
|
||||||
|
<div className="text-xs text-text-muted">{user.email}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
|
||||||
|
user.role === 'admin'
|
||||||
|
? 'bg-purple-500/20 text-purple-400'
|
||||||
|
: 'bg-surface-elevated text-text-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="hidden px-4 py-3 md:table-cell">
|
||||||
|
{user.banned ? (
|
||||||
|
<span className="inline-flex rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
|
||||||
|
Banned
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleRoleToggle(user)}
|
||||||
|
className="text-xs text-blue-400 hover:text-blue-300"
|
||||||
|
title={user.role === 'admin' ? 'Demote to member' : 'Promote to admin'}
|
||||||
|
>
|
||||||
|
{user.role === 'admin' ? 'Demote' : 'Promote'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleBanToggle(user)}
|
||||||
|
className={cn(
|
||||||
|
'text-xs',
|
||||||
|
user.banned
|
||||||
|
? 'text-green-400 hover:text-green-300'
|
||||||
|
: 'text-yellow-400 hover:text-yellow-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{user.banned ? 'Unban' : 'Ban'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDelete(user)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create User Form ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CreateUserFormProps {
|
||||||
|
onCancel: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateUserForm({ onCancel, onCreated }: CreateUserFormProps): React.ReactElement {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [role, setRole] = useState('member');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api('/api/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name, email, password, role },
|
||||||
|
});
|
||||||
|
onCreated();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create user');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||||
|
<h3 className="mb-3 text-sm font-medium text-text-primary">Create New User</h3>
|
||||||
|
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-3">
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Role</label>
|
||||||
|
<select
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="member">member</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Health Tab ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function HealthTab(): React.ReactElement {
|
||||||
|
const [health, setHealth] = useState<HealthStatusDto | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadHealth = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await api<HealthStatusDto>('/api/admin/health');
|
||||||
|
setHealth(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load health');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadHealth();
|
||||||
|
}, [loadHealth]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="text-sm text-text-muted">Loading health status...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadHealth()}
|
||||||
|
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!health) return <></>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status={health.status} />
|
||||||
|
<span className="text-sm text-text-muted">
|
||||||
|
Last checked: {new Date(health.checkedAt).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadHealth()}
|
||||||
|
className="text-xs text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{/* Database */}
|
||||||
|
<HealthCard title="Database (PostgreSQL)" status={health.database.status}>
|
||||||
|
{health.database.latencyMs !== undefined && (
|
||||||
|
<p className="text-xs text-text-muted">Latency: {health.database.latencyMs}ms</p>
|
||||||
|
)}
|
||||||
|
{health.database.error && <p className="text-xs text-red-400">{health.database.error}</p>}
|
||||||
|
</HealthCard>
|
||||||
|
|
||||||
|
{/* Cache */}
|
||||||
|
<HealthCard title="Cache (Valkey)" status={health.cache.status}>
|
||||||
|
{health.cache.latencyMs !== undefined && (
|
||||||
|
<p className="text-xs text-text-muted">Latency: {health.cache.latencyMs}ms</p>
|
||||||
|
)}
|
||||||
|
{health.cache.error && <p className="text-xs text-red-400">{health.cache.error}</p>}
|
||||||
|
</HealthCard>
|
||||||
|
|
||||||
|
{/* Agent Pool */}
|
||||||
|
<HealthCard title="Agent Pool" status="ok">
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Active sessions: {health.agentPool.activeSessions}
|
||||||
|
</p>
|
||||||
|
</HealthCard>
|
||||||
|
|
||||||
|
{/* Providers */}
|
||||||
|
<HealthCard
|
||||||
|
title="LLM Providers"
|
||||||
|
status={health.providers.some((p) => p.available) ? 'ok' : 'error'}
|
||||||
|
>
|
||||||
|
{health.providers.length === 0 ? (
|
||||||
|
<p className="text-xs text-text-muted">No providers configured</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{health.providers.map((p) => (
|
||||||
|
<li key={p.id} className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-text-secondary">{p.name}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-1.5 py-0.5',
|
||||||
|
p.available ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.available ? `${p.modelCount} models` : 'unavailable'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</HealthCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper Components ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: 'ok' | 'degraded' | 'error' }): React.ReactElement {
|
||||||
|
const map = {
|
||||||
|
ok: 'bg-green-500/20 text-green-400',
|
||||||
|
degraded: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
error: 'bg-red-500/20 text-red-400',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={cn('rounded-full px-2 py-0.5 text-xs font-medium capitalize', map[status])}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthCardProps {
|
||||||
|
title: string;
|
||||||
|
status: 'ok' | 'error';
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthCard({ title, status, children }: HealthCardProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
|
||||||
|
<span
|
||||||
|
className={cn('h-2 w-2 rounded-full', status === 'ok' ? 'bg-green-400' : 'bg-red-400')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { getSocket } from '@/lib/socket';
|
import { destroySocket, getSocket } from '@/lib/socket';
|
||||||
import type { Conversation, Message } from '@/lib/types';
|
import type { Conversation, Message } from '@/lib/types';
|
||||||
import { ConversationList } from '@/components/chat/conversation-list';
|
import { ConversationList } from '@/components/chat/conversation-list';
|
||||||
import { MessageBubble } from '@/components/chat/message-bubble';
|
import { MessageBubble } from '@/components/chat/message-bubble';
|
||||||
@@ -17,6 +17,15 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Track the active conversation ID in a ref so socket event handlers always
|
||||||
|
// see the current value without needing to be re-registered.
|
||||||
|
const activeIdRef = useRef<string | null>(null);
|
||||||
|
activeIdRef.current = activeId;
|
||||||
|
|
||||||
|
// Accumulate streamed text in a ref so agent:end can read the full content
|
||||||
|
// without stale-closure issues.
|
||||||
|
const streamingTextRef = useRef('');
|
||||||
|
|
||||||
// Load conversations on mount
|
// Load conversations on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<Conversation[]>('/api/conversations')
|
api<Conversation[]>('/api/conversations')
|
||||||
@@ -30,6 +39,10 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Clear streaming state when switching conversations
|
||||||
|
setIsStreaming(false);
|
||||||
|
setStreamingText('');
|
||||||
|
streamingTextRef.current = '';
|
||||||
api<Message[]>(`/api/conversations/${activeId}/messages`)
|
api<Message[]>(`/api/conversations/${activeId}/messages`)
|
||||||
.then(setMessages)
|
.then(setMessages)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -40,50 +53,81 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages, streamingText]);
|
}, [messages, streamingText]);
|
||||||
|
|
||||||
// Socket.io setup
|
// Socket.io setup — connect once for the page lifetime
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
socket.connect();
|
|
||||||
|
|
||||||
socket.on('agent:text', (data: { conversationId: string; text: string }) => {
|
function onAgentStart(data: { conversationId: string }): void {
|
||||||
setStreamingText((prev) => prev + data.text);
|
// Only update state if the event belongs to the currently viewed conversation
|
||||||
});
|
if (activeIdRef.current !== data.conversationId) return;
|
||||||
|
|
||||||
socket.on('agent:start', () => {
|
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
});
|
streamingTextRef.current = '';
|
||||||
|
}
|
||||||
|
|
||||||
socket.on('agent:end', (data: { conversationId: string }) => {
|
function onAgentText(data: { conversationId: string; text: string }): void {
|
||||||
|
if (activeIdRef.current !== data.conversationId) return;
|
||||||
|
streamingTextRef.current += data.text;
|
||||||
|
setStreamingText((prev) => prev + data.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAgentEnd(data: { conversationId: string }): void {
|
||||||
|
if (activeIdRef.current !== data.conversationId) return;
|
||||||
|
const finalText = streamingTextRef.current;
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
// Reload messages to get the final persisted version
|
streamingTextRef.current = '';
|
||||||
api<Message[]>(`/api/conversations/${data.conversationId}/messages`)
|
// Append the completed assistant message to the local message list.
|
||||||
.then(setMessages)
|
// The Pi agent session is in-memory so the assistant response is not
|
||||||
.catch(() => {});
|
// persisted to the DB — we build the local UI state instead.
|
||||||
});
|
if (finalText) {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `assistant-${Date.now()}`,
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
role: 'assistant' as const,
|
||||||
|
content: finalText,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
socket.on('error', (data: { error: string }) => {
|
function onError(data: { error: string; conversationId?: string }): void {
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
|
streamingTextRef.current = '';
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: `error-${Date.now()}`,
|
id: `error-${Date.now()}`,
|
||||||
conversationId: '',
|
conversationId: data.conversationId ?? '',
|
||||||
role: 'system',
|
role: 'system' as const,
|
||||||
content: `Error: ${data.error}`,
|
content: `Error: ${data.error}`,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
socket.on('agent:start', onAgentStart);
|
||||||
|
socket.on('agent:text', onAgentText);
|
||||||
|
socket.on('agent:end', onAgentEnd);
|
||||||
|
socket.on('error', onError);
|
||||||
|
|
||||||
|
// Connect if not already connected
|
||||||
|
if (!socket.connected) {
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('agent:text');
|
socket.off('agent:start', onAgentStart);
|
||||||
socket.off('agent:start');
|
socket.off('agent:text', onAgentText);
|
||||||
socket.off('agent:end');
|
socket.off('agent:end', onAgentEnd);
|
||||||
socket.off('error');
|
socket.off('error', onError);
|
||||||
socket.disconnect();
|
// Fully tear down the socket when the chat page unmounts so we get a
|
||||||
|
// fresh authenticated connection next time the page is visited.
|
||||||
|
destroySocket();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -97,42 +141,105 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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(
|
const handleSend = useCallback(
|
||||||
async (content: string) => {
|
async (content: string) => {
|
||||||
let convId = activeId;
|
let convId = activeId;
|
||||||
|
|
||||||
// Auto-create conversation if none selected
|
// Auto-create conversation if none selected
|
||||||
if (!convId) {
|
if (!convId) {
|
||||||
|
const autoTitle = content.slice(0, 60);
|
||||||
const conv = await api<Conversation>('/api/conversations', {
|
const conv = await api<Conversation>('/api/conversations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { title: content.slice(0, 50) },
|
body: { title: autoTitle },
|
||||||
});
|
});
|
||||||
setConversations((prev) => [conv, ...prev]);
|
setConversations((prev) => [conv, ...prev]);
|
||||||
setActiveId(conv.id);
|
setActiveId(conv.id);
|
||||||
convId = 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(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimistic user message
|
// Optimistic user message in local UI state
|
||||||
const userMsg: Message = {
|
setMessages((prev) => [
|
||||||
id: `temp-${Date.now()}`,
|
...prev,
|
||||||
conversationId: convId,
|
{
|
||||||
role: 'user',
|
id: `user-${Date.now()}`,
|
||||||
content,
|
conversationId: convId,
|
||||||
createdAt: new Date().toISOString(),
|
role: 'user' as const,
|
||||||
};
|
content,
|
||||||
setMessages((prev) => [...prev, userMsg]);
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// Persist user message
|
// Persist the user message to the DB so conversation history is
|
||||||
await api<Message>(`/api/conversations/${convId}/messages`, {
|
// available when the page is reloaded or a new session starts.
|
||||||
|
api<Message>(`/api/conversations/${convId}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { role: 'user', content },
|
body: { role: 'user', content },
|
||||||
|
}).catch(() => {
|
||||||
|
// Non-fatal: the agent can still process the message even if
|
||||||
|
// REST persistence fails.
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send to WebSocket for streaming response
|
// Send to WebSocket — gateway creates/resumes the agent session and
|
||||||
|
// streams the response back via agent:start / agent:text / agent:end.
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
|
if (!socket.connected) {
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
socket.emit('message', { conversationId: convId, content });
|
socket.emit('message', { conversationId: convId, content });
|
||||||
},
|
},
|
||||||
[activeId],
|
[activeId, conversations, messages],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -142,6 +249,9 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
onSelect={setActiveId}
|
onSelect={setActiveId}
|
||||||
onNew={handleNewConversation}
|
onNew={handleNewConversation}
|
||||||
|
onRename={handleRename}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onArchive={handleArchive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
|
|||||||
338
apps/web/src/app/(dashboard)/projects/[id]/page.tsx
Normal file
338
apps/web/src/app/(dashboard)/projects/[id]/page.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
|
||||||
|
import { MissionTimeline } from '@/components/projects/mission-timeline';
|
||||||
|
import { PrdViewer } from '@/components/projects/prd-viewer';
|
||||||
|
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||||
|
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||||
|
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
active: 'bg-success/20 text-success',
|
||||||
|
paused: 'bg-warning/20 text-warning',
|
||||||
|
completed: 'bg-blue-600/20 text-blue-400',
|
||||||
|
archived: 'bg-gray-600/20 text-gray-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TabButtonProps {
|
||||||
|
id: Tab;
|
||||||
|
label: string;
|
||||||
|
activeTab: Tab;
|
||||||
|
onClick: (tab: Tab) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({ id, label, activeTab, onClick }: TabButtonProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onClick(id)}
|
||||||
|
className={cn(
|
||||||
|
'border-b-2 px-4 py-2 text-sm transition-colors',
|
||||||
|
activeTab === id
|
||||||
|
? 'border-text-primary text-text-primary'
|
||||||
|
: 'border-transparent text-text-muted hover:text-text-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectDetailPage(): React.ReactElement {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const id = typeof params['id'] === 'string' ? params['id'] : '';
|
||||||
|
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [missions, setMissions] = useState<Mission[]>([]);
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||||
|
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
|
||||||
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
api<Project>(`/api/projects/${id}`),
|
||||||
|
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
|
||||||
|
api<Task[]>(`/api/tasks?projectId=${id}`).catch(() => [] as Task[]),
|
||||||
|
])
|
||||||
|
.then(([proj, allMissions, tks]) => {
|
||||||
|
setProject(proj);
|
||||||
|
setMissions(allMissions.filter((m) => m.projectId === id));
|
||||||
|
setTasks(tks);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
setError(err.message ?? 'Failed to load project');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleTaskClick = useCallback((task: Task) => {
|
||||||
|
setSelectedTask(task);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseTaskModal = useCallback(() => {
|
||||||
|
setSelectedTask(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="py-16 text-center">
|
||||||
|
<p className="text-sm text-text-muted">Loading project...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !project) {
|
||||||
|
return (
|
||||||
|
<div className="py-16 text-center">
|
||||||
|
<p className="text-sm text-error">{error ?? 'Project not found'}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/projects')}
|
||||||
|
className="mt-4 text-sm text-text-muted underline hover:text-text-secondary"
|
||||||
|
>
|
||||||
|
Back to projects
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredTasks = taskFilter === 'all' ? tasks : tasks.filter((t) => t.status === taskFilter);
|
||||||
|
|
||||||
|
const prdContent = getPrdContent(project);
|
||||||
|
const hasPrd = Boolean(prdContent);
|
||||||
|
|
||||||
|
const tabs: { id: Tab; label: string }[] = [
|
||||||
|
{ id: 'overview', label: 'Overview' },
|
||||||
|
{ id: 'tasks', label: `Tasks (${tasks.length})` },
|
||||||
|
{ id: 'missions', label: `Missions (${missions.length})` },
|
||||||
|
...(hasPrd ? [{ id: 'prd' as Tab, label: 'PRD' }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/projects')}
|
||||||
|
className="hover:text-text-secondary"
|
||||||
|
>
|
||||||
|
Projects
|
||||||
|
</button>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-text-primary">{project.name}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Project header */}
|
||||||
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-2 py-0.5 text-xs',
|
||||||
|
statusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{project.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{project.description && (
|
||||||
|
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-text-muted">
|
||||||
|
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
|
||||||
|
{new Date(project.updatedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats bar */}
|
||||||
|
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard label="Tasks" value={String(tasks.length)} />
|
||||||
|
<StatCard
|
||||||
|
label="Done"
|
||||||
|
value={String(tasks.filter((t) => t.status === 'done').length)}
|
||||||
|
valueClass="text-success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="In Progress"
|
||||||
|
value={String(tasks.filter((t) => t.status === 'in-progress').length)}
|
||||||
|
valueClass="text-blue-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Blocked"
|
||||||
|
value={String(tasks.filter((t) => t.status === 'blocked').length)}
|
||||||
|
valueClass={tasks.some((t) => t.status === 'blocked') ? 'text-error' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="mb-6 flex gap-0 border-b border-surface-border">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabButton
|
||||||
|
key={tab.id}
|
||||||
|
id={tab.id}
|
||||||
|
label={tab.label}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onClick={setActiveTab}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<OverviewTab project={project} missions={missions} tasks={tasks} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'tasks' && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<TaskStatusSummary
|
||||||
|
tasks={tasks}
|
||||||
|
activeFilter={taskFilter}
|
||||||
|
onFilterChange={setTaskFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TaskListView tasks={filteredTasks} onTaskClick={handleTaskClick} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'missions' && <MissionTimeline missions={missions} />}
|
||||||
|
|
||||||
|
{activeTab === 'prd' && prdContent && (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
|
||||||
|
<PrdViewer content={prdContent} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Task detail modal */}
|
||||||
|
{selectedTask && <TaskDetailModal task={selectedTask} onClose={handleCloseTaskModal} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewTabProps {
|
||||||
|
project: Project;
|
||||||
|
missions: Mission[];
|
||||||
|
tasks: Task[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewTab({ project, missions, tasks }: OverviewTabProps): React.ReactElement {
|
||||||
|
const recentTasks = [...tasks]
|
||||||
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Recent tasks */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
|
||||||
|
{recentTasks.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||||
|
<p className="text-sm text-text-muted">No tasks yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentTasks.map((task) => (
|
||||||
|
<TaskSummaryRow key={task.id} task={task} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Mission summary */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
|
||||||
|
{missions.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||||
|
<p className="text-sm text-text-muted">No missions yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MissionTimeline missions={missions.slice(0, 4)} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
{project.metadata && Object.keys(project.metadata).length > 0 && (
|
||||||
|
<section className="lg:col-span-2">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Project Metadata</h2>
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||||
|
<pre className="overflow-x-auto text-xs text-text-muted">
|
||||||
|
{JSON.stringify(project.metadata, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskStatusColors: Record<string, string> = {
|
||||||
|
'not-started': 'bg-gray-600/20 text-gray-300',
|
||||||
|
'in-progress': 'bg-blue-600/20 text-blue-400',
|
||||||
|
blocked: 'bg-error/20 text-error',
|
||||||
|
done: 'bg-success/20 text-success',
|
||||||
|
cancelled: 'bg-gray-600/20 text-gray-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
function TaskSummaryRow({ task }: { task: Task }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-card px-3 py-2">
|
||||||
|
<span className="truncate text-sm text-text-primary">{task.title}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full px-2 py-0.5 text-xs',
|
||||||
|
taskStatusColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueClass,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
valueClass?: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-3">
|
||||||
|
<p className="text-xs text-text-muted">{label}</p>
|
||||||
|
<p className={cn('mt-1 text-lg font-semibold', valueClass ?? 'text-text-primary')}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrdContent(project: Project): string | null {
|
||||||
|
if (!project.metadata) return null;
|
||||||
|
|
||||||
|
const prd = project.metadata['prd'];
|
||||||
|
if (typeof prd === 'string' && prd.trim().length > 0) return prd;
|
||||||
|
|
||||||
|
const prdContent = project.metadata['prdContent'];
|
||||||
|
if (typeof prdContent === 'string' && prdContent.trim().length > 0) return prdContent;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { Project } from '@/lib/types';
|
import type { Project } from '@/lib/types';
|
||||||
import { ProjectCard } from '@/components/projects/project-card';
|
import { ProjectCard } from '@/components/projects/project-card';
|
||||||
@@ -8,6 +9,7 @@ import { ProjectCard } from '@/components/projects/project-card';
|
|||||||
export default function ProjectsPage(): React.ReactElement {
|
export default function ProjectsPage(): React.ReactElement {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<Project[]>('/api/projects')
|
api<Project[]>('/api/projects')
|
||||||
@@ -16,9 +18,12 @@ export default function ProjectsPage(): React.ReactElement {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleProjectClick = useCallback((project: Project) => {
|
const handleProjectClick = useCallback(
|
||||||
console.log('Project clicked:', project.id);
|
(project: Project) => {
|
||||||
}, []);
|
router.push(`/projects/${project.id}`);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,150 +1,808 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useSession } from '@/lib/auth-client';
|
import { authClient, useSession } from '@/lib/auth-client';
|
||||||
|
|
||||||
interface ProviderInfo {
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
name: string;
|
|
||||||
enabled: boolean;
|
|
||||||
modelCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelInfo {
|
interface ModelInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
|
||||||
provider: string;
|
provider: string;
|
||||||
contextWindow: number;
|
name: string;
|
||||||
reasoning: boolean;
|
reasoning: boolean;
|
||||||
cost: { input: number; output: number };
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestConnectionResult {
|
||||||
|
providerId: string;
|
||||||
|
reachable: boolean;
|
||||||
|
latencyMs?: number;
|
||||||
|
error?: string;
|
||||||
|
discoveredModels?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestState = 'idle' | 'testing' | 'success' | 'error';
|
||||||
|
|
||||||
|
interface ProviderTestStatus {
|
||||||
|
state: TestState;
|
||||||
|
result?: TestConnectionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Preference {
|
||||||
|
key: string;
|
||||||
|
value: unknown;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
type Tab = 'profile' | 'appearance' | 'notifications' | 'providers';
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
|
||||||
|
const p = prefs.find((x) => x.key === key);
|
||||||
|
if (p === undefined) return fallback;
|
||||||
|
return p.value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SettingsPage(): React.ReactElement {
|
export default function SettingsPage(): React.ReactElement {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
||||||
|
const tabs: { id: Tab; label: string }[] = [
|
||||||
|
{ id: 'profile', label: 'Profile' },
|
||||||
|
{ id: 'appearance', label: 'Appearance' },
|
||||||
|
{ id: 'notifications', label: 'Notifications' },
|
||||||
|
{ id: 'providers', label: 'Providers' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex gap-1 border-b border-surface-border">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-b-2 border-accent text-accent'
|
||||||
|
: 'text-text-secondary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'profile' && <ProfileTab session={session} />}
|
||||||
|
{activeTab === 'appearance' && <AppearanceTab />}
|
||||||
|
{activeTab === 'notifications' && <NotificationsTab />}
|
||||||
|
{activeTab === 'providers' && <ProvidersTab />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Profile Tab ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProfileTab({
|
||||||
|
session,
|
||||||
|
}: {
|
||||||
|
session: { user: { id: string; name: string; email: string; image?: string | null } } | null;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const [name, setName] = useState(session?.user.name ?? '');
|
||||||
|
const [image, setImage] = useState(session?.user.image ?? '');
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
|
// Sync from session when it loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.user) {
|
||||||
|
setName(session.user.name ?? '');
|
||||||
|
setImage(session.user.image ?? '');
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const handleSave = async (): Promise<void> => {
|
||||||
|
setSaveState('saving');
|
||||||
|
setErrorMsg('');
|
||||||
|
try {
|
||||||
|
const result = await authClient.updateUser({ name, image: image || null });
|
||||||
|
if (result.error) {
|
||||||
|
setErrorMsg(result.error.message ?? 'Failed to update profile');
|
||||||
|
setSaveState('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaveState('saved');
|
||||||
|
setTimeout(() => setSaveState('idle'), 2000);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
||||||
|
setErrorMsg(message);
|
||||||
|
setSaveState('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-medium text-text-secondary">Profile</h2>
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-4">
|
||||||
|
<FormField label="Display Name" id="profile-name">
|
||||||
|
<input
|
||||||
|
id="profile-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Your name"
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Email" id="profile-email">
|
||||||
|
<input
|
||||||
|
id="profile-email"
|
||||||
|
type="email"
|
||||||
|
value={session?.user.email ?? ''}
|
||||||
|
disabled
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-muted opacity-60 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">Email cannot be changed here.</p>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Avatar URL" id="profile-image">
|
||||||
|
<input
|
||||||
|
id="profile-image"
|
||||||
|
type="url"
|
||||||
|
value={image}
|
||||||
|
onChange={(e) => setImage(e.target.value)}
|
||||||
|
placeholder="https://example.com/avatar.png"
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<SaveButton state={saveState} onClick={handleSave} />
|
||||||
|
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Appearance Tab ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AppearanceTab(): React.ReactElement {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [theme, setTheme] = useState<Theme>('system');
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [defaultModel, setDefaultModel] = useState('');
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
api<Preference[]>('/api/memory/preferences?category=appearance')
|
||||||
api<ProviderInfo[]>('/api/providers').catch(() => []),
|
.catch(() => [] as Preference[])
|
||||||
api<ModelInfo[]>('/api/providers/models').catch(() => []),
|
.then((p) => {
|
||||||
])
|
setTheme(prefValue<Theme>(p, 'ui.theme', 'system'));
|
||||||
.then(([p, m]) => {
|
setSidebarCollapsed(prefValue<boolean>(p, 'ui.sidebar_collapsed', false));
|
||||||
setProviders(p);
|
setDefaultModel(prefValue<string>(p, 'ui.default_model', ''));
|
||||||
setModels(m);
|
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
const handleSave = async (): Promise<void> => {
|
||||||
<div className="mx-auto max-w-3xl space-y-8">
|
setSaveState('saving');
|
||||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
setErrorMsg('');
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
api('/api/memory/preferences', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { key: 'ui.theme', value: theme, category: 'appearance', source: 'user' },
|
||||||
|
}),
|
||||||
|
api('/api/memory/preferences', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
key: 'ui.sidebar_collapsed',
|
||||||
|
value: sidebarCollapsed,
|
||||||
|
category: 'appearance',
|
||||||
|
source: 'user',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(defaultModel
|
||||||
|
? [
|
||||||
|
api('/api/memory/preferences', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
key: 'ui.default_model',
|
||||||
|
value: defaultModel,
|
||||||
|
category: 'appearance',
|
||||||
|
source: 'user',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
setSaveState('saved');
|
||||||
|
setTimeout(() => setSaveState('idle'), 2000);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||||
|
setErrorMsg(message);
|
||||||
|
setSaveState('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
{/* Profile */}
|
if (loading) {
|
||||||
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Profile</h2>
|
<h2 className="mb-4 text-lg font-medium text-text-secondary">Appearance</h2>
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
<p className="text-sm text-text-muted">Loading preferences...</p>
|
||||||
{session?.user ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Field label="Name" value={session.user.name ?? '—'} />
|
|
||||||
<Field label="Email" value={session.user.email} />
|
|
||||||
<Field label="User ID" value={session.user.id} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-text-muted">Not signed in</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Providers */}
|
return (
|
||||||
<section>
|
<section className="space-y-4">
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">LLM Providers</h2>
|
<h2 className="text-lg font-medium text-text-secondary">Appearance</h2>
|
||||||
{loading ? (
|
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
||||||
<p className="text-sm text-text-muted">Loading providers...</p>
|
{/* Theme */}
|
||||||
) : providers.length === 0 ? (
|
<div>
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
<label className="block text-sm font-medium text-text-primary mb-2">Theme</label>
|
||||||
<p className="text-sm text-text-muted">No providers configured</p>
|
<div className="flex gap-3">
|
||||||
</div>
|
{(['system', 'light', 'dark'] as Theme[]).map((t) => (
|
||||||
) : (
|
<button
|
||||||
<div className="space-y-3">
|
key={t}
|
||||||
{providers.map((p) => (
|
type="button"
|
||||||
<div
|
onClick={() => setTheme(t)}
|
||||||
key={p.name}
|
className={`rounded-lg border px-4 py-2 text-sm capitalize transition-colors ${
|
||||||
className="flex items-center justify-between rounded-lg border border-surface-border bg-surface-card p-4"
|
theme === t
|
||||||
|
? 'border-accent bg-accent/10 text-accent'
|
||||||
|
: 'border-surface-border bg-surface-elevated text-text-secondary hover:border-accent/50'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div>
|
{t}
|
||||||
<p className="text-sm font-medium text-text-primary">{p.name}</p>
|
</button>
|
||||||
<p className="text-xs text-text-muted">{p.modelCount} models available</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
|
||||||
p.enabled ? 'bg-success/20 text-success' : 'bg-gray-600/20 text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p.enabled ? 'Active' : 'Disabled'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Models */}
|
{/* Sidebar collapsed default */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-text-primary">Collapse sidebar by default</p>
|
||||||
|
<p className="text-xs text-text-muted">Start with sidebar collapsed on page load</p>
|
||||||
|
</div>
|
||||||
|
<Toggle checked={sidebarCollapsed} onChange={setSidebarCollapsed} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Default model */}
|
||||||
|
<FormField label="Default Model" id="default-model">
|
||||||
|
<input
|
||||||
|
id="default-model"
|
||||||
|
type="text"
|
||||||
|
value={defaultModel}
|
||||||
|
onChange={(e) => setDefaultModel(e.target.value)}
|
||||||
|
placeholder="e.g. ollama/llama3.2"
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">
|
||||||
|
Model ID to pre-select for new conversations.
|
||||||
|
</p>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<SaveButton state={saveState} onClick={handleSave} />
|
||||||
|
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Notifications Tab ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NotificationsTab(): React.ReactElement {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [emailAgentComplete, setEmailAgentComplete] = useState(false);
|
||||||
|
const [emailMentions, setEmailMentions] = useState(true);
|
||||||
|
const [emailDigest, setEmailDigest] = useState(false);
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api<Preference[]>('/api/memory/preferences?category=communication')
|
||||||
|
.catch(() => [] as Preference[])
|
||||||
|
.then((p) => {
|
||||||
|
setEmailAgentComplete(prefValue<boolean>(p, 'notify.email_agent_complete', false));
|
||||||
|
setEmailMentions(prefValue<boolean>(p, 'notify.email_mentions', true));
|
||||||
|
setEmailDigest(prefValue<boolean>(p, 'notify.email_digest', false));
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async (): Promise<void> => {
|
||||||
|
setSaveState('saving');
|
||||||
|
setErrorMsg('');
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
api('/api/memory/preferences', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
key: 'notify.email_agent_complete',
|
||||||
|
value: emailAgentComplete,
|
||||||
|
category: 'communication',
|
||||||
|
source: 'user',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
api('/api/memory/preferences', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
key: 'notify.email_mentions',
|
||||||
|
value: emailMentions,
|
||||||
|
category: 'communication',
|
||||||
|
source: 'user',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
api('/api/memory/preferences', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
key: 'notify.email_digest',
|
||||||
|
value: emailDigest,
|
||||||
|
category: 'communication',
|
||||||
|
source: 'user',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
setSaveState('saved');
|
||||||
|
setTimeout(() => setSaveState('idle'), 2000);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||||
|
setErrorMsg(message);
|
||||||
|
setSaveState('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Available Models</h2>
|
<h2 className="mb-4 text-lg font-medium text-text-secondary">Notifications</h2>
|
||||||
{loading ? (
|
<p className="text-sm text-text-muted">Loading preferences...</p>
|
||||||
<p className="text-sm text-text-muted">Loading models...</p>
|
|
||||||
) : models.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<p className="text-sm text-text-muted">No models available</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-hidden rounded-lg border border-surface-border">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
|
||||||
<th className="px-4 py-2 font-medium">Model</th>
|
|
||||||
<th className="px-4 py-2 font-medium">Provider</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{models.map((m) => (
|
|
||||||
<tr
|
|
||||||
key={`${m.provider}-${m.id}`}
|
|
||||||
className="border-b border-surface-border last:border-b-0"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-2 text-sm text-text-primary">
|
|
||||||
{m.name}
|
|
||||||
{m.reasoning && (
|
|
||||||
<span className="ml-2 text-xs text-purple-400">reasoning</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-text-muted">{m.provider}</td>
|
|
||||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
||||||
{(m.contextWindow / 1000).toFixed(0)}k
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
||||||
${m.cost.input} / ${m.cost.output}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-medium text-text-secondary">Notifications</h2>
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
||||||
|
<p className="text-xs text-text-muted">Configure when you receive email notifications.</p>
|
||||||
|
|
||||||
|
<NotifyRow
|
||||||
|
label="Agent task completed"
|
||||||
|
description="Email when an agent finishes a task"
|
||||||
|
checked={emailAgentComplete}
|
||||||
|
onChange={setEmailAgentComplete}
|
||||||
|
/>
|
||||||
|
<NotifyRow
|
||||||
|
label="Mentions"
|
||||||
|
description="Email when you are mentioned in a conversation"
|
||||||
|
checked={emailMentions}
|
||||||
|
onChange={setEmailMentions}
|
||||||
|
/>
|
||||||
|
<NotifyRow
|
||||||
|
label="Weekly digest"
|
||||||
|
description="Weekly summary of activity"
|
||||||
|
checked={emailDigest}
|
||||||
|
onChange={setEmailDigest}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<SaveButton state={saveState} onClick={handleSave} />
|
||||||
|
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Providers Tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProvidersTab(): React.ReactElement {
|
||||||
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api<ProviderInfo[]>('/api/providers')
|
||||||
|
.catch(() => [] as ProviderInfo[])
|
||||||
|
.then((p) => setProviders(p))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const testConnection = useCallback(async (providerId: string): Promise<void> => {
|
||||||
|
setTestStatuses((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: { state: 'testing' },
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const result = await api<TestConnectionResult>('/api/providers/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { providerId },
|
||||||
|
});
|
||||||
|
setTestStatuses((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: { state: result.reachable ? 'success' : 'error', result },
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
setTestStatuses((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: {
|
||||||
|
state: 'error',
|
||||||
|
result: { providerId, reachable: false, error: 'Request failed' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const defaultModel: ModelInfo | undefined = providers
|
||||||
|
.flatMap((p) => p.models)
|
||||||
|
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-text-muted">Loading providers...</p>
|
||||||
|
) : providers.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
No providers configured. Set{' '}
|
||||||
|
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">OLLAMA_BASE_URL</code>{' '}
|
||||||
|
or{' '}
|
||||||
|
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
||||||
|
MOSAIC_CUSTOM_PROVIDERS
|
||||||
|
</code>{' '}
|
||||||
|
to add providers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<ProviderCard
|
||||||
|
key={provider.id}
|
||||||
|
provider={provider}
|
||||||
|
defaultModel={defaultModel}
|
||||||
|
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
|
||||||
|
onTest={() => void testConnection(provider.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared UI Components ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FormField({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label htmlFor={id} className="block text-sm font-medium text-text-primary">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, value }: { label: string; value: string }): React.ReactElement {
|
function Toggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-surface-card ${
|
||||||
|
checked ? 'bg-accent' : 'bg-surface-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
checked ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotifyRow({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-text-muted">{label}</span>
|
<div>
|
||||||
<span className="text-sm text-text-primary">{value}</span>
|
<p className="text-sm font-medium text-text-primary">{label}</p>
|
||||||
|
<p className="text-xs text-text-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
<Toggle checked={checked} onChange={onChange} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SaveButton({
|
||||||
|
state,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
state: SaveState;
|
||||||
|
onClick: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={state === 'saving'}
|
||||||
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{state === 'saving' ? 'Saving...' : state === 'saved' ? 'Saved!' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provider Card (from original page) ──────────────────────────────────────
|
||||||
|
|
||||||
|
interface ProviderCardProps {
|
||||||
|
provider: ProviderInfo;
|
||||||
|
defaultModel: ModelInfo | undefined;
|
||||||
|
testStatus: ProviderTestStatus;
|
||||||
|
onTest: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderCard({
|
||||||
|
provider,
|
||||||
|
defaultModel,
|
||||||
|
testStatus,
|
||||||
|
onTest,
|
||||||
|
}: ProviderCardProps): React.ReactElement {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card">
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ProviderAvatar id={provider.id} />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-text-primary">{provider.name}</span>
|
||||||
|
<ProviderStatusBadge available={provider.available} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
{provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TestConnectionButton status={testStatus} onTest={onTest} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="rounded px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label={expanded ? 'Collapse models' : 'Expand models'}
|
||||||
|
>
|
||||||
|
{expanded ? '▲ Hide' : '▼ Models'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test result banner */}
|
||||||
|
{testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && (
|
||||||
|
<TestResultBanner result={testStatus.result} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Model list */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-surface-border">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-surface-elevated text-left text-xs text-text-muted">
|
||||||
|
<th className="px-4 py-2 font-medium">Model</th>
|
||||||
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Capabilities</th>
|
||||||
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
|
||||||
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Default</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{provider.models.map((model) => (
|
||||||
|
<ModelRow
|
||||||
|
key={model.id}
|
||||||
|
model={model}
|
||||||
|
isDefault={
|
||||||
|
defaultModel?.id === model.id && defaultModel?.provider === model.provider
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelRowProps {
|
||||||
|
model: ModelInfo;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelRow({ model, isDefault }: ModelRowProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<tr className="border-t border-surface-border">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="text-sm text-text-primary">{model.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="hidden px-4 py-2 md:table-cell">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<CapabilityBadge label="chat" />
|
||||||
|
{model.reasoning && <CapabilityBadge label="reasoning" color="purple" />}
|
||||||
|
{model.inputTypes.includes('image') && <CapabilityBadge label="vision" color="blue" />}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
||||||
|
{formatContext(model.contextWindow)}
|
||||||
|
</td>
|
||||||
|
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
||||||
|
{model.cost.input === 0 && model.cost.output === 0
|
||||||
|
? 'free'
|
||||||
|
: `$${model.cost.input} / $${model.cost.output}`}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
{isDefault && (
|
||||||
|
<span
|
||||||
|
className="inline-block rounded-full bg-accent/20 px-2 py-0.5 text-xs font-medium text-accent"
|
||||||
|
title="Default model used for new sessions"
|
||||||
|
>
|
||||||
|
default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderAvatar({ id }: { id: string }): React.ReactElement {
|
||||||
|
const letter = id.charAt(0).toUpperCase();
|
||||||
|
return (
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-elevated text-sm font-semibold text-text-secondary">
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderStatusBadge({ available }: { available: boolean }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
available ? 'bg-success/20 text-success' : 'bg-surface-elevated text-text-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{available ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestConnectionButtonProps {
|
||||||
|
status: ProviderTestStatus;
|
||||||
|
onTest: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): React.ReactElement {
|
||||||
|
const isTesting = status.state === 'testing';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTest}
|
||||||
|
disabled={isTesting}
|
||||||
|
className="rounded px-2 py-1 text-xs transition-colors hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
title="Test connection"
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<span className="text-text-muted">Testing…</span>
|
||||||
|
) : status.state === 'success' ? (
|
||||||
|
<span className="text-success">✓ Reachable</span>
|
||||||
|
) : status.state === 'error' ? (
|
||||||
|
<span className="text-error">✗ Unreachable</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted">Test</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestResultBanner({ result }: { result: TestConnectionResult }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`px-4 py-2 text-xs ${
|
||||||
|
result.reachable ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{result.reachable ? (
|
||||||
|
<>
|
||||||
|
Connected
|
||||||
|
{result.latencyMs !== undefined && (
|
||||||
|
<span className="ml-1 opacity-70">({result.latencyMs}ms)</span>
|
||||||
|
)}
|
||||||
|
{result.discoveredModels && result.discoveredModels.length > 0 && (
|
||||||
|
<span className="ml-2 opacity-70">
|
||||||
|
— {result.discoveredModels.length} model
|
||||||
|
{result.discoveredModels.length !== 1 ? 's' : ''} discovered
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Connection failed{result.error ? `: ${result.error}` : ''}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapabilityBadge({
|
||||||
|
label,
|
||||||
|
color = 'default',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
color?: 'default' | 'purple' | 'blue';
|
||||||
|
}): React.ReactElement {
|
||||||
|
const colorClass =
|
||||||
|
color === 'purple'
|
||||||
|
? 'bg-purple-500/20 text-purple-400'
|
||||||
|
: color === 'blue'
|
||||||
|
? 'bg-blue-500/20 text-blue-400'
|
||||||
|
: 'bg-surface-elevated text-text-muted';
|
||||||
|
return <span className={`rounded px-1.5 py-0.5 text-xs ${colorClass}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatContext(tokens: number): string {
|
||||||
|
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
||||||
|
return String(tokens);
|
||||||
|
}
|
||||||
|
|||||||
40
apps/web/src/components/admin-role-guard.tsx
Normal file
40
apps/web/src/components/admin-role-guard.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useSession } from '@/lib/auth-client';
|
||||||
|
|
||||||
|
interface AdminRoleGuardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminRoleGuard({ children }: AdminRoleGuardProps): React.ReactElement | null {
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const user = session?.user as
|
||||||
|
| (NonNullable<typeof session>['user'] & { role?: string })
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending && !session) {
|
||||||
|
router.replace('/login');
|
||||||
|
} else if (!isPending && session && user?.role !== 'admin') {
|
||||||
|
router.replace('/');
|
||||||
|
}
|
||||||
|
}, [isPending, session, user?.role, router]);
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-sm text-text-muted">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session || user?.role !== 'admin') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import type { Conversation } from '@/lib/types';
|
import type { Conversation } from '@/lib/types';
|
||||||
|
|
||||||
@@ -8,6 +9,32 @@ interface ConversationListProps {
|
|||||||
activeId: string | null;
|
activeId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
|
onRename: (id: string, title: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onArchive: (id: string, archived: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
conversationId: string;
|
||||||
|
x: number;
|
||||||
|
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();
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationList({
|
export function ConversationList({
|
||||||
@@ -15,43 +42,265 @@ export function ConversationList({
|
|||||||
activeId,
|
activeId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onNew,
|
onNew,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
onArchive,
|
||||||
}: ConversationListProps): React.ReactElement {
|
}: ConversationListProps): React.ReactElement {
|
||||||
return (
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
<div className="flex h-full w-64 flex-col border-r border-surface-border bg-surface-card">
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
<div className="flex items-center justify-between p-3">
|
const [renameValue, setRenameValue] = useState('');
|
||||||
<h2 className="text-sm font-medium text-text-secondary">Conversations</h2>
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
<button
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
type="button"
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
onClick={onNew}
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
className="rounded-md px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-surface-elevated"
|
|
||||||
>
|
|
||||||
+ New
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
const activeConversations = conversations.filter((c) => !c.archived);
|
||||||
{conversations.length === 0 && (
|
const archivedConversations = conversations.filter((c) => c.archived);
|
||||||
<p className="px-3 py-2 text-xs text-text-muted">No conversations yet</p>
|
|
||||||
)}
|
const filteredActive = searchQuery
|
||||||
{conversations.map((conv) => (
|
? activeConversations.filter((c) =>
|
||||||
|
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
: activeConversations;
|
||||||
|
|
||||||
|
const filteredArchived = searchQuery
|
||||||
|
? archivedConversations.filter((c) =>
|
||||||
|
(c.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 });
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeContextMenu = useCallback(() => {
|
||||||
|
setContextMenu(null);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startRename = useCallback(
|
||||||
|
(id: string, currentTitle: string | null) => {
|
||||||
|
setRenamingId(id);
|
||||||
|
setRenameValue(currentTitle ?? '');
|
||||||
|
closeContextMenu();
|
||||||
|
setTimeout(() => renameInputRef.current?.focus(), 0);
|
||||||
|
},
|
||||||
|
[closeContextMenu],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitRename = useCallback(() => {
|
||||||
|
if (renamingId) {
|
||||||
|
const trimmed = renameValue.trim();
|
||||||
|
onRename(renamingId, trimmed || 'Untitled');
|
||||||
|
}
|
||||||
|
setRenamingId(null);
|
||||||
|
setRenameValue('');
|
||||||
|
}, [renamingId, renameValue, onRename]);
|
||||||
|
|
||||||
|
const cancelRename = useCallback(() => {
|
||||||
|
setRenamingId(null);
|
||||||
|
setRenameValue('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRenameKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') commitRename();
|
||||||
|
if (e.key === 'Escape') cancelRename();
|
||||||
|
},
|
||||||
|
[commitRename, cancelRename],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteClick = useCallback((id: string) => {
|
||||||
|
setDeleteConfirmId(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmDelete = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
onDelete(id);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
closeContextMenu();
|
||||||
|
},
|
||||||
|
[onDelete, closeContextMenu],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleArchiveToggle = useCallback(
|
||||||
|
(id: string, archived: boolean) => {
|
||||||
|
onArchive(id, archived);
|
||||||
|
closeContextMenu();
|
||||||
|
},
|
||||||
|
[onArchive, closeContextMenu],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextConv = contextMenu
|
||||||
|
? conversations.find((c) => c.id === contextMenu.conversationId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function renderConversationItem(conv: Conversation): React.ReactElement {
|
||||||
|
const isActive = activeId === conv.id;
|
||||||
|
const isRenaming = renamingId === conv.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={conv.id} className="group relative">
|
||||||
|
{isRenaming ? (
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
ref={renameInputRef}
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.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"
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
key={conv.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(conv.id)}
|
onClick={() => onSelect(conv.id)}
|
||||||
|
onDoubleClick={() => startRename(conv.id, conv.title)}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, conv.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full px-3 py-2 text-left text-sm transition-colors',
|
'w-full px-3 py-2 text-left text-sm transition-colors',
|
||||||
activeId === conv.id
|
isActive
|
||||||
? 'bg-blue-600/20 text-blue-400'
|
? 'bg-blue-600/20 text-blue-400'
|
||||||
: 'text-text-secondary hover:bg-surface-elevated',
|
: 'text-text-secondary hover:bg-surface-elevated',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="block truncate">{conv.title ?? 'Untitled'}</span>
|
<span className="block truncate">{conv.title ?? 'Untitled'}</span>
|
||||||
<span className="block text-xs text-text-muted">
|
<span className="block text-xs text-text-muted">
|
||||||
{new Date(conv.updatedAt).toLocaleDateString()}
|
{formatRelativeTime(conv.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop to close context menu */}
|
||||||
|
{contextMenu && (
|
||||||
|
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNew}
|
||||||
|
className="rounded-md px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-surface-elevated"
|
||||||
|
>
|
||||||
|
+ New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="px-3 pb-2">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</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}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{filteredActive.map((conv) => renderConversationItem(conv))}
|
||||||
|
|
||||||
|
{/* Archived section */}
|
||||||
|
{archivedConversations.length > 0 && (
|
||||||
|
<div className="mt-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"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block transition-transform',
|
||||||
|
showArchived ? 'rotate-90' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
►
|
||||||
|
</span>
|
||||||
|
Archived ({archivedConversations.length})
|
||||||
|
</button>
|
||||||
|
{showArchived && (
|
||||||
|
<div className="opacity-60">
|
||||||
|
{filteredArchived.map((conv) => renderConversationItem(conv))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context menu */}
|
||||||
|
{contextMenu && contextConv && (
|
||||||
|
<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 }}
|
||||||
|
>
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
{contextConv.archived ? 'Unarchive' : '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>
|
||||||
|
) : (
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,22 @@ interface StreamingMessageProps {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StreamingMessage({ text }: StreamingMessageProps): React.ReactElement | null {
|
export function StreamingMessage({ text }: StreamingMessageProps): React.ReactElement {
|
||||||
if (!text) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start">
|
<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-[75%] rounded-xl border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-primary">
|
||||||
<div className="whitespace-pre-wrap break-words">{text}</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>
|
||||||
|
)}
|
||||||
<div className="mt-1 flex items-center gap-1 text-xs text-text-muted">
|
<div className="mt-1 flex items-center gap-1 text-xs text-text-muted">
|
||||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" />
|
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
||||||
Thinking...
|
{text ? 'Responding...' : 'Thinking...'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
92
apps/web/src/components/projects/mission-timeline.tsx
Normal file
92
apps/web/src/components/projects/mission-timeline.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import type { Mission, MissionStatus } from '@/lib/types';
|
||||||
|
|
||||||
|
interface MissionTimelineProps {
|
||||||
|
missions: Mission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOrder: MissionStatus[] = ['planning', 'active', 'paused', 'completed', 'failed'];
|
||||||
|
|
||||||
|
const statusStyles: Record<MissionStatus, { dot: string; label: string; badge: string }> = {
|
||||||
|
planning: {
|
||||||
|
dot: 'bg-gray-400',
|
||||||
|
label: 'text-gray-400',
|
||||||
|
badge: 'bg-gray-600/20 text-gray-300',
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
dot: 'bg-blue-400',
|
||||||
|
label: 'text-blue-400',
|
||||||
|
badge: 'bg-blue-600/20 text-blue-400',
|
||||||
|
},
|
||||||
|
paused: {
|
||||||
|
dot: 'bg-warning',
|
||||||
|
label: 'text-warning',
|
||||||
|
badge: 'bg-warning/20 text-warning',
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
dot: 'bg-success',
|
||||||
|
label: 'text-success',
|
||||||
|
badge: 'bg-success/20 text-success',
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
dot: 'bg-error',
|
||||||
|
label: 'text-error',
|
||||||
|
badge: 'bg-error/20 text-error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function sortMissions(missions: Mission[]): Mission[] {
|
||||||
|
return [...missions].sort((a, b) => {
|
||||||
|
const aIdx = statusOrder.indexOf(a.status);
|
||||||
|
const bIdx = statusOrder.indexOf(b.status);
|
||||||
|
if (aIdx !== bIdx) return aIdx - bIdx;
|
||||||
|
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MissionTimeline({ missions }: MissionTimelineProps): React.ReactElement {
|
||||||
|
if (missions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<p className="text-sm text-text-muted">No missions for this project</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = sortMissions(missions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol className="relative space-y-0">
|
||||||
|
{sorted.map((mission, idx) => {
|
||||||
|
const styles = statusStyles[mission.status] ?? statusStyles.planning;
|
||||||
|
const isLast = idx === sorted.length - 1;
|
||||||
|
return (
|
||||||
|
<li key={mission.id} className="relative flex gap-4 pb-6 last:pb-0">
|
||||||
|
{/* Vertical line */}
|
||||||
|
{!isLast && <div className="absolute left-2.5 top-5 h-full w-px bg-surface-border" />}
|
||||||
|
{/* Status dot */}
|
||||||
|
<div
|
||||||
|
className={cn('mt-1 h-5 w-5 shrink-0 rounded-full border-2 border-bg', styles.dot)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 rounded-lg border border-surface-border bg-surface-card p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium text-text-primary">{mission.name}</span>
|
||||||
|
<span className={cn('shrink-0 rounded-full px-2 py-0.5 text-xs', styles.badge)}>
|
||||||
|
{mission.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{mission.description && (
|
||||||
|
<p className="mt-1 text-xs text-text-muted">{mission.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-text-muted">
|
||||||
|
Created {new Date(mission.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/web/src/components/projects/prd-viewer.tsx
Normal file
99
apps/web/src/components/projects/prd-viewer.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface PrdViewerProps {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight markdown-to-HTML renderer for PRD content.
|
||||||
|
* Supports headings, bold, italic, inline code, code blocks, and lists.
|
||||||
|
* No external dependency — keeps bundle size minimal.
|
||||||
|
*/
|
||||||
|
function renderMarkdown(md: string): string {
|
||||||
|
let html = md
|
||||||
|
// Escape HTML entities first to prevent XSS
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
// Code blocks (must run before inline code)
|
||||||
|
html = html.replace(/```[\w]*\n?([\s\S]*?)```/g, (_match, code: string) => {
|
||||||
|
return `<pre class="overflow-x-auto rounded-lg bg-surface-elevated p-4 my-4"><code class="text-xs text-text-secondary whitespace-pre">${code.trim()}</code></pre>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Headings
|
||||||
|
html = html.replace(
|
||||||
|
/^#### (.+)$/gm,
|
||||||
|
'<h4 class="text-sm font-semibold text-text-primary mt-4 mb-1">$1</h4>',
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/^### (.+)$/gm,
|
||||||
|
'<h3 class="text-base font-semibold text-text-primary mt-6 mb-2">$1</h3>',
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/^## (.+)$/gm,
|
||||||
|
'<h2 class="text-lg font-semibold text-text-primary mt-8 mb-3">$1</h2>',
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/^# (.+)$/gm,
|
||||||
|
'<h1 class="text-xl font-bold text-text-primary mt-8 mb-4">$1</h1>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Horizontal rule
|
||||||
|
html = html.replace(/^---$/gm, '<hr class="my-6 border-surface-border" />');
|
||||||
|
|
||||||
|
// Unordered list items
|
||||||
|
html = html.replace(
|
||||||
|
/^[-*] (.+)$/gm,
|
||||||
|
'<li class="ml-4 text-sm text-text-secondary list-disc">$1</li>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ordered list items
|
||||||
|
html = html.replace(
|
||||||
|
/^\d+\. (.+)$/gm,
|
||||||
|
'<li class="ml-4 text-sm text-text-secondary list-decimal">$1</li>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bold + italic
|
||||||
|
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||||
|
// Bold
|
||||||
|
html = html.replace(
|
||||||
|
/\*\*(.+?)\*\*/g,
|
||||||
|
'<strong class="font-semibold text-text-primary">$1</strong>',
|
||||||
|
);
|
||||||
|
// Italic
|
||||||
|
html = html.replace(/\*(.+?)\*/g, '<em class="italic text-text-secondary">$1</em>');
|
||||||
|
|
||||||
|
// Inline code
|
||||||
|
html = html.replace(
|
||||||
|
/`([^`]+)`/g,
|
||||||
|
'<code class="rounded bg-surface-elevated px-1 py-0.5 text-xs text-text-secondary">$1</code>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Paragraphs — wrap lines that aren't already wrapped in a block element
|
||||||
|
const lines = html.split('\n');
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
result.push('');
|
||||||
|
} else if (
|
||||||
|
trimmed.startsWith('<h') ||
|
||||||
|
trimmed.startsWith('<pre') ||
|
||||||
|
trimmed.startsWith('<li') ||
|
||||||
|
trimmed.startsWith('<hr')
|
||||||
|
) {
|
||||||
|
result.push(trimmed);
|
||||||
|
} else {
|
||||||
|
result.push(`<p class="text-sm text-text-secondary leading-relaxed my-2">${trimmed}</p>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrdViewer({ content }: PrdViewerProps): React.ReactElement {
|
||||||
|
const html = renderMarkdown(content);
|
||||||
|
|
||||||
|
return <div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
|
}
|
||||||
231
apps/web/src/components/tasks/task-detail-modal.tsx
Normal file
231
apps/web/src/components/tasks/task-detail-modal.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import type { Task } from '@/lib/types';
|
||||||
|
|
||||||
|
interface TaskDetailModalProps {
|
||||||
|
task: Task;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
critical: 'text-error',
|
||||||
|
high: 'text-warning',
|
||||||
|
medium: 'text-blue-400',
|
||||||
|
low: 'text-text-muted',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadgeColors: Record<string, string> = {
|
||||||
|
'not-started': 'bg-gray-600/20 text-gray-300',
|
||||||
|
'in-progress': 'bg-blue-600/20 text-blue-400',
|
||||||
|
blocked: 'bg-error/20 text-error',
|
||||||
|
done: 'bg-success/20 text-success',
|
||||||
|
cancelled: 'bg-gray-600/20 text-gray-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 py-2 text-sm">
|
||||||
|
<span className="w-24 shrink-0 text-text-muted">{label}</span>
|
||||||
|
<span className="text-text-primary">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskDetailModal({ task, onClose }: TaskDetailModalProps): React.ReactElement {
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Trap focus on mount
|
||||||
|
useEffect(() => {
|
||||||
|
dialogRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prLinks = extractPrLinks(task.metadata);
|
||||||
|
|
||||||
|
return (
|
||||||
|
/* backdrop */
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="task-detail-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-surface-border bg-surface-card outline-none"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-3 border-b border-surface-border p-4">
|
||||||
|
<h2 id="task-detail-title" className="text-base font-semibold text-text-primary">
|
||||||
|
{task.title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close task details"
|
||||||
|
className="shrink-0 rounded p-1 text-text-muted hover:text-text-primary"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{/* Badges */}
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-2 py-0.5 text-xs',
|
||||||
|
statusBadgeColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.status}
|
||||||
|
</span>
|
||||||
|
<span className={cn('text-xs', priorityColors[task.priority])}>
|
||||||
|
{task.priority} priority
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{task.description && (
|
||||||
|
<div className="mb-4 rounded-lg bg-surface-elevated p-3">
|
||||||
|
<p className="text-sm text-text-secondary">{task.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="divide-y divide-surface-border rounded-lg border border-surface-border px-3">
|
||||||
|
{task.assignee ? <DetailRow label="Assignee">{task.assignee}</DetailRow> : null}
|
||||||
|
{task.dueDate ? (
|
||||||
|
<DetailRow label="Due date">
|
||||||
|
{new Date(task.dueDate).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</DetailRow>
|
||||||
|
) : null}
|
||||||
|
<DetailRow label="Created">
|
||||||
|
{new Date(task.createdAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Updated">
|
||||||
|
{new Date(task.updatedAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</DetailRow>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{task.tags != null && task.tags.length > 0 ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="mb-2 text-xs text-text-muted">Tags</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{task.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full bg-surface-elevated px-2 py-0.5 text-xs text-text-secondary"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* PR Links */}
|
||||||
|
{prLinks.length > 0 ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="mb-2 text-xs text-text-muted">Pull Requests</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{prLinks.map((link) => (
|
||||||
|
<li key={link.url}>
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-blue-400 underline hover:text-blue-300"
|
||||||
|
>
|
||||||
|
{link.label ?? link.url}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Notes from metadata */}
|
||||||
|
<NotesSection notes={getNotesString(task.metadata)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrLink {
|
||||||
|
url: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPrLinks(metadata: Record<string, unknown> | null): PrLink[] {
|
||||||
|
if (!metadata) return [];
|
||||||
|
|
||||||
|
const raw = metadata['pr_links'] ?? metadata['prLinks'];
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.filter((item): item is PrLink => {
|
||||||
|
if (typeof item === 'string') return true;
|
||||||
|
if (typeof item === 'object' && item !== null && 'url' in item) return true;
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.map((item): PrLink => {
|
||||||
|
if (typeof item === 'string') return { url: item };
|
||||||
|
return item as PrLink;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotesString(metadata: Record<string, unknown> | null): string | null {
|
||||||
|
if (!metadata) return null;
|
||||||
|
const notes = metadata['notes'];
|
||||||
|
if (typeof notes === 'string' && notes.trim().length > 0) return notes;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotesSection({ notes }: { notes: string | null }): React.ReactElement | null {
|
||||||
|
if (!notes) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="mb-2 text-xs text-text-muted">Notes</p>
|
||||||
|
<div className="rounded-lg bg-surface-elevated p-3">
|
||||||
|
<p className="whitespace-pre-wrap text-xs text-text-secondary">{notes}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/web/src/components/tasks/task-status-summary.tsx
Normal file
99
apps/web/src/components/tasks/task-status-summary.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import type { Task, TaskStatus } from '@/lib/types';
|
||||||
|
|
||||||
|
interface TaskStatusSummaryProps {
|
||||||
|
tasks: Task[];
|
||||||
|
activeFilter: TaskStatus | 'all';
|
||||||
|
onFilterChange: (filter: TaskStatus | 'all') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: {
|
||||||
|
id: TaskStatus | 'all';
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
activeColor: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
id: 'all',
|
||||||
|
label: 'All',
|
||||||
|
color: 'text-text-muted',
|
||||||
|
activeColor: 'bg-surface-elevated text-text-primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'not-started',
|
||||||
|
label: 'Not Started',
|
||||||
|
color: 'text-gray-400',
|
||||||
|
activeColor: 'bg-gray-600/20 text-gray-300',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'in-progress',
|
||||||
|
label: 'In Progress',
|
||||||
|
color: 'text-blue-400',
|
||||||
|
activeColor: 'bg-blue-600/20 text-blue-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blocked',
|
||||||
|
label: 'Blocked',
|
||||||
|
color: 'text-error',
|
||||||
|
activeColor: 'bg-error/20 text-error',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'done',
|
||||||
|
label: 'Done',
|
||||||
|
color: 'text-success',
|
||||||
|
activeColor: 'bg-success/20 text-success',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TaskStatusSummary({
|
||||||
|
tasks,
|
||||||
|
activeFilter,
|
||||||
|
onFilterChange,
|
||||||
|
}: TaskStatusSummaryProps): React.ReactElement {
|
||||||
|
const counts: Record<TaskStatus | 'all', number> = {
|
||||||
|
all: tasks.length,
|
||||||
|
'not-started': 0,
|
||||||
|
'in-progress': 0,
|
||||||
|
blocked: 0,
|
||||||
|
done: 0,
|
||||||
|
cancelled: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
counts[task.status] = (counts[task.status] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{statusConfig.map((config) => {
|
||||||
|
const count = counts[config.id];
|
||||||
|
const isActive = activeFilter === config.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={config.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onFilterChange(config.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors',
|
||||||
|
isActive
|
||||||
|
? cn('border-transparent', config.activeColor)
|
||||||
|
: 'border-surface-border text-text-muted hover:border-gray-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{config.label}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-1.5 py-0.5 text-xs font-medium',
|
||||||
|
isActive ? 'bg-black/20' : 'bg-surface-elevated',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
|
import { adminClient } from 'better-auth/client/plugins';
|
||||||
|
|
||||||
export const authClient: ReturnType<typeof createAuthClient> = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
|
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
|
||||||
|
plugins: [adminClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { useSession, signIn, signUp, signOut } = authClient;
|
export const { useSession, signIn, signUp, signOut } = authClient;
|
||||||
|
|||||||
@@ -9,7 +9,24 @@ export function getSocket(): Socket {
|
|||||||
socket = io(`${GATEWAY_URL}/chat`, {
|
socket = io(`${GATEWAY_URL}/chat`, {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset singleton reference when socket is fully closed so the next
|
||||||
|
// getSocket() call creates a fresh instance instead of returning a
|
||||||
|
// closed/dead socket.
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
socket = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tear down the singleton socket and reset the reference. */
|
||||||
|
export function destroySocket(): void {
|
||||||
|
if (socket) {
|
||||||
|
socket.offAny();
|
||||||
|
socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface Conversation {
|
|||||||
userId: string;
|
userId: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
|
archived: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,22 @@ export interface Project {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
ownerId?: string;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mission statuses. */
|
||||||
|
export type MissionStatus = 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
/** Mission returned by the gateway API. */
|
||||||
|
export interface Mission {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: MissionStatus;
|
||||||
|
projectId: string | null;
|
||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
11
apps/web/tsconfig.e2e.json
Normal file
11
apps/web/tsconfig.e2e.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["e2e/**/*.ts", "playwright.config.ts"]
|
||||||
|
}
|
||||||
@@ -12,5 +12,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "e2e", "playwright.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
POSTGRES_DB: mosaic
|
POSTGRES_DB: mosaic
|
||||||
volumes:
|
volumes:
|
||||||
- pg_data:/var/lib/postgresql/data
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
- ./infra/pg-init:/docker-entrypoint-initdb.d:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U mosaic']
|
test: ['CMD-SHELL', 'pg_isready -U mosaic']
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
**ID:** mvp-20260312
|
**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.
|
**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
|
**Phase:** Execution
|
||||||
**Current Milestone:** Phase 5: Remote Control (v0.0.6)
|
**Current Milestone:** Phase 8: Polish & Beta (v0.1.0)
|
||||||
**Progress:** 5 / 8 milestones
|
**Progress:** 8 / 9 milestones
|
||||||
**Status:** active
|
**Status:** active
|
||||||
**Last Updated:** 2026-03-13 UTC
|
**Last Updated:** 2026-03-15 UTC
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
@@ -36,9 +36,10 @@
|
|||||||
| 2 | ms-159 | Phase 2: Agent Layer (v0.0.3) | done | — | — | 2026-03-13 | 2026-03-12 |
|
| 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 |
|
| 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 |
|
| 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) | not-started | — | — | — | — |
|
| 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) | not-started | — | — | — | — |
|
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | done | — | #104 | 2026-03-14 | 2026-03-14 |
|
||||||
| 7 | ms-164 | Phase 7: Polish & Beta (v0.1.0) | not-started | — | — | — | — |
|
| 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 | — | — | — | — |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -68,7 +69,9 @@
|
|||||||
| 7 | claude-opus-4-6 | 2026-03-12 | — | context limit | P2-007 |
|
| 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 |
|
| 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 |
|
| 9 | claude-opus-4-6 | 2026-03-12 | — | context limit | P3-007 |
|
||||||
| 10 | claude-opus-4-6 | 2026-03-13 | — | active | P3-008 |
|
| 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 |
|
||||||
|
|
||||||
## Scratchpad
|
## Scratchpad
|
||||||
|
|
||||||
|
|||||||
141
docs/TASKS.md
141
docs/TASKS.md
@@ -2,67 +2,80 @@
|
|||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
|
||||||
| id | status | milestone | description | pr | notes |
|
| id | status | milestone | description | pr | notes |
|
||||||
| ------ | ----------- | --------- | ------------------------------------------------------------- | --- | ----- |
|
| ------ | ----------- | --------- | ------------------------------------------------------------------- | ---- | ------------- |
|
||||||
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
||||||
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
|
| 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-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-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-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-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 |
|
||||||
| P0-007 | done | Phase 0 | CI pipeline — Woodpecker config | #69 | #7 |
|
| 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-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 |
|
| 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-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-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-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-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 |
|
||||||
| P1-005 | done | Phase 1 | Gateway routes — conversations CRUD + messages | #72 | #14 |
|
| 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-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 |
|
||||||
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
|
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
|
||||||
| P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 |
|
| 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 |
|
| 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-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-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-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-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-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-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 |
|
| 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-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-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-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-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-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-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 |
|
||||||
| P3-007 | done | Phase 3 | Admin panel — user management, RBAC | #89 | #32 |
|
| 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 |
|
| 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-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 |
|
||||||
| P4-002 | done | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 |
|
| 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-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-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 |
|
||||||
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
|
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
|
||||||
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
|
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
|
||||||
| P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 |
|
| P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 |
|
||||||
| P5-001 | not-started | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 |
|
| 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-002 | done | Phase 5 | @mosaic/discord-plugin — Discord bot + channel plugin | #61 | #42 |
|
||||||
| P5-003 | not-started | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
|
| P5-003 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
|
||||||
| P5-004 | not-started | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
|
| P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
|
||||||
| P5-005 | not-started | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | — | #45 |
|
| P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 |
|
||||||
| P6-001 | not-started | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | — | #46 |
|
| P6-001 | done | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | #104 | #46 |
|
||||||
| P6-002 | not-started | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | — | #47 |
|
| P6-002 | done | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | #101 | #47 |
|
||||||
| P6-003 | not-started | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | — | #48 |
|
| P6-003 | done | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | #100 | #48 |
|
||||||
| P6-004 | not-started | Phase 6 | @mosaic/mosaic — install wizard for v1 | — | #49 |
|
| 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-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
|
||||||
| P6-006 | not-started | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
||||||
| P7-001 | not-started | Phase 7 | MCP endpoint hardening — streamable HTTP transport | — | #52 |
|
| P7-009 | done | Phase 7 | Web chat — WebSocket integration, streaming, conversation switching | #136 | #120 W1 done |
|
||||||
| P7-002 | not-started | Phase 7 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
|
| P7-001 | done | Phase 7 | MCP endpoint hardening — streamable HTTP transport | #137 | #52 W1 done |
|
||||||
| P7-003 | not-started | Phase 7 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
| P7-010 | done | Phase 7 | Web conversation management — list, search, rename, delete, archive | #139 | #121 W2 done |
|
||||||
| P7-004 | not-started | Phase 7 | E2E test suite — Playwright critical paths | — | #55 |
|
| P7-015 | done | Phase 7 | Agent tool expansion — file ops, git, shell exec, web fetch | #138 | #126 W2 done |
|
||||||
| P7-005 | not-started | Phase 7 | Performance optimization | — | #56 |
|
| P7-011 | done | Phase 7 | Web project detail views — missions, tasks, PRDs, dashboards | #140 | #122 W3 done |
|
||||||
| P7-006 | not-started | Phase 7 | Documentation — user guide, admin guide, dev guide | — | #57 |
|
| P7-016 | done | Phase 7 | MCP client — gateway connects to external MCP servers as tools | #141 | #127 W3 done |
|
||||||
| P7-007 | not-started | Phase 7 | Bare-metal deployment docs + .env.example | — | #58 |
|
| P7-012 | done | Phase 7 | Web provider management UI — add, configure, test LLM providers | #142 | #123 W4 done |
|
||||||
| P7-008 | not-started | Phase 7 | Beta release gate — v0.1.0 tag | — | #59 |
|
| P7-017 | done | Phase 7 | Agent skill invocation — load and execute skills from catalog | #143 | #128 W4 done |
|
||||||
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
| P7-013 | done | Phase 7 | Web settings persistence — profile, preferences save to DB | #145 | #124 W5 done |
|
||||||
| FIX-02 | not-started | Backlog | TUI agent:end — fix React state updater side-effect | — | #63 |
|
| P7-018 | done | Phase 7 | CLI model/provider switching — --model, --provider, /model in TUI | #144 | #129 W5 done |
|
||||||
| FIX-03 | not-started | Backlog | Agent session — cwd sandbox, system prompt, tool restrictions | — | #64 |
|
| 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 |
|
||||||
|
|||||||
311
docs/guides/admin-guide.md
Normal file
311
docs/guides/admin-guide.md
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
# Mosaic Stack — Admin Guide
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [User Management](#user-management)
|
||||||
|
2. [System Health Monitoring](#system-health-monitoring)
|
||||||
|
3. [Provider Configuration](#provider-configuration)
|
||||||
|
4. [MCP Server Configuration](#mcp-server-configuration)
|
||||||
|
5. [Environment Variables Reference](#environment-variables-reference)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Management
|
||||||
|
|
||||||
|
Admins access user management at `/admin` in the web dashboard. All admin
|
||||||
|
endpoints require a session with `role = admin`.
|
||||||
|
|
||||||
|
### Creating a User
|
||||||
|
|
||||||
|
**Via the web admin panel:**
|
||||||
|
|
||||||
|
1. Navigate to `/admin`.
|
||||||
|
2. Click **Create User**.
|
||||||
|
3. Enter name, email, password, and role (`admin` or `member`).
|
||||||
|
4. Submit.
|
||||||
|
|
||||||
|
**Via the API:**
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/admin/users
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"password": "securepassword",
|
||||||
|
"role": "member"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Passwords are hashed by BetterAuth before storage. Passwords are never stored in
|
||||||
|
plaintext.
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
| Role | Permissions |
|
||||||
|
| -------- | --------------------------------------------------------------------- |
|
||||||
|
| `admin` | Full access: user management, health, all agent tools |
|
||||||
|
| `member` | Standard user access; agent tool set restricted by `AGENT_USER_TOOLS` |
|
||||||
|
|
||||||
|
### Updating a User's Role
|
||||||
|
|
||||||
|
```http
|
||||||
|
PATCH /api/admin/users/:id/role
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "role": "admin" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Banning and Unbanning
|
||||||
|
|
||||||
|
Banned users cannot sign in. Provide an optional reason:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/admin/users/:id/ban
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "reason": "Violated terms of service" }
|
||||||
|
```
|
||||||
|
|
||||||
|
To lift a ban:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/admin/users/:id/unban
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deleting a User
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/admin/users/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
This permanently deletes the user. Related data (sessions, accounts) is
|
||||||
|
cascade-deleted. Conversations and tasks reference the user via `owner_id`
|
||||||
|
which is set to `NULL` on delete (`set null`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System Health Monitoring
|
||||||
|
|
||||||
|
The health endpoint is available to admin users only.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/admin/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Sample response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"database": { "status": "ok", "latencyMs": 2 },
|
||||||
|
"cache": { "status": "ok", "latencyMs": 1 },
|
||||||
|
"agentPool": { "activeSessions": 3 },
|
||||||
|
"providers": [{ "id": "ollama", "name": "ollama", "available": true, "modelCount": 3 }],
|
||||||
|
"checkedAt": "2026-03-15T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` is `ok` when both database and cache pass. It is `degraded` when either
|
||||||
|
service fails.
|
||||||
|
|
||||||
|
The web admin panel at `/admin` polls this endpoint and renders the results in a
|
||||||
|
status dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provider Configuration
|
||||||
|
|
||||||
|
Providers are configured via environment variables and loaded at gateway startup.
|
||||||
|
No restart-free hot reload is supported; the gateway must be restarted after
|
||||||
|
changing provider env vars.
|
||||||
|
|
||||||
|
### Ollama
|
||||||
|
|
||||||
|
Set `OLLAMA_BASE_URL` (or the legacy `OLLAMA_HOST`) to the base URL of your
|
||||||
|
Ollama instance:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
```
|
||||||
|
|
||||||
|
Specify which models to expose (comma-separated):
|
||||||
|
|
||||||
|
```env
|
||||||
|
OLLAMA_MODELS=llama3.2,codellama,mistral
|
||||||
|
```
|
||||||
|
|
||||||
|
Default when unset: `llama3.2,codellama,mistral`.
|
||||||
|
|
||||||
|
The gateway registers Ollama models using the OpenAI-compatible completions API
|
||||||
|
(`/v1/chat/completions`).
|
||||||
|
|
||||||
|
### Custom Providers (OpenAI-compatible APIs)
|
||||||
|
|
||||||
|
Any OpenAI-compatible API (LM Studio, llama.cpp HTTP server, etc.) can be
|
||||||
|
registered via `MOSAIC_CUSTOM_PROVIDERS`. The value is a JSON array:
|
||||||
|
|
||||||
|
```env
|
||||||
|
MOSAIC_CUSTOM_PROVIDERS='[
|
||||||
|
{
|
||||||
|
"id": "lmstudio",
|
||||||
|
"name": "LM Studio",
|
||||||
|
"baseUrl": "http://localhost:1234",
|
||||||
|
"models": ["mistral-7b-instruct"]
|
||||||
|
}
|
||||||
|
]'
|
||||||
|
```
|
||||||
|
|
||||||
|
Each entry must include:
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
| --------- | -------- | ----------------------------------- |
|
||||||
|
| `id` | Yes | Unique provider identifier |
|
||||||
|
| `name` | Yes | Display name |
|
||||||
|
| `baseUrl` | Yes | API base URL (no trailing slash) |
|
||||||
|
| `models` | Yes | Array of model ID strings to expose |
|
||||||
|
| `apiKey` | No | API key if required by the endpoint |
|
||||||
|
|
||||||
|
### Testing Provider Connectivity
|
||||||
|
|
||||||
|
From the web admin panel or settings page, click **Test** next to a provider.
|
||||||
|
This calls:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/agent/providers/:id/test
|
||||||
|
```
|
||||||
|
|
||||||
|
The response includes `reachable`, `latencyMs`, and optionally
|
||||||
|
`discoveredModels`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Server Configuration
|
||||||
|
|
||||||
|
The gateway can connect to external MCP (Model Context Protocol) servers and
|
||||||
|
expose their tools to agent sessions.
|
||||||
|
|
||||||
|
Set `MCP_SERVERS` to a JSON array of server configurations:
|
||||||
|
|
||||||
|
```env
|
||||||
|
MCP_SERVERS='[
|
||||||
|
{
|
||||||
|
"name": "my-tools",
|
||||||
|
"url": "http://localhost:3001/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer my-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]'
|
||||||
|
```
|
||||||
|
|
||||||
|
Each entry:
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
| --------- | -------- | ----------------------------------- |
|
||||||
|
| `name` | Yes | Unique server name |
|
||||||
|
| `url` | Yes | MCP server URL (`/mcp` endpoint) |
|
||||||
|
| `headers` | No | Additional HTTP headers (e.g. auth) |
|
||||||
|
|
||||||
|
On gateway startup, each configured server is connected and its tools are
|
||||||
|
discovered. Tools are bridged into the Pi SDK tool format and become available
|
||||||
|
in agent sessions.
|
||||||
|
|
||||||
|
The gateway itself also exposes an MCP server endpoint at `POST /mcp` for
|
||||||
|
external clients. Authentication requires a valid BetterAuth session (cookie or
|
||||||
|
`Authorization` header).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
### Required
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| -------------------- | ----------------------------------------------------------------------------------------- |
|
||||||
|
| `BETTER_AUTH_SECRET` | Secret key for BetterAuth session signing. Must be set or gateway will not start. |
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection string. Default: `postgresql://mosaic:mosaic@localhost:5433/mosaic` |
|
||||||
|
|
||||||
|
### Gateway
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| --------------------- | ----------------------- | ---------------------------------------------- |
|
||||||
|
| `GATEWAY_PORT` | `4000` | Port the gateway listens on |
|
||||||
|
| `GATEWAY_CORS_ORIGIN` | `http://localhost:3000` | Allowed CORS origin for browser clients |
|
||||||
|
| `BETTER_AUTH_URL` | `http://localhost:4000` | Public URL of the gateway (used by BetterAuth) |
|
||||||
|
|
||||||
|
### SSO (Optional)
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ------------------------- | ------------------------------ |
|
||||||
|
| `AUTHENTIK_CLIENT_ID` | Authentik OAuth2 client ID |
|
||||||
|
| `AUTHENTIK_CLIENT_SECRET` | Authentik OAuth2 client secret |
|
||||||
|
| `AUTHENTIK_ISSUER` | Authentik OIDC issuer URL |
|
||||||
|
|
||||||
|
All three Authentik variables must be set together. If only `AUTHENTIK_CLIENT_ID`
|
||||||
|
is set, a warning is logged and SSO is disabled.
|
||||||
|
|
||||||
|
### Agent
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ------------------------ | --------------- | ------------------------------------------------------- |
|
||||||
|
| `AGENT_FILE_SANDBOX_DIR` | `process.cwd()` | Root directory for file/git/shell tool access |
|
||||||
|
| `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
|
||||||
|
| `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users |
|
||||||
|
|
||||||
|
### Providers
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ------------------------- | ---------------------------- | ------------------------------------------------ |
|
||||||
|
| `OLLAMA_BASE_URL` | — | Ollama API base URL |
|
||||||
|
| `OLLAMA_HOST` | — | Alias for `OLLAMA_BASE_URL` (legacy) |
|
||||||
|
| `OLLAMA_MODELS` | `llama3.2,codellama,mistral` | Comma-separated Ollama model IDs |
|
||||||
|
| `MOSAIC_CUSTOM_PROVIDERS` | — | JSON array of custom OpenAI-compatible providers |
|
||||||
|
|
||||||
|
### Memory and Embeddings
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ----------------------- | --------------------------- | ---------------------------------------------------- |
|
||||||
|
| `OPENAI_API_KEY` | — | API key for OpenAI embedding and summarization calls |
|
||||||
|
| `EMBEDDING_API_URL` | `https://api.openai.com/v1` | Base URL for embedding API |
|
||||||
|
| `EMBEDDING_MODEL` | `text-embedding-3-small` | Embedding model ID |
|
||||||
|
| `SUMMARIZATION_API_URL` | `https://api.openai.com/v1` | Base URL for log summarization API |
|
||||||
|
| `SUMMARIZATION_MODEL` | `gpt-4o-mini` | Model used for log summarization |
|
||||||
|
| `SUMMARIZATION_CRON` | `0 */6 * * *` | Cron schedule for log summarization (every 6 hours) |
|
||||||
|
| `TIER_MANAGEMENT_CRON` | `0 3 * * *` | Cron schedule for log tier management (daily at 3am) |
|
||||||
|
|
||||||
|
### MCP
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ------------- | ------------------------------------------------ |
|
||||||
|
| `MCP_SERVERS` | JSON array of external MCP server configurations |
|
||||||
|
|
||||||
|
### Plugins
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ---------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
|
||||||
|
| `DISCORD_GUILD_ID` | Discord guild/server ID |
|
||||||
|
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:4000`) |
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
|
||||||
|
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
||||||
|
|
||||||
|
### Observability
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ----------------------------- | ----------------------- | -------------------------------- |
|
||||||
|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4318` | OpenTelemetry collector endpoint |
|
||||||
|
| `OTEL_SERVICE_NAME` | `mosaic-gateway` | Service name in traces |
|
||||||
|
|
||||||
|
### Web App
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ------------------------- | ----------------------- | -------------------------------------- |
|
||||||
|
| `NEXT_PUBLIC_GATEWAY_URL` | `http://localhost:4000` | Gateway URL used by the Next.js client |
|
||||||
|
|
||||||
|
### Coordination
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
||||||
|
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |
|
||||||
384
docs/guides/deployment.md
Normal file
384
docs/guides/deployment.md
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
This guide covers deploying Mosaic in two modes: **Docker Compose** (recommended for quick setup) and **bare-metal** (production, full control).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
| Dependency | Minimum version | Notes |
|
||||||
|
| ---------------- | --------------- | ---------------------------------------------- |
|
||||||
|
| Node.js | 22 LTS | Required for ESM + `--experimental-vm-modules` |
|
||||||
|
| pnpm | 9 | `npm install -g pnpm` |
|
||||||
|
| PostgreSQL | 17 | Must have the `pgvector` extension |
|
||||||
|
| Valkey | 8 | Redis-compatible; Redis 7+ also works |
|
||||||
|
| Docker + Compose | v2 | For the Docker Compose path only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Compose Deployment (Quick Start)
|
||||||
|
|
||||||
|
The `docker-compose.yml` at the repository root starts PostgreSQL 17 (with pgvector), Valkey 8, an OpenTelemetry Collector, and Jaeger.
|
||||||
|
|
||||||
|
### 1. Clone and configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> mosaic
|
||||||
|
cd mosaic
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env`. The minimum required change is:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
BETTER_AUTH_SECRET=<output of: openssl rand -base64 32>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start infrastructure services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Services and their ports:
|
||||||
|
|
||||||
|
| Service | Default port |
|
||||||
|
| --------------------- | ------------------------ |
|
||||||
|
| PostgreSQL | `localhost:5433` |
|
||||||
|
| Valkey | `localhost:6380` |
|
||||||
|
| OTEL Collector (HTTP) | `localhost:4318` |
|
||||||
|
| OTEL Collector (gRPC) | `localhost:4317` |
|
||||||
|
| Jaeger UI | `http://localhost:16686` |
|
||||||
|
|
||||||
|
Override host ports via `PG_HOST_PORT` and `VALKEY_HOST_PORT` in `.env` if the defaults conflict.
|
||||||
|
|
||||||
|
### 3. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Initialize the database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaic/db db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Build all packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Start the gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaic/gateway dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Or for production (after build):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node apps/gateway/dist/main.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Start the web app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
pnpm --filter @mosaic/web dev
|
||||||
|
|
||||||
|
# Production (after build)
|
||||||
|
pnpm --filter @mosaic/web start
|
||||||
|
```
|
||||||
|
|
||||||
|
The web app runs on port `3000` by default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bare-Metal Deployment
|
||||||
|
|
||||||
|
Use this path when you want to manage PostgreSQL and Valkey yourself (e.g., existing infrastructure, managed cloud databases).
|
||||||
|
|
||||||
|
### Step 1 — Install system dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Node.js 22 via nvm
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||||
|
nvm install 22
|
||||||
|
nvm use 22
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
npm install -g pnpm
|
||||||
|
|
||||||
|
# PostgreSQL 17 with pgvector (Debian/Ubuntu example)
|
||||||
|
sudo apt-get install -y postgresql-17 postgresql-17-pgvector
|
||||||
|
|
||||||
|
# Valkey
|
||||||
|
# Follow https://valkey.io/download/ for your distribution
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Create the database
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Run as the postgres superuser
|
||||||
|
CREATE USER mosaic WITH PASSWORD 'change-me';
|
||||||
|
CREATE DATABASE mosaic OWNER mosaic;
|
||||||
|
\c mosaic
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3 — Clone and configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> /opt/mosaic
|
||||||
|
cd /opt/mosaic
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `/opt/mosaic/.env`. Required fields:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
DATABASE_URL=postgresql://mosaic:<password>@localhost:5432/mosaic
|
||||||
|
VALKEY_URL=redis://localhost:6379
|
||||||
|
BETTER_AUTH_SECRET=<openssl rand -base64 32>
|
||||||
|
BETTER_AUTH_URL=https://your-domain.example.com
|
||||||
|
GATEWAY_CORS_ORIGIN=https://your-domain.example.com
|
||||||
|
NEXT_PUBLIC_GATEWAY_URL=https://your-domain.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4 — Install dependencies and build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5 — Run database migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaic/db db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6 — Start the gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node apps/gateway/dist/main.js
|
||||||
|
```
|
||||||
|
|
||||||
|
The gateway reads `.env` from the monorepo root automatically (via `dotenv` in `main.ts`).
|
||||||
|
|
||||||
|
### Step 7 — Start the web app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Next.js standalone output
|
||||||
|
node apps/web/.next/standalone/server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
The standalone build is self-contained; it does not require `node_modules` to be present at runtime.
|
||||||
|
|
||||||
|
### Step 8 — Configure a reverse proxy
|
||||||
|
|
||||||
|
#### Nginx example
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# /etc/nginx/sites-available/mosaic
|
||||||
|
|
||||||
|
# Gateway API
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name your-domain.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/your-domain.crt;
|
||||||
|
ssl_certificate_key /etc/ssl/private/your-domain.key;
|
||||||
|
|
||||||
|
# WebSocket support (for chat.gateway.ts / Socket.IO)
|
||||||
|
location /socket.io/ {
|
||||||
|
proxy_pass http://127.0.0.1:4000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# REST + auth
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:4000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Web app (optional — serve on a subdomain or a separate server block)
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name app.your-domain.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/your-domain.crt;
|
||||||
|
ssl_certificate_key /etc/ssl/private/your-domain.key;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Caddy example
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
# /etc/caddy/Caddyfile
|
||||||
|
|
||||||
|
your-domain.example.com {
|
||||||
|
reverse_proxy /socket.io/* localhost:4000 {
|
||||||
|
header_up Upgrade {http.upgrade}
|
||||||
|
header_up Connection {http.connection}
|
||||||
|
}
|
||||||
|
reverse_proxy localhost:4000
|
||||||
|
}
|
||||||
|
|
||||||
|
app.your-domain.example.com {
|
||||||
|
reverse_proxy localhost:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Considerations
|
||||||
|
|
||||||
|
### systemd Services
|
||||||
|
|
||||||
|
Create a service unit for each process.
|
||||||
|
|
||||||
|
**Gateway** — `/etc/systemd/system/mosaic-gateway.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Mosaic Gateway
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=mosaic
|
||||||
|
WorkingDirectory=/opt/mosaic
|
||||||
|
EnvironmentFile=/opt/mosaic/.env
|
||||||
|
ExecStart=/usr/bin/node apps/gateway/dist/main.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web app** — `/etc/systemd/system/mosaic-web.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Mosaic Web App
|
||||||
|
After=network.target mosaic-gateway.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=mosaic
|
||||||
|
WorkingDirectory=/opt/mosaic/apps/web
|
||||||
|
EnvironmentFile=/opt/mosaic/.env
|
||||||
|
ExecStart=/usr/bin/node .next/standalone/server.js
|
||||||
|
Environment=PORT=3000
|
||||||
|
Environment=HOSTNAME=127.0.0.1
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable and start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now mosaic-gateway mosaic-web
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log Management
|
||||||
|
|
||||||
|
Gateway and web app logs go to systemd journal by default. View with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u mosaic-gateway -f
|
||||||
|
journalctl -u mosaic-web -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Rotate logs by configuring `journald` in `/etc/systemd/journald.conf`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
SystemMaxUse=500M
|
||||||
|
MaxRetentionSec=30day
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Checklist
|
||||||
|
|
||||||
|
- Set `BETTER_AUTH_SECRET` to a cryptographically random value (`openssl rand -base64 32`).
|
||||||
|
- Restrict `GATEWAY_CORS_ORIGIN` to your exact frontend origin — do not use `*`.
|
||||||
|
- Run services as a dedicated non-root system user (e.g., `mosaic`).
|
||||||
|
- Firewall: only expose ports 80/443 externally; keep 4000 and 3000 bound to `127.0.0.1`.
|
||||||
|
- Set `AGENT_FILE_SANDBOX_DIR` to a directory outside the application root to prevent agent tools from accessing source code.
|
||||||
|
- If using `AGENT_USER_TOOLS`, enumerate only the tools non-admin users need.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Gateway fails to start — "BETTER_AUTH_SECRET is required"
|
||||||
|
|
||||||
|
`BETTER_AUTH_SECRET` is missing or empty. Set it in `.env` and restart.
|
||||||
|
|
||||||
|
### `DATABASE_URL` connection refused
|
||||||
|
|
||||||
|
Verify PostgreSQL is running and the port matches. The Docker Compose default is `5433`; bare-metal typically uses `5432`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -c '\conninfo'
|
||||||
|
```
|
||||||
|
|
||||||
|
### pgvector extension missing
|
||||||
|
|
||||||
|
```sql
|
||||||
|
\c mosaic
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Valkey / Redis connection refused
|
||||||
|
|
||||||
|
Check the URL in `VALKEY_URL`. The Docker Compose default is port `6380`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli -u "$VALKEY_URL" ping
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket connections fail in production
|
||||||
|
|
||||||
|
Ensure your reverse proxy forwards the `Upgrade` and `Connection` headers. See the Nginx/Caddy examples above.
|
||||||
|
|
||||||
|
### Ollama models not appearing
|
||||||
|
|
||||||
|
Set `OLLAMA_BASE_URL` to the URL where Ollama is running (e.g., `http://localhost:11434`) and set `OLLAMA_MODELS` to a comma-separated list of model IDs you have pulled.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama pull llama3.2
|
||||||
|
```
|
||||||
|
|
||||||
|
### OTEL traces not appearing in Jaeger
|
||||||
|
|
||||||
|
Verify the collector is reachable at `OTEL_EXPORTER_OTLP_ENDPOINT`. With Docker Compose the default is `http://localhost:4318`. Check `docker compose ps` and `docker compose logs otel-collector`.
|
||||||
|
|
||||||
|
### Summarization / embedding features not working
|
||||||
|
|
||||||
|
These features require `OPENAI_API_KEY` to be set, or you must point `SUMMARIZATION_API_URL` / `EMBEDDING_API_URL` to an OpenAI-compatible endpoint (e.g., a local Ollama instance with an embeddings model).
|
||||||
515
docs/guides/dev-guide.md
Normal file
515
docs/guides/dev-guide.md
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
# Mosaic Stack — Developer Guide
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Architecture Overview](#architecture-overview)
|
||||||
|
2. [Local Development Setup](#local-development-setup)
|
||||||
|
3. [Building and Testing](#building-and-testing)
|
||||||
|
4. [Adding New Agent Tools](#adding-new-agent-tools)
|
||||||
|
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||||
|
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||||
|
7. [API Endpoint Reference](#api-endpoint-reference)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
Mosaic Stack is a TypeScript monorepo managed with **pnpm workspaces** and
|
||||||
|
**Turborepo**.
|
||||||
|
|
||||||
|
```
|
||||||
|
mosaic-mono-v1/
|
||||||
|
├── apps/
|
||||||
|
│ ├── gateway/ # NestJS + Fastify API server
|
||||||
|
│ └── web/ # Next.js 16 + React 19 web dashboard
|
||||||
|
├── packages/
|
||||||
|
│ ├── agent/ # Agent session types (shared)
|
||||||
|
│ ├── auth/ # BetterAuth configuration
|
||||||
|
│ ├── brain/ # Structured data layer (projects, tasks, missions)
|
||||||
|
│ ├── cli/ # mosaic CLI and TUI (Ink)
|
||||||
|
│ ├── coord/ # Mission coordination engine
|
||||||
|
│ ├── db/ # Drizzle ORM schema, migrations, client
|
||||||
|
│ ├── design-tokens/ # Shared design system tokens
|
||||||
|
│ ├── log/ # Agent log ingestion and tiering
|
||||||
|
│ ├── memory/ # Preference and insight storage
|
||||||
|
│ ├── mosaic/ # Install wizard and bootstrap utilities
|
||||||
|
│ ├── prdy/ # PRD wizard CLI
|
||||||
|
│ ├── quality-rails/ # Code quality scaffolder CLI
|
||||||
|
│ ├── queue/ # Valkey-backed task queue
|
||||||
|
│ └── types/ # Shared TypeScript types
|
||||||
|
├── docker/ # Dockerfile(s) for containerized deployment
|
||||||
|
├── infra/ # Infra config (OTEL collector, pg-init scripts)
|
||||||
|
├── docker-compose.yml # Local services (Postgres, Valkey, OTEL, Jaeger)
|
||||||
|
└── CLAUDE.md # Project conventions for AI coding agents
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Technology Choices
|
||||||
|
|
||||||
|
| Concern | Technology |
|
||||||
|
| ----------------- | ---------------------------------------- |
|
||||||
|
| API framework | NestJS with Fastify adapter |
|
||||||
|
| Web framework | Next.js 16 (App Router), React 19 |
|
||||||
|
| ORM | Drizzle ORM |
|
||||||
|
| Database | PostgreSQL 17 + pgvector extension |
|
||||||
|
| Auth | BetterAuth |
|
||||||
|
| Agent harness | Pi SDK (`@mariozechner/pi-coding-agent`) |
|
||||||
|
| Queue | Valkey 8 (Redis-compatible) |
|
||||||
|
| Build | pnpm workspaces + Turborepo |
|
||||||
|
| CI | Woodpecker CI |
|
||||||
|
| Observability | OpenTelemetry → Jaeger |
|
||||||
|
| Module resolution | NodeNext (ESM everywhere) |
|
||||||
|
|
||||||
|
### Module System
|
||||||
|
|
||||||
|
All packages use `"type": "module"` and NodeNext resolution. Import paths must
|
||||||
|
include the `.js` extension even when the source file is `.ts`.
|
||||||
|
|
||||||
|
NestJS `@Inject()` decorators must be used explicitly because `tsx`/`esbuild`
|
||||||
|
does not support `emitDecoratorMetadata`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- pnpm 9+
|
||||||
|
- Docker and Docker Compose
|
||||||
|
|
||||||
|
### 1. Clone and Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> mosaic-mono-v1
|
||||||
|
cd mosaic-mono-v1
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start Infrastructure Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts:
|
||||||
|
|
||||||
|
| Service | Port | Description |
|
||||||
|
| ------------------------ | -------------- | -------------------- |
|
||||||
|
| PostgreSQL 17 + pgvector | `5433` (host) | Primary database |
|
||||||
|
| Valkey 8 | `6380` (host) | Queue and cache |
|
||||||
|
| OpenTelemetry Collector | `4317`, `4318` | OTEL gRPC and HTTP |
|
||||||
|
| Jaeger | `16686` | Distributed trace UI |
|
||||||
|
|
||||||
|
### 3. Configure Environment
|
||||||
|
|
||||||
|
Create a `.env` file in the monorepo root:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database (matches docker-compose defaults)
|
||||||
|
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
|
||||||
|
|
||||||
|
# Auth (required — generate a random 32+ char string)
|
||||||
|
BETTER_AUTH_SECRET=change-me-to-a-random-secret
|
||||||
|
|
||||||
|
# Gateway
|
||||||
|
GATEWAY_PORT=4000
|
||||||
|
GATEWAY_CORS_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
|
# Web
|
||||||
|
NEXT_PUBLIC_GATEWAY_URL=http://localhost:4000
|
||||||
|
|
||||||
|
# Optional: Ollama
|
||||||
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
OLLAMA_MODELS=llama3.2
|
||||||
|
```
|
||||||
|
|
||||||
|
The gateway loads `.env` from the monorepo root via `dotenv` at startup
|
||||||
|
(`apps/gateway/src/main.ts`).
|
||||||
|
|
||||||
|
### 4. Push the Database Schema
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaic/db db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies the Drizzle schema directly to the database (development only; use
|
||||||
|
migrations in production).
|
||||||
|
|
||||||
|
### 5. Start the Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaic/gateway exec tsx src/main.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The gateway starts on port `4000` by default.
|
||||||
|
|
||||||
|
### 6. Start the Web App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaic/web dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The web app starts on port `3000` by default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building and Testing
|
||||||
|
|
||||||
|
### TypeScript Typecheck
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs `tsc --noEmit` across all packages in dependency order via Turborepo.
|
||||||
|
|
||||||
|
### Lint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs ESLint across all packages. Config is in `eslint.config.mjs` at the root.
|
||||||
|
|
||||||
|
### Format Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm format:check
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs Prettier in check mode. To auto-fix:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm format
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs Vitest across all packages. The workspace config is at
|
||||||
|
`vitest.workspace.ts`.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Builds all packages and apps in dependency order.
|
||||||
|
|
||||||
|
### Pre-Push Gates (MANDATORY)
|
||||||
|
|
||||||
|
All three must pass before any push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm format:check && pnpm typecheck && pnpm lint
|
||||||
|
```
|
||||||
|
|
||||||
|
A pre-push hook enforces this mechanically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding New Agent Tools
|
||||||
|
|
||||||
|
Agent tools are Pi SDK `ToolDefinition` objects registered in
|
||||||
|
`apps/gateway/src/agent/agent.service.ts`.
|
||||||
|
|
||||||
|
### 1. Create a Tool Factory File
|
||||||
|
|
||||||
|
Add a new file in `apps/gateway/src/agent/tools/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/gateway/src/agent/tools/my-tools.ts
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
|
|
||||||
|
export function createMyTools(): ToolDefinition[] {
|
||||||
|
const myTool: ToolDefinition = {
|
||||||
|
name: 'my_tool_name',
|
||||||
|
label: 'Human Readable Label',
|
||||||
|
description: 'What this tool does.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
input: Type.String({ description: 'The input parameter' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const { input } = params as { input: string };
|
||||||
|
const result = `Processed: ${input}`;
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: result }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [myTool];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Register the Tools in AgentService
|
||||||
|
|
||||||
|
In `apps/gateway/src/agent/agent.service.ts`, import and call your factory
|
||||||
|
alongside the existing tool registrations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createMyTools } from './tools/my-tools.js';
|
||||||
|
|
||||||
|
// Inside the session creation logic where tools are assembled:
|
||||||
|
const tools: ToolDefinition[] = [
|
||||||
|
...createBrainTools(this.brain),
|
||||||
|
...createCoordTools(this.coordService),
|
||||||
|
...createMemoryTools(this.memory, this.embeddingService),
|
||||||
|
...createFileTools(sandboxDir),
|
||||||
|
...createGitTools(sandboxDir),
|
||||||
|
...createShellTools(sandboxDir),
|
||||||
|
...createWebTools(),
|
||||||
|
...createMyTools(), // Add this line
|
||||||
|
...mcpTools,
|
||||||
|
...skillTools,
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Export from the Tools Index
|
||||||
|
|
||||||
|
Add an export to `apps/gateway/src/agent/tools/index.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { createMyTools } from './my-tools.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Typecheck and Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding New MCP Tools
|
||||||
|
|
||||||
|
Mosaic connects to external MCP servers via `McpClientService`. To expose tools
|
||||||
|
from a new MCP server:
|
||||||
|
|
||||||
|
### 1. Run an MCP Server
|
||||||
|
|
||||||
|
Implement a standard MCP server that exposes tools via the streamable HTTP
|
||||||
|
transport or SSE transport. The server must accept connections at a `/mcp`
|
||||||
|
endpoint.
|
||||||
|
|
||||||
|
### 2. Configure `MCP_SERVERS`
|
||||||
|
|
||||||
|
In your `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
MCP_SERVERS='[{"name":"my-server","url":"http://localhost:3001/mcp"}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
With authentication:
|
||||||
|
|
||||||
|
```env
|
||||||
|
MCP_SERVERS='[{"name":"secure-server","url":"http://my-server/mcp","headers":{"Authorization":"Bearer token"}}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Restart the Gateway
|
||||||
|
|
||||||
|
On startup, `McpClientService` (`apps/gateway/src/mcp-client/mcp-client.service.ts`)
|
||||||
|
connects to each configured server, calls `tools/list`, and bridges the results
|
||||||
|
to Pi SDK `ToolDefinition` format. These tools become available in all new agent
|
||||||
|
sessions.
|
||||||
|
|
||||||
|
### Tool Naming
|
||||||
|
|
||||||
|
Bridged MCP tool names are taken directly from the MCP server's tool manifest.
|
||||||
|
Ensure names do not conflict with built-in tools (check
|
||||||
|
`apps/gateway/src/agent/tools/`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema and Migrations
|
||||||
|
|
||||||
|
The schema lives in a single file:
|
||||||
|
`packages/db/src/schema.ts`
|
||||||
|
|
||||||
|
### Schema Overview
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
| -------------------- | ------------------------------------------------- |
|
||||||
|
| `users` | User accounts (BetterAuth-compatible) |
|
||||||
|
| `sessions` | Auth sessions |
|
||||||
|
| `accounts` | OAuth accounts |
|
||||||
|
| `verifications` | Email verification tokens |
|
||||||
|
| `projects` | Project records |
|
||||||
|
| `missions` | Mission records (linked to projects) |
|
||||||
|
| `tasks` | Task records (linked to projects and/or missions) |
|
||||||
|
| `conversations` | Chat conversation metadata |
|
||||||
|
| `messages` | Individual chat messages |
|
||||||
|
| `preferences` | Per-user key-value preference store |
|
||||||
|
| `insights` | Vector-embedded memory insights |
|
||||||
|
| `agent_logs` | Agent interaction logs (hot/warm/cold tiers) |
|
||||||
|
| `skills` | Installed agent skills |
|
||||||
|
| `summarization_jobs` | Log summarization job tracking |
|
||||||
|
|
||||||
|
The `insights` table uses a `vector(1536)` column (pgvector) for semantic search.
|
||||||
|
|
||||||
|
### Development: Push Schema
|
||||||
|
|
||||||
|
Apply schema changes directly to the dev database (no migration files created):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaic/db db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generating Migrations
|
||||||
|
|
||||||
|
For production-safe, versioned changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaic/db db:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a new SQL migration file in `packages/db/drizzle/`.
|
||||||
|
|
||||||
|
### Running Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaic/db db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drizzle Config
|
||||||
|
|
||||||
|
Config is at `packages/db/drizzle.config.ts`. The schema file path and output
|
||||||
|
directory are defined there.
|
||||||
|
|
||||||
|
### Adding a New Table
|
||||||
|
|
||||||
|
1. Add the table definition to `packages/db/src/schema.ts`.
|
||||||
|
2. Export it from `packages/db/src/index.ts`.
|
||||||
|
3. Run `pnpm --filter @mosaic/db db:push` (dev) or
|
||||||
|
`pnpm --filter @mosaic/db db:generate && pnpm --filter @mosaic/db db:migrate`
|
||||||
|
(production).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoint Reference
|
||||||
|
|
||||||
|
All endpoints are served by the gateway at `http://localhost:4000` by default.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Authentication uses BetterAuth session cookies. The auth handler is mounted at
|
||||||
|
`/api/auth/*` via a Fastify low-level hook in
|
||||||
|
`apps/gateway/src/auth/auth.controller.ts`.
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| ------------------------- | ------ | -------------------------------- |
|
||||||
|
| `/api/auth/sign-in/email` | POST | Sign in with email/password |
|
||||||
|
| `/api/auth/sign-up/email` | POST | Register a new account |
|
||||||
|
| `/api/auth/sign-out` | POST | Sign out (clears session cookie) |
|
||||||
|
| `/api/auth/get-session` | GET | Returns the current session |
|
||||||
|
|
||||||
|
### Chat
|
||||||
|
|
||||||
|
WebSocket namespace `/chat` (Socket.IO). Authentication via session cookie.
|
||||||
|
|
||||||
|
Events sent by the client:
|
||||||
|
|
||||||
|
| Event | Payload | Description |
|
||||||
|
| --------- | --------------------------------------------------- | -------------- |
|
||||||
|
| `message` | `{ content, conversationId?, provider?, modelId? }` | Send a message |
|
||||||
|
|
||||||
|
Events emitted by the server:
|
||||||
|
|
||||||
|
| Event | Payload | Description |
|
||||||
|
| ------- | --------------------------- | ---------------------- |
|
||||||
|
| `token` | `{ token, conversationId }` | Streaming token |
|
||||||
|
| `end` | `{ conversationId }` | Stream complete |
|
||||||
|
| `error` | `{ message }` | Error during streaming |
|
||||||
|
|
||||||
|
HTTP endpoints (`apps/gateway/src/chat/chat.controller.ts`):
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth | Description |
|
||||||
|
| -------------------------------------- | ------ | ---- | ------------------------------- |
|
||||||
|
| `/api/chat/conversations` | GET | User | List conversations |
|
||||||
|
| `/api/chat/conversations/:id/messages` | GET | User | Get messages for a conversation |
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
All admin endpoints require `role = admin`.
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| --------------------------------- | ------ | -------------------- |
|
||||||
|
| `GET /api/admin/users` | GET | List all users |
|
||||||
|
| `GET /api/admin/users/:id` | GET | Get a single user |
|
||||||
|
| `POST /api/admin/users` | POST | Create a user |
|
||||||
|
| `PATCH /api/admin/users/:id/role` | PATCH | Update user role |
|
||||||
|
| `POST /api/admin/users/:id/ban` | POST | Ban a user |
|
||||||
|
| `POST /api/admin/users/:id/unban` | POST | Unban a user |
|
||||||
|
| `DELETE /api/admin/users/:id` | DELETE | Delete a user |
|
||||||
|
| `GET /api/admin/health` | GET | System health status |
|
||||||
|
|
||||||
|
### Agent / Providers
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth | Description |
|
||||||
|
| ------------------------------------ | ------ | ---- | ----------------------------------- |
|
||||||
|
| `GET /api/agent/providers` | GET | User | List all providers and their models |
|
||||||
|
| `GET /api/agent/providers/models` | GET | User | List available models |
|
||||||
|
| `POST /api/agent/providers/:id/test` | POST | User | Test provider connectivity |
|
||||||
|
|
||||||
|
### Projects / Brain
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth | Description |
|
||||||
|
| -------------------------------- | ------ | ---- | ---------------- |
|
||||||
|
| `GET /api/brain/projects` | GET | User | List projects |
|
||||||
|
| `POST /api/brain/projects` | POST | User | Create a project |
|
||||||
|
| `GET /api/brain/projects/:id` | GET | User | Get a project |
|
||||||
|
| `PATCH /api/brain/projects/:id` | PATCH | User | Update a project |
|
||||||
|
| `DELETE /api/brain/projects/:id` | DELETE | User | Delete a project |
|
||||||
|
| `GET /api/brain/tasks` | GET | User | List tasks |
|
||||||
|
| `POST /api/brain/tasks` | POST | User | Create a task |
|
||||||
|
| `GET /api/brain/tasks/:id` | GET | User | Get a task |
|
||||||
|
| `PATCH /api/brain/tasks/:id` | PATCH | User | Update a task |
|
||||||
|
| `DELETE /api/brain/tasks/:id` | DELETE | User | Delete a task |
|
||||||
|
|
||||||
|
### Memory / Preferences
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth | Description |
|
||||||
|
| ----------------------------- | ------ | ---- | -------------------- |
|
||||||
|
| `GET /api/memory/preferences` | GET | User | Get user preferences |
|
||||||
|
| `PUT /api/memory/preferences` | PUT | User | Upsert a preference |
|
||||||
|
|
||||||
|
### MCP Server (Gateway-side)
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth | Description |
|
||||||
|
| ----------- | ------ | --------------------------------------------- | ----------------------------- |
|
||||||
|
| `POST /mcp` | POST | User (session cookie or Authorization header) | MCP streamable HTTP transport |
|
||||||
|
| `GET /mcp` | GET | User | MCP SSE stream reconnect |
|
||||||
|
|
||||||
|
### Skills
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth | Description |
|
||||||
|
| ------------------------ | ------ | ----- | --------------------- |
|
||||||
|
| `GET /api/skills` | GET | User | List installed skills |
|
||||||
|
| `POST /api/skills` | POST | Admin | Install a skill |
|
||||||
|
| `PATCH /api/skills/:id` | PATCH | Admin | Update a skill |
|
||||||
|
| `DELETE /api/skills/:id` | DELETE | Admin | Remove a skill |
|
||||||
|
|
||||||
|
### Coord (Mission Coordination)
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth | Description |
|
||||||
|
| ------------------------------- | ------ | ---- | ---------------- |
|
||||||
|
| `GET /api/coord/missions` | GET | User | List missions |
|
||||||
|
| `POST /api/coord/missions` | POST | User | Create a mission |
|
||||||
|
| `GET /api/coord/missions/:id` | GET | User | Get a mission |
|
||||||
|
| `PATCH /api/coord/missions/:id` | PATCH | User | Update a mission |
|
||||||
|
|
||||||
|
### Observability
|
||||||
|
|
||||||
|
OpenTelemetry traces are exported to the OTEL collector (`OTEL_EXPORTER_OTLP_ENDPOINT`).
|
||||||
|
View traces in Jaeger at `http://localhost:16686`.
|
||||||
|
|
||||||
|
Tracing is initialized before NestJS bootstrap in
|
||||||
|
`apps/gateway/src/tracing.ts`. The import order in `apps/gateway/src/main.ts`
|
||||||
|
is intentional: `import './tracing.js'` must come before any NestJS imports.
|
||||||
238
docs/guides/user-guide.md
Normal file
238
docs/guides/user-guide.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# Mosaic Stack — User Guide
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Getting Started](#getting-started)
|
||||||
|
2. [Chat Interface](#chat-interface)
|
||||||
|
3. [Projects](#projects)
|
||||||
|
4. [Tasks](#tasks)
|
||||||
|
5. [Settings](#settings)
|
||||||
|
6. [CLI Usage](#cli-usage)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
Mosaic Stack requires a running gateway. Your administrator provides the URL
|
||||||
|
(default: `http://localhost:4000`) and creates your account.
|
||||||
|
|
||||||
|
### Logging In (Web)
|
||||||
|
|
||||||
|
1. Navigate to the Mosaic web app (default: `http://localhost:3000`).
|
||||||
|
2. You are redirected to `/login` automatically.
|
||||||
|
3. Enter your email and password, then click **Sign in**.
|
||||||
|
4. On success you land on the **Chat** page.
|
||||||
|
|
||||||
|
### Registering an Account
|
||||||
|
|
||||||
|
If self-registration is enabled:
|
||||||
|
|
||||||
|
1. Go to `/register`.
|
||||||
|
2. Enter your name, email, and password.
|
||||||
|
3. Submit. You are signed in and redirected to Chat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chat Interface
|
||||||
|
|
||||||
|
### Sending a Message
|
||||||
|
|
||||||
|
1. Type your message in the input bar at the bottom of the Chat page.
|
||||||
|
2. Press **Enter** to send.
|
||||||
|
3. The assistant response streams in real time. A spinner indicates the agent is
|
||||||
|
processing.
|
||||||
|
|
||||||
|
### Streaming Responses
|
||||||
|
|
||||||
|
Responses appear token by token as the model generates them. You can read the
|
||||||
|
response while it is still being produced. The streaming indicator clears when
|
||||||
|
the response is complete.
|
||||||
|
|
||||||
|
### Conversation Management
|
||||||
|
|
||||||
|
- **New conversation**: Navigate to `/chat` or click **New Chat** in the sidebar.
|
||||||
|
A new conversation ID is created automatically on your first message.
|
||||||
|
- **Resume a conversation**: Conversations are stored server-side. Refresh the
|
||||||
|
page or navigate away and back to continue where you left off. The current
|
||||||
|
conversation ID is shown in the URL.
|
||||||
|
- **Conversation list**: The sidebar shows recent conversations. Click any entry
|
||||||
|
to switch.
|
||||||
|
|
||||||
|
### Model and Provider
|
||||||
|
|
||||||
|
The current model and provider are displayed in the chat header. To change them,
|
||||||
|
use the Settings page (see [Provider Settings](#providers)) or the CLI
|
||||||
|
`/model` and `/provider` commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
Projects group related missions and tasks. Navigate to **Projects** in the
|
||||||
|
sidebar.
|
||||||
|
|
||||||
|
### Creating a Project
|
||||||
|
|
||||||
|
1. Go to `/projects`.
|
||||||
|
2. Click **New Project**.
|
||||||
|
3. Enter a name and optional description.
|
||||||
|
4. Select a status: `active`, `paused`, `completed`, or `archived`.
|
||||||
|
5. Save. The project appears in the list.
|
||||||
|
|
||||||
|
### Viewing a Project
|
||||||
|
|
||||||
|
Click a project card to open its detail view at `/projects/<id>`. From here you
|
||||||
|
can see the project's missions, tasks, and metadata.
|
||||||
|
|
||||||
|
### Managing Tasks within a Project
|
||||||
|
|
||||||
|
Tasks are linked to projects and optionally to missions. See [Tasks](#tasks) for
|
||||||
|
full details. On the project detail page, the task list is filtered to the
|
||||||
|
selected project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
Navigate to **Tasks** in the sidebar to see all tasks across all projects.
|
||||||
|
|
||||||
|
### Task Statuses
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
| ------------- | ------------------------ |
|
||||||
|
| `not-started` | Not yet started |
|
||||||
|
| `in-progress` | Actively being worked on |
|
||||||
|
| `blocked` | Waiting on something |
|
||||||
|
| `done` | Completed |
|
||||||
|
| `cancelled` | No longer needed |
|
||||||
|
|
||||||
|
### Creating a Task
|
||||||
|
|
||||||
|
1. Go to `/tasks`.
|
||||||
|
2. Click **New Task**.
|
||||||
|
3. Enter a title, optional description, and link to a project or mission.
|
||||||
|
4. Set the status and priority.
|
||||||
|
5. Save.
|
||||||
|
|
||||||
|
### Updating a Task
|
||||||
|
|
||||||
|
Click a task to open its detail panel. Edit the fields inline and save.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
Navigate to **Settings** in the sidebar (or `/settings`) to manage your profile,
|
||||||
|
appearance, and providers.
|
||||||
|
|
||||||
|
### Profile Tab
|
||||||
|
|
||||||
|
- **Name**: Display name shown in the UI.
|
||||||
|
- **Email**: Read-only; contact your administrator to change email.
|
||||||
|
- Changes save automatically when you click **Save Profile**.
|
||||||
|
|
||||||
|
### Appearance Tab
|
||||||
|
|
||||||
|
- **Theme**: Choose `light`, `dark`, or `system`.
|
||||||
|
- The theme preference is saved to your account and applies on all devices.
|
||||||
|
|
||||||
|
### Notifications Tab
|
||||||
|
|
||||||
|
Configure notification preferences (future feature; placeholder in the current
|
||||||
|
release).
|
||||||
|
|
||||||
|
### Providers Tab
|
||||||
|
|
||||||
|
View all configured LLM providers and their models.
|
||||||
|
|
||||||
|
- **Test Connection**: Click **Test** next to a provider to check reachability.
|
||||||
|
The result shows latency and discovered models.
|
||||||
|
- Provider configuration is managed by your administrator via environment
|
||||||
|
variables. See the [Admin Guide](./admin-guide.md) for setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
The `mosaic` CLI provides a terminal interface to the same gateway API.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
The CLI ships as part of the `@mosaic/cli` package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the monorepo root
|
||||||
|
pnpm --filter @mosaic/cli build
|
||||||
|
node packages/cli/dist/cli.js --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if installed globally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signing In
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic login --gateway http://localhost:4000 --email you@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
You are prompted for a password if `--password` is not supplied. The session
|
||||||
|
cookie is saved locally and reused on subsequent commands.
|
||||||
|
|
||||||
|
### Launching the TUI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic tui
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
| ----------------------- | ----------------------- | ---------------------------------- |
|
||||||
|
| `--gateway <url>` | `http://localhost:4000` | Gateway URL |
|
||||||
|
| `--conversation <id>` | — | Resume a specific conversation |
|
||||||
|
| `--model <modelId>` | server default | Model to use (e.g. `llama3.2`) |
|
||||||
|
| `--provider <provider>` | server default | Provider (e.g. `ollama`, `openai`) |
|
||||||
|
|
||||||
|
If no valid session exists you are prompted to sign in before the TUI launches.
|
||||||
|
|
||||||
|
### TUI Slash Commands
|
||||||
|
|
||||||
|
Inside the TUI, type a `/` command and press Enter:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| ---------------------- | ------------------------------ |
|
||||||
|
| `/model <modelId>` | Switch to a different model |
|
||||||
|
| `/provider <provider>` | Switch to a different provider |
|
||||||
|
| `/models` | List available models |
|
||||||
|
| `/exit` or `/quit` | Exit the TUI |
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List saved sessions
|
||||||
|
mosaic sessions list
|
||||||
|
|
||||||
|
# Resume a session
|
||||||
|
mosaic sessions resume <sessionId>
|
||||||
|
|
||||||
|
# Destroy a session
|
||||||
|
mosaic sessions destroy <sessionId>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the Mosaic installation wizard
|
||||||
|
mosaic wizard
|
||||||
|
|
||||||
|
# PRD wizard (generate product requirement documents)
|
||||||
|
mosaic prdy
|
||||||
|
|
||||||
|
# Quality rails scaffolder
|
||||||
|
mosaic quality-rails
|
||||||
|
```
|
||||||
98
docs/plans/2026-03-13-gateway-security-hardening.md
Normal file
98
docs/plans/2026-03-13-gateway-security-hardening.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Gateway Security Hardening Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Finish the requested gateway security hardening fixes in the existing `fix/gateway-security` worktree and produce a PR-ready branch.
|
||||||
|
|
||||||
|
**Architecture:** Tighten NestJS gateway boundaries in-place by enforcing auth guards, session validation, ownership checks, DTO validation, and Fastify security defaults. Preserve the current module structure and existing ESM import conventions.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS 11, Fastify, Socket.IO, Better Auth, class-validator, Vitest, pnpm, TypeScript ESM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Reconcile Security Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `apps/gateway/src/chat/__tests__/chat-security.test.ts`
|
||||||
|
- Modify: `apps/gateway/src/__tests__/resource-ownership.test.ts`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
- Encode the requested DTO constraints and socket-auth contract exactly.
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @mosaic/gateway test -- src/chat/__tests__/chat-security.test.ts src/__tests__/resource-ownership.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL on current DTO/helper mismatch.
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
- Update DTO/helper/controller code only where tests prove a gap.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run the same command and require green.
|
||||||
|
|
||||||
|
### Task 2: Align Gateway Runtime Hardening
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `apps/gateway/src/conversations/conversations.dto.ts`
|
||||||
|
- Modify: `apps/gateway/src/chat/chat.dto.ts`
|
||||||
|
- Modify: `apps/gateway/src/chat/chat.gateway-auth.ts`
|
||||||
|
- Modify: `apps/gateway/src/chat/chat.gateway.ts`
|
||||||
|
- Modify: `apps/gateway/src/main.ts`
|
||||||
|
- Modify: `apps/gateway/src/app.module.ts`
|
||||||
|
|
||||||
|
**Step 1: Verify remaining requested deltas**
|
||||||
|
|
||||||
|
- Confirm code matches requested guard, rate limit, helmet, body limit, env validation, and CORS settings.
|
||||||
|
|
||||||
|
**Step 2: Apply minimal patch**
|
||||||
|
|
||||||
|
- Keep changes scoped to requested behavior only.
|
||||||
|
|
||||||
|
**Step 3: Run targeted tests**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @mosaic/gateway test -- src/chat/__tests__/chat-security.test.ts src/__tests__/resource-ownership.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Verification, Review, and Delivery
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `docs/reports/code-review/gateway-security-20260313.md`
|
||||||
|
- Create: `docs/reports/qa/gateway-security-20260313.md`
|
||||||
|
- Modify: `docs/scratchpads/gateway-security-20260313.md`
|
||||||
|
|
||||||
|
**Step 1: Run baseline gates**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Perform manual code review**
|
||||||
|
|
||||||
|
- Record correctness/security/testing/doc findings.
|
||||||
|
|
||||||
|
**Step 3: Commit and publish**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting"
|
||||||
|
git push origin fix/gateway-security
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Open PR and notify**
|
||||||
|
|
||||||
|
- Open PR titled `fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting`
|
||||||
|
- Run `openclaw system event --text "PR ready: mosaic-mono-v1 fix/gateway-security — 7 security fixes" --mode now`
|
||||||
|
- Remove worktree after PR is created.
|
||||||
40
docs/plans/authentik-sso-setup.md
Normal file
40
docs/plans/authentik-sso-setup.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Authentik SSO Setup
|
||||||
|
|
||||||
|
## Create the Authentik application
|
||||||
|
|
||||||
|
1. In Authentik, create an OAuth2/OpenID Provider.
|
||||||
|
2. Create an Application and link it to that provider.
|
||||||
|
3. Copy the generated client ID and client secret.
|
||||||
|
|
||||||
|
## Required environment variables
|
||||||
|
|
||||||
|
Set these values for the gateway/auth runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTHENTIK_CLIENT_ID=your-client-id
|
||||||
|
AUTHENTIK_CLIENT_SECRET=your-client-secret
|
||||||
|
AUTHENTIK_ISSUER=https://authentik.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
`AUTHENTIK_ISSUER` should be the Authentik base URL, for example `https://authentik.example.com`.
|
||||||
|
|
||||||
|
## Redirect URI
|
||||||
|
|
||||||
|
Configure this redirect URI in the Authentik provider/application:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{BETTER_AUTH_URL}/api/auth/callback/authentik
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://mosaic.example.com/api/auth/callback/authentik
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test the flow
|
||||||
|
|
||||||
|
1. Start the gateway with `BETTER_AUTH_URL` and the Authentik environment variables set.
|
||||||
|
2. Open the Mosaic login flow and choose the Authentik provider.
|
||||||
|
3. Complete the Authentik login.
|
||||||
|
4. Confirm the browser returns to Mosaic and a session is created successfully.
|
||||||
23
docs/reports/code-review/gateway-security-20260313.md
Normal file
23
docs/reports/code-review/gateway-security-20260313.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Code Review Report — Gateway Security Hardening
|
||||||
|
|
||||||
|
## Scope Reviewed
|
||||||
|
|
||||||
|
- `apps/gateway/src/chat/chat.gateway-auth.ts`
|
||||||
|
- `apps/gateway/src/chat/chat.gateway.ts`
|
||||||
|
- `apps/gateway/src/conversations/conversations.dto.ts`
|
||||||
|
- `apps/gateway/src/chat/__tests__/chat-security.test.ts`
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
- No blocker findings in the final changed surface.
|
||||||
|
|
||||||
|
## Review Summary
|
||||||
|
|
||||||
|
- Correctness: socket auth helper now returns Better Auth session data unchanged, and gateway disconnects clients whose handshake does not narrow to a valid session payload
|
||||||
|
- Security: conversation role validation now rejects `system`; conversation content ceiling is 32k; chat request ceiling remains 10k
|
||||||
|
- Testing: targeted auth, ownership, and DTO regression tests pass
|
||||||
|
- Quality: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` all pass after the final edits
|
||||||
|
|
||||||
|
## Residual Risk
|
||||||
|
|
||||||
|
- `chat.gateway.ts` uses local narrowing around an `unknown` session result because the requested helper contract intentionally returns `unknown`.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user