Compare commits
95 Commits
c1ec0ad7ef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ade9e968ca | |||
| 413ecdb63b | |||
| e85fb11f03 | |||
| 0869a3dcb6 | |||
| a70f149886 | |||
| 2f1ee53c8d | |||
| b52c4e7ff9 | |||
| af56684e84 | |||
| ee4d6fa12b | |||
| 5bd08b0d0b | |||
| 1eb581553a | |||
| da62b9bb73 | |||
| 62fc76fea6 | |||
| 8b38026fed | |||
| 82b1b4cb41 | |||
| 22e08e4ef2 | |||
| 29cc37f8df | |||
| 091fb54f77 | |||
| 939479ac7e | |||
| 9031509bbd | |||
| f11a005538 | |||
| 8484e060d7 | |||
| 673ca32d5a | |||
| a777f1f695 | |||
| d7d8c3c88d | |||
| aec8085f60 | |||
| 44da50d0b3 | |||
| 44fb402ef2 | |||
| f42c47e314 | |||
| 8069aeadb5 | |||
| 1f883c4c04 | |||
| 5207d8c0c9 | |||
| d1c9a747b9 | |||
| 3d669713d7 | |||
| 1a6cf113c8 | |||
| 48d734516a | |||
| 83477165d4 | |||
| c45cec3bba | |||
| b1baa70e00 | |||
| 55340dc661 | |||
| a8d426e3c0 | |||
| 40e12214cf | |||
| 892ffd637f | |||
| 394a46bef2 | |||
| 29a78890c9 | |||
| 0c88010123 | |||
| 7f94ecdc7a | |||
| 5b77774d91 | |||
| a16371c6f9 | |||
| 51d46b2e4a | |||
| 6582785ddd | |||
| ae0bebe2e0 | |||
| 173b429c62 | |||
| 7d505e75f8 | |||
| cd1c52c506 | |||
| a00f1e1fd7 | |||
| 9305cacd4a | |||
| 0d5aa5c3ae | |||
| eb34eb8104 | |||
| 5165a30fad | |||
| 6eb91c9eba | |||
| e7da4ca25e | |||
| e1e265804a | |||
| d361d00674 | |||
| 78ff8f8e70 | |||
| 2463b7b8ba | |||
| 5b235a668f | |||
| c5ab179071 | |||
| b4f4de6f7a | |||
| 2b6bed2480 | |||
| eba33fc93d | |||
| c23c33b0c5 | |||
| c5253e9d62 | |||
| e898551814 | |||
| 3607554902 | |||
| a25a77a43c | |||
| 861eff4686 | |||
| 99a4567e32 | |||
| 559c6b3831 | |||
| 631e5010b5 | |||
| 09e377ecd7 | |||
| deafcdc84b | |||
| 66d401461c | |||
| 01ae164b61 | |||
| 029c190c05 | |||
| 477d0c8fdf | |||
| 03af39def9 | |||
| dc7e0c805c | |||
| 2b010fadda | |||
| c25e753f35 | |||
| d3c8b8cadd | |||
| a3a0d7afca | |||
| ab2b68c93c | |||
| 7a46c81897 | |||
| e59e517d5c |
@@ -343,6 +343,11 @@ RATE_LIMIT_STORAGE=redis
|
|||||||
# DISCORD_CONTROL_CHANNEL_ID=channel-id-for-commands
|
# DISCORD_CONTROL_CHANNEL_ID=channel-id-for-commands
|
||||||
# DISCORD_WORKSPACE_ID=your-workspace-uuid
|
# DISCORD_WORKSPACE_ID=your-workspace-uuid
|
||||||
#
|
#
|
||||||
|
# Agent channel routing: Maps Discord channels to specific agents.
|
||||||
|
# Format: <channelId>:<agentName>,<channelId>:<agentName>
|
||||||
|
# Example: 123456789:jarvis,987654321:builder
|
||||||
|
# DISCORD_AGENT_CHANNELS=
|
||||||
|
#
|
||||||
# SECURITY: DISCORD_WORKSPACE_ID must be a valid workspace UUID from your database.
|
# SECURITY: DISCORD_WORKSPACE_ID must be a valid workspace UUID from your database.
|
||||||
# All Discord commands will execute within this workspace context for proper
|
# All Discord commands will execute within this workspace context for proper
|
||||||
# multi-tenant isolation. Each Discord bot instance should be configured for
|
# multi-tenant isolation. Each Discord bot instance should be configured for
|
||||||
|
|||||||
@@ -1,56 +1,56 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"mission_id": "ms21-multi-tenant-rbac-data-migration-20260228",
|
"mission_id": "ms22-p2-named-agent-fleet-20260304",
|
||||||
"name": "MS21 Multi-Tenant RBAC Data Migration",
|
"name": "MS22-P2 Named Agent Fleet",
|
||||||
"description": "Build multi-tenant user/workspace/team management, break-glass auth, RBAC UI enforcement, and migrate jarvis-brain data into Mosaic Stack",
|
"description": "",
|
||||||
"project_path": "/home/jwoltje/src/mosaic-stack",
|
"project_path": "/home/jwoltje/src/mosaic-stack",
|
||||||
"created_at": "2026-02-28T17:10:22Z",
|
"created_at": "2026-03-05T01:53:28Z",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"task_prefix": "MS21",
|
"task_prefix": "",
|
||||||
"quality_gates": "pnpm lint && pnpm build && pnpm test",
|
"quality_gates": "",
|
||||||
"milestone_version": "0.0.21",
|
"milestone_version": "0.0.1",
|
||||||
"milestones": [
|
"milestones": [
|
||||||
{
|
{
|
||||||
"id": "phase-1",
|
"id": "phase-1",
|
||||||
"name": "Schema and Admin API",
|
"name": "Schema+Seed",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"branch": "schema-and-admin-api",
|
"branch": "schema-seed",
|
||||||
"issue_ref": "",
|
"issue_ref": "",
|
||||||
"started_at": "",
|
"started_at": "",
|
||||||
"completed_at": ""
|
"completed_at": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "phase-2",
|
"id": "phase-2",
|
||||||
"name": "Break-Glass Authentication",
|
"name": "Admin CRUD",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"branch": "break-glass-authentication",
|
"branch": "admin-crud",
|
||||||
"issue_ref": "",
|
"issue_ref": "",
|
||||||
"started_at": "",
|
"started_at": "",
|
||||||
"completed_at": ""
|
"completed_at": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "phase-3",
|
"id": "phase-3",
|
||||||
"name": "Data Migration",
|
"name": "User CRUD",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"branch": "data-migration",
|
"branch": "user-crud",
|
||||||
"issue_ref": "",
|
"issue_ref": "",
|
||||||
"started_at": "",
|
"started_at": "",
|
||||||
"completed_at": ""
|
"completed_at": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "phase-4",
|
"id": "phase-4",
|
||||||
"name": "Admin UI",
|
"name": "Agent Routing",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"branch": "admin-ui",
|
"branch": "agent-routing",
|
||||||
"issue_ref": "",
|
"issue_ref": "",
|
||||||
"started_at": "",
|
"started_at": "",
|
||||||
"completed_at": ""
|
"completed_at": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "phase-5",
|
"id": "phase-5",
|
||||||
"name": "RBAC UI Enforcement",
|
"name": "Discord+UI",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"branch": "rbac-ui-enforcement",
|
"branch": "discord-ui",
|
||||||
"issue_ref": "",
|
"issue_ref": "",
|
||||||
"started_at": "",
|
"started_at": "",
|
||||||
"completed_at": ""
|
"completed_at": ""
|
||||||
@@ -65,26 +65,5 @@
|
|||||||
"completed_at": ""
|
"completed_at": ""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"sessions": [
|
"sessions": []
|
||||||
{
|
|
||||||
"session_id": "sess-001",
|
|
||||||
"runtime": "unknown",
|
|
||||||
"started_at": "2026-02-28T17:48:51Z",
|
|
||||||
"ended_at": "",
|
|
||||||
"ended_reason": "",
|
|
||||||
"milestone_at_end": "",
|
|
||||||
"tasks_completed": [],
|
|
||||||
"last_task_id": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"session_id": "sess-002",
|
|
||||||
"runtime": "unknown",
|
|
||||||
"started_at": "2026-02-28T20:30:13Z",
|
|
||||||
"ended_at": "",
|
|
||||||
"ended_reason": "",
|
|
||||||
"milestone_at_end": "",
|
|
||||||
"tasks_completed": [],
|
|
||||||
"last_task_id": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"session_id": "sess-002",
|
|
||||||
"runtime": "unknown",
|
|
||||||
"pid": 3178395,
|
|
||||||
"started_at": "2026-02-28T20:30:13Z",
|
|
||||||
"project_path": "/tmp/ms21-ui-001",
|
|
||||||
"milestone_id": ""
|
|
||||||
}
|
|
||||||
2
.npmrc
2
.npmrc
@@ -1 +1,3 @@
|
|||||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
|
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
|
||||||
|
supportedArchitectures[libc][]=glibc
|
||||||
|
supportedArchitectures[cpu][]=x64
|
||||||
|
|||||||
27
.woodpecker/base-image.yml
Normal file
27
.woodpecker/base-image.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
when:
|
||||||
|
- event: manual
|
||||||
|
- event: cron
|
||||||
|
cron: weekly-base-image
|
||||||
|
|
||||||
|
variables:
|
||||||
|
- &kaniko_setup |
|
||||||
|
mkdir -p /kaniko/.docker
|
||||||
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||||
|
|
||||||
|
steps:
|
||||||
|
build-base:
|
||||||
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
environment:
|
||||||
|
GITEA_USER:
|
||||||
|
from_secret: gitea_username
|
||||||
|
GITEA_TOKEN:
|
||||||
|
from_secret: gitea_token
|
||||||
|
commands:
|
||||||
|
- *kaniko_setup
|
||||||
|
- /kaniko/executor
|
||||||
|
--context .
|
||||||
|
--dockerfile docker/base.Dockerfile
|
||||||
|
--destination git.mosaicstack.dev/mosaic/node-base:24-slim
|
||||||
|
--destination git.mosaicstack.dev/mosaic/node-base:latest
|
||||||
|
--cache=true
|
||||||
|
--cache-repo git.mosaicstack.dev/mosaic/node-base/cache
|
||||||
@@ -29,9 +29,11 @@ when:
|
|||||||
- ".trivyignore"
|
- ".trivyignore"
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
- &node_image "node:24-alpine"
|
- &node_image "node:24-slim"
|
||||||
- &install_deps |
|
- &install_deps |
|
||||||
corepack enable
|
corepack enable
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends python3 make g++
|
||||||
|
pnpm config set store-dir /root/.local/share/pnpm/store
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
- &use_deps |
|
- &use_deps |
|
||||||
corepack enable
|
corepack enable
|
||||||
@@ -168,7 +170,7 @@ steps:
|
|||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-api/cache $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
@@ -193,7 +195,7 @@ steps:
|
|||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-orchestrator/cache $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
@@ -218,7 +220,7 @@ steps:
|
|||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
|
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-web/cache --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
@@ -335,3 +337,47 @@ steps:
|
|||||||
- security-trivy-api
|
- security-trivy-api
|
||||||
- security-trivy-orchestrator
|
- security-trivy-orchestrator
|
||||||
- security-trivy-web
|
- security-trivy-web
|
||||||
|
|
||||||
|
# ─── Deploy to Docker Swarm via Portainer API (main only) ─────────────────────
|
||||||
|
|
||||||
|
deploy-swarm:
|
||||||
|
image: alpine:3
|
||||||
|
failure: ignore
|
||||||
|
environment:
|
||||||
|
PORTAINER_URL:
|
||||||
|
from_secret: portainer_url
|
||||||
|
PORTAINER_API_KEY:
|
||||||
|
from_secret: portainer_api_key
|
||||||
|
PORTAINER_STACK_ID: "121"
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache curl
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
echo "🚀 Deploying to Docker Swarm via Portainer API..."
|
||||||
|
|
||||||
|
# Use Portainer API to update the stack (forces pull of new images)
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "X-API-Key: $PORTAINER_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"$PORTAINER_URL/api/stacks/$PORTAINER_STACK_ID/git/redeploy")
|
||||||
|
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||||
|
BODY=$(echo "$RESPONSE" | head -n -1)
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "202" ]; then
|
||||||
|
echo "✅ Stack update triggered successfully"
|
||||||
|
else
|
||||||
|
echo "❌ Stack update failed (HTTP $HTTP_CODE)"
|
||||||
|
echo "$BODY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for services to converge
|
||||||
|
echo "⏳ Waiting for services to converge..."
|
||||||
|
sleep 30
|
||||||
|
echo "✅ Deploy complete"
|
||||||
|
when:
|
||||||
|
- branch: [main]
|
||||||
|
event: [push, manual, tag]
|
||||||
|
depends_on:
|
||||||
|
- link-packages
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Base image for all stages
|
# Base image for all stages
|
||||||
# Uses Debian slim (glibc) instead of Alpine (musl) because native Node.js addons
|
# Uses Debian slim (glibc) instead of Alpine (musl) because native Node.js addons
|
||||||
# (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries.
|
# (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries.
|
||||||
FROM node:24-slim AS base
|
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
|
||||||
|
|
||||||
# Install pnpm globally
|
# Install pnpm globally
|
||||||
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
||||||
@@ -19,9 +19,9 @@ COPY turbo.json ./
|
|||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|
||||||
# Install build tools for native addons (node-pty requires node-gyp compilation)
|
# Install build tools for native addons (node-pty requires node-gyp compilation)
|
||||||
# and OpenSSL for Prisma engine detection
|
# Note: openssl and ca-certificates pre-installed in base image
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
python3 make g++ openssl \
|
python3 make g++ \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy all package.json files for workspace resolution
|
# Copy all package.json files for workspace resolution
|
||||||
@@ -30,6 +30,9 @@ COPY packages/ui/package.json ./packages/ui/
|
|||||||
COPY packages/config/package.json ./packages/config/
|
COPY packages/config/package.json ./packages/config/
|
||||||
COPY apps/api/package.json ./apps/api/
|
COPY apps/api/package.json ./apps/api/
|
||||||
|
|
||||||
|
# Copy npm configuration for native binary architecture hints
|
||||||
|
COPY .npmrc ./
|
||||||
|
|
||||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
||||||
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
|
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
|
||||||
# scripts or fail to find prebuilt binaries for this Node.js version
|
# scripts or fail to find prebuilt binaries for this Node.js version
|
||||||
@@ -61,19 +64,14 @@ RUN pnpm turbo build --filter=@mosaic/api --force
|
|||||||
# ======================
|
# ======================
|
||||||
# Production stage
|
# Production stage
|
||||||
# ======================
|
# ======================
|
||||||
FROM node:24-slim AS production
|
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
|
||||||
|
|
||||||
# Install dumb-init for proper signal handling (static binary from GitHub,
|
# dumb-init, openssl, ca-certificates pre-installed in base image
|
||||||
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors)
|
|
||||||
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
|
|
||||||
|
|
||||||
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||||
# - openssl: Prisma engine detection requires libssl
|
# - Remove npm/npx to reduce image size (not used in production)
|
||||||
# - No build tools needed here — native addons are compiled in the deps stage
|
# - Create non-root user
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
|
||||||
&& chmod 755 /usr/local/bin/dumb-init \
|
|
||||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"@nestjs/mapped-types": "^2.1.0",
|
"@nestjs/mapped-types": "^2.1.0",
|
||||||
"@nestjs/platform-express": "^11.1.12",
|
"@nestjs/platform-express": "^11.1.12",
|
||||||
"@nestjs/platform-socket.io": "^11.1.12",
|
"@nestjs/platform-socket.io": "^11.1.12",
|
||||||
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.12",
|
"@nestjs/websockets": "^11.1.12",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
@@ -59,7 +60,9 @@
|
|||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
|
"dockerode": "^4.0.9",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"ioredis": "^5.9.2",
|
"ioredis": "^5.9.2",
|
||||||
"jose": "^6.1.3",
|
"jose": "^6.1.3",
|
||||||
@@ -88,6 +91,7 @@
|
|||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
|
"@types/dockerode": "^3.3.47",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/highlight.js": "^10.1.0",
|
"@types/highlight.js": "^10.1.0",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SystemConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"encrypted" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BreakglassUser" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "BreakglassUser_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LlmProvider" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"baseUrl" TEXT,
|
||||||
|
"apiKey" TEXT,
|
||||||
|
"apiType" TEXT NOT NULL DEFAULT 'openai-completions',
|
||||||
|
"models" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "LlmProvider_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserContainer" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"containerId" TEXT,
|
||||||
|
"containerName" TEXT NOT NULL,
|
||||||
|
"gatewayPort" INTEGER,
|
||||||
|
"gatewayToken" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'stopped',
|
||||||
|
"lastActiveAt" TIMESTAMP(3),
|
||||||
|
"idleTimeoutMin" INTEGER NOT NULL DEFAULT 30,
|
||||||
|
"config" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "UserContainer_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SystemContainer" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL,
|
||||||
|
"containerId" TEXT,
|
||||||
|
"gatewayPort" INTEGER,
|
||||||
|
"gatewayToken" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'stopped',
|
||||||
|
"primaryModel" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SystemContainer_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserAgentConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"primaryModel" TEXT,
|
||||||
|
"fallbackModels" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"personality" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "UserAgentConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "BreakglassUser_username_key" ON "BreakglassUser"("username");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "LlmProvider_userId_idx" ON "LlmProvider"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "LlmProvider_userId_name_key" ON "LlmProvider"("userId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UserContainer_userId_key" ON "UserContainer"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SystemContainer_name_key" ON "SystemContainer"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UserAgentConfig_userId_key" ON "UserAgentConfig"("userId");
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- MS21: Add admin, local auth, and invitation fields to users table
|
||||||
|
-- These columns were added to schema.prisma but never captured in a migration.
|
||||||
|
|
||||||
|
ALTER TABLE "users"
|
||||||
|
ADD COLUMN IF NOT EXISTS "deactivated_at" TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS "is_local_auth" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS "password_hash" TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS "invited_by" UUID,
|
||||||
|
ADD COLUMN IF NOT EXISTS "invitation_token" TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS "invited_at" TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "users_invitation_token_key" ON "users"("invitation_token");
|
||||||
@@ -1625,3 +1625,117 @@ model ConversationArchive {
|
|||||||
@@index([startedAt])
|
@@index([startedAt])
|
||||||
@@map("conversation_archives")
|
@@map("conversation_archives")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AGENT FLEET MODULE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model SystemConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
value String
|
||||||
|
encrypted Boolean @default(false)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model BreakglassUser {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
username String @unique
|
||||||
|
passwordHash String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model LlmProvider {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
name String
|
||||||
|
displayName String
|
||||||
|
type String
|
||||||
|
baseUrl String?
|
||||||
|
apiKey String?
|
||||||
|
apiType String @default("openai-completions")
|
||||||
|
models Json @default("[]")
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, name])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserContainer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
containerId String?
|
||||||
|
containerName String
|
||||||
|
gatewayPort Int?
|
||||||
|
gatewayToken String
|
||||||
|
status String @default("stopped")
|
||||||
|
lastActiveAt DateTime?
|
||||||
|
idleTimeoutMin Int @default(30)
|
||||||
|
config Json @default("{}")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model SystemContainer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
role String
|
||||||
|
containerId String?
|
||||||
|
gatewayPort Int?
|
||||||
|
gatewayToken String
|
||||||
|
status String @default("stopped")
|
||||||
|
primaryModel String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserAgentConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
primaryModel String?
|
||||||
|
fallbackModels Json @default("[]")
|
||||||
|
personality String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model AgentTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique // "jarvis", "builder", "medic"
|
||||||
|
displayName String // "Jarvis", "Builder", "Medic"
|
||||||
|
role String // "orchestrator" | "coding" | "monitoring"
|
||||||
|
personality String // SOUL.md content (markdown)
|
||||||
|
primaryModel String // "opus", "codex", "haiku"
|
||||||
|
fallbackModels Json @default("[]") // ["sonnet", "haiku"]
|
||||||
|
toolPermissions Json @default("[]") // ["exec", "read", "write", ...]
|
||||||
|
discordChannel String? // "jarvis", "builder", "medic-alerts"
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
isDefault Boolean @default(false) // Include in new user provisioning
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserAgent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
templateId String? // null = custom agent
|
||||||
|
name String // "jarvis", "builder", "medic" or custom
|
||||||
|
displayName String
|
||||||
|
role String
|
||||||
|
personality String // User can customize
|
||||||
|
primaryModel String?
|
||||||
|
fallbackModels Json @default("[]")
|
||||||
|
toolPermissions Json @default("[]")
|
||||||
|
discordChannel String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, name])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
EntryStatus,
|
EntryStatus,
|
||||||
Visibility,
|
Visibility,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
|
import { seedAgentTemplates } from "../src/seed/agent-templates.seed";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -586,6 +587,9 @@ This is a draft document. See [[architecture-overview]] for current state.`,
|
|||||||
|
|
||||||
console.log(`Created ${links.length} knowledge links`);
|
console.log(`Created ${links.length} knowledge links`);
|
||||||
});
|
});
|
||||||
|
// Seed default agent templates (idempotent)
|
||||||
|
await seedAgentTemplates(prisma);
|
||||||
|
|
||||||
console.log("Seeding completed successfully!");
|
console.log("Seeding completed successfully!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller, Get, Query, Param, UseGuards } from "@nestjs/common";
|
import { Controller, Get, Query, Param, UseGuards } from "@nestjs/common";
|
||||||
import { ActivityService } from "./activity.service";
|
import { ActivityService } from "./activity.service";
|
||||||
import { EntityType } from "@prisma/client";
|
import { EntityType } from "@prisma/client";
|
||||||
import type { QueryActivityLogDto } from "./dto";
|
import { QueryActivityLogDto } from "./dto";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||||
|
|||||||
@@ -117,12 +117,13 @@ export class ActivityService {
|
|||||||
/**
|
/**
|
||||||
* Get a single activity log by ID
|
* Get a single activity log by ID
|
||||||
*/
|
*/
|
||||||
async findOne(id: string, workspaceId: string): Promise<ActivityLogResult | null> {
|
async findOne(id: string, workspaceId?: string): Promise<ActivityLogResult | null> {
|
||||||
|
const where: Prisma.ActivityLogWhereUniqueInput = { id };
|
||||||
|
if (workspaceId) {
|
||||||
|
where.workspaceId = workspaceId;
|
||||||
|
}
|
||||||
return await this.prisma.activityLog.findUnique({
|
return await this.prisma.activityLog.findUnique({
|
||||||
where: {
|
where,
|
||||||
id,
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -384,10 +384,18 @@ describe("ActivityLoggingInterceptor", () => {
|
|||||||
const context = createMockExecutionContext("POST", {}, body, user);
|
const context = createMockExecutionContext("POST", {}, body, user);
|
||||||
const next = createMockCallHandler(result);
|
const next = createMockCallHandler(result);
|
||||||
|
|
||||||
|
mockActivityService.logActivity.mockResolvedValue({
|
||||||
|
id: "activity-123",
|
||||||
|
});
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
interceptor.intercept(context, next).subscribe(() => {
|
interceptor.intercept(context, next).subscribe(() => {
|
||||||
// Should not call logActivity when workspaceId is missing
|
// workspaceId is now optional, so logActivity should be called without it
|
||||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
expect(mockActivityService.logActivity).toHaveBeenCalled();
|
||||||
|
const callArgs = mockActivityService.logActivity.mock.calls[0][0];
|
||||||
|
expect(callArgs.userId).toBe("user-123");
|
||||||
|
expect(callArgs.entityId).toBe("task-123");
|
||||||
|
expect(callArgs.workspaceId).toBeUndefined();
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -412,10 +420,18 @@ describe("ActivityLoggingInterceptor", () => {
|
|||||||
const context = createMockExecutionContext("POST", {}, body, user);
|
const context = createMockExecutionContext("POST", {}, body, user);
|
||||||
const next = createMockCallHandler(result);
|
const next = createMockCallHandler(result);
|
||||||
|
|
||||||
|
mockActivityService.logActivity.mockResolvedValue({
|
||||||
|
id: "activity-123",
|
||||||
|
});
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
interceptor.intercept(context, next).subscribe(() => {
|
interceptor.intercept(context, next).subscribe(() => {
|
||||||
// Should not call logActivity when workspaceId is missing
|
// workspaceId is now optional, so logActivity should be called without it
|
||||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
expect(mockActivityService.logActivity).toHaveBeenCalled();
|
||||||
|
const callArgs = mockActivityService.logActivity.mock.calls[0][0];
|
||||||
|
expect(callArgs.userId).toBe("user-123");
|
||||||
|
expect(callArgs.entityId).toBe("task-123");
|
||||||
|
expect(callArgs.workspaceId).toBeUndefined();
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { tap } from "rxjs/operators";
|
|||||||
import { ActivityService } from "../activity.service";
|
import { ActivityService } from "../activity.service";
|
||||||
import { ActivityAction, EntityType } from "@prisma/client";
|
import { ActivityAction, EntityType } from "@prisma/client";
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import type { CreateActivityLogInput } from "../interfaces/activity.interface";
|
||||||
import type { AuthenticatedRequest } from "../../common/types/user.types";
|
import type { AuthenticatedRequest } from "../../common/types/user.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,10 +62,13 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
|
|||||||
// Extract entity information
|
// Extract entity information
|
||||||
const resultObj = result as Record<string, unknown> | undefined;
|
const resultObj = result as Record<string, unknown> | undefined;
|
||||||
const entityId = params.id ?? (resultObj?.id as string | undefined);
|
const entityId = params.id ?? (resultObj?.id as string | undefined);
|
||||||
|
|
||||||
|
// workspaceId is now optional - log events even when missing
|
||||||
const workspaceId = user.workspaceId ?? (body.workspaceId as string | undefined);
|
const workspaceId = user.workspaceId ?? (body.workspaceId as string | undefined);
|
||||||
|
|
||||||
if (!entityId || !workspaceId) {
|
// Log with warning if entityId is missing, but still proceed with logging if workspaceId exists
|
||||||
this.logger.warn("Cannot log activity: missing entityId or workspaceId");
|
if (!entityId) {
|
||||||
|
this.logger.warn("Cannot log activity: missing entityId");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +96,8 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
|
|||||||
const userAgent =
|
const userAgent =
|
||||||
typeof userAgentHeader === "string" ? userAgentHeader : userAgentHeader?.[0];
|
typeof userAgentHeader === "string" ? userAgentHeader : userAgentHeader?.[0];
|
||||||
|
|
||||||
// Log the activity
|
// Log the activity — workspaceId is optional
|
||||||
await this.activityService.logActivity({
|
const activityInput: CreateActivityLogInput = {
|
||||||
workspaceId,
|
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
action,
|
action,
|
||||||
entityType,
|
entityType,
|
||||||
@@ -102,7 +105,11 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
|
|||||||
details,
|
details,
|
||||||
ipAddress: ip ?? undefined,
|
ipAddress: ip ?? undefined,
|
||||||
userAgent: userAgent ?? undefined,
|
userAgent: userAgent ?? undefined,
|
||||||
});
|
};
|
||||||
|
if (workspaceId) {
|
||||||
|
activityInput.workspaceId = workspaceId;
|
||||||
|
}
|
||||||
|
await this.activityService.logActivity(activityInput);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Don't fail the request if activity logging fails
|
// Don't fail the request if activity logging fails
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import type { ActivityAction, EntityType, Prisma } from "@prisma/client";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for creating a new activity log entry
|
* Interface for creating a new activity log entry
|
||||||
|
* workspaceId is optional - allows logging events without workspace context
|
||||||
*/
|
*/
|
||||||
export interface CreateActivityLogInput {
|
export interface CreateActivityLogInput {
|
||||||
workspaceId: string;
|
workspaceId?: string | null;
|
||||||
userId: string;
|
userId: string;
|
||||||
action: ActivityAction;
|
action: ActivityAction;
|
||||||
entityType: EntityType;
|
entityType: EntityType;
|
||||||
|
|||||||
40
apps/api/src/agent-config/agent-config.controller.ts
Normal file
40
apps/api/src/agent-config/agent-config.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Req,
|
||||||
|
UnauthorizedException,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { AgentConfigService } from "./agent-config.service";
|
||||||
|
import { AgentConfigGuard, type AgentConfigRequest } from "./agent-config.guard";
|
||||||
|
|
||||||
|
@Controller("internal")
|
||||||
|
@UseGuards(AgentConfigGuard)
|
||||||
|
export class AgentConfigController {
|
||||||
|
constructor(private readonly agentConfigService: AgentConfigService) {}
|
||||||
|
|
||||||
|
// GET /api/internal/agent-config/:id
|
||||||
|
// Auth: Bearer token (validated against UserContainer.gatewayToken or SystemContainer.gatewayToken)
|
||||||
|
// Returns: assembled openclaw.json
|
||||||
|
//
|
||||||
|
// The :id param is the container record ID (cuid)
|
||||||
|
// Token must match the container requesting its own config
|
||||||
|
@Get("agent-config/:id")
|
||||||
|
async getAgentConfig(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Req() request: AgentConfigRequest
|
||||||
|
): Promise<object> {
|
||||||
|
const containerAuth = request.containerAuth;
|
||||||
|
if (!containerAuth) {
|
||||||
|
throw new UnauthorizedException("Missing container authentication context");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerAuth.id !== id) {
|
||||||
|
throw new ForbiddenException("Token is not authorized for the requested container");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.agentConfigService.generateConfigForContainer(containerAuth.type, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/api/src/agent-config/agent-config.guard.ts
Normal file
43
apps/api/src/agent-config/agent-config.guard.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { AgentConfigService, type ContainerTokenValidation } from "./agent-config.service";
|
||||||
|
|
||||||
|
export interface AgentConfigRequest extends Request {
|
||||||
|
containerAuth?: ContainerTokenValidation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentConfigGuard implements CanActivate {
|
||||||
|
constructor(private readonly agentConfigService: AgentConfigService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<AgentConfigRequest>();
|
||||||
|
const token = this.extractBearerToken(request.headers.authorization);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException("Missing Bearer token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerAuth = await this.agentConfigService.validateContainerToken(token);
|
||||||
|
if (!containerAuth) {
|
||||||
|
throw new UnauthorizedException("Invalid container token");
|
||||||
|
}
|
||||||
|
|
||||||
|
request.containerAuth = containerAuth;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractBearerToken(headerValue: string | string[] | undefined): string | null {
|
||||||
|
const normalizedHeader = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
||||||
|
if (!normalizedHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [scheme, token] = normalizedHeader.split(" ");
|
||||||
|
if (!scheme || !token || scheme.toLowerCase() !== "bearer") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/agent-config/agent-config.module.ts
Normal file
14
apps/api/src/agent-config/agent-config.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { CryptoModule } from "../crypto/crypto.module";
|
||||||
|
import { AgentConfigController } from "./agent-config.controller";
|
||||||
|
import { AgentConfigService } from "./agent-config.service";
|
||||||
|
import { AgentConfigGuard } from "./agent-config.guard";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, CryptoModule],
|
||||||
|
controllers: [AgentConfigController],
|
||||||
|
providers: [AgentConfigService, AgentConfigGuard],
|
||||||
|
exports: [AgentConfigService],
|
||||||
|
})
|
||||||
|
export class AgentConfigModule {}
|
||||||
215
apps/api/src/agent-config/agent-config.service.spec.ts
Normal file
215
apps/api/src/agent-config/agent-config.service.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { AgentConfigService } from "./agent-config.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CryptoService } from "../crypto/crypto.service";
|
||||||
|
|
||||||
|
describe("AgentConfigService", () => {
|
||||||
|
let service: AgentConfigService;
|
||||||
|
|
||||||
|
const mockPrismaService = {
|
||||||
|
userAgentConfig: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
llmProvider: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
userContainer: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
systemContainer: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCryptoService = {
|
||||||
|
isEncrypted: vi.fn((value: string) => value.startsWith("enc:")),
|
||||||
|
decrypt: vi.fn((value: string) => value.replace(/^enc:/, "")),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
service = new AgentConfigService(
|
||||||
|
mockPrismaService as unknown as PrismaService,
|
||||||
|
mockCryptoService as unknown as CryptoService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generateUserConfig returns valid openclaw.json structure", async () => {
|
||||||
|
mockPrismaService.userAgentConfig.findUnique.mockResolvedValue({
|
||||||
|
id: "cfg-1",
|
||||||
|
userId: "user-1",
|
||||||
|
primaryModel: "my-zai/glm-5",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrismaService.userContainer.findUnique.mockResolvedValue({
|
||||||
|
id: "container-1",
|
||||||
|
userId: "user-1",
|
||||||
|
gatewayPort: 19001,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrismaService.llmProvider.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "provider-1",
|
||||||
|
userId: "user-1",
|
||||||
|
name: "my-zai",
|
||||||
|
displayName: "Z.ai",
|
||||||
|
type: "zai",
|
||||||
|
baseUrl: "https://api.z.ai/v1",
|
||||||
|
apiKey: "enc:secret-zai-key",
|
||||||
|
apiType: "openai-completions",
|
||||||
|
models: [{ id: "glm-5" }],
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.generateUserConfig("user-1");
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
gateway: {
|
||||||
|
mode: "local",
|
||||||
|
port: 19001,
|
||||||
|
bind: "lan",
|
||||||
|
auth: { mode: "token" },
|
||||||
|
http: {
|
||||||
|
endpoints: {
|
||||||
|
chatCompletions: { enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: "my-zai/glm-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
"my-zai": {
|
||||||
|
apiKey: "secret-zai-key",
|
||||||
|
baseUrl: "https://api.z.ai/v1",
|
||||||
|
models: {
|
||||||
|
"glm-5": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generateUserConfig decrypts API keys correctly", async () => {
|
||||||
|
mockPrismaService.userAgentConfig.findUnique.mockResolvedValue({
|
||||||
|
id: "cfg-1",
|
||||||
|
userId: "user-1",
|
||||||
|
primaryModel: "openai-work/gpt-4.1",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrismaService.userContainer.findUnique.mockResolvedValue({
|
||||||
|
id: "container-1",
|
||||||
|
userId: "user-1",
|
||||||
|
gatewayPort: 18789,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrismaService.llmProvider.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "provider-1",
|
||||||
|
userId: "user-1",
|
||||||
|
name: "openai-work",
|
||||||
|
displayName: "OpenAI Work",
|
||||||
|
type: "openai",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
apiKey: "enc:encrypted-openai-key",
|
||||||
|
apiType: "openai-completions",
|
||||||
|
models: [{ id: "gpt-4.1" }],
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.generateUserConfig("user-1");
|
||||||
|
|
||||||
|
expect(mockCryptoService.decrypt).toHaveBeenCalledWith("enc:encrypted-openai-key");
|
||||||
|
expect(result.models.providers["openai-work"]?.apiKey).toBe("encrypted-openai-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generateUserConfig handles user with no providers", async () => {
|
||||||
|
mockPrismaService.userAgentConfig.findUnique.mockResolvedValue({
|
||||||
|
id: "cfg-1",
|
||||||
|
userId: "user-2",
|
||||||
|
primaryModel: "openai/gpt-4o-mini",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrismaService.userContainer.findUnique.mockResolvedValue({
|
||||||
|
id: "container-2",
|
||||||
|
userId: "user-2",
|
||||||
|
gatewayPort: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrismaService.llmProvider.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.generateUserConfig("user-2");
|
||||||
|
|
||||||
|
expect(result.models.providers).toEqual({});
|
||||||
|
expect(result.gateway.port).toBe(18789);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateContainerToken returns correct type for user container", async () => {
|
||||||
|
mockPrismaService.userContainer.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "user-container-1",
|
||||||
|
gatewayToken: "enc:user-token-1",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockPrismaService.systemContainer.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.validateContainerToken("user-token-1");
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: "user",
|
||||||
|
id: "user-container-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateContainerToken returns correct type for system container", async () => {
|
||||||
|
mockPrismaService.userContainer.findMany.mockResolvedValue([]);
|
||||||
|
mockPrismaService.systemContainer.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "system-container-1",
|
||||||
|
gatewayToken: "enc:system-token-1",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.validateContainerToken("system-token-1");
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: "system",
|
||||||
|
id: "system-container-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateContainerToken returns null for invalid token", async () => {
|
||||||
|
mockPrismaService.userContainer.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "user-container-1",
|
||||||
|
gatewayToken: "enc:user-token-1",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPrismaService.systemContainer.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "system-container-1",
|
||||||
|
gatewayToken: "enc:system-token-1",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.validateContainerToken("no-match");
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
285
apps/api/src/agent-config/agent-config.service.ts
Normal file
285
apps/api/src/agent-config/agent-config.service.ts
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import type { LlmProvider } from "@prisma/client";
|
||||||
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CryptoService } from "../crypto/crypto.service";
|
||||||
|
|
||||||
|
const DEFAULT_GATEWAY_PORT = 18789;
|
||||||
|
const DEFAULT_PRIMARY_MODEL = "openai/gpt-4o-mini";
|
||||||
|
|
||||||
|
type ContainerType = "user" | "system";
|
||||||
|
|
||||||
|
export interface ContainerTokenValidation {
|
||||||
|
type: ContainerType;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenClawModelMap = Record<string, Record<string, never>>;
|
||||||
|
|
||||||
|
interface OpenClawProviderConfig {
|
||||||
|
apiKey?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
models: OpenClawModelMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenClawConfig {
|
||||||
|
gateway: {
|
||||||
|
mode: "local";
|
||||||
|
port: number;
|
||||||
|
bind: "lan";
|
||||||
|
auth: { mode: "token" };
|
||||||
|
http: {
|
||||||
|
endpoints: {
|
||||||
|
chatCompletions: { enabled: true };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
models: {
|
||||||
|
providers: Record<string, OpenClawProviderConfig>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentConfigService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly crypto: CryptoService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Generate complete openclaw.json for a user container
|
||||||
|
async generateUserConfig(userId: string): Promise<OpenClawConfig> {
|
||||||
|
const [userAgentConfig, providers, userContainer] = await Promise.all([
|
||||||
|
this.prisma.userAgentConfig.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
}),
|
||||||
|
this.prisma.llmProvider.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "asc",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.userContainer.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!userContainer) {
|
||||||
|
throw new NotFoundException(`User container not found for user ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryModel =
|
||||||
|
userAgentConfig?.primaryModel ??
|
||||||
|
this.resolvePrimaryModelFromProviders(providers) ??
|
||||||
|
DEFAULT_PRIMARY_MODEL;
|
||||||
|
|
||||||
|
return this.buildOpenClawConfig(primaryModel, userContainer.gatewayPort, providers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate config for a system container
|
||||||
|
async generateSystemConfig(containerId: string): Promise<OpenClawConfig> {
|
||||||
|
const systemContainer = await this.prisma.systemContainer.findUnique({
|
||||||
|
where: { id: containerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!systemContainer) {
|
||||||
|
throw new NotFoundException(`System container ${containerId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.buildOpenClawConfig(
|
||||||
|
systemContainer.primaryModel || DEFAULT_PRIMARY_MODEL,
|
||||||
|
systemContainer.gatewayPort,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateConfigForContainer(
|
||||||
|
type: ContainerType,
|
||||||
|
containerId: string
|
||||||
|
): Promise<OpenClawConfig> {
|
||||||
|
if (type === "system") {
|
||||||
|
return this.generateSystemConfig(containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userContainer = await this.prisma.userContainer.findUnique({
|
||||||
|
where: { id: containerId },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userContainer) {
|
||||||
|
throw new NotFoundException(`User container ${containerId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.generateUserConfig(userContainer.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a container's bearer token
|
||||||
|
async validateContainerToken(token: string): Promise<ContainerTokenValidation | null> {
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userContainers, systemContainers] = await Promise.all([
|
||||||
|
this.prisma.userContainer.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
gatewayToken: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.systemContainer.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
gatewayToken: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let match: ContainerTokenValidation | null = null;
|
||||||
|
|
||||||
|
for (const container of userContainers) {
|
||||||
|
const storedToken = this.decryptContainerToken(container.gatewayToken);
|
||||||
|
if (!match && storedToken && this.tokensEqual(storedToken, token)) {
|
||||||
|
match = { type: "user", id: container.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const container of systemContainers) {
|
||||||
|
const storedToken = this.decryptContainerToken(container.gatewayToken);
|
||||||
|
if (!match && storedToken && this.tokensEqual(storedToken, token)) {
|
||||||
|
match = { type: "system", id: container.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildOpenClawConfig(
|
||||||
|
primaryModel: string,
|
||||||
|
gatewayPort: number | null,
|
||||||
|
providers: LlmProvider[]
|
||||||
|
): OpenClawConfig {
|
||||||
|
return {
|
||||||
|
gateway: {
|
||||||
|
mode: "local",
|
||||||
|
port: gatewayPort ?? DEFAULT_GATEWAY_PORT,
|
||||||
|
bind: "lan",
|
||||||
|
auth: { mode: "token" },
|
||||||
|
http: {
|
||||||
|
endpoints: {
|
||||||
|
chatCompletions: { enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: primaryModel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
providers: this.buildProviderConfig(providers),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildProviderConfig(providers: LlmProvider[]): Record<string, OpenClawProviderConfig> {
|
||||||
|
const providerConfig: Record<string, OpenClawProviderConfig> = {};
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const config: OpenClawProviderConfig = {
|
||||||
|
models: this.extractModels(provider.models),
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiKey = this.decryptIfNeeded(provider.apiKey);
|
||||||
|
if (apiKey) {
|
||||||
|
config.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.baseUrl) {
|
||||||
|
config.baseUrl = provider.baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
providerConfig[provider.name] = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
return providerConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractModels(models: unknown): OpenClawModelMap {
|
||||||
|
const modelMap: OpenClawModelMap = {};
|
||||||
|
|
||||||
|
if (!Array.isArray(models)) {
|
||||||
|
return modelMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const modelEntry of models) {
|
||||||
|
if (typeof modelEntry === "string") {
|
||||||
|
modelMap[modelEntry] = {};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasModelId(modelEntry)) {
|
||||||
|
modelMap[modelEntry.id] = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modelMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePrimaryModelFromProviders(providers: LlmProvider[]): string | null {
|
||||||
|
for (const provider of providers) {
|
||||||
|
const modelIds = Object.keys(this.extractModels(provider.models));
|
||||||
|
const firstModelId = modelIds[0];
|
||||||
|
|
||||||
|
if (firstModelId) {
|
||||||
|
return `${provider.name}/${firstModelId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptIfNeeded(value: string | null | undefined): string | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.crypto.isEncrypted(value)) {
|
||||||
|
return this.crypto.decrypt(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptContainerToken(value: string): string | null {
|
||||||
|
try {
|
||||||
|
return this.decryptIfNeeded(value) ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private tokensEqual(left: string, right: string): boolean {
|
||||||
|
const leftDigest = createHash("sha256").update(left, "utf8").digest();
|
||||||
|
const rightDigest = createHash("sha256").update(right, "utf8").digest();
|
||||||
|
return timingSafeEqual(leftDigest, rightDigest);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasModelId(modelEntry: unknown): modelEntry is { id: string } {
|
||||||
|
if (typeof modelEntry !== "object" || modelEntry === null || !("id" in modelEntry)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof (modelEntry as { id?: unknown }).id === "string";
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/api/src/agent-template/agent-template.controller.ts
Normal file
47
apps/api/src/agent-template/agent-template.controller.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { AgentTemplateService } from "./agent-template.service";
|
||||||
|
import { CreateAgentTemplateDto } from "./dto/create-agent-template.dto";
|
||||||
|
import { UpdateAgentTemplateDto } from "./dto/update-agent-template.dto";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||||
|
|
||||||
|
@Controller("admin/agent-templates")
|
||||||
|
@UseGuards(AuthGuard, AdminGuard)
|
||||||
|
export class AgentTemplateController {
|
||||||
|
constructor(private readonly agentTemplateService: AgentTemplateService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll() {
|
||||||
|
return this.agentTemplateService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.agentTemplateService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateAgentTemplateDto) {
|
||||||
|
return this.agentTemplateService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateAgentTemplateDto) {
|
||||||
|
return this.agentTemplateService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.agentTemplateService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/agent-template/agent-template.module.ts
Normal file
12
apps/api/src/agent-template/agent-template.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AgentTemplateService } from "./agent-template.service";
|
||||||
|
import { AgentTemplateController } from "./agent-template.controller";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [AgentTemplateController],
|
||||||
|
providers: [AgentTemplateService],
|
||||||
|
exports: [AgentTemplateService],
|
||||||
|
})
|
||||||
|
export class AgentTemplateModule {}
|
||||||
57
apps/api/src/agent-template/agent-template.service.ts
Normal file
57
apps/api/src/agent-template/agent-template.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable, NotFoundException, ConflictException } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CreateAgentTemplateDto } from "./dto/create-agent-template.dto";
|
||||||
|
import { UpdateAgentTemplateDto } from "./dto/update-agent-template.dto";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentTemplateService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll() {
|
||||||
|
return this.prisma.agentTemplate.findMany({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const template = await this.prisma.agentTemplate.findUnique({ where: { id } });
|
||||||
|
if (!template) throw new NotFoundException(`AgentTemplate ${id} not found`);
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByName(name: string) {
|
||||||
|
const template = await this.prisma.agentTemplate.findUnique({ where: { name } });
|
||||||
|
if (!template) throw new NotFoundException(`AgentTemplate "${name}" not found`);
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateAgentTemplateDto) {
|
||||||
|
const existing = await this.prisma.agentTemplate.findUnique({ where: { name: dto.name } });
|
||||||
|
if (existing) throw new ConflictException(`AgentTemplate "${dto.name}" already exists`);
|
||||||
|
|
||||||
|
return this.prisma.agentTemplate.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
displayName: dto.displayName,
|
||||||
|
role: dto.role,
|
||||||
|
personality: dto.personality,
|
||||||
|
primaryModel: dto.primaryModel,
|
||||||
|
fallbackModels: dto.fallbackModels ?? ([] as string[]),
|
||||||
|
toolPermissions: dto.toolPermissions ?? ([] as string[]),
|
||||||
|
...(dto.discordChannel !== undefined && { discordChannel: dto.discordChannel }),
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
isDefault: dto.isDefault ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateAgentTemplateDto) {
|
||||||
|
await this.findOne(id);
|
||||||
|
return this.prisma.agentTemplate.update({ where: { id }, data: dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
await this.findOne(id);
|
||||||
|
return this.prisma.agentTemplate.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/api/src/agent-template/dto/create-agent-template.dto.ts
Normal file
43
apps/api/src/agent-template/dto/create-agent-template.dto.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { IsString, IsBoolean, IsOptional, IsArray, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateAgentTemplateDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
displayName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
role!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
personality!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
primaryModel!: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
fallbackModels?: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
toolPermissions?: string[];
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
discordChannel?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from "@nestjs/mapped-types";
|
||||||
|
import { CreateAgentTemplateDto } from "./create-agent-template.dto";
|
||||||
|
|
||||||
|
export class UpdateAgentTemplateDto extends PartialType(CreateAgentTemplateDto) {}
|
||||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
|||||||
import { APP_INTERCEPTOR, APP_GUARD } from "@nestjs/core";
|
import { APP_INTERCEPTOR, APP_GUARD } from "@nestjs/core";
|
||||||
import { ThrottlerModule } from "@nestjs/throttler";
|
import { ThrottlerModule } from "@nestjs/throttler";
|
||||||
import { BullModule } from "@nestjs/bullmq";
|
import { BullModule } from "@nestjs/bullmq";
|
||||||
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { ThrottlerValkeyStorageService, ThrottlerApiKeyGuard } from "./common/throttler";
|
import { ThrottlerValkeyStorageService, ThrottlerApiKeyGuard } from "./common/throttler";
|
||||||
import { CsrfGuard } from "./common/guards/csrf.guard";
|
import { CsrfGuard } from "./common/guards/csrf.guard";
|
||||||
import { CsrfService } from "./common/services/csrf.service";
|
import { CsrfService } from "./common/services/csrf.service";
|
||||||
@@ -47,10 +48,19 @@ import { TerminalModule } from "./terminal/terminal.module";
|
|||||||
import { PersonalitiesModule } from "./personalities/personalities.module";
|
import { PersonalitiesModule } from "./personalities/personalities.module";
|
||||||
import { WorkspacesModule } from "./workspaces/workspaces.module";
|
import { WorkspacesModule } from "./workspaces/workspaces.module";
|
||||||
import { AdminModule } from "./admin/admin.module";
|
import { AdminModule } from "./admin/admin.module";
|
||||||
|
import { AgentTemplateModule } from "./agent-template/agent-template.module";
|
||||||
|
import { UserAgentModule } from "./user-agent/user-agent.module";
|
||||||
import { TeamsModule } from "./teams/teams.module";
|
import { TeamsModule } from "./teams/teams.module";
|
||||||
import { ImportModule } from "./import/import.module";
|
import { ImportModule } from "./import/import.module";
|
||||||
import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module";
|
import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module";
|
||||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||||
|
import { AgentConfigModule } from "./agent-config/agent-config.module";
|
||||||
|
import { ContainerLifecycleModule } from "./container-lifecycle/container-lifecycle.module";
|
||||||
|
import { ContainerReaperModule } from "./container-reaper/container-reaper.module";
|
||||||
|
import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module";
|
||||||
|
import { OnboardingModule } from "./onboarding/onboarding.module";
|
||||||
|
import { ChatProxyModule } from "./chat-proxy/chat-proxy.module";
|
||||||
|
import { OrchestratorModule } from "./orchestrator/orchestrator.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -81,6 +91,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
};
|
};
|
||||||
})(),
|
})(),
|
||||||
}),
|
}),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
TelemetryModule,
|
TelemetryModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
@@ -120,9 +131,18 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
PersonalitiesModule,
|
PersonalitiesModule,
|
||||||
WorkspacesModule,
|
WorkspacesModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
AgentTemplateModule,
|
||||||
|
UserAgentModule,
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
ConversationArchiveModule,
|
ConversationArchiveModule,
|
||||||
|
AgentConfigModule,
|
||||||
|
ContainerLifecycleModule,
|
||||||
|
ContainerReaperModule,
|
||||||
|
FleetSettingsModule,
|
||||||
|
OnboardingModule,
|
||||||
|
ChatProxyModule,
|
||||||
|
OrchestratorModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export class AuthController {
|
|||||||
// @SkipCsrf avoids double-protection conflicts.
|
// @SkipCsrf avoids double-protection conflicts.
|
||||||
// See: https://www.better-auth.com/docs/reference/security
|
// See: https://www.better-auth.com/docs/reference/security
|
||||||
@SkipCsrf()
|
@SkipCsrf()
|
||||||
@Throttle({ strict: { limit: 10, ttl: 60000 } })
|
@Throttle({ default: { ttl: 60_000, limit: 5 } })
|
||||||
async handleAuth(@Req() req: ExpressRequest, @Res() res: ExpressResponse): Promise<void> {
|
async handleAuth(@Req() req: ExpressRequest, @Res() res: ExpressResponse): Promise<void> {
|
||||||
// Extract client IP for logging
|
// Extract client IP for logging
|
||||||
const clientIp = this.getClientIp(req);
|
const clientIp = this.getClientIp(req);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MatrixService } from "./matrix/matrix.service";
|
|||||||
import { StitcherService } from "../stitcher/stitcher.service";
|
import { StitcherService } from "../stitcher/stitcher.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { BullMqService } from "../bullmq/bullmq.service";
|
import { BullMqService } from "../bullmq/bullmq.service";
|
||||||
|
import { ChatProxyService } from "../chat-proxy/chat-proxy.service";
|
||||||
import { CHAT_PROVIDERS } from "./bridge.constants";
|
import { CHAT_PROVIDERS } from "./bridge.constants";
|
||||||
import type { IChatProvider } from "./interfaces";
|
import type { IChatProvider } from "./interfaces";
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
@@ -89,6 +90,7 @@ interface SavedEnvVars {
|
|||||||
MATRIX_CONTROL_ROOM_ID?: string;
|
MATRIX_CONTROL_ROOM_ID?: string;
|
||||||
MATRIX_WORKSPACE_ID?: string;
|
MATRIX_WORKSPACE_ID?: string;
|
||||||
ENCRYPTION_KEY?: string;
|
ENCRYPTION_KEY?: string;
|
||||||
|
MOSAIC_SECRET_KEY?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("BridgeModule", () => {
|
describe("BridgeModule", () => {
|
||||||
@@ -106,6 +108,7 @@ describe("BridgeModule", () => {
|
|||||||
MATRIX_CONTROL_ROOM_ID: process.env.MATRIX_CONTROL_ROOM_ID,
|
MATRIX_CONTROL_ROOM_ID: process.env.MATRIX_CONTROL_ROOM_ID,
|
||||||
MATRIX_WORKSPACE_ID: process.env.MATRIX_WORKSPACE_ID,
|
MATRIX_WORKSPACE_ID: process.env.MATRIX_WORKSPACE_ID,
|
||||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||||
|
MOSAIC_SECRET_KEY: process.env.MOSAIC_SECRET_KEY,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clear all bridge env vars
|
// Clear all bridge env vars
|
||||||
@@ -120,6 +123,8 @@ describe("BridgeModule", () => {
|
|||||||
|
|
||||||
// Set encryption key (needed by StitcherService)
|
// Set encryption key (needed by StitcherService)
|
||||||
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
// Set MOSAIC_SECRET_KEY (needed by CryptoService via ChatProxyModule)
|
||||||
|
process.env.MOSAIC_SECRET_KEY = "test-mosaic-secret-key-minimum-32-characters-long";
|
||||||
|
|
||||||
// Clear ready callbacks
|
// Clear ready callbacks
|
||||||
mockReadyCallbacks.length = 0;
|
mockReadyCallbacks.length = 0;
|
||||||
@@ -149,6 +154,10 @@ describe("BridgeModule", () => {
|
|||||||
.useValue({})
|
.useValue({})
|
||||||
.overrideProvider(BullMqService)
|
.overrideProvider(BullMqService)
|
||||||
.useValue({})
|
.useValue({})
|
||||||
|
.overrideProvider(ChatProxyService)
|
||||||
|
.useValue({
|
||||||
|
proxyChat: vi.fn().mockResolvedValue(new Response()),
|
||||||
|
})
|
||||||
.compile();
|
.compile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { MatrixRoomService } from "./matrix/matrix-room.service";
|
|||||||
import { MatrixStreamingService } from "./matrix/matrix-streaming.service";
|
import { MatrixStreamingService } from "./matrix/matrix-streaming.service";
|
||||||
import { CommandParserService } from "./parser/command-parser.service";
|
import { CommandParserService } from "./parser/command-parser.service";
|
||||||
import { StitcherModule } from "../stitcher/stitcher.module";
|
import { StitcherModule } from "../stitcher/stitcher.module";
|
||||||
|
import { ChatProxyModule } from "../chat-proxy/chat-proxy.module";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
import { CHAT_PROVIDERS } from "./bridge.constants";
|
import { CHAT_PROVIDERS } from "./bridge.constants";
|
||||||
import type { IChatProvider } from "./interfaces";
|
import type { IChatProvider } from "./interfaces";
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ const logger = new Logger("BridgeModule");
|
|||||||
* MatrixRoomService handles workspace-to-Matrix-room mapping.
|
* MatrixRoomService handles workspace-to-Matrix-room mapping.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [StitcherModule],
|
imports: [StitcherModule, ChatProxyModule, PrismaModule],
|
||||||
providers: [
|
providers: [
|
||||||
CommandParserService,
|
CommandParserService,
|
||||||
MatrixRoomService,
|
MatrixRoomService,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { DiscordService } from "./discord.service";
|
import { DiscordService } from "./discord.service";
|
||||||
import { StitcherService } from "../../stitcher/stitcher.service";
|
import { StitcherService } from "../../stitcher/stitcher.service";
|
||||||
|
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
|
||||||
|
import { PrismaService } from "../../prisma/prisma.service";
|
||||||
import { Client, Events, GatewayIntentBits, Message } from "discord.js";
|
import { Client, Events, GatewayIntentBits, Message } from "discord.js";
|
||||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||||
import type { ChatMessage, ChatCommand } from "../interfaces";
|
import type { ChatMessage, ChatCommand } from "../interfaces";
|
||||||
@@ -61,6 +63,8 @@ vi.mock("discord.js", () => {
|
|||||||
describe("DiscordService", () => {
|
describe("DiscordService", () => {
|
||||||
let service: DiscordService;
|
let service: DiscordService;
|
||||||
let stitcherService: StitcherService;
|
let stitcherService: StitcherService;
|
||||||
|
let chatProxyService: ChatProxyService;
|
||||||
|
let prismaService: PrismaService;
|
||||||
|
|
||||||
const mockStitcherService = {
|
const mockStitcherService = {
|
||||||
dispatchJob: vi.fn().mockResolvedValue({
|
dispatchJob: vi.fn().mockResolvedValue({
|
||||||
@@ -71,12 +75,29 @@ describe("DiscordService", () => {
|
|||||||
trackJobEvent: vi.fn().mockResolvedValue(undefined),
|
trackJobEvent: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockChatProxyService = {
|
||||||
|
proxyChat: vi.fn().mockResolvedValue(
|
||||||
|
new Response('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\ndata: [DONE]\n\n', {
|
||||||
|
headers: { "Content-Type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPrismaService = {
|
||||||
|
workspace: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
ownerId: "owner-user-id",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Set environment variables for testing
|
// Set environment variables for testing
|
||||||
process.env.DISCORD_BOT_TOKEN = "test-token";
|
process.env.DISCORD_BOT_TOKEN = "test-token";
|
||||||
process.env.DISCORD_GUILD_ID = "test-guild-id";
|
process.env.DISCORD_GUILD_ID = "test-guild-id";
|
||||||
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
|
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
|
||||||
process.env.DISCORD_WORKSPACE_ID = "test-workspace-id";
|
process.env.DISCORD_WORKSPACE_ID = "test-workspace-id";
|
||||||
|
process.env.DISCORD_AGENT_CHANNELS = "jarvis-channel:jarvis,builder-channel:builder";
|
||||||
|
|
||||||
// Clear callbacks
|
// Clear callbacks
|
||||||
mockReadyCallbacks.length = 0;
|
mockReadyCallbacks.length = 0;
|
||||||
@@ -89,11 +110,21 @@ describe("DiscordService", () => {
|
|||||||
provide: StitcherService,
|
provide: StitcherService,
|
||||||
useValue: mockStitcherService,
|
useValue: mockStitcherService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: ChatProxyService,
|
||||||
|
useValue: mockChatProxyService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<DiscordService>(DiscordService);
|
service = module.get<DiscordService>(DiscordService);
|
||||||
stitcherService = module.get<StitcherService>(StitcherService);
|
stitcherService = module.get<StitcherService>(StitcherService);
|
||||||
|
chatProxyService = module.get<ChatProxyService>(ChatProxyService);
|
||||||
|
prismaService = module.get<PrismaService>(PrismaService);
|
||||||
|
|
||||||
// Clear all mocks
|
// Clear all mocks
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -449,6 +480,14 @@ describe("DiscordService", () => {
|
|||||||
provide: StitcherService,
|
provide: StitcherService,
|
||||||
useValue: mockStitcherService,
|
useValue: mockStitcherService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: ChatProxyService,
|
||||||
|
useValue: mockChatProxyService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -470,6 +509,14 @@ describe("DiscordService", () => {
|
|||||||
provide: StitcherService,
|
provide: StitcherService,
|
||||||
useValue: mockStitcherService,
|
useValue: mockStitcherService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: ChatProxyService,
|
||||||
|
useValue: mockChatProxyService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -492,6 +539,14 @@ describe("DiscordService", () => {
|
|||||||
provide: StitcherService,
|
provide: StitcherService,
|
||||||
useValue: mockStitcherService,
|
useValue: mockStitcherService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: ChatProxyService,
|
||||||
|
useValue: mockChatProxyService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -654,4 +709,150 @@ describe("DiscordService", () => {
|
|||||||
expect(loggedError.statusCode).toBe(408);
|
expect(loggedError.statusCode).toBe(408);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Agent Channel Routing", () => {
|
||||||
|
it("should load agent channel mappings from environment", () => {
|
||||||
|
// The service should have loaded the agent channels from DISCORD_AGENT_CHANNELS
|
||||||
|
expect((service as any).agentChannels.size).toBe(2);
|
||||||
|
expect((service as any).agentChannels.get("jarvis-channel")).toBe("jarvis");
|
||||||
|
expect((service as any).agentChannels.get("builder-channel")).toBe("builder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty agent channels config", async () => {
|
||||||
|
delete process.env.DISCORD_AGENT_CHANNELS;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
DiscordService,
|
||||||
|
{
|
||||||
|
provide: StitcherService,
|
||||||
|
useValue: mockStitcherService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ChatProxyService,
|
||||||
|
useValue: mockChatProxyService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const newService = module.get<DiscordService>(DiscordService);
|
||||||
|
expect((newService as any).agentChannels.size).toBe(0);
|
||||||
|
|
||||||
|
// Restore for other tests
|
||||||
|
process.env.DISCORD_AGENT_CHANNELS = "jarvis-channel:jarvis,builder-channel:builder";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should route messages in agent channels to ChatProxyService", async () => {
|
||||||
|
const mockChannel = {
|
||||||
|
send: vi.fn().mockResolvedValue({}),
|
||||||
|
isTextBased: () => true,
|
||||||
|
sendTyping: vi.fn(),
|
||||||
|
};
|
||||||
|
(mockClient.channels.fetch as any).mockResolvedValue(mockChannel);
|
||||||
|
|
||||||
|
// Create a mock streaming response
|
||||||
|
const mockStreamResponse = new Response(
|
||||||
|
'data: {"choices":[{"delta":{"content":"Test response"}}]}\n\ndata: [DONE]\n\n',
|
||||||
|
{ headers: { "Content-Type": "text/event-stream" } }
|
||||||
|
);
|
||||||
|
mockChatProxyService.proxyChat.mockResolvedValue(mockStreamResponse);
|
||||||
|
|
||||||
|
await service.connect();
|
||||||
|
|
||||||
|
// Simulate a message in the jarvis channel
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-agent-1",
|
||||||
|
channelId: "jarvis-channel",
|
||||||
|
authorId: "user-1",
|
||||||
|
authorName: "TestUser",
|
||||||
|
content: "Hello Jarvis!",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call handleAgentChat directly
|
||||||
|
await (service as any).handleAgentChat(message, "jarvis");
|
||||||
|
|
||||||
|
// Verify ChatProxyService was called with workspace owner's ID and agent name
|
||||||
|
expect(mockChatProxyService.proxyChat).toHaveBeenCalledWith(
|
||||||
|
"owner-user-id",
|
||||||
|
[{ role: "user", content: "Hello Jarvis!" }],
|
||||||
|
undefined,
|
||||||
|
"jarvis"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify response was sent to channel
|
||||||
|
expect(mockChannel.send).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not route empty messages", async () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-empty",
|
||||||
|
channelId: "jarvis-channel",
|
||||||
|
authorId: "user-1",
|
||||||
|
authorName: "TestUser",
|
||||||
|
content: " ",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await (service as any).handleAgentChat(message, "jarvis");
|
||||||
|
|
||||||
|
expect(mockChatProxyService.proxyChat).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle ChatProxyService errors gracefully", async () => {
|
||||||
|
const mockChannel = {
|
||||||
|
send: vi.fn().mockResolvedValue({}),
|
||||||
|
isTextBased: () => true,
|
||||||
|
sendTyping: vi.fn(),
|
||||||
|
};
|
||||||
|
(mockClient.channels.fetch as any).mockResolvedValue(mockChannel);
|
||||||
|
|
||||||
|
mockChatProxyService.proxyChat.mockRejectedValue(new Error("Agent not found"));
|
||||||
|
|
||||||
|
await service.connect();
|
||||||
|
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-error",
|
||||||
|
channelId: "jarvis-channel",
|
||||||
|
authorId: "user-1",
|
||||||
|
authorName: "TestUser",
|
||||||
|
content: "Hello",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await (service as any).handleAgentChat(message, "jarvis");
|
||||||
|
|
||||||
|
// Should send error message to channel
|
||||||
|
expect(mockChannel.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Failed to get response from jarvis")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should split long messages for Discord", () => {
|
||||||
|
const longContent = "A".repeat(5000);
|
||||||
|
const chunks = (service as any).splitMessageForDiscord(longContent);
|
||||||
|
|
||||||
|
// Should split into chunks of 2000 or less
|
||||||
|
expect(chunks.length).toBeGreaterThan(1);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
expect(chunk.length).toBeLessThanOrEqual(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reassembled content should match original
|
||||||
|
expect(chunks.join("")).toBe(longContent.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prefer paragraph breaks when splitting messages", () => {
|
||||||
|
const content = "A".repeat(1500) + "\n\n" + "B".repeat(1500);
|
||||||
|
const chunks = (service as any).splitMessageForDiscord(content);
|
||||||
|
|
||||||
|
expect(chunks.length).toBe(2);
|
||||||
|
expect(chunks[0]).toContain("A");
|
||||||
|
expect(chunks[1]).toContain("B");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { Client, Events, GatewayIntentBits, TextChannel, ThreadChannel } from "discord.js";
|
import { Client, Events, GatewayIntentBits, TextChannel, ThreadChannel } from "discord.js";
|
||||||
import { StitcherService } from "../../stitcher/stitcher.service";
|
import { StitcherService } from "../../stitcher/stitcher.service";
|
||||||
|
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
|
||||||
|
import { PrismaService } from "../../prisma/prisma.service";
|
||||||
import { sanitizeForLogging } from "../../common/utils";
|
import { sanitizeForLogging } from "../../common/utils";
|
||||||
import type {
|
import type {
|
||||||
IChatProvider,
|
IChatProvider,
|
||||||
@@ -17,6 +19,7 @@ import type {
|
|||||||
* - Connect to Discord via bot token
|
* - Connect to Discord via bot token
|
||||||
* - Listen for commands in designated channels
|
* - Listen for commands in designated channels
|
||||||
* - Forward commands to stitcher
|
* - Forward commands to stitcher
|
||||||
|
* - Route messages in agent channels to specific agents via ChatProxyService
|
||||||
* - Receive status updates from herald
|
* - Receive status updates from herald
|
||||||
* - Post updates to threads
|
* - Post updates to threads
|
||||||
*/
|
*/
|
||||||
@@ -28,12 +31,21 @@ export class DiscordService implements IChatProvider {
|
|||||||
private readonly botToken: string;
|
private readonly botToken: string;
|
||||||
private readonly controlChannelId: string;
|
private readonly controlChannelId: string;
|
||||||
private readonly workspaceId: string;
|
private readonly workspaceId: string;
|
||||||
|
private readonly agentChannels = new Map<string, string>();
|
||||||
|
private workspaceOwnerId: string | null = null;
|
||||||
|
|
||||||
constructor(private readonly stitcherService: StitcherService) {
|
constructor(
|
||||||
|
private readonly stitcherService: StitcherService,
|
||||||
|
private readonly chatProxyService: ChatProxyService,
|
||||||
|
private readonly prisma: PrismaService
|
||||||
|
) {
|
||||||
this.botToken = process.env.DISCORD_BOT_TOKEN ?? "";
|
this.botToken = process.env.DISCORD_BOT_TOKEN ?? "";
|
||||||
this.controlChannelId = process.env.DISCORD_CONTROL_CHANNEL_ID ?? "";
|
this.controlChannelId = process.env.DISCORD_CONTROL_CHANNEL_ID ?? "";
|
||||||
this.workspaceId = process.env.DISCORD_WORKSPACE_ID ?? "";
|
this.workspaceId = process.env.DISCORD_WORKSPACE_ID ?? "";
|
||||||
|
|
||||||
|
// Load agent channel mappings from environment
|
||||||
|
this.loadAgentChannels();
|
||||||
|
|
||||||
// Initialize Discord client with required intents
|
// Initialize Discord client with required intents
|
||||||
this.client = new Client({
|
this.client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
@@ -46,6 +58,51 @@ export class DiscordService implements IChatProvider {
|
|||||||
this.setupEventHandlers();
|
this.setupEventHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load agent channel mappings from environment variables.
|
||||||
|
* Format: DISCORD_AGENT_CHANNELS=<channelId>:<agentName>,<channelId>:<agentName>
|
||||||
|
* Example: DISCORD_AGENT_CHANNELS=123456:jarvis,789012:builder
|
||||||
|
*/
|
||||||
|
private loadAgentChannels(): void {
|
||||||
|
const channelsConfig = process.env.DISCORD_AGENT_CHANNELS ?? "";
|
||||||
|
if (!channelsConfig) {
|
||||||
|
this.logger.debug("No agent channels configured (DISCORD_AGENT_CHANNELS not set)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channels = channelsConfig.split(",").map((pair) => pair.trim());
|
||||||
|
for (const channel of channels) {
|
||||||
|
const [channelId, agentName] = channel.split(":");
|
||||||
|
if (channelId && agentName) {
|
||||||
|
this.agentChannels.set(channelId.trim(), agentName.trim());
|
||||||
|
this.logger.log(`Agent channel mapped: ${channelId.trim()} → ${agentName.trim()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the workspace owner's user ID for chat proxy routing.
|
||||||
|
* Caches the result after first lookup.
|
||||||
|
*/
|
||||||
|
private async getWorkspaceOwnerId(): Promise<string> {
|
||||||
|
if (this.workspaceOwnerId) {
|
||||||
|
return this.workspaceOwnerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await this.prisma.workspace.findUnique({
|
||||||
|
where: { id: this.workspaceId },
|
||||||
|
select: { ownerId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error(`Workspace not found: ${this.workspaceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.workspaceOwnerId = workspace.ownerId;
|
||||||
|
this.logger.debug(`Workspace owner resolved: ${workspace.ownerId}`);
|
||||||
|
return workspace.ownerId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup event handlers for Discord client
|
* Setup event handlers for Discord client
|
||||||
*/
|
*/
|
||||||
@@ -60,9 +117,6 @@ export class DiscordService implements IChatProvider {
|
|||||||
// Ignore bot messages
|
// Ignore bot messages
|
||||||
if (message.author.bot) return;
|
if (message.author.bot) return;
|
||||||
|
|
||||||
// Check if message is in control channel
|
|
||||||
if (message.channelId !== this.controlChannelId) return;
|
|
||||||
|
|
||||||
// Parse message into ChatMessage format
|
// Parse message into ChatMessage format
|
||||||
const chatMessage: ChatMessage = {
|
const chatMessage: ChatMessage = {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@@ -74,6 +128,16 @@ export class DiscordService implements IChatProvider {
|
|||||||
...(message.channel.isThread() && { threadId: message.channelId }),
|
...(message.channel.isThread() && { threadId: message.channelId }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if message is in an agent channel
|
||||||
|
const agentName = this.agentChannels.get(message.channelId);
|
||||||
|
if (agentName) {
|
||||||
|
void this.handleAgentChat(chatMessage, agentName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if message is in control channel for commands
|
||||||
|
if (message.channelId !== this.controlChannelId) return;
|
||||||
|
|
||||||
// Parse command
|
// Parse command
|
||||||
const command = this.parseCommand(chatMessage);
|
const command = this.parseCommand(chatMessage);
|
||||||
if (command) {
|
if (command) {
|
||||||
@@ -394,4 +458,150 @@ export class DiscordService implements IChatProvider {
|
|||||||
|
|
||||||
await this.sendMessage(message.channelId, helpMessage);
|
await this.sendMessage(message.channelId, helpMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle agent chat - Route message to specific agent via ChatProxyService
|
||||||
|
* Messages in agent channels are sent directly to the agent without requiring @mosaic prefix.
|
||||||
|
*/
|
||||||
|
private async handleAgentChat(message: ChatMessage, agentName: string): Promise<void> {
|
||||||
|
this.logger.log(
|
||||||
|
`Routing message from ${message.authorName} to agent "${agentName}" in channel ${message.channelId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ignore empty messages
|
||||||
|
if (!message.content.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get workspace owner ID for routing
|
||||||
|
const userId = await this.getWorkspaceOwnerId();
|
||||||
|
|
||||||
|
// Build message history (just the user's message for now)
|
||||||
|
const messages = [{ role: "user" as const, content: message.content }];
|
||||||
|
|
||||||
|
// Send typing indicator while waiting for response
|
||||||
|
const channel = await this.client.channels.fetch(message.channelId);
|
||||||
|
if (channel?.isTextBased()) {
|
||||||
|
void (channel as TextChannel).sendTyping();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy to agent
|
||||||
|
const response = await this.chatProxyService.proxyChat(
|
||||||
|
userId,
|
||||||
|
messages,
|
||||||
|
undefined,
|
||||||
|
agentName
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stream the response to channel
|
||||||
|
await this.streamResponseToChannel(message.channelId, response);
|
||||||
|
|
||||||
|
this.logger.debug(`Agent "${agentName}" response sent to channel ${message.channelId}`);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.error(`Failed to route message to agent "${agentName}": ${errorMessage}`);
|
||||||
|
await this.sendMessage(
|
||||||
|
message.channelId,
|
||||||
|
`Failed to get response from ${agentName}. Please try again later.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream SSE response from chat proxy and send to Discord channel.
|
||||||
|
* Collects the full response and sends as a single message for reliability.
|
||||||
|
*/
|
||||||
|
private async streamResponseToChannel(channelId: string, response: Response): Promise<string> {
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error("Response body is not readable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let fullContent = "";
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
let readResult = await reader.read();
|
||||||
|
while (!readResult.done) {
|
||||||
|
const { value } = readResult;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("data: ")) {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === "[DONE]") continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data) as {
|
||||||
|
choices?: { delta?: { content?: string } }[];
|
||||||
|
};
|
||||||
|
const content = parsed.choices?.[0]?.delta?.content;
|
||||||
|
if (content) {
|
||||||
|
fullContent += content;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readResult = await reader.read();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the full response to Discord
|
||||||
|
if (fullContent.trim()) {
|
||||||
|
// Discord has a 2000 character limit, split if needed
|
||||||
|
const chunks = this.splitMessageForDiscord(fullContent);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
await this.sendMessage(channelId, chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullContent;
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a message into chunks that fit within Discord's 2000 character limit.
|
||||||
|
* Tries to split on paragraph or sentence boundaries when possible.
|
||||||
|
*/
|
||||||
|
private splitMessageForDiscord(content: string, maxLength = 2000): string[] {
|
||||||
|
if (content.length <= maxLength) {
|
||||||
|
return [content];
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let remaining = content;
|
||||||
|
|
||||||
|
while (remaining.length > maxLength) {
|
||||||
|
// Try to find a good break point
|
||||||
|
let breakPoint = remaining.lastIndexOf("\n\n", maxLength);
|
||||||
|
if (breakPoint < maxLength * 0.5) {
|
||||||
|
breakPoint = remaining.lastIndexOf("\n", maxLength);
|
||||||
|
}
|
||||||
|
if (breakPoint < maxLength * 0.5) {
|
||||||
|
breakPoint = remaining.lastIndexOf(". ", maxLength);
|
||||||
|
}
|
||||||
|
if (breakPoint < maxLength * 0.5) {
|
||||||
|
breakPoint = remaining.lastIndexOf(" ", maxLength);
|
||||||
|
}
|
||||||
|
if (breakPoint < maxLength * 0.5) {
|
||||||
|
breakPoint = maxLength - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(remaining.slice(0, breakPoint + 1).trim());
|
||||||
|
remaining = remaining.slice(breakPoint + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
chunks.push(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { StitcherService } from "../../stitcher/stitcher.service";
|
|||||||
import { HeraldService } from "../../herald/herald.service";
|
import { HeraldService } from "../../herald/herald.service";
|
||||||
import { PrismaService } from "../../prisma/prisma.service";
|
import { PrismaService } from "../../prisma/prisma.service";
|
||||||
import { BullMqService } from "../../bullmq/bullmq.service";
|
import { BullMqService } from "../../bullmq/bullmq.service";
|
||||||
|
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
|
||||||
import type { IChatProvider } from "../interfaces";
|
import type { IChatProvider } from "../interfaces";
|
||||||
import { JOB_CREATED, JOB_STARTED } from "../../job-events/event-types";
|
import { JOB_CREATED, JOB_STARTED } from "../../job-events/event-types";
|
||||||
|
|
||||||
@@ -192,6 +193,7 @@ function setDiscordEnv(): void {
|
|||||||
|
|
||||||
function setEncryptionKey(): void {
|
function setEncryptionKey(): void {
|
||||||
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
process.env.MOSAIC_SECRET_KEY = "test-mosaic-secret-key-minimum-32-characters-long";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -205,6 +207,10 @@ async function compileBridgeModule(): Promise<TestingModule> {
|
|||||||
.useValue({})
|
.useValue({})
|
||||||
.overrideProvider(BullMqService)
|
.overrideProvider(BullMqService)
|
||||||
.useValue({})
|
.useValue({})
|
||||||
|
.overrideProvider(ChatProxyService)
|
||||||
|
.useValue({
|
||||||
|
proxyChat: vi.fn().mockResolvedValue(new Response()),
|
||||||
|
})
|
||||||
.compile();
|
.compile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
152
apps/api/src/chat-proxy/chat-proxy.controller.ts
Normal file
152
apps/api/src/chat-proxy/chat-proxy.controller.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Body, Controller, HttpException, Logger, Post, Req, Res, UseGuards } from "@nestjs/common";
|
||||||
|
import type { Response } from "express";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
|
||||||
|
import type { MaybeAuthenticatedRequest } from "../auth/types/better-auth-request.interface";
|
||||||
|
import { ChatStreamDto } from "./chat-proxy.dto";
|
||||||
|
import { ChatProxyService } from "./chat-proxy.service";
|
||||||
|
|
||||||
|
@Controller("chat")
|
||||||
|
export class ChatProxyController {
|
||||||
|
private readonly logger = new Logger(ChatProxyController.name);
|
||||||
|
|
||||||
|
constructor(private readonly chatProxyService: ChatProxyService) {}
|
||||||
|
|
||||||
|
// POST /api/chat/guest
|
||||||
|
// Guest chat endpoint - no authentication required
|
||||||
|
// Uses a shared LLM configuration for unauthenticated users
|
||||||
|
@SkipCsrf()
|
||||||
|
@Post("guest")
|
||||||
|
async guestChat(
|
||||||
|
@Body() body: ChatStreamDto,
|
||||||
|
@Req() req: MaybeAuthenticatedRequest,
|
||||||
|
@Res() res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
req.once("close", () => {
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstreamResponse = await this.chatProxyService.proxyGuestChat(
|
||||||
|
body.messages,
|
||||||
|
abortController.signal
|
||||||
|
);
|
||||||
|
|
||||||
|
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
||||||
|
if (upstreamContentType) {
|
||||||
|
res.setHeader("Content-Type", upstreamContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstreamResponse.body) {
|
||||||
|
throw new Error("LLM response did not include a stream body");
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const chunk of upstreamResponse.body as unknown as AsyncIterable<Uint8Array>) {
|
||||||
|
if (res.writableEnded || res.destroyed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logStreamError(error);
|
||||||
|
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.write("event: error\n");
|
||||||
|
res.write(`data: ${JSON.stringify({ error: this.toSafeClientMessage(error) })}\n\n`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/chat/stream
|
||||||
|
// Request: { messages: Array<{role, content}> }
|
||||||
|
// Response: SSE stream of chat completion events
|
||||||
|
// Requires authentication - uses user's personal OpenClaw container
|
||||||
|
@Post("stream")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
async streamChat(
|
||||||
|
@Body() body: ChatStreamDto,
|
||||||
|
@Req() req: MaybeAuthenticatedRequest,
|
||||||
|
@Res() res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
this.logger.warn("streamChat called without user ID after AuthGuard");
|
||||||
|
throw new HttpException("Authentication required", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
req.once("close", () => {
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstreamResponse = await this.chatProxyService.proxyChat(
|
||||||
|
userId,
|
||||||
|
body.messages,
|
||||||
|
abortController.signal,
|
||||||
|
body.agent
|
||||||
|
);
|
||||||
|
|
||||||
|
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
||||||
|
if (upstreamContentType) {
|
||||||
|
res.setHeader("Content-Type", upstreamContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstreamResponse.body) {
|
||||||
|
throw new Error("OpenClaw response did not include a stream body");
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const chunk of upstreamResponse.body as unknown as AsyncIterable<Uint8Array>) {
|
||||||
|
if (res.writableEnded || res.destroyed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logStreamError(error);
|
||||||
|
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.write("event: error\n");
|
||||||
|
res.write(`data: ${JSON.stringify({ error: this.toSafeClientMessage(error) })}\n\n`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSafeClientMessage(error: unknown): string {
|
||||||
|
if (error instanceof HttpException && error.getStatus() < 500) {
|
||||||
|
return "Chat request was rejected";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Chat stream failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
private logStreamError(error: unknown): void {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
this.logger.warn(`Chat stream failed: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn(`Chat stream failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/api/src/chat-proxy/chat-proxy.dto.ts
Normal file
36
apps/api/src/chat-proxy/chat-proxy.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatMessageDto implements ChatMessage {
|
||||||
|
@IsString({ message: "role must be a string" })
|
||||||
|
@IsNotEmpty({ message: "role is required" })
|
||||||
|
role!: string;
|
||||||
|
|
||||||
|
@IsString({ message: "content must be a string" })
|
||||||
|
@IsNotEmpty({ message: "content is required" })
|
||||||
|
content!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatStreamDto {
|
||||||
|
@IsArray({ message: "messages must be an array" })
|
||||||
|
@ArrayMinSize(1, { message: "messages must contain at least one message" })
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ChatMessageDto)
|
||||||
|
messages!: ChatMessageDto[];
|
||||||
|
|
||||||
|
@IsString({ message: "agent must be a string" })
|
||||||
|
@IsOptional()
|
||||||
|
agent?: string;
|
||||||
|
}
|
||||||
16
apps/api/src/chat-proxy/chat-proxy.module.ts
Normal file
16
apps/api/src/chat-proxy/chat-proxy.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { AgentConfigModule } from "../agent-config/agent-config.module";
|
||||||
|
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { ChatProxyController } from "./chat-proxy.controller";
|
||||||
|
import { ChatProxyService } from "./chat-proxy.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule, ConfigModule],
|
||||||
|
controllers: [ChatProxyController],
|
||||||
|
providers: [ChatProxyService],
|
||||||
|
exports: [ChatProxyService],
|
||||||
|
})
|
||||||
|
export class ChatProxyModule {}
|
||||||
250
apps/api/src/chat-proxy/chat-proxy.service.spec.ts
Normal file
250
apps/api/src/chat-proxy/chat-proxy.service.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import {
|
||||||
|
ServiceUnavailableException,
|
||||||
|
NotFoundException,
|
||||||
|
BadGatewayException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ChatProxyService } from "./chat-proxy.service";
|
||||||
|
|
||||||
|
describe("ChatProxyService", () => {
|
||||||
|
const userId = "user-123";
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
userAgentConfig: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
userAgent: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerLifecycle = {
|
||||||
|
ensureRunning: vi.fn(),
|
||||||
|
touch: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
get: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: ChatProxyService;
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
service = new ChatProxyService(prisma as never, containerLifecycle as never, config as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getContainerUrl", () => {
|
||||||
|
it("calls ensureRunning and touch for the user", async () => {
|
||||||
|
containerLifecycle.ensureRunning.mockResolvedValue({
|
||||||
|
url: "http://mosaic-user-user-123:19000",
|
||||||
|
token: "gateway-token",
|
||||||
|
});
|
||||||
|
containerLifecycle.touch.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const url = await service.getContainerUrl(userId);
|
||||||
|
|
||||||
|
expect(url).toBe("http://mosaic-user-user-123:19000");
|
||||||
|
expect(containerLifecycle.ensureRunning).toHaveBeenCalledWith(userId);
|
||||||
|
expect(containerLifecycle.touch).toHaveBeenCalledWith(userId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("proxyChat", () => {
|
||||||
|
it("forwards the request to the user's OpenClaw container", async () => {
|
||||||
|
containerLifecycle.ensureRunning.mockResolvedValue({
|
||||||
|
url: "http://mosaic-user-user-123:19000",
|
||||||
|
token: "gateway-token",
|
||||||
|
});
|
||||||
|
containerLifecycle.touch.mockResolvedValue(undefined);
|
||||||
|
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
|
||||||
|
|
||||||
|
const messages = [{ role: "user", content: "Hello from Mosaic" }];
|
||||||
|
const response = await service.proxyChat(userId, messages);
|
||||||
|
|
||||||
|
expect(response).toBeInstanceOf(Response);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://mosaic-user-user-123:19000/v1/chat/completions",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer gateway-token",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
const parsedBody = JSON.parse(String(request.body));
|
||||||
|
expect(parsedBody).toEqual({
|
||||||
|
messages,
|
||||||
|
model: "openclaw:default",
|
||||||
|
stream: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ServiceUnavailableException on connection refused errors", async () => {
|
||||||
|
containerLifecycle.ensureRunning.mockResolvedValue({
|
||||||
|
url: "http://mosaic-user-user-123:19000",
|
||||||
|
token: "gateway-token",
|
||||||
|
});
|
||||||
|
containerLifecycle.touch.mockResolvedValue(undefined);
|
||||||
|
fetchMock.mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:19000"));
|
||||||
|
|
||||||
|
await expect(service.proxyChat(userId, [])).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ServiceUnavailableException on timeout errors", async () => {
|
||||||
|
containerLifecycle.ensureRunning.mockResolvedValue({
|
||||||
|
url: "http://mosaic-user-user-123:19000",
|
||||||
|
token: "gateway-token",
|
||||||
|
});
|
||||||
|
containerLifecycle.touch.mockResolvedValue(undefined);
|
||||||
|
fetchMock.mockRejectedValue(new Error("The operation was aborted due to timeout"));
|
||||||
|
|
||||||
|
await expect(service.proxyChat(userId, [])).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("proxyChat with agent routing", () => {
|
||||||
|
it("includes agent config when agentName is specified", async () => {
|
||||||
|
const mockAgent = {
|
||||||
|
name: "jarvis",
|
||||||
|
displayName: "Jarvis",
|
||||||
|
personality: "Capable, direct, proactive.",
|
||||||
|
primaryModel: "opus",
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
containerLifecycle.ensureRunning.mockResolvedValue({
|
||||||
|
url: "http://mosaic-user-user-123:19000",
|
||||||
|
token: "gateway-token",
|
||||||
|
});
|
||||||
|
containerLifecycle.touch.mockResolvedValue(undefined);
|
||||||
|
prisma.userAgent.findUnique.mockResolvedValue(mockAgent);
|
||||||
|
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
|
||||||
|
|
||||||
|
const messages = [{ role: "user", content: "Hello Jarvis" }];
|
||||||
|
await service.proxyChat(userId, messages, undefined, "jarvis");
|
||||||
|
|
||||||
|
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
const parsedBody = JSON.parse(String(request.body));
|
||||||
|
|
||||||
|
expect(parsedBody).toEqual({
|
||||||
|
messages,
|
||||||
|
model: "opus",
|
||||||
|
stream: true,
|
||||||
|
agent: "jarvis",
|
||||||
|
agent_personality: "Capable, direct, proactive.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NotFoundException when agent not found", async () => {
|
||||||
|
containerLifecycle.ensureRunning.mockResolvedValue({
|
||||||
|
url: "http://mosaic-user-user-123:19000",
|
||||||
|
token: "gateway-token",
|
||||||
|
});
|
||||||
|
containerLifecycle.touch.mockResolvedValue(undefined);
|
||||||
|
prisma.userAgent.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const messages = [{ role: "user", content: "Hello" }];
|
||||||
|
await expect(service.proxyChat(userId, messages, undefined, "nonexistent")).rejects.toThrow(
|
||||||
|
NotFoundException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NotFoundException when agent is not active", async () => {
|
||||||
|
containerLifecycle.ensureRunning.mockResolvedValue({
|
||||||
|
url: "http://mosaic-user-user-123:19000",
|
||||||
|
token: "gateway-token",
|
||||||
|
});
|
||||||
|
containerLifecycle.touch.mockResolvedValue(undefined);
|
||||||
|
prisma.userAgent.findUnique.mockResolvedValue({
|
||||||
|
name: "inactive-agent",
|
||||||
|
displayName: "Inactive",
|
||||||
|
personality: "...",
|
||||||
|
primaryModel: null,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = [{ role: "user", content: "Hello" }];
|
||||||
|
await expect(
|
||||||
|
service.proxyChat(userId, messages, undefined, "inactive-agent")
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to default model when agent has no primaryModel", async () => {
|
||||||
|
const mockAgent = {
|
||||||
|
name: "jarvis",
|
||||||
|
displayName: "Jarvis",
|
||||||
|
personality: "Capable, direct, proactive.",
|
||||||
|
primaryModel: null,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
containerLifecycle.ensureRunning.mockResolvedValue({
|
||||||
|
url: "http://mosaic-user-user-123:19000",
|
||||||
|
token: "gateway-token",
|
||||||
|
});
|
||||||
|
containerLifecycle.touch.mockResolvedValue(undefined);
|
||||||
|
prisma.userAgent.findUnique.mockResolvedValue(mockAgent);
|
||||||
|
prisma.userAgentConfig.findUnique.mockResolvedValue(null);
|
||||||
|
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
|
||||||
|
|
||||||
|
const messages = [{ role: "user", content: "Hello" }];
|
||||||
|
await service.proxyChat(userId, messages, undefined, "jarvis");
|
||||||
|
|
||||||
|
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
const parsedBody = JSON.parse(String(request.body));
|
||||||
|
|
||||||
|
expect(parsedBody.model).toBe("openclaw:default");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("proxyGuestChat", () => {
|
||||||
|
it("uses environment variables for guest LLM configuration", async () => {
|
||||||
|
config.get.mockImplementation((key: string) => {
|
||||||
|
if (key === "GUEST_LLM_URL") return "http://10.1.1.42:11434/v1";
|
||||||
|
if (key === "GUEST_LLM_MODEL") return "llama3.2";
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
|
||||||
|
|
||||||
|
const messages = [{ role: "user", content: "Hello" }];
|
||||||
|
await service.proxyGuestChat(messages);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://10.1.1.42:11434/v1/chat/completions",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
const parsedBody = JSON.parse(String(request.body));
|
||||||
|
expect(parsedBody.model).toBe("llama3.2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws BadGatewayException on guest LLM errors", async () => {
|
||||||
|
config.get.mockReturnValue(undefined);
|
||||||
|
fetchMock.mockResolvedValue(new Response("Internal Server Error", { status: 500 }));
|
||||||
|
|
||||||
|
const messages = [{ role: "user", content: "Hello" }];
|
||||||
|
await expect(service.proxyGuestChat(messages)).rejects.toThrow(BadGatewayException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
226
apps/api/src/chat-proxy/chat-proxy.service.ts
Normal file
226
apps/api/src/chat-proxy/chat-proxy.service.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import {
|
||||||
|
BadGatewayException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { ChatMessage } from "./chat-proxy.dto";
|
||||||
|
|
||||||
|
const DEFAULT_OPENCLAW_MODEL = "openclaw:default";
|
||||||
|
const DEFAULT_GUEST_LLM_URL = "http://10.1.1.42:11434/v1";
|
||||||
|
const DEFAULT_GUEST_LLM_MODEL = "llama3.2";
|
||||||
|
|
||||||
|
interface ContainerConnection {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentConfig {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
personality: string;
|
||||||
|
primaryModel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChatProxyService {
|
||||||
|
private readonly logger = new Logger(ChatProxyService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly containerLifecycle: ContainerLifecycleService,
|
||||||
|
private readonly config: ConfigService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Get the user's OpenClaw container URL and mark it active.
|
||||||
|
async getContainerUrl(userId: string): Promise<string> {
|
||||||
|
const { url } = await this.getContainerConnection(userId);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy chat request to OpenClaw.
|
||||||
|
async proxyChat(
|
||||||
|
userId: string,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
agentName?: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId);
|
||||||
|
|
||||||
|
// Get agent config if specified
|
||||||
|
let agentConfig: AgentConfig | null = null;
|
||||||
|
if (agentName) {
|
||||||
|
agentConfig = await this.getAgentConfig(userId, agentName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = agentConfig?.primaryModel ?? (await this.getPreferredModel(userId));
|
||||||
|
|
||||||
|
const requestBody: Record<string, unknown> = {
|
||||||
|
messages,
|
||||||
|
model,
|
||||||
|
stream: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add agent config if available
|
||||||
|
if (agentConfig) {
|
||||||
|
requestBody.agent = agentConfig.name;
|
||||||
|
requestBody.agent_personality = agentConfig.personality;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestInit: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${gatewayToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
requestInit.signal = signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${containerUrl}/v1/chat/completions`, requestInit);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await this.readResponseText(response);
|
||||||
|
const status = `${String(response.status)} ${response.statusText}`.trim();
|
||||||
|
this.logger.warn(
|
||||||
|
detail ? `OpenClaw returned ${status}: ${detail}` : `OpenClaw returned ${status}`
|
||||||
|
);
|
||||||
|
throw new BadGatewayException(`OpenClaw returned ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof BadGatewayException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(`Failed to proxy chat request: ${message}`);
|
||||||
|
throw new ServiceUnavailableException("Failed to proxy chat to OpenClaw");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy guest chat request to configured LLM endpoint.
|
||||||
|
* Uses environment variables for configuration:
|
||||||
|
* - GUEST_LLM_URL: OpenAI-compatible endpoint URL
|
||||||
|
* - GUEST_LLM_API_KEY: API key (optional, for cloud providers)
|
||||||
|
* - GUEST_LLM_MODEL: Model name to use
|
||||||
|
*/
|
||||||
|
async proxyGuestChat(messages: ChatMessage[], signal?: AbortSignal): Promise<Response> {
|
||||||
|
const llmUrl = this.config.get<string>("GUEST_LLM_URL") ?? DEFAULT_GUEST_LLM_URL;
|
||||||
|
const llmApiKey = this.config.get<string>("GUEST_LLM_API_KEY");
|
||||||
|
const llmModel = this.config.get<string>("GUEST_LLM_MODEL") ?? DEFAULT_GUEST_LLM_MODEL;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (llmApiKey) {
|
||||||
|
headers.Authorization = `Bearer ${llmApiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestInit: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
messages,
|
||||||
|
model: llmModel,
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
requestInit.signal = signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Guest chat proxying to ${llmUrl} with model ${llmModel}`);
|
||||||
|
const response = await fetch(`${llmUrl}/chat/completions`, requestInit);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await this.readResponseText(response);
|
||||||
|
const status = `${String(response.status)} ${response.statusText}`.trim();
|
||||||
|
this.logger.warn(
|
||||||
|
detail ? `Guest LLM returned ${status}: ${detail}` : `Guest LLM returned ${status}`
|
||||||
|
);
|
||||||
|
throw new BadGatewayException(`Guest LLM returned ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof BadGatewayException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(`Failed to proxy guest chat request: ${message}`);
|
||||||
|
throw new ServiceUnavailableException("Failed to proxy guest chat to LLM");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getContainerConnection(userId: string): Promise<ContainerConnection> {
|
||||||
|
const connection = await this.containerLifecycle.ensureRunning(userId);
|
||||||
|
await this.containerLifecycle.touch(userId);
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPreferredModel(userId: string): Promise<string> {
|
||||||
|
const config = await this.prisma.userAgentConfig.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { primaryModel: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryModel = config?.primaryModel?.trim();
|
||||||
|
if (!primaryModel) {
|
||||||
|
return DEFAULT_OPENCLAW_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return primaryModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readResponseText(response: Response): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const text = (await response.text()).trim();
|
||||||
|
return text.length > 0 ? text : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAgentConfig(userId: string, agentName: string): Promise<AgentConfig> {
|
||||||
|
const agent = await this.prisma.userAgent.findUnique({
|
||||||
|
where: { userId_name: { userId, name: agentName } },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
displayName: true,
|
||||||
|
personality: true,
|
||||||
|
primaryModel: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
throw new NotFoundException(`Agent "${agentName}" not found for user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agent.isActive) {
|
||||||
|
throw new NotFoundException(`Agent "${agentName}" is not active`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: agent.name,
|
||||||
|
displayName: agent.displayName,
|
||||||
|
personality: agent.personality,
|
||||||
|
primaryModel: agent.primaryModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,17 @@ describe("CsrfGuard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("State-changing methods requiring CSRF", () => {
|
describe("State-changing methods requiring CSRF", () => {
|
||||||
|
it("should allow POST with Bearer auth without CSRF token", () => {
|
||||||
|
const context = createContext(
|
||||||
|
"POST",
|
||||||
|
{},
|
||||||
|
{ authorization: "Bearer api-token" },
|
||||||
|
false,
|
||||||
|
"user-123"
|
||||||
|
);
|
||||||
|
expect(guard.canActivate(context)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("should reject POST without CSRF token", () => {
|
it("should reject POST without CSRF token", () => {
|
||||||
const context = createContext("POST", {}, {}, false, "user-123");
|
const context = createContext("POST", {}, {}, false, "user-123");
|
||||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export class CsrfGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Get CSRF token from cookie and header
|
// Get CSRF token from cookie and header
|
||||||
const cookies = request.cookies as Record<string, string> | undefined;
|
const cookies = request.cookies as Record<string, string> | undefined;
|
||||||
const cookieToken = cookies?.["csrf-token"];
|
const cookieToken = cookies?.["csrf-token"];
|
||||||
@@ -106,14 +111,9 @@ export class CsrfGuard implements CanActivate {
|
|||||||
|
|
||||||
throw new ForbiddenException("CSRF token not bound to session");
|
throw new ForbiddenException("CSRF token not bound to session");
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
this.logger.debug({
|
|
||||||
event: "CSRF_SKIP_SESSION_BINDING",
|
|
||||||
method: request.method,
|
|
||||||
path: request.path,
|
|
||||||
reason: "User context not yet available (global guard runs before AuthGuard)",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
// Note: when userId is absent, the double-submit cookie check above is
|
||||||
|
// sufficient CSRF protection. AuthGuard populates request.user afterward.
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { CryptoModule } from "../crypto/crypto.module";
|
||||||
|
import { ContainerLifecycleService } from "./container-lifecycle.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule, PrismaModule, CryptoModule],
|
||||||
|
providers: [ContainerLifecycleService],
|
||||||
|
exports: [ContainerLifecycleService],
|
||||||
|
})
|
||||||
|
export class ContainerLifecycleModule {}
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ConfigService } from "@nestjs/config";
|
||||||
|
import type { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { CryptoService } from "../crypto/crypto.service";
|
||||||
|
|
||||||
|
interface MockUserContainerRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
containerId: string | null;
|
||||||
|
containerName: string;
|
||||||
|
gatewayPort: number | null;
|
||||||
|
gatewayToken: string;
|
||||||
|
status: string;
|
||||||
|
lastActiveAt: Date | null;
|
||||||
|
idleTimeoutMin: number;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dockerMock = vi.hoisted(() => {
|
||||||
|
interface MockDockerContainerState {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
running: boolean;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containers = new Map<string, MockDockerContainerState>();
|
||||||
|
const handles = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
inspect: ReturnType<typeof vi.fn>;
|
||||||
|
start: ReturnType<typeof vi.fn>;
|
||||||
|
stop: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
const ensureHandle = (id: string) => {
|
||||||
|
const existing = handles.get(id);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = {
|
||||||
|
inspect: vi.fn(async () => {
|
||||||
|
const container = containers.get(id);
|
||||||
|
if (!container) {
|
||||||
|
throw { statusCode: 404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Id: container.id,
|
||||||
|
State: {
|
||||||
|
Running: container.running,
|
||||||
|
},
|
||||||
|
NetworkSettings: {
|
||||||
|
Ports: {
|
||||||
|
"18789/tcp": [{ HostPort: String(container.port) }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
start: vi.fn(async () => {
|
||||||
|
const container = containers.get(id);
|
||||||
|
if (!container) {
|
||||||
|
throw { statusCode: 404 };
|
||||||
|
}
|
||||||
|
container.running = true;
|
||||||
|
}),
|
||||||
|
stop: vi.fn(async () => {
|
||||||
|
const container = containers.get(id);
|
||||||
|
if (!container) {
|
||||||
|
throw { statusCode: 404 };
|
||||||
|
}
|
||||||
|
container.running = false;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
handles.set(id, handle);
|
||||||
|
return handle;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listContainers = vi.fn(
|
||||||
|
async (options?: { all?: boolean; filters?: { name?: string[] } }) => {
|
||||||
|
const nameFilter = options?.filters?.name?.[0];
|
||||||
|
return [...containers.values()]
|
||||||
|
.filter((container) => (nameFilter ? container.name.includes(nameFilter) : true))
|
||||||
|
.map((container) => ({
|
||||||
|
Id: container.id,
|
||||||
|
Names: [`/${container.name}`],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const getContainer = vi.fn((id: string) => ensureHandle(id));
|
||||||
|
|
||||||
|
const createContainer = vi.fn(
|
||||||
|
async (options: {
|
||||||
|
name?: string;
|
||||||
|
HostConfig?: { PortBindings?: Record<string, Array<{ HostPort?: string }>> };
|
||||||
|
}) => {
|
||||||
|
const id = `ctr-${containers.size + 1}`;
|
||||||
|
const name = options.name ?? id;
|
||||||
|
const hostPort = options.HostConfig?.PortBindings?.["18789/tcp"]?.[0]?.HostPort;
|
||||||
|
const port = hostPort ? Number.parseInt(hostPort, 10) : 0;
|
||||||
|
|
||||||
|
containers.set(id, {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
running: false,
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ensureHandle(id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const dockerInstance = {
|
||||||
|
listContainers,
|
||||||
|
getContainer,
|
||||||
|
createContainer,
|
||||||
|
};
|
||||||
|
|
||||||
|
const constructorSpy = vi.fn();
|
||||||
|
class DockerConstructorMock {
|
||||||
|
constructor(options?: unknown) {
|
||||||
|
constructorSpy(options);
|
||||||
|
return dockerInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerContainer = (container: MockDockerContainerState) => {
|
||||||
|
containers.set(container.id, { ...container });
|
||||||
|
ensureHandle(container.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
containers.clear();
|
||||||
|
handles.clear();
|
||||||
|
constructorSpy.mockClear();
|
||||||
|
listContainers.mockClear();
|
||||||
|
getContainer.mockClear();
|
||||||
|
createContainer.mockClear();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
DockerConstructorMock,
|
||||||
|
constructorSpy,
|
||||||
|
createContainer,
|
||||||
|
handles,
|
||||||
|
registerContainer,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("dockerode", () => ({
|
||||||
|
default: dockerMock.DockerConstructorMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ContainerLifecycleService } from "./container-lifecycle.service";
|
||||||
|
|
||||||
|
function createConfigMock(values: Record<string, string> = {}) {
|
||||||
|
return {
|
||||||
|
get: vi.fn((key: string) => values[key]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCryptoMock() {
|
||||||
|
return {
|
||||||
|
generateToken: vi.fn(() => "generated-token"),
|
||||||
|
encrypt: vi.fn((value: string) => `enc:${value}`),
|
||||||
|
decrypt: vi.fn((value: string) => value.replace(/^enc:/, "")),
|
||||||
|
isEncrypted: vi.fn((value: string) => value.startsWith("enc:")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectRecord(
|
||||||
|
record: MockUserContainerRecord,
|
||||||
|
select?: Record<string, boolean>
|
||||||
|
): Partial<MockUserContainerRecord> {
|
||||||
|
if (!select) {
|
||||||
|
return { ...record };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projection: Partial<MockUserContainerRecord> = {};
|
||||||
|
for (const [field, enabled] of Object.entries(select)) {
|
||||||
|
if (enabled) {
|
||||||
|
const key = field as keyof MockUserContainerRecord;
|
||||||
|
projection[key] = record[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return projection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPrismaMock(initialRecords: MockUserContainerRecord[] = []) {
|
||||||
|
const records = new Map<string, MockUserContainerRecord>();
|
||||||
|
for (const record of initialRecords) {
|
||||||
|
records.set(record.userId, { ...record });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userContainer = {
|
||||||
|
findUnique: vi.fn(
|
||||||
|
async (args: {
|
||||||
|
where: { userId?: string; id?: string };
|
||||||
|
select?: Record<string, boolean>;
|
||||||
|
}) => {
|
||||||
|
let record: MockUserContainerRecord | undefined;
|
||||||
|
if (args.where.userId) {
|
||||||
|
record = records.get(args.where.userId);
|
||||||
|
} else if (args.where.id) {
|
||||||
|
record = [...records.values()].find((entry) => entry.id === args.where.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectRecord(record, args.select);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
create: vi.fn(
|
||||||
|
async (args: {
|
||||||
|
data: Partial<MockUserContainerRecord> & {
|
||||||
|
userId: string;
|
||||||
|
containerName: string;
|
||||||
|
gatewayToken: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const now = new Date();
|
||||||
|
const next: MockUserContainerRecord = {
|
||||||
|
id: args.data.id ?? `uc-${records.size + 1}`,
|
||||||
|
userId: args.data.userId,
|
||||||
|
containerId: args.data.containerId ?? null,
|
||||||
|
containerName: args.data.containerName,
|
||||||
|
gatewayPort: args.data.gatewayPort ?? null,
|
||||||
|
gatewayToken: args.data.gatewayToken,
|
||||||
|
status: args.data.status ?? "stopped",
|
||||||
|
lastActiveAt: args.data.lastActiveAt ?? null,
|
||||||
|
idleTimeoutMin: args.data.idleTimeoutMin ?? 30,
|
||||||
|
config: args.data.config ?? {},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
records.set(next.userId, next);
|
||||||
|
return { ...next };
|
||||||
|
}
|
||||||
|
),
|
||||||
|
update: vi.fn(
|
||||||
|
async (args: { where: { userId: string }; data: Partial<MockUserContainerRecord> }) => {
|
||||||
|
const record = records.get(args.where.userId);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`Record ${args.where.userId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated: MockUserContainerRecord = {
|
||||||
|
...record,
|
||||||
|
...args.data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
records.set(updated.userId, updated);
|
||||||
|
return { ...updated };
|
||||||
|
}
|
||||||
|
),
|
||||||
|
updateMany: vi.fn(
|
||||||
|
async (args: { where: { userId: string }; data: Partial<MockUserContainerRecord> }) => {
|
||||||
|
const record = records.get(args.where.userId);
|
||||||
|
if (!record) {
|
||||||
|
return { count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated: MockUserContainerRecord = {
|
||||||
|
...record,
|
||||||
|
...args.data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
records.set(updated.userId, updated);
|
||||||
|
return { count: 1 };
|
||||||
|
}
|
||||||
|
),
|
||||||
|
findMany: vi.fn(
|
||||||
|
async (args?: {
|
||||||
|
where?: {
|
||||||
|
status?: string;
|
||||||
|
lastActiveAt?: { not: null };
|
||||||
|
gatewayPort?: { not: null };
|
||||||
|
};
|
||||||
|
select?: Record<string, boolean>;
|
||||||
|
}) => {
|
||||||
|
let rows = [...records.values()];
|
||||||
|
|
||||||
|
if (args?.where?.status) {
|
||||||
|
rows = rows.filter((record) => record.status === args.where?.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args?.where?.lastActiveAt?.not === null) {
|
||||||
|
rows = rows.filter((record) => record.lastActiveAt !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args?.where?.gatewayPort?.not === null) {
|
||||||
|
rows = rows.filter((record) => record.gatewayPort !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((record) => projectRecord(record, args?.select));
|
||||||
|
}
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
prisma: {
|
||||||
|
userContainer,
|
||||||
|
},
|
||||||
|
records,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRecord(overrides: Partial<MockUserContainerRecord>): MockUserContainerRecord {
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? "uc-default",
|
||||||
|
userId: overrides.userId ?? "user-default",
|
||||||
|
containerId: overrides.containerId ?? null,
|
||||||
|
containerName: overrides.containerName ?? "mosaic-user-user-default",
|
||||||
|
gatewayPort: overrides.gatewayPort ?? null,
|
||||||
|
gatewayToken: overrides.gatewayToken ?? "enc:token-default",
|
||||||
|
status: overrides.status ?? "stopped",
|
||||||
|
lastActiveAt: overrides.lastActiveAt ?? null,
|
||||||
|
idleTimeoutMin: overrides.idleTimeoutMin ?? 30,
|
||||||
|
config: overrides.config ?? {},
|
||||||
|
createdAt: overrides.createdAt ?? now,
|
||||||
|
updatedAt: overrides.updatedAt ?? now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ContainerLifecycleService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
dockerMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ensureRunning creates container when none exists", async () => {
|
||||||
|
const { prisma, records } = createPrismaMock();
|
||||||
|
const crypto = createCryptoMock();
|
||||||
|
const config = createConfigMock();
|
||||||
|
const service = new ContainerLifecycleService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
crypto as unknown as CryptoService,
|
||||||
|
config as unknown as ConfigService
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.ensureRunning("user-1");
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
url: "http://mosaic-user-user-1:19000",
|
||||||
|
token: "generated-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedRecord = records.get("user-1");
|
||||||
|
expect(updatedRecord?.status).toBe("running");
|
||||||
|
expect(updatedRecord?.containerId).toBe("ctr-1");
|
||||||
|
expect(updatedRecord?.gatewayPort).toBe(19000);
|
||||||
|
expect(updatedRecord?.gatewayToken).toBe("enc:generated-token");
|
||||||
|
|
||||||
|
expect(dockerMock.createContainer).toHaveBeenCalledTimes(1);
|
||||||
|
const [createCall] = dockerMock.createContainer.mock.calls[0] as [
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
Image: string;
|
||||||
|
Env: string[];
|
||||||
|
HostConfig: { Binds: string[]; NetworkMode: string };
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(createCall.name).toBe("mosaic-user-user-1");
|
||||||
|
expect(createCall.Image).toBe("alpine/openclaw:latest");
|
||||||
|
expect(createCall.HostConfig.Binds).toEqual(["mosaic-user-user-1-state:/home/node/.openclaw"]);
|
||||||
|
expect(createCall.HostConfig.NetworkMode).toBe("mosaic-internal");
|
||||||
|
expect(createCall.Env).toContain("AGENT_TOKEN=generated-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ensureRunning starts existing stopped container", async () => {
|
||||||
|
const { prisma, records } = createPrismaMock([
|
||||||
|
createRecord({
|
||||||
|
id: "uc-1",
|
||||||
|
userId: "user-2",
|
||||||
|
containerId: "ctr-stopped",
|
||||||
|
containerName: "mosaic-user-user-2",
|
||||||
|
gatewayToken: "enc:existing-token",
|
||||||
|
status: "stopped",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const crypto = createCryptoMock();
|
||||||
|
const config = createConfigMock();
|
||||||
|
const service = new ContainerLifecycleService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
crypto as unknown as CryptoService,
|
||||||
|
config as unknown as ConfigService
|
||||||
|
);
|
||||||
|
|
||||||
|
dockerMock.registerContainer({
|
||||||
|
id: "ctr-stopped",
|
||||||
|
name: "mosaic-user-user-2",
|
||||||
|
running: false,
|
||||||
|
port: 19042,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.ensureRunning("user-2");
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
url: "http://mosaic-user-user-2:19042",
|
||||||
|
token: "existing-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = dockerMock.handles.get("ctr-stopped");
|
||||||
|
expect(handle?.start).toHaveBeenCalledTimes(1);
|
||||||
|
expect(records.get("user-2")?.status).toBe("running");
|
||||||
|
expect(records.get("user-2")?.gatewayPort).toBe(19042);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ensureRunning returns existing running container", async () => {
|
||||||
|
const { prisma } = createPrismaMock([
|
||||||
|
createRecord({
|
||||||
|
id: "uc-2",
|
||||||
|
userId: "user-3",
|
||||||
|
containerId: "ctr-running",
|
||||||
|
containerName: "mosaic-user-user-3",
|
||||||
|
gatewayPort: 19043,
|
||||||
|
gatewayToken: "enc:running-token",
|
||||||
|
status: "running",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const crypto = createCryptoMock();
|
||||||
|
const config = createConfigMock();
|
||||||
|
const service = new ContainerLifecycleService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
crypto as unknown as CryptoService,
|
||||||
|
config as unknown as ConfigService
|
||||||
|
);
|
||||||
|
|
||||||
|
dockerMock.registerContainer({
|
||||||
|
id: "ctr-running",
|
||||||
|
name: "mosaic-user-user-3",
|
||||||
|
running: true,
|
||||||
|
port: 19043,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.ensureRunning("user-3");
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
url: "http://mosaic-user-user-3:19043",
|
||||||
|
token: "running-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dockerMock.createContainer).not.toHaveBeenCalled();
|
||||||
|
const handle = dockerMock.handles.get("ctr-running");
|
||||||
|
expect(handle?.start).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stop gracefully stops container and updates DB", async () => {
|
||||||
|
const { prisma, records } = createPrismaMock([
|
||||||
|
createRecord({
|
||||||
|
id: "uc-stop",
|
||||||
|
userId: "user-stop",
|
||||||
|
containerId: "ctr-stop",
|
||||||
|
containerName: "mosaic-user-user-stop",
|
||||||
|
gatewayPort: 19044,
|
||||||
|
status: "running",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const crypto = createCryptoMock();
|
||||||
|
const config = createConfigMock();
|
||||||
|
const service = new ContainerLifecycleService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
crypto as unknown as CryptoService,
|
||||||
|
config as unknown as ConfigService
|
||||||
|
);
|
||||||
|
|
||||||
|
dockerMock.registerContainer({
|
||||||
|
id: "ctr-stop",
|
||||||
|
name: "mosaic-user-user-stop",
|
||||||
|
running: true,
|
||||||
|
port: 19044,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.stop("user-stop");
|
||||||
|
|
||||||
|
const handle = dockerMock.handles.get("ctr-stop");
|
||||||
|
expect(handle?.stop).toHaveBeenCalledWith({ t: 10 });
|
||||||
|
|
||||||
|
const updatedRecord = records.get("user-stop");
|
||||||
|
expect(updatedRecord?.status).toBe("stopped");
|
||||||
|
expect(updatedRecord?.containerId).toBeNull();
|
||||||
|
expect(updatedRecord?.gatewayPort).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reapIdle stops only containers past their idle timeout", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const { prisma, records } = createPrismaMock([
|
||||||
|
createRecord({
|
||||||
|
id: "uc-old",
|
||||||
|
userId: "user-old",
|
||||||
|
containerId: "ctr-old",
|
||||||
|
containerName: "mosaic-user-user-old",
|
||||||
|
gatewayPort: 19045,
|
||||||
|
status: "running",
|
||||||
|
lastActiveAt: new Date(now - 60 * 60 * 1000),
|
||||||
|
idleTimeoutMin: 30,
|
||||||
|
}),
|
||||||
|
createRecord({
|
||||||
|
id: "uc-fresh",
|
||||||
|
userId: "user-fresh",
|
||||||
|
containerId: "ctr-fresh",
|
||||||
|
containerName: "mosaic-user-user-fresh",
|
||||||
|
gatewayPort: 19046,
|
||||||
|
status: "running",
|
||||||
|
lastActiveAt: new Date(now - 5 * 60 * 1000),
|
||||||
|
idleTimeoutMin: 30,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const crypto = createCryptoMock();
|
||||||
|
const config = createConfigMock();
|
||||||
|
const service = new ContainerLifecycleService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
crypto as unknown as CryptoService,
|
||||||
|
config as unknown as ConfigService
|
||||||
|
);
|
||||||
|
|
||||||
|
dockerMock.registerContainer({
|
||||||
|
id: "ctr-old",
|
||||||
|
name: "mosaic-user-user-old",
|
||||||
|
running: true,
|
||||||
|
port: 19045,
|
||||||
|
});
|
||||||
|
dockerMock.registerContainer({
|
||||||
|
id: "ctr-fresh",
|
||||||
|
name: "mosaic-user-user-fresh",
|
||||||
|
running: true,
|
||||||
|
port: 19046,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.reapIdle();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
stopped: ["user-old"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(records.get("user-old")?.status).toBe("stopped");
|
||||||
|
expect(records.get("user-fresh")?.status).toBe("running");
|
||||||
|
|
||||||
|
const oldHandle = dockerMock.handles.get("ctr-old");
|
||||||
|
const freshHandle = dockerMock.handles.get("ctr-fresh");
|
||||||
|
expect(oldHandle?.stop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(freshHandle?.stop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("touch updates lastActiveAt", async () => {
|
||||||
|
const { prisma, records } = createPrismaMock([
|
||||||
|
createRecord({
|
||||||
|
id: "uc-touch",
|
||||||
|
userId: "user-touch",
|
||||||
|
containerName: "mosaic-user-user-touch",
|
||||||
|
lastActiveAt: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const crypto = createCryptoMock();
|
||||||
|
const config = createConfigMock();
|
||||||
|
const service = new ContainerLifecycleService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
crypto as unknown as CryptoService,
|
||||||
|
config as unknown as ConfigService
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.touch("user-touch");
|
||||||
|
|
||||||
|
const updatedRecord = records.get("user-touch");
|
||||||
|
expect(updatedRecord?.lastActiveAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getStatus returns null for unknown user", async () => {
|
||||||
|
const { prisma } = createPrismaMock();
|
||||||
|
const crypto = createCryptoMock();
|
||||||
|
const config = createConfigMock();
|
||||||
|
const service = new ContainerLifecycleService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
crypto as unknown as CryptoService,
|
||||||
|
config as unknown as ConfigService
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = await service.getStatus("missing-user");
|
||||||
|
|
||||||
|
expect(status).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
532
apps/api/src/container-lifecycle/container-lifecycle.service.ts
Normal file
532
apps/api/src/container-lifecycle/container-lifecycle.service.ts
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import Docker from "dockerode";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CryptoService } from "../crypto/crypto.service";
|
||||||
|
|
||||||
|
const DEFAULT_DOCKER_SOCKET_PATH = "/var/run/docker.sock";
|
||||||
|
const DEFAULT_DOCKER_TCP_PORT = 2375;
|
||||||
|
const DEFAULT_OPENCLAW_IMAGE = "alpine/openclaw:latest";
|
||||||
|
const DEFAULT_OPENCLAW_NETWORK = "mosaic-internal";
|
||||||
|
const DEFAULT_OPENCLAW_PORT_RANGE_START = 19000;
|
||||||
|
const DEFAULT_MOSAIC_API_URL = "http://mosaic-api:3000/api";
|
||||||
|
const OPENCLAW_GATEWAY_PORT_KEY = "18789/tcp";
|
||||||
|
const OPENCLAW_STATE_PATH = "/home/node/.openclaw";
|
||||||
|
const CONTAINER_STOP_TIMEOUT_SECONDS = 10;
|
||||||
|
|
||||||
|
interface ContainerHandle {
|
||||||
|
inspect(): Promise<DockerInspect>;
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(options?: { t?: number }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DockerInspect {
|
||||||
|
Id?: string;
|
||||||
|
State?: {
|
||||||
|
Running?: boolean;
|
||||||
|
Health?: {
|
||||||
|
Status?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
NetworkSettings?: {
|
||||||
|
Ports?: Record<string, { HostPort?: string }[] | null>;
|
||||||
|
};
|
||||||
|
HostConfig?: {
|
||||||
|
PortBindings?: Record<string, { HostPort?: string }[] | null>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserContainerRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
containerId: string | null;
|
||||||
|
containerName: string;
|
||||||
|
gatewayPort: number | null;
|
||||||
|
gatewayToken: string;
|
||||||
|
status: string;
|
||||||
|
lastActiveAt: Date | null;
|
||||||
|
idleTimeoutMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContainerLookup {
|
||||||
|
containerId: string | null;
|
||||||
|
containerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContainerLifecycleService {
|
||||||
|
private readonly logger = new Logger(ContainerLifecycleService.name);
|
||||||
|
private readonly docker: Docker;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly crypto: CryptoService,
|
||||||
|
private readonly config: ConfigService
|
||||||
|
) {
|
||||||
|
const dockerHost = this.config.get<string>("DOCKER_HOST");
|
||||||
|
this.docker = this.createDockerClient(dockerHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure a user's container is running. Creates if needed, starts if stopped.
|
||||||
|
// Returns the container's internal URL and gateway token.
|
||||||
|
async ensureRunning(userId: string): Promise<{ url: string; token: string }> {
|
||||||
|
const containerRecord = await this.getOrCreateContainerRecord(userId);
|
||||||
|
const token = this.getGatewayToken(containerRecord.gatewayToken);
|
||||||
|
const existingContainer = await this.resolveContainer(containerRecord);
|
||||||
|
|
||||||
|
let container: ContainerHandle;
|
||||||
|
if (existingContainer) {
|
||||||
|
container = existingContainer;
|
||||||
|
const inspect = await container.inspect();
|
||||||
|
if (!inspect.State?.Running) {
|
||||||
|
await container.start();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const port = await this.findAvailableGatewayPort();
|
||||||
|
container = await this.createContainer(containerRecord, token, port);
|
||||||
|
await container.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
const inspect = await container.inspect();
|
||||||
|
const containerId = inspect.Id;
|
||||||
|
if (!containerId) {
|
||||||
|
throw new Error(
|
||||||
|
`Docker inspect did not return container ID for ${containerRecord.containerName}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gatewayPort = this.extractGatewayPort(inspect);
|
||||||
|
if (!gatewayPort) {
|
||||||
|
throw new Error(`Could not determine gateway port for ${containerRecord.containerName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await this.prisma.userContainer.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
containerId,
|
||||||
|
gatewayPort,
|
||||||
|
status: "running",
|
||||||
|
lastActiveAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `http://${containerRecord.containerName}:${String(gatewayPort)}`,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop a user's container
|
||||||
|
async stop(userId: string): Promise<void> {
|
||||||
|
const containerRecord = await this.prisma.userContainer.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!containerRecord) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = await this.resolveContainer(containerRecord);
|
||||||
|
if (container) {
|
||||||
|
try {
|
||||||
|
await container.stop({ t: CONTAINER_STOP_TIMEOUT_SECONDS });
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.isDockerNotFound(error) && !this.isAlreadyStopped(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.userContainer.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
status: "stopped",
|
||||||
|
containerId: null,
|
||||||
|
gatewayPort: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop idle containers (called by cron/scheduler)
|
||||||
|
async reapIdle(): Promise<{ stopped: string[] }> {
|
||||||
|
const now = Date.now();
|
||||||
|
const runningContainers = await this.prisma.userContainer.findMany({
|
||||||
|
where: {
|
||||||
|
status: "running",
|
||||||
|
lastActiveAt: { not: null },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
lastActiveAt: true,
|
||||||
|
idleTimeoutMin: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopped: string[] = [];
|
||||||
|
for (const container of runningContainers) {
|
||||||
|
const lastActiveAt = container.lastActiveAt;
|
||||||
|
if (!lastActiveAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idleLimitMs = container.idleTimeoutMin * 60 * 1000;
|
||||||
|
if (now - lastActiveAt.getTime() < idleLimitMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.stop(container.userId);
|
||||||
|
stopped.push(container.userId);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to stop idle container for user ${container.userId}: ${this.getErrorMessage(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stopped };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check all running containers
|
||||||
|
async healthCheckAll(): Promise<{ userId: string; healthy: boolean; error?: string }[]> {
|
||||||
|
const runningContainers = await this.prisma.userContainer.findMany({
|
||||||
|
where: {
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
containerId: true,
|
||||||
|
containerName: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: { userId: string; healthy: boolean; error?: string }[] = [];
|
||||||
|
for (const containerRecord of runningContainers) {
|
||||||
|
const container = await this.resolveContainer(containerRecord);
|
||||||
|
if (!container) {
|
||||||
|
results.push({
|
||||||
|
userId: containerRecord.userId,
|
||||||
|
healthy: false,
|
||||||
|
error: "Container not found",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inspect = await container.inspect();
|
||||||
|
const isRunning = inspect.State?.Running === true;
|
||||||
|
const healthState = inspect.State?.Health?.Status;
|
||||||
|
const healthy = isRunning && healthState !== "unhealthy";
|
||||||
|
|
||||||
|
if (healthy) {
|
||||||
|
results.push({
|
||||||
|
userId: containerRecord.userId,
|
||||||
|
healthy: true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
userId: containerRecord.userId,
|
||||||
|
healthy: false,
|
||||||
|
error:
|
||||||
|
healthState === "unhealthy" ? "Container healthcheck failed" : "Container not running",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
userId: containerRecord.userId,
|
||||||
|
healthy: false,
|
||||||
|
error: this.getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart a container with fresh config (for config updates)
|
||||||
|
async restart(userId: string): Promise<void> {
|
||||||
|
await this.stop(userId);
|
||||||
|
await this.ensureRunning(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lastActiveAt timestamp (called on each chat request)
|
||||||
|
async touch(userId: string): Promise<void> {
|
||||||
|
await this.prisma.userContainer.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
lastActiveAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get container status for a user
|
||||||
|
async getStatus(
|
||||||
|
userId: string
|
||||||
|
): Promise<{ status: string; port?: number; lastActive?: Date } | null> {
|
||||||
|
const container = await this.prisma.userContainer.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
gatewayPort: true,
|
||||||
|
lastActiveAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status: { status: string; port?: number; lastActive?: Date } = {
|
||||||
|
status: container.status,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (container.gatewayPort !== null) {
|
||||||
|
status.port = container.gatewayPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container.lastActiveAt !== null) {
|
||||||
|
status.lastActive = container.lastActiveAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDockerClient(dockerHost?: string): Docker {
|
||||||
|
if (!dockerHost || dockerHost.trim().length === 0) {
|
||||||
|
return new Docker({ socketPath: DEFAULT_DOCKER_SOCKET_PATH });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dockerHost.startsWith("unix://")) {
|
||||||
|
return new Docker({ socketPath: dockerHost.slice("unix://".length) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dockerHost.startsWith("tcp://")) {
|
||||||
|
const parsed = new URL(dockerHost.replace("tcp://", "http://"));
|
||||||
|
return new Docker({
|
||||||
|
host: parsed.hostname,
|
||||||
|
port: this.parseInteger(parsed.port, DEFAULT_DOCKER_TCP_PORT),
|
||||||
|
protocol: "http",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dockerHost.startsWith("http://") || dockerHost.startsWith("https://")) {
|
||||||
|
const parsed = new URL(dockerHost);
|
||||||
|
const protocol = parsed.protocol.replace(":", "");
|
||||||
|
return new Docker({
|
||||||
|
host: parsed.hostname,
|
||||||
|
port: this.parseInteger(parsed.port, DEFAULT_DOCKER_TCP_PORT),
|
||||||
|
protocol: protocol === "https" ? "https" : "http",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Docker({ socketPath: dockerHost });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrCreateContainerRecord(userId: string): Promise<UserContainerRecord> {
|
||||||
|
const existingContainer = await this.prisma.userContainer.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingContainer) {
|
||||||
|
return existingContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = this.crypto.generateToken();
|
||||||
|
const containerName = this.getContainerName(userId);
|
||||||
|
return this.prisma.userContainer.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
containerName,
|
||||||
|
gatewayToken: this.crypto.encrypt(token),
|
||||||
|
status: "stopped",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContainerName(userId: string): string {
|
||||||
|
return `mosaic-user-${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVolumeName(userId: string): string {
|
||||||
|
return `mosaic-user-${userId}-state`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOpenClawImage(): string {
|
||||||
|
return this.config.get<string>("OPENCLAW_IMAGE") ?? DEFAULT_OPENCLAW_IMAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOpenClawNetwork(): string {
|
||||||
|
return this.config.get<string>("OPENCLAW_NETWORK") ?? DEFAULT_OPENCLAW_NETWORK;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMosaicApiUrl(): string {
|
||||||
|
return this.config.get<string>("MOSAIC_API_URL") ?? DEFAULT_MOSAIC_API_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPortRangeStart(): number {
|
||||||
|
return this.parseInteger(
|
||||||
|
this.config.get<string>("OPENCLAW_PORT_RANGE_START"),
|
||||||
|
DEFAULT_OPENCLAW_PORT_RANGE_START
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveContainer(record: ContainerLookup): Promise<ContainerHandle | null> {
|
||||||
|
if (record.containerId) {
|
||||||
|
const byId = this.docker.getContainer(record.containerId) as unknown as ContainerHandle;
|
||||||
|
if (await this.containerExists(byId)) {
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byName = await this.findContainerByName(record.containerName);
|
||||||
|
if (byName) {
|
||||||
|
return byName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findContainerByName(containerName: string): Promise<ContainerHandle | null> {
|
||||||
|
const containers = await this.docker.listContainers({
|
||||||
|
all: true,
|
||||||
|
filters: {
|
||||||
|
name: [containerName],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const match = containers.find((container) => {
|
||||||
|
const names = container.Names;
|
||||||
|
return names.some((name) => name === `/${containerName}` || name.includes(containerName));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match?.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.docker.getContainer(match.Id) as unknown as ContainerHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async containerExists(container: ContainerHandle): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await container.inspect();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isDockerNotFound(error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createContainer(
|
||||||
|
containerRecord: UserContainerRecord,
|
||||||
|
token: string,
|
||||||
|
gatewayPort: number
|
||||||
|
): Promise<ContainerHandle> {
|
||||||
|
const container = await this.docker.createContainer({
|
||||||
|
name: containerRecord.containerName,
|
||||||
|
Image: this.getOpenClawImage(),
|
||||||
|
Env: [
|
||||||
|
`MOSAIC_API_URL=${this.getMosaicApiUrl()}`,
|
||||||
|
`AGENT_TOKEN=${token}`,
|
||||||
|
`AGENT_ID=${containerRecord.id}`,
|
||||||
|
],
|
||||||
|
ExposedPorts: {
|
||||||
|
[OPENCLAW_GATEWAY_PORT_KEY]: {},
|
||||||
|
},
|
||||||
|
HostConfig: {
|
||||||
|
Binds: [`${this.getVolumeName(containerRecord.userId)}:${OPENCLAW_STATE_PATH}`],
|
||||||
|
PortBindings: {
|
||||||
|
[OPENCLAW_GATEWAY_PORT_KEY]: [{ HostPort: String(gatewayPort) }],
|
||||||
|
},
|
||||||
|
NetworkMode: this.getOpenClawNetwork(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return container as unknown as ContainerHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractGatewayPort(inspect: DockerInspect): number | null {
|
||||||
|
const networkPort = inspect.NetworkSettings?.Ports?.[OPENCLAW_GATEWAY_PORT_KEY]?.[0]?.HostPort;
|
||||||
|
if (networkPort) {
|
||||||
|
return this.parseInteger(networkPort, 0) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostPort = inspect.HostConfig?.PortBindings?.[OPENCLAW_GATEWAY_PORT_KEY]?.[0]?.HostPort;
|
||||||
|
if (hostPort) {
|
||||||
|
return this.parseInteger(hostPort, 0) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findAvailableGatewayPort(): Promise<number> {
|
||||||
|
const usedPorts = await this.prisma.userContainer.findMany({
|
||||||
|
where: {
|
||||||
|
gatewayPort: { not: null },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
gatewayPort: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const takenPorts = new Set<number>();
|
||||||
|
for (const entry of usedPorts) {
|
||||||
|
if (entry.gatewayPort !== null) {
|
||||||
|
takenPorts.add(entry.gatewayPort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = this.getPortRangeStart();
|
||||||
|
while (takenPorts.has(candidate)) {
|
||||||
|
candidate += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGatewayToken(storedToken: string): string {
|
||||||
|
if (this.crypto.isEncrypted(storedToken)) {
|
||||||
|
return this.crypto.decrypt(storedToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return storedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseInteger(value: string | undefined, fallback: number): number {
|
||||||
|
if (!value) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDockerNotFound(error: unknown): boolean {
|
||||||
|
return this.getDockerStatusCode(error) === 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAlreadyStopped(error: unknown): boolean {
|
||||||
|
return this.getDockerStatusCode(error) === 304;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDockerStatusCode(error: unknown): number | null {
|
||||||
|
if (typeof error !== "object" || error === null || !("statusCode" in error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = error.statusCode;
|
||||||
|
return typeof statusCode === "number" ? statusCode : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Unknown error";
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/container-reaper/container-reaper.module.ts
Normal file
10
apps/api/src/container-reaper/container-reaper.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
|
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
|
||||||
|
import { ContainerReaperService } from "./container-reaper.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ScheduleModule, ContainerLifecycleModule],
|
||||||
|
providers: [ContainerReaperService],
|
||||||
|
})
|
||||||
|
export class ContainerReaperModule {}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
|
||||||
|
import { ContainerReaperService } from "./container-reaper.service";
|
||||||
|
|
||||||
|
describe("ContainerReaperService", () => {
|
||||||
|
let service: ContainerReaperService;
|
||||||
|
let containerLifecycle: Pick<ContainerLifecycleService, "reapIdle">;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
containerLifecycle = {
|
||||||
|
reapIdle: vi.fn(),
|
||||||
|
};
|
||||||
|
service = new ContainerReaperService(containerLifecycle as ContainerLifecycleService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reapIdleContainers calls containerLifecycle.reapIdle()", async () => {
|
||||||
|
vi.mocked(containerLifecycle.reapIdle).mockResolvedValue({ stopped: [] });
|
||||||
|
|
||||||
|
await service.reapIdleContainers();
|
||||||
|
|
||||||
|
expect(containerLifecycle.reapIdle).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reapIdleContainers handles errors gracefully", async () => {
|
||||||
|
const error = new Error("reap failure");
|
||||||
|
vi.mocked(containerLifecycle.reapIdle).mockRejectedValue(error);
|
||||||
|
const loggerError = vi.spyOn(service["logger"], "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(service.reapIdleContainers()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(loggerError).toHaveBeenCalledWith(
|
||||||
|
"Failed to reap idle containers",
|
||||||
|
expect.stringContaining("reap failure")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reapIdleContainers logs stopped container count", async () => {
|
||||||
|
vi.mocked(containerLifecycle.reapIdle).mockResolvedValue({ stopped: ["user-1", "user-2"] });
|
||||||
|
const loggerLog = vi.spyOn(service["logger"], "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await service.reapIdleContainers();
|
||||||
|
|
||||||
|
expect(loggerLog).toHaveBeenCalledWith("Stopped 2 idle containers: user-1, user-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
30
apps/api/src/container-reaper/container-reaper.service.ts
Normal file
30
apps/api/src/container-reaper/container-reaper.service.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { Cron, CronExpression } from "@nestjs/schedule";
|
||||||
|
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContainerReaperService {
|
||||||
|
private readonly logger = new Logger(ContainerReaperService.name);
|
||||||
|
|
||||||
|
constructor(private readonly containerLifecycle: ContainerLifecycleService) {}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||||
|
async reapIdleContainers(): Promise<void> {
|
||||||
|
this.logger.log("Running idle container reap cycle...");
|
||||||
|
try {
|
||||||
|
const result = await this.containerLifecycle.reapIdle();
|
||||||
|
if (result.stopped.length > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Stopped ${String(result.stopped.length)} idle containers: ${result.stopped.join(", ")}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.debug("No idle containers to stop");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
"Failed to reap idle containers",
|
||||||
|
error instanceof Error ? error.stack : String(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { DashboardService } from "./dashboard.service";
|
|||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||||
import type { DashboardSummaryDto } from "./dto";
|
import { DashboardSummaryDto } from "./dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller for dashboard endpoints.
|
* Controller for dashboard endpoints.
|
||||||
|
|||||||
115
apps/api/src/fleet-settings/fleet-settings.controller.ts
Normal file
115
apps/api/src/fleet-settings/fleet-settings.controller.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { AuthUser } from "@mosaic/shared";
|
||||||
|
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||||
|
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import {
|
||||||
|
CreateProviderDto,
|
||||||
|
ResetPasswordDto,
|
||||||
|
UpdateAgentConfigDto,
|
||||||
|
UpdateOidcDto,
|
||||||
|
UpdateProviderDto,
|
||||||
|
} from "./fleet-settings.dto";
|
||||||
|
import { FleetSettingsService } from "./fleet-settings.service";
|
||||||
|
|
||||||
|
@Controller("fleet-settings")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class FleetSettingsController {
|
||||||
|
constructor(private readonly fleetSettingsService: FleetSettingsService) {}
|
||||||
|
|
||||||
|
// --- Provider endpoints (user-scoped) ---
|
||||||
|
// GET /api/fleet-settings/providers — list user's providers
|
||||||
|
@Get("providers")
|
||||||
|
async listProviders(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.fleetSettingsService.listProviders(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/fleet-settings/providers/:id — get single provider
|
||||||
|
@Get("providers/:id")
|
||||||
|
async getProvider(@CurrentUser() user: AuthUser, @Param("id") id: string) {
|
||||||
|
return this.fleetSettingsService.getProvider(user.id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/fleet-settings/providers — create provider
|
||||||
|
@Post("providers")
|
||||||
|
async createProvider(@CurrentUser() user: AuthUser, @Body() dto: CreateProviderDto) {
|
||||||
|
return this.fleetSettingsService.createProvider(user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/fleet-settings/providers/:id — update provider
|
||||||
|
@Patch("providers/:id")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async updateProvider(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdateProviderDto
|
||||||
|
) {
|
||||||
|
await this.fleetSettingsService.updateProvider(user.id, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/fleet-settings/providers/:id — delete provider
|
||||||
|
@Delete("providers/:id")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async deleteProvider(@CurrentUser() user: AuthUser, @Param("id") id: string) {
|
||||||
|
await this.fleetSettingsService.deleteProvider(user.id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Agent config endpoints (user-scoped) ---
|
||||||
|
// GET /api/fleet-settings/agent-config — get user's agent config
|
||||||
|
@Get("agent-config")
|
||||||
|
async getAgentConfig(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.fleetSettingsService.getAgentConfig(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/fleet-settings/agent-config — update user's agent config
|
||||||
|
@Patch("agent-config")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async updateAgentConfig(@CurrentUser() user: AuthUser, @Body() dto: UpdateAgentConfigDto) {
|
||||||
|
await this.fleetSettingsService.updateAgentConfig(user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OIDC endpoints (admin only — use AdminGuard) ---
|
||||||
|
// GET /api/fleet-settings/oidc — get OIDC config
|
||||||
|
@Get("oidc")
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
async getOidcConfig() {
|
||||||
|
return this.fleetSettingsService.getOidcConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/fleet-settings/oidc — update OIDC config
|
||||||
|
@Put("oidc")
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async updateOidcConfig(@Body() dto: UpdateOidcDto) {
|
||||||
|
await this.fleetSettingsService.updateOidcConfig(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/fleet-settings/oidc — remove OIDC config
|
||||||
|
@Delete("oidc")
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async deleteOidcConfig() {
|
||||||
|
await this.fleetSettingsService.deleteOidcConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Breakglass endpoints (admin only) ---
|
||||||
|
// POST /api/fleet-settings/breakglass/reset-password — reset admin password
|
||||||
|
@Post("breakglass/reset-password")
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async resetBreakglassPassword(@Body() dto: ResetPasswordDto) {
|
||||||
|
await this.fleetSettingsService.resetBreakglassPassword(dto.username, dto.newPassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
apps/api/src/fleet-settings/fleet-settings.dto.ts
Normal file
122
apps/api/src/fleet-settings/fleet-settings.dto.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
ArrayNotEmpty,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export class CreateProviderDto {
|
||||||
|
@IsString({ message: "name must be a string" })
|
||||||
|
@IsNotEmpty({ message: "name is required" })
|
||||||
|
@MaxLength(100, { message: "name must not exceed 100 characters" })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString({ message: "displayName must be a string" })
|
||||||
|
@IsNotEmpty({ message: "displayName is required" })
|
||||||
|
@MaxLength(255, { message: "displayName must not exceed 255 characters" })
|
||||||
|
displayName!: string;
|
||||||
|
|
||||||
|
@IsString({ message: "type must be a string" })
|
||||||
|
@IsNotEmpty({ message: "type is required" })
|
||||||
|
@MaxLength(100, { message: "type must not exceed 100 characters" })
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl(
|
||||||
|
{ require_tld: false },
|
||||||
|
{ message: "baseUrl must be a valid URL (for example: https://api.example.com/v1)" }
|
||||||
|
)
|
||||||
|
baseUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "apiKey must be a string" })
|
||||||
|
apiKey?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "apiType must be a string" })
|
||||||
|
@MaxLength(100, { message: "apiType must not exceed 100 characters" })
|
||||||
|
apiType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: "models must be an array" })
|
||||||
|
@IsObject({ each: true, message: "each model must be an object" })
|
||||||
|
models?: Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateProviderDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "displayName must be a string" })
|
||||||
|
@MaxLength(255, { message: "displayName must not exceed 255 characters" })
|
||||||
|
displayName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl(
|
||||||
|
{ require_tld: false },
|
||||||
|
{ message: "baseUrl must be a valid URL (for example: https://api.example.com/v1)" }
|
||||||
|
)
|
||||||
|
baseUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "apiKey must be a string" })
|
||||||
|
apiKey?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean({ message: "isActive must be a boolean" })
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: "models must be an array" })
|
||||||
|
@IsObject({ each: true, message: "each model must be an object" })
|
||||||
|
models?: Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateAgentConfigDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "primaryModel must be a string" })
|
||||||
|
@MaxLength(255, { message: "primaryModel must not exceed 255 characters" })
|
||||||
|
primaryModel?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: "fallbackModels must be an array" })
|
||||||
|
@ArrayNotEmpty({ message: "fallbackModels cannot be empty" })
|
||||||
|
@IsString({ each: true, message: "each fallback model must be a string" })
|
||||||
|
fallbackModels?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "personality must be a string" })
|
||||||
|
personality?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateOidcDto {
|
||||||
|
@IsString({ message: "issuerUrl must be a string" })
|
||||||
|
@IsNotEmpty({ message: "issuerUrl is required" })
|
||||||
|
@IsUrl(
|
||||||
|
{ require_tld: false },
|
||||||
|
{ message: "issuerUrl must be a valid URL (for example: https://issuer.example.com)" }
|
||||||
|
)
|
||||||
|
issuerUrl!: string;
|
||||||
|
|
||||||
|
@IsString({ message: "clientId must be a string" })
|
||||||
|
@IsNotEmpty({ message: "clientId is required" })
|
||||||
|
clientId!: string;
|
||||||
|
|
||||||
|
@IsString({ message: "clientSecret must be a string" })
|
||||||
|
@IsNotEmpty({ message: "clientSecret is required" })
|
||||||
|
clientSecret!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
@IsString({ message: "username must be a string" })
|
||||||
|
@IsNotEmpty({ message: "username is required" })
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@IsString({ message: "newPassword must be a string" })
|
||||||
|
@MinLength(8, { message: "newPassword must be at least 8 characters" })
|
||||||
|
newPassword!: string;
|
||||||
|
}
|
||||||
14
apps/api/src/fleet-settings/fleet-settings.module.ts
Normal file
14
apps/api/src/fleet-settings/fleet-settings.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { CryptoModule } from "../crypto/crypto.module";
|
||||||
|
import { FleetSettingsController } from "./fleet-settings.controller";
|
||||||
|
import { FleetSettingsService } from "./fleet-settings.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, PrismaModule, CryptoModule],
|
||||||
|
controllers: [FleetSettingsController],
|
||||||
|
providers: [FleetSettingsService],
|
||||||
|
exports: [FleetSettingsService],
|
||||||
|
})
|
||||||
|
export class FleetSettingsModule {}
|
||||||
200
apps/api/src/fleet-settings/fleet-settings.service.spec.ts
Normal file
200
apps/api/src/fleet-settings/fleet-settings.service.spec.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { compare } from "bcryptjs";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { FleetSettingsService } from "./fleet-settings.service";
|
||||||
|
import type { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { CryptoService } from "../crypto/crypto.service";
|
||||||
|
|
||||||
|
describe("FleetSettingsService", () => {
|
||||||
|
let service: FleetSettingsService;
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
llmProvider: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
userAgentConfig: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
upsert: vi.fn(),
|
||||||
|
},
|
||||||
|
systemConfig: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
upsert: vi.fn(),
|
||||||
|
deleteMany: vi.fn(),
|
||||||
|
},
|
||||||
|
breakglassUser: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCrypto = {
|
||||||
|
encrypt: vi.fn((value: string) => `enc:${value}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new FleetSettingsService(
|
||||||
|
mockPrisma as unknown as PrismaService,
|
||||||
|
mockCrypto as unknown as CryptoService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listProviders returns only providers for the given userId", async () => {
|
||||||
|
mockPrisma.llmProvider.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "prov-1",
|
||||||
|
name: "openai-main",
|
||||||
|
displayName: "OpenAI",
|
||||||
|
type: "openai",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
isActive: true,
|
||||||
|
models: [{ id: "gpt-4.1" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.listProviders("user-1");
|
||||||
|
|
||||||
|
expect(mockPrisma.llmProvider.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: "user-1" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
displayName: true,
|
||||||
|
type: true,
|
||||||
|
baseUrl: true,
|
||||||
|
isActive: true,
|
||||||
|
models: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: "prov-1",
|
||||||
|
name: "openai-main",
|
||||||
|
displayName: "OpenAI",
|
||||||
|
type: "openai",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
isActive: true,
|
||||||
|
models: [{ id: "gpt-4.1" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createProvider encrypts apiKey", async () => {
|
||||||
|
mockPrisma.llmProvider.create.mockResolvedValue({
|
||||||
|
id: "prov-2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.createProvider("user-1", {
|
||||||
|
name: "zai-main",
|
||||||
|
displayName: "Z.ai",
|
||||||
|
type: "zai",
|
||||||
|
apiKey: "plaintext-key",
|
||||||
|
models: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCrypto.encrypt).toHaveBeenCalledWith("plaintext-key");
|
||||||
|
expect(mockPrisma.llmProvider.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
userId: "user-1",
|
||||||
|
name: "zai-main",
|
||||||
|
displayName: "Z.ai",
|
||||||
|
type: "zai",
|
||||||
|
baseUrl: null,
|
||||||
|
apiKey: "enc:plaintext-key",
|
||||||
|
apiType: "openai-completions",
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ id: "prov-2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateProvider rejects if not owned by user", async () => {
|
||||||
|
mockPrisma.llmProvider.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProvider("user-1", "provider-1", {
|
||||||
|
displayName: "New Name",
|
||||||
|
})
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
|
||||||
|
expect(mockPrisma.llmProvider.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteProvider rejects if not owned by user", async () => {
|
||||||
|
mockPrisma.llmProvider.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.deleteProvider("user-1", "provider-1")).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockPrisma.llmProvider.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getOidcConfig never returns clientSecret", async () => {
|
||||||
|
mockPrisma.systemConfig.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
key: "oidc.issuerUrl",
|
||||||
|
value: "https://issuer.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "oidc.clientId",
|
||||||
|
value: "client-id-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "oidc.clientSecret",
|
||||||
|
value: "enc:very-secret",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getOidcConfig();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
issuerUrl: "https://issuer.example.com",
|
||||||
|
clientId: "client-id-1",
|
||||||
|
configured: true,
|
||||||
|
});
|
||||||
|
expect(result).not.toHaveProperty("clientSecret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateOidcConfig encrypts clientSecret", async () => {
|
||||||
|
await service.updateOidcConfig({
|
||||||
|
issuerUrl: "https://issuer.example.com",
|
||||||
|
clientId: "client-id-1",
|
||||||
|
clientSecret: "super-secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCrypto.encrypt).toHaveBeenCalledWith("super-secret");
|
||||||
|
expect(mockPrisma.systemConfig.upsert).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockPrisma.systemConfig.upsert).toHaveBeenCalledWith({
|
||||||
|
where: { key: "oidc.clientSecret" },
|
||||||
|
update: { value: "enc:super-secret", encrypted: true },
|
||||||
|
create: { key: "oidc.clientSecret", value: "enc:super-secret", encrypted: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resetBreakglassPassword hashes new password", async () => {
|
||||||
|
mockPrisma.breakglassUser.findUnique.mockResolvedValue({
|
||||||
|
id: "bg-1",
|
||||||
|
username: "admin",
|
||||||
|
passwordHash: "old-hash",
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.resetBreakglassPassword("admin", "new-password-123");
|
||||||
|
|
||||||
|
expect(mockPrisma.breakglassUser.update).toHaveBeenCalledOnce();
|
||||||
|
const updateCall = mockPrisma.breakglassUser.update.mock.calls[0]?.[0];
|
||||||
|
const newHash = updateCall?.data?.passwordHash;
|
||||||
|
expect(newHash).toBeTypeOf("string");
|
||||||
|
expect(newHash).not.toBe("new-password-123");
|
||||||
|
expect(await compare("new-password-123", newHash as string)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
296
apps/api/src/fleet-settings/fleet-settings.service.ts
Normal file
296
apps/api/src/fleet-settings/fleet-settings.service.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CryptoService } from "../crypto/crypto.service";
|
||||||
|
import type {
|
||||||
|
CreateProviderDto,
|
||||||
|
ResetPasswordDto,
|
||||||
|
UpdateAgentConfigDto,
|
||||||
|
UpdateOidcDto,
|
||||||
|
UpdateProviderDto,
|
||||||
|
} from "./fleet-settings.dto";
|
||||||
|
|
||||||
|
const BCRYPT_ROUNDS = 12;
|
||||||
|
const DEFAULT_PROVIDER_API_TYPE = "openai-completions";
|
||||||
|
const OIDC_ISSUER_KEY = "oidc.issuerUrl";
|
||||||
|
const OIDC_CLIENT_ID_KEY = "oidc.clientId";
|
||||||
|
const OIDC_CLIENT_SECRET_KEY = "oidc.clientSecret";
|
||||||
|
const OIDC_KEYS = [OIDC_ISSUER_KEY, OIDC_CLIENT_ID_KEY, OIDC_CLIENT_SECRET_KEY] as const;
|
||||||
|
|
||||||
|
export interface FleetProviderResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
type: string;
|
||||||
|
baseUrl: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
models: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetAgentConfigResponse {
|
||||||
|
primaryModel: string | null;
|
||||||
|
fallbackModels: unknown[];
|
||||||
|
personality: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OidcConfigResponse {
|
||||||
|
issuerUrl?: string;
|
||||||
|
clientId?: string;
|
||||||
|
configured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FleetSettingsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly crypto: CryptoService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// --- LLM Provider CRUD (per-user scoped) ---
|
||||||
|
|
||||||
|
async listProviders(userId: string): Promise<FleetProviderResponse[]> {
|
||||||
|
return this.prisma.llmProvider.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
displayName: true,
|
||||||
|
type: true,
|
||||||
|
baseUrl: true,
|
||||||
|
isActive: true,
|
||||||
|
models: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProvider(userId: string, providerId: string): Promise<FleetProviderResponse> {
|
||||||
|
const provider = await this.prisma.llmProvider.findFirst({
|
||||||
|
where: {
|
||||||
|
id: providerId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
displayName: true,
|
||||||
|
type: true,
|
||||||
|
baseUrl: true,
|
||||||
|
isActive: true,
|
||||||
|
models: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new NotFoundException(`Provider ${providerId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProvider(userId: string, data: CreateProviderDto): Promise<{ id: string }> {
|
||||||
|
const provider = await this.prisma.llmProvider.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
name: data.name,
|
||||||
|
displayName: data.displayName,
|
||||||
|
type: data.type,
|
||||||
|
baseUrl: data.baseUrl ?? null,
|
||||||
|
apiKey: data.apiKey ? this.crypto.encrypt(data.apiKey) : null,
|
||||||
|
apiType: data.apiType ?? DEFAULT_PROVIDER_API_TYPE,
|
||||||
|
models: (data.models ?? []) as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProvider(userId: string, providerId: string, data: UpdateProviderDto): Promise<void> {
|
||||||
|
await this.assertProviderOwnership(userId, providerId);
|
||||||
|
|
||||||
|
const updateData: Prisma.LlmProviderUpdateInput = {};
|
||||||
|
if (data.displayName !== undefined) {
|
||||||
|
updateData.displayName = data.displayName;
|
||||||
|
}
|
||||||
|
if (data.baseUrl !== undefined) {
|
||||||
|
updateData.baseUrl = data.baseUrl;
|
||||||
|
}
|
||||||
|
if (data.isActive !== undefined) {
|
||||||
|
updateData.isActive = data.isActive;
|
||||||
|
}
|
||||||
|
if (data.models !== undefined) {
|
||||||
|
updateData.models = data.models as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
if (data.apiKey !== undefined) {
|
||||||
|
updateData.apiKey = data.apiKey.length > 0 ? this.crypto.encrypt(data.apiKey) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.llmProvider.update({
|
||||||
|
where: { id: providerId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProvider(userId: string, providerId: string): Promise<void> {
|
||||||
|
await this.assertProviderOwnership(userId, providerId);
|
||||||
|
|
||||||
|
await this.prisma.llmProvider.delete({
|
||||||
|
where: { id: providerId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User Agent Config ---
|
||||||
|
|
||||||
|
async getAgentConfig(userId: string): Promise<FleetAgentConfigResponse> {
|
||||||
|
const config = await this.prisma.userAgentConfig.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
primaryModel: true,
|
||||||
|
fallbackModels: true,
|
||||||
|
personality: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return {
|
||||||
|
primaryModel: null,
|
||||||
|
fallbackModels: [],
|
||||||
|
personality: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
primaryModel: config.primaryModel,
|
||||||
|
fallbackModels: this.normalizeJsonArray(config.fallbackModels),
|
||||||
|
personality: config.personality,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAgentConfig(userId: string, data: UpdateAgentConfigDto): Promise<void> {
|
||||||
|
const updateData: Prisma.UserAgentConfigUpdateInput = {};
|
||||||
|
if (data.primaryModel !== undefined) {
|
||||||
|
updateData.primaryModel = data.primaryModel;
|
||||||
|
}
|
||||||
|
if (data.personality !== undefined) {
|
||||||
|
updateData.personality = data.personality;
|
||||||
|
}
|
||||||
|
if (data.fallbackModels !== undefined) {
|
||||||
|
updateData.fallbackModels = data.fallbackModels as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createData: Prisma.UserAgentConfigCreateInput = {
|
||||||
|
userId,
|
||||||
|
fallbackModels: (data.fallbackModels ?? []) as Prisma.InputJsonValue,
|
||||||
|
...(data.primaryModel !== undefined ? { primaryModel: data.primaryModel } : {}),
|
||||||
|
...(data.personality !== undefined ? { personality: data.personality } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.prisma.userAgentConfig.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: createData,
|
||||||
|
update: updateData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OIDC Config (admin only) ---
|
||||||
|
|
||||||
|
async getOidcConfig(): Promise<OidcConfigResponse> {
|
||||||
|
const entries = await this.prisma.systemConfig.findMany({
|
||||||
|
where: {
|
||||||
|
key: {
|
||||||
|
in: [...OIDC_KEYS],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
key: true,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const byKey = new Map(entries.map((entry) => [entry.key, entry.value]));
|
||||||
|
const issuerUrl = byKey.get(OIDC_ISSUER_KEY);
|
||||||
|
const clientId = byKey.get(OIDC_CLIENT_ID_KEY);
|
||||||
|
const hasSecret = byKey.has(OIDC_CLIENT_SECRET_KEY);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(issuerUrl ? { issuerUrl } : {}),
|
||||||
|
...(clientId ? { clientId } : {}),
|
||||||
|
configured: Boolean(issuerUrl && clientId && hasSecret),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOidcConfig(data: UpdateOidcDto): Promise<void> {
|
||||||
|
const encryptedSecret = this.crypto.encrypt(data.clientSecret);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.upsertSystemConfig(OIDC_ISSUER_KEY, data.issuerUrl, false),
|
||||||
|
this.upsertSystemConfig(OIDC_CLIENT_ID_KEY, data.clientId, false),
|
||||||
|
this.upsertSystemConfig(OIDC_CLIENT_SECRET_KEY, encryptedSecret, true),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOidcConfig(): Promise<void> {
|
||||||
|
await this.prisma.systemConfig.deleteMany({
|
||||||
|
where: {
|
||||||
|
key: {
|
||||||
|
in: [...OIDC_KEYS],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Breakglass (admin only) ---
|
||||||
|
|
||||||
|
async resetBreakglassPassword(
|
||||||
|
username: ResetPasswordDto["username"],
|
||||||
|
newPassword: ResetPasswordDto["newPassword"]
|
||||||
|
): Promise<void> {
|
||||||
|
const user = await this.prisma.breakglassUser.findUnique({
|
||||||
|
where: { username },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(`Breakglass user ${username} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hash(newPassword, BCRYPT_ROUNDS);
|
||||||
|
|
||||||
|
await this.prisma.breakglassUser.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { passwordHash },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertProviderOwnership(userId: string, providerId: string): Promise<void> {
|
||||||
|
const provider = await this.prisma.llmProvider.findFirst({
|
||||||
|
where: {
|
||||||
|
id: providerId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new NotFoundException(`Provider ${providerId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertSystemConfig(key: string, value: string, encrypted: boolean): Promise<void> {
|
||||||
|
await this.prisma.systemConfig.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { value, encrypted },
|
||||||
|
create: { key, value, encrypted },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeJsonArray(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller, Get, Param, Query } from "@nestjs/common";
|
import { Controller, Get, Param, Query } from "@nestjs/common";
|
||||||
import type { LlmUsageLog } from "@prisma/client";
|
import type { LlmUsageLog } from "@prisma/client";
|
||||||
import { LlmUsageService } from "./llm-usage.service";
|
import { LlmUsageService } from "./llm-usage.service";
|
||||||
import type { UsageAnalyticsQueryDto, UsageAnalyticsResponseDto } from "./dto";
|
import { UsageAnalyticsQueryDto, UsageAnalyticsResponseDto } from "./dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LLM Usage Controller
|
* LLM Usage Controller
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { RequestMethod, ValidationPipe } from "@nestjs/common";
|
import { RequestMethod, ValidationPipe } from "@nestjs/common";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
|
import helmet from "helmet";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { getTrustedOrigins } from "./auth/auth.config";
|
import { getTrustedOrigins } from "./auth/auth.config";
|
||||||
import { GlobalExceptionFilter } from "./filters/global-exception.filter";
|
import { GlobalExceptionFilter } from "./filters/global-exception.filter";
|
||||||
@@ -33,6 +34,14 @@ async function bootstrap() {
|
|||||||
// Enable cookie parser for session handling
|
// Enable cookie parser for session handling
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Enable helmet security headers
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: false, // Let Next.js handle CSP
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Enable global validation pipe with transformation
|
// Enable global validation pipe with transformation
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
|
|||||||
63
apps/api/src/onboarding/onboarding.controller.ts
Normal file
63
apps/api/src/onboarding/onboarding.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
AddProviderDto,
|
||||||
|
ConfigureOidcDto,
|
||||||
|
CreateBreakglassDto,
|
||||||
|
TestProviderDto,
|
||||||
|
} from "./onboarding.dto";
|
||||||
|
import { OnboardingGuard } from "./onboarding.guard";
|
||||||
|
import { OnboardingService } from "./onboarding.service";
|
||||||
|
|
||||||
|
@Controller("onboarding")
|
||||||
|
export class OnboardingController {
|
||||||
|
constructor(private readonly onboardingService: OnboardingService) {}
|
||||||
|
|
||||||
|
// GET /api/onboarding/status — returns { completed: boolean }
|
||||||
|
@Get("status")
|
||||||
|
async status(): Promise<{ completed: boolean }> {
|
||||||
|
return {
|
||||||
|
completed: await this.onboardingService.isCompleted(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/onboarding/breakglass — body: { username, password } → create admin
|
||||||
|
@Post("breakglass")
|
||||||
|
@UseGuards(OnboardingGuard)
|
||||||
|
async createBreakglass(
|
||||||
|
@Body() body: CreateBreakglassDto
|
||||||
|
): Promise<{ id: string; username: string }> {
|
||||||
|
return this.onboardingService.createBreakglassUser(body.username, body.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/onboarding/oidc — body: { issuerUrl, clientId, clientSecret } → save OIDC
|
||||||
|
@Post("oidc")
|
||||||
|
@UseGuards(OnboardingGuard)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async configureOidc(@Body() body: ConfigureOidcDto): Promise<void> {
|
||||||
|
await this.onboardingService.configureOidc(body.issuerUrl, body.clientId, body.clientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/onboarding/provider — body: { name, displayName, type, baseUrl?, apiKey?, models? } → add provider
|
||||||
|
@Post("provider")
|
||||||
|
@UseGuards(OnboardingGuard)
|
||||||
|
async addProvider(@Body() body: AddProviderDto): Promise<{ id: string }> {
|
||||||
|
const userId = await this.onboardingService.getBreakglassUserId();
|
||||||
|
|
||||||
|
return this.onboardingService.addProvider(userId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/onboarding/provider/test — body: { type, baseUrl?, apiKey? } → test connection
|
||||||
|
@Post("provider/test")
|
||||||
|
@UseGuards(OnboardingGuard)
|
||||||
|
async testProvider(@Body() body: TestProviderDto): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.onboardingService.testProvider(body.type, body.baseUrl, body.apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/onboarding/complete — mark done
|
||||||
|
@Post("complete")
|
||||||
|
@UseGuards(OnboardingGuard)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async complete(): Promise<void> {
|
||||||
|
await this.onboardingService.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/api/src/onboarding/onboarding.dto.ts
Normal file
71
apps/api/src/onboarding/onboarding.dto.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsArray, IsOptional, IsString, IsUrl, MinLength, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateBreakglassDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConfigureOidcDto {
|
||||||
|
@IsString()
|
||||||
|
@IsUrl({ require_tld: false })
|
||||||
|
issuerUrl!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
clientId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
clientSecret!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProviderModelDto {
|
||||||
|
@IsString()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddProviderDto {
|
||||||
|
@IsString()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
displayName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
baseUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
apiKey?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ProviderModelDto)
|
||||||
|
models?: ProviderModelDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestProviderDto {
|
||||||
|
@IsString()
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
baseUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
apiKey?: string;
|
||||||
|
}
|
||||||
17
apps/api/src/onboarding/onboarding.guard.ts
Normal file
17
apps/api/src/onboarding/onboarding.guard.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
||||||
|
import { OnboardingService } from "./onboarding.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OnboardingGuard implements CanActivate {
|
||||||
|
constructor(private readonly onboardingService: OnboardingService) {}
|
||||||
|
|
||||||
|
async canActivate(_context: ExecutionContext): Promise<boolean> {
|
||||||
|
const completed = await this.onboardingService.isCompleted();
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
throw new ForbiddenException("Onboarding already completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/onboarding/onboarding.module.ts
Normal file
15
apps/api/src/onboarding/onboarding.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { CryptoModule } from "../crypto/crypto.module";
|
||||||
|
import { OnboardingController } from "./onboarding.controller";
|
||||||
|
import { OnboardingService } from "./onboarding.service";
|
||||||
|
import { OnboardingGuard } from "./onboarding.guard";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, CryptoModule, ConfigModule],
|
||||||
|
controllers: [OnboardingController],
|
||||||
|
providers: [OnboardingService, OnboardingGuard],
|
||||||
|
exports: [OnboardingService],
|
||||||
|
})
|
||||||
|
export class OnboardingModule {}
|
||||||
206
apps/api/src/onboarding/onboarding.service.spec.ts
Normal file
206
apps/api/src/onboarding/onboarding.service.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
import { OnboardingService } from "./onboarding.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CryptoService } from "../crypto/crypto.service";
|
||||||
|
|
||||||
|
vi.mock("bcryptjs", () => ({
|
||||||
|
hash: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("OnboardingService", () => {
|
||||||
|
let service: OnboardingService;
|
||||||
|
|
||||||
|
const mockPrismaService = {
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
upsert: vi.fn(),
|
||||||
|
},
|
||||||
|
breakglassUser: {
|
||||||
|
count: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
llmProvider: {
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCryptoService = {
|
||||||
|
encrypt: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
service = new OnboardingService(
|
||||||
|
mockPrismaService as unknown as PrismaService,
|
||||||
|
mockCryptoService as unknown as CryptoService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isCompleted returns false when no config exists", async () => {
|
||||||
|
mockPrismaService.systemConfig.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.isCompleted()).resolves.toBe(false);
|
||||||
|
expect(mockPrismaService.systemConfig.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { key: "onboarding.completed" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isCompleted returns true when completed", async () => {
|
||||||
|
mockPrismaService.systemConfig.findUnique.mockResolvedValue({
|
||||||
|
id: "cfg-1",
|
||||||
|
key: "onboarding.completed",
|
||||||
|
value: "true",
|
||||||
|
encrypted: false,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.isCompleted()).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createBreakglassUser hashes password and creates record", async () => {
|
||||||
|
const mockedHash = vi.mocked(hash);
|
||||||
|
mockedHash.mockResolvedValue("hashed-password");
|
||||||
|
|
||||||
|
mockPrismaService.breakglassUser.count.mockResolvedValue(0);
|
||||||
|
mockPrismaService.breakglassUser.create.mockResolvedValue({
|
||||||
|
id: "breakglass-1",
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.createBreakglassUser("admin", "supersecret123");
|
||||||
|
|
||||||
|
expect(mockedHash).toHaveBeenCalledWith("supersecret123", 12);
|
||||||
|
expect(mockPrismaService.breakglassUser.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
username: "admin",
|
||||||
|
passwordHash: "hashed-password",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ id: "breakglass-1", username: "admin" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createBreakglassUser rejects if user already exists", async () => {
|
||||||
|
mockPrismaService.breakglassUser.count.mockResolvedValue(1);
|
||||||
|
|
||||||
|
await expect(service.createBreakglassUser("admin", "supersecret123")).rejects.toThrow(
|
||||||
|
"Breakglass user already exists"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("configureOidc encrypts secret and saves to SystemConfig", async () => {
|
||||||
|
mockCryptoService.encrypt.mockReturnValue("enc:oidc-secret");
|
||||||
|
mockPrismaService.systemConfig.upsert.mockResolvedValue({
|
||||||
|
id: "cfg",
|
||||||
|
key: "oidc.clientSecret",
|
||||||
|
value: "enc:oidc-secret",
|
||||||
|
encrypted: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.configureOidc("https://auth.example.com", "client-id", "client-secret");
|
||||||
|
|
||||||
|
expect(mockCryptoService.encrypt).toHaveBeenCalledWith("client-secret");
|
||||||
|
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
|
||||||
|
where: { key: "oidc.issuerUrl" },
|
||||||
|
create: {
|
||||||
|
key: "oidc.issuerUrl",
|
||||||
|
value: "https://auth.example.com",
|
||||||
|
encrypted: false,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
value: "https://auth.example.com",
|
||||||
|
encrypted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
|
||||||
|
where: { key: "oidc.clientId" },
|
||||||
|
create: {
|
||||||
|
key: "oidc.clientId",
|
||||||
|
value: "client-id",
|
||||||
|
encrypted: false,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
value: "client-id",
|
||||||
|
encrypted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
|
||||||
|
where: { key: "oidc.clientSecret" },
|
||||||
|
create: {
|
||||||
|
key: "oidc.clientSecret",
|
||||||
|
value: "enc:oidc-secret",
|
||||||
|
encrypted: true,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
value: "enc:oidc-secret",
|
||||||
|
encrypted: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addProvider encrypts apiKey and creates LlmProvider", async () => {
|
||||||
|
mockCryptoService.encrypt.mockReturnValue("enc:api-key");
|
||||||
|
mockPrismaService.llmProvider.create.mockResolvedValue({
|
||||||
|
id: "provider-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.addProvider("breakglass-1", {
|
||||||
|
name: "my-openai",
|
||||||
|
displayName: "OpenAI",
|
||||||
|
type: "openai",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
apiKey: "sk-test",
|
||||||
|
models: [{ id: "gpt-4o-mini", name: "GPT-4o Mini" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCryptoService.encrypt).toHaveBeenCalledWith("sk-test");
|
||||||
|
expect(mockPrismaService.llmProvider.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
userId: "breakglass-1",
|
||||||
|
name: "my-openai",
|
||||||
|
displayName: "OpenAI",
|
||||||
|
type: "openai",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
apiKey: "enc:api-key",
|
||||||
|
models: [{ id: "gpt-4o-mini", name: "GPT-4o Mini" }],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ id: "provider-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("complete sets SystemConfig flag", async () => {
|
||||||
|
mockPrismaService.systemConfig.upsert.mockResolvedValue({
|
||||||
|
id: "cfg-1",
|
||||||
|
key: "onboarding.completed",
|
||||||
|
value: "true",
|
||||||
|
encrypted: false,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.complete();
|
||||||
|
|
||||||
|
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
|
||||||
|
where: { key: "onboarding.completed" },
|
||||||
|
create: {
|
||||||
|
key: "onboarding.completed",
|
||||||
|
value: "true",
|
||||||
|
encrypted: false,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
value: "true",
|
||||||
|
encrypted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
191
apps/api/src/onboarding/onboarding.service.ts
Normal file
191
apps/api/src/onboarding/onboarding.service.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
|
||||||
|
import type { InputJsonValue } from "@prisma/client/runtime/library";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CryptoService } from "../crypto/crypto.service";
|
||||||
|
|
||||||
|
const BCRYPT_ROUNDS = 12;
|
||||||
|
const TEST_PROVIDER_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
const ONBOARDING_COMPLETED_KEY = "onboarding.completed";
|
||||||
|
const OIDC_ISSUER_URL_KEY = "oidc.issuerUrl";
|
||||||
|
const OIDC_CLIENT_ID_KEY = "oidc.clientId";
|
||||||
|
const OIDC_CLIENT_SECRET_KEY = "oidc.clientSecret";
|
||||||
|
|
||||||
|
interface ProviderModelInput {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddProviderInput {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
type: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
models?: ProviderModelInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OnboardingService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly crypto: CryptoService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Check if onboarding is completed
|
||||||
|
async isCompleted(): Promise<boolean> {
|
||||||
|
const completedFlag = await this.prisma.systemConfig.findUnique({
|
||||||
|
where: { key: ONBOARDING_COMPLETED_KEY },
|
||||||
|
});
|
||||||
|
|
||||||
|
return completedFlag?.value === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Create breakglass admin user
|
||||||
|
async createBreakglassUser(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{ id: string; username: string }> {
|
||||||
|
const breakglassCount = await this.prisma.breakglassUser.count();
|
||||||
|
if (breakglassCount > 0) {
|
||||||
|
throw new ConflictException("Breakglass user already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hash(password, BCRYPT_ROUNDS);
|
||||||
|
|
||||||
|
return this.prisma.breakglassUser.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Configure OIDC provider (optional)
|
||||||
|
async configureOidc(issuerUrl: string, clientId: string, clientSecret: string): Promise<void> {
|
||||||
|
const encryptedSecret = this.crypto.encrypt(clientSecret);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.upsertSystemConfig(OIDC_ISSUER_URL_KEY, issuerUrl, false),
|
||||||
|
this.upsertSystemConfig(OIDC_CLIENT_ID_KEY, clientId, false),
|
||||||
|
this.upsertSystemConfig(OIDC_CLIENT_SECRET_KEY, encryptedSecret, true),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Add first LLM provider
|
||||||
|
async addProvider(userId: string, data: AddProviderInput): Promise<{ id: string }> {
|
||||||
|
const encryptedApiKey = data.apiKey ? this.crypto.encrypt(data.apiKey) : undefined;
|
||||||
|
|
||||||
|
return this.prisma.llmProvider.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
name: data.name,
|
||||||
|
displayName: data.displayName,
|
||||||
|
type: data.type,
|
||||||
|
baseUrl: data.baseUrl ?? null,
|
||||||
|
apiKey: encryptedApiKey ?? null,
|
||||||
|
models: (data.models ?? []) as unknown as InputJsonValue,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3b: Test LLM provider connection
|
||||||
|
async testProvider(
|
||||||
|
type: string,
|
||||||
|
baseUrl?: string,
|
||||||
|
apiKey?: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const normalizedType = type.trim().toLowerCase();
|
||||||
|
if (!normalizedType) {
|
||||||
|
return { success: false, error: "Provider type is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let probeUrl: string;
|
||||||
|
try {
|
||||||
|
probeUrl = this.buildProbeUrl(normalizedType, baseUrl);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
if (apiKey) {
|
||||||
|
headers.Authorization = `Bearer ${apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(probeUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(TEST_PROVIDER_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Provider returned ${String(response.status)} ${response.statusText}`.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Mark onboarding complete
|
||||||
|
async complete(): Promise<void> {
|
||||||
|
await this.upsertSystemConfig(ONBOARDING_COMPLETED_KEY, "true", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBreakglassUserId(): Promise<string> {
|
||||||
|
const user = await this.prisma.breakglassUser.findFirst({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new BadRequestException("Create a breakglass user before adding a provider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertSystemConfig(key: string, value: string, encrypted: boolean): Promise<void> {
|
||||||
|
await this.prisma.systemConfig.upsert({
|
||||||
|
where: { key },
|
||||||
|
create: { key, value, encrypted },
|
||||||
|
update: { value, encrypted },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildProbeUrl(type: string, baseUrl?: string): string {
|
||||||
|
const resolvedBaseUrl = baseUrl ?? this.getDefaultProviderBaseUrl(type);
|
||||||
|
const normalizedBaseUrl = resolvedBaseUrl.endsWith("/")
|
||||||
|
? resolvedBaseUrl
|
||||||
|
: `${resolvedBaseUrl}/`;
|
||||||
|
const endpointPath = type === "ollama" ? "api/tags" : "models";
|
||||||
|
|
||||||
|
return new URL(endpointPath, normalizedBaseUrl).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultProviderBaseUrl(type: string): string {
|
||||||
|
if (type === "ollama") {
|
||||||
|
return "http://localhost:11434";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "https://api.openai.com/v1";
|
||||||
|
}
|
||||||
|
}
|
||||||
194
apps/api/src/orchestrator/orchestrator.controller.spec.ts
Normal file
194
apps/api/src/orchestrator/orchestrator.controller.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi, afterEach } from "vitest";
|
||||||
|
import type { Response } from "express";
|
||||||
|
import { AgentStatus } from "@prisma/client";
|
||||||
|
import { OrchestratorController } from "./orchestrator.controller";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
|
||||||
|
describe("OrchestratorController", () => {
|
||||||
|
const mockPrismaService = {
|
||||||
|
agent: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let controller: OrchestratorController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
controller = new OrchestratorController(mockPrismaService as unknown as PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAgents", () => {
|
||||||
|
it("returns active agents with API widget shape", async () => {
|
||||||
|
mockPrismaService.agent.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Planner",
|
||||||
|
status: AgentStatus.WORKING,
|
||||||
|
role: "planner",
|
||||||
|
createdAt: new Date("2026-02-28T10:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await controller.getAgents();
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Planner",
|
||||||
|
status: AgentStatus.WORKING,
|
||||||
|
type: "planner",
|
||||||
|
createdAt: new Date("2026-02-28T10:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mockPrismaService.agent.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
status: {
|
||||||
|
not: AgentStatus.TERMINATED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to type=agent when role is missing", async () => {
|
||||||
|
mockPrismaService.agent.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "agent-2",
|
||||||
|
name: null,
|
||||||
|
status: AgentStatus.IDLE,
|
||||||
|
role: null,
|
||||||
|
createdAt: new Date("2026-02-28T11:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await controller.getAgents();
|
||||||
|
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
id: "agent-2",
|
||||||
|
type: "agent",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("streamEvents", () => {
|
||||||
|
it("sets SSE headers and writes initial data payload", async () => {
|
||||||
|
const onHandlers: Record<string, (() => void) | undefined> = {};
|
||||||
|
const mockRes = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
end: vi.fn(),
|
||||||
|
on: vi.fn((event: string, handler: () => void) => {
|
||||||
|
onHandlers[event] = handler;
|
||||||
|
return mockRes;
|
||||||
|
}),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
mockPrismaService.agent.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Worker",
|
||||||
|
status: AgentStatus.WORKING,
|
||||||
|
role: "worker",
|
||||||
|
createdAt: new Date("2026-02-28T12:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await controller.streamEvents(mockRes);
|
||||||
|
|
||||||
|
expect(mockRes.setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream");
|
||||||
|
expect(mockRes.setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache");
|
||||||
|
expect(mockRes.setHeader).toHaveBeenCalledWith("Connection", "keep-alive");
|
||||||
|
expect(mockRes.setHeader).toHaveBeenCalledWith("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
expect(mockRes.write).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('"type":"agents:updated"')
|
||||||
|
);
|
||||||
|
expect(typeof onHandlers.close).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polls every 5 seconds and only emits when payload changes", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const onHandlers: Record<string, (() => void) | undefined> = {};
|
||||||
|
const mockRes = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
end: vi.fn(),
|
||||||
|
on: vi.fn((event: string, handler: () => void) => {
|
||||||
|
onHandlers[event] = handler;
|
||||||
|
return mockRes;
|
||||||
|
}),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
const firstPayload = [
|
||||||
|
{
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Worker",
|
||||||
|
status: AgentStatus.WORKING,
|
||||||
|
role: "worker",
|
||||||
|
createdAt: new Date("2026-02-28T12:00:00.000Z"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const secondPayload = [
|
||||||
|
{
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Worker",
|
||||||
|
status: AgentStatus.WAITING,
|
||||||
|
role: "worker",
|
||||||
|
createdAt: new Date("2026-02-28T12:00:00.000Z"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockPrismaService.agent.findMany
|
||||||
|
.mockResolvedValueOnce(firstPayload)
|
||||||
|
.mockResolvedValueOnce(firstPayload)
|
||||||
|
.mockResolvedValueOnce(secondPayload);
|
||||||
|
|
||||||
|
await controller.streamEvents(mockRes);
|
||||||
|
|
||||||
|
// 1 initial data event
|
||||||
|
const getDataEventCalls = () =>
|
||||||
|
mockRes.write.mock.calls.filter(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].startsWith("data: ")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getDataEventCalls()).toHaveLength(1);
|
||||||
|
|
||||||
|
// No change after first poll => no new data event
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(getDataEventCalls()).toHaveLength(1);
|
||||||
|
|
||||||
|
// Status changed on second poll => emits new data event
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(getDataEventCalls()).toHaveLength(2);
|
||||||
|
|
||||||
|
onHandlers.close?.();
|
||||||
|
expect(mockRes.end).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("security", () => {
|
||||||
|
it("uses AuthGuard at the controller level", () => {
|
||||||
|
const guards = Reflect.getMetadata("__guards__", OrchestratorController) as unknown[];
|
||||||
|
const guardClasses = guards.map((guard) => guard);
|
||||||
|
|
||||||
|
expect(guardClasses).toContain(AuthGuard);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
211
apps/api/src/orchestrator/orchestrator.controller.ts
Normal file
211
apps/api/src/orchestrator/orchestrator.controller.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { Controller, Get, Query, Res, UseGuards } from "@nestjs/common";
|
||||||
|
import { AgentStatus } from "@prisma/client";
|
||||||
|
import type { Response } from "express";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
|
const AGENT_POLL_INTERVAL_MS = 5_000;
|
||||||
|
const SSE_HEARTBEAT_MS = 15_000;
|
||||||
|
const DEFAULT_EVENTS_LIMIT = 25;
|
||||||
|
|
||||||
|
interface OrchestratorAgentDto {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
status: AgentStatus;
|
||||||
|
type: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrchestratorEventDto {
|
||||||
|
type: string;
|
||||||
|
timestamp: string;
|
||||||
|
agentId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrchestratorHealthDto {
|
||||||
|
status: "healthy" | "degraded" | "unhealthy";
|
||||||
|
database: "connected" | "disconnected";
|
||||||
|
agents: {
|
||||||
|
total: number;
|
||||||
|
working: number;
|
||||||
|
idle: number;
|
||||||
|
errored: number;
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller("orchestrator")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class OrchestratorController {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get("agents")
|
||||||
|
async getAgents(): Promise<OrchestratorAgentDto[]> {
|
||||||
|
return this.fetchActiveAgents();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("events/recent")
|
||||||
|
async getRecentEvents(
|
||||||
|
@Query("limit") limit?: string
|
||||||
|
): Promise<{ events: OrchestratorEventDto[] }> {
|
||||||
|
const eventsLimit = limit ? parseInt(limit, 10) : DEFAULT_EVENTS_LIMIT;
|
||||||
|
const safeLimit = Math.min(Math.max(eventsLimit, 1), 100);
|
||||||
|
|
||||||
|
// Fetch recent agent activity to derive events
|
||||||
|
const agents = await this.prisma.agent.findMany({
|
||||||
|
where: {
|
||||||
|
status: {
|
||||||
|
not: AgentStatus.TERMINATED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
take: safeLimit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derive events from agent status changes
|
||||||
|
const events: OrchestratorEventDto[] = agents.map((agent) => ({
|
||||||
|
type: `agent:${agent.status.toLowerCase()}`,
|
||||||
|
timestamp: agent.createdAt.toISOString(),
|
||||||
|
agentId: agent.id,
|
||||||
|
data: {
|
||||||
|
name: agent.name,
|
||||||
|
role: agent.role,
|
||||||
|
model: agent.model,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { events };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("health")
|
||||||
|
async getHealth(): Promise<OrchestratorHealthDto> {
|
||||||
|
let databaseConnected = false;
|
||||||
|
let agents: OrchestratorAgentDto[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check database connectivity
|
||||||
|
await this.prisma.$queryRaw`SELECT 1`;
|
||||||
|
databaseConnected = true;
|
||||||
|
|
||||||
|
// Get agent counts
|
||||||
|
agents = await this.fetchActiveAgents();
|
||||||
|
} catch {
|
||||||
|
databaseConnected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const working = agents.filter((a) => a.status === AgentStatus.WORKING).length;
|
||||||
|
const idle = agents.filter((a) => a.status === AgentStatus.IDLE).length;
|
||||||
|
const errored = agents.filter((a) => a.status === AgentStatus.ERROR).length;
|
||||||
|
|
||||||
|
let status: OrchestratorHealthDto["status"] = "healthy";
|
||||||
|
if (!databaseConnected) {
|
||||||
|
status = "unhealthy";
|
||||||
|
} else if (errored > 0) {
|
||||||
|
status = "degraded";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
database: databaseConnected ? "connected" : "disconnected",
|
||||||
|
agents: {
|
||||||
|
total: agents.length,
|
||||||
|
working,
|
||||||
|
idle,
|
||||||
|
errored,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("events")
|
||||||
|
async streamEvents(@Res() res: Response): Promise<void> {
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
if (typeof res.flushHeaders === "function") {
|
||||||
|
res.flushHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
let isClosed = false;
|
||||||
|
let previousSnapshot = "";
|
||||||
|
|
||||||
|
const emitSnapshotIfChanged = async (): Promise<void> => {
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agents = await this.fetchActiveAgents();
|
||||||
|
const snapshot = JSON.stringify(agents);
|
||||||
|
|
||||||
|
if (snapshot !== previousSnapshot) {
|
||||||
|
previousSnapshot = snapshot;
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: "agents:updated",
|
||||||
|
agents,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
res.write(`event: error\n`);
|
||||||
|
res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await emitSnapshotIfChanged();
|
||||||
|
|
||||||
|
const pollInterval = setInterval(() => {
|
||||||
|
void emitSnapshotIfChanged();
|
||||||
|
}, AGENT_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
if (!isClosed) {
|
||||||
|
res.write(": keepalive\n\n");
|
||||||
|
}
|
||||||
|
}, SSE_HEARTBEAT_MS);
|
||||||
|
|
||||||
|
res.on("close", () => {
|
||||||
|
isClosed = true;
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchActiveAgents(): Promise<OrchestratorAgentDto[]> {
|
||||||
|
const agents = await this.prisma.agent.findMany({
|
||||||
|
where: {
|
||||||
|
status: {
|
||||||
|
not: AgentStatus.TERMINATED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return agents.map((agent) => ({
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
status: agent.status,
|
||||||
|
type: agent.role ?? "agent",
|
||||||
|
createdAt: agent.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/orchestrator/orchestrator.module.ts
Normal file
10
apps/api/src/orchestrator/orchestrator.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { OrchestratorController } from "./orchestrator.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, PrismaModule],
|
||||||
|
controllers: [OrchestratorController],
|
||||||
|
})
|
||||||
|
export class OrchestratorModule {}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Matches,
|
Matches,
|
||||||
|
IsUUID,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +44,10 @@ export class CreateProjectDto {
|
|||||||
})
|
})
|
||||||
color?: string;
|
color?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID("4", { message: "domainId must be a valid UUID" })
|
||||||
|
domainId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject({ message: "metadata must be an object" })
|
@IsObject({ message: "metadata must be an object" })
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Matches,
|
Matches,
|
||||||
|
IsUUID,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,6 +46,10 @@ export class UpdateProjectDto {
|
|||||||
})
|
})
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID("4", { message: "domainId must be a valid UUID" })
|
||||||
|
domainId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject({ message: "metadata must be an object" })
|
@IsObject({ message: "metadata must be an object" })
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export class ProjectsService {
|
|||||||
createProjectDto: CreateProjectDto
|
createProjectDto: CreateProjectDto
|
||||||
): Promise<ProjectWithRelations> {
|
): Promise<ProjectWithRelations> {
|
||||||
const data: Prisma.ProjectCreateInput = {
|
const data: Prisma.ProjectCreateInput = {
|
||||||
|
...(createProjectDto.domainId
|
||||||
|
? { domain: { connect: { id: createProjectDto.domainId } } }
|
||||||
|
: {}),
|
||||||
name: createProjectDto.name,
|
name: createProjectDto.name,
|
||||||
description: createProjectDto.description ?? null,
|
description: createProjectDto.description ?? null,
|
||||||
color: createProjectDto.color ?? null,
|
color: createProjectDto.color ?? null,
|
||||||
@@ -221,6 +224,18 @@ export class ProjectsService {
|
|||||||
if (updateProjectDto.startDate !== undefined) updateData.startDate = updateProjectDto.startDate;
|
if (updateProjectDto.startDate !== undefined) updateData.startDate = updateProjectDto.startDate;
|
||||||
if (updateProjectDto.endDate !== undefined) updateData.endDate = updateProjectDto.endDate;
|
if (updateProjectDto.endDate !== undefined) updateData.endDate = updateProjectDto.endDate;
|
||||||
if (updateProjectDto.color !== undefined) updateData.color = updateProjectDto.color;
|
if (updateProjectDto.color !== undefined) updateData.color = updateProjectDto.color;
|
||||||
|
if (updateProjectDto.domainId !== undefined)
|
||||||
|
updateData.domain = updateProjectDto.domainId
|
||||||
|
? { connect: { id: updateProjectDto.domainId } }
|
||||||
|
: { disconnect: true };
|
||||||
|
if (updateProjectDto.domainId !== undefined)
|
||||||
|
updateData.domain = updateProjectDto.domainId
|
||||||
|
? {
|
||||||
|
connect: {
|
||||||
|
id: updateProjectDto.domainId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { disconnect: true };
|
||||||
if (updateProjectDto.metadata !== undefined) {
|
if (updateProjectDto.metadata !== undefined) {
|
||||||
updateData.metadata = updateProjectDto.metadata as unknown as Prisma.InputJsonValue;
|
updateData.metadata = updateProjectDto.metadata as unknown as Prisma.InputJsonValue;
|
||||||
}
|
}
|
||||||
|
|||||||
62
apps/api/src/seed/agent-templates.seed.ts
Normal file
62
apps/api/src/seed/agent-templates.seed.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const AGENT_TEMPLATES = [
|
||||||
|
{
|
||||||
|
name: "jarvis",
|
||||||
|
displayName: "Jarvis",
|
||||||
|
role: "orchestrator",
|
||||||
|
personality: `# Jarvis - Orchestrator Agent\n\nYou are Jarvis, the orchestrator and COO. You plan, delegate, and coordinate. You never write code directly — you spawn workers. You are direct, capable, and proactive. Your job is to get things done without hand-holding.\n\n## Core Traits\n- Direct and concise\n- Resourceful — figure it out before asking\n- Proactive — find problems to solve\n- Delegator — workers execute, you orchestrate`,
|
||||||
|
primaryModel: "opus",
|
||||||
|
fallbackModels: ["sonnet"],
|
||||||
|
toolPermissions: ["read", "write", "exec", "browser", "web_search", "memory_search"],
|
||||||
|
discordChannel: "jarvis",
|
||||||
|
isActive: true,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "builder",
|
||||||
|
displayName: "Builder",
|
||||||
|
role: "coding",
|
||||||
|
personality: `# Builder - Coding Agent\n\nYou are Builder, the coding agent. You implement features, fix bugs, and write tests. You work in worktrees, follow the E2E delivery protocol, and never skip quality gates. You are methodical and thorough.\n\n## Core Traits\n- Works in git worktrees (never touches main directly)\n- Runs lint + typecheck + tests before every commit\n- Follows the Mosaic E2E delivery framework\n- Never marks a task done until CI is green`,
|
||||||
|
primaryModel: "codex",
|
||||||
|
fallbackModels: ["sonnet", "haiku"],
|
||||||
|
toolPermissions: ["read", "write", "exec"],
|
||||||
|
discordChannel: "builder",
|
||||||
|
isActive: true,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "medic",
|
||||||
|
displayName: "Medic",
|
||||||
|
role: "monitoring",
|
||||||
|
personality: `# Medic - Health Monitoring Agent\n\nYou are Medic, the health monitoring agent. You watch services, check deployments, alert on anomalies, and verify system health. You are vigilant, calm, and proactive.\n\n## Core Traits\n- Monitors service health proactively\n- Alerts clearly and concisely\n- Tracks uptime and deployment status\n- Never panics — diagnoses methodically`,
|
||||||
|
primaryModel: "haiku",
|
||||||
|
fallbackModels: ["sonnet"],
|
||||||
|
toolPermissions: ["read", "exec"],
|
||||||
|
discordChannel: "medic-alerts",
|
||||||
|
isActive: true,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seedAgentTemplates(prisma: PrismaClient): Promise<void> {
|
||||||
|
for (const template of AGENT_TEMPLATES) {
|
||||||
|
await prisma.agentTemplate.upsert({
|
||||||
|
where: { name: template.name },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: template.name,
|
||||||
|
displayName: template.displayName,
|
||||||
|
role: template.role,
|
||||||
|
personality: template.personality,
|
||||||
|
primaryModel: template.primaryModel,
|
||||||
|
fallbackModels: template.fallbackModels,
|
||||||
|
toolPermissions: template.toolPermissions,
|
||||||
|
discordChannel: template.discordChannel,
|
||||||
|
isActive: template.isActive,
|
||||||
|
isDefault: template.isDefault,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log("✅ Agent templates seeded:", AGENT_TEMPLATES.map((t) => t.name).join(", "));
|
||||||
|
}
|
||||||
@@ -66,7 +66,9 @@ interface StartTranscriptionPayload {
|
|||||||
@WSGateway({
|
@WSGateway({
|
||||||
namespace: "/speech",
|
namespace: "/speech",
|
||||||
cors: {
|
cors: {
|
||||||
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
origin: (process.env.TRUSTED_ORIGINS ?? process.env.WEB_URL ?? "http://localhost:3000")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim()),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ interface AuthenticatedSocket extends Socket {
|
|||||||
@WSGateway({
|
@WSGateway({
|
||||||
namespace: "/terminal",
|
namespace: "/terminal",
|
||||||
cors: {
|
cors: {
|
||||||
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
origin: (process.env.TRUSTED_ORIGINS ?? process.env.WEB_URL ?? "http://localhost:3000")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim()),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
43
apps/api/src/user-agent/dto/create-user-agent.dto.ts
Normal file
43
apps/api/src/user-agent/dto/create-user-agent.dto.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { IsString, IsBoolean, IsOptional, IsArray, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateUserAgentDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
templateId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
displayName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
role!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
personality!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
primaryModel?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
fallbackModels?: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
toolPermissions?: string[];
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
discordChannel?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
4
apps/api/src/user-agent/dto/update-user-agent.dto.ts
Normal file
4
apps/api/src/user-agent/dto/update-user-agent.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from "@nestjs/mapped-types";
|
||||||
|
import { CreateUserAgentDto } from "./create-user-agent.dto";
|
||||||
|
|
||||||
|
export class UpdateUserAgentDto extends PartialType(CreateUserAgentDto) {}
|
||||||
70
apps/api/src/user-agent/user-agent.controller.ts
Normal file
70
apps/api/src/user-agent/user-agent.controller.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { UserAgentService } from "./user-agent.service";
|
||||||
|
import { CreateUserAgentDto } from "./dto/create-user-agent.dto";
|
||||||
|
import { UpdateUserAgentDto } from "./dto/update-user-agent.dto";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||||
|
import type { AuthUser } from "@mosaic/shared";
|
||||||
|
|
||||||
|
@Controller("agents")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class UserAgentController {
|
||||||
|
constructor(private readonly userAgentService: UserAgentService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.userAgentService.findAll(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("status")
|
||||||
|
getAllStatuses(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.userAgentService.getAllStatuses(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
findOne(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.userAgentService.findOne(user.id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/status")
|
||||||
|
getStatus(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.userAgentService.getStatus(user.id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() dto: CreateUserAgentDto) {
|
||||||
|
return this.userAgentService.create(user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("from-template/:templateId")
|
||||||
|
createFromTemplate(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param("templateId", ParseUUIDPipe) templateId: string
|
||||||
|
) {
|
||||||
|
return this.userAgentService.createFromTemplate(user.id, templateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
update(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateUserAgentDto
|
||||||
|
) {
|
||||||
|
return this.userAgentService.update(user.id, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
remove(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.userAgentService.remove(user.id, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/user-agent/user-agent.module.ts
Normal file
12
apps/api/src/user-agent/user-agent.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { UserAgentService } from "./user-agent.service";
|
||||||
|
import { UserAgentController } from "./user-agent.controller";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [UserAgentController],
|
||||||
|
providers: [UserAgentService],
|
||||||
|
exports: [UserAgentService],
|
||||||
|
})
|
||||||
|
export class UserAgentModule {}
|
||||||
300
apps/api/src/user-agent/user-agent.service.spec.ts
Normal file
300
apps/api/src/user-agent/user-agent.service.spec.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { UserAgentService } from "./user-agent.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { NotFoundException, ConflictException, ForbiddenException } from "@nestjs/common";
|
||||||
|
|
||||||
|
describe("UserAgentService", () => {
|
||||||
|
let service: UserAgentService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
|
||||||
|
const mockPrismaService = {
|
||||||
|
userAgent: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
agentTemplate: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUserId = "550e8400-e29b-41d4-a716-446655440001";
|
||||||
|
const mockAgentId = "550e8400-e29b-41d4-a716-446655440002";
|
||||||
|
const mockTemplateId = "550e8400-e29b-41d4-a716-446655440003";
|
||||||
|
|
||||||
|
const mockAgent = {
|
||||||
|
id: mockAgentId,
|
||||||
|
userId: mockUserId,
|
||||||
|
templateId: null,
|
||||||
|
name: "jarvis",
|
||||||
|
displayName: "Jarvis",
|
||||||
|
role: "orchestrator",
|
||||||
|
personality: "Capable, direct, proactive.",
|
||||||
|
primaryModel: "opus",
|
||||||
|
fallbackModels: ["sonnet"],
|
||||||
|
toolPermissions: ["all"],
|
||||||
|
discordChannel: "jarvis",
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTemplate = {
|
||||||
|
id: mockTemplateId,
|
||||||
|
name: "builder",
|
||||||
|
displayName: "Builder",
|
||||||
|
role: "coding",
|
||||||
|
personality: "Focused, thorough.",
|
||||||
|
primaryModel: "codex",
|
||||||
|
fallbackModels: ["sonnet"],
|
||||||
|
toolPermissions: ["exec", "read", "write"],
|
||||||
|
discordChannel: "builder",
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
UserAgentService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<UserAgentService>(UserAgentService);
|
||||||
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should return all agents for a user", async () => {
|
||||||
|
mockPrismaService.userAgent.findMany.mockResolvedValue([mockAgent]);
|
||||||
|
|
||||||
|
const result = await service.findAll(mockUserId);
|
||||||
|
|
||||||
|
expect(result).toEqual([mockAgent]);
|
||||||
|
expect(mockPrismaService.userAgent.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: mockUserId },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array if no agents", async () => {
|
||||||
|
mockPrismaService.userAgent.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.findAll(mockUserId);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findOne", () => {
|
||||||
|
it("should return an agent by id", async () => {
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
|
||||||
|
|
||||||
|
const result = await service.findOne(mockUserId, mockAgentId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockAgent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if agent not found", async () => {
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.findOne(mockUserId, mockAgentId)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ForbiddenException if agent belongs to different user", async () => {
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue({
|
||||||
|
...mockAgent,
|
||||||
|
userId: "different-user-id",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.findOne(mockUserId, mockAgentId)).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findByName", () => {
|
||||||
|
it("should return an agent by name", async () => {
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
|
||||||
|
|
||||||
|
const result = await service.findByName(mockUserId, "jarvis");
|
||||||
|
|
||||||
|
expect(result).toEqual(mockAgent);
|
||||||
|
expect(mockPrismaService.userAgent.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { userId_name: { userId: mockUserId, name: "jarvis" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if agent not found", async () => {
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.findByName(mockUserId, "nonexistent")).rejects.toThrow(
|
||||||
|
NotFoundException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should create a new agent", async () => {
|
||||||
|
const createDto = {
|
||||||
|
name: "jarvis",
|
||||||
|
displayName: "Jarvis",
|
||||||
|
role: "orchestrator",
|
||||||
|
personality: "Capable, direct, proactive.",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrismaService.userAgent.create.mockResolvedValue(mockAgent);
|
||||||
|
|
||||||
|
const result = await service.create(mockUserId, createDto);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockAgent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ConflictException if agent name already exists", async () => {
|
||||||
|
const createDto = {
|
||||||
|
name: "jarvis",
|
||||||
|
displayName: "Jarvis",
|
||||||
|
role: "orchestrator",
|
||||||
|
personality: "Capable, direct, proactive.",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
|
||||||
|
|
||||||
|
await expect(service.create(mockUserId, createDto)).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if templateId is invalid", async () => {
|
||||||
|
const createDto = {
|
||||||
|
name: "custom",
|
||||||
|
displayName: "Custom",
|
||||||
|
role: "custom",
|
||||||
|
personality: "Custom agent",
|
||||||
|
templateId: "nonexistent-template",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrismaService.agentTemplate.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.create(mockUserId, createDto)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createFromTemplate", () => {
|
||||||
|
it("should create an agent from a template", async () => {
|
||||||
|
mockPrismaService.agentTemplate.findUnique.mockResolvedValue(mockTemplate);
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrismaService.userAgent.create.mockResolvedValue({
|
||||||
|
...mockAgent,
|
||||||
|
templateId: mockTemplateId,
|
||||||
|
name: mockTemplate.name,
|
||||||
|
displayName: mockTemplate.displayName,
|
||||||
|
role: mockTemplate.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.createFromTemplate(mockUserId, mockTemplateId);
|
||||||
|
|
||||||
|
expect(result.name).toBe(mockTemplate.name);
|
||||||
|
expect(result.displayName).toBe(mockTemplate.displayName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if template not found", async () => {
|
||||||
|
mockPrismaService.agentTemplate.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.createFromTemplate(mockUserId, mockTemplateId)).rejects.toThrow(
|
||||||
|
NotFoundException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ConflictException if agent name already exists", async () => {
|
||||||
|
mockPrismaService.agentTemplate.findUnique.mockResolvedValue(mockTemplate);
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
|
||||||
|
|
||||||
|
await expect(service.createFromTemplate(mockUserId, mockTemplateId)).rejects.toThrow(
|
||||||
|
ConflictException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update", () => {
|
||||||
|
it("should update an agent", async () => {
|
||||||
|
const updateDto = { displayName: "Updated Jarvis" };
|
||||||
|
const updatedAgent = { ...mockAgent, ...updateDto };
|
||||||
|
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
|
||||||
|
mockPrismaService.userAgent.update.mockResolvedValue(updatedAgent);
|
||||||
|
|
||||||
|
const result = await service.update(mockUserId, mockAgentId, updateDto);
|
||||||
|
|
||||||
|
expect(result.displayName).toBe("Updated Jarvis");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ConflictException if new name already exists", async () => {
|
||||||
|
const updateDto = { name: "existing-name" };
|
||||||
|
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
|
||||||
|
// Second call checks for existing name
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue({ ...mockAgent, id: "other-id" });
|
||||||
|
|
||||||
|
await expect(service.update(mockUserId, mockAgentId, updateDto)).rejects.toThrow(
|
||||||
|
ConflictException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove", () => {
|
||||||
|
it("should delete an agent", async () => {
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
|
||||||
|
mockPrismaService.userAgent.delete.mockResolvedValue(mockAgent);
|
||||||
|
|
||||||
|
const result = await service.remove(mockUserId, mockAgentId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockAgent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getStatus", () => {
|
||||||
|
it("should return agent status", async () => {
|
||||||
|
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
|
||||||
|
|
||||||
|
const result = await service.getStatus(mockUserId, mockAgentId);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: mockAgentId,
|
||||||
|
name: "jarvis",
|
||||||
|
displayName: "Jarvis",
|
||||||
|
role: "orchestrator",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllStatuses", () => {
|
||||||
|
it("should return all agent statuses", async () => {
|
||||||
|
mockPrismaService.userAgent.findMany.mockResolvedValue([mockAgent]);
|
||||||
|
|
||||||
|
const result = await service.getAllStatuses(mockUserId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
id: mockAgentId,
|
||||||
|
name: "jarvis",
|
||||||
|
displayName: "Jarvis",
|
||||||
|
role: "orchestrator",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
153
apps/api/src/user-agent/user-agent.service.ts
Normal file
153
apps/api/src/user-agent/user-agent.service.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CreateUserAgentDto } from "./dto/create-user-agent.dto";
|
||||||
|
import { UpdateUserAgentDto } from "./dto/update-user-agent.dto";
|
||||||
|
|
||||||
|
export interface AgentStatusResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
isActive: boolean;
|
||||||
|
containerStatus?: "running" | "stopped" | "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserAgentService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(userId: string) {
|
||||||
|
return this.prisma.userAgent.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(userId: string, id: string) {
|
||||||
|
const agent = await this.prisma.userAgent.findUnique({ where: { id } });
|
||||||
|
if (!agent) throw new NotFoundException(`UserAgent ${id} not found`);
|
||||||
|
if (agent.userId !== userId) throw new ForbiddenException("Access denied to this agent");
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByName(userId: string, name: string) {
|
||||||
|
const agent = await this.prisma.userAgent.findUnique({
|
||||||
|
where: { userId_name: { userId, name } },
|
||||||
|
});
|
||||||
|
if (!agent) throw new NotFoundException(`UserAgent "${name}" not found for user`);
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(userId: string, dto: CreateUserAgentDto) {
|
||||||
|
// Check for unique name within user scope
|
||||||
|
const existing = await this.prisma.userAgent.findUnique({
|
||||||
|
where: { userId_name: { userId, name: dto.name } },
|
||||||
|
});
|
||||||
|
if (existing)
|
||||||
|
throw new ConflictException(`UserAgent "${dto.name}" already exists for this user`);
|
||||||
|
|
||||||
|
// If templateId provided, verify it exists
|
||||||
|
if (dto.templateId) {
|
||||||
|
const template = await this.prisma.agentTemplate.findUnique({
|
||||||
|
where: { id: dto.templateId },
|
||||||
|
});
|
||||||
|
if (!template) throw new NotFoundException(`AgentTemplate ${dto.templateId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.userAgent.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
templateId: dto.templateId ?? null,
|
||||||
|
name: dto.name,
|
||||||
|
displayName: dto.displayName,
|
||||||
|
role: dto.role,
|
||||||
|
personality: dto.personality,
|
||||||
|
primaryModel: dto.primaryModel ?? null,
|
||||||
|
fallbackModels: dto.fallbackModels ?? ([] as string[]),
|
||||||
|
toolPermissions: dto.toolPermissions ?? ([] as string[]),
|
||||||
|
discordChannel: dto.discordChannel ?? null,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFromTemplate(userId: string, templateId: string) {
|
||||||
|
const template = await this.prisma.agentTemplate.findUnique({
|
||||||
|
where: { id: templateId },
|
||||||
|
});
|
||||||
|
if (!template) throw new NotFoundException(`AgentTemplate ${templateId} not found`);
|
||||||
|
|
||||||
|
// Check for unique name within user scope
|
||||||
|
const existing = await this.prisma.userAgent.findUnique({
|
||||||
|
where: { userId_name: { userId, name: template.name } },
|
||||||
|
});
|
||||||
|
if (existing)
|
||||||
|
throw new ConflictException(`UserAgent "${template.name}" already exists for this user`);
|
||||||
|
|
||||||
|
return this.prisma.userAgent.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
templateId: template.id,
|
||||||
|
name: template.name,
|
||||||
|
displayName: template.displayName,
|
||||||
|
role: template.role,
|
||||||
|
personality: template.personality,
|
||||||
|
primaryModel: template.primaryModel,
|
||||||
|
fallbackModels: template.fallbackModels as string[],
|
||||||
|
toolPermissions: template.toolPermissions as string[],
|
||||||
|
discordChannel: template.discordChannel,
|
||||||
|
isActive: template.isActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(userId: string, id: string, dto: UpdateUserAgentDto) {
|
||||||
|
const agent = await this.findOne(userId, id);
|
||||||
|
|
||||||
|
// If name is being changed, check for uniqueness
|
||||||
|
if (dto.name && dto.name !== agent.name) {
|
||||||
|
const existing = await this.prisma.userAgent.findUnique({
|
||||||
|
where: { userId_name: { userId, name: dto.name } },
|
||||||
|
});
|
||||||
|
if (existing)
|
||||||
|
throw new ConflictException(`UserAgent "${dto.name}" already exists for this user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.userAgent.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(userId: string, id: string) {
|
||||||
|
await this.findOne(userId, id);
|
||||||
|
return this.prisma.userAgent.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus(userId: string, id: string): Promise<AgentStatusResponse> {
|
||||||
|
const agent = await this.findOne(userId, id);
|
||||||
|
return {
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
displayName: agent.displayName,
|
||||||
|
role: agent.role,
|
||||||
|
isActive: agent.isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllStatuses(userId: string): Promise<AgentStatusResponse[]> {
|
||||||
|
const agents = await this.findAll(userId);
|
||||||
|
return agents.map((agent) => ({
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
displayName: agent.displayName,
|
||||||
|
role: agent.role,
|
||||||
|
isActive: agent.isActive,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/api/src/widgets/widgets.controller.throttler.spec.ts
Normal file
31
apps/api/src/widgets/widgets.controller.throttler.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { WidgetsController } from "./widgets.controller";
|
||||||
|
|
||||||
|
const THROTTLER_SKIP_DEFAULT_KEY = "THROTTLER:SKIPdefault";
|
||||||
|
|
||||||
|
describe("WidgetsController throttler metadata", () => {
|
||||||
|
it("marks widget data polling endpoints to skip throttling", () => {
|
||||||
|
const pollingHandlers = [
|
||||||
|
WidgetsController.prototype.getStatCardData,
|
||||||
|
WidgetsController.prototype.getChartData,
|
||||||
|
WidgetsController.prototype.getListData,
|
||||||
|
WidgetsController.prototype.getCalendarPreviewData,
|
||||||
|
WidgetsController.prototype.getActiveProjectsData,
|
||||||
|
WidgetsController.prototype.getAgentChainsData,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const handler of pollingHandlers) {
|
||||||
|
expect(Reflect.getMetadata(THROTTLER_SKIP_DEFAULT_KEY, handler)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not skip throttling for non-polling widget routes", () => {
|
||||||
|
expect(
|
||||||
|
Reflect.getMetadata(THROTTLER_SKIP_DEFAULT_KEY, WidgetsController.prototype.findAll)
|
||||||
|
).toBe(undefined);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Reflect.getMetadata(THROTTLER_SKIP_DEFAULT_KEY, WidgetsController.prototype.findByName)
|
||||||
|
).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
|
import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
|
||||||
|
import { SkipThrottle as SkipThrottler } from "@nestjs/throttler";
|
||||||
import { WidgetsService } from "./widgets.service";
|
import { WidgetsService } from "./widgets.service";
|
||||||
import { WidgetDataService } from "./widget-data.service";
|
import { WidgetDataService } from "./widget-data.service";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||||
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
|
import { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
|
||||||
import type { RequestWithWorkspace } from "../common/types/user.types";
|
import type { RequestWithWorkspace } from "../common/types/user.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +44,7 @@ export class WidgetsController {
|
|||||||
* Get stat card widget data
|
* Get stat card widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/stat-card")
|
@Post("data/stat-card")
|
||||||
|
@SkipThrottler()
|
||||||
@UseGuards(WorkspaceGuard)
|
@UseGuards(WorkspaceGuard)
|
||||||
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
|
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
|
||||||
return this.widgetDataService.getStatCardData(req.workspace.id, query);
|
return this.widgetDataService.getStatCardData(req.workspace.id, query);
|
||||||
@@ -53,6 +55,7 @@ export class WidgetsController {
|
|||||||
* Get chart widget data
|
* Get chart widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/chart")
|
@Post("data/chart")
|
||||||
|
@SkipThrottler()
|
||||||
@UseGuards(WorkspaceGuard)
|
@UseGuards(WorkspaceGuard)
|
||||||
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
|
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
|
||||||
return this.widgetDataService.getChartData(req.workspace.id, query);
|
return this.widgetDataService.getChartData(req.workspace.id, query);
|
||||||
@@ -63,6 +66,7 @@ export class WidgetsController {
|
|||||||
* Get list widget data
|
* Get list widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/list")
|
@Post("data/list")
|
||||||
|
@SkipThrottler()
|
||||||
@UseGuards(WorkspaceGuard)
|
@UseGuards(WorkspaceGuard)
|
||||||
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
|
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
|
||||||
return this.widgetDataService.getListData(req.workspace.id, query);
|
return this.widgetDataService.getListData(req.workspace.id, query);
|
||||||
@@ -73,6 +77,7 @@ export class WidgetsController {
|
|||||||
* Get calendar preview widget data
|
* Get calendar preview widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/calendar-preview")
|
@Post("data/calendar-preview")
|
||||||
|
@SkipThrottler()
|
||||||
@UseGuards(WorkspaceGuard)
|
@UseGuards(WorkspaceGuard)
|
||||||
async getCalendarPreviewData(
|
async getCalendarPreviewData(
|
||||||
@Request() req: RequestWithWorkspace,
|
@Request() req: RequestWithWorkspace,
|
||||||
@@ -86,6 +91,7 @@ export class WidgetsController {
|
|||||||
* Get active projects widget data
|
* Get active projects widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/active-projects")
|
@Post("data/active-projects")
|
||||||
|
@SkipThrottler()
|
||||||
@UseGuards(WorkspaceGuard)
|
@UseGuards(WorkspaceGuard)
|
||||||
async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
|
async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
|
||||||
return this.widgetDataService.getActiveProjectsData(req.workspace.id);
|
return this.widgetDataService.getActiveProjectsData(req.workspace.id);
|
||||||
@@ -96,6 +102,7 @@ export class WidgetsController {
|
|||||||
* Get agent chains widget data (active agent sessions)
|
* Get agent chains widget data (active agent sessions)
|
||||||
*/
|
*/
|
||||||
@Post("data/agent-chains")
|
@Post("data/agent-chains")
|
||||||
|
@SkipThrottler()
|
||||||
@UseGuards(WorkspaceGuard)
|
@UseGuards(WorkspaceGuard)
|
||||||
async getAgentChainsData(@Request() req: RequestWithWorkspace) {
|
async getAgentChainsData(@Request() req: RequestWithWorkspace) {
|
||||||
return this.widgetDataService.getAgentChainsData(req.workspace.id);
|
return this.widgetDataService.getAgentChainsData(req.workspace.id);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
|||||||
import { Permission, RequirePermission } from "../common/decorators";
|
import { Permission, RequirePermission } from "../common/decorators";
|
||||||
import type { WorkspaceMember } from "@prisma/client";
|
import type { WorkspaceMember } from "@prisma/client";
|
||||||
import type { AuthenticatedUser } from "../common/types/user.types";
|
import type { AuthenticatedUser } from "../common/types/user.types";
|
||||||
import type { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto";
|
import { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-scoped workspace operations.
|
* User-scoped workspace operations.
|
||||||
@@ -29,6 +29,25 @@ export class WorkspacesController {
|
|||||||
return this.workspacesService.getUserWorkspaces(user.id);
|
return this.workspacesService.getUserWorkspaces(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/workspaces/:workspaceId/stats
|
||||||
|
* Returns member, project, and domain counts for a workspace.
|
||||||
|
*/
|
||||||
|
@Get(":workspaceId/stats")
|
||||||
|
async getStats(@Param("workspaceId") workspaceId: string) {
|
||||||
|
return this.workspacesService.getStats(workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/workspaces/:workspaceId/members
|
||||||
|
* Returns the list of members for a workspace.
|
||||||
|
*/
|
||||||
|
@Get(":workspaceId/members")
|
||||||
|
@UseGuards(WorkspaceGuard)
|
||||||
|
async getMembers(@Param("workspaceId") workspaceId: string) {
|
||||||
|
return this.workspacesService.getMembers(workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/workspaces/:workspaceId/members
|
* POST /api/workspaces/:workspaceId/members
|
||||||
* Add a member to a workspace with the specified role.
|
* Add a member to a workspace with the specified role.
|
||||||
|
|||||||
@@ -321,6 +321,18 @@ export class WorkspacesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get members of a workspace.
|
||||||
|
*/
|
||||||
|
async getMembers(workspaceId: string) {
|
||||||
|
return this.prisma.workspaceMember.findMany({
|
||||||
|
where: { workspaceId },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, createdAt: true } },
|
||||||
|
},
|
||||||
|
orderBy: { joinedAt: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
private assertCanAssignRole(
|
private assertCanAssignRole(
|
||||||
actorRole: WorkspaceMemberRole,
|
actorRole: WorkspaceMemberRole,
|
||||||
requestedRole: WorkspaceMemberRole
|
requestedRole: WorkspaceMemberRole
|
||||||
@@ -342,4 +354,15 @@ export class WorkspacesService {
|
|||||||
private isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
|
private isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
|
||||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
|
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getStats(
|
||||||
|
workspaceId: string
|
||||||
|
): Promise<{ memberCount: number; projectCount: number; domainCount: number }> {
|
||||||
|
const [memberCount, projectCount, domainCount] = await Promise.all([
|
||||||
|
this.prisma.workspaceMember.count({ where: { workspaceId } }),
|
||||||
|
this.prisma.project.count({ where: { workspaceId } }),
|
||||||
|
this.prisma.domain.count({ where: { workspaceId } }),
|
||||||
|
]);
|
||||||
|
return { memberCount, projectCount, domainCount };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -601,9 +601,21 @@ class TestCoordinatorIntegration:
|
|||||||
coordinator = Coordinator(queue_manager=queue_manager, poll_interval=0.02)
|
coordinator = Coordinator(queue_manager=queue_manager, poll_interval=0.02)
|
||||||
|
|
||||||
task = asyncio.create_task(coordinator.start())
|
task = asyncio.create_task(coordinator.start())
|
||||||
await asyncio.sleep(0.5) # Allow time for processing
|
|
||||||
await coordinator.stop()
|
|
||||||
|
|
||||||
|
# Poll for completion with timeout instead of fixed sleep
|
||||||
|
deadline = asyncio.get_event_loop().time() + 5.0 # 5 second timeout
|
||||||
|
while asyncio.get_event_loop().time() < deadline:
|
||||||
|
all_completed = True
|
||||||
|
for i in range(157, 162):
|
||||||
|
item = queue_manager.get_item(i)
|
||||||
|
if item is None or item.status != QueueItemStatus.COMPLETED:
|
||||||
|
all_completed = False
|
||||||
|
break
|
||||||
|
if all_completed:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
await coordinator.stop()
|
||||||
task.cancel()
|
task.cancel()
|
||||||
try:
|
try:
|
||||||
await task
|
await task
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Base image for all stages
|
# Base image for all stages
|
||||||
# Uses Debian slim (glibc) instead of Alpine (musl) for native addon compatibility.
|
# Uses Debian slim (glibc) instead of Alpine (musl) for native addon compatibility.
|
||||||
FROM node:24-slim AS base
|
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
|
||||||
|
|
||||||
# Install pnpm globally
|
# Install pnpm globally
|
||||||
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
||||||
@@ -22,6 +22,9 @@ COPY packages/shared/package.json ./packages/shared/
|
|||||||
COPY packages/config/package.json ./packages/config/
|
COPY packages/config/package.json ./packages/config/
|
||||||
COPY apps/orchestrator/package.json ./apps/orchestrator/
|
COPY apps/orchestrator/package.json ./apps/orchestrator/
|
||||||
|
|
||||||
|
# Copy npm configuration for native binary architecture hints
|
||||||
|
COPY .npmrc ./
|
||||||
|
|
||||||
# Install ALL dependencies (not just production)
|
# Install ALL dependencies (not just production)
|
||||||
# No cache mount — Kaniko builds are ephemeral in CI
|
# No cache mount — Kaniko builds are ephemeral in CI
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
@@ -54,7 +57,7 @@ RUN find ./apps/orchestrator/dist \( -name '*.spec.js' -o -name '*.spec.js.map'
|
|||||||
# ======================
|
# ======================
|
||||||
# Production stage
|
# Production stage
|
||||||
# ======================
|
# ======================
|
||||||
FROM node:24-slim AS production
|
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
|
||||||
|
|
||||||
# Add metadata labels
|
# Add metadata labels
|
||||||
LABEL maintainer="mosaic-team@mosaicstack.dev"
|
LABEL maintainer="mosaic-team@mosaicstack.dev"
|
||||||
@@ -65,13 +68,12 @@ LABEL org.opencontainers.image.vendor="Mosaic Stack"
|
|||||||
LABEL org.opencontainers.image.title="Mosaic Orchestrator"
|
LABEL org.opencontainers.image.title="Mosaic Orchestrator"
|
||||||
LABEL org.opencontainers.image.description="Agent orchestration service for Mosaic Stack"
|
LABEL org.opencontainers.image.description="Agent orchestration service for Mosaic Stack"
|
||||||
|
|
||||||
# Install dumb-init for proper signal handling (static binary from GitHub,
|
# dumb-init, ca-certificates pre-installed in base image
|
||||||
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors)
|
|
||||||
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
|
|
||||||
|
|
||||||
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||||
|
# - Remove npm/npx to reduce image size (not used in production)
|
||||||
|
# - Create non-root user
|
||||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||||
&& chmod 755 /usr/local/bin/dumb-init \
|
|
||||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Base image for all stages
|
# Base image for all stages
|
||||||
# Uses Debian slim (glibc) for consistency with API/orchestrator and to prevent
|
# Uses Debian slim (glibc) for consistency with API/orchestrator and to prevent
|
||||||
# future native addon compatibility issues with Alpine's musl libc.
|
# future native addon compatibility issues with Alpine's musl libc.
|
||||||
FROM node:24-slim AS base
|
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
|
||||||
|
|
||||||
# Install pnpm globally
|
# Install pnpm globally
|
||||||
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
||||||
@@ -24,6 +24,9 @@ COPY packages/ui/package.json ./packages/ui/
|
|||||||
COPY packages/config/package.json ./packages/config/
|
COPY packages/config/package.json ./packages/config/
|
||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
|
||||||
|
# Copy npm configuration for native binary architecture hints
|
||||||
|
COPY .npmrc ./
|
||||||
|
|
||||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -38,6 +41,9 @@ COPY packages/ui/package.json ./packages/ui/
|
|||||||
COPY packages/config/package.json ./packages/config/
|
COPY packages/config/package.json ./packages/config/
|
||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
|
||||||
|
# Copy npm configuration for native binary architecture hints
|
||||||
|
COPY .npmrc ./
|
||||||
|
|
||||||
# Install production dependencies only
|
# Install production dependencies only
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
@@ -87,15 +93,14 @@ RUN mkdir -p ./apps/web/public
|
|||||||
# ======================
|
# ======================
|
||||||
# Production stage
|
# Production stage
|
||||||
# ======================
|
# ======================
|
||||||
FROM node:24-slim AS production
|
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
|
||||||
|
|
||||||
# Install dumb-init for proper signal handling (static binary from GitHub,
|
# dumb-init, ca-certificates pre-installed in base image
|
||||||
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors)
|
|
||||||
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
|
|
||||||
|
|
||||||
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||||
|
# - Remove npm/npx to reduce image size (not used in production)
|
||||||
|
# - Create non-root user
|
||||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||||
&& chmod 755 /usr/local/bin/dumb-init \
|
|
||||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nextjs
|
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nextjs
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared";
|
||||||
import { EntryStatus, Visibility } from "@mosaic/shared";
|
import { EntryStatus, Visibility } from "@mosaic/shared";
|
||||||
|
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { fetchEntries, createEntry, deleteEntry } from "@/lib/api/knowledge";
|
import { fetchEntries, createEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge";
|
||||||
import type { EntriesResponse, CreateEntryData, EntryFilters } from "@/lib/api/knowledge";
|
import type { EntriesResponse, CreateEntryData, EntryFilters } from "@/lib/api/knowledge";
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
@@ -421,6 +421,26 @@ function CreateEntryDialog({
|
|||||||
const [visibility, setVisibility] = useState<Visibility>(Visibility.PRIVATE);
|
const [visibility, setVisibility] = useState<Visibility>(Visibility.PRIVATE);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Tag state
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [availableTags, setAvailableTags] = useState<KnowledgeTag[]>([]);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const tagInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Load available tags when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
fetchTags()
|
||||||
|
.then((tags) => {
|
||||||
|
setAvailableTags(tags);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("Failed to load tags:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
function resetForm(): void {
|
function resetForm(): void {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setContent("");
|
setContent("");
|
||||||
@@ -428,6 +448,9 @@ function CreateEntryDialog({
|
|||||||
setStatus(EntryStatus.DRAFT);
|
setStatus(EntryStatus.DRAFT);
|
||||||
setVisibility(Visibility.PRIVATE);
|
setVisibility(Visibility.PRIVATE);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
setSelectedTags([]);
|
||||||
|
setTagInput("");
|
||||||
|
setShowSuggestions(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
||||||
@@ -452,6 +475,7 @@ function CreateEntryDialog({
|
|||||||
content: trimmedContent,
|
content: trimmedContent,
|
||||||
status,
|
status,
|
||||||
visibility,
|
visibility,
|
||||||
|
tags: selectedTags,
|
||||||
};
|
};
|
||||||
const trimmedSummary = summary.trim();
|
const trimmedSummary = summary.trim();
|
||||||
if (trimmedSummary) {
|
if (trimmedSummary) {
|
||||||
@@ -610,6 +634,212 @@ function CreateEntryDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label
|
||||||
|
htmlFor="entry-tags"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tags
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
minHeight: 38,
|
||||||
|
padding: "6px 8px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 4,
|
||||||
|
alignItems: "center",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Selected tag chips */}
|
||||||
|
{selectedTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
padding: "2px 8px",
|
||||||
|
background: "var(--surface-2)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-sm)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTags((prev) => prev.filter((t) => t !== tag));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--muted)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{/* Tag text input */}
|
||||||
|
<input
|
||||||
|
ref={tagInputRef}
|
||||||
|
id="entry-tags"
|
||||||
|
type="text"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTagInput(e.target.value);
|
||||||
|
setShowSuggestions(e.target.value.length > 0);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === ",") {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = tagInput.trim();
|
||||||
|
if (trimmed && !selectedTags.includes(trimmed)) {
|
||||||
|
setSelectedTags((prev) => [...prev, trimmed]);
|
||||||
|
setTagInput("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === "Backspace" && tagInput === "" && selectedTags.length > 0) {
|
||||||
|
setSelectedTags((prev) => prev.slice(0, -1));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// Delay to allow click on suggestion
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}, 150);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (tagInput.length > 0) setShowSuggestions(true);
|
||||||
|
}}
|
||||||
|
placeholder={selectedTags.length === 0 ? "Add tags..." : ""}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 80,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
outline: "none",
|
||||||
|
padding: "2px 0",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Autocomplete suggestions */}
|
||||||
|
{showSuggestions && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "100%",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
marginTop: 4,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||||
|
maxHeight: 150,
|
||||||
|
overflowY: "auto",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{availableTags
|
||||||
|
.filter(
|
||||||
|
(t) =>
|
||||||
|
t.name.toLowerCase().includes(tagInput.toLowerCase()) &&
|
||||||
|
!selectedTags.includes(t.name)
|
||||||
|
)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedTags.includes(tag.name)) {
|
||||||
|
setSelectedTags((prev) => [...prev, tag.name]);
|
||||||
|
}
|
||||||
|
setTagInput("");
|
||||||
|
setShowSuggestions(false);
|
||||||
|
tagInputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
textAlign: "left",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--surface-2)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{availableTags.filter(
|
||||||
|
(t) =>
|
||||||
|
t.name.toLowerCase().includes(tagInput.toLowerCase()) &&
|
||||||
|
!selectedTags.includes(t.name)
|
||||||
|
).length === 0 &&
|
||||||
|
tagInput.trim() &&
|
||||||
|
!selectedTags.includes(tagInput.trim()) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const trimmed = tagInput.trim();
|
||||||
|
if (trimmed && !selectedTags.includes(trimmed)) {
|
||||||
|
setSelectedTags((prev) => [...prev, trimmed]);
|
||||||
|
}
|
||||||
|
setTagInput("");
|
||||||
|
setShowSuggestions(false);
|
||||||
|
tagInputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
textAlign: "left",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontStyle: "italic",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create "{tagInput.trim()}"
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Status + Visibility row */}
|
{/* Status + Visibility row */}
|
||||||
<div style={{ display: "flex", gap: 16, marginBottom: 16 }}>
|
<div style={{ display: "flex", gap: 16, marginBottom: 16 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
|
|||||||
188
apps/web/src/app/(authenticated)/kanban/page.test.tsx
Normal file
188
apps/web/src/app/(authenticated)/kanban/page.test.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { Task } from "@mosaic/shared";
|
||||||
|
import { TaskPriority, TaskStatus } from "@mosaic/shared";
|
||||||
|
import KanbanPage from "./page";
|
||||||
|
|
||||||
|
const mockReplace = vi.fn();
|
||||||
|
let mockSearchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: (): { replace: typeof mockReplace } => ({ replace: mockReplace }),
|
||||||
|
useSearchParams: (): URLSearchParams => mockSearchParams,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@hello-pangea/dnd", () => ({
|
||||||
|
DragDropContext: ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
||||||
|
<div data-testid="mock-dnd-context">{children}</div>
|
||||||
|
),
|
||||||
|
Droppable: ({
|
||||||
|
children,
|
||||||
|
droppableId,
|
||||||
|
}: {
|
||||||
|
children: (provided: {
|
||||||
|
innerRef: (el: HTMLElement | null) => void;
|
||||||
|
droppableProps: Record<string, never>;
|
||||||
|
placeholder: React.ReactNode;
|
||||||
|
}) => React.ReactNode;
|
||||||
|
droppableId: string;
|
||||||
|
}): React.JSX.Element => (
|
||||||
|
<div data-testid={`mock-droppable-${droppableId}`}>
|
||||||
|
{children({
|
||||||
|
innerRef: () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
|
droppableProps: {},
|
||||||
|
placeholder: null,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
Draggable: ({
|
||||||
|
children,
|
||||||
|
draggableId,
|
||||||
|
}: {
|
||||||
|
children: (
|
||||||
|
provided: {
|
||||||
|
innerRef: (el: HTMLElement | null) => void;
|
||||||
|
draggableProps: { style: Record<string, string> };
|
||||||
|
dragHandleProps: Record<string, string>;
|
||||||
|
},
|
||||||
|
snapshot: { isDragging: boolean }
|
||||||
|
) => React.ReactNode;
|
||||||
|
draggableId: string;
|
||||||
|
index: number;
|
||||||
|
}): React.JSX.Element => (
|
||||||
|
<div data-testid={`mock-draggable-${draggableId}`}>
|
||||||
|
{children(
|
||||||
|
{
|
||||||
|
innerRef: () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
|
draggableProps: { style: {} },
|
||||||
|
dragHandleProps: {},
|
||||||
|
},
|
||||||
|
{ isDragging: false }
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/MosaicSpinner", () => ({
|
||||||
|
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
|
||||||
|
<div data-testid="mosaic-spinner">{label ?? "Loading..."}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseWorkspaceId = vi.fn<() => string | null>();
|
||||||
|
vi.mock("@/lib/hooks", () => ({
|
||||||
|
useWorkspaceId: (): string | null => mockUseWorkspaceId(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetchTasks = vi.fn<() => Promise<Task[]>>();
|
||||||
|
const mockUpdateTask = vi.fn<() => Promise<unknown>>();
|
||||||
|
const mockCreateTask = vi.fn<() => Promise<Task>>();
|
||||||
|
vi.mock("@/lib/api/tasks", () => ({
|
||||||
|
fetchTasks: (...args: unknown[]): Promise<Task[]> => mockFetchTasks(...(args as [])),
|
||||||
|
updateTask: (...args: unknown[]): Promise<unknown> => mockUpdateTask(...(args as [])),
|
||||||
|
createTask: (...args: unknown[]): Promise<Task> => mockCreateTask(...(args as [])),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetchProjects = vi.fn<() => Promise<unknown[]>>();
|
||||||
|
vi.mock("@/lib/api/projects", () => ({
|
||||||
|
fetchProjects: (...args: unknown[]): Promise<unknown[]> => mockFetchProjects(...(args as [])),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createdTask: Task = {
|
||||||
|
id: "task-new-1",
|
||||||
|
title: "Ship Kanban add task flow",
|
||||||
|
description: null,
|
||||||
|
status: TaskStatus.NOT_STARTED,
|
||||||
|
priority: TaskPriority.MEDIUM,
|
||||||
|
dueDate: null,
|
||||||
|
creatorId: "user-1",
|
||||||
|
assigneeId: null,
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
projectId: "project-42",
|
||||||
|
parentId: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
metadata: {},
|
||||||
|
completedAt: null,
|
||||||
|
createdAt: new Date("2026-03-01"),
|
||||||
|
updatedAt: new Date("2026-03-01"),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("KanbanPage add task flow", (): void => {
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockSearchParams = new URLSearchParams("project=project-42");
|
||||||
|
mockUseWorkspaceId.mockReturnValue("ws-1");
|
||||||
|
mockFetchTasks.mockResolvedValue([]);
|
||||||
|
mockFetchProjects.mockResolvedValue([]);
|
||||||
|
mockCreateTask.mockResolvedValue(createdTask);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens add-task form in a column and creates a task via API", async (): Promise<void> => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<KanbanPage />);
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("Kanban Board")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the "+ Add task" button in the To Do column
|
||||||
|
const addTaskButtons = screen.getAllByRole("button", { name: /\+ Add task/i });
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
await user.click(addTaskButtons[0]!); // First column is "To Do"
|
||||||
|
|
||||||
|
// Type in the title input
|
||||||
|
const titleInput = screen.getByPlaceholderText("Task title...");
|
||||||
|
await user.type(titleInput, createdTask.title);
|
||||||
|
|
||||||
|
// Click the Add button
|
||||||
|
await user.click(screen.getByRole("button", { name: /✓ Add/i }));
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(mockCreateTask).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: createdTask.title,
|
||||||
|
status: TaskStatus.NOT_STARTED,
|
||||||
|
projectId: "project-42",
|
||||||
|
}),
|
||||||
|
"ws-1"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels add-task form when pressing Escape", async (): Promise<void> => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<KanbanPage />);
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("Kanban Board")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the "+ Add task" button
|
||||||
|
const addTaskButtons = screen.getAllByRole("button", { name: /\+ Add task/i });
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
await user.click(addTaskButtons[0]!);
|
||||||
|
|
||||||
|
// Type in the title input
|
||||||
|
const titleInput = screen.getByPlaceholderText("Task title...");
|
||||||
|
await user.type(titleInput, "Test task");
|
||||||
|
|
||||||
|
// Press Escape to cancel
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
|
// Form should be closed, back to "+ Add task" button
|
||||||
|
await waitFor((): void => {
|
||||||
|
const buttons = screen.getAllByRole("button", { name: /\+ Add task/i });
|
||||||
|
expect(buttons.length).toBe(5); // One per column
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not have called createTask
|
||||||
|
expect(mockCreateTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
} from "@hello-pangea/dnd";
|
} from "@hello-pangea/dnd";
|
||||||
|
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
import { fetchTasks, updateTask, type TaskFilters } from "@/lib/api/tasks";
|
import { fetchTasks, updateTask, createTask, type TaskFilters } from "@/lib/api/tasks";
|
||||||
import { fetchProjects, type Project } from "@/lib/api/projects";
|
import { fetchProjects, type Project } from "@/lib/api/projects";
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
import type { Task } from "@mosaic/shared";
|
import type { Task } from "@mosaic/shared";
|
||||||
@@ -184,9 +184,48 @@ function TaskCard({ task, provided, snapshot, columnAccent }: TaskCardProps): Re
|
|||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
config: ColumnConfig;
|
config: ColumnConfig;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
|
onAddTask: (status: TaskStatus, title: string, projectId?: string) => Promise<void>;
|
||||||
|
projectId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanColumn({ config, tasks }: KanbanColumnProps): ReactElement {
|
function KanbanColumn({ config, tasks, onAddTask, projectId }: KanbanColumnProps): ReactElement {
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Focus input when form is shown
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAddForm && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [showAddForm]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.SyntheticEvent): Promise<void> => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!inputValue.trim() || isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onAddTask(config.status, inputValue.trim(), projectId);
|
||||||
|
setInputValue("");
|
||||||
|
setShowAddForm(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[KanbanColumn] Failed to add task:", err);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setShowAddForm(false);
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -268,6 +307,128 @@ function KanbanColumn({ config, tasks }: KanbanColumnProps): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Droppable>
|
</Droppable>
|
||||||
|
|
||||||
|
{/* Add Task Form */}
|
||||||
|
{!showAddForm ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAddForm(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "10px 16px",
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
transition: "color 0.15s",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--text)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add task
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ padding: "8px 12px 12px", borderTop: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Task title..."
|
||||||
|
disabled={isSubmitting}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: `1px solid ${inputValue ? "var(--primary)" : "var(--border)"}`,
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
outline: "none",
|
||||||
|
opacity: isSubmitting ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !inputValue.trim()}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: "1px solid var(--primary)",
|
||||||
|
background: "var(--primary)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: isSubmitting || !inputValue.trim() ? "not-allowed" : "pointer",
|
||||||
|
opacity: isSubmitting || !inputValue.trim() ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓ Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAddForm(false);
|
||||||
|
setInputValue("");
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
cursor: isSubmitting ? "not-allowed" : "pointer",
|
||||||
|
opacity: isSubmitting ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6, fontSize: "0.75rem", color: "var(--muted)" }}>
|
||||||
|
Press{" "}
|
||||||
|
<kbd
|
||||||
|
style={{
|
||||||
|
padding: "2px 4px",
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
borderRadius: "2px",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enter
|
||||||
|
</kbd>{" "}
|
||||||
|
to save,{" "}
|
||||||
|
<kbd
|
||||||
|
style={{
|
||||||
|
padding: "2px 4px",
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
borderRadius: "2px",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Escape
|
||||||
|
</kbd>{" "}
|
||||||
|
to cancel
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -621,6 +782,31 @@ export default function KanbanPage(): ReactElement {
|
|||||||
void loadTasks(workspaceId);
|
void loadTasks(workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- add task handler --- */
|
||||||
|
|
||||||
|
const handleAddTask = useCallback(
|
||||||
|
async (status: TaskStatus, title: string, projectId?: string) => {
|
||||||
|
try {
|
||||||
|
const wsId = workspaceId ?? undefined;
|
||||||
|
const taskData: { title: string; status: TaskStatus; projectId?: string } = {
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
if (projectId) {
|
||||||
|
taskData.projectId = projectId;
|
||||||
|
}
|
||||||
|
const newTask = await createTask(taskData, wsId);
|
||||||
|
// Optimistically add to local state
|
||||||
|
setTasks((prev) => [...prev, newTask]);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Kanban] Failed to create task:", err);
|
||||||
|
// Re-fetch on error to get consistent state
|
||||||
|
void loadTasks(workspaceId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[workspaceId, loadTasks]
|
||||||
|
);
|
||||||
|
|
||||||
/* --- render --- */
|
/* --- render --- */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -727,23 +913,8 @@ export default function KanbanPage(): ReactElement {
|
|||||||
Clear filters
|
Clear filters
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : tasks.length === 0 ? (
|
|
||||||
/* Empty state */
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 48,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
|
|
||||||
No tasks yet. Create some tasks to see them here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
/* Board */
|
/* Board (always render columns to allow adding first task) */
|
||||||
<DragDropContext onDragEnd={handleDragEnd}>
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -755,7 +926,13 @@ export default function KanbanPage(): ReactElement {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{COLUMNS.map((col) => (
|
{COLUMNS.map((col) => (
|
||||||
<KanbanColumn key={col.status} config={col} tasks={grouped[col.status]} />
|
<KanbanColumn
|
||||||
|
key={col.status}
|
||||||
|
config={col}
|
||||||
|
tasks={grouped[col.status]}
|
||||||
|
onAddTask={handleAddTask}
|
||||||
|
projectId={filterProject}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
|
|||||||
@@ -4,21 +4,39 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
import { fetchRunnerJobs, fetchJobSteps, RunnerJobStatus } from "@/lib/api/runner-jobs";
|
import {
|
||||||
import type { RunnerJob, JobStep } from "@/lib/api/runner-jobs";
|
fetchActivityLogs,
|
||||||
|
ActivityAction,
|
||||||
|
EntityType,
|
||||||
|
type ActivityLog,
|
||||||
|
type ActivityLogFilters,
|
||||||
|
} from "@/lib/api/activity";
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type StatusFilter = "all" | "running" | "completed" | "failed" | "queued";
|
type ActionFilter = "all" | ActivityAction;
|
||||||
|
type EntityFilter = "all" | EntityType;
|
||||||
type DateRange = "24h" | "7d" | "30d" | "all";
|
type DateRange = "24h" | "7d" | "30d" | "all";
|
||||||
|
|
||||||
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
|
const ACTION_OPTIONS: { value: ActionFilter; label: string }[] = [
|
||||||
{ value: "all", label: "All statuses" },
|
{ value: "all", label: "All actions" },
|
||||||
{ value: "running", label: "Running" },
|
{ value: ActivityAction.CREATED, label: "Created" },
|
||||||
{ value: "completed", label: "Completed" },
|
{ value: ActivityAction.UPDATED, label: "Updated" },
|
||||||
{ value: "failed", label: "Failed" },
|
{ value: ActivityAction.DELETED, label: "Deleted" },
|
||||||
{ value: "queued", label: "Queued" },
|
{ value: ActivityAction.COMPLETED, label: "Completed" },
|
||||||
|
{ value: ActivityAction.ASSIGNED, label: "Assigned" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ENTITY_OPTIONS: { value: EntityFilter; label: string }[] = [
|
||||||
|
{ value: "all", label: "All entities" },
|
||||||
|
{ value: EntityType.TASK, label: "Tasks" },
|
||||||
|
{ value: EntityType.EVENT, label: "Events" },
|
||||||
|
{ value: EntityType.PROJECT, label: "Projects" },
|
||||||
|
{ value: EntityType.WORKSPACE, label: "Workspaces" },
|
||||||
|
{ value: EntityType.USER, label: "Users" },
|
||||||
|
{ value: EntityType.DOMAIN, label: "Domains" },
|
||||||
|
{ value: EntityType.IDEA, label: "Ideas" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DATE_RANGES: { value: DateRange; label: string }[] = [
|
const DATE_RANGES: { value: DateRange; label: string }[] = [
|
||||||
@@ -28,37 +46,37 @@ const DATE_RANGES: { value: DateRange; label: string }[] = [
|
|||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_FILTER_TO_ENUM: Record<StatusFilter, RunnerJobStatus[] | undefined> = {
|
|
||||||
all: undefined,
|
|
||||||
running: [RunnerJobStatus.RUNNING],
|
|
||||||
completed: [RunnerJobStatus.COMPLETED],
|
|
||||||
failed: [RunnerJobStatus.FAILED],
|
|
||||||
queued: [RunnerJobStatus.QUEUED, RunnerJobStatus.PENDING],
|
|
||||||
};
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 5_000;
|
const POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getStatusColor(status: string): string {
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
switch (status) {
|
[ActivityAction.CREATED]: "var(--ms-teal-400)",
|
||||||
case "RUNNING":
|
[ActivityAction.UPDATED]: "var(--ms-blue-400)",
|
||||||
return "var(--ms-amber-400)";
|
[ActivityAction.DELETED]: "var(--danger)",
|
||||||
case "COMPLETED":
|
[ActivityAction.COMPLETED]: "var(--ms-emerald-400)",
|
||||||
return "var(--ms-teal-400)";
|
[ActivityAction.ASSIGNED]: "var(--ms-amber-400)",
|
||||||
case "FAILED":
|
};
|
||||||
case "CANCELLED":
|
|
||||||
return "var(--danger)";
|
function getActionColor(action: string): string {
|
||||||
case "QUEUED":
|
return ACTION_COLORS[action] ?? "var(--muted)";
|
||||||
case "PENDING":
|
|
||||||
return "var(--ms-blue-400)";
|
|
||||||
default:
|
|
||||||
return "var(--muted)";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string | null): string {
|
const ENTITY_LABELS: Record<string, string> = {
|
||||||
if (!dateStr) return "\u2014";
|
[EntityType.TASK]: "Task",
|
||||||
|
[EntityType.EVENT]: "Event",
|
||||||
|
[EntityType.PROJECT]: "Project",
|
||||||
|
[EntityType.WORKSPACE]: "Workspace",
|
||||||
|
[EntityType.USER]: "User",
|
||||||
|
[EntityType.DOMAIN]: "Domain",
|
||||||
|
[EntityType.IDEA]: "Idea",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getEntityTypeLabel(entityType: string): string {
|
||||||
|
return ENTITY_LABELS[entityType] ?? entityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string): string {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const diffMs = now - date.getTime();
|
const diffMs = now - date.getTime();
|
||||||
@@ -74,29 +92,6 @@ function formatRelativeTime(dateStr: string | null): string {
|
|||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(startedAt: string | null, completedAt: string | null): string {
|
|
||||||
if (!startedAt) return "\u2014";
|
|
||||||
const start = new Date(startedAt).getTime();
|
|
||||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
|
||||||
const ms = end - start;
|
|
||||||
if (ms < 1_000) return `${String(ms)}ms`;
|
|
||||||
const sec = Math.floor(ms / 1_000);
|
|
||||||
if (sec < 60) return `${String(sec)}s`;
|
|
||||||
const min = Math.floor(sec / 60);
|
|
||||||
const remainSec = sec % 60;
|
|
||||||
return `${String(min)}m ${String(remainSec)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStepDuration(durationMs: number | null): string {
|
|
||||||
if (durationMs === null) return "\u2014";
|
|
||||||
if (durationMs < 1_000) return `${String(durationMs)}ms`;
|
|
||||||
const sec = Math.floor(durationMs / 1_000);
|
|
||||||
if (sec < 60) return `${String(sec)}s`;
|
|
||||||
const min = Math.floor(sec / 60);
|
|
||||||
const remainSec = sec % 60;
|
|
||||||
return `${String(min)}m ${String(remainSec)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWithinDateRange(dateStr: string, range: DateRange): boolean {
|
function isWithinDateRange(dateStr: string, range: DateRange): boolean {
|
||||||
if (range === "all") return true;
|
if (range === "all") return true;
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
@@ -105,18 +100,16 @@ function isWithinDateRange(dateStr: string, range: DateRange): boolean {
|
|||||||
return now - date.getTime() < hours * 60 * 60 * 1_000;
|
return now - date.getTime() < hours * 60 * 60 * 1_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Status Badge ─────────────────────────────────────────────────────
|
// ─── Action Badge ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }): ReactElement {
|
function ActionBadge({ action }: { action: string }): ReactElement {
|
||||||
const color = getStatusColor(status);
|
const color = getActionColor(action);
|
||||||
const isRunning = status === "RUNNING";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 6,
|
|
||||||
padding: "2px 10px",
|
padding: "2px 10px",
|
||||||
borderRadius: 9999,
|
borderRadius: 9999,
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
@@ -127,18 +120,7 @@ function StatusBadge({ status }: { status: string }): ReactElement {
|
|||||||
textTransform: "capitalize",
|
textTransform: "capitalize",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isRunning && (
|
{action.toLowerCase()}
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: color,
|
|
||||||
animation: "pulse 1.5s ease-in-out infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{status.toLowerCase()}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -149,59 +131,55 @@ export default function LogsPage(): ReactElement {
|
|||||||
const workspaceId = useWorkspaceId();
|
const workspaceId = useWorkspaceId();
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
const [jobs, setJobs] = useState<RunnerJob[]>([]);
|
const [activities, setActivities] = useState<ActivityLog[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Expanded job and steps
|
|
||||||
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
|
||||||
const [jobStepsMap, setJobStepsMap] = useState<Record<string, JobStep[]>>({});
|
|
||||||
const [stepsLoading, setStepsLoading] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
const [actionFilter, setActionFilter] = useState<ActionFilter>("all");
|
||||||
|
const [entityFilter, setEntityFilter] = useState<EntityFilter>("all");
|
||||||
const [dateRange, setDateRange] = useState<DateRange>("7d");
|
const [dateRange, setDateRange] = useState<DateRange>("7d");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
// Auto-refresh
|
// Auto-refresh
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// Hover state
|
|
||||||
const [hoveredRowId, setHoveredRowId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// ─── Data Loading ─────────────────────────────────────────────────
|
// ─── Data Loading ─────────────────────────────────────────────────
|
||||||
|
|
||||||
const loadJobs = useCallback(async (): Promise<void> => {
|
const loadActivities = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const statusEnums = STATUS_FILTER_TO_ENUM[statusFilter];
|
const filters: ActivityLogFilters = {};
|
||||||
const filters: Parameters<typeof fetchRunnerJobs>[0] = {};
|
|
||||||
if (workspaceId) {
|
if (workspaceId) {
|
||||||
filters.workspaceId = workspaceId;
|
filters.workspaceId = workspaceId;
|
||||||
}
|
}
|
||||||
if (statusEnums) {
|
if (actionFilter !== "all") {
|
||||||
filters.status = statusEnums;
|
filters.action = actionFilter;
|
||||||
|
}
|
||||||
|
if (entityFilter !== "all") {
|
||||||
|
filters.entityType = entityFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await fetchRunnerJobs(filters);
|
const response: Awaited<ReturnType<typeof fetchActivityLogs>> =
|
||||||
setJobs(data);
|
await fetchActivityLogs(filters);
|
||||||
|
setActivities(response);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error("[Logs] Failed to fetch runner jobs:", err);
|
console.error("[Logs] Failed to fetch activity logs:", err);
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: "We had trouble loading jobs. Please try again when you're ready."
|
: "We had trouble loading activity logs. Please try again when you're ready."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [workspaceId, statusFilter]);
|
}, [workspaceId, actionFilter, entityFilter]);
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
loadJobs()
|
loadActivities()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -216,13 +194,13 @@ export default function LogsPage(): ReactElement {
|
|||||||
return (): void => {
|
return (): void => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [loadJobs]);
|
}, [loadActivities]);
|
||||||
|
|
||||||
// Auto-refresh polling
|
// Auto-refresh polling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) {
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
void loadJobs();
|
void loadActivities();
|
||||||
}, POLL_INTERVAL_MS);
|
}, POLL_INTERVAL_MS);
|
||||||
} else if (intervalRef.current) {
|
} else if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
@@ -235,55 +213,22 @@ export default function LogsPage(): ReactElement {
|
|||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [autoRefresh, loadJobs]);
|
}, [autoRefresh, loadActivities]);
|
||||||
|
|
||||||
// ─── Steps Loading ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const toggleExpand = useCallback(
|
|
||||||
(jobId: string) => {
|
|
||||||
if (expandedJobId === jobId) {
|
|
||||||
setExpandedJobId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setExpandedJobId(jobId);
|
|
||||||
|
|
||||||
// Load steps if not already loaded
|
|
||||||
if (!jobStepsMap[jobId] && !stepsLoading.has(jobId)) {
|
|
||||||
setStepsLoading((prev) => new Set(prev).add(jobId));
|
|
||||||
|
|
||||||
fetchJobSteps(jobId, workspaceId ?? undefined)
|
|
||||||
.then((steps) => {
|
|
||||||
setJobStepsMap((prev) => ({ ...prev, [jobId]: steps }));
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
console.error("[Logs] Failed to fetch steps for job:", jobId, err);
|
|
||||||
setJobStepsMap((prev) => ({ ...prev, [jobId]: [] }));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setStepsLoading((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(jobId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[expandedJobId, jobStepsMap, stepsLoading, workspaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Filtering ────────────────────────────────────────────────────
|
// ─── Filtering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const filteredJobs = jobs.filter((job) => {
|
const filteredActivities = activities.filter((activity) => {
|
||||||
// Date range filter
|
// Date range filter
|
||||||
if (!isWithinDateRange(job.createdAt, dateRange)) return false;
|
if (!isWithinDateRange(activity.createdAt, dateRange)) return false;
|
||||||
|
|
||||||
// Search filter
|
// Search filter
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
const matchesType = job.type.toLowerCase().includes(q);
|
const matchesEntity = getEntityTypeLabel(activity.entityType).toLowerCase().includes(q);
|
||||||
const matchesId = job.id.toLowerCase().includes(q);
|
const matchesId = activity.entityId.toLowerCase().includes(q);
|
||||||
if (!matchesType && !matchesId) return false;
|
const matchesUser = activity.user?.name?.toLowerCase().includes(q);
|
||||||
|
const matchesEmail = activity.user?.email.toLowerCase().includes(q);
|
||||||
|
if (!matchesEntity && !matchesId && !matchesUser && !matchesEmail) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -293,7 +238,7 @@ export default function LogsPage(): ReactElement {
|
|||||||
|
|
||||||
const handleManualRefresh = (): void => {
|
const handleManualRefresh = (): void => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
void loadJobs().finally(() => {
|
void loadActivities().finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -307,16 +252,12 @@ export default function LogsPage(): ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
{/* Pulse animation for running status */}
|
{/* Pulse animation for auto-refresh */}
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.4; }
|
50% { opacity: 0.4; }
|
||||||
}
|
}
|
||||||
@keyframes auto-refresh-spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* ─── Header ─────────────────────────────────────────────── */}
|
{/* ─── Header ─────────────────────────────────────────────── */}
|
||||||
@@ -332,10 +273,10 @@ export default function LogsPage(): ReactElement {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
Logs & Telemetry
|
Activity Logs
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1" style={{ color: "var(--text-muted)" }}>
|
<p className="mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
Runner job history and step-level detail
|
Audit trail and activity history
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -408,11 +349,11 @@ export default function LogsPage(): ReactElement {
|
|||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Status filter */}
|
{/* Action filter */}
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={actionFilter}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setStatusFilter(e.target.value as StatusFilter);
|
setActionFilter(e.target.value as ActionFilter);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: "8px 12px",
|
padding: "8px 12px",
|
||||||
@@ -425,7 +366,31 @@ export default function LogsPage(): ReactElement {
|
|||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{STATUS_OPTIONS.map((opt) => (
|
{ACTION_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Entity filter */}
|
||||||
|
<select
|
||||||
|
value={entityFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEntityFilter(e.target.value as EntityFilter);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text)",
|
||||||
|
cursor: "pointer",
|
||||||
|
minWidth: 140,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ENTITY_OPTIONS.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</option>
|
</option>
|
||||||
@@ -467,7 +432,7 @@ export default function LogsPage(): ReactElement {
|
|||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by job type..."
|
placeholder="Search by entity or user..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
@@ -487,9 +452,9 @@ export default function LogsPage(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Content ────────────────────────────────────────────── */}
|
{/* ─── Content ────────────────────────────────────────────── */}
|
||||||
{isLoading && jobs.length === 0 ? (
|
{isLoading && activities.length === 0 ? (
|
||||||
<div className="flex justify-center py-16">
|
<div className="flex justify-center py-16">
|
||||||
<MosaicSpinner label="Loading jobs..." />
|
<MosaicSpinner label="Loading activity logs..." />
|
||||||
</div>
|
</div>
|
||||||
) : error !== null ? (
|
) : error !== null ? (
|
||||||
<div
|
<div
|
||||||
@@ -508,7 +473,7 @@ export default function LogsPage(): ReactElement {
|
|||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : filteredJobs.length === 0 ? (
|
) : filteredActivities.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg p-8 text-center"
|
className="rounded-lg p-8 text-center"
|
||||||
style={{
|
style={{
|
||||||
@@ -516,10 +481,10 @@ export default function LogsPage(): ReactElement {
|
|||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p style={{ color: "var(--text-muted)" }}>No jobs found</p>
|
<p style={{ color: "var(--text-muted)" }}>No activity logs found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ─── Job Table ──────────────────────────────────────────── */
|
/* ─── Activity Table ──────────────────────────────────────── */
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@@ -535,7 +500,7 @@ export default function LogsPage(): ReactElement {
|
|||||||
background: "var(--bg-mid)",
|
background: "var(--bg-mid)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{["Job Type", "Status", "Started", "Duration", "Steps"].map((header) => (
|
{["Action", "Entity", "User", "Details", "Time"].map((header) => (
|
||||||
<th
|
<th
|
||||||
key={header}
|
key={header}
|
||||||
style={{
|
style={{
|
||||||
@@ -556,32 +521,9 @@ export default function LogsPage(): ReactElement {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredJobs.map((job) => {
|
{filteredActivities.map((activity) => (
|
||||||
const isExpanded = expandedJobId === job.id;
|
<ActivityRow key={activity.id} activity={activity} />
|
||||||
const isHovered = hoveredRowId === job.id;
|
))}
|
||||||
const steps = jobStepsMap[job.id];
|
|
||||||
const isStepsLoading = stepsLoading.has(job.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<JobRow
|
|
||||||
key={job.id}
|
|
||||||
job={job}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
isHovered={isHovered}
|
|
||||||
steps={steps}
|
|
||||||
isStepsLoading={isStepsLoading}
|
|
||||||
onToggle={() => {
|
|
||||||
toggleExpand(job.id);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setHoveredRowId(job.id);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setHoveredRowId(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -591,260 +533,91 @@ export default function LogsPage(): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Job Row Component ────────────────────────────────────────────────
|
// ─── Activity Row Component ───────────────────────────────────────────
|
||||||
|
|
||||||
function JobRow({
|
|
||||||
job,
|
|
||||||
isExpanded,
|
|
||||||
isHovered,
|
|
||||||
steps,
|
|
||||||
isStepsLoading,
|
|
||||||
onToggle,
|
|
||||||
onMouseEnter,
|
|
||||||
onMouseLeave,
|
|
||||||
}: {
|
|
||||||
job: RunnerJob;
|
|
||||||
isExpanded: boolean;
|
|
||||||
isHovered: boolean;
|
|
||||||
steps: JobStep[] | undefined;
|
|
||||||
isStepsLoading: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onMouseEnter: () => void;
|
|
||||||
onMouseLeave: () => void;
|
|
||||||
}): ReactElement {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<tr
|
|
||||||
onClick={onToggle}
|
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
style={{
|
|
||||||
background: isExpanded
|
|
||||||
? "var(--surface-2)"
|
|
||||||
: isHovered
|
|
||||||
? "var(--surface-2)"
|
|
||||||
: "var(--surface)",
|
|
||||||
cursor: "pointer",
|
|
||||||
borderBottom: isExpanded ? "none" : "1px solid var(--border)",
|
|
||||||
transition: "background 100ms ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
width: 16,
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
transition: "transform 150ms ease",
|
|
||||||
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
▶
|
|
||||||
</span>
|
|
||||||
{job.type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "12px 16px" }}>
|
|
||||||
<StatusBadge status={job.status} />
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatRelativeTime(job.startedAt ?? job.createdAt)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatDuration(job.startedAt, job.completedAt)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{steps ? String(steps.length) : "\u2014"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{/* Expanded Steps Section */}
|
|
||||||
{isExpanded && (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
style={{
|
|
||||||
padding: 0,
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--bg-mid)",
|
|
||||||
padding: "12px 16px 12px 48px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isStepsLoading ? (
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
|
|
||||||
<MosaicSpinner size={24} label="Loading steps..." />
|
|
||||||
</div>
|
|
||||||
) : !steps || steps.length === 0 ? (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
padding: "8px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No steps recorded for this job
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{["#", "Name", "Phase", "Status", "Duration"].map((header) => (
|
|
||||||
<th
|
|
||||||
key={header}
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
textAlign: "left",
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{header}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{steps
|
|
||||||
.sort((a, b) => a.ordinal - b.ordinal)
|
|
||||||
.map((step) => (
|
|
||||||
<StepRow key={step.id} step={step} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Job error message if failed */}
|
|
||||||
{job.error && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 12,
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--danger)",
|
|
||||||
background: "color-mix(in srgb, var(--danger) 8%, transparent)",
|
|
||||||
border: "1px solid color-mix(in srgb, var(--danger) 20%, transparent)",
|
|
||||||
wordBreak: "break-all",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{job.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step Row Component ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StepRow({ step }: { step: JobStep }): ReactElement {
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
|
|
||||||
|
function ActivityRow({ activity }: { activity: ActivityLog }): ReactElement {
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
onMouseEnter={() => {
|
|
||||||
setHovered(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setHovered(false);
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
background: hovered ? "color-mix(in srgb, var(--surface) 50%, transparent)" : "transparent",
|
background: "var(--surface)",
|
||||||
borderBottom: "1px solid color-mix(in srgb, var(--border) 50%, transparent)",
|
borderBottom: "1px solid var(--border)",
|
||||||
transition: "background 100ms ease",
|
transition: "background 100ms ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td
|
<td style={{ padding: "12px 16px" }}>
|
||||||
style={{
|
<ActionBadge action={activity.action} />
|
||||||
padding: "6px 12px",
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{String(step.ordinal)}
|
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
padding: "6px 12px",
|
padding: "12px 16px",
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
color: "var(--text)",
|
color: "var(--text)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{step.name}
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<span>{getEntityTypeLabel(activity.entityType)}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activity.entityId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
padding: "6px 12px",
|
padding: "12px 16px",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.82rem",
|
||||||
fontFamily: "var(--mono)",
|
color: "var(--text)",
|
||||||
color: "var(--text-muted)",
|
|
||||||
textTransform: "lowercase",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{step.phase}
|
{activity.user ? (
|
||||||
</td>
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<td style={{ padding: "6px 12px" }}>
|
<span>{activity.user.name ?? activity.user.email}</span>
|
||||||
<StatusBadge status={step.status} />
|
{activity.user.name && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activity.user.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--muted)" }}>—</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
padding: "6px 12px",
|
padding: "12px 16px",
|
||||||
fontSize: "0.78rem",
|
fontSize: "0.78rem",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
maxWidth: 300,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
title={activity.details ? JSON.stringify(activity.details) : undefined}
|
||||||
|
>
|
||||||
|
{activity.details ? JSON.stringify(activity.details) : "—"}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: "0.82rem",
|
||||||
fontFamily: "var(--mono)",
|
fontFamily: "var(--mono)",
|
||||||
color: "var(--text-muted)",
|
color: "var(--text-muted)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatStepDuration(step.durationMs)}
|
{formatRelativeTime(activity.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
491
apps/web/src/app/(authenticated)/projects/[id]/page.tsx
Normal file
491
apps/web/src/app/(authenticated)/projects/[id]/page.tsx
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
|
import { fetchProject, type ProjectDetail } from "@/lib/api/projects";
|
||||||
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
|
|
||||||
|
interface BadgeStyle {
|
||||||
|
label: string;
|
||||||
|
bg: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
style: BadgeStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetaItemProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectStatusStyle(status: string): BadgeStyle {
|
||||||
|
switch (status) {
|
||||||
|
case "PLANNING":
|
||||||
|
return { label: "Planning", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" };
|
||||||
|
case "ACTIVE":
|
||||||
|
return { label: "Active", bg: "rgba(20,184,166,0.15)", color: "var(--success)" };
|
||||||
|
case "PAUSED":
|
||||||
|
return { label: "Paused", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
||||||
|
case "COMPLETED":
|
||||||
|
return { label: "Completed", bg: "rgba(139,92,246,0.15)", color: "var(--purple)" };
|
||||||
|
case "ARCHIVED":
|
||||||
|
return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||||
|
default:
|
||||||
|
return { label: status, bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityStyle(priority: string | null | undefined): BadgeStyle {
|
||||||
|
switch (priority) {
|
||||||
|
case "HIGH":
|
||||||
|
return { label: "High", bg: "rgba(229,72,77,0.15)", color: "var(--danger)" };
|
||||||
|
case "MEDIUM":
|
||||||
|
return { label: "Medium", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
||||||
|
case "LOW":
|
||||||
|
return { label: "Low", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||||
|
default:
|
||||||
|
return { label: "Unspecified", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskStatusStyle(status: string): BadgeStyle {
|
||||||
|
switch (status) {
|
||||||
|
case "NOT_STARTED":
|
||||||
|
return { label: "Not Started", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" };
|
||||||
|
case "IN_PROGRESS":
|
||||||
|
return { label: "In Progress", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
||||||
|
case "PAUSED":
|
||||||
|
return { label: "Paused", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||||
|
case "COMPLETED":
|
||||||
|
return { label: "Completed", bg: "rgba(20,184,166,0.15)", color: "var(--success)" };
|
||||||
|
case "ARCHIVED":
|
||||||
|
return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||||
|
default:
|
||||||
|
return { label: status, bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "Not set";
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "Not set";
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFriendlyErrorMessage(error: unknown): string {
|
||||||
|
const fallback = "We had trouble loading this project. Please try again when you're ready.";
|
||||||
|
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error.message.trim();
|
||||||
|
if (message.toLowerCase().includes("not found")) {
|
||||||
|
return "Project not found. It may have been deleted or you may not have access to it.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ style: statusStyle }: StatusBadgeProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "2px 10px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
background: statusStyle.bg,
|
||||||
|
color: statusStyle.color,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusStyle.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaItem({ label, value }: MetaItemProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
padding: "10px 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ margin: "0 0 4px", fontSize: "0.75rem", color: "var(--muted)" }}>{label}</p>
|
||||||
|
<p style={{ margin: 0, fontSize: "0.85rem", color: "var(--text)" }}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectDetailPage(): ReactElement {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ id: string | string[] }>();
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
|
const rawProjectId = params.id;
|
||||||
|
const projectId = Array.isArray(rawProjectId) ? (rawProjectId[0] ?? null) : rawProjectId;
|
||||||
|
|
||||||
|
const [project, setProject] = useState<ProjectDetail | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadProject = useCallback(async (id: string, wsId: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await fetchProject(id, wsId);
|
||||||
|
setProject(data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[ProjectDetail] Failed to fetch project:", err);
|
||||||
|
setProject(null);
|
||||||
|
setError(toFriendlyErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) {
|
||||||
|
setProject(null);
|
||||||
|
setError("The project link is invalid. Please return to the projects page.");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
setProject(null);
|
||||||
|
setError("Select a workspace to view this project.");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = projectId;
|
||||||
|
const wsId = workspaceId;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await fetchProject(id, wsId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setProject(data);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[ProjectDetail] Failed to fetch project:", err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setProject(null);
|
||||||
|
setError(toFriendlyErrorMessage(err));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [projectId, workspaceId]);
|
||||||
|
|
||||||
|
function handleRetry(): void {
|
||||||
|
if (!projectId || !workspaceId) return;
|
||||||
|
void loadProject(projectId, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBack(): void {
|
||||||
|
router.push("/projects");
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectStatus = project ? getProjectStatusStyle(project.status) : null;
|
||||||
|
const projectPriority = project ? getPriorityStyle(project.priority) : null;
|
||||||
|
const dueDate = project?.dueDate ?? project?.endDate;
|
||||||
|
const creator =
|
||||||
|
project?.creator.name && project.creator.name.trim().length > 0
|
||||||
|
? `${project.creator.name} (${project.creator.email})`
|
||||||
|
: (project?.creator.email ?? "Unknown");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8" style={{ maxWidth: 960 }}>
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Back to projects
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<MosaicSpinner label="Loading project..." />
|
||||||
|
</div>
|
||||||
|
) : error !== null ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: 32,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--danger)", margin: "0 0 20px" }}>{error}</p>
|
||||||
|
<div style={{ display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap" }}>
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to projects
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
background: "var(--danger)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : project === null ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: 32,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--muted)", margin: 0 }}>Project details are not available.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<h1
|
||||||
|
style={{ margin: 0, fontSize: "1.875rem", fontWeight: 700, color: "var(--text)" }}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{projectStatus && <StatusBadge style={projectStatus} />}
|
||||||
|
{projectPriority && <StatusBadge style={projectPriority} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.description ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "14px 0 0",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "14px 0 0",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
fontStyle: "italic",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No description provided.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3" style={{ marginTop: 18 }}>
|
||||||
|
<MetaItem label="Start date" value={formatDate(project.startDate)} />
|
||||||
|
<MetaItem label="Due date" value={formatDate(dueDate)} />
|
||||||
|
<MetaItem label="Created" value={formatDateTime(project.createdAt)} />
|
||||||
|
<MetaItem label="Updated" value={formatDateTime(project.updatedAt)} />
|
||||||
|
<MetaItem label="Creator" value={creator} />
|
||||||
|
<MetaItem
|
||||||
|
label="Work items"
|
||||||
|
value={`${String(project._count.tasks)} tasks · ${String(project._count.events)} events`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: "0 0 12px", fontSize: "1.1rem", color: "var(--text)" }}>
|
||||||
|
Tasks ({String(project._count.tasks)})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{project.tasks.length === 0 ? (
|
||||||
|
<p style={{ margin: 0, color: "var(--muted)", fontSize: "0.9rem" }}>
|
||||||
|
No tasks yet for this project.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{project.tasks.map((task, index) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
style={{
|
||||||
|
padding: "12px 0",
|
||||||
|
borderTop: index === 0 ? "none" : "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<p style={{ margin: 0, color: "var(--text)", fontWeight: 500 }}>
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: "4px 0 0", color: "var(--muted)", fontSize: "0.8rem" }}>
|
||||||
|
Due: {formatDate(task.dueDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<StatusBadge style={getTaskStatusStyle(task.status)} />
|
||||||
|
<StatusBadge style={getPriorityStyle(task.priority)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: "0 0 12px", fontSize: "1.1rem", color: "var(--text)" }}>
|
||||||
|
Events ({String(project._count.events)})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{project.events.length === 0 ? (
|
||||||
|
<p style={{ margin: 0, color: "var(--muted)", fontSize: "0.9rem" }}>
|
||||||
|
No events scheduled for this project.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{project.events.map((event, index) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
style={{
|
||||||
|
padding: "12px 0",
|
||||||
|
borderTop: index === 0 ? "none" : "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ margin: 0, color: "var(--text)", fontWeight: 500 }}>
|
||||||
|
{event.title}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: "4px 0 0", color: "var(--muted)", fontSize: "0.8rem" }}>
|
||||||
|
{formatDateTime(event.startTime)} - {formatDateTime(event.endTime)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
import { fetchProjects, createProject, deleteProject, ProjectStatus } from "@/lib/api/projects";
|
import { fetchProjects, createProject, deleteProject, ProjectStatus } from "@/lib/api/projects";
|
||||||
import type { Project, CreateProjectDto } from "@/lib/api/projects";
|
import type { Project, CreateProjectDto } from "@/lib/api/projects";
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
|
import { fetchDomains } from "@/lib/api/domains";
|
||||||
|
import type { Domain } from "@mosaic/shared";
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
Status badge helpers
|
Status badge helpers
|
||||||
@@ -65,11 +67,14 @@ interface ProjectCardProps {
|
|||||||
project: Project;
|
project: Project;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onClick: (id: string) => void;
|
onClick: (id: string) => void;
|
||||||
|
domains: Domain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectCard({ project, onDelete, onClick }: ProjectCardProps): ReactElement {
|
function ProjectCard({ project, onDelete, onClick, domains }: ProjectCardProps): ReactElement {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const status = getStatusStyle(project.status);
|
const status = getStatusStyle(project.status);
|
||||||
|
// Find domain if project has a domainId
|
||||||
|
const domain = project.domainId ? domains.find((d) => d.id === project.domainId) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -204,6 +209,22 @@ function ProjectCard({ project, onDelete, onClick }: ProjectCardProps): ReactEle
|
|||||||
>
|
>
|
||||||
{status.label}
|
{status.label}
|
||||||
</span>
|
</span>
|
||||||
|
{domain && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "2px 10px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
background: "rgba(139,92,246,0.15)",
|
||||||
|
color: "var(--purple)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{domain.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Timestamps */}
|
{/* Timestamps */}
|
||||||
<span
|
<span
|
||||||
@@ -229,6 +250,7 @@ interface CreateDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSubmit: (data: CreateProjectDto) => Promise<void>;
|
onSubmit: (data: CreateProjectDto) => Promise<void>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
|
domains: Domain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateProjectDialog({
|
function CreateProjectDialog({
|
||||||
@@ -236,20 +258,24 @@ function CreateProjectDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
domains,
|
||||||
}: CreateDialogProps): ReactElement {
|
}: CreateDialogProps): ReactElement {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [domainId, setDomainId] = useState("");
|
||||||
|
|
||||||
function resetForm(): void {
|
function resetForm(): void {
|
||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
setDomainId("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
setDomainId("");
|
||||||
|
|
||||||
const trimmedName = name.trim();
|
const trimmedName = name.trim();
|
||||||
if (!trimmedName) {
|
if (!trimmedName) {
|
||||||
@@ -263,6 +289,9 @@ function CreateProjectDialog({
|
|||||||
if (trimmedDesc) {
|
if (trimmedDesc) {
|
||||||
payload.description = trimmedDesc;
|
payload.description = trimmedDesc;
|
||||||
}
|
}
|
||||||
|
if (domainId) {
|
||||||
|
payload.domainId = domainId;
|
||||||
|
}
|
||||||
await onSubmit(payload);
|
await onSubmit(payload);
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -382,6 +411,47 @@ function CreateProjectDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Domain */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label
|
||||||
|
htmlFor="project-domain"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Domain (optional)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="project-domain"
|
||||||
|
value={domainId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDomainId(e.target.value);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{domains.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Form error */}
|
{/* Form error */}
|
||||||
{formError !== null && (
|
{formError !== null && (
|
||||||
<p style={{ color: "var(--danger)", fontSize: "0.85rem", margin: "0 0 12px" }}>
|
<p style={{ color: "var(--danger)", fontSize: "0.85rem", margin: "0 0 12px" }}>
|
||||||
@@ -532,6 +602,7 @@ export default function ProjectsPage(): ReactElement {
|
|||||||
const workspaceId = useWorkspaceId();
|
const workspaceId = useWorkspaceId();
|
||||||
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [domains, setDomains] = useState<Domain[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -601,6 +672,33 @@ export default function ProjectsPage(): ReactElement {
|
|||||||
};
|
};
|
||||||
}, [workspaceId]);
|
}, [workspaceId]);
|
||||||
|
|
||||||
|
// Load domains
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const wsId = workspaceId;
|
||||||
|
|
||||||
|
async function loadDomains(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetchDomains(undefined, wsId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setDomains(response.data);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Projects] Failed to fetch domains:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadDomains();
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [workspaceId]);
|
||||||
|
|
||||||
function handleRetry(): void {
|
function handleRetry(): void {
|
||||||
void loadProjects(workspaceId);
|
void loadProjects(workspaceId);
|
||||||
}
|
}
|
||||||
@@ -779,6 +877,7 @@ export default function ProjectsPage(): ReactElement {
|
|||||||
project={project}
|
project={project}
|
||||||
onDelete={handleDeleteRequest}
|
onDelete={handleDeleteRequest}
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
|
domains={domains}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -790,6 +889,7 @@ export default function ProjectsPage(): ReactElement {
|
|||||||
onOpenChange={setCreateOpen}
|
onOpenChange={setCreateOpen}
|
||||||
onSubmit={handleCreate}
|
onSubmit={handleCreate}
|
||||||
isSubmitting={isCreating}
|
isSubmitting={isCreating}
|
||||||
|
domains={domains}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
|
|||||||
356
apps/web/src/app/(authenticated)/settings/agent-config/page.tsx
Normal file
356
apps/web/src/app/(authenticated)/settings/agent-config/page.tsx
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type ReactElement,
|
||||||
|
type SyntheticEvent,
|
||||||
|
} from "react";
|
||||||
|
import { FleetSettingsNav } from "@/components/settings/FleetSettingsNav";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
fetchFleetAgentConfig,
|
||||||
|
fetchFleetProviders,
|
||||||
|
updateFleetAgentConfig,
|
||||||
|
type FleetProvider,
|
||||||
|
type FleetProviderModel,
|
||||||
|
type UpdateFleetAgentConfigRequest,
|
||||||
|
} from "@/lib/api/fleet-settings";
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error && error.message.trim().length > 0) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProviderModels(models: unknown): FleetProviderModel[] {
|
||||||
|
if (!Array.isArray(models)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: FleetProviderModel[] = [];
|
||||||
|
|
||||||
|
models.forEach((entry) => {
|
||||||
|
if (typeof entry === "string" && entry.trim().length > 0) {
|
||||||
|
parsed.push({ id: entry.trim(), name: entry.trim() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry && typeof entry === "object") {
|
||||||
|
const record = entry as Record<string, unknown>;
|
||||||
|
const id =
|
||||||
|
typeof record.id === "string"
|
||||||
|
? record.id.trim()
|
||||||
|
: typeof record.name === "string"
|
||||||
|
? record.name.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (id.length > 0) {
|
||||||
|
parsed.push({ id, name: id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return parsed.filter((model) => {
|
||||||
|
if (seen.has(model.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(model.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseModelList(value: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(/\n|,/g)
|
||||||
|
.map((segment) => segment.trim())
|
||||||
|
.filter((segment) => segment.length > 0)
|
||||||
|
.filter((segment) => {
|
||||||
|
if (seen.has(segment)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(segment);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveAvailableModels(providers: FleetProvider[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const models: string[] = [];
|
||||||
|
|
||||||
|
providers.forEach((provider) => {
|
||||||
|
normalizeProviderModels(provider.models).forEach((model) => {
|
||||||
|
if (seen.has(model.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(model.id);
|
||||||
|
models.push(model.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return models.sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentConfigSettingsPage(): ReactElement {
|
||||||
|
const [providers, setProviders] = useState<FleetProvider[]>([]);
|
||||||
|
const [primaryModel, setPrimaryModel] = useState<string>("");
|
||||||
|
const [fallbackModelsText, setFallbackModelsText] = useState<string>("");
|
||||||
|
const [personality, setPersonality] = useState<string>("");
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const availableModels = useMemo(() => deriveAvailableModels(providers), [providers]);
|
||||||
|
const fallbackModels = useMemo(() => parseModelList(fallbackModelsText), [fallbackModelsText]);
|
||||||
|
|
||||||
|
const modelSelectOptions = useMemo(() => {
|
||||||
|
if (primaryModel.length > 0 && !availableModels.includes(primaryModel)) {
|
||||||
|
return [primaryModel, ...availableModels];
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableModels;
|
||||||
|
}, [availableModels, primaryModel]);
|
||||||
|
|
||||||
|
const loadSettings = useCallback(async (): Promise<void> => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [providerData, agentConfig] = await Promise.all([
|
||||||
|
fetchFleetProviders(),
|
||||||
|
fetchFleetAgentConfig(),
|
||||||
|
]);
|
||||||
|
setProviders(providerData);
|
||||||
|
setPrimaryModel(agentConfig.primaryModel ?? "");
|
||||||
|
setFallbackModelsText(agentConfig.fallbackModels.join("\n"));
|
||||||
|
setPersonality(agentConfig.personality ?? "");
|
||||||
|
setError(null);
|
||||||
|
} catch (loadError: unknown) {
|
||||||
|
setError(getErrorMessage(loadError, "Failed to load agent configuration."));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSettings();
|
||||||
|
}, [loadSettings]);
|
||||||
|
|
||||||
|
function appendFallbackModel(model: string): void {
|
||||||
|
const current = parseModelList(fallbackModelsText);
|
||||||
|
if (current.includes(model)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [...current, model];
|
||||||
|
setFallbackModelsText(next.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(event: SyntheticEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
|
||||||
|
const updatePayload: UpdateFleetAgentConfigRequest = {
|
||||||
|
personality: personality.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (primaryModel.trim().length > 0) {
|
||||||
|
updatePayload.primaryModel = primaryModel.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedFallbacks = parseModelList(fallbackModelsText).filter(
|
||||||
|
(model) => model !== primaryModel.trim()
|
||||||
|
);
|
||||||
|
if (parsedFallbacks.length > 0) {
|
||||||
|
updatePayload.fallbackModels = parsedFallbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
await updateFleetAgentConfig(updatePayload);
|
||||||
|
setSuccessMessage("Agent configuration saved.");
|
||||||
|
await loadSettings();
|
||||||
|
} catch (saveError: unknown) {
|
||||||
|
setError(getErrorMessage(saveError, "Failed to save agent configuration."));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Agent Configuration</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Assign primary and fallback models for your agent runtime behavior.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FleetSettingsNav />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Current Assignment</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Snapshot of your currently saved model routing configuration.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading configuration...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Primary Model</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{primaryModel.length > 0 ? primaryModel : "No primary model configured"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Fallback Models</p>
|
||||||
|
{fallbackModels.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No fallback models configured</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{fallbackModels.map((model) => (
|
||||||
|
<Badge key={`current-${model}`} variant="outline">
|
||||||
|
{model}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Update Agent Config</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Select a primary model and define fallback ordering. Models come from your provider
|
||||||
|
settings.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={(event) => void handleSave(event)} className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="primary-model">Primary Model</Label>
|
||||||
|
<Select
|
||||||
|
value={primaryModel.length > 0 ? primaryModel : "__none__"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setPrimaryModel(value === "__none__" ? "" : value);
|
||||||
|
}}
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="primary-model">
|
||||||
|
<SelectValue placeholder="Select a primary model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">No primary model selected</SelectItem>
|
||||||
|
{modelSelectOptions.map((model) => (
|
||||||
|
<SelectItem key={model} value={model}>
|
||||||
|
{model}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{availableModels.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No models available yet. Add provider models first in Providers settings.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fallback-models">Fallback Models</Label>
|
||||||
|
<Textarea
|
||||||
|
id="fallback-models"
|
||||||
|
value={fallbackModelsText}
|
||||||
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setFallbackModelsText(event.target.value);
|
||||||
|
}}
|
||||||
|
rows={4}
|
||||||
|
placeholder={"One model per line\nExample: gpt-4.1-mini"}
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
/>
|
||||||
|
{availableModels.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableModels
|
||||||
|
.filter((model) => model !== primaryModel)
|
||||||
|
.map((model) => (
|
||||||
|
<Button
|
||||||
|
key={`suggest-${model}`}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
appendFallbackModel(model);
|
||||||
|
}}
|
||||||
|
disabled={fallbackModels.includes(model) || isSaving}
|
||||||
|
>
|
||||||
|
{fallbackModels.includes(model) ? `Added: ${model}` : `Add ${model}`}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-personality">Personality / SOUL</Label>
|
||||||
|
<Textarea
|
||||||
|
id="agent-personality"
|
||||||
|
value={personality}
|
||||||
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setPersonality(event.target.value);
|
||||||
|
}}
|
||||||
|
rows={8}
|
||||||
|
placeholder="Optional system personality instructions..."
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{successMessage ? <p className="text-sm text-emerald-600">{successMessage}</p> : null}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isLoading || isSaving}>
|
||||||
|
{isSaving ? "Saving..." : "Save Agent Config"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
492
apps/web/src/app/(authenticated)/settings/auth/page.tsx
Normal file
492
apps/web/src/app/(authenticated)/settings/auth/page.tsx
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type ReactElement,
|
||||||
|
type SyntheticEvent,
|
||||||
|
} from "react";
|
||||||
|
import { FleetSettingsNav } from "@/components/settings/FleetSettingsNav";
|
||||||
|
import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
deleteFleetOidcConfig,
|
||||||
|
fetchFleetOidcConfig,
|
||||||
|
resetBreakglassAdminPassword,
|
||||||
|
updateFleetOidcConfig,
|
||||||
|
type FleetOidcConfig,
|
||||||
|
} from "@/lib/api/fleet-settings";
|
||||||
|
import { fetchOnboardingStatus } from "@/lib/api/onboarding";
|
||||||
|
|
||||||
|
interface OidcFormState {
|
||||||
|
issuerUrl: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreakglassFormState {
|
||||||
|
username: string;
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_OIDC_FORM: OidcFormState = {
|
||||||
|
issuerUrl: "",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_BREAKGLASS_FORM: BreakglassFormState = {
|
||||||
|
username: "",
|
||||||
|
currentPassword: "",
|
||||||
|
newPassword: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error && error.message.trim().length > 0) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdminGuardError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = error.message.toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized.includes("requires system administrator") ||
|
||||||
|
normalized.includes("forbidden") ||
|
||||||
|
normalized.includes("403")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthSettingsPage(): ReactElement {
|
||||||
|
const [oidcConfig, setOidcConfig] = useState<FleetOidcConfig | null>(null);
|
||||||
|
const [oidcForm, setOidcForm] = useState<OidcFormState>(INITIAL_OIDC_FORM);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [isSavingOidc, setIsSavingOidc] = useState<boolean>(false);
|
||||||
|
const [isDeletingOidc, setIsDeletingOidc] = useState<boolean>(false);
|
||||||
|
const [oidcError, setOidcError] = useState<string | null>(null);
|
||||||
|
const [oidcSuccessMessage, setOidcSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [showRemoveOidcDialog, setShowRemoveOidcDialog] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [breakglassForm, setBreakglassForm] =
|
||||||
|
useState<BreakglassFormState>(INITIAL_BREAKGLASS_FORM);
|
||||||
|
const [breakglassStatus, setBreakglassStatus] = useState<"active" | "inactive">("inactive");
|
||||||
|
const [isResettingPassword, setIsResettingPassword] = useState<boolean>(false);
|
||||||
|
const [breakglassError, setBreakglassError] = useState<string | null>(null);
|
||||||
|
const [breakglassSuccessMessage, setBreakglassSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [isAccessDenied, setIsAccessDenied] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const loadAuthSettings = useCallback(async (): Promise<void> => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [oidcResponse, onboardingStatus] = await Promise.all([
|
||||||
|
fetchFleetOidcConfig(),
|
||||||
|
fetchOnboardingStatus().catch(() => ({ completed: false })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setOidcConfig(oidcResponse);
|
||||||
|
setOidcForm({
|
||||||
|
issuerUrl: oidcResponse.issuerUrl ?? "",
|
||||||
|
clientId: oidcResponse.clientId ?? "",
|
||||||
|
clientSecret: "",
|
||||||
|
});
|
||||||
|
setBreakglassStatus(onboardingStatus.completed ? "active" : "inactive");
|
||||||
|
setIsAccessDenied(false);
|
||||||
|
setOidcError(null);
|
||||||
|
} catch (loadError: unknown) {
|
||||||
|
if (isAdminGuardError(loadError)) {
|
||||||
|
setIsAccessDenied(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOidcError(getErrorMessage(loadError, "Failed to load authentication settings."));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAuthSettings();
|
||||||
|
}, [loadAuthSettings]);
|
||||||
|
|
||||||
|
async function handleSaveOidc(event: SyntheticEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
setOidcError(null);
|
||||||
|
setOidcSuccessMessage(null);
|
||||||
|
|
||||||
|
const issuerUrl = oidcForm.issuerUrl.trim();
|
||||||
|
const clientId = oidcForm.clientId.trim();
|
||||||
|
const clientSecret = oidcForm.clientSecret.trim();
|
||||||
|
|
||||||
|
if (issuerUrl.length === 0 || clientId.length === 0 || clientSecret.length === 0) {
|
||||||
|
setOidcError("Issuer URL, client ID, and client secret are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSavingOidc(true);
|
||||||
|
await updateFleetOidcConfig({
|
||||||
|
issuerUrl,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
});
|
||||||
|
setOidcSuccessMessage("OIDC configuration updated.");
|
||||||
|
await loadAuthSettings();
|
||||||
|
} catch (saveError: unknown) {
|
||||||
|
setOidcError(getErrorMessage(saveError, "Failed to update OIDC configuration."));
|
||||||
|
} finally {
|
||||||
|
setIsSavingOidc(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveOidc(): Promise<void> {
|
||||||
|
try {
|
||||||
|
setIsDeletingOidc(true);
|
||||||
|
await deleteFleetOidcConfig();
|
||||||
|
setOidcSuccessMessage("OIDC configuration removed.");
|
||||||
|
setShowRemoveOidcDialog(false);
|
||||||
|
await loadAuthSettings();
|
||||||
|
} catch (deleteError: unknown) {
|
||||||
|
setOidcError(getErrorMessage(deleteError, "Failed to remove OIDC configuration."));
|
||||||
|
} finally {
|
||||||
|
setIsDeletingOidc(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetBreakglassPassword(event: SyntheticEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
setBreakglassError(null);
|
||||||
|
setBreakglassSuccessMessage(null);
|
||||||
|
|
||||||
|
const username = breakglassForm.username.trim();
|
||||||
|
const newPassword = breakglassForm.newPassword;
|
||||||
|
const confirmPassword = breakglassForm.confirmPassword;
|
||||||
|
|
||||||
|
if (username.length === 0) {
|
||||||
|
setBreakglassError("Username is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setBreakglassError("New password must be at least 8 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setBreakglassError("Password confirmation does not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsResettingPassword(true);
|
||||||
|
await resetBreakglassAdminPassword({
|
||||||
|
username,
|
||||||
|
newPassword,
|
||||||
|
});
|
||||||
|
setBreakglassSuccessMessage(`Password reset for "${username}".`);
|
||||||
|
setBreakglassStatus("active");
|
||||||
|
setBreakglassForm((previous) => ({
|
||||||
|
...previous,
|
||||||
|
currentPassword: "",
|
||||||
|
newPassword: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
}));
|
||||||
|
} catch (resetError: unknown) {
|
||||||
|
setBreakglassError(getErrorMessage(resetError, "Failed to reset breakglass password."));
|
||||||
|
} finally {
|
||||||
|
setIsResettingPassword(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Authentication Settings</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Configure OIDC and breakglass admin recovery credentials.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FleetSettingsNav />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-sm text-muted-foreground">
|
||||||
|
Loading authentication settings...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && isAccessDenied ? (
|
||||||
|
<SettingsAccessDenied message="Authentication settings require system administrator privileges." />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && !isAccessDenied ? (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>OIDC Provider</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage your OpenID Connect issuer and OAuth client credentials.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="rounded-lg border p-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium">Configured</p>
|
||||||
|
<Badge variant={oidcConfig?.configured ? "default" : "secondary"}>
|
||||||
|
{oidcConfig?.configured ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Issuer URL: {oidcConfig?.issuerUrl ?? "Not configured"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Client ID: {oidcConfig?.clientId ?? "Not configured"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Client secret: hidden</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={(event) => void handleSaveOidc(event)} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="oidc-issuer-url">Issuer URL</Label>
|
||||||
|
<Input
|
||||||
|
id="oidc-issuer-url"
|
||||||
|
value={oidcForm.issuerUrl}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setOidcForm((previous) => ({ ...previous, issuerUrl: event.target.value }));
|
||||||
|
}}
|
||||||
|
placeholder="https://issuer.example.com"
|
||||||
|
disabled={isSavingOidc}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="oidc-client-id">Client ID</Label>
|
||||||
|
<Input
|
||||||
|
id="oidc-client-id"
|
||||||
|
value={oidcForm.clientId}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setOidcForm((previous) => ({ ...previous, clientId: event.target.value }));
|
||||||
|
}}
|
||||||
|
placeholder="mosaic-web"
|
||||||
|
disabled={isSavingOidc}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="oidc-client-secret">Client Secret</Label>
|
||||||
|
<Input
|
||||||
|
id="oidc-client-secret"
|
||||||
|
type="password"
|
||||||
|
value={oidcForm.clientSecret}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setOidcForm((previous) => ({
|
||||||
|
...previous,
|
||||||
|
clientSecret: event.target.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="Enter new secret"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isSavingOidc}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The secret is encrypted on save and never returned to the UI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{oidcError ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{oidcError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{oidcSuccessMessage ? (
|
||||||
|
<p className="text-sm text-emerald-600">{oidcSuccessMessage}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="submit" disabled={isSavingOidc}>
|
||||||
|
{isSavingOidc ? "Saving..." : "Save OIDC"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setShowRemoveOidcDialog(true);
|
||||||
|
}}
|
||||||
|
disabled={isDeletingOidc || !oidcConfig?.configured}
|
||||||
|
>
|
||||||
|
Remove OIDC
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Breakglass Admin</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Reset breakglass credentials for emergency local access.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium">Status</p>
|
||||||
|
<Badge variant={breakglassStatus === "active" ? "default" : "secondary"}>
|
||||||
|
{breakglassStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => void handleResetBreakglassPassword(event)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="breakglass-username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="breakglass-username"
|
||||||
|
value={breakglassForm.username}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setBreakglassForm((previous) => ({
|
||||||
|
...previous,
|
||||||
|
username: event.target.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="admin"
|
||||||
|
disabled={isResettingPassword}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="breakglass-current-password">Current Password (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="breakglass-current-password"
|
||||||
|
type="password"
|
||||||
|
value={breakglassForm.currentPassword}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setBreakglassForm((previous) => ({
|
||||||
|
...previous,
|
||||||
|
currentPassword: event.target.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="Optional operator confirmation"
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={isResettingPassword}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="breakglass-new-password">New Password</Label>
|
||||||
|
<Input
|
||||||
|
id="breakglass-new-password"
|
||||||
|
type="password"
|
||||||
|
value={breakglassForm.newPassword}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setBreakglassForm((previous) => ({
|
||||||
|
...previous,
|
||||||
|
newPassword: event.target.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isResettingPassword}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="breakglass-confirm-password">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="breakglass-confirm-password"
|
||||||
|
type="password"
|
||||||
|
value={breakglassForm.confirmPassword}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setBreakglassForm((previous) => ({
|
||||||
|
...previous,
|
||||||
|
confirmPassword: event.target.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="Re-enter password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isResettingPassword}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{breakglassError ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{breakglassError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{breakglassSuccessMessage ? (
|
||||||
|
<p className="text-sm text-emerald-600">{breakglassSuccessMessage}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isResettingPassword}>
|
||||||
|
{isResettingPassword ? "Resetting..." : "Reset Password"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={showRemoveOidcDialog}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !isDeletingOidc) {
|
||||||
|
setShowRemoveOidcDialog(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove OIDC Configuration</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will remove issuer URL, client ID, and client secret from system configuration.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeletingOidc}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleRemoveOidc} disabled={isDeletingOidc}>
|
||||||
|
{isDeletingOidc ? "Removing..." : "Remove OIDC"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -200,6 +200,82 @@ const categories: CategoryConfig[] = [
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "LLM Providers",
|
||||||
|
description:
|
||||||
|
"Add and manage LLM providers, encrypted API keys, base URLs, and model inventories.",
|
||||||
|
href: "/settings/providers",
|
||||||
|
accent: "var(--ms-blue-400)",
|
||||||
|
iconBg: "rgba(47, 128, 255, 0.12)",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="2.5" y="4" width="15" height="12" rx="2" />
|
||||||
|
<path d="M2.5 8h15" />
|
||||||
|
<circle cx="6" cy="12" r="1" />
|
||||||
|
<circle cx="10" cy="12" r="1" />
|
||||||
|
<circle cx="14" cy="12" r="1" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Agent Config",
|
||||||
|
description: "Choose primary and fallback models, plus optional personality/SOUL instructions.",
|
||||||
|
href: "/settings/agent-config",
|
||||||
|
accent: "var(--ms-teal-400)",
|
||||||
|
iconBg: "rgba(20, 184, 166, 0.12)",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M4 5h12" />
|
||||||
|
<path d="M4 10h12" />
|
||||||
|
<path d="M4 15h7" />
|
||||||
|
<circle cx="14.5" cy="15" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Authentication",
|
||||||
|
description: "Manage OIDC provider settings and breakglass admin password recovery.",
|
||||||
|
href: "/settings/auth",
|
||||||
|
accent: "var(--ms-amber-400)",
|
||||||
|
iconBg: "rgba(245, 158, 11, 0.12)",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="5" y="8" width="10" height="8" rx="1.5" />
|
||||||
|
<path d="M7 8V6a3 3 0 0 1 6 0v2" />
|
||||||
|
<circle cx="10" cy="12" r="1" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Users",
|
title: "Users",
|
||||||
description: "Invite, manage roles, and deactivate users across your workspaces.",
|
description: "Invite, manage roles, and deactivate users across your workspaces.",
|
||||||
|
|||||||
634
apps/web/src/app/(authenticated)/settings/providers/page.tsx
Normal file
634
apps/web/src/app/(authenticated)/settings/providers/page.tsx
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type ReactElement,
|
||||||
|
type SyntheticEvent,
|
||||||
|
} from "react";
|
||||||
|
import { Settings, Trash2 } from "lucide-react";
|
||||||
|
import { FleetSettingsNav } from "@/components/settings/FleetSettingsNav";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
createFleetProvider,
|
||||||
|
deleteFleetProvider,
|
||||||
|
fetchFleetProviders,
|
||||||
|
updateFleetProvider,
|
||||||
|
type CreateFleetProviderRequest,
|
||||||
|
type FleetProvider,
|
||||||
|
type FleetProviderModel,
|
||||||
|
type UpdateFleetProviderRequest,
|
||||||
|
} from "@/lib/api/fleet-settings";
|
||||||
|
|
||||||
|
interface ProviderTypeOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderFormState {
|
||||||
|
type: string;
|
||||||
|
displayName: string;
|
||||||
|
apiKey: string;
|
||||||
|
baseUrl: string;
|
||||||
|
modelsText: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_TYPE_OPTIONS: ProviderTypeOption[] = [
|
||||||
|
{ value: "openai", label: "OpenAI Compatible" },
|
||||||
|
{ value: "claude", label: "Claude / Anthropic" },
|
||||||
|
{ value: "ollama", label: "Ollama" },
|
||||||
|
{ value: "zai", label: "Z.ai" },
|
||||||
|
{ value: "custom", label: "Custom" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const INITIAL_FORM: ProviderFormState = {
|
||||||
|
type: "openai",
|
||||||
|
displayName: "",
|
||||||
|
apiKey: "",
|
||||||
|
baseUrl: "",
|
||||||
|
modelsText: "",
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildProviderName(displayName: string, type: string): string {
|
||||||
|
const slug = displayName
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-+$/, "");
|
||||||
|
|
||||||
|
const candidate = `${type}-${slug.length > 0 ? slug : "provider"}`;
|
||||||
|
return candidate.slice(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error && error.message.trim().length > 0) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProviderModels(models: unknown): FleetProviderModel[] {
|
||||||
|
if (!Array.isArray(models)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: FleetProviderModel[] = [];
|
||||||
|
|
||||||
|
models.forEach((entry) => {
|
||||||
|
if (typeof entry === "string" && entry.trim().length > 0) {
|
||||||
|
normalized.push({ id: entry.trim(), name: entry.trim() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry && typeof entry === "object") {
|
||||||
|
const record = entry as Record<string, unknown>;
|
||||||
|
const id =
|
||||||
|
typeof record.id === "string"
|
||||||
|
? record.id.trim()
|
||||||
|
: typeof record.name === "string"
|
||||||
|
? record.name.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (id.length > 0) {
|
||||||
|
const name =
|
||||||
|
typeof record.name === "string" && record.name.trim().length > 0
|
||||||
|
? record.name.trim()
|
||||||
|
: id;
|
||||||
|
normalized.push({ id, name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return normalized.filter((model) => {
|
||||||
|
if (seen.has(model.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(model.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelsToEditorText(models: unknown): string {
|
||||||
|
return normalizeProviderModels(models)
|
||||||
|
.map((model) => model.id)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseModelsText(value: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(/\r?\n/g)
|
||||||
|
.map((segment) => segment.trim())
|
||||||
|
.filter((segment) => segment.length > 0)
|
||||||
|
.filter((segment) => {
|
||||||
|
if (seen.has(segment)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(segment);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskApiKey(value: string): string {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return "Not set";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length <= 7) {
|
||||||
|
return "*".repeat(Math.max(4, value.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.slice(0, 3)}****...${value.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProvidersSettingsPage(): ReactElement {
|
||||||
|
const [providers, setProviders] = useState<FleetProvider[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||||
|
const [editingProvider, setEditingProvider] = useState<FleetProvider | null>(null);
|
||||||
|
const [form, setForm] = useState<ProviderFormState>(INITIAL_FORM);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<FleetProvider | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const loadProviders = useCallback(async (showLoadingState: boolean): Promise<void> => {
|
||||||
|
if (showLoadingState) {
|
||||||
|
setIsLoading(true);
|
||||||
|
} else {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchFleetProviders();
|
||||||
|
setProviders(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (loadError: unknown) {
|
||||||
|
setError(getErrorMessage(loadError, "Failed to load providers."));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadProviders(true);
|
||||||
|
}, [loadProviders]);
|
||||||
|
|
||||||
|
const apiKeyHint = useMemo(() => {
|
||||||
|
const enteredKey = form.apiKey.trim();
|
||||||
|
|
||||||
|
if (enteredKey.length > 0) {
|
||||||
|
return `Masked preview: ${maskApiKey(enteredKey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingProvider) {
|
||||||
|
return "Stored API key remains encrypted and hidden. Enter a new key only when rotating.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "API keys are never shown decrypted. Only masked previews are displayed while typing.";
|
||||||
|
}, [editingProvider, form.apiKey]);
|
||||||
|
|
||||||
|
function openCreateDialog(): void {
|
||||||
|
setEditingProvider(null);
|
||||||
|
setForm(INITIAL_FORM);
|
||||||
|
setFormError(null);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(provider: FleetProvider): void {
|
||||||
|
setEditingProvider(provider);
|
||||||
|
setForm({
|
||||||
|
type: provider.type,
|
||||||
|
displayName: provider.displayName,
|
||||||
|
apiKey: "",
|
||||||
|
baseUrl: provider.baseUrl ?? "",
|
||||||
|
modelsText: modelsToEditorText(provider.models),
|
||||||
|
isActive: provider.isActive,
|
||||||
|
});
|
||||||
|
setFormError(null);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog(): void {
|
||||||
|
if (isSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setEditingProvider(null);
|
||||||
|
setForm(INITIAL_FORM);
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: SyntheticEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
|
||||||
|
const displayName = form.displayName.trim();
|
||||||
|
if (displayName.length === 0) {
|
||||||
|
setFormError("Display name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = parseModelsText(form.modelsText);
|
||||||
|
const providerModels = models.map((id) => ({ id, name: id }));
|
||||||
|
const baseUrl = form.baseUrl.trim();
|
||||||
|
const apiKey = form.apiKey.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
if (editingProvider) {
|
||||||
|
const updatePayload: UpdateFleetProviderRequest = {
|
||||||
|
displayName,
|
||||||
|
isActive: form.isActive,
|
||||||
|
models: providerModels,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (baseUrl.length > 0) {
|
||||||
|
updatePayload.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey.length > 0) {
|
||||||
|
updatePayload.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateFleetProvider(editingProvider.id, updatePayload);
|
||||||
|
setSuccessMessage(`Updated provider "${displayName}".`);
|
||||||
|
} else {
|
||||||
|
const createPayload: CreateFleetProviderRequest = {
|
||||||
|
name: buildProviderName(displayName, form.type),
|
||||||
|
displayName,
|
||||||
|
type: form.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (baseUrl.length > 0) {
|
||||||
|
createPayload.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey.length > 0) {
|
||||||
|
createPayload.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerModels.length > 0) {
|
||||||
|
createPayload.models = providerModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createFleetProvider(createPayload);
|
||||||
|
setSuccessMessage(`Added provider "${displayName}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setEditingProvider(null);
|
||||||
|
setForm(INITIAL_FORM);
|
||||||
|
await loadProviders(false);
|
||||||
|
} catch (saveError: unknown) {
|
||||||
|
setFormError(getErrorMessage(saveError, "Unable to save provider."));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteProvider(): Promise<void> {
|
||||||
|
if (!deleteTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsDeleting(true);
|
||||||
|
await deleteFleetProvider(deleteTarget.id);
|
||||||
|
setSuccessMessage(`Deleted provider "${deleteTarget.displayName}".`);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await loadProviders(false);
|
||||||
|
} catch (deleteError: unknown) {
|
||||||
|
setError(getErrorMessage(deleteError, "Failed to delete provider."));
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">LLM Providers</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Manage provider endpoints, model inventories, and encrypted API credentials.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FleetSettingsNav />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Provider Directory</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
API keys are always encrypted in storage and never displayed in plaintext.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
void loadProviders(false);
|
||||||
|
}}
|
||||||
|
disabled={isLoading || isRefreshing}
|
||||||
|
>
|
||||||
|
{isRefreshing ? "Refreshing..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={openCreateDialog}>Add Provider</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{successMessage ? <p className="text-sm text-emerald-600">{successMessage}</p> : null}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading providers...</p>
|
||||||
|
) : providers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No providers configured yet. Add one to make models available for agent assignment.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
providers.map((provider) => {
|
||||||
|
const providerModels = normalizeProviderModels(provider.models);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={provider.id}
|
||||||
|
className="rounded-lg border p-4 flex flex-col gap-4 md:flex-row md:items-start md:justify-between"
|
||||||
|
>
|
||||||
|
<div className="space-y-2 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="font-semibold truncate">{provider.displayName}</p>
|
||||||
|
<Badge variant={provider.isActive ? "default" : "secondary"}>
|
||||||
|
{provider.isActive ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">{provider.type}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Name: {provider.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Base URL: {provider.baseUrl ?? "Provider default"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
API Key: encrypted and hidden (never returned decrypted)
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{providerModels.length === 0 ? (
|
||||||
|
<Badge variant="secondary">No models configured</Badge>
|
||||||
|
) : (
|
||||||
|
providerModels.map((model) => (
|
||||||
|
<Badge key={`${provider.id}-${model.id}`} variant="outline">
|
||||||
|
{model.id}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
openEditDialog(provider);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTarget(provider);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
closeDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingProvider ? "Edit Provider" : "Add Provider"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure connection details and model IDs. API keys are masked in the UI.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={(event) => void handleSubmit(event)} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="provider-type">Type</Label>
|
||||||
|
<Select
|
||||||
|
value={form.type}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setForm((previous) => ({ ...previous, type: value }));
|
||||||
|
}}
|
||||||
|
disabled={Boolean(editingProvider)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="provider-type">
|
||||||
|
<SelectValue placeholder="Select provider type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROVIDER_TYPE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="provider-display-name">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="provider-display-name"
|
||||||
|
value={form.displayName}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setForm((previous) => ({ ...previous, displayName: event.target.value }));
|
||||||
|
}}
|
||||||
|
placeholder="OpenAI Primary"
|
||||||
|
maxLength={255}
|
||||||
|
disabled={isSaving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="provider-api-key">API Key</Label>
|
||||||
|
<Input
|
||||||
|
id="provider-api-key"
|
||||||
|
type="password"
|
||||||
|
value={form.apiKey}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setForm((previous) => ({ ...previous, apiKey: event.target.value }));
|
||||||
|
}}
|
||||||
|
placeholder={editingProvider ? "Enter new key to rotate" : "sk-..."}
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{apiKeyHint}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="provider-base-url">Base URL</Label>
|
||||||
|
<Input
|
||||||
|
id="provider-base-url"
|
||||||
|
value={form.baseUrl}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setForm((previous) => ({ ...previous, baseUrl: event.target.value }));
|
||||||
|
}}
|
||||||
|
placeholder="https://api.provider.com/v1"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="provider-models">Models</Label>
|
||||||
|
<Textarea
|
||||||
|
id="provider-models"
|
||||||
|
value={form.modelsText}
|
||||||
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setForm((previous) => ({ ...previous, modelsText: event.target.value }));
|
||||||
|
}}
|
||||||
|
placeholder={"One model ID per line\nExample: gpt-4.1-mini"}
|
||||||
|
rows={5}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingProvider ? (
|
||||||
|
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="provider-active">Provider Status</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Disable to keep configuration without using this provider.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="provider-active"
|
||||||
|
checked={form.isActive}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setForm((previous) => ({ ...previous, isActive: checked }));
|
||||||
|
}}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{formError ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={closeDialog} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSaving}>
|
||||||
|
{isSaving ? "Saving..." : editingProvider ? "Save Changes" : "Create Provider"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !isDeleting) {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Provider</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Delete provider "{deleteTarget?.displayName}"? This removes its configuration and
|
||||||
|
model mappings.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDeleteProvider} disabled={isDeleting}>
|
||||||
|
{isDeleting ? "Deleting..." : "Delete Provider"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/web/src/app/onboarding/layout.tsx
Normal file
9
apps/web/src/app/onboarding/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export default function OnboardingLayout({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-gradient-to-b from-slate-50 to-white p-4 sm:p-6">
|
||||||
|
<div className="w-full max-w-3xl">{children}</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
apps/web/src/app/onboarding/page.tsx
Normal file
36
apps/web/src/app/onboarding/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
|
||||||
|
import { API_BASE_URL } from "@/lib/config";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
interface OnboardingStatusResponse {
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOnboardingStatus(): Promise<OnboardingStatusResponse> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/onboarding/status`, {
|
||||||
|
method: "GET",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { completed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as OnboardingStatusResponse;
|
||||||
|
} catch {
|
||||||
|
return { completed: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OnboardingPage(): Promise<React.JSX.Element> {
|
||||||
|
const status = await getOnboardingStatus();
|
||||||
|
|
||||||
|
if (status.completed) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <OnboardingWizard />;
|
||||||
|
}
|
||||||
128
apps/web/src/components/chat/AgentSelector.tsx
Normal file
128
apps/web/src/components/chat/AgentSelector.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface AgentSelectorProps {
|
||||||
|
selectedAgent?: string | null;
|
||||||
|
onChange?: (agent: string | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_CONFIG = {
|
||||||
|
jarvis: {
|
||||||
|
displayName: "Jarvis",
|
||||||
|
role: "Orchestrator",
|
||||||
|
color: "#3498db",
|
||||||
|
},
|
||||||
|
builder: {
|
||||||
|
displayName: "Builder",
|
||||||
|
role: "Coding Agent",
|
||||||
|
color: "#3b82f6",
|
||||||
|
},
|
||||||
|
medic: {
|
||||||
|
displayName: "Medic",
|
||||||
|
role: "Health Monitor",
|
||||||
|
color: "#10b981",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function JarvisIcon({ className }: { className?: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 ${className ?? ""}`}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 2v4M12 22v-4" />
|
||||||
|
<path d="M2 12h4M22 12h-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BuilderIcon({ className }: { className?: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 ${className ?? ""}`}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
>
|
||||||
|
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MedicIcon({ className }: { className?: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 ${className ?? ""}`}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
>
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_ICONS: Record<string, React.FC<{ className?: string }>> = {
|
||||||
|
jarvis: JarvisIcon,
|
||||||
|
builder: BuilderIcon,
|
||||||
|
medic: MedicIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgentSelector({
|
||||||
|
selectedAgent,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: AgentSelectorProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium" style={{ color: "rgb(var(--text-muted))" }}>
|
||||||
|
Agent
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Object.entries(AGENT_CONFIG).map(([name, config]) => {
|
||||||
|
const Icon = AGENT_ICONS[name];
|
||||||
|
const isSelected = selectedAgent === name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange?.(isSelected ? null : name)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg border transition-all text-xs ${
|
||||||
|
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
|
||||||
|
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||||
|
style={{
|
||||||
|
borderColor: isSelected
|
||||||
|
? "rgb(var(--accent-primary))"
|
||||||
|
: "rgb(var(--border-default))",
|
||||||
|
color: isSelected ? "rgb(var(--accent-primary))" : "rgb(var(--text-primary))",
|
||||||
|
}}
|
||||||
|
title={`${config.displayName} — ${config.role}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.color,
|
||||||
|
width: "8px",
|
||||||
|
height: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Icon && <Icon />}
|
||||||
|
<span className="font-medium">{config.displayName}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { useWorkspaceId } from "@/lib/hooks";
|
|||||||
import { MessageList } from "./MessageList";
|
import { MessageList } from "./MessageList";
|
||||||
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
|
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
|
||||||
import { ChatEmptyState } from "./ChatEmptyState";
|
import { ChatEmptyState } from "./ChatEmptyState";
|
||||||
|
import { AgentSelector } from "./AgentSelector";
|
||||||
import type { Message } from "@/hooks/useChat";
|
import type { Message } from "@/hooks/useChat";
|
||||||
|
|
||||||
export interface ChatRef {
|
export interface ChatRef {
|
||||||
@@ -66,6 +67,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2");
|
const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2");
|
||||||
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
|
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
|
||||||
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
|
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
||||||
|
|
||||||
// Suggestion fill value: controls ChatInput's textarea content
|
// Suggestion fill value: controls ChatInput's textarea content
|
||||||
const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined);
|
const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined);
|
||||||
@@ -88,6 +90,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
temperature,
|
temperature,
|
||||||
maxTokens,
|
maxTokens,
|
||||||
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
||||||
|
...(selectedAgent !== null && { agent: selectedAgent }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read workspace ID from localStorage (set by auth-context after session check).
|
// Read workspace ID from localStorage (set by auth-context after session check).
|
||||||
@@ -342,6 +345,31 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
|
{!user && (
|
||||||
|
<div className="mx-4 mb-2 lg:mx-auto lg:max-w-4xl lg:px-8">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-2 rounded-lg border px-4 py-3 text-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-1))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
style={{ color: "rgb(var(--text-secondary))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||||
|
Sign in to chat with Jarvis
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="sticky bottom-0 border-t"
|
className="sticky bottom-0 border-t"
|
||||||
style={{
|
style={{
|
||||||
@@ -350,6 +378,13 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
|
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
|
||||||
|
<div className="mb-3">
|
||||||
|
<AgentSelector
|
||||||
|
selectedAgent={selectedAgent}
|
||||||
|
onChange={setSelectedAgent}
|
||||||
|
disabled={isChatLoading || isStreaming || !user}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
disabled={isChatLoading || !user}
|
disabled={isChatLoading || !user}
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export function ChatOverlay(): React.JSX.Element {
|
|||||||
onClick={open}
|
onClick={open}
|
||||||
className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 lg:bottom-8 lg:right-8"
|
className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 lg:bottom-8 lg:right-8"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "rgb(var(--accent-primary))",
|
backgroundColor: "var(--accent-primary, #10b981)",
|
||||||
color: "rgb(var(--text-on-accent))",
|
color: "var(--text-on-accent, #ffffff)",
|
||||||
}}
|
}}
|
||||||
aria-label="Open chat"
|
aria-label="Open chat"
|
||||||
title="Open Jarvis chat (Cmd+Shift+J)"
|
title="Open Jarvis chat (Cmd+Shift+J)"
|
||||||
@@ -78,18 +78,18 @@ export function ChatOverlay(): React.JSX.Element {
|
|||||||
if (isMinimized) {
|
if (isMinimized) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed bottom-0 right-0 z-40 w-full sm:w-96"
|
className="fixed bottom-0 right-0 z-40 w-full shadow-2xl sm:w-96"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "rgb(var(--surface-0))",
|
backgroundColor: "var(--surface-0, #ffffff)",
|
||||||
borderColor: "rgb(var(--border-default))",
|
borderColor: "var(--border-default, #e5e7eb)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={expand}
|
onClick={expand}
|
||||||
className="flex w-full items-center justify-between border-t px-4 py-3 text-left transition-colors hover:bg-black/5 focus:outline-none focus:ring-2 focus:ring-inset"
|
className="flex w-full items-center justify-between border-t px-4 py-3 text-left transition-colors hover:bg-black/5 focus:outline-none focus:ring-2 focus:ring-inset"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "rgb(var(--border-default))",
|
borderColor: "var(--border-default, #e5e7eb)",
|
||||||
backgroundColor: "rgb(var(--surface-0))",
|
backgroundColor: "var(--surface-0, #ffffff)",
|
||||||
}}
|
}}
|
||||||
aria-label="Expand chat"
|
aria-label="Expand chat"
|
||||||
>
|
>
|
||||||
@@ -135,10 +135,10 @@ export function ChatOverlay(): React.JSX.Element {
|
|||||||
|
|
||||||
{/* Chat Panel */}
|
{/* Chat Panel */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-y-0 right-0 z-40 flex w-full flex-col border-l sm:w-96 lg:inset-y-16"
|
className="fixed inset-y-0 right-0 z-40 flex w-full flex-col border-l shadow-2xl sm:w-96 lg:inset-y-16"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "rgb(var(--surface-0))",
|
backgroundColor: "var(--surface-0, #ffffff)",
|
||||||
borderColor: "rgb(var(--border-default))",
|
borderColor: "var(--border-default, #e5e7eb)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
import { UsageWidget } from "@/components/ui/UsageWidget";
|
||||||
import { useSidebar } from "./SidebarContext";
|
import { useSidebar } from "./SidebarContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -350,6 +351,9 @@ export function AppHeader(): React.JSX.Element {
|
|||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
|
{/* Usage Widget */}
|
||||||
|
<UsageWidget />
|
||||||
|
|
||||||
{/* User Avatar + Dropdown */}
|
{/* User Avatar + Dropdown */}
|
||||||
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
|
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
106
apps/web/src/components/onboarding/OnboardingWizard.test.tsx
Normal file
106
apps/web/src/components/onboarding/OnboardingWizard.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { OnboardingWizard } from "./OnboardingWizard";
|
||||||
|
|
||||||
|
const mockPush = vi.fn();
|
||||||
|
const mockGetStatus = vi.fn();
|
||||||
|
const mockCreateBreakglass = vi.fn();
|
||||||
|
const mockConfigureOidc = vi.fn();
|
||||||
|
const mockTestProvider = vi.fn();
|
||||||
|
const mockAddProvider = vi.fn();
|
||||||
|
const mockCompleteOnboarding = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: (): { push: typeof mockPush } => ({
|
||||||
|
push: mockPush,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/onboarding", () => ({
|
||||||
|
fetchOnboardingStatus: (): ReturnType<typeof mockGetStatus> => mockGetStatus(),
|
||||||
|
createBreakglassAdmin: (...args: unknown[]): ReturnType<typeof mockCreateBreakglass> =>
|
||||||
|
mockCreateBreakglass(...args),
|
||||||
|
configureOidcProvider: (...args: unknown[]): ReturnType<typeof mockConfigureOidc> =>
|
||||||
|
mockConfigureOidc(...args),
|
||||||
|
testOnboardingProvider: (...args: unknown[]): ReturnType<typeof mockTestProvider> =>
|
||||||
|
mockTestProvider(...args),
|
||||||
|
addOnboardingProvider: (...args: unknown[]): ReturnType<typeof mockAddProvider> =>
|
||||||
|
mockAddProvider(...args),
|
||||||
|
completeOnboarding: (): ReturnType<typeof mockCompleteOnboarding> => mockCompleteOnboarding(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("OnboardingWizard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPush.mockReset();
|
||||||
|
mockGetStatus.mockReset();
|
||||||
|
mockCreateBreakglass.mockReset();
|
||||||
|
mockConfigureOidc.mockReset();
|
||||||
|
mockTestProvider.mockReset();
|
||||||
|
mockAddProvider.mockReset();
|
||||||
|
mockCompleteOnboarding.mockReset();
|
||||||
|
|
||||||
|
mockGetStatus.mockResolvedValue({ completed: false });
|
||||||
|
mockCreateBreakglass.mockResolvedValue({ id: "bg-1", username: "admin" });
|
||||||
|
mockConfigureOidc.mockResolvedValue(undefined);
|
||||||
|
mockTestProvider.mockResolvedValue({ success: true });
|
||||||
|
mockAddProvider.mockResolvedValue({ id: "provider-1" });
|
||||||
|
mockCompleteOnboarding.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the first step with admin setup fields", async () => {
|
||||||
|
render(<OnboardingWizard />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Welcome to Mosaic Stack. Let's get you set up.")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Username")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Password")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Confirm Password")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1. Admin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates admin form fields before submit", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<OnboardingWizard />);
|
||||||
|
|
||||||
|
await screen.findByText("Welcome to Mosaic Stack. Let's get you set up.");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create Admin" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Username must be at least 3 characters.")).toBeInTheDocument();
|
||||||
|
expect(mockCreateBreakglass).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports happy path with OIDC skipped", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<OnboardingWizard />);
|
||||||
|
|
||||||
|
await screen.findByText("Welcome to Mosaic Stack. Let's get you set up.");
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Username"), "admin");
|
||||||
|
await user.type(screen.getByLabelText("Password"), "verysecurepassword");
|
||||||
|
await user.type(screen.getByLabelText("Confirm Password"), "verysecurepassword");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create Admin" }));
|
||||||
|
|
||||||
|
await screen.findByText("Configure OIDC Provider (Optional)");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Skip" }));
|
||||||
|
|
||||||
|
await screen.findByText("Add Your First LLM Provider");
|
||||||
|
await user.type(screen.getByLabelText("Display Name"), "My OpenAI");
|
||||||
|
await user.type(screen.getByLabelText("API Key"), "sk-test-key");
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Test Connection" }));
|
||||||
|
await screen.findByText("Connection successful.");
|
||||||
|
|
||||||
|
const addProviderButton = screen.getByRole("button", { name: "Add Provider" });
|
||||||
|
expect(addProviderButton).toBeEnabled();
|
||||||
|
await user.click(addProviderButton);
|
||||||
|
|
||||||
|
await screen.findByText("You're all set");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Launch Mosaic Stack" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPush).toHaveBeenCalledWith("/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user