Compare commits
4 Commits
v0.0.8
...
0fe2cb79a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fe2cb79a7 | |||
| caf058db0d | |||
| aa93c0c614 | |||
| 180604661e |
129
.env.example
129
.env.example
@@ -1,129 +1,20 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# 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
|
||||||
|
|
||||||
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
|
# Valkey (Redis-compatible, port 6380 avoids conflict with host Redis/Valkey)
|
||||||
# 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 override for the Valkey container (default: 6380)
|
# Docker Compose host port overrides (optional)
|
||||||
|
# PG_HOST_PORT=5433
|
||||||
# VALKEY_HOST_PORT=6380
|
# VALKEY_HOST_PORT=6380
|
||||||
|
|
||||||
|
# OpenTelemetry
|
||||||
# ─── 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
|
|
||||||
|
|
||||||
# Public base URL of the gateway (used by BetterAuth for callback URLs)
|
|
||||||
BETTER_AUTH_URL=http://localhost: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
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||||
|
|
||||||
# Service name shown in traces
|
|
||||||
OTEL_SERVICE_NAME=mosaic-gateway
|
OTEL_SERVICE_NAME=mosaic-gateway
|
||||||
|
|
||||||
|
# Auth (BetterAuth)
|
||||||
|
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
||||||
|
BETTER_AUTH_URL=http://localhost:4000
|
||||||
|
|
||||||
# ─── AI Providers ────────────────────────────────────────────────────────────
|
# Gateway
|
||||||
|
GATEWAY_PORT=4000
|
||||||
# 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 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
npx lint-staged
|
npx lint-staged
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
pnpm typecheck && pnpm lint && pnpm format:check
|
pnpm typecheck && pnpm lint && pnpm format:check
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ pnpm-lock.yaml
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
**/drizzle
|
**/drizzle
|
||||||
**/.next
|
**/.next
|
||||||
.claude/
|
|
||||||
|
|||||||
@@ -1,80 +1,57 @@
|
|||||||
variables:
|
variables:
|
||||||
- &node_image 'node:22-alpine'
|
- &node_image 'node:22-alpine'
|
||||||
- &enable_pnpm 'corepack enable'
|
- &install_deps |
|
||||||
|
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:
|
||||||
- corepack enable
|
- *install_deps
|
||||||
- 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:
|
||||||
- *enable_pnpm
|
- *install_deps
|
||||||
- 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:
|
||||||
- *enable_pnpm
|
- *install_deps
|
||||||
- pnpm lint
|
- pnpm lint
|
||||||
depends_on:
|
depends_on:
|
||||||
- typecheck
|
- install
|
||||||
|
|
||||||
format:
|
format:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *install_deps
|
||||||
- pnpm format:check
|
- pnpm format:check
|
||||||
depends_on:
|
depends_on:
|
||||||
- typecheck
|
- install
|
||||||
|
|
||||||
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:
|
||||||
- *enable_pnpm
|
- *install_deps
|
||||||
- pnpm test
|
- pnpm test
|
||||||
depends_on:
|
depends_on:
|
||||||
- typecheck
|
- install
|
||||||
|
|
||||||
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:
|
||||||
- *enable_pnpm
|
- *install_deps
|
||||||
- pnpm build
|
- pnpm build
|
||||||
depends_on:
|
depends_on:
|
||||||
|
- typecheck
|
||||||
- lint
|
- lint
|
||||||
- format
|
- format
|
||||||
- test
|
- test
|
||||||
|
|||||||
@@ -15,16 +15,12 @@
|
|||||||
"@fastify/helmet": "^13.0.2",
|
"@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",
|
||||||
@@ -43,14 +39,12 @@
|
|||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.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",
|
||||||
|
|||||||
@@ -86,52 +86,4 @@ describe('Resource ownership checks', () => {
|
|||||||
ForbiddenException,
|
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' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
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(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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,18 +2,15 @@ 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, McpClientModule, SkillsModule],
|
imports: [CoordModule],
|
||||||
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
providers: [ProviderService, RoutingService, AgentService],
|
||||||
controllers: [ProvidersController, SessionsController],
|
controllers: [ProvidersController, SessionsController],
|
||||||
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
exports: [AgentService, ProviderService, RoutingService],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
export class AgentModule {}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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,
|
||||||
@@ -14,41 +13,14 @@ 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 {
|
||||||
@@ -61,12 +33,6 @@ 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()
|
||||||
@@ -75,57 +41,21 @@ 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,
|
) {
|
||||||
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
this.customTools = [
|
||||||
) {}
|
...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> {
|
||||||
@@ -150,76 +80,18 @@ 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}, sandbox=${sandboxDir}, tools=${allowedTools === null ? 'all' : allowedTools.join(',') || 'none'})`,
|
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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: allCustomTools,
|
customTools: this.customTools,
|
||||||
resourceLoader,
|
|
||||||
});
|
});
|
||||||
piSession = result.session;
|
piSession = result.session;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -252,9 +124,6 @@ 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);
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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,15 +2,14 @@ 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;
|
||||||
|
|
||||||
onModuleInit(): void {
|
async onModuleInit(): Promise<void> {
|
||||||
const authStorage = AuthStorage.inMemory();
|
const authStorage = AuthStorage.create();
|
||||||
this.registry = new ModelRegistry(authStorage);
|
this.registry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
this.registerOllamaProvider();
|
this.registerOllamaProvider();
|
||||||
@@ -65,63 +64,6 @@ 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,
|
||||||
@@ -150,16 +92,14 @@ export class ProviderService implements OnModuleInit {
|
|||||||
.map((modelId: string) => modelId.trim())
|
.map((modelId: string) => modelId.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
this.registry.registerProvider('ollama', {
|
this.registerCustomProvider({
|
||||||
|
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,7 +3,6 @@ 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)
|
||||||
@@ -23,11 +22,6 @@ 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);
|
||||||
|
|||||||
@@ -12,33 +12,3 @@ 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[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
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];
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
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,7 +1,2 @@
|
|||||||
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';
|
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
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];
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
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) : '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
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];
|
|
||||||
}
|
|
||||||
@@ -14,9 +14,6 @@ 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';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -35,9 +32,6 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
MemoryModule,
|
MemoryModule,
|
||||||
LogModule,
|
LogModule,
|
||||||
SkillsModule,
|
SkillsModule,
|
||||||
PluginModule,
|
|
||||||
McpModule,
|
|
||||||
AdminModule,
|
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -7,17 +7,16 @@ 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();
|
||||||
|
|
||||||
// BetterAuth is mounted at the raw HTTP level via Fastify's onRequest hook,
|
// Use Fastify's addHook to intercept auth requests at the raw HTTP level,
|
||||||
// bypassing NestJS middleware (including CORS). We must set CORS headers
|
// before Fastify's body parser runs. This avoids conflicts with NestJS's
|
||||||
// manually on the raw response before handing off to BetterAuth.
|
// custom content-type parser.
|
||||||
fastify.addHook(
|
fastify.addHook(
|
||||||
'onRequest',
|
'onRequest',
|
||||||
(
|
(
|
||||||
req: { raw: IncomingMessage; url: string; method: string },
|
req: { raw: IncomingMessage; url: string },
|
||||||
reply: { raw: ServerResponse; hijack: () => void },
|
reply: { raw: ServerResponse; hijack: () => void },
|
||||||
done: () => void,
|
done: () => void,
|
||||||
) => {
|
) => {
|
||||||
@@ -26,27 +25,6 @@ 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(() => {
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||||
IsBoolean,
|
|
||||||
IsIn,
|
|
||||||
IsObject,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
IsUUID,
|
|
||||||
MaxLength,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateConversationDto {
|
export class CreateConversationDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -28,10 +20,6 @@ export class UpdateConversationDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
archived?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SendMessageDto {
|
export class SendMessageDto {
|
||||||
|
|||||||
@@ -1,30 +1,17 @@
|
|||||||
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 {
|
||||||
@@ -62,8 +49,6 @@ 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);
|
||||||
@@ -85,121 +70,4 @@ 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,5 +1,3 @@
|
|||||||
// ── File-based coord DTOs (legacy file-system backed) ──
|
|
||||||
|
|
||||||
export interface CoordMissionStatusDto {
|
export interface CoordMissionStatusDto {
|
||||||
mission: {
|
mission: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -49,42 +47,3 @@ 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,6 +1,4 @@
|
|||||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import type { Brain } from '@mosaic/brain';
|
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
|
||||||
import {
|
import {
|
||||||
loadMission,
|
loadMission,
|
||||||
getMissionStatus,
|
getMissionStatus,
|
||||||
@@ -18,8 +16,6 @@ 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);
|
||||||
@@ -74,68 +70,4 @@ 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,10 +1,4 @@
|
|||||||
import {
|
import { Injectable, Logger, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common';
|
||||||
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';
|
||||||
|
|
||||||
@@ -13,7 +7,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(@Inject(SummarizationService) private readonly summarization: SummarizationService) {}
|
constructor(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,
|
||||||
@Inject(EmbeddingService) private readonly embeddings: 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,10 +1,3 @@
|
|||||||
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';
|
||||||
@@ -13,35 +6,18 @@ import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fa
|
|||||||
import helmet from '@fastify/helmet';
|
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');
|
|
||||||
|
|
||||||
if (!process.env['BETTER_AUTH_SECRET']) {
|
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||||
throw new Error('BETTER_AUTH_SECRET is required');
|
throw new Error('BETTER_AUTH_SECRET is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const logger = new Logger('Bootstrap');
|
||||||
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>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
AppModule,
|
AppModule,
|
||||||
new FastifyAdapter({ bodyLimit: 1_048_576 }),
|
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 });
|
await app.register(helmet as never, { contentSecurityPolicy: false });
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
@@ -52,7 +28,6 @@ async function bootstrap(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
mountAuthHandler(app);
|
mountAuthHandler(app);
|
||||||
mountMcpHandler(app, app.get(McpService));
|
|
||||||
|
|
||||||
const port = Number(process.env['GATEWAY_PORT'] ?? 4000);
|
const port = Number(process.env['GATEWAY_PORT'] ?? 4000);
|
||||||
await app.listen(port, '0.0.0.0');
|
await app.listen(port, '0.0.0.0');
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { McpClientService } from './mcp-client.service.js';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [McpClientService],
|
|
||||||
exports: [McpClientService],
|
|
||||||
})
|
|
||||||
export class McpClientModule {}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export const MCP_CLIENT_SERVICE = 'MCP_CLIENT_SERVICE';
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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[];
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export const MCP_SERVICE = 'MCP_SERVICE';
|
|
||||||
@@ -15,7 +15,6 @@ 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';
|
||||||
|
|
||||||
@@ -24,33 +23,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,
|
||||||
@Inject(EmbeddingService) private readonly embeddings: EmbeddingService,
|
private readonly embeddings: EmbeddingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── Preferences ────────────────────────────────────────────────────
|
// ─── Preferences ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Get('preferences')
|
@Get('preferences')
|
||||||
async listPreferences(@CurrentUser() user: { id: string }, @Query('category') category?: string) {
|
async listPreferences(@Query('userId') userId: string, @Query('category') category?: string) {
|
||||||
if (category) {
|
if (category) {
|
||||||
return this.memory.preferences.findByUserAndCategory(
|
return this.memory.preferences.findByUserAndCategory(
|
||||||
user.id,
|
userId,
|
||||||
category as Parameters<typeof this.memory.preferences.findByUserAndCategory>[1],
|
category as Parameters<typeof this.memory.preferences.findByUserAndCategory>[1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.memory.preferences.findByUser(user.id);
|
return this.memory.preferences.findByUser(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('preferences/:key')
|
@Get('preferences/:key')
|
||||||
async getPreference(@CurrentUser() user: { id: string }, @Param('key') key: string) {
|
async getPreference(@Query('userId') userId: string, @Param('key') key: string) {
|
||||||
const pref = await this.memory.preferences.findByUserAndKey(user.id, key);
|
const pref = await this.memory.preferences.findByUserAndKey(userId, 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(@CurrentUser() user: { id: string }, @Body() dto: UpsertPreferenceDto) {
|
async upsertPreference(@Query('userId') userId: string, @Body() dto: UpsertPreferenceDto) {
|
||||||
return this.memory.preferences.upsert({
|
return this.memory.preferences.upsert({
|
||||||
userId: user.id,
|
userId,
|
||||||
key: dto.key,
|
key: dto.key,
|
||||||
value: dto.value,
|
value: dto.value,
|
||||||
category: dto.category,
|
category: dto.category,
|
||||||
@@ -60,16 +59,16 @@ export class MemoryController {
|
|||||||
|
|
||||||
@Delete('preferences/:key')
|
@Delete('preferences/:key')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async removePreference(@CurrentUser() user: { id: string }, @Param('key') key: string) {
|
async removePreference(@Query('userId') userId: string, @Param('key') key: string) {
|
||||||
const deleted = await this.memory.preferences.remove(user.id, key);
|
const deleted = await this.memory.preferences.remove(userId, 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(@CurrentUser() user: { id: string }, @Query('limit') limit?: string) {
|
async listInsights(@Query('userId') userId: string, @Query('limit') limit?: string) {
|
||||||
return this.memory.insights.findByUser(user.id, limit ? Number(limit) : undefined);
|
return this.memory.insights.findByUser(userId, limit ? Number(limit) : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('insights/:id')
|
@Get('insights/:id')
|
||||||
@@ -80,13 +79,13 @@ export class MemoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('insights')
|
@Post('insights')
|
||||||
async createInsight(@CurrentUser() user: { id: string }, @Body() dto: CreateInsightDto) {
|
async createInsight(@Query('userId') userId: 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: user.id,
|
userId,
|
||||||
content: dto.content,
|
content: dto.content,
|
||||||
source: dto.source,
|
source: dto.source,
|
||||||
category: dto.category,
|
category: dto.category,
|
||||||
@@ -105,7 +104,7 @@ export class MemoryController {
|
|||||||
// ─── Search ─────────────────────────────────────────────────────────
|
// ─── Search ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Post('search')
|
@Post('search')
|
||||||
async searchMemory(@CurrentUser() user: { id: string }, @Body() dto: SearchMemoryDto) {
|
async searchMemory(@Query('userId') userId: string, @Body() dto: SearchMemoryDto) {
|
||||||
if (!this.embeddings.available) {
|
if (!this.embeddings.available) {
|
||||||
return {
|
return {
|
||||||
query: dto.query,
|
query: dto.query,
|
||||||
@@ -116,7 +115,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(
|
||||||
user.id,
|
userId,
|
||||||
queryEmbedding,
|
queryEmbedding,
|
||||||
dto.limit ?? 10,
|
dto.limit ?? 10,
|
||||||
dto.maxDistance ?? 0.8,
|
dto.maxDistance ?? 0.8,
|
||||||
|
|||||||
@@ -36,10 +36,7 @@ export class MissionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() dto: CreateMissionDto, @CurrentUser() user: { id: string }) {
|
async create(@Body() dto: CreateMissionDto) {
|
||||||
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,
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export interface IChannelPlugin {
|
|
||||||
readonly name: string;
|
|
||||||
start(): Promise<void>;
|
|
||||||
stop(): Promise<void>;
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export const PLUGIN_REGISTRY = Symbol('PLUGIN_REGISTRY');
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Inject,
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
@@ -19,7 +18,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(@Inject(SkillsService) private readonly skills: SkillsService) {}
|
constructor(private readonly skills: SkillsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list() {
|
async list() {
|
||||||
|
|||||||
@@ -28,48 +28,17 @@ 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) {
|
if (projectId) return this.brain.tasks.findByProject(projectId);
|
||||||
await this.getOwnedProject(projectId, user.id, 'Task');
|
if (missionId) return this.brain.tasks.findByMission(missionId);
|
||||||
return this.brain.tasks.findByProject(projectId);
|
if (status)
|
||||||
}
|
return this.brain.tasks.findByStatus(
|
||||||
if (missionId) {
|
status as Parameters<typeof this.brain.tasks.findByStatus>[0],
|
||||||
await this.getOwnedMission(missionId, user.id, 'Task');
|
);
|
||||||
return this.brain.tasks.findByMission(missionId);
|
return this.brain.tasks.findAll();
|
||||||
}
|
|
||||||
|
|
||||||
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')
|
||||||
@@ -78,13 +47,7 @@ export class TasksController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() dto: CreateTaskDto, @CurrentUser() user: { id: string }) {
|
async create(@Body() dto: CreateTaskDto) {
|
||||||
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,
|
||||||
|
|||||||
@@ -10,9 +10,7 @@
|
|||||||
"@mosaic/db": ["../../packages/db/src/index.ts"],
|
"@mosaic/db": ["../../packages/db/src/index.ts"],
|
||||||
"@mosaic/log": ["../../packages/log/src/index.ts"],
|
"@mosaic/log": ["../../packages/log/src/index.ts"],
|
||||||
"@mosaic/memory": ["../../packages/memory/src/index.ts"],
|
"@mosaic/memory": ["../../packages/memory/src/index.ts"],
|
||||||
"@mosaic/types": ["../../packages/types/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"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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,7 +8,6 @@
|
|||||||
"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": {
|
||||||
@@ -22,7 +21,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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,531 +1,99 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { AdminRoleGuard } from '@/components/admin-role-guard';
|
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
interface SessionInfo {
|
||||||
|
|
||||||
interface UserDto {
|
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
provider: string;
|
||||||
email: string;
|
modelId: string;
|
||||||
role: string;
|
|
||||||
banned: boolean;
|
|
||||||
banReason: string | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
promptCount: number;
|
||||||
|
channels: string[];
|
||||||
|
durationMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserListDto {
|
interface SessionsResponse {
|
||||||
users: UserDto[];
|
sessions: SessionInfo[];
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServiceStatusDto {
|
|
||||||
status: 'ok' | 'error';
|
|
||||||
latencyMs?: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProviderStatusDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
available: boolean;
|
|
||||||
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 {
|
export default function AdminPage(): React.ReactElement {
|
||||||
return (
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||||
<AdminRoleGuard>
|
|
||||||
<AdminContent />
|
|
||||||
</AdminRoleGuard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminContent(): React.ReactElement {
|
|
||||||
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Admin Panel</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-1 border-b border-surface-border">
|
|
||||||
{(['users', 'health'] as const).map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={cn(
|
|
||||||
'px-4 py-2 text-sm font-medium capitalize transition-colors',
|
|
||||||
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>
|
|
||||||
|
|
||||||
{activeTab === 'users' ? <UsersTab /> : <HealthTab />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Users Tab ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function UsersTab(): React.ReactElement {
|
|
||||||
const [users, setUsers] = useState<UserDto[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
void loadUsers();
|
api<SessionsResponse>('/api/sessions')
|
||||||
}, [loadUsers]);
|
.then((res) => setSessions(res.sessions))
|
||||||
|
.catch(() => {})
|
||||||
async function handleRoleToggle(user: UserDto): Promise<void> {
|
.finally(() => setLoading(false));
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="mx-auto max-w-4xl space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<h1 className="text-2xl font-semibold">Admin</h1>
|
||||||
<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 && (
|
{/* User Management placeholder */}
|
||||||
<CreateUserForm
|
<section>
|
||||||
onCancel={() => setShowCreate(false)}
|
<h2 className="mb-4 text-lg font-medium text-text-secondary">User Management</h2>
|
||||||
onCreated={() => {
|
|
||||||
setShowCreate(false);
|
|
||||||
void loadUsers();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{users.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
<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>
|
<p className="text-sm text-text-muted">
|
||||||
</div>
|
User management will be available when the admin API is implemented
|
||||||
) : (
|
|
||||||
<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>
|
</p>
|
||||||
</HealthCard>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Providers */}
|
{/* Active Agent Sessions */}
|
||||||
<HealthCard
|
<section>
|
||||||
title="LLM Providers"
|
<h2 className="mb-4 text-lg font-medium text-text-secondary">Active Agent Sessions</h2>
|
||||||
status={health.providers.some((p) => p.available) ? 'ok' : 'error'}
|
{loading ? (
|
||||||
>
|
<p className="text-sm text-text-muted">Loading sessions...</p>
|
||||||
{health.providers.length === 0 ? (
|
) : sessions.length === 0 ? (
|
||||||
<p className="text-xs text-text-muted">No providers configured</p>
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||||
) : (
|
<p className="text-sm text-text-muted">No active sessions</p>
|
||||||
<ul className="space-y-1">
|
</div>
|
||||||
{health.providers.map((p) => (
|
) : (
|
||||||
<li key={p.id} className="flex items-center justify-between text-xs">
|
<div className="overflow-hidden rounded-lg border border-surface-border">
|
||||||
<span className="text-text-secondary">{p.name}</span>
|
<table className="w-full">
|
||||||
<span
|
<thead>
|
||||||
className={cn(
|
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
||||||
'rounded-full px-1.5 py-0.5',
|
<th className="px-4 py-2 font-medium">Session ID</th>
|
||||||
p.available ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400',
|
<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>
|
||||||
{p.available ? `${p.modelCount} models` : 'unavailable'}
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Duration</th>
|
||||||
</span>
|
</tr>
|
||||||
</li>
|
</thead>
|
||||||
))}
|
<tbody>
|
||||||
</ul>
|
{sessions.map((s) => (
|
||||||
)}
|
<tr key={s.id} className="border-b border-surface-border last:border-b-0">
|
||||||
</HealthCard>
|
<td className="max-w-[200px] truncate px-4 py-2 font-mono text-xs text-text-primary">
|
||||||
</div>
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helper Components ─────────────────────────────────────────────────────────
|
function formatDuration(ms: number): string {
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
function StatusBadge({ status }: { status: 'ok' | 'degraded' | 'error' }): React.ReactElement {
|
if (seconds < 60) return `${seconds}s`;
|
||||||
const map = {
|
const minutes = Math.floor(seconds / 60);
|
||||||
ok: 'bg-green-500/20 text-green-400',
|
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
||||||
degraded: 'bg-yellow-500/20 text-yellow-400',
|
const hours = Math.floor(minutes / 60);
|
||||||
error: 'bg-red-500/20 text-red-400',
|
return `${hours}h ${minutes % 60}m`;
|
||||||
};
|
|
||||||
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 { destroySocket, getSocket } from '@/lib/socket';
|
import { 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,15 +17,6 @@ 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')
|
||||||
@@ -39,10 +30,6 @@ 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(() => {});
|
||||||
@@ -53,81 +40,50 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages, streamingText]);
|
}, [messages, streamingText]);
|
||||||
|
|
||||||
// Socket.io setup — connect once for the page lifetime
|
// Socket.io setup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
|
socket.connect();
|
||||||
|
|
||||||
function onAgentStart(data: { conversationId: string }): void {
|
socket.on('agent:text', (data: { conversationId: string; text: string }) => {
|
||||||
// Only update state if the event belongs to the currently viewed conversation
|
setStreamingText((prev) => prev + data.text);
|
||||||
if (activeIdRef.current !== data.conversationId) return;
|
});
|
||||||
|
|
||||||
|
socket.on('agent:start', () => {
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
streamingTextRef.current = '';
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function onAgentText(data: { conversationId: string; text: string }): void {
|
socket.on('agent:end', (data: { conversationId: string }) => {
|
||||||
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('');
|
||||||
streamingTextRef.current = '';
|
// Reload messages to get the final persisted version
|
||||||
// Append the completed assistant message to the local message list.
|
api<Message[]>(`/api/conversations/${data.conversationId}/messages`)
|
||||||
// The Pi agent session is in-memory so the assistant response is not
|
.then(setMessages)
|
||||||
// persisted to the DB — we build the local UI state instead.
|
.catch(() => {});
|
||||||
if (finalText) {
|
});
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: `assistant-${Date.now()}`,
|
|
||||||
conversationId: data.conversationId,
|
|
||||||
role: 'assistant' as const,
|
|
||||||
content: finalText,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onError(data: { error: string; conversationId?: string }): void {
|
socket.on('error', (data: { error: string }) => {
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
streamingTextRef.current = '';
|
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: `error-${Date.now()}`,
|
id: `error-${Date.now()}`,
|
||||||
conversationId: data.conversationId ?? '',
|
conversationId: '',
|
||||||
role: 'system' as const,
|
role: 'system',
|
||||||
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:start', onAgentStart);
|
socket.off('agent:text');
|
||||||
socket.off('agent:text', onAgentText);
|
socket.off('agent:start');
|
||||||
socket.off('agent:end', onAgentEnd);
|
socket.off('agent:end');
|
||||||
socket.off('error', onError);
|
socket.off('error');
|
||||||
// Fully tear down the socket when the chat page unmounts so we get a
|
socket.disconnect();
|
||||||
// fresh authenticated connection next time the page is visited.
|
|
||||||
destroySocket();
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -141,105 +97,42 @@ 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: autoTitle },
|
body: { title: content.slice(0, 50) },
|
||||||
});
|
});
|
||||||
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 in local UI state
|
// Optimistic user message
|
||||||
setMessages((prev) => [
|
const userMsg: Message = {
|
||||||
...prev,
|
id: `temp-${Date.now()}`,
|
||||||
{
|
conversationId: convId,
|
||||||
id: `user-${Date.now()}`,
|
role: 'user',
|
||||||
conversationId: convId,
|
content,
|
||||||
role: 'user' as const,
|
createdAt: new Date().toISOString(),
|
||||||
content,
|
};
|
||||||
createdAt: new Date().toISOString(),
|
setMessages((prev) => [...prev, userMsg]);
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Persist the user message to the DB so conversation history is
|
// Persist user message
|
||||||
// available when the page is reloaded or a new session starts.
|
await api<Message>(`/api/conversations/${convId}/messages`, {
|
||||||
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 — gateway creates/resumes the agent session and
|
// Send to WebSocket for streaming response
|
||||||
// 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, conversations, messages],
|
[activeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -249,9 +142,6 @@ 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">
|
||||||
|
|||||||
@@ -1,338 +0,0 @@
|
|||||||
'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,7 +1,6 @@
|
|||||||
'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';
|
||||||
@@ -9,7 +8,6 @@ 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')
|
||||||
@@ -18,12 +16,9 @@ export default function ProjectsPage(): React.ReactElement {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleProjectClick = useCallback(
|
const handleProjectClick = useCallback((project: Project) => {
|
||||||
(project: Project) => {
|
console.log('Project clicked:', project.id);
|
||||||
router.push(`/projects/${project.id}`);
|
}, []);
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,808 +1,150 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { authClient, useSession } from '@/lib/auth-client';
|
import { useSession } from '@/lib/auth-client';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
interface ProviderInfo {
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
modelCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ModelInfo {
|
interface ModelInfo {
|
||||||
id: string;
|
id: string;
|
||||||
|
name: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
name: string;
|
|
||||||
reasoning: boolean;
|
|
||||||
contextWindow: number;
|
contextWindow: number;
|
||||||
maxTokens: number;
|
reasoning: boolean;
|
||||||
inputTypes: ('text' | 'image')[];
|
cost: { input: number; output: number };
|
||||||
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 [activeTab, setActiveTab] = useState<Tab>('profile');
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
||||||
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const tabs: { id: Tab; label: string }[] = [
|
useEffect(() => {
|
||||||
{ id: 'profile', label: 'Profile' },
|
Promise.all([
|
||||||
{ id: 'appearance', label: 'Appearance' },
|
api<ProviderInfo[]>('/api/providers').catch(() => []),
|
||||||
{ id: 'notifications', label: 'Notifications' },
|
api<ModelInfo[]>('/api/providers/models').catch(() => []),
|
||||||
{ id: 'providers', label: 'Providers' },
|
])
|
||||||
];
|
.then(([p, m]) => {
|
||||||
|
setProviders(p);
|
||||||
|
setModels(m);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
<div className="mx-auto max-w-3xl space-y-8">
|
||||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||||
|
|
||||||
{/* Tab bar */}
|
{/* Profile */}
|
||||||
<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 [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(() => {
|
|
||||||
api<Preference[]>('/api/memory/preferences?category=appearance')
|
|
||||||
.catch(() => [] as Preference[])
|
|
||||||
.then((p) => {
|
|
||||||
setTheme(prefValue<Theme>(p, 'ui.theme', 'system'));
|
|
||||||
setSidebarCollapsed(prefValue<boolean>(p, 'ui.sidebar_collapsed', false));
|
|
||||||
setDefaultModel(prefValue<string>(p, 'ui.default_model', ''));
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
|
||||||
setSaveState('saving');
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Appearance</h2>
|
<h2 className="mb-4 text-lg font-medium text-text-secondary">Profile</h2>
|
||||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||||
|
{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>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{/* Providers */}
|
||||||
<section className="space-y-4">
|
<section>
|
||||||
<h2 className="text-lg font-medium text-text-secondary">Appearance</h2>
|
<h2 className="mb-4 text-lg font-medium text-text-secondary">LLM Providers</h2>
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
{loading ? (
|
||||||
{/* Theme */}
|
<p className="text-sm text-text-muted">Loading providers...</p>
|
||||||
<div>
|
) : providers.length === 0 ? (
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">Theme</label>
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||||
<div className="flex gap-3">
|
<p className="text-sm text-text-muted">No providers configured</p>
|
||||||
{(['system', 'light', 'dark'] as Theme[]).map((t) => (
|
</div>
|
||||||
<button
|
) : (
|
||||||
key={t}
|
<div className="space-y-3">
|
||||||
type="button"
|
{providers.map((p) => (
|
||||||
onClick={() => setTheme(t)}
|
<div
|
||||||
className={`rounded-lg border px-4 py-2 text-sm capitalize transition-colors ${
|
key={p.name}
|
||||||
theme === t
|
className="flex items-center justify-between rounded-lg border border-surface-border bg-surface-card p-4"
|
||||||
? 'border-accent bg-accent/10 text-accent'
|
|
||||||
: 'border-surface-border bg-surface-elevated text-text-secondary hover:border-accent/50'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{t}
|
<div>
|
||||||
</button>
|
<p className="text-sm font-medium text-text-primary">{p.name}</p>
|
||||||
|
<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>
|
)}
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Notifications</h2>
|
|
||||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{/* Models */}
|
||||||
<section className="space-y-4">
|
<section>
|
||||||
<h2 className="text-lg font-medium text-text-secondary">Notifications</h2>
|
<h2 className="mb-4 text-lg font-medium text-text-secondary">Available Models</h2>
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
{loading ? (
|
||||||
<p className="text-xs text-text-muted">Configure when you receive email notifications.</p>
|
<p className="text-sm text-text-muted">Loading models...</p>
|
||||||
|
) : models.length === 0 ? (
|
||||||
<NotifyRow
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||||
label="Agent task completed"
|
<p className="text-sm text-text-muted">No models available</p>
|
||||||
description="Email when an agent finishes a task"
|
</div>
|
||||||
checked={emailAgentComplete}
|
) : (
|
||||||
onChange={setEmailAgentComplete}
|
<div className="overflow-hidden rounded-lg border border-surface-border">
|
||||||
/>
|
<table className="w-full">
|
||||||
<NotifyRow
|
<thead>
|
||||||
label="Mentions"
|
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
||||||
description="Email when you are mentioned in a conversation"
|
<th className="px-4 py-2 font-medium">Model</th>
|
||||||
checked={emailMentions}
|
<th className="px-4 py-2 font-medium">Provider</th>
|
||||||
onChange={setEmailMentions}
|
<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>
|
||||||
<NotifyRow
|
</tr>
|
||||||
label="Weekly digest"
|
</thead>
|
||||||
description="Weekly summary of activity"
|
<tbody>
|
||||||
checked={emailDigest}
|
{models.map((m) => (
|
||||||
onChange={setEmailDigest}
|
<tr
|
||||||
/>
|
key={`${m.provider}-${m.id}`}
|
||||||
|
className="border-b border-surface-border last:border-b-0"
|
||||||
<div className="flex items-center gap-3 pt-2">
|
>
|
||||||
<SaveButton state={saveState} onClick={handleSave} />
|
<td className="px-4 py-2 text-sm text-text-primary">
|
||||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
{m.name}
|
||||||
</div>
|
{m.reasoning && (
|
||||||
</div>
|
<span className="ml-2 text-xs text-purple-400">reasoning</span>
|
||||||
</section>
|
)}
|
||||||
);
|
</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">
|
||||||
// ─── Providers Tab ────────────────────────────────────────────────────────────
|
{(m.contextWindow / 1000).toFixed(0)}k
|
||||||
|
</td>
|
||||||
function ProvidersTab(): React.ReactElement {
|
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
||||||
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
${m.cost.input} / ${m.cost.output}
|
||||||
const [loading, setLoading] = useState(true);
|
</td>
|
||||||
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
</tr>
|
||||||
|
))}
|
||||||
useEffect(() => {
|
</tbody>
|
||||||
api<ProviderInfo[]>('/api/providers')
|
</table>
|
||||||
.catch(() => [] as ProviderInfo[])
|
</div>
|
||||||
.then((p) => setProviders(p))
|
)}
|
||||||
.finally(() => setLoading(false));
|
</section>
|
||||||
}, []);
|
|
||||||
|
|
||||||
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 Toggle({
|
function Field({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||||
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">
|
||||||
<div>
|
<span className="text-sm text-text-muted">{label}</span>
|
||||||
<p className="text-sm font-medium text-text-primary">{label}</p>
|
<span className="text-sm text-text-primary">{value}</span>
|
||||||
<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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
'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,6 +1,5 @@
|
|||||||
'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';
|
||||||
|
|
||||||
@@ -9,32 +8,6 @@ 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({
|
||||||
@@ -42,265 +15,43 @@ export function ConversationList({
|
|||||||
activeId,
|
activeId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onNew,
|
onNew,
|
||||||
onRename,
|
|
||||||
onDelete,
|
|
||||||
onArchive,
|
|
||||||
}: ConversationListProps): React.ReactElement {
|
}: ConversationListProps): React.ReactElement {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
return (
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
<div className="flex h-full w-64 flex-col border-r border-surface-border bg-surface-card">
|
||||||
const [renameValue, setRenameValue] = useState('');
|
<div className="flex items-center justify-between p-3">
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
<h2 className="text-sm font-medium text-text-secondary">Conversations</h2>
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
<button
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
type="button"
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
onClick={onNew}
|
||||||
|
className="rounded-md px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-surface-elevated"
|
||||||
|
>
|
||||||
|
+ New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
const activeConversations = conversations.filter((c) => !c.archived);
|
<div className="flex-1 overflow-y-auto">
|
||||||
const archivedConversations = conversations.filter((c) => c.archived);
|
{conversations.length === 0 && (
|
||||||
|
<p className="px-3 py-2 text-xs text-text-muted">No conversations yet</p>
|
||||||
const filteredActive = searchQuery
|
)}
|
||||||
? activeConversations.filter((c) =>
|
{conversations.map((conv) => (
|
||||||
(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',
|
||||||
isActive
|
activeId === conv.id
|
||||||
? '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">
|
||||||
{formatRelativeTime(conv.updatedAt)}
|
{new Date(conv.updatedAt).toLocaleDateString()}
|
||||||
</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,22 +5,16 @@ interface StreamingMessageProps {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StreamingMessage({ text }: StreamingMessageProps): React.ReactElement {
|
export function StreamingMessage({ text }: StreamingMessageProps): React.ReactElement | null {
|
||||||
|
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">
|
||||||
{text ? (
|
<div className="whitespace-pre-wrap break-words">{text}</div>
|
||||||
<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-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" />
|
||||||
{text ? 'Responding...' : 'Thinking...'}
|
Thinking...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
'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 }} />;
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
'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,9 +1,7 @@
|
|||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
import { adminClient } from 'better-auth/client/plugins';
|
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient: ReturnType<typeof createAuthClient> = 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,24 +9,7 @@ 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,7 +4,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -52,22 +51,6 @@ 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;
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"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", "e2e", "playwright.config.ts"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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 8: Polish & Beta (v0.1.0)
|
**Current Milestone:** Phase 5: Remote Control (v0.0.6)
|
||||||
**Progress:** 8 / 9 milestones
|
**Progress:** 5 / 8 milestones
|
||||||
**Status:** active
|
**Status:** active
|
||||||
**Last Updated:** 2026-03-15 UTC
|
**Last Updated:** 2026-03-13 UTC
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
@@ -36,10 +36,9 @@
|
|||||||
| 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) | done | — | #99 | 2026-03-14 | 2026-03-14 |
|
| 5 | ms-162 | Phase 5: Remote Control (v0.0.6) | not-started | — | — | — | — |
|
||||||
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | done | — | #104 | 2026-03-14 | 2026-03-14 |
|
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | not-started | — | — | — | — |
|
||||||
| 7 | ms-164 | Phase 7: Feature Completion (v0.0.8) | done | — | — | 2026-03-15 | 2026-03-15 |
|
| 7 | ms-164 | Phase 7: Polish & Beta (v0.1.0) | not-started | — | — | — | — |
|
||||||
| 8 | ms-165 | Phase 8: Polish & Beta (v0.1.0) | not-started | — | — | — | — |
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -69,9 +68,7 @@
|
|||||||
| 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 | — | context limit | P3-008 |
|
| 10 | claude-opus-4-6 | 2026-03-13 | — | active | 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,80 +2,67 @@
|
|||||||
|
|
||||||
> 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 | done | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 |
|
| P5-001 | not-started | 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 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
|
| P5-003 | not-started | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
|
||||||
| P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
|
| P5-004 | not-started | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
|
||||||
| P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 |
|
| P5-005 | not-started | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | — | #45 |
|
||||||
| P6-001 | done | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | #104 | #46 |
|
| P6-001 | not-started | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | — | #46 |
|
||||||
| P6-002 | done | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | #101 | #47 |
|
| P6-002 | not-started | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | — | #47 |
|
||||||
| P6-003 | done | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | #100 | #48 |
|
| P6-003 | not-started | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | — | #48 |
|
||||||
| P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 |
|
| P6-004 | not-started | Phase 6 | @mosaic/mosaic — install wizard for v1 | — | #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 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
| P6-006 | not-started | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
||||||
| P7-009 | done | Phase 7 | Web chat — WebSocket integration, streaming, conversation switching | #136 | #120 W1 done |
|
| P7-001 | not-started | Phase 7 | MCP endpoint hardening — streamable HTTP transport | — | #52 |
|
||||||
| P7-001 | done | Phase 7 | MCP endpoint hardening — streamable HTTP transport | #137 | #52 W1 done |
|
| P7-002 | not-started | Phase 7 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
|
||||||
| P7-010 | done | Phase 7 | Web conversation management — list, search, rename, delete, archive | #139 | #121 W2 done |
|
| P7-003 | not-started | Phase 7 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
||||||
| P7-015 | done | Phase 7 | Agent tool expansion — file ops, git, shell exec, web fetch | #138 | #126 W2 done |
|
| P7-004 | not-started | Phase 7 | E2E test suite — Playwright critical paths | — | #55 |
|
||||||
| P7-011 | done | Phase 7 | Web project detail views — missions, tasks, PRDs, dashboards | #140 | #122 W3 done |
|
| P7-005 | not-started | Phase 7 | Performance optimization | — | #56 |
|
||||||
| P7-016 | done | Phase 7 | MCP client — gateway connects to external MCP servers as tools | #141 | #127 W3 done |
|
| P7-006 | not-started | Phase 7 | Documentation — user guide, admin guide, dev guide | — | #57 |
|
||||||
| P7-012 | done | Phase 7 | Web provider management UI — add, configure, test LLM providers | #142 | #123 W4 done |
|
| P7-007 | not-started | Phase 7 | Bare-metal deployment docs + .env.example | — | #58 |
|
||||||
| P7-017 | done | Phase 7 | Agent skill invocation — load and execute skills from catalog | #143 | #128 W4 done |
|
| P7-008 | not-started | Phase 7 | Beta release gate — v0.1.0 tag | — | #59 |
|
||||||
| P7-013 | done | Phase 7 | Web settings persistence — profile, preferences save to DB | #145 | #124 W5 done |
|
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
||||||
| P7-018 | done | Phase 7 | CLI model/provider switching — --model, --provider, /model in TUI | #144 | #129 W5 done |
|
| FIX-02 | not-started | Backlog | TUI agent:end — fix React state updater side-effect | — | #63 |
|
||||||
| P7-014 | done | Phase 7 | Web admin panel — user CRUD, role assignment, system health | #150 | #125 W6 done |
|
| FIX-03 | not-started | Backlog | Agent session — cwd sandbox, system prompt, tool restrictions | — | #64 |
|
||||||
| 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 |
|
|
||||||
|
|||||||
@@ -1,311 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,515 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Scratchpad — P5-001 Plugin Host
|
|
||||||
|
|
||||||
- Task: P5-001 / issue #41
|
|
||||||
- Branch: feat/p5-plugin-host
|
|
||||||
- Objective: add global NestJS plugin host module, wire Discord import, register active plugins from env, and attach to AppModule.
|
|
||||||
- TDD: skipped as optional for module wiring/integration work; relying on targeted typecheck/lint and module-level situational verification.
|
|
||||||
- Constraints: ESM .js imports, explicit @Inject(), follow existing gateway patterns, do not touch TASKS.md.
|
|
||||||
|
|
||||||
## Progress Log
|
|
||||||
|
|
||||||
- 2026-03-13: session started in worktree; loading gateway/plugin package context.
|
|
||||||
- 2026-03-13: implemented initial plugin module, service, interface, and AppModule wiring; pending verification.
|
|
||||||
- 2026-03-13: added `@mosaic/discord-plugin` as a gateway workspace dependency and regenerated `pnpm-lock.yaml`.
|
|
||||||
- 2026-03-13: built gateway dependency chain so workspace packages exported `dist/*` for clean TypeScript resolution in this fresh worktree.
|
|
||||||
- 2026-03-13: verification complete.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaic/gateway... build` ✅
|
|
||||||
- `pnpm --filter @mosaic/gateway typecheck` ✅
|
|
||||||
- `pnpm --filter @mosaic/gateway lint` ✅
|
|
||||||
- `pnpm format:check` ✅
|
|
||||||
- `pnpm typecheck` ✅
|
|
||||||
- `pnpm lint` ✅
|
|
||||||
|
|
||||||
## Review
|
|
||||||
|
|
||||||
- Automated review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
|
|
||||||
- Manual review: diff inspection of gateway plugin host changes
|
|
||||||
- Result: no blocker findings
|
|
||||||
@@ -73,64 +73,6 @@ User confirmed: start the planning gate.
|
|||||||
| ------- | ---------- | --------- | ---------- | ----------------------------------------------------------------------------------- |
|
| ------- | ---------- | --------- | ---------- | ----------------------------------------------------------------------------------- |
|
||||||
| 7-8 | 2026-03-12 | Phase 2 | P2-007 | 19 unit tests (routing + coord). PR #79 merged, issue #25 closed. Phase 2 complete. |
|
| 7-8 | 2026-03-12 | Phase 2 | P2-007 | 19 unit tests (routing + coord). PR #79 merged, issue #25 closed. Phase 2 complete. |
|
||||||
|
|
||||||
### Session 11 — Phase 5 completion
|
|
||||||
|
|
||||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
|
||||||
| ------- | ---------- | --------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| 11 | 2026-03-14 | Phase 5 | P5-005 | Wired Telegram plugin into gateway (was stubbed). Updated .env.example with all P5 env vars. PR #99 merged, issue #45 closed. Phase 5 complete. |
|
|
||||||
|
|
||||||
**Findings during verification:**
|
|
||||||
|
|
||||||
- Telegram plugin was built but not wired into gateway (stub warning in plugin.module.ts)
|
|
||||||
- Discord plugin was fully wired
|
|
||||||
- SSO/Authentik OIDC adapter was fully wired
|
|
||||||
- All three quality gates passing
|
|
||||||
|
|
||||||
### Session 11 (continued) — Phase 6 completion
|
|
||||||
|
|
||||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
|
||||||
| ------- | ---------- | --------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| 11 | 2026-03-14 | Phase 6 | P6-002, P6-003, P6-004, P6-001, P6-006 | Full CLI & Tools migration. PRs #100-#104 merged. Also fixed 2 gateway startup bugs (PR #102). Phase 6 complete. |
|
|
||||||
|
|
||||||
**Phase 6 details:**
|
|
||||||
|
|
||||||
- P6-002: @mosaic/prdy migrated from v0 (~400 LOC). PR #101.
|
|
||||||
- P6-003: @mosaic/quality-rails migrated from v0 (~500 LOC). PR #100.
|
|
||||||
- P6-004: @mosaic/mosaic wizard migrated from v0 (2272 LOC, 28 files). PR #103.
|
|
||||||
- P6-001: CLI subcommands wired — tui, prdy, quality-rails, wizard all working. PR #104.
|
|
||||||
- BUG-1: PLUGIN_REGISTRY circular import fixed via plugin.tokens.ts. PR #102.
|
|
||||||
- BUG-2: AuthStorage.create() → .inMemory() to prevent silent exit. PR #102.
|
|
||||||
|
|
||||||
### Session 11 (continued) — E2E testing + bug fixes + Phase 7 rescope
|
|
||||||
|
|
||||||
**Bug fixes merged during E2E testing (PRs #107-#117):**
|
|
||||||
|
|
||||||
- CI: from_secret syntax for Woodpecker v2 (#107)
|
|
||||||
- Gateway: dotenv loading from monorepo root (#108)
|
|
||||||
- Gateway: missing @Inject() decorators causing silent hang (#109)
|
|
||||||
- Gateway: CORS + memory userId + pgvector auto-init (#110)
|
|
||||||
- Auth: BetterAuth trustedOrigins for web dashboard (#111)
|
|
||||||
- Auth: CORS headers on raw BetterAuth HTTP handler (#112)
|
|
||||||
- Husky: removed deprecated v9 shim lines (#113)
|
|
||||||
- CLI: login command + authenticated TUI sessions (#114)
|
|
||||||
- CLI: Origin header on auth requests (#115)
|
|
||||||
- Agent: Ollama provider registration with openai-completions API (#116, #117)
|
|
||||||
|
|
||||||
**E2E testing results:**
|
|
||||||
|
|
||||||
- Web UI: login works, projects list, chats list (but chat doesn't function)
|
|
||||||
- TUI: authenticated connection works, agent responds via Ollama llama3.2
|
|
||||||
- Agent tools: brain, coord, memory tools confirmed working
|
|
||||||
- Gateway: all routes mapped, providers register correctly
|
|
||||||
|
|
||||||
**Phase 7 rescoped (Jason directed):**
|
|
||||||
|
|
||||||
- Phase 7 renamed from "Polish & Beta" to "Feature Completion (v0.0.8)"
|
|
||||||
- Added 13 new tasks (P7-009 through P7-021): web UI, agent tools, CLI, coord architecture
|
|
||||||
- P7-002 (extra SSO), P7-003 (extra LLM), P7-005 (perf), P7-008 (v0.1.0 tag) moved to Phase 8
|
|
||||||
- Phase 8 added as "Polish & Beta (v0.1.0)"
|
|
||||||
- Reason: platform isn't feature-complete enough for beta — web UI is scaffolded but non-functional for real use, agent tooling is minimal, CLI needs model switching
|
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
(none at this time)
|
(none at this time)
|
||||||
@@ -178,47 +120,3 @@ User confirmed: start the planning gate.
|
|||||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
| ------- | ---------- | --------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------- | ---------- | --------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 10 | 2026-03-13 | Phase 4 | P4-001 through P4-007 | Full memory + log system: DB schema (preferences, insights w/ pgvector, agent_logs, skills, summarization_jobs), @mosaic/memory + @mosaic/log packages, embedding service, summarization pipeline w/ cron, memory tools in agent sessions, skill management CRUD. All gates green. |
|
| 10 | 2026-03-13 | Phase 4 | P4-001 through P4-007 | Full memory + log system: DB schema (preferences, insights w/ pgvector, agent_logs, skills, summarization_jobs), @mosaic/memory + @mosaic/log packages, embedding service, summarization pipeline w/ cron, memory tools in agent sessions, skill management CRUD. All gates green. |
|
||||||
|
|
||||||
### Session 12 — Phase 7 planning + execution start
|
|
||||||
|
|
||||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
|
||||||
| ------- | ---------- | --------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| 12 | 2026-03-15 | Phase 7 | Planning | Merged rescope PR #119. Created 15 Gitea issues (#120-#134) for P7-009 through P7-021 + FIX-02/FIX-03. Planned 10-wave execution order with 2-worker parallelism. |
|
|
||||||
|
|
||||||
**Phase 7 execution plan (10 waves, max 2 parallel workers):**
|
|
||||||
|
|
||||||
| Wave | Task A | Task B |
|
|
||||||
| ---- | ------------------------------- | -------------------------------------------- |
|
|
||||||
| 1 | P7-009 Web chat WS (#120) | P7-001 MCP hardening (#52) |
|
|
||||||
| 2 | P7-010 Conversation mgmt (#121) | P7-015 Agent tools (#126) |
|
|
||||||
| 3 | P7-011 Project views (#122) | P7-016 MCP client (#127) |
|
|
||||||
| 4 | P7-012 Provider UI (#123) | P7-017 Skill invocation (#128) |
|
|
||||||
| 5 | P7-013 Settings persist (#124) | P7-018 CLI model switch (#129) |
|
|
||||||
| 6 | P7-014 Admin panel (#125) | P7-019 CLI sessions (#130) |
|
|
||||||
| 7 | P7-020 Coord DB (#131) | — |
|
|
||||||
| 8 | FIX-02 TUI state (#133) | FIX-03 Agent sandbox (#134) |
|
|
||||||
| 9 | P7-004 E2E Playwright (#55) | P7-006 Docs (#57) + P7-007 Deploy docs (#58) |
|
|
||||||
| 10 | P7-021 Verify Phase 7 (#132) | — |
|
|
||||||
|
|
||||||
### Session 12 — Phase 7 completion summary
|
|
||||||
|
|
||||||
**All 17 Phase 7 tasks + 2 backlog fixes completed in a single session.**
|
|
||||||
|
|
||||||
PRs merged: #136, #137, #138, #139, #140, #141, #142, #143, #144, #145, #146, #147, #148, #149, #150, #151, #152, #153
|
|
||||||
Issues closed: #52, #55, #57, #58, #120-#134
|
|
||||||
|
|
||||||
**Verification evidence:**
|
|
||||||
|
|
||||||
- Typecheck: 32/32 tasks green
|
|
||||||
- Lint: 18/18 packages green
|
|
||||||
- Format: All files clean
|
|
||||||
- 19 PRs squash-merged to main, all quality gates passed
|
|
||||||
|
|
||||||
**Phase 7 delivered:**
|
|
||||||
|
|
||||||
- Web: functional chat (WS streaming), conversation management, project detail views, provider UI, settings persistence, admin panel
|
|
||||||
- Agent: 7 new tools (file/git/shell/web), MCP server (14 tools), MCP client (external server bridge), skill invocation
|
|
||||||
- CLI: model/provider switching, session management
|
|
||||||
- Infrastructure: coord DB migration, agent sandbox hardening
|
|
||||||
- Quality: E2E Playwright suite (~35 tests), comprehensive docs (user/admin/dev/deployment)
|
|
||||||
- Fixes: TUI state updater, agent session sandboxing
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# Scratchpad — P5-003 Telegram Plugin
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement `@mosaic/telegram-plugin` by matching the established Discord plugin pattern with Telegraf + socket.io-client, add package docs, and pass package typecheck/lint.
|
|
||||||
|
|
||||||
## Requirements Source
|
|
||||||
|
|
||||||
- docs/PRD.md: Phase 5 remote control / Telegram plugin
|
|
||||||
- docs/TASKS.md: P5-003
|
|
||||||
- User task brief dated 2026-03-13
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Inspect Discord plugin behavior and package conventions
|
|
||||||
2. Add Telegram runtime dependencies if missing
|
|
||||||
3. Implement Telegram plugin with matching gateway event flow
|
|
||||||
4. Add README usage documentation
|
|
||||||
5. Run package typecheck and lint
|
|
||||||
6. Run code review and remediate findings
|
|
||||||
7. Commit, push, open PR, notify, remove worktree
|
|
||||||
|
|
||||||
## TDD Rationale
|
|
||||||
|
|
||||||
ASSUMPTION: No existing telegram package test harness or fixture coverage makes package-level TDD
|
|
||||||
disproportionate for this plugin scaffold task. Validation will rely on typecheck, lint, and
|
|
||||||
manual structural parity with the Discord plugin.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- Telegram API typings may differ from Discord’s event shapes and require narrower guards.
|
|
||||||
- Socket event payloads may already include `role` in shared gateway expectations.
|
|
||||||
|
|
||||||
## Progress Log
|
|
||||||
|
|
||||||
- 2026-03-13: Loaded Mosaic/global/repo guidance, mission files, Discord reference implementation, and Telegram package scaffold.
|
|
||||||
- 2026-03-13: Added `telegraf` and `socket.io-client` to `@mosaic/telegram-plugin`.
|
|
||||||
- 2026-03-13: Implemented Telegram message forwarding, gateway streaming accumulation, response chunking, and package README.
|
|
||||||
|
|
||||||
## Verification Evidence
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaic/telegram-plugin typecheck` → pass
|
|
||||||
- `pnpm --filter @mosaic/telegram-plugin lint` → pass
|
|
||||||
- `pnpm typecheck` → pass
|
|
||||||
- `pnpm lint` → pass
|
|
||||||
|
|
||||||
## Review
|
|
||||||
|
|
||||||
- Automated uncommitted review wrapper was invoked for the current delta.
|
|
||||||
- Manual review completed against Discord parity, gateway event contracts, and package docs; no additional blockers found.
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# P5-004 Scratchpad
|
|
||||||
|
|
||||||
- Objective: Add optional Authentik OIDC SSO adapter via Better Auth genericOAuth.
|
|
||||||
- Task ref: P5-004
|
|
||||||
- Issue ref: #96
|
|
||||||
- Plan:
|
|
||||||
1. Inspect auth/gateway surfaces and Better Auth plugin shape.
|
|
||||||
2. Add failing coverage for auth config/startup validation where feasible.
|
|
||||||
3. Implement adapter, docs, and warnings.
|
|
||||||
4. Run targeted typechecks, lint, and review.
|
|
||||||
|
|
||||||
- TDD note: no low-friction auth plugin or bootstrap-env test seam exists for `packages/auth/src/auth.ts` or `apps/gateway/src/main.ts`. This change is configuration-oriented and does not alter an existing behavioral contract with a current test harness. I skipped new tests for this pass and relied on exact typecheck/lint/test commands plus manual review.
|
|
||||||
|
|
||||||
- Changes:
|
|
||||||
1. Added conditional Better Auth `genericOAuth` plugin registration for the `authentik` provider in `packages/auth/src/auth.ts`.
|
|
||||||
2. Added a soft startup warning in `apps/gateway/src/main.ts` for incomplete Authentik env configuration.
|
|
||||||
3. Added `docs/plans/authentik-sso-setup.md` with env, redirect URI, and test-flow guidance.
|
|
||||||
4. Confirmed `packages/auth/src/index.ts` already exports `AuthConfig`; no change required there.
|
|
||||||
|
|
||||||
- Verification:
|
|
||||||
1. `pnpm --filter @mosaic/db build`
|
|
||||||
2. `pnpm --filter @mosaic/auth typecheck`
|
|
||||||
3. `pnpm --filter @mosaic/gateway typecheck`
|
|
||||||
4. `pnpm lint`
|
|
||||||
5. `pnpm format:check`
|
|
||||||
6. `pnpm --filter @mosaic/auth test`
|
|
||||||
7. `pnpm --filter @mosaic/gateway test`
|
|
||||||
|
|
||||||
- Results:
|
|
||||||
1. `@mosaic/auth` typecheck passed after replacing the non-existent `enabled` field with conditional plugin registration.
|
|
||||||
2. `@mosaic/gateway` typecheck passed.
|
|
||||||
3. Repo lint passed.
|
|
||||||
4. Prettier check passed after formatting `apps/gateway/src/main.ts`.
|
|
||||||
5. `@mosaic/auth` tests reported `No test files found, exiting with code 0`.
|
|
||||||
6. `@mosaic/gateway` tests passed: `3` files, `20` tests.
|
|
||||||
|
|
||||||
- Review:
|
|
||||||
1. Manual review of the diff found no blocker issues.
|
|
||||||
2. External `codex-code-review.sh --uncommitted` was attempted but did not return a usable verdict in-session; no automated review findings were available from that run.
|
|
||||||
|
|
||||||
- Situational evidence:
|
|
||||||
1. Provider activation is env-gated by `AUTHENTIK_CLIENT_ID`.
|
|
||||||
2. Misconfigured optional SSO surfaces a warning instead of crashing gateway startup.
|
|
||||||
3. Setup doc records the expected redirect path: `{BETTER_AUTH_URL}/api/auth/callback/authentik`.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Task Ownership Gap Fix Scratchpad
|
|
||||||
|
|
||||||
## Metadata
|
|
||||||
|
|
||||||
- Date: 2026-03-13
|
|
||||||
- Worktree: `/home/jwoltje/src/mosaic-mono-v1-worktrees/fix-task-ownership`
|
|
||||||
- Branch: `fix/task-mission-ownership`
|
|
||||||
- Scope: Fix ownership checks in TasksController/MissionsController and extend gateway ownership tests
|
|
||||||
- Related tracker: worker task only; `docs/TASKS.md` is orchestrator-owned and left unchanged
|
|
||||||
- Budget assumption: no explicit token cap; keep scope limited to requested gateway permission fixes
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Close ownership gaps so task listing/creation and mission creation enforce project/mission ownership and reject cross-user access.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
1. TasksController `list()` enforces ownership for `projectId` and `missionId`, and does not return cross-user data when neither filter is provided.
|
|
||||||
2. TasksController `create()` rejects unowned `projectId` and `missionId` references.
|
|
||||||
3. MissionsController `create()` rejects unowned `projectId` references.
|
|
||||||
4. Gateway ownership tests cover forbidden task creation and forbidden task listing by unowned project.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Inspect current controller and ownership test patterns.
|
|
||||||
2. Add failing permission tests first.
|
|
||||||
3. Patch controller methods with existing ownership helpers.
|
|
||||||
4. Run targeted gateway tests, then gateway typecheck/lint/full test.
|
|
||||||
5. Perform independent review, record evidence, then complete the requested git/PR workflow.
|
|
||||||
|
|
||||||
## TDD Notes
|
|
||||||
|
|
||||||
- Required: yes. This is auth/permission logic and a bugfix.
|
|
||||||
- Strategy: add failing tests in `resource-ownership.test.ts`, verify red, then implement minimal controller changes.
|
|
||||||
|
|
||||||
## Verification Log
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaic/gateway test -- src/__tests__/resource-ownership.test.ts`
|
|
||||||
- Red: failed with 2 expected permission-path failures before controller changes.
|
|
||||||
- Green: passed after wiring ownership checks and adding owned-task filtering coverage.
|
|
||||||
- `pnpm --filter @mosaic/gateway typecheck`
|
|
||||||
- Pass on 2026-03-13 after fixing parameter ordering and mission project nullability.
|
|
||||||
- `pnpm --filter @mosaic/gateway lint`
|
|
||||||
- Pass on 2026-03-13.
|
|
||||||
- `pnpm --filter @mosaic/gateway test`
|
|
||||||
- Pass on 2026-03-13 with 3 test files and 23 tests passing.
|
|
||||||
- `pnpm format:check`
|
|
||||||
- Pass on 2026-03-13.
|
|
||||||
|
|
||||||
## Review Log
|
|
||||||
|
|
||||||
- Manual review: checked for auth regressions, cross-user list leakage, and dashboard behavior impact; kept unfiltered task list functional by filtering to owned projects/missions instead of returning an empty list.
|
|
||||||
- Automated review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` running/re-run for independent review evidence.
|
|
||||||
|
|
||||||
## Risks / Blockers
|
|
||||||
|
|
||||||
- Repository-wide Mosaic instructions require merge/issue closure, but the user explicitly instructed PR-only and no merge; follow the user instruction.
|
|
||||||
- `docs/TASKS.md` is orchestrator-owned and will not be edited from this worker task.
|
|
||||||
@@ -20,13 +20,7 @@ export default tseslint.config(
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: tsParser,
|
parser: tsParser,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
projectService: {
|
projectService: true,
|
||||||
allowDefaultProject: [
|
|
||||||
'apps/web/e2e/*.ts',
|
|
||||||
'apps/web/e2e/helpers/*.ts',
|
|
||||||
'apps/web/playwright.config.ts',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS vector;
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { betterAuth } from 'better-auth';
|
import { betterAuth } from 'better-auth';
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||||
import { admin, genericOAuth } from 'better-auth/plugins';
|
|
||||||
import type { Db } from '@mosaic/db';
|
import type { Db } from '@mosaic/db';
|
||||||
|
|
||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
@@ -11,36 +10,6 @@ export interface AuthConfig {
|
|||||||
|
|
||||||
export function createAuth(config: AuthConfig) {
|
export function createAuth(config: AuthConfig) {
|
||||||
const { db, baseURL, secret } = config;
|
const { db, baseURL, secret } = config;
|
||||||
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
|
|
||||||
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
|
||||||
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
|
|
||||||
const plugins = authentikClientId
|
|
||||||
? [
|
|
||||||
genericOAuth({
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
providerId: 'authentik',
|
|
||||||
clientId: authentikClientId,
|
|
||||||
clientSecret: authentikClientSecret ?? '',
|
|
||||||
discoveryUrl: authentikIssuer
|
|
||||||
? `${authentikIssuer}/.well-known/openid-configuration`
|
|
||||||
: undefined,
|
|
||||||
authorizationUrl: authentikIssuer
|
|
||||||
? `${authentikIssuer}/application/o/authorize/`
|
|
||||||
: undefined,
|
|
||||||
tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined,
|
|
||||||
userInfoUrl: authentikIssuer
|
|
||||||
? `${authentikIssuer}/application/o/userinfo/`
|
|
||||||
: undefined,
|
|
||||||
scopes: ['openid', 'email', 'profile'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
|
||||||
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
|
||||||
|
|
||||||
return betterAuth({
|
return betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
@@ -50,7 +19,6 @@ export function createAuth(config: AuthConfig) {
|
|||||||
baseURL: baseURL ?? process.env['BETTER_AUTH_URL'] ?? 'http://localhost:4000',
|
baseURL: baseURL ?? process.env['BETTER_AUTH_URL'] ?? 'http://localhost:4000',
|
||||||
secret: secret ?? process.env['BETTER_AUTH_SECRET'],
|
secret: secret ?? process.env['BETTER_AUTH_SECRET'],
|
||||||
basePath: '/api/auth',
|
basePath: '/api/auth',
|
||||||
trustedOrigins,
|
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
@@ -68,7 +36,6 @@ export function createAuth(config: AuthConfig) {
|
|||||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||||
updateAge: 60 * 60 * 24, // refresh daily
|
updateAge: 60 * 60 * 24, // refresh daily
|
||||||
},
|
},
|
||||||
plugins: [...(plugins ?? []), admin({ defaultRole: 'member', adminRoles: ['admin'] })],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import type { Db } from '@mosaic/db';
|
import type { Db } from '@mosaic/db';
|
||||||
import { createProjectsRepo, type ProjectsRepo } from './projects.js';
|
import { createProjectsRepo, type ProjectsRepo } from './projects.js';
|
||||||
import { createMissionsRepo, type MissionsRepo } from './missions.js';
|
import { createMissionsRepo, type MissionsRepo } from './missions.js';
|
||||||
import { createMissionTasksRepo, type MissionTasksRepo } from './mission-tasks.js';
|
|
||||||
import { createTasksRepo, type TasksRepo } from './tasks.js';
|
import { createTasksRepo, type TasksRepo } from './tasks.js';
|
||||||
import { createConversationsRepo, type ConversationsRepo } from './conversations.js';
|
import { createConversationsRepo, type ConversationsRepo } from './conversations.js';
|
||||||
|
|
||||||
export interface Brain {
|
export interface Brain {
|
||||||
projects: ProjectsRepo;
|
projects: ProjectsRepo;
|
||||||
missions: MissionsRepo;
|
missions: MissionsRepo;
|
||||||
missionTasks: MissionTasksRepo;
|
|
||||||
tasks: TasksRepo;
|
tasks: TasksRepo;
|
||||||
conversations: ConversationsRepo;
|
conversations: ConversationsRepo;
|
||||||
}
|
}
|
||||||
@@ -17,7 +15,6 @@ export function createBrain(db: Db): Brain {
|
|||||||
return {
|
return {
|
||||||
projects: createProjectsRepo(db),
|
projects: createProjectsRepo(db),
|
||||||
missions: createMissionsRepo(db),
|
missions: createMissionsRepo(db),
|
||||||
missionTasks: createMissionTasksRepo(db),
|
|
||||||
tasks: createTasksRepo(db),
|
tasks: createTasksRepo(db),
|
||||||
conversations: createConversationsRepo(db),
|
conversations: createConversationsRepo(db),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,12 +11,6 @@ export {
|
|||||||
type Mission,
|
type Mission,
|
||||||
type NewMission,
|
type NewMission,
|
||||||
} from './missions.js';
|
} from './missions.js';
|
||||||
export {
|
|
||||||
createMissionTasksRepo,
|
|
||||||
type MissionTasksRepo,
|
|
||||||
type MissionTask,
|
|
||||||
type NewMissionTask,
|
|
||||||
} from './mission-tasks.js';
|
|
||||||
export { createTasksRepo, type TasksRepo, type Task, type NewTask } from './tasks.js';
|
export { createTasksRepo, type TasksRepo, type Task, type NewTask } from './tasks.js';
|
||||||
export {
|
export {
|
||||||
createConversationsRepo,
|
createConversationsRepo,
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { eq, and, type Db, missionTasks } from '@mosaic/db';
|
|
||||||
|
|
||||||
export type MissionTask = typeof missionTasks.$inferSelect;
|
|
||||||
export type NewMissionTask = typeof missionTasks.$inferInsert;
|
|
||||||
|
|
||||||
export function createMissionTasksRepo(db: Db) {
|
|
||||||
return {
|
|
||||||
async findByMission(missionId: string): Promise<MissionTask[]> {
|
|
||||||
return db.select().from(missionTasks).where(eq(missionTasks.missionId, missionId));
|
|
||||||
},
|
|
||||||
|
|
||||||
async findByMissionAndUser(missionId: string, userId: string): Promise<MissionTask[]> {
|
|
||||||
return db
|
|
||||||
.select()
|
|
||||||
.from(missionTasks)
|
|
||||||
.where(and(eq(missionTasks.missionId, missionId), eq(missionTasks.userId, userId)));
|
|
||||||
},
|
|
||||||
|
|
||||||
async findById(id: string): Promise<MissionTask | undefined> {
|
|
||||||
const rows = await db.select().from(missionTasks).where(eq(missionTasks.id, id));
|
|
||||||
return rows[0];
|
|
||||||
},
|
|
||||||
|
|
||||||
async findByIdAndUser(id: string, userId: string): Promise<MissionTask | undefined> {
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(missionTasks)
|
|
||||||
.where(and(eq(missionTasks.id, id), eq(missionTasks.userId, userId)));
|
|
||||||
return rows[0];
|
|
||||||
},
|
|
||||||
|
|
||||||
async create(data: NewMissionTask): Promise<MissionTask> {
|
|
||||||
const rows = await db.insert(missionTasks).values(data).returning();
|
|
||||||
return rows[0]!;
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(id: string, data: Partial<NewMissionTask>): Promise<MissionTask | undefined> {
|
|
||||||
const rows = await db
|
|
||||||
.update(missionTasks)
|
|
||||||
.set({ ...data, updatedAt: new Date() })
|
|
||||||
.where(eq(missionTasks.id, id))
|
|
||||||
.returning();
|
|
||||||
return rows[0];
|
|
||||||
},
|
|
||||||
|
|
||||||
async remove(id: string): Promise<boolean> {
|
|
||||||
const rows = await db.delete(missionTasks).where(eq(missionTasks.id, id)).returning();
|
|
||||||
return rows.length > 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
async removeByMission(missionId: string): Promise<number> {
|
|
||||||
const rows = await db
|
|
||||||
.delete(missionTasks)
|
|
||||||
.where(eq(missionTasks.missionId, missionId))
|
|
||||||
.returning();
|
|
||||||
return rows.length;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MissionTasksRepo = ReturnType<typeof createMissionTasksRepo>;
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, and, type Db, missions } from '@mosaic/db';
|
import { eq, type Db, missions } from '@mosaic/db';
|
||||||
|
|
||||||
export type Mission = typeof missions.$inferSelect;
|
export type Mission = typeof missions.$inferSelect;
|
||||||
export type NewMission = typeof missions.$inferInsert;
|
export type NewMission = typeof missions.$inferInsert;
|
||||||
@@ -9,34 +9,15 @@ export function createMissionsRepo(db: Db) {
|
|||||||
return db.select().from(missions);
|
return db.select().from(missions);
|
||||||
},
|
},
|
||||||
|
|
||||||
async findAllByUser(userId: string): Promise<Mission[]> {
|
|
||||||
return db.select().from(missions).where(eq(missions.userId, userId));
|
|
||||||
},
|
|
||||||
|
|
||||||
async findById(id: string): Promise<Mission | undefined> {
|
async findById(id: string): Promise<Mission | undefined> {
|
||||||
const rows = await db.select().from(missions).where(eq(missions.id, id));
|
const rows = await db.select().from(missions).where(eq(missions.id, id));
|
||||||
return rows[0];
|
return rows[0];
|
||||||
},
|
},
|
||||||
|
|
||||||
async findByIdAndUser(id: string, userId: string): Promise<Mission | undefined> {
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(missions)
|
|
||||||
.where(and(eq(missions.id, id), eq(missions.userId, userId)));
|
|
||||||
return rows[0];
|
|
||||||
},
|
|
||||||
|
|
||||||
async findByProject(projectId: string): Promise<Mission[]> {
|
async findByProject(projectId: string): Promise<Mission[]> {
|
||||||
return db.select().from(missions).where(eq(missions.projectId, projectId));
|
return db.select().from(missions).where(eq(missions.projectId, projectId));
|
||||||
},
|
},
|
||||||
|
|
||||||
async findByProjectAndUser(projectId: string, userId: string): Promise<Mission[]> {
|
|
||||||
return db
|
|
||||||
.select()
|
|
||||||
.from(missions)
|
|
||||||
.where(and(eq(missions.projectId, projectId), eq(missions.userId, userId)));
|
|
||||||
},
|
|
||||||
|
|
||||||
async create(data: NewMission): Promise<Mission> {
|
async create(data: NewMission): Promise<Mission> {
|
||||||
const rows = await db.insert(missions).values(data).returning();
|
const rows = await db.insert(missions).values(data).returning();
|
||||||
return rows[0]!;
|
return rows[0]!;
|
||||||
|
|||||||
@@ -21,9 +21,6 @@
|
|||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaic/mosaic": "workspace:^",
|
|
||||||
"@mosaic/prdy": "workspace:^",
|
|
||||||
"@mosaic/quality-rails": "workspace:^",
|
|
||||||
"ink": "^5.0.0",
|
"ink": "^5.0.0",
|
||||||
"ink-text-input": "^6.0.0",
|
"ink-text-input": "^6.0.0",
|
||||||
"ink-spinner": "^5.0.0",
|
"ink-spinner": "^5.0.0",
|
||||||
@@ -32,7 +29,6 @@
|
|||||||
"commander": "^13.0.0"
|
"commander": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"tsx": "^4.0.0",
|
"tsx": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
|
|
||||||
const SESSION_DIR = resolve(homedir(), '.mosaic');
|
|
||||||
const SESSION_FILE = resolve(SESSION_DIR, 'session.json');
|
|
||||||
|
|
||||||
interface StoredSession {
|
|
||||||
gatewayUrl: string;
|
|
||||||
cookie: string;
|
|
||||||
userId: string;
|
|
||||||
email: string;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthResult {
|
|
||||||
cookie: string;
|
|
||||||
userId: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign in to the gateway and return the session cookie.
|
|
||||||
*/
|
|
||||||
export async function signIn(
|
|
||||||
gatewayUrl: string,
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
): Promise<AuthResult> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/auth/sign-in/email`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Origin: gatewayUrl },
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
redirect: 'manual',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
throw new Error(`Sign-in failed (${res.status}): ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract set-cookie header
|
|
||||||
const setCookieHeader = res.headers.getSetCookie?.() ?? [];
|
|
||||||
const sessionCookie = setCookieHeader
|
|
||||||
.map((c) => c.split(';')[0]!)
|
|
||||||
.filter((c) => c.startsWith('better-auth.session_token='))
|
|
||||||
.join('; ');
|
|
||||||
|
|
||||||
if (!sessionCookie) {
|
|
||||||
throw new Error('No session cookie returned from sign-in');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the response body for user info
|
|
||||||
const data = (await res.json()) as { user?: { id: string; email: string } };
|
|
||||||
const userId = data.user?.id ?? 'unknown';
|
|
||||||
const userEmail = data.user?.email ?? email;
|
|
||||||
|
|
||||||
return { cookie: sessionCookie, userId, email: userEmail };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save session to ~/.mosaic/session.json
|
|
||||||
*/
|
|
||||||
export function saveSession(gatewayUrl: string, auth: AuthResult): void {
|
|
||||||
if (!existsSync(SESSION_DIR)) {
|
|
||||||
mkdirSync(SESSION_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const session: StoredSession = {
|
|
||||||
gatewayUrl,
|
|
||||||
cookie: auth.cookie,
|
|
||||||
userId: auth.userId,
|
|
||||||
email: auth.email,
|
|
||||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days
|
|
||||||
};
|
|
||||||
|
|
||||||
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a saved session. Returns null if no session, expired, or wrong gateway.
|
|
||||||
*/
|
|
||||||
export function loadSession(gatewayUrl: string): AuthResult | null {
|
|
||||||
if (!existsSync(SESSION_FILE)) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = readFileSync(SESSION_FILE, 'utf-8');
|
|
||||||
const session = JSON.parse(raw) as StoredSession;
|
|
||||||
|
|
||||||
if (session.gatewayUrl !== gatewayUrl) return null;
|
|
||||||
if (new Date(session.expiresAt) < new Date()) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
cookie: session.cookie,
|
|
||||||
userId: session.userId,
|
|
||||||
email: session.email,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a stored session is still active by hitting get-session.
|
|
||||||
*/
|
|
||||||
export async function validateSession(gatewayUrl: string, cookie: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/auth/get-session`, {
|
|
||||||
headers: { Cookie: cookie, Origin: gatewayUrl },
|
|
||||||
});
|
|
||||||
return res.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user