Compare commits
55 Commits
feat/custo
...
feat/ms22-
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cea212a34 | |||
| 619b31e105 | |||
| c53a4a4c7d | |||
| 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 |
@@ -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
|
||||||
|
|||||||
@@ -3,16 +3,25 @@ when:
|
|||||||
- event: cron
|
- event: cron
|
||||||
cron: weekly-base-image
|
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:
|
steps:
|
||||||
build-base:
|
build-base:
|
||||||
image: woodpeckerci/plugin-docker-buildx:latest
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
privileged: true
|
environment:
|
||||||
settings:
|
GITEA_USER:
|
||||||
registry: git.mosaicstack.dev
|
from_secret: gitea_username
|
||||||
repo: git.mosaicstack.dev/mosaic/node-base
|
GITEA_TOKEN:
|
||||||
tags: 24-slim
|
|
||||||
dockerfile: docker/base.Dockerfile
|
|
||||||
username:
|
|
||||||
from_secret: gitea_user
|
|
||||||
password:
|
|
||||||
from_secret: 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,10 @@ 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 config set store-dir /root/.local/share/pnpm/store
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
- &use_deps |
|
- &use_deps |
|
||||||
@@ -169,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]
|
||||||
@@ -194,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]
|
||||||
@@ -219,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]
|
||||||
@@ -336,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -1703,3 +1703,39 @@ model UserAgentConfig {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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) {}
|
||||||
@@ -48,6 +48,8 @@ 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";
|
||||||
@@ -129,6 +131,8 @@ import { OrchestratorModule } from "./orchestrator/orchestrator.module";
|
|||||||
PersonalitiesModule,
|
PersonalitiesModule,
|
||||||
WorkspacesModule,
|
WorkspacesModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
AgentTemplateModule,
|
||||||
|
UserAgentModule,
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
ConversationArchiveModule,
|
ConversationArchiveModule,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,79 @@
|
|||||||
import {
|
import { Body, Controller, HttpException, Logger, Post, Req, Res, UseGuards } from "@nestjs/common";
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
HttpException,
|
|
||||||
Logger,
|
|
||||||
Post,
|
|
||||||
Req,
|
|
||||||
Res,
|
|
||||||
UnauthorizedException,
|
|
||||||
UseGuards,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
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 type { MaybeAuthenticatedRequest } from "../auth/types/better-auth-request.interface";
|
||||||
import { ChatStreamDto } from "./chat-proxy.dto";
|
import { ChatStreamDto } from "./chat-proxy.dto";
|
||||||
import { ChatProxyService } from "./chat-proxy.service";
|
import { ChatProxyService } from "./chat-proxy.service";
|
||||||
|
|
||||||
@Controller("chat")
|
@Controller("chat")
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class ChatProxyController {
|
export class ChatProxyController {
|
||||||
private readonly logger = new Logger(ChatProxyController.name);
|
private readonly logger = new Logger(ChatProxyController.name);
|
||||||
|
|
||||||
constructor(private readonly chatProxyService: ChatProxyService) {}
|
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
|
// POST /api/chat/stream
|
||||||
// Request: { messages: Array<{role, content}> }
|
// Request: { messages: Array<{role, content}> }
|
||||||
// Response: SSE stream of chat completion events
|
// Response: SSE stream of chat completion events
|
||||||
|
// Requires authentication - uses user's personal OpenClaw container
|
||||||
@Post("stream")
|
@Post("stream")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
async streamChat(
|
async streamChat(
|
||||||
@Body() body: ChatStreamDto,
|
@Body() body: ChatStreamDto,
|
||||||
@Req() req: MaybeAuthenticatedRequest,
|
@Req() req: MaybeAuthenticatedRequest,
|
||||||
@@ -33,7 +81,8 @@ export class ChatProxyController {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new UnauthorizedException("No authenticated user found on request");
|
this.logger.warn("streamChat called without user ID after AuthGuard");
|
||||||
|
throw new HttpException("Authentication required", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
@@ -50,7 +99,8 @@ export class ChatProxyController {
|
|||||||
const upstreamResponse = await this.chatProxyService.proxyChat(
|
const upstreamResponse = await this.chatProxyService.proxyChat(
|
||||||
userId,
|
userId,
|
||||||
body.messages,
|
body.messages,
|
||||||
abortController.signal
|
abortController.signal,
|
||||||
|
body.agent
|
||||||
);
|
);
|
||||||
|
|
||||||
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { ArrayMinSize, IsArray, IsNotEmpty, IsString, ValidateNested } from "class-validator";
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: string;
|
role: string;
|
||||||
@@ -22,4 +29,8 @@ export class ChatStreamDto {
|
|||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => ChatMessageDto)
|
@Type(() => ChatMessageDto)
|
||||||
messages!: ChatMessageDto[];
|
messages!: ChatMessageDto[];
|
||||||
|
|
||||||
|
@IsString({ message: "agent must be a string" })
|
||||||
|
@IsOptional()
|
||||||
|
agent?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { AgentConfigModule } from "../agent-config/agent-config.module";
|
import { AgentConfigModule } from "../agent-config/agent-config.module";
|
||||||
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
|
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
|
||||||
@@ -7,7 +8,7 @@ import { ChatProxyController } from "./chat-proxy.controller";
|
|||||||
import { ChatProxyService } from "./chat-proxy.service";
|
import { ChatProxyService } from "./chat-proxy.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule],
|
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule, ConfigModule],
|
||||||
controllers: [ChatProxyController],
|
controllers: [ChatProxyController],
|
||||||
providers: [ChatProxyService],
|
providers: [ChatProxyService],
|
||||||
exports: [ChatProxyService],
|
exports: [ChatProxyService],
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { ServiceUnavailableException } from "@nestjs/common";
|
import {
|
||||||
|
ServiceUnavailableException,
|
||||||
|
NotFoundException,
|
||||||
|
BadGatewayException,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ChatProxyService } from "./chat-proxy.service";
|
import { ChatProxyService } from "./chat-proxy.service";
|
||||||
|
|
||||||
@@ -9,6 +13,9 @@ describe("ChatProxyService", () => {
|
|||||||
userAgentConfig: {
|
userAgentConfig: {
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
},
|
},
|
||||||
|
userAgent: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerLifecycle = {
|
const containerLifecycle = {
|
||||||
@@ -16,13 +23,17 @@ describe("ChatProxyService", () => {
|
|||||||
touch: vi.fn(),
|
touch: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
get: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
let service: ChatProxyService;
|
let service: ChatProxyService;
|
||||||
let fetchMock: ReturnType<typeof vi.fn>;
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchMock = vi.fn();
|
fetchMock = vi.fn();
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
service = new ChatProxyService(prisma as never, containerLifecycle as never);
|
service = new ChatProxyService(prisma as never, containerLifecycle as never, config as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -105,4 +116,135 @@ describe("ChatProxyService", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,26 +2,38 @@ import {
|
|||||||
BadGatewayException,
|
BadGatewayException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
|
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import type { ChatMessage } from "./chat-proxy.dto";
|
import type { ChatMessage } from "./chat-proxy.dto";
|
||||||
|
|
||||||
const DEFAULT_OPENCLAW_MODEL = "openclaw:default";
|
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 {
|
interface ContainerConnection {
|
||||||
url: string;
|
url: string;
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AgentConfig {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
personality: string;
|
||||||
|
primaryModel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ChatProxyService {
|
export class ChatProxyService {
|
||||||
private readonly logger = new Logger(ChatProxyService.name);
|
private readonly logger = new Logger(ChatProxyService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly containerLifecycle: ContainerLifecycleService
|
private readonly containerLifecycle: ContainerLifecycleService,
|
||||||
|
private readonly config: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Get the user's OpenClaw container URL and mark it active.
|
// Get the user's OpenClaw container URL and mark it active.
|
||||||
@@ -34,21 +46,38 @@ export class ChatProxyService {
|
|||||||
async proxyChat(
|
async proxyChat(
|
||||||
userId: string,
|
userId: string,
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
agentName?: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId);
|
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId);
|
||||||
const model = await this.getPreferredModel(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 = {
|
const requestInit: RequestInit = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${gatewayToken}`,
|
Authorization: `Bearer ${gatewayToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(requestBody),
|
||||||
messages,
|
|
||||||
model,
|
|
||||||
stream: true,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (signal) {
|
if (signal) {
|
||||||
@@ -79,6 +108,65 @@ export class ChatProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
private async getContainerConnection(userId: string): Promise<ContainerConnection> {
|
||||||
const connection = await this.containerLifecycle.ensureRunning(userId);
|
const connection = await this.containerLifecycle.ensureRunning(userId);
|
||||||
await this.containerLifecycle.touch(userId);
|
await this.containerLifecycle.touch(userId);
|
||||||
@@ -107,4 +195,32 @@ export class ChatProxyService {
|
|||||||
return null;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Res, UseGuards } from "@nestjs/common";
|
import { Controller, Get, Query, Res, UseGuards } from "@nestjs/common";
|
||||||
import { AgentStatus } from "@prisma/client";
|
import { AgentStatus } from "@prisma/client";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
@@ -6,6 +6,7 @@ import { PrismaService } from "../prisma/prisma.service";
|
|||||||
|
|
||||||
const AGENT_POLL_INTERVAL_MS = 5_000;
|
const AGENT_POLL_INTERVAL_MS = 5_000;
|
||||||
const SSE_HEARTBEAT_MS = 15_000;
|
const SSE_HEARTBEAT_MS = 15_000;
|
||||||
|
const DEFAULT_EVENTS_LIMIT = 25;
|
||||||
|
|
||||||
interface OrchestratorAgentDto {
|
interface OrchestratorAgentDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,6 +16,26 @@ interface OrchestratorAgentDto {
|
|||||||
createdAt: Date;
|
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")
|
@Controller("orchestrator")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class OrchestratorController {
|
export class OrchestratorController {
|
||||||
@@ -25,6 +46,81 @@ export class OrchestratorController {
|
|||||||
return this.fetchActiveAgents();
|
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")
|
@Get("events")
|
||||||
async streamEvents(@Res() res: Response): Promise<void> {
|
async streamEvents(@Res() res: Response): Promise<void> {
|
||||||
res.setHeader("Content-Type", "text/event-stream");
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
|||||||
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(", "));
|
||||||
|
}
|
||||||
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
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 */}
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ interface Agent {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWorking(status: string): boolean {
|
||||||
|
const s = status.toLowerCase();
|
||||||
|
return s === "running" || s === "working";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdle(status: string): boolean {
|
||||||
|
const s = status.toLowerCase();
|
||||||
|
return s === "idle" || s === "spawning" || s === "waiting" || s === "queued";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isErrored(status: string): boolean {
|
||||||
|
const s = status.toLowerCase();
|
||||||
|
return s === "failed" || s === "error";
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -74,25 +89,20 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
|||||||
}, [fetchAgents]);
|
}, [fetchAgents]);
|
||||||
|
|
||||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||||
const statusLower = status.toLowerCase();
|
if (isWorking(status)) {
|
||||||
switch (statusLower) {
|
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
|
||||||
case "running":
|
|
||||||
case "working":
|
|
||||||
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
|
|
||||||
case "spawning":
|
|
||||||
case "queued":
|
|
||||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
|
||||||
case "completed":
|
|
||||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
|
||||||
case "failed":
|
|
||||||
case "error":
|
|
||||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
|
||||||
case "terminated":
|
|
||||||
case "killed":
|
|
||||||
return <CheckCircle className="w-4 h-4 text-gray-500" />;
|
|
||||||
default:
|
|
||||||
return <Clock className="w-4 h-4 text-gray-400" />;
|
|
||||||
}
|
}
|
||||||
|
if (isIdle(status)) {
|
||||||
|
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||||
|
}
|
||||||
|
if (isErrored(status)) {
|
||||||
|
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||||
|
}
|
||||||
|
const s = status.toLowerCase();
|
||||||
|
if (s === "completed" || s === "terminated" || s === "killed") {
|
||||||
|
return <CheckCircle className="w-4 h-4 text-gray-500" />;
|
||||||
|
}
|
||||||
|
return <Clock className="w-4 h-4 text-gray-400" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusText = (status: string): string => {
|
const getStatusText = (status: string): string => {
|
||||||
@@ -121,9 +131,9 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
|||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: agents.length,
|
total: agents.length,
|
||||||
working: agents.filter((a) => a.status.toLowerCase() === "running").length,
|
working: agents.filter((a) => isWorking(a.status)).length,
|
||||||
idle: agents.filter((a) => a.status.toLowerCase() === "spawning").length,
|
idle: agents.filter((a) => isIdle(a.status)).length,
|
||||||
error: agents.filter((a) => a.status.toLowerCase() === "failed").length,
|
error: agents.filter((a) => isErrored(a.status)).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -176,9 +186,9 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
|||||||
<div
|
<div
|
||||||
key={agent.agentId}
|
key={agent.agentId}
|
||||||
className={`p-3 rounded-lg border ${
|
className={`p-3 rounded-lg border ${
|
||||||
agent.status.toLowerCase() === "failed"
|
isErrored(agent.status)
|
||||||
? "bg-red-50 border-red-200"
|
? "bg-red-50 border-red-200"
|
||||||
: agent.status.toLowerCase() === "running"
|
: isWorking(agent.status)
|
||||||
? "bg-blue-50 border-blue-200"
|
? "bg-blue-50 border-blue-200"
|
||||||
: "bg-gray-50 border-gray-200"
|
: "bg-gray-50 border-gray-200"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -4,61 +4,43 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Calendar as CalendarIcon, Clock, MapPin } from "lucide-react";
|
import { Calendar as CalendarIcon, Clock, MapPin } from "lucide-react";
|
||||||
import type { WidgetProps } from "@mosaic/shared";
|
import type { WidgetProps, Event } from "@mosaic/shared";
|
||||||
|
import { fetchEvents } from "@/lib/api/events";
|
||||||
interface Event {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
startTime: string;
|
|
||||||
endTime?: string;
|
|
||||||
location?: string;
|
|
||||||
allDay: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Mock data for now - will fetch from API later
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
let isMounted = true;
|
||||||
const now = new Date();
|
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
||||||
const tomorrow = new Date(today);
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
const loadEvents = async (): Promise<void> => {
|
||||||
setEvents([
|
setIsLoading(true);
|
||||||
{
|
try {
|
||||||
id: "1",
|
const data = await fetchEvents();
|
||||||
title: "Team Standup",
|
if (isMounted) {
|
||||||
startTime: new Date(today.setHours(9, 0, 0, 0)).toISOString(),
|
setEvents(data);
|
||||||
endTime: new Date(today.setHours(9, 30, 0, 0)).toISOString(),
|
}
|
||||||
location: "Zoom",
|
} catch {
|
||||||
allDay: false,
|
if (isMounted) {
|
||||||
},
|
setEvents([]);
|
||||||
{
|
}
|
||||||
id: "2",
|
} finally {
|
||||||
title: "Project Review",
|
if (isMounted) {
|
||||||
startTime: new Date(today.setHours(14, 0, 0, 0)).toISOString(),
|
setIsLoading(false);
|
||||||
endTime: new Date(today.setHours(15, 0, 0, 0)).toISOString(),
|
}
|
||||||
location: "Conference Room A",
|
}
|
||||||
allDay: false,
|
};
|
||||||
},
|
|
||||||
{
|
void loadEvents();
|
||||||
id: "3",
|
|
||||||
title: "Sprint Planning",
|
return (): void => {
|
||||||
startTime: new Date(tomorrow.setHours(10, 0, 0, 0)).toISOString(),
|
isMounted = false;
|
||||||
endTime: new Date(tomorrow.setHours(12, 0, 0, 0)).toISOString(),
|
};
|
||||||
allDay: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 500);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatTime = (dateString: string): string => {
|
const formatTime = (dateValue: Date | string): string => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateValue);
|
||||||
return date.toLocaleTimeString("en-US", {
|
return date.toLocaleTimeString("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
@@ -66,8 +48,8 @@ export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDay = (dateString: string): string => {
|
const formatDay = (dateValue: Date | string): string => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateValue);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const tomorrow = new Date(today);
|
const tomorrow = new Date(today);
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|||||||
@@ -4,68 +4,56 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { CheckCircle, Circle, Clock, AlertCircle } from "lucide-react";
|
import { CheckCircle, Circle, Clock, AlertCircle } from "lucide-react";
|
||||||
import type { WidgetProps } from "@mosaic/shared";
|
import { TaskPriority, TaskStatus, type WidgetProps, type Task } from "@mosaic/shared";
|
||||||
|
import { fetchTasks } from "@/lib/api/tasks";
|
||||||
|
|
||||||
interface Task {
|
export function TasksWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
priority: string;
|
|
||||||
dueDate?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-empty-pattern
|
|
||||||
export function TasksWidget({}: WidgetProps): React.JSX.Element {
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Mock data for now - will fetch from API later
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
let isMounted = true;
|
||||||
// Simulate API call
|
|
||||||
setTimeout(() => {
|
const loadTasks = async (): Promise<void> => {
|
||||||
setTasks([
|
setIsLoading(true);
|
||||||
{
|
try {
|
||||||
id: "1",
|
const data = await fetchTasks();
|
||||||
title: "Complete project documentation",
|
if (isMounted) {
|
||||||
status: "IN_PROGRESS",
|
setTasks(data);
|
||||||
priority: "HIGH",
|
}
|
||||||
dueDate: "2024-02-01",
|
} catch {
|
||||||
},
|
if (isMounted) {
|
||||||
{
|
setTasks([]);
|
||||||
id: "2",
|
}
|
||||||
title: "Review pull requests",
|
} finally {
|
||||||
status: "NOT_STARTED",
|
if (isMounted) {
|
||||||
priority: "MEDIUM",
|
setIsLoading(false);
|
||||||
dueDate: "2024-02-02",
|
}
|
||||||
},
|
}
|
||||||
{
|
};
|
||||||
id: "3",
|
|
||||||
title: "Update dependencies",
|
void loadTasks();
|
||||||
status: "COMPLETED",
|
|
||||||
priority: "LOW",
|
return (): void => {
|
||||||
dueDate: "2024-01-30",
|
isMounted = false;
|
||||||
},
|
};
|
||||||
]);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 500);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getPriorityIcon = (priority: string): React.JSX.Element => {
|
const getPriorityIcon = (priority: TaskPriority): React.JSX.Element => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case "HIGH":
|
case TaskPriority.HIGH:
|
||||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||||
case "MEDIUM":
|
case TaskPriority.MEDIUM:
|
||||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||||
case "LOW":
|
case TaskPriority.LOW:
|
||||||
return <Circle className="w-4 h-4 text-gray-400" />;
|
return <Circle className="w-4 h-4 text-gray-400" />;
|
||||||
default:
|
default:
|
||||||
return <Circle className="w-4 h-4 text-gray-400" />;
|
return <Circle className="w-4 h-4 text-gray-400" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
const getStatusIcon = (status: TaskStatus): React.JSX.Element => {
|
||||||
return status === "COMPLETED" ? (
|
return status === TaskStatus.COMPLETED ? (
|
||||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<Circle className="w-4 h-4 text-gray-400" />
|
<Circle className="w-4 h-4 text-gray-400" />
|
||||||
@@ -74,8 +62,8 @@ export function TasksWidget({}: WidgetProps): React.JSX.Element {
|
|||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: tasks.length,
|
total: tasks.length,
|
||||||
inProgress: tasks.filter((t) => t.status === "IN_PROGRESS").length,
|
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
|
||||||
completed: tasks.filter((t) => t.status === "COMPLETED").length,
|
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -1,16 +1,58 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { act, render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type { Event } from "@mosaic/shared";
|
||||||
import { CalendarWidget } from "../CalendarWidget";
|
import { CalendarWidget } from "../CalendarWidget";
|
||||||
|
import { fetchEvents } from "@/lib/api/events";
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/events", () => ({
|
||||||
|
fetchEvents: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockEvents: Event[] = [
|
||||||
|
{
|
||||||
|
id: "event-1",
|
||||||
|
title: "API Planning",
|
||||||
|
description: null,
|
||||||
|
startTime: new Date("2026-02-01T09:00:00Z"),
|
||||||
|
endTime: new Date("2026-02-01T09:30:00Z"),
|
||||||
|
allDay: false,
|
||||||
|
location: "Zoom",
|
||||||
|
recurrence: null,
|
||||||
|
creatorId: "user-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
projectId: null,
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date("2026-01-30T09:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-01-30T09:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "event-2",
|
||||||
|
title: "API Review",
|
||||||
|
description: null,
|
||||||
|
startTime: new Date("2026-02-02T10:00:00Z"),
|
||||||
|
endTime: new Date("2026-02-02T11:00:00Z"),
|
||||||
|
allDay: false,
|
||||||
|
location: "Room 1",
|
||||||
|
recurrence: null,
|
||||||
|
creatorId: "user-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
projectId: null,
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date("2026-01-30T09:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-01-30T09:00:00Z"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
async function finishWidgetLoad(): Promise<void> {
|
async function finishWidgetLoad(): Promise<void> {
|
||||||
await act(async () => {
|
await waitFor(() => {
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
expect(screen.queryByText("Loading events...")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("CalendarWidget", (): void => {
|
describe("CalendarWidget", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.useFakeTimers();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(fetchEvents).mockResolvedValue(mockEvents);
|
||||||
vi.setSystemTime(new Date("2026-02-01T08:00:00Z"));
|
vi.setSystemTime(new Date("2026-02-01T08:00:00Z"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,15 +66,15 @@ describe("CalendarWidget", (): void => {
|
|||||||
expect(screen.getByText("Loading events...")).toBeInTheDocument();
|
expect(screen.getByText("Loading events...")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders upcoming events after loading", async (): Promise<void> => {
|
it("fetches and renders upcoming events after loading", async (): Promise<void> => {
|
||||||
render(<CalendarWidget id="calendar-1" />);
|
render(<CalendarWidget id="calendar-1" />);
|
||||||
|
|
||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
|
expect(fetchEvents).toHaveBeenCalledTimes(1);
|
||||||
expect(screen.getByText("Upcoming Events")).toBeInTheDocument();
|
expect(screen.getByText("Upcoming Events")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Team Standup")).toBeInTheDocument();
|
expect(screen.getByText("API Planning")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Project Review")).toBeInTheDocument();
|
expect(screen.getByText("API Review")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Sprint Planning")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows relative day labels", async (): Promise<void> => {
|
it("shows relative day labels", async (): Promise<void> => {
|
||||||
@@ -50,6 +92,15 @@ describe("CalendarWidget", (): void => {
|
|||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
expect(screen.getByText("Zoom")).toBeInTheDocument();
|
expect(screen.getByText("Zoom")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Conference Room A")).toBeInTheDocument();
|
expect(screen.getByText("Room 1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows empty state when no events are returned", async (): Promise<void> => {
|
||||||
|
vi.mocked(fetchEvents).mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
render(<CalendarWidget id="calendar-1" />);
|
||||||
|
await finishWidgetLoad();
|
||||||
|
|
||||||
|
expect(screen.getByText("No upcoming events")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,80 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { act, render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { TaskStatus, TaskPriority, type Task } from "@mosaic/shared";
|
||||||
import { TasksWidget } from "../TasksWidget";
|
import { TasksWidget } from "../TasksWidget";
|
||||||
|
import { fetchTasks } from "@/lib/api/tasks";
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/tasks", () => ({
|
||||||
|
fetchTasks: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockTasks: Task[] = [
|
||||||
|
{
|
||||||
|
id: "task-1",
|
||||||
|
title: "API task one",
|
||||||
|
description: null,
|
||||||
|
status: TaskStatus.IN_PROGRESS,
|
||||||
|
priority: TaskPriority.HIGH,
|
||||||
|
dueDate: new Date("2026-02-03T09:00:00Z"),
|
||||||
|
creatorId: "user-1",
|
||||||
|
assigneeId: "user-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
projectId: null,
|
||||||
|
parentId: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
metadata: {},
|
||||||
|
completedAt: null,
|
||||||
|
createdAt: new Date("2026-02-01T09:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-02-01T09:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-2",
|
||||||
|
title: "API task two",
|
||||||
|
description: null,
|
||||||
|
status: TaskStatus.NOT_STARTED,
|
||||||
|
priority: TaskPriority.MEDIUM,
|
||||||
|
dueDate: new Date("2026-02-04T09:00:00Z"),
|
||||||
|
creatorId: "user-1",
|
||||||
|
assigneeId: "user-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
projectId: null,
|
||||||
|
parentId: null,
|
||||||
|
sortOrder: 1,
|
||||||
|
metadata: {},
|
||||||
|
completedAt: null,
|
||||||
|
createdAt: new Date("2026-02-01T09:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-02-01T09:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-3",
|
||||||
|
title: "API task three",
|
||||||
|
description: null,
|
||||||
|
status: TaskStatus.COMPLETED,
|
||||||
|
priority: TaskPriority.LOW,
|
||||||
|
dueDate: new Date("2026-02-05T09:00:00Z"),
|
||||||
|
creatorId: "user-1",
|
||||||
|
assigneeId: "user-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
projectId: null,
|
||||||
|
parentId: null,
|
||||||
|
sortOrder: 2,
|
||||||
|
metadata: {},
|
||||||
|
completedAt: new Date("2026-02-02T09:00:00Z"),
|
||||||
|
createdAt: new Date("2026-02-01T09:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-02-02T09:00:00Z"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
async function finishWidgetLoad(): Promise<void> {
|
async function finishWidgetLoad(): Promise<void> {
|
||||||
await act(async () => {
|
await waitFor(() => {
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
expect(screen.queryByText("Loading tasks...")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TasksWidget", (): void => {
|
describe("TasksWidget", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.useFakeTimers();
|
vi.clearAllMocks();
|
||||||
});
|
vi.mocked(fetchTasks).mockResolvedValue(mockTasks);
|
||||||
|
|
||||||
afterEach((): void => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders loading state initially", (): void => {
|
it("renders loading state initially", (): void => {
|
||||||
@@ -23,25 +83,26 @@ describe("TasksWidget", (): void => {
|
|||||||
expect(screen.getByText("Loading tasks...")).toBeInTheDocument();
|
expect(screen.getByText("Loading tasks...")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders default summary stats", async (): Promise<void> => {
|
it("fetches tasks and renders summary stats", async (): Promise<void> => {
|
||||||
render(<TasksWidget id="tasks-1" />);
|
render(<TasksWidget id="tasks-1" />);
|
||||||
|
|
||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
|
expect(fetchTasks).toHaveBeenCalledTimes(1);
|
||||||
expect(screen.getByText("Total")).toBeInTheDocument();
|
expect(screen.getByText("Total")).toBeInTheDocument();
|
||||||
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Done")).toBeInTheDocument();
|
expect(screen.getByText("Done")).toBeInTheDocument();
|
||||||
expect(screen.getByText("3")).toBeInTheDocument();
|
expect(screen.getByText("3")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders default task rows", async (): Promise<void> => {
|
it("renders task rows from API response", async (): Promise<void> => {
|
||||||
render(<TasksWidget id="tasks-1" />);
|
render(<TasksWidget id="tasks-1" />);
|
||||||
|
|
||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
expect(screen.getByText("Complete project documentation")).toBeInTheDocument();
|
expect(screen.getByText("API task one")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Review pull requests")).toBeInTheDocument();
|
expect(screen.getByText("API task two")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Update dependencies")).toBeInTheDocument();
|
expect(screen.getByText("API task three")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows due date labels for each task", async (): Promise<void> => {
|
it("shows due date labels for each task", async (): Promise<void> => {
|
||||||
@@ -51,4 +112,13 @@ describe("TasksWidget", (): void => {
|
|||||||
|
|
||||||
expect(screen.getAllByText(/Due:/).length).toBe(3);
|
expect(screen.getAllByText(/Due:/).length).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows empty state when API returns no tasks", async (): Promise<void> => {
|
||||||
|
vi.mocked(fetchTasks).mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
render(<TasksWidget id="tasks-1" />);
|
||||||
|
await finishWidgetLoad();
|
||||||
|
|
||||||
|
expect(screen.getByText("No tasks yet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useChat, type Message } from "./useChat";
|
|||||||
import * as chatApi from "@/lib/api/chat";
|
import * as chatApi from "@/lib/api/chat";
|
||||||
import * as ideasApi from "@/lib/api/ideas";
|
import * as ideasApi from "@/lib/api/ideas";
|
||||||
import type { Idea } from "@/lib/api/ideas";
|
import type { Idea } from "@/lib/api/ideas";
|
||||||
import type { ChatResponse } from "@/lib/api/chat";
|
|
||||||
|
|
||||||
// Mock the API modules - use importOriginal to preserve types/enums
|
// Mock the API modules - use importOriginal to preserve types/enums
|
||||||
vi.mock("@/lib/api/chat", () => ({
|
vi.mock("@/lib/api/chat", () => ({
|
||||||
@@ -37,24 +36,8 @@ const mockStreamChatMessage = chatApi.streamChatMessage as MockedFunction<
|
|||||||
const mockCreateConversation = ideasApi.createConversation as MockedFunction<
|
const mockCreateConversation = ideasApi.createConversation as MockedFunction<
|
||||||
typeof ideasApi.createConversation
|
typeof ideasApi.createConversation
|
||||||
>;
|
>;
|
||||||
const mockUpdateConversation = ideasApi.updateConversation as MockedFunction<
|
|
||||||
typeof ideasApi.updateConversation
|
|
||||||
>;
|
|
||||||
const mockGetIdea = ideasApi.getIdea as MockedFunction<typeof ideasApi.getIdea>;
|
const mockGetIdea = ideasApi.getIdea as MockedFunction<typeof ideasApi.getIdea>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a mock ChatResponse
|
|
||||||
*/
|
|
||||||
function createMockChatResponse(content: string, model = "llama3.2"): ChatResponse {
|
|
||||||
return {
|
|
||||||
message: { role: "assistant" as const, content },
|
|
||||||
model,
|
|
||||||
done: true,
|
|
||||||
promptEvalCount: 10,
|
|
||||||
evalCount: 5,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a mock Idea
|
* Creates a mock Idea
|
||||||
*/
|
*/
|
||||||
@@ -76,9 +59,9 @@ function createMockIdea(id: string, title: string, content: string): Idea {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure streamChatMessage to immediately fail,
|
* Configure streamChatMessage to immediately fail,
|
||||||
* triggering the fallback to sendChatMessage.
|
* without using a non-streaming fallback.
|
||||||
*/
|
*/
|
||||||
function makeStreamFail(): void {
|
function makeStreamFail(error: Error = new Error("Streaming not available")): void {
|
||||||
mockStreamChatMessage.mockImplementation(
|
mockStreamChatMessage.mockImplementation(
|
||||||
(
|
(
|
||||||
_request,
|
_request,
|
||||||
@@ -88,7 +71,7 @@ function makeStreamFail(): void {
|
|||||||
_signal?: AbortSignal
|
_signal?: AbortSignal
|
||||||
): void => {
|
): void => {
|
||||||
// Call synchronously so the Promise rejects immediately
|
// Call synchronously so the Promise rejects immediately
|
||||||
onError(new Error("Streaming not available"));
|
onError(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,24 +138,7 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendMessage (fallback path when streaming fails)", () => {
|
describe("sendMessage (streaming failure path)", () => {
|
||||||
it("should add user message and assistant response via fallback", async () => {
|
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Hello there!"));
|
|
||||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("Hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(3); // welcome + user + assistant
|
|
||||||
expect(result.current.messages[1]?.role).toBe("user");
|
|
||||||
expect(result.current.messages[1]?.content).toBe("Hello");
|
|
||||||
expect(result.current.messages[2]?.role).toBe("assistant");
|
|
||||||
expect(result.current.messages[2]?.content).toBe("Hello there!");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not send empty messages", async () => {
|
it("should not send empty messages", async () => {
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
@@ -186,22 +152,19 @@ describe("useChat", () => {
|
|||||||
expect(result.current.messages).toHaveLength(1); // only welcome
|
expect(result.current.messages).toHaveLength(1); // only welcome
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle API errors gracefully", async () => {
|
it("should handle streaming errors gracefully", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("API Error"));
|
makeStreamFail(new Error("Streaming not available"));
|
||||||
|
|
||||||
const onError = vi.fn();
|
const { result } = renderHook(() => useChat());
|
||||||
const { result } = renderHook(() => useChat({ onError }));
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
// Streaming fails, no fallback, placeholder is removed
|
||||||
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
expect(result.current.error).toContain("Chat error:");
|
||||||
expect(result.current.messages).toHaveLength(3);
|
expect(result.current.messages).toHaveLength(2); // welcome + user (no assistant)
|
||||||
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -588,9 +551,8 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
describe("clearError", () => {
|
describe("clearError", () => {
|
||||||
it("should clear error state", async () => {
|
it("should clear error state", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Test error"));
|
makeStreamFail(new Error("Test error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
@@ -598,7 +560,7 @@ describe("useChat", () => {
|
|||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
expect(result.current.error).toContain("Chat error:");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.clearError();
|
result.current.clearError();
|
||||||
@@ -608,87 +570,14 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("error context logging", () => {
|
// Note: "error context logging" tests removed - the detailed logging with LLM_ERROR type
|
||||||
it("should log comprehensive error context when sendMessage fails", async () => {
|
// was removed in commit 44da50d when guest fallback mode was removed.
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
// The implementation now uses simple console.warn for streaming failures.
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("LLM timeout"));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat({ model: "llama3.2" }));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("Hello world");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to send chat message",
|
|
||||||
expect.objectContaining({
|
|
||||||
errorType: "LLM_ERROR",
|
|
||||||
messageLength: 11,
|
|
||||||
messagePreview: "Hello world",
|
|
||||||
model: "llama3.2",
|
|
||||||
timestamp: expect.any(String) as string,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should truncate long message previews to 50 characters", async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Failed"));
|
|
||||||
|
|
||||||
const longMessage = "A".repeat(100);
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage(longMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to send chat message",
|
|
||||||
expect.objectContaining({
|
|
||||||
messagePreview: "A".repeat(50),
|
|
||||||
messageLength: 100,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include message count in error context", async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
// First successful message via streaming
|
|
||||||
makeStreamSucceed(["OK"]);
|
|
||||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("First");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Second message: streaming fails, fallback fails
|
|
||||||
makeStreamFail();
|
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Fail"));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("Second");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to send chat message",
|
|
||||||
expect.objectContaining({
|
|
||||||
messageCount: expect.any(Number) as number,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("LLM vs persistence error separation", () => {
|
describe("LLM vs persistence error separation", () => {
|
||||||
it("should show LLM error and add error message to chat when API fails", async () => {
|
it("should show streaming error when stream fails", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available"));
|
makeStreamFail(new Error("Streaming not available"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
@@ -696,9 +585,9 @@ describe("useChat", () => {
|
|||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
// Streaming fails, placeholder is removed, error is set
|
||||||
expect(result.current.messages).toHaveLength(3);
|
expect(result.current.error).toContain("Chat error:");
|
||||||
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
expect(result.current.messages).toHaveLength(2); // welcome + user (no assistant)
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep assistant message visible when save fails (streaming path)", async () => {
|
it("should keep assistant message visible when save fails (streaming path)", async () => {
|
||||||
@@ -717,27 +606,10 @@ describe("useChat", () => {
|
|||||||
expect(result.current.error).toContain("Message sent but failed to save");
|
expect(result.current.error).toContain("Message sent but failed to save");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep assistant message visible when save fails (fallback path)", async () => {
|
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Great answer!"));
|
|
||||||
mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost"));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("Hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(3);
|
|
||||||
expect(result.current.messages[2]?.content).toBe("Great answer!");
|
|
||||||
expect(result.current.error).toContain("Message sent but failed to save");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should log with PERSISTENCE_ERROR type when save fails", async () => {
|
it("should log with PERSISTENCE_ERROR type when save fails", async () => {
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
|
makeStreamSucceed(["Response"]);
|
||||||
mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
|
mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
@@ -765,53 +637,6 @@ describe("useChat", () => {
|
|||||||
expect(llmErrorCalls).toHaveLength(0);
|
expect(llmErrorCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use different user-facing messages for LLM vs save errors", async () => {
|
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
// LLM error path (streaming fails + fallback fails)
|
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Timeout"));
|
|
||||||
const { result: result1 } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result1.current.sendMessage("Test");
|
|
||||||
});
|
|
||||||
|
|
||||||
const llmError = result1.current.error;
|
|
||||||
|
|
||||||
// Save error path (streaming succeeds, save fails)
|
|
||||||
makeStreamSucceed(["OK"]);
|
|
||||||
mockCreateConversation.mockRejectedValueOnce(new Error("DB down"));
|
|
||||||
const { result: result2 } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result2.current.sendMessage("Test");
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveError = result2.current.error;
|
|
||||||
|
|
||||||
expect(llmError).toBe("Unable to send message. Please try again.");
|
|
||||||
expect(saveError).toContain("Message sent but failed to save");
|
|
||||||
expect(llmError).not.toEqual(saveError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle non-Error throws from LLM API", async () => {
|
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
mockSendChatMessage.mockRejectedValueOnce("string error");
|
|
||||||
|
|
||||||
const onError = vi.fn();
|
|
||||||
const { result } = renderHook(() => useChat({ onError }));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("Hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
|
||||||
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
||||||
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle non-Error throws from persistence layer", async () => {
|
it("should handle non-Error throws from persistence layer", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
@@ -829,37 +654,5 @@ describe("useChat", () => {
|
|||||||
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
||||||
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle updateConversation failure for existing conversations", async () => {
|
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
// First message via fallback
|
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("First response"));
|
|
||||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("First");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.conversationId).toBe("conv-1");
|
|
||||||
|
|
||||||
// Second message via fallback, updateConversation fails
|
|
||||||
makeStreamFail();
|
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Second response"));
|
|
||||||
mockUpdateConversation.mockRejectedValueOnce(new Error("Connection reset"));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("Second");
|
|
||||||
});
|
|
||||||
|
|
||||||
const assistantMessages = result.current.messages.filter(
|
|
||||||
(m) => m.role === "assistant" && m.id !== "welcome"
|
|
||||||
);
|
|
||||||
expect(assistantMessages[assistantMessages.length - 1]?.content).toBe("Second response");
|
|
||||||
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
import {
|
import { streamChatMessage, type ChatMessage as ApiChatMessage } from "@/lib/api/chat";
|
||||||
sendChatMessage,
|
|
||||||
streamChatMessage,
|
|
||||||
type ChatMessage as ApiChatMessage,
|
|
||||||
} from "@/lib/api/chat";
|
|
||||||
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
|
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
|
||||||
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
|
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
|
||||||
|
|
||||||
@@ -31,6 +27,7 @@ export interface UseChatOptions {
|
|||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
agent?: string;
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +64,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
maxTokens,
|
maxTokens,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
projectId,
|
projectId,
|
||||||
|
agent,
|
||||||
onError,
|
onError,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@@ -81,6 +79,10 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
const projectIdRef = useRef<string | null>(projectId ?? null);
|
const projectIdRef = useRef<string | null>(projectId ?? null);
|
||||||
projectIdRef.current = projectId ?? null;
|
projectIdRef.current = projectId ?? null;
|
||||||
|
|
||||||
|
// Track agent in ref to prevent stale closures
|
||||||
|
const agentRef = useRef<string | undefined>(agent);
|
||||||
|
agentRef.current = agent;
|
||||||
|
|
||||||
// Track messages in ref to prevent stale closures during rapid sends
|
// Track messages in ref to prevent stale closures during rapid sends
|
||||||
const messagesRef = useRef<Message[]>(messages);
|
const messagesRef = useRef<Message[]>(messages);
|
||||||
messagesRef.current = messages;
|
messagesRef.current = messages;
|
||||||
@@ -213,13 +215,12 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
...(temperature !== undefined && { temperature }),
|
...(temperature !== undefined && { temperature }),
|
||||||
...(maxTokens !== undefined && { maxTokens }),
|
...(maxTokens !== undefined && { maxTokens }),
|
||||||
...(systemPrompt !== undefined && { systemPrompt }),
|
...(systemPrompt !== undefined && { systemPrompt }),
|
||||||
|
...(agentRef.current && { agent: agentRef.current }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortControllerRef.current = controller;
|
abortControllerRef.current = controller;
|
||||||
|
|
||||||
let streamingSucceeded = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
let hasReceivedData = false;
|
let hasReceivedData = false;
|
||||||
@@ -247,7 +248,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
streamingSucceeded = true;
|
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
resolve();
|
resolve();
|
||||||
@@ -278,8 +278,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming failed — fall back to non-streaming
|
// Streaming failed — show error (no guest fallback, auth required)
|
||||||
console.warn("Streaming failed, falling back to non-streaming", {
|
console.warn("Streaming failed", {
|
||||||
error: err instanceof Error ? err : new Error(String(err)),
|
error: err instanceof Error ? err : new Error(String(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,66 +289,15 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
return withoutPlaceholder;
|
return withoutPlaceholder;
|
||||||
});
|
});
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
try {
|
const errorMsg = err instanceof Error ? err.message : "Chat unavailable";
|
||||||
const response = await sendChatMessage(request);
|
setError(`Chat error: ${errorMsg}`);
|
||||||
|
return;
|
||||||
const assistantMessage: Message = {
|
|
||||||
id: `assistant-${Date.now().toString()}`,
|
|
||||||
role: "assistant",
|
|
||||||
content: response.message.content,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
model: response.model,
|
|
||||||
promptTokens: response.promptEvalCount ?? 0,
|
|
||||||
completionTokens: response.evalCount ?? 0,
|
|
||||||
totalTokens: (response.promptEvalCount ?? 0) + (response.evalCount ?? 0),
|
|
||||||
};
|
|
||||||
|
|
||||||
setMessages((prev) => {
|
|
||||||
const updated = [...prev, assistantMessage];
|
|
||||||
messagesRef.current = updated;
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
streamingSucceeded = true;
|
|
||||||
} catch (fallbackErr: unknown) {
|
|
||||||
const errorMsg =
|
|
||||||
fallbackErr instanceof Error ? fallbackErr.message : "Failed to send message";
|
|
||||||
setError("Unable to send message. Please try again.");
|
|
||||||
onError?.(fallbackErr instanceof Error ? fallbackErr : new Error(errorMsg));
|
|
||||||
console.error("Failed to send chat message", {
|
|
||||||
error: fallbackErr,
|
|
||||||
errorType: "LLM_ERROR",
|
|
||||||
conversationId: conversationIdRef.current,
|
|
||||||
messageLength: content.length,
|
|
||||||
messagePreview: content.substring(0, 50),
|
|
||||||
model,
|
|
||||||
messageCount: messagesRef.current.length,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const errorMessage: Message = {
|
|
||||||
id: `error-${String(Date.now())}`,
|
|
||||||
role: "assistant",
|
|
||||||
content: "Something went wrong. Please try again.",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setMessages((prev) => {
|
|
||||||
const updated = [...prev, errorMessage];
|
|
||||||
messagesRef.current = updated;
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
if (!streamingSucceeded) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalMessages = messagesRef.current;
|
const finalMessages = messagesRef.current;
|
||||||
|
|
||||||
const isFirstMessage =
|
const isFirstMessage =
|
||||||
|
|||||||
125
apps/web/src/lib/api/agents.ts
Normal file
125
apps/web/src/lib/api/agents.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Agent API client
|
||||||
|
* Handles agent-related API interactions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiGet, apiPost, apiPatch, apiDelete } from "./client";
|
||||||
|
|
||||||
|
export interface AgentStatus {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
isActive: boolean;
|
||||||
|
containerStatus?: "running" | "stopped" | "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAgent {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
templateId: string | null;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
personality: string;
|
||||||
|
primaryModel: string | null;
|
||||||
|
fallbackModels: string[];
|
||||||
|
toolPermissions: string[];
|
||||||
|
discordChannel: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserAgentRequest {
|
||||||
|
templateId?: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
personality: string;
|
||||||
|
primaryModel?: string;
|
||||||
|
fallbackModels?: string[];
|
||||||
|
toolPermissions?: string[];
|
||||||
|
discordChannel?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserAgentRequest {
|
||||||
|
name?: string;
|
||||||
|
displayName?: string;
|
||||||
|
role?: string;
|
||||||
|
personality?: string;
|
||||||
|
primaryModel?: string;
|
||||||
|
fallbackModels?: string[];
|
||||||
|
toolPermissions?: string[];
|
||||||
|
discordChannel?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserAgentRequest {
|
||||||
|
name?: string;
|
||||||
|
displayName?: string;
|
||||||
|
role?: string;
|
||||||
|
personality?: string;
|
||||||
|
primaryModel?: string;
|
||||||
|
fallbackModels?: string[];
|
||||||
|
toolPermissions?: string[];
|
||||||
|
discordChannel?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all user's agents
|
||||||
|
*/
|
||||||
|
export async function getAgents(): Promise<UserAgent[]> {
|
||||||
|
return apiGet<UserAgent[]>("/api/agents");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all agent statuses
|
||||||
|
*/
|
||||||
|
export async function getAgentStatuses(): Promise<AgentStatus[]> {
|
||||||
|
return apiGet<AgentStatus[]>("/api/agents/status");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single agent by ID
|
||||||
|
*/
|
||||||
|
export async function getAgent(id: string): Promise<UserAgent> {
|
||||||
|
return apiGet<UserAgent>(`/api/agents/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single agent's status
|
||||||
|
*/
|
||||||
|
export async function getAgentStatus(id: string): Promise<AgentStatus> {
|
||||||
|
return apiGet<AgentStatus>(`/api/agents/${id}/status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new custom agent
|
||||||
|
*/
|
||||||
|
export async function createAgent(data: CreateUserAgentRequest): Promise<UserAgent> {
|
||||||
|
return apiPost<UserAgent>("/api/agents", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an agent from a template
|
||||||
|
*/
|
||||||
|
export async function createAgentFromTemplate(templateId: string): Promise<UserAgent> {
|
||||||
|
return apiPost<UserAgent>(`/api/agents/from-template/${templateId}`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an agent
|
||||||
|
*/
|
||||||
|
export async function updateAgent(id: string, data: UpdateUserAgentRequest): Promise<UserAgent> {
|
||||||
|
return apiPatch<UserAgent>(`/api/agents/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an agent
|
||||||
|
*/
|
||||||
|
export async function deleteAgent(id: string): Promise<void> {
|
||||||
|
await apiDelete(`/api/agents/${id}`);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export interface ChatRequest {
|
|||||||
temperature?: number;
|
temperature?: number;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
agent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatResponse {
|
export interface ChatResponse {
|
||||||
@@ -92,6 +93,145 @@ async function ensureCsrfTokenForStream(): Promise<string> {
|
|||||||
return fetchCsrfToken();
|
return fetchCsrfToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a guest chat message (no authentication required).
|
||||||
|
* Uses /api/chat/guest endpoint with shared LLM configuration.
|
||||||
|
*
|
||||||
|
* @param request - Chat request
|
||||||
|
* @param onChunk - Called with each token string as it arrives
|
||||||
|
* @param onComplete - Called when the stream finishes successfully
|
||||||
|
* @param onError - Called if the stream encounters an error
|
||||||
|
* @param signal - Optional AbortSignal for cancellation
|
||||||
|
*/
|
||||||
|
export function streamGuestChat(
|
||||||
|
request: ChatRequest,
|
||||||
|
onChunk: (chunk: string) => void,
|
||||||
|
onComplete: () => void,
|
||||||
|
onError: (error: Error) => void,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): void {
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/chat/guest`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
messages: request.messages,
|
||||||
|
stream: true,
|
||||||
|
...(request.agent && { agent: request.agent }),
|
||||||
|
}),
|
||||||
|
signal: signal ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => response.statusText);
|
||||||
|
throw new Error(`Guest chat failed: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Response body is not readable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
let readerDone = false;
|
||||||
|
while (!readerDone) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
readerDone = done;
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// SSE messages are separated by double newlines
|
||||||
|
const parts = buffer.split("\n\n");
|
||||||
|
buffer = parts.pop() ?? "";
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
// Handle event: error format
|
||||||
|
const eventMatch = /^event:\s*(\S+)\n/i.exec(trimmed);
|
||||||
|
const dataMatch = /^data:\s*(.+)$/im.exec(trimmed);
|
||||||
|
|
||||||
|
if (eventMatch?.[1] === "error" && dataMatch?.[1]) {
|
||||||
|
try {
|
||||||
|
const errorData = JSON.parse(dataMatch[1].trim()) as {
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
throw new Error(errorData.error ?? "Stream error occurred");
|
||||||
|
} catch (parseErr) {
|
||||||
|
if (parseErr instanceof SyntaxError) {
|
||||||
|
throw new Error("Stream error occurred");
|
||||||
|
}
|
||||||
|
throw parseErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard SSE format: data: {...}
|
||||||
|
for (const line of trimmed.split("\n")) {
|
||||||
|
if (!line.startsWith("data: ")) continue;
|
||||||
|
|
||||||
|
const data = line.slice("data: ".length).trim();
|
||||||
|
|
||||||
|
if (data === "[DONE]") {
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(data);
|
||||||
|
|
||||||
|
// Handle OpenAI format
|
||||||
|
const openAiChunk = parsed as OpenAiSseChunk;
|
||||||
|
if (openAiChunk.choices?.[0]?.delta?.content) {
|
||||||
|
onChunk(openAiChunk.choices[0].delta.content);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle simple token format
|
||||||
|
const simpleChunk = parsed as SimpleTokenChunk;
|
||||||
|
if (simpleChunk.token) {
|
||||||
|
onChunk(simpleChunk.token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (simpleChunk.done === true) {
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = openAiChunk.error ?? simpleChunk.error;
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
} catch (parseErr) {
|
||||||
|
if (parseErr instanceof SyntaxError) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw parseErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream a chat message from the LLM using SSE over fetch.
|
* Stream a chat message from the LLM using SSE over fetch.
|
||||||
*
|
*
|
||||||
@@ -134,7 +274,11 @@ export function streamChatMessage(
|
|||||||
"X-CSRF-Token": csrfToken,
|
"X-CSRF-Token": csrfToken,
|
||||||
},
|
},
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ messages: request.messages, stream: true }),
|
body: JSON.stringify({
|
||||||
|
messages: request.messages,
|
||||||
|
stream: true,
|
||||||
|
...(request.agent && { agent: request.agent }),
|
||||||
|
}),
|
||||||
signal: signal ?? null,
|
signal: signal ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
53
apps/web/src/lib/api/telemetry.test.ts
Normal file
53
apps/web/src/lib/api/telemetry.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { fetchUsageSummary } from "./telemetry";
|
||||||
|
|
||||||
|
vi.mock("./client", () => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { apiGet } = await import("./client");
|
||||||
|
|
||||||
|
describe("Telemetry API Client", (): void => {
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-03-02T12:00:00Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches usage summary from llm usage analytics endpoint", async (): Promise<void> => {
|
||||||
|
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
totalCalls: 47,
|
||||||
|
totalPromptTokens: 120000,
|
||||||
|
totalCompletionTokens: 125800,
|
||||||
|
totalTokens: 245800,
|
||||||
|
totalCostCents: 342,
|
||||||
|
averageDurationMs: 3200,
|
||||||
|
byProvider: [],
|
||||||
|
byModel: [],
|
||||||
|
byTaskType: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await fetchUsageSummary("30d");
|
||||||
|
|
||||||
|
const calledEndpoint = vi.mocked(apiGet).mock.calls[0]?.[0];
|
||||||
|
expect(calledEndpoint).toMatch(/^\/api\/llm-usage\/analytics\?/);
|
||||||
|
|
||||||
|
const queryString = calledEndpoint?.split("?")[1] ?? "";
|
||||||
|
const params = new URLSearchParams(queryString);
|
||||||
|
expect(params.get("startDate")).toBeTruthy();
|
||||||
|
expect(params.get("endDate")).toBeTruthy();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
totalTokens: 245800,
|
||||||
|
totalCost: 3.42,
|
||||||
|
taskCount: 47,
|
||||||
|
avgQualityGatePassRate: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Telemetry API Client
|
* Telemetry API Client
|
||||||
* Handles telemetry data fetching for the usage dashboard.
|
* Handles telemetry data fetching for the usage dashboard.
|
||||||
*
|
|
||||||
* NOTE: Currently returns mock/placeholder data since the telemetry API
|
|
||||||
* aggregation endpoints don't exist yet. The important thing is the UI structure.
|
|
||||||
* When the backend endpoints are ready, replace mock calls with real apiGet() calls.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiGet, type ApiResponse } from "./client";
|
import { apiGet, type ApiResponse } from "./client";
|
||||||
@@ -60,65 +56,84 @@ export interface EstimateResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Mock Data Generators ────────────────────────────────────────────
|
interface ProviderUsageAnalyticsItem {
|
||||||
|
provider: string;
|
||||||
|
calls: number;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costCents: number;
|
||||||
|
averageDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
function generateDateRange(range: TimeRange): string[] {
|
interface ModelUsageAnalyticsItem {
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
model: string;
|
||||||
const dates: string[] = [];
|
calls: number;
|
||||||
const now = new Date();
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costCents: number;
|
||||||
|
averageDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = days - 1; i >= 0; i--) {
|
interface TaskTypeUsageAnalyticsItem {
|
||||||
const d = new Date(now);
|
taskType: string;
|
||||||
d.setDate(d.getDate() - i);
|
calls: number;
|
||||||
dates.push(d.toISOString().split("T")[0] ?? "");
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costCents: number;
|
||||||
|
averageDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageAnalyticsResponse {
|
||||||
|
totalCalls: number;
|
||||||
|
totalPromptTokens: number;
|
||||||
|
totalCompletionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
totalCostCents: number;
|
||||||
|
averageDurationMs: number;
|
||||||
|
byProvider: ProviderUsageAnalyticsItem[];
|
||||||
|
byModel: ModelUsageAnalyticsItem[];
|
||||||
|
byTaskType: TaskTypeUsageAnalyticsItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_OUTCOME_COLORS = ["#6EBF8B", "#F5C862", "#94A3B8", "#C4A5DE", "#7AA2F7"];
|
||||||
|
const DAYS_BY_RANGE: Record<TimeRange, number> = {
|
||||||
|
"7d": 7,
|
||||||
|
"30d": 30,
|
||||||
|
"90d": 90,
|
||||||
|
};
|
||||||
|
const analyticsRequestCache = new Map<TimeRange, Promise<UsageAnalyticsResponse>>();
|
||||||
|
|
||||||
|
function buildAnalyticsEndpoint(timeRange: TimeRange): string {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date(endDate);
|
||||||
|
startDate.setDate(startDate.getDate() - (DAYS_BY_RANGE[timeRange] - 1));
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
startDate: startDate.toISOString(),
|
||||||
|
endDate: endDate.toISOString(),
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
return `/api/llm-usage/analytics?${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsageAnalytics(timeRange: TimeRange): Promise<UsageAnalyticsResponse> {
|
||||||
|
const cachedRequest = analyticsRequestCache.get(timeRange);
|
||||||
|
if (cachedRequest) {
|
||||||
|
return cachedRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
return dates;
|
const request = apiGet<ApiResponse<UsageAnalyticsResponse>>(buildAnalyticsEndpoint(timeRange))
|
||||||
}
|
.then((response) => response.data)
|
||||||
|
.finally(() => {
|
||||||
|
analyticsRequestCache.delete(timeRange);
|
||||||
|
});
|
||||||
|
|
||||||
function generateMockTokenUsage(range: TimeRange): TokenUsagePoint[] {
|
analyticsRequestCache.set(timeRange, request);
|
||||||
const dates = generateDateRange(range);
|
return request;
|
||||||
|
|
||||||
return dates.map((date) => {
|
|
||||||
const baseInput = 8000 + Math.floor(Math.random() * 12000);
|
|
||||||
const baseOutput = 3000 + Math.floor(Math.random() * 7000);
|
|
||||||
return {
|
|
||||||
date,
|
|
||||||
inputTokens: baseInput,
|
|
||||||
outputTokens: baseOutput,
|
|
||||||
totalTokens: baseInput + baseOutput,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMockSummary(range: TimeRange): UsageSummary {
|
|
||||||
const multiplier = range === "7d" ? 1 : range === "30d" ? 4 : 12;
|
|
||||||
return {
|
|
||||||
totalTokens: 245_800 * multiplier,
|
|
||||||
totalCost: 3.42 * multiplier,
|
|
||||||
taskCount: 47 * multiplier,
|
|
||||||
avgQualityGatePassRate: 0.87,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMockCostBreakdown(): CostBreakdownItem[] {
|
|
||||||
return [
|
|
||||||
{ model: "claude-sonnet-4-5", provider: "anthropic", cost: 18.5, taskCount: 124 },
|
|
||||||
{ model: "gpt-4o", provider: "openai", cost: 12.3, taskCount: 89 },
|
|
||||||
{ model: "claude-haiku-3.5", provider: "anthropic", cost: 4.2, taskCount: 156 },
|
|
||||||
{ model: "llama-3.3-70b", provider: "ollama", cost: 0, taskCount: 67 },
|
|
||||||
{ model: "gemini-2.0-flash", provider: "google", cost: 2.8, taskCount: 42 },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDA-friendly colors: calm, no aggressive reds
|
|
||||||
function generateMockTaskOutcomes(): TaskOutcomeItem[] {
|
|
||||||
return [
|
|
||||||
{ outcome: "Success", count: 312, color: "#6EBF8B" },
|
|
||||||
{ outcome: "Partial", count: 48, color: "#F5C862" },
|
|
||||||
{ outcome: "Timeout", count: 18, color: "#94A3B8" },
|
|
||||||
{ outcome: "Incomplete", count: 22, color: "#C4A5DE" },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── API Functions ───────────────────────────────────────────────────
|
// ─── API Functions ───────────────────────────────────────────────────
|
||||||
@@ -127,47 +142,54 @@ function generateMockTaskOutcomes(): TaskOutcomeItem[] {
|
|||||||
* Fetch usage summary data (total tokens, cost, task count, quality rate)
|
* Fetch usage summary data (total tokens, cost, task count, quality rate)
|
||||||
*/
|
*/
|
||||||
export async function fetchUsageSummary(timeRange: TimeRange): Promise<UsageSummary> {
|
export async function fetchUsageSummary(timeRange: TimeRange): Promise<UsageSummary> {
|
||||||
// TODO: Replace with real API call when backend aggregation endpoints are ready
|
const analytics = await fetchUsageAnalytics(timeRange);
|
||||||
// const response = await apiGet<ApiResponse<UsageSummary>>(`/api/telemetry/summary?range=${timeRange}`);
|
|
||||||
// return response.data;
|
return {
|
||||||
void apiGet; // suppress unused import warning in the meantime
|
totalTokens: analytics.totalTokens,
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
totalCost: analytics.totalCostCents / 100,
|
||||||
return generateMockSummary(timeRange);
|
taskCount: analytics.totalCalls,
|
||||||
|
avgQualityGatePassRate: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch token usage time series for charts
|
* Fetch token usage time series for charts
|
||||||
*/
|
*/
|
||||||
export async function fetchTokenUsage(timeRange: TimeRange): Promise<TokenUsagePoint[]> {
|
export function fetchTokenUsage(timeRange: TimeRange): Promise<TokenUsagePoint[]> {
|
||||||
// TODO: Replace with real API call
|
void timeRange;
|
||||||
// const response = await apiGet<ApiResponse<TokenUsagePoint[]>>(`/api/telemetry/tokens?range=${timeRange}`);
|
return Promise.resolve([]);
|
||||||
// return response.data;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
||||||
return generateMockTokenUsage(timeRange);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch cost breakdown by model
|
* Fetch cost breakdown by model
|
||||||
*/
|
*/
|
||||||
export async function fetchCostBreakdown(timeRange: TimeRange): Promise<CostBreakdownItem[]> {
|
export async function fetchCostBreakdown(timeRange: TimeRange): Promise<CostBreakdownItem[]> {
|
||||||
// TODO: Replace with real API call
|
const analytics = await fetchUsageAnalytics(timeRange);
|
||||||
// const response = await apiGet<ApiResponse<CostBreakdownItem[]>>(`/api/telemetry/costs?range=${timeRange}`);
|
|
||||||
// return response.data;
|
return analytics.byModel
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
.filter((item) => item.calls > 0)
|
||||||
void timeRange;
|
.sort((a, b) => b.costCents - a.costCents)
|
||||||
return generateMockCostBreakdown();
|
.map((item) => ({
|
||||||
|
model: item.model,
|
||||||
|
provider: "unknown",
|
||||||
|
cost: item.costCents / 100,
|
||||||
|
taskCount: item.calls,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch task outcome distribution
|
* Fetch task outcome distribution
|
||||||
*/
|
*/
|
||||||
export async function fetchTaskOutcomes(timeRange: TimeRange): Promise<TaskOutcomeItem[]> {
|
export async function fetchTaskOutcomes(timeRange: TimeRange): Promise<TaskOutcomeItem[]> {
|
||||||
// TODO: Replace with real API call
|
const analytics = await fetchUsageAnalytics(timeRange);
|
||||||
// const response = await apiGet<ApiResponse<TaskOutcomeItem[]>>(`/api/telemetry/outcomes?range=${timeRange}`);
|
|
||||||
// return response.data;
|
return analytics.byTaskType
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
.filter((item) => item.calls > 0)
|
||||||
void timeRange;
|
.map((item, index) => ({
|
||||||
return generateMockTaskOutcomes();
|
outcome: item.taskType,
|
||||||
|
count: item.calls,
|
||||||
|
color: TASK_OUTCOME_COLORS[index % TASK_OUTCOME_COLORS.length] ?? "#94A3B8",
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
# - OpenBao: Standalone container (see docker-compose.openbao.yml)
|
# - OpenBao: Standalone container (see docker-compose.openbao.yml)
|
||||||
# - Authentik: External OIDC provider
|
# - Authentik: External OIDC provider
|
||||||
# - Ollama: External AI inference
|
# - Ollama: External AI inference
|
||||||
|
# - PostgreSQL: Provided by the openbrain stack (openbrain_brain-db)
|
||||||
|
# Deploy openbrain stack before this stack.
|
||||||
#
|
#
|
||||||
# Usage (Portainer):
|
# Usage (Portainer):
|
||||||
# 1. Stacks -> Add Stack -> Upload or paste
|
# 1. Stacks -> Add Stack -> Upload or paste
|
||||||
@@ -36,37 +38,75 @@
|
|||||||
# Required vars use plain ${VAR} — the app validates at startup.
|
# Required vars use plain ${VAR} — the app validates at startup.
|
||||||
#
|
#
|
||||||
# ==============================================
|
# ==============================================
|
||||||
|
# DATABASE (openbrain_brain-db — external)
|
||||||
|
# ==============================================
|
||||||
|
#
|
||||||
|
# This stack uses the PostgreSQL instance from the openbrain stack.
|
||||||
|
# The openbrain stack must be deployed first and its brain-internal
|
||||||
|
# overlay network must exist.
|
||||||
|
#
|
||||||
|
# Required env vars for DB access:
|
||||||
|
# BRAIN_DB_ADMIN_USER — openbrain superuser (default: openbrain)
|
||||||
|
# BRAIN_DB_ADMIN_PASSWORD — openbrain superuser password
|
||||||
|
# (must match openbrain stack POSTGRES_PASSWORD)
|
||||||
|
# POSTGRES_USER — mosaic application DB user (created by mosaic-db-init)
|
||||||
|
# POSTGRES_PASSWORD — mosaic application DB password
|
||||||
|
# POSTGRES_DB — mosaic application database name (default: mosaic)
|
||||||
|
#
|
||||||
|
# ==============================================
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ============================================
|
# ============================================
|
||||||
# CORE INFRASTRUCTURE
|
# DATABASE INIT
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# PostgreSQL Database
|
# Mosaic Database Init
|
||||||
# ======================
|
# ======================
|
||||||
postgres:
|
# Creates the mosaic application user and database in the shared
|
||||||
image: git.mosaicstack.dev/mosaic/stack-postgres:${IMAGE_TAG:-latest}
|
# openbrain PostgreSQL instance (openbrain_brain-db).
|
||||||
|
# Runs once and exits. Idempotent — safe to run on every deploy.
|
||||||
|
mosaic-db-init:
|
||||||
|
image: postgres:17-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
PGHOST: openbrain_brain-db
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
PGPORT: 5432
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
PGUSER: ${BRAIN_DB_ADMIN_USER:-openbrain}
|
||||||
POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-256MB}
|
PGPASSWORD: ${BRAIN_DB_ADMIN_PASSWORD}
|
||||||
POSTGRES_EFFECTIVE_CACHE_SIZE: ${POSTGRES_EFFECTIVE_CACHE_SIZE:-1GB}
|
MOSAIC_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-100}
|
MOSAIC_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
volumes:
|
MOSAIC_DB: ${POSTGRES_DB:-mosaic}
|
||||||
- postgres_data:/var/lib/postgresql/data
|
entrypoint: ["sh", "-c"]
|
||||||
healthcheck:
|
command:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
- |
|
||||||
interval: 10s
|
until pg_isready -h openbrain_brain-db -p 5432 -U $${PGUSER}; do
|
||||||
timeout: 5s
|
echo "Waiting for openbrain_brain-db..."
|
||||||
retries: 5
|
sleep 2
|
||||||
start_period: 30s
|
done
|
||||||
|
echo "Database ready. Creating mosaic user and database..."
|
||||||
|
|
||||||
|
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_roles WHERE rolname='$${MOSAIC_USER}'" | grep -q 1 || \
|
||||||
|
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE USER $${MOSAIC_USER} WITH PASSWORD '$${MOSAIC_PASSWORD}';"
|
||||||
|
|
||||||
|
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_database WHERE datname='$${MOSAIC_DB}'" | grep -q 1 || \
|
||||||
|
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE DATABASE $${MOSAIC_DB} OWNER $${MOSAIC_USER} ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;"
|
||||||
|
|
||||||
|
echo "Enabling required extensions in $${MOSAIC_DB}..."
|
||||||
|
psql -h openbrain_brain-db -U $${PGUSER} -d $${MOSAIC_DB} -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||||
|
psql -h openbrain_brain-db -U $${PGUSER} -d $${MOSAIC_DB} -c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";"
|
||||||
|
|
||||||
|
echo "Mosaic database ready: $${MOSAIC_DB}"
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- openbrain-brain-internal
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 5
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# CORE INFRASTRUCTURE
|
||||||
|
# ============================================
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# Valkey Cache
|
# Valkey Cache
|
||||||
@@ -105,7 +145,7 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: ${API_PORT:-3001}
|
PORT: ${API_PORT:-3001}
|
||||||
API_HOST: ${API_HOST:-0.0.0.0}
|
API_HOST: ${API_HOST:-0.0.0.0}
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@openbrain_brain-db:5432/${POSTGRES_DB:-mosaic}
|
||||||
VALKEY_URL: redis://valkey:6379
|
VALKEY_URL: redis://valkey:6379
|
||||||
# Auth (external Authentik)
|
# Auth (external Authentik)
|
||||||
OIDC_ENABLED: ${OIDC_ENABLED:-false}
|
OIDC_ENABLED: ${OIDC_ENABLED:-false}
|
||||||
@@ -163,6 +203,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
- traefik-public
|
- traefik-public
|
||||||
|
- openbrain-brain-internal
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
@@ -307,36 +348,36 @@ services:
|
|||||||
# ======================
|
# ======================
|
||||||
# Synapse Database Init
|
# Synapse Database Init
|
||||||
# ======================
|
# ======================
|
||||||
# Creates the 'synapse' database in the shared PostgreSQL instance.
|
# Creates the 'synapse' database in the shared openbrain PostgreSQL instance.
|
||||||
# Runs once and exits. Idempotent — safe to run on every deploy.
|
# Runs once and exits. Idempotent — safe to run on every deploy.
|
||||||
synapse-db-init:
|
synapse-db-init:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
environment:
|
environment:
|
||||||
PGHOST: postgres
|
PGHOST: openbrain_brain-db
|
||||||
PGPORT: 5432
|
PGPORT: 5432
|
||||||
PGUSER: ${POSTGRES_USER}
|
PGUSER: ${BRAIN_DB_ADMIN_USER:-openbrain}
|
||||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
PGPASSWORD: ${BRAIN_DB_ADMIN_PASSWORD}
|
||||||
SYNAPSE_DB: ${SYNAPSE_POSTGRES_DB}
|
SYNAPSE_DB: ${SYNAPSE_POSTGRES_DB}
|
||||||
SYNAPSE_USER: ${SYNAPSE_POSTGRES_USER}
|
SYNAPSE_USER: ${SYNAPSE_POSTGRES_USER}
|
||||||
SYNAPSE_PASSWORD: ${SYNAPSE_POSTGRES_PASSWORD}
|
SYNAPSE_PASSWORD: ${SYNAPSE_POSTGRES_PASSWORD}
|
||||||
entrypoint: ["sh", "-c"]
|
entrypoint: ["sh", "-c"]
|
||||||
command:
|
command:
|
||||||
- |
|
- |
|
||||||
until pg_isready -h postgres -p 5432 -U $${PGUSER}; do
|
until pg_isready -h openbrain_brain-db -p 5432 -U $${PGUSER}; do
|
||||||
echo "Waiting for PostgreSQL..."
|
echo "Waiting for openbrain_brain-db..."
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
echo "PostgreSQL is ready. Creating Synapse database and user..."
|
echo "Database ready. Creating Synapse user and database..."
|
||||||
|
|
||||||
psql -h postgres -U $${PGUSER} -tc "SELECT 1 FROM pg_roles WHERE rolname='$${SYNAPSE_USER}'" | grep -q 1 || \
|
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_roles WHERE rolname='$${SYNAPSE_USER}'" | grep -q 1 || \
|
||||||
psql -h postgres -U $${PGUSER} -c "CREATE USER $${SYNAPSE_USER} WITH PASSWORD '$${SYNAPSE_PASSWORD}';"
|
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE USER $${SYNAPSE_USER} WITH PASSWORD '$${SYNAPSE_PASSWORD}';"
|
||||||
|
|
||||||
psql -h postgres -U $${PGUSER} -tc "SELECT 1 FROM pg_database WHERE datname='$${SYNAPSE_DB}'" | grep -q 1 || \
|
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_database WHERE datname='$${SYNAPSE_DB}'" | grep -q 1 || \
|
||||||
psql -h postgres -U $${PGUSER} -c "CREATE DATABASE $${SYNAPSE_DB} OWNER $${SYNAPSE_USER} ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;"
|
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE DATABASE $${SYNAPSE_DB} OWNER $${SYNAPSE_USER} ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;"
|
||||||
|
|
||||||
echo "Synapse database ready: $${SYNAPSE_DB}"
|
echo "Synapse database ready: $${SYNAPSE_DB}"
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- openbrain-brain-internal
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
@@ -451,7 +492,6 @@ services:
|
|||||||
# Volumes
|
# Volumes
|
||||||
# ======================
|
# ======================
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
|
||||||
valkey_data:
|
valkey_data:
|
||||||
orchestrator_workspace:
|
orchestrator_workspace:
|
||||||
speaches_models:
|
speaches_models:
|
||||||
@@ -464,3 +504,6 @@ networks:
|
|||||||
driver: overlay
|
driver: overlay
|
||||||
traefik-public:
|
traefik-public:
|
||||||
external: true
|
external: true
|
||||||
|
openbrain-brain-internal:
|
||||||
|
external: true
|
||||||
|
name: openbrain_brain-internal
|
||||||
|
|||||||
@@ -1,52 +1,71 @@
|
|||||||
# Mission Manifest — MS21 Multi-Tenant RBAC Data Migration
|
# Mission Manifest — MS22-P2 Named Agent Fleet
|
||||||
|
|
||||||
> Persistent document tracking full mission scope, status, and session history.
|
> Persistent document tracking full mission scope, status, and session history.
|
||||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||||
|
|
||||||
## Mission
|
## Mission
|
||||||
|
|
||||||
**ID:** ms21-multi-tenant-rbac-data-migration-20260228
|
**ID:** ms22-p2-named-agent-fleet-20260304
|
||||||
**Statement:** Build multi-tenant user/workspace/team management, break-glass auth, RBAC UI enforcement, and migrate jarvis-brain data into Mosaic Stack
|
**Statement:** Implement named agent fleet (jarvis, builder, medic) with per-agent personalities, model assignments, Discord channel routing, and WebUI selector.
|
||||||
**Phase:** Intake
|
**PRD:** `docs/PRD-MS22-P2-AGENT-FLEET.md`
|
||||||
**Current Milestone:** —
|
**Phase:** Completion
|
||||||
**Progress:** 0 / 6 milestones
|
**Status:** completed
|
||||||
**Status:** active
|
**Last Updated:** 2026-03-05
|
||||||
**Last Updated:** 2026-02-28 17:10 UTC
|
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
<!-- Define measurable success criteria here -->
|
1. ✅ AgentTemplate and UserAgent tables exist and are seeded with jarvis/builder/medic
|
||||||
|
2. ✅ Admin CRUD endpoints at `/admin/agent-templates` work and are guarded
|
||||||
|
3. ✅ User agent CRUD endpoints allow per-user agent customization
|
||||||
|
4. ✅ Chat proxy routes messages to correct agent by name
|
||||||
|
5. ✅ Discord channel → agent routing maps #jarvis/#builder/#medic-alerts
|
||||||
|
6. ✅ WebUI shows agent selector and connects to correct agent
|
||||||
|
7. ✅ All CI gates green
|
||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
| # | ID | Name | Status | Tasks | Notes |
|
||||||
| --- | ------- | -------------------------- | ------- | ------ | ----- | ------- | --------- |
|
| --- | ------------- | ------------- | ------- | ---------------------- | --------------------------- |
|
||||||
| 1 | phase-1 | Schema and Admin API | pending | — | — | — | — |
|
| 1 | schema-seed | Schema+Seed | ✅ done | P2-001, P2-002 | PRs #675, #677 merged |
|
||||||
| 2 | phase-2 | Break-Glass Authentication | pending | — | — | — | — |
|
| 2 | admin-crud | Admin CRUD | ✅ done | P2-003 | PR #678 merged |
|
||||||
| 3 | phase-3 | Data Migration | pending | — | — | — | — |
|
| 3 | user-crud | User CRUD | ✅ done | P2-004 | PR #682 merged |
|
||||||
| 4 | phase-4 | Admin UI | pending | — | — | — | — |
|
| 4 | agent-routing | Agent Routing | ✅ done | P2-005, P2-006 | PR #684 merged |
|
||||||
| 5 | phase-5 | RBAC UI Enforcement | pending | — | — | — | — |
|
| 5 | discord-ui | Discord+UI | ✅ done | P2-007, P2-008, P2-009 | PRs #685, #687, #688 merged |
|
||||||
| 6 | phase-6 | Verification | pending | — | — | — | — |
|
| 6 | verification | Verification | ✅ done | P2-010 | All CI gates green |
|
||||||
|
|
||||||
## Deployment
|
## Task Summary
|
||||||
|
|
||||||
| Target | URL | Method |
|
See `docs/TASKS.md` — MS22 Phase 2 section for full task details.
|
||||||
| ------ | --- | ------ |
|
|
||||||
| — | — | — |
|
| Task | Status | PR | Notes |
|
||||||
|
| ----------------------- | ------- | ---- | ------------------------------ |
|
||||||
|
| P2-001 Schema | ✅ done | #675 | AgentTemplate + UserAgent |
|
||||||
|
| P2-002 Seed | ✅ done | #677 | jarvis/builder/medic templates |
|
||||||
|
| P2-003 Admin CRUD | ✅ done | #678 | /admin/agent-templates |
|
||||||
|
| P2-004 User CRUD | ✅ done | #682 | /api/agents |
|
||||||
|
| P2-005 Status endpoints | ✅ done | #684 | Agent status API |
|
||||||
|
| P2-006 Chat routing | ✅ done | #684 | Agent routing in chat proxy |
|
||||||
|
| P2-007 Discord routing | ✅ done | #688 | Channel → agent routing |
|
||||||
|
| P2-008 WebUI selector | ✅ done | #685 | AgentSelector component |
|
||||||
|
| P2-009 Unit tests | ✅ done | #687 | Agent services tests |
|
||||||
|
| P2-010 E2E verification | ✅ done | — | 3547 tests pass, CI green |
|
||||||
|
|
||||||
## Token Budget
|
## Token Budget
|
||||||
|
|
||||||
| Metric | Value |
|
| Phase | Est | Used |
|
||||||
| ------ | ------ |
|
| ----------------- | -------- | -------- |
|
||||||
| Budget | — |
|
| Schema+Seed+CRUD | 30K | ~15K |
|
||||||
| Used | 0 |
|
| User CRUD+Routing | 40K | ~25K |
|
||||||
| Mode | normal |
|
| Discord+UI | 30K | ~24K |
|
||||||
|
| Verification | 10K | ~5K |
|
||||||
|
| **Total** | **110K** | **~69K** |
|
||||||
|
|
||||||
## Session History
|
## Session Log
|
||||||
|
|
||||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
| Date | Work Done |
|
||||||
| ------- | ------- | ------- | -------- | ------------ | --------- |
|
| ---------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 2026-03-05 | Session 5: Completed P2-010 E2E verification. All 10 tasks done. Mission complete. |
|
||||||
## Scratchpad
|
| 2026-03-05 | Session 4: Completed P2-007 (Discord routing) PR #688. Milestone 5 complete. 9/10 tasks done, only E2E remains. |
|
||||||
|
| 2026-03-05 | Session 3: Completed P2-008 (WebUI agent selector) PR #685. Milestones 1-4 + P2-008 complete (3 tasks remaining). |
|
||||||
Path: `docs/scratchpads/ms21-multi-tenant-rbac-data-migration-20260228.md`
|
| 2026-03-04 | Session 2: Fixed CI security audit, merged PRs #681, #678, #682. Milestones 1-3 complete (4/6 remaining). |
|
||||||
|
| 2026-03-04 | P2-001..003 shipped; CI fix; postgres rebuilt; mission initialized |
|
||||||
|
|||||||
182
docs/PRD-MS22-P2-AGENT-FLEET.md
Normal file
182
docs/PRD-MS22-P2-AGENT-FLEET.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# PRD: MS22 Phase 2 — Named Agent Fleet
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
- **Owner:** Jason Woltje
|
||||||
|
- **Date:** 2026-03-04
|
||||||
|
- **Status:** draft
|
||||||
|
- **Design Doc:** `~/src/jarvis-brain/docs/planning/FLEET-EVOLUTION-PLAN.md`
|
||||||
|
- **Depends On:** MS22 Phase 1 (DB-Centric Architecture) — COMPLETE
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Mosaic Stack has the infrastructure for per-user containers and knowledge layer, but no predefined agent personalities. Users start with a blank slate. For Jason's personal use case, we need named agents with distinct roles, personalities, and tool access that can collaborate through the shared knowledge layer.
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
|
||||||
|
1. **Named agents** — jarvis (orchestrator), builder (coding), medic (monitoring)
|
||||||
|
2. **Per-agent model assignment** — Opus for jarvis, Codex for builder, Haiku for medic
|
||||||
|
3. **Tool permissions** — Restrict dangerous tools to appropriate agents
|
||||||
|
4. **Discord bindings** — Route agents to specific channels
|
||||||
|
5. **Mosaic skill** — All agents can read/write findings and memory
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
- Agent personality definitions (SOUL.md for each)
|
||||||
|
- Agent registry in Mosaic DB
|
||||||
|
- Per-agent model configuration
|
||||||
|
- Per-agent tool permission sets
|
||||||
|
- Discord channel routing
|
||||||
|
- Default agent templates for new users
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
- Matrix observation rooms (nice-to-have)
|
||||||
|
- WebUI chat improvements (separate phase)
|
||||||
|
- Cross-agent quality gates (future)
|
||||||
|
- Team workspaces (future)
|
||||||
|
|
||||||
|
## Agent Definitions
|
||||||
|
|
||||||
|
### Jarvis — Orchestrator
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **Role** | Main orchestrator, user-facing assistant |
|
||||||
|
| **Model** | Opus (primary), Sonnet (fallback) |
|
||||||
|
| **Tools** | All tools — full access |
|
||||||
|
| **Discord** | #jarvis |
|
||||||
|
| **Personality** | Capable, direct, proactive. Gets stuff done without hand-holding. Thinks before acting, speaks up when seeing a better way. NOT a yes-man. |
|
||||||
|
|
||||||
|
### Builder — Coding Agent
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
| --------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| **Role** | Code implementation, PRs, refactoring |
|
||||||
|
| **Model** | Codex (primary, uses OpenAI credits), Sonnet (fallback) |
|
||||||
|
| **Tools** | exec, read, write, edit, github, browser |
|
||||||
|
| **Discord** | #builder |
|
||||||
|
| **Personality** | Focused, thorough. Writes clean code. Tests before declaring done. Documents decisions. |
|
||||||
|
|
||||||
|
### Medic — Health Monitoring
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
| --------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| **Role** | System health checks, alerts, monitoring |
|
||||||
|
| **Model** | Haiku (primary), MiniMax (fallback) |
|
||||||
|
| **Tools** | exec (SSH), nodes, cron, message (alerts only) |
|
||||||
|
| **Discord** | #medic-alerts |
|
||||||
|
| **Personality** | Vigilant, concise. Alerts on anomalies. Proactive health checks. Minimal noise. |
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
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
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Agent Templates (Admin)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/agent-templates — List all templates
|
||||||
|
POST /api/admin/agent-templates — Create template
|
||||||
|
GET /api/admin/agent-templates/:id — Get template
|
||||||
|
PATCH /api/admin/agent-templates/:id — Update template
|
||||||
|
DELETE /api/admin/agent-templates/:id — Delete template
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Agents
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/agents — List user's agents
|
||||||
|
POST /api/agents — Create custom agent (or from template)
|
||||||
|
GET /api/agents/:id — Get agent details
|
||||||
|
PATCH /api/agents/:id — Update agent (personality, model)
|
||||||
|
DELETE /api/agents/:id — Delete custom agent
|
||||||
|
POST /api/agents/:id/chat — Chat with agent (proxy to container)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent Status
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/agents/status — All agents status for user
|
||||||
|
GET /api/agents/:id/status — Single agent status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Breakdown
|
||||||
|
|
||||||
|
| Task ID | Phase | Description | Scope | Dependencies | Estimate |
|
||||||
|
| -------------- | ------- | ---------------------------------------------- | ----- | ------------ | -------- |
|
||||||
|
| P2-DB-001 | schema | Prisma models: AgentTemplate, UserAgent | api | P1a | 10K |
|
||||||
|
| P2-SEED-001 | seed | Seed default agents (jarvis, builder, medic) | api | P2-DB-001 | 5K |
|
||||||
|
| P2-API-001 | api | Agent template CRUD endpoints | api | P2-DB-001 | 15K |
|
||||||
|
| P2-API-002 | api | User agent CRUD endpoints | api | P2-DB-001 | 15K |
|
||||||
|
| P2-API-003 | api | Agent status endpoints | api | P2-DB-001 | 10K |
|
||||||
|
| P2-PROXY-001 | api | Agent chat routing (select agent by name) | api | P2-API-002 | 15K |
|
||||||
|
| P2-DISCORD-001 | discord | Route Discord messages to correct agent | api | P2-PROXY-001 | 15K |
|
||||||
|
| P2-UI-001 | web | Agent list/selector in WebUI | web | P2-API-002 | 15K |
|
||||||
|
| P2-UI-002 | web | Agent detail/edit page | web | P2-UI-001 | 15K |
|
||||||
|
| P2-TEST-001 | test | Unit tests for agent services | api | P2-API-002 | 15K |
|
||||||
|
| P2-VER-001 | verify | End-to-end: Discord → correct agent → response | stack | all | 10K |
|
||||||
|
|
||||||
|
**Total Estimate:** ~140K tokens
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
1. ✅ User can list available agents in WebUI
|
||||||
|
2. ✅ User can select agent and chat with it
|
||||||
|
3. ✅ Discord messages in #jarvis go to jarvis agent
|
||||||
|
4. ✅ Discord messages in #builder go to builder agent
|
||||||
|
5. ✅ Each agent uses its assigned model
|
||||||
|
6. ✅ Each agent has correct tool permissions
|
||||||
|
7. ✅ Agents can read/write findings via mosaic skill
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
| --------------------------- | ------------------------------------------------ |
|
||||||
|
| Agent routing complexity | Keep it simple: map Discord channel → agent name |
|
||||||
|
| Tool permission enforcement | OpenClaw config generation respects permissions |
|
||||||
|
| Model fallback failures | Log and alert, don't block user |
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Review this PRD with Jason
|
||||||
|
2. Create Mission MS22-P2 in TASKS.md
|
||||||
|
3. Begin with P2-DB-001 (schema)
|
||||||
566
docs/PRD-MS22-platform.md
Normal file
566
docs/PRD-MS22-platform.md
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
# PRD: Mosaic Stack Dashboard & Platform Implementation
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
- Owner: Jason Woltje
|
||||||
|
- Date: 2026-02-22
|
||||||
|
- Status: in-progress
|
||||||
|
- Best-Guess Mode: true
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard that doesn't match the production-ready design vision. The reference design (dashboard.html) defines a comprehensive command center UI with sidebar navigation, topbar, terminal panel, and multiple page layouts. The current implementation uses mismatched design tokens (raw Tailwind colors vs CSS variables), has no collapsible sidebar, no global terminal, and lacks the polished design system from the reference.
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
|
||||||
|
1. Implement the dashboard.html reference design as the production UI foundation
|
||||||
|
2. Establish a consistent CSS design token system that supports multiple themes
|
||||||
|
3. Build a responsive, accessible app shell with collapsible sidebar and full-width header
|
||||||
|
4. Create a theme system supporting installable theme packages
|
||||||
|
5. Build all dashboard pages (Dashboard, Projects, Workspace, Kanban, Files, Logs, Settings, Profile)
|
||||||
|
6. Implement real backend integration (no mock data)
|
||||||
|
7. Support multi-tenant configuration with RBAC
|
||||||
|
8. Implement federation (master-master and master-slave)
|
||||||
|
9. Build global terminal, project chat, and master chat session
|
||||||
|
10. Configure telemetry with opt-out support
|
||||||
|
|
||||||
|
## Completed Work
|
||||||
|
|
||||||
|
### MS15-DashboardShell (v0.0.15) — Complete
|
||||||
|
|
||||||
|
Design system + app shell + dashboard page. PRs #451-454.
|
||||||
|
|
||||||
|
- CSS design token system (colors, fonts, spacing, radii)
|
||||||
|
- App shell layout: collapsible sidebar + full-width header + main content
|
||||||
|
- Sidebar navigation with groups, icons, badges, active states, collapse/expand
|
||||||
|
- Responsive layout with hamburger at small breakpoints
|
||||||
|
- Light/dark theme matching reference design
|
||||||
|
- Mosaic logo spinner as global loading indicator
|
||||||
|
- Shared component updates in packages/ui
|
||||||
|
- Dashboard page: metrics strip, orchestrator sessions, quick actions, activity feed, token budget
|
||||||
|
- Grain overlay texture
|
||||||
|
|
||||||
|
### Go-Live MVP (v0.0.16) — Complete
|
||||||
|
|
||||||
|
Dashboard polish, task ingestion pipeline, agent cycle visibility, deploy + smoke test. PRs #458, #460, #462, #464.
|
||||||
|
|
||||||
|
- Fixed broken test suites and removed legacy unused widgets
|
||||||
|
- Visual + theme polish across all components
|
||||||
|
- Dashboard summary API endpoint (aggregated task counts, project counts, activity, jobs)
|
||||||
|
- Dashboard widgets wired to real API data (ActivityFeed, DashboardMetrics, OrchestratorSessions)
|
||||||
|
- WebSocket emits for job status/progress/step events
|
||||||
|
- Dashboard auto-refresh with polling + progress bars + step status indicators
|
||||||
|
- Deployed to mosaic.woltje.com, auth working via Authentik
|
||||||
|
- Release tag v0.0.16
|
||||||
|
|
||||||
|
### MS16+MS17-PagesDataIntegration (v0.0.17) — Complete
|
||||||
|
|
||||||
|
All pages built + wired to real API data. PRs #470-484 (15 PRs). Issues #466-469.
|
||||||
|
|
||||||
|
- Custom 404 pages (global + authenticated route groups)
|
||||||
|
- Settings root page with 4 category cards
|
||||||
|
- Tasks, Calendar, Knowledge pages wired to real API (238+ lines mock data removed)
|
||||||
|
- Projects list page with create/delete dialogs
|
||||||
|
- Project Workspace page with tabbed view (Tasks, Agent Sessions, Settings)
|
||||||
|
- Kanban board with drag-and-drop (@hello-pangea/dnd), 5 status columns, optimistic updates
|
||||||
|
- File Manager page with list/grid views, search, create/delete
|
||||||
|
- Logs & Telemetry page with auto-refresh, expandable rows, filters
|
||||||
|
- Profile page with user info and preferences
|
||||||
|
- All 5125 tests passing, CI pipeline #585 green
|
||||||
|
- Deployed and smoke-tested at mosaic.woltje.com
|
||||||
|
|
||||||
|
### MS18-ThemeWidgets (v0.0.18) — Complete
|
||||||
|
|
||||||
|
Theme package system, widget registry, WYSIWYG editor, Kanban filtering. PRs #493-505. Issues #487-491.
|
||||||
|
|
||||||
|
- 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TypeScript theme packages
|
||||||
|
- ThemeProvider with dynamic CSS variable application and instant switching
|
||||||
|
- Theme selection UI in Settings with live preview swatches
|
||||||
|
- Widget definition registry with configurable sizing and schemas
|
||||||
|
- WidgetGrid dashboard with drag-and-drop layout (react-grid-layout)
|
||||||
|
- Widget picker drawer for adding widgets from registry
|
||||||
|
- Per-widget configuration dialog driven by configSchema
|
||||||
|
- Layout save/load/rename/delete via UserLayout API
|
||||||
|
- Tiptap WYSIWYG editor for knowledge entries with toolbar
|
||||||
|
- Markdown round-trip (import/export)
|
||||||
|
- Kanban board filtering by project, assignee, priority, search with URL persistence
|
||||||
|
- 1,195 web tests, 3,243 API tests passing
|
||||||
|
|
||||||
|
### MS19-ChatTerminal (v0.0.19) — Complete
|
||||||
|
|
||||||
|
Real terminal with PTY backend, chat streaming, orchestrator integration. PRs #515-522. Issues #508-512.
|
||||||
|
|
||||||
|
- NestJS WebSocket gateway (/terminal namespace) with node-pty for real shell sessions
|
||||||
|
- Terminal session persistence in PostgreSQL (Prisma model: TerminalSession)
|
||||||
|
- xterm.js integration with FitAddon, WebLinksAddon, CSS variable theme support
|
||||||
|
- Multi-session terminal tabs: create/close/rename, tab switching, session recovery
|
||||||
|
- SSE chat streaming with token-by-token rendering, abort/cancel support
|
||||||
|
- Master chat polish: model selector dropdown, temperature/maxTokens config, ChatEmptyState
|
||||||
|
- Orchestrator command system: /status, /agents, /jobs, /pause, /resume, /help
|
||||||
|
- Agent output terminal: SSE streaming from orchestrator, lifecycle indicators, read-only view
|
||||||
|
- Command autocomplete with keyboard navigation in chat input
|
||||||
|
- 328 MS19-specific tests (268 web + 60 API), 4744 total passing
|
||||||
|
- Deployed and smoke-tested at mosaic.woltje.com (CI #635 green)
|
||||||
|
|
||||||
|
### Bugfix: API Global Prefix (post-MS18) — Complete
|
||||||
|
|
||||||
|
PR #507. Fixed systemic 404 on all data endpoints.
|
||||||
|
|
||||||
|
- Added `setGlobalPrefix("api")` to NestJS with exclusions for /health and /auth/\*
|
||||||
|
- Normalized 6 federation controllers to remove redundant api/ prefix
|
||||||
|
- Fixed rollup CVE (GHSA-mw96-cpmx-2vgc) via pnpm override
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope (MS16+MS17 — Pages & Data Integration)
|
||||||
|
|
||||||
|
This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) are combined because the backend API modules already exist — the work is primarily frontend page creation and API wiring.
|
||||||
|
|
||||||
|
1. Projects list page with CRUD (wire to existing `/api/projects`)
|
||||||
|
2. Project workspace/detail page (wire to `/api/projects/:id`, `/api/tasks`, `/api/runner-jobs`)
|
||||||
|
3. Kanban board page with status-based columns (wire to existing `/api/tasks`)
|
||||||
|
4. File Manager page with tree/list view and CRUD (wire to existing `/api/knowledge`)
|
||||||
|
5. Logs & Telemetry page with log viewer and filtering (wire to `/api/runner-jobs`, job steps, events)
|
||||||
|
6. Settings root/index page linking to existing subpages
|
||||||
|
7. Custom 404 page for unknown routes
|
||||||
|
8. Wire `/tasks` page to real API data (currently mock)
|
||||||
|
9. Wire `/calendar` page to real API data (currently mock)
|
||||||
|
10. Wire `/knowledge` pages to real API data (currently mock)
|
||||||
|
|
||||||
|
### In Scope (Future Milestones — Documented for Planning)
|
||||||
|
|
||||||
|
11. Theme system with installable theme packages (MS18)
|
||||||
|
12. Widget system with installable widget packages, customizable sizes (MS18)
|
||||||
|
13. Global terminal: project/orchestrator level, smart (MS19)
|
||||||
|
14. Project-level orchestrator chat (MS19)
|
||||||
|
15. Master chat session: collapsible sidebar/slideout, always available (MS19)
|
||||||
|
16. Site stabilization: workspace context propagation for mutations (MS20)
|
||||||
|
17. Site stabilization: personalities API + UI (MS20)
|
||||||
|
18. Site stabilization: user preferences API endpoint (MS20)
|
||||||
|
19. Site stabilization: orchestrator 502 and WebSocket connectivity (MS20)
|
||||||
|
20. Site stabilization: credential management UI (MS20)
|
||||||
|
21. Site stabilization: terminal page route (MS20)
|
||||||
|
22. Site stabilization: favicon, dark mode dropdown fix (MS20)
|
||||||
|
23. Settings page for ALL environment variables, dynamically configurable via webUI (MS21)
|
||||||
|
24. Multi-tenant configuration with admin user management (MS21)
|
||||||
|
25. Team management with shared data spaces and chat rooms (MS21)
|
||||||
|
26. RBAC for file access, resources, models (MS21)
|
||||||
|
27. Federation: master-master and master-slave with key exchange (MS22)
|
||||||
|
28. Federation testing: 3 instances on Portainer (woltje.com domain) (MS22)
|
||||||
|
29. Agent task mapping configuration: system-level defaults, user-level overrides (MS23)
|
||||||
|
30. Telemetry: opt-out, customizable endpoint, sanitized data (MS23)
|
||||||
|
31. File manager with WYSIWYG editing: system/user/project levels (MS18)
|
||||||
|
32. User-level and project-level Kanban with filtering (MS18)
|
||||||
|
33. Break-glass authentication user (MS20)
|
||||||
|
34. Playwright E2E tests for all pages (MS23)
|
||||||
|
35. API documentation via Swagger (MS23)
|
||||||
|
36. Backend endpoints for all dashboard data (MS17 — already complete for existing modules)
|
||||||
|
37. Profile page linked from user card (MS16)
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
1. Mobile native app
|
||||||
|
2. Third-party marketplace for themes/widgets (initial implementation is local package management only)
|
||||||
|
3. Mobile native app deployment targets
|
||||||
|
4. Calendar system redesign (existing calendar implementation is retained)
|
||||||
|
|
||||||
|
## User/Stakeholder Requirements
|
||||||
|
|
||||||
|
1. The `jarvis` user must be able to log into mosaic.woltje.com via Authentik as administrator with access to all pages
|
||||||
|
2. A standard `jarvis-user` must operate at a lower permission level
|
||||||
|
3. A break-glass user must have access without Authentik authentication
|
||||||
|
4. All pages must be navigable without errors (no 404s from sidebar links)
|
||||||
|
5. Light and dark themes must work across all pages and components
|
||||||
|
6. Sidebar must be collapsible with open/close button; hidden by default at small breakpoints
|
||||||
|
7. Hamburger button visible at lower breakpoints for sidebar control
|
||||||
|
8. The Mosaic Stack logo icon must be the site-wide loading spinner
|
||||||
|
9. No mock data — all data pulled from backend APIs
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
### FR-001: Design Token System
|
||||||
|
|
||||||
|
- CSS custom properties for all colors, spacing, typography, radii
|
||||||
|
- Dark theme as default (`:root`), light theme via `[data-theme="light"]`
|
||||||
|
- Fonts: Outfit (body), Fira Code (monospace)
|
||||||
|
- All components must use design tokens, never hardcoded colors
|
||||||
|
- **Status: COMPLETE (MS15)**
|
||||||
|
|
||||||
|
### FR-002: App Shell Layout
|
||||||
|
|
||||||
|
- CSS Grid: sidebar column + header row + main content
|
||||||
|
- Full-width header spanning above sidebar and content
|
||||||
|
- ASSUMPTION: Header spans full width including above sidebar area. The logo is in the header, not the sidebar. Rationale: User explicitly stated "The logo will NOT be part of the sidebar."
|
||||||
|
- **Status: COMPLETE (MS15)**
|
||||||
|
|
||||||
|
### FR-003: Sidebar Navigation
|
||||||
|
|
||||||
|
- Nav groups: Overview (Dashboard), Workspace (Projects, Project Workspace, Kanban, File Manager), Operations (Logs & Telemetry, Terminal), System (Settings)
|
||||||
|
- Collapsible: icon-only mode when collapsed
|
||||||
|
- Active state indicator (left border accent)
|
||||||
|
- User card in footer with avatar, name, role, online status
|
||||||
|
- ASSUMPTION: Sidebar footer user card navigates to Profile page. Rationale: Matches reference design behavior.
|
||||||
|
- **Status: COMPLETE (MS15+MS16) — Profile page added in PR #482.**
|
||||||
|
|
||||||
|
### FR-004: Header/Topbar
|
||||||
|
|
||||||
|
- Logo + brand wordmark (left)
|
||||||
|
- Search bar with keyboard shortcut hint
|
||||||
|
- System status indicator
|
||||||
|
- Terminal toggle button
|
||||||
|
- Notification bell with badge
|
||||||
|
- Theme toggle (sun/moon icon)
|
||||||
|
- User avatar button with dropdown (Profile, Account Settings, Sign Out)
|
||||||
|
- **Status: COMPLETE (MS15)**
|
||||||
|
|
||||||
|
### FR-005: Responsive Design
|
||||||
|
|
||||||
|
- Breakpoints: sm (640px), md (768px), lg (1024px), xl (1280px)
|
||||||
|
- Below md: sidebar hidden, hamburger button in header
|
||||||
|
- md-lg: sidebar can be toggled
|
||||||
|
- lg+: sidebar visible by default
|
||||||
|
- **Status: COMPLETE (MS15)**
|
||||||
|
|
||||||
|
### FR-006: Dashboard Page
|
||||||
|
|
||||||
|
- 6-cell metrics strip with colored top borders and trend indicators
|
||||||
|
- Active Orchestrator Sessions card with agent nodes
|
||||||
|
- Quick Actions 2x2 grid
|
||||||
|
- Activity Feed sidebar card
|
||||||
|
- Token Budget sidebar card with progress bars
|
||||||
|
- Wired to real API via `/api/dashboard/summary`
|
||||||
|
- **Status: COMPLETE (Go-Live MVP)**
|
||||||
|
|
||||||
|
### FR-007: Loading Spinner
|
||||||
|
|
||||||
|
- Mosaic logo icon (4 corner squares + center circle) with CSS rotation animation
|
||||||
|
- Used as global loading indicator across all pages
|
||||||
|
- Available as a shared component
|
||||||
|
- **Status: COMPLETE (MS15)**
|
||||||
|
|
||||||
|
### FR-008: Projects Page (MS16)
|
||||||
|
|
||||||
|
- Projects list view with card or table layout
|
||||||
|
- Project creation dialog/form
|
||||||
|
- Project detail view (name, description, status, created/updated timestamps)
|
||||||
|
- Wire to existing `/api/projects` (full CRUD already implemented)
|
||||||
|
- Navigate from sidebar → /projects
|
||||||
|
- **Status: COMPLETE (MS16) — PR #477. Card layout, create/delete dialogs, status badges.**
|
||||||
|
|
||||||
|
### FR-009: Project Workspace Page (MS16)
|
||||||
|
|
||||||
|
- Single-project view showing tasks, agent sessions, and project settings
|
||||||
|
- Task list for selected project
|
||||||
|
- Agent session history and status
|
||||||
|
- Wire to `/api/projects/:id`, `/api/tasks`, `/api/runner-jobs`
|
||||||
|
- Navigate from sidebar → /workspace (with project context)
|
||||||
|
- **Status: COMPLETE (MS16) — PR #479. Tabbed view (Tasks, Agent Sessions, Settings), project selector mode.**
|
||||||
|
|
||||||
|
### FR-010: Kanban Board Page (MS16)
|
||||||
|
|
||||||
|
- Drag-and-drop board with columns mapped to task status values
|
||||||
|
- Task cards showing title, assignee, priority, status
|
||||||
|
- Column headers with task counts
|
||||||
|
- Wire to existing `/api/tasks` (status field drives columns)
|
||||||
|
- Navigate from sidebar → /kanban
|
||||||
|
- **Status: COMPLETE (MS16) — PR #478. 5 columns (NOT_STARTED→ARCHIVED), @hello-pangea/dnd, optimistic updates.**
|
||||||
|
|
||||||
|
### FR-011: File Manager Page (MS16)
|
||||||
|
|
||||||
|
- Tree or list view of knowledge entries
|
||||||
|
- CRUD operations (create, read, update, delete)
|
||||||
|
- Search functionality
|
||||||
|
- Wire to existing `/api/knowledge` (full CRUD + search already implemented)
|
||||||
|
- Navigate from sidebar → /files
|
||||||
|
- **Status: COMPLETE (MS16) — PR #481. List+grid views, search, create/delete dialogs.**
|
||||||
|
|
||||||
|
### FR-012: Logs & Telemetry Page (MS16)
|
||||||
|
|
||||||
|
- Log viewer with timestamp, level, source, message columns
|
||||||
|
- Filtering by level, source, date range
|
||||||
|
- Auto-refresh for live logs
|
||||||
|
- Wire to existing runner-jobs, job steps, and events APIs
|
||||||
|
- Navigate from sidebar → /logs
|
||||||
|
- **Status: COMPLETE (MS16) — PR #480. Auto-refresh (5s polling), expandable rows, filters.**
|
||||||
|
|
||||||
|
### FR-013: Settings Root Page (MS16)
|
||||||
|
|
||||||
|
- Landing/index page for settings
|
||||||
|
- Category cards linking to existing subpages: Credentials, Domains, Personalities, Workspaces
|
||||||
|
- Navigate from sidebar → /settings (currently 404; subpages exist)
|
||||||
|
- **Status: COMPLETE (MS16) — PR #471. 4 category cards with icons and hover states.**
|
||||||
|
|
||||||
|
### FR-014: Custom 404 Page (MS16)
|
||||||
|
|
||||||
|
- Branded 404 page matching design system
|
||||||
|
- Helpful message and navigation link back to dashboard
|
||||||
|
- Applied to all unmatched routes within authenticated layout
|
||||||
|
- **Status: COMPLETE (MS16) — PR #472. Global + authenticated route-group 404 pages.**
|
||||||
|
|
||||||
|
### FR-015: Mock Data Elimination (MS16+MS17)
|
||||||
|
|
||||||
|
- `/tasks` page: replace mock data with `/api/tasks` calls
|
||||||
|
- `/calendar` page: replace mock data with `/api/events` calls
|
||||||
|
- `/knowledge` pages: replace mock data with `/api/knowledge` calls
|
||||||
|
- All pages must render real data from backend APIs
|
||||||
|
- **Status: COMPLETE (MS16+MS17) — PRs #473-#476. 238+ lines of mock data removed.**
|
||||||
|
|
||||||
|
### FR-016: Theme System (MS18) — COMPLETE
|
||||||
|
|
||||||
|
- 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TypeScript theme packages
|
||||||
|
- ThemeProvider loads themes dynamically, applies CSS variables, instant switching
|
||||||
|
- Theme selection UI in Settings with live preview swatches
|
||||||
|
- UserPreference.theme persists selection across sessions
|
||||||
|
- **Status: COMPLETE (MS18) — PRs #493-495**
|
||||||
|
|
||||||
|
### FR-017: Terminal Panel (MS19) — COMPLETE
|
||||||
|
|
||||||
|
- Bottom drawer panel, toggleable from header and sidebar
|
||||||
|
- Real xterm.js terminal with PTY backend via WebSocket
|
||||||
|
- Multiple tabs: shell sessions, orchestrator agent output, build logs
|
||||||
|
- Terminal session persistence (create/close/rename tabs)
|
||||||
|
- Smart terminal operating at project/orchestrator level
|
||||||
|
- ASSUMPTION: Terminal backend uses node-pty for PTY management, communicating via WebSocket namespace (/terminal). Rationale: node-pty is the standard for Node.js terminal emulation, used by VS Code.
|
||||||
|
- ASSUMPTION: Terminal sessions are workspace-scoped and stored in PostgreSQL for recovery. Rationale: Consistent with existing workspace isolation pattern.
|
||||||
|
- **Status: COMPLETE (MS19) — PRs #515 (gateway), #517 (persistence), #518 (xterm.js), #520 (tabs), #522 (agent tabs). 60 API + 176 web tests.**
|
||||||
|
|
||||||
|
### FR-018: Chat Streaming & Master Chat (MS19) — COMPLETE
|
||||||
|
|
||||||
|
- Complete SSE streaming for token-by-token chat rendering
|
||||||
|
- Master chat sidebar (ChatOverlay) polish: model selector, conversation search, keyboard shortcuts
|
||||||
|
- Chat persistence via Ideas API (already implemented)
|
||||||
|
- ASSUMPTION: Chat streaming uses existing SSE infrastructure in LLM controller. Frontend needs streamChatMessage() completion. Rationale: Backend SSE is already working, only frontend wiring is missing.
|
||||||
|
- **Status: COMPLETE (MS19) — PRs #516 (streaming), #519 (polish). Model selector, temperature/maxTokens config, ChatEmptyState, Cmd+N/L shortcuts. 78 web tests.**
|
||||||
|
|
||||||
|
### FR-019: Project-Level Orchestrator Chat (MS19) — COMPLETE
|
||||||
|
|
||||||
|
- Chat context scoped to active project
|
||||||
|
- Can trigger orchestrator actions: spawn agent, check status, view jobs
|
||||||
|
- Command prefix system (/spawn, /status, /jobs) parsed in chat
|
||||||
|
- Agent output viewable in terminal tabs
|
||||||
|
- ASSUMPTION: Orchestrator commands route through existing web proxy (/api/orchestrator/\*) to orchestrator service. Rationale: Proxy routes already exist and handle auth.
|
||||||
|
- **Status: COMPLETE (MS19) — PRs #521 (commands), #522 (agent terminal). /status, /agents, /jobs, /pause, /resume, /help commands. Agent output streaming via SSE. 113 web tests.**
|
||||||
|
|
||||||
|
### FR-020: Site Stabilization & Feature Gaps (MS20) — IN PROGRESS
|
||||||
|
|
||||||
|
Runtime bugs and feature gaps discovered during live testing of mosaic.woltje.com.
|
||||||
|
|
||||||
|
**Workspace Context Propagation:**
|
||||||
|
|
||||||
|
- Domains page: "Workspace ID is required" when creating domains
|
||||||
|
- Projects page: "Workspace ID is required" when creating projects
|
||||||
|
- Credentials page: unable to add credentials (button disabled, feature stub)
|
||||||
|
- ASSUMPTION: The `useWorkspaceId()` hook + auto-detect in `apiRequest` from PR #532 handles reads, but mutation endpoints on some pages don't pass workspace ID correctly. Rationale: GET requests work after PR #532 but POST/mutation requests still fail on domains and projects pages.
|
||||||
|
|
||||||
|
**Missing API Endpoints:**
|
||||||
|
|
||||||
|
- `/api/personalities` — no controller/service exists; frontend expects GET/POST/PATCH/DELETE
|
||||||
|
- `/users/me/preferences` — listed in PRD API table but returns 404; frontend profile page depends on it
|
||||||
|
- ASSUMPTION: Personalities API follows existing NestJS module patterns (controller + service + DTO + Prisma model). Rationale: Consistent with all other API modules in the codebase.
|
||||||
|
- ASSUMPTION: User preferences endpoint is part of the existing users module but route is not registered. Rationale: PRD lists it as an existing endpoint.
|
||||||
|
|
||||||
|
**Orchestrator Connectivity:**
|
||||||
|
|
||||||
|
- All orchestrator-proxied endpoints return HTTP 502
|
||||||
|
- Orchestrator WebSocket connection fails ("Reconnecting to server...")
|
||||||
|
- Dashboard widgets: Agent Status, Task Progress, Orchestrator Events all error
|
||||||
|
- ASSUMPTION: The orchestrator service container runs but the Next.js API proxy cannot reach it. Root cause is likely environment variable or network configuration in Docker Swarm. Rationale: The orchestrator container exists in the compose file and has Traefik labels.
|
||||||
|
|
||||||
|
**UI/UX Issues:**
|
||||||
|
|
||||||
|
- Dark mode theming on Formality Level dropdown in Personalities page incorrect
|
||||||
|
- favicon.ico missing (404)
|
||||||
|
- Terminal sidebar link uses `#terminal` anchor instead of page route
|
||||||
|
- `useWorkspaceId` warning in console: no workspace ID in localStorage on fresh sessions
|
||||||
|
- ASSUMPTION: Terminal should have a dedicated page route `/terminal` that renders the terminal panel full-screen. Rationale: The sidebar has a Terminal link in the Operations section alongside Logs, implying it should be a navigable page.
|
||||||
|
|
||||||
|
**Credential Management:**
|
||||||
|
|
||||||
|
- "Add Credential" button is `disabled` in code — feature was stubbed as "coming soon"
|
||||||
|
- Need to implement credential creation UI and wire to existing `/api/credentials` CRUD endpoints
|
||||||
|
- ASSUMPTION: Credential CRUD frontend can use the existing `/api/credentials` API which was built during M7-CredentialSecurity. Rationale: Backend endpoints exist per audit.
|
||||||
|
|
||||||
|
### FR-021: Settings Configuration (Future — MS21)
|
||||||
|
|
||||||
|
- All environment variables configurable via UI
|
||||||
|
- Minimal launch env vars, rest configurable dynamically
|
||||||
|
- Settings stored in DB with RLS
|
||||||
|
- Theme selection, widget management, federation config, telemetry config
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
1. Security: All API endpoints require authentication. RBAC enforced. No PII in telemetry. Secrets never hardcoded.
|
||||||
|
2. Performance: Dashboard loads in <2s. No layout shift during theme toggle. Sidebar toggle is instant (<100ms animation).
|
||||||
|
3. Reliability: Break-glass auth ensures access when Authentik is down.
|
||||||
|
4. Observability: Telemetry with opt-out support. Wide-event logging. Customizable telemetry endpoint.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### MS15-DashboardShell — COMPLETE
|
||||||
|
|
||||||
|
1. ~~Design tokens from dashboard.html are implemented in globals.css~~ DONE
|
||||||
|
2. ~~App shell shows full-width header with logo, collapsible sidebar, main content area~~ DONE
|
||||||
|
3. ~~Sidebar has all nav groups with icons, collapses to icon-only mode~~ DONE
|
||||||
|
4. ~~Hamburger button appears at mobile breakpoints, sidebar hidden by default~~ DONE
|
||||||
|
5. ~~Light/dark theme toggle works across all components~~ DONE
|
||||||
|
6. ~~Mosaic logo spinner is used as site-wide loading indicator~~ DONE
|
||||||
|
7. ~~Dashboard page shows metrics strip, orchestrator sessions, quick actions, activity feed, token budget~~ DONE
|
||||||
|
8. ~~All shared components in packages/ui use design tokens (no hardcoded colors)~~ DONE
|
||||||
|
9. ~~Lint, typecheck, and existing tests pass~~ DONE
|
||||||
|
10. ~~Grain overlay texture from reference is applied~~ DONE
|
||||||
|
|
||||||
|
### Go-Live MVP (v0.0.16) — COMPLETE
|
||||||
|
|
||||||
|
11. ~~Dashboard widgets wired to real API data~~ DONE
|
||||||
|
12. ~~WebSocket emits for agent job lifecycle~~ DONE
|
||||||
|
13. ~~Deployed to mosaic.woltje.com with auth working~~ DONE
|
||||||
|
|
||||||
|
### MS16+MS17 — Pages & Data Integration — COMPLETE
|
||||||
|
|
||||||
|
14. ~~All sidebar links navigate to functional pages (no 404s)~~ DONE
|
||||||
|
15. ~~Projects page: list, create, view project details~~ DONE
|
||||||
|
16. ~~Workspace page: view single project with tasks and agent sessions~~ DONE
|
||||||
|
17. ~~Kanban page: drag-and-drop board with task status columns~~ DONE
|
||||||
|
18. ~~File Manager page: tree/list view with CRUD operations~~ DONE
|
||||||
|
19. ~~Logs page: log viewer with filtering and auto-refresh~~ DONE
|
||||||
|
20. ~~Settings root page: category index linking to subpages~~ DONE
|
||||||
|
21. ~~Custom 404 page for unknown routes~~ DONE
|
||||||
|
22. ~~`/tasks` page uses real API data (no mock)~~ DONE
|
||||||
|
23. ~~`/calendar` page uses real API data (no mock)~~ DONE
|
||||||
|
24. ~~`/knowledge` pages use real API data (no mock)~~ DONE
|
||||||
|
25. ~~All new pages support light/dark theme~~ DONE
|
||||||
|
26. ~~All new pages are responsive (sm/md/lg/xl breakpoints)~~ DONE
|
||||||
|
27. ~~Lint, typecheck, and tests pass~~ DONE
|
||||||
|
28. ~~Deployed and smoke-tested at mosaic.woltje.com~~ DONE
|
||||||
|
|
||||||
|
### MS18 — Theme & Widget System — COMPLETE
|
||||||
|
|
||||||
|
29. ~~5+ themes with live preview and instant switching~~ DONE
|
||||||
|
30. ~~Theme selection UI in Settings with swatches~~ DONE
|
||||||
|
31. ~~UserPreference.theme persists across sessions~~ DONE
|
||||||
|
32. ~~WidgetGrid dashboard with drag/resize/add/remove~~ DONE
|
||||||
|
33. ~~Widget picker UI from registry~~ DONE
|
||||||
|
34. ~~Per-widget configuration dialog~~ DONE
|
||||||
|
35. ~~Layout save/load/rename/delete via API~~ DONE
|
||||||
|
36. ~~Tiptap WYSIWYG editor for knowledge entries~~ DONE
|
||||||
|
37. ~~Markdown round-trip (import/export)~~ DONE
|
||||||
|
38. ~~Kanban filtering by project, assignee, priority, search~~ DONE
|
||||||
|
39. ~~All features support all themes~~ DONE
|
||||||
|
40. ~~Lint, typecheck, tests pass~~ DONE
|
||||||
|
|
||||||
|
### MS19 — Chat & Terminal — COMPLETE
|
||||||
|
|
||||||
|
41. ~~Terminal panel has real xterm.js with PTY backend~~ DONE — PR #518
|
||||||
|
42. ~~Terminal supports multiple named sessions (tabs)~~ DONE — PR #520
|
||||||
|
43. ~~Terminal sessions persist and recover on reconnect~~ DONE — PR #517
|
||||||
|
44. ~~Chat streaming renders tokens in real-time (SSE)~~ DONE — PR #516
|
||||||
|
45. ~~Master chat sidebar accessible from any page (Cmd+Shift+J)~~ DONE — PR #519
|
||||||
|
46. ~~Master chat supports model selection and conversation management~~ DONE — PR #519
|
||||||
|
47. ~~Project-level chat can trigger orchestrator actions~~ DONE — PR #521
|
||||||
|
48. ~~Agent output viewable in terminal tabs~~ DONE — PR #522
|
||||||
|
49. ~~All features support all themes~~ DONE — CSS variables throughout
|
||||||
|
50. ~~Lint, typecheck, tests pass~~ DONE — 1441 web + 3303 API = 4744 total
|
||||||
|
51. ~~Deployed and smoke-tested~~ DONE — CI #635 green, web deployed to mosaic.woltje.com
|
||||||
|
|
||||||
|
### Full Project (All Milestones)
|
||||||
|
|
||||||
|
52. jarvis user logs in via Authentik, has admin access to all pages
|
||||||
|
53. jarvis-user has standard access at lower permission level
|
||||||
|
54. Break-glass user has access without Authentik
|
||||||
|
55. Three Mosaic Stack instances on Portainer with federation testing
|
||||||
|
56. Playwright tests confirm all pages, functions, theming work
|
||||||
|
57. No errors during site navigation
|
||||||
|
58. API documented via Swagger with proper auth gating
|
||||||
|
59. Telemetry working locally with wide-event logging
|
||||||
|
60. Mosaic Telemetry properly reporting to telemetry endpoint
|
||||||
|
|
||||||
|
## Constraints and Dependencies
|
||||||
|
|
||||||
|
1. Next.js 16 with App Router — all pages use server/client component patterns
|
||||||
|
2. Tailwind CSS 3.4 — design tokens must integrate with Tailwind's utility class system
|
||||||
|
3. BetterAuth for authentication — must maintain existing auth flow
|
||||||
|
4. Authentik as IdP at auth.diversecanvas.com — must remain operational
|
||||||
|
5. PostgreSQL 17 with Prisma — all settings stored in DB
|
||||||
|
6. Portainer for deployment — 3 instances needed for federation testing
|
||||||
|
7. packages/ui is shared across apps — changes affect all consumers
|
||||||
|
8. Backend API modules already exist for all page data needs — no new API endpoints required for MS16+MS17 scope
|
||||||
|
|
||||||
|
## Risks and Open Questions
|
||||||
|
|
||||||
|
1. **Risk**: Pages need to match the design system established in MS15. Inconsistency would degrade UX. Mitigation: Use existing design tokens and shared components exclusively. **RESOLVED** — All MS16+MS17 pages use design tokens consistently.
|
||||||
|
2. **Risk**: Kanban drag-and-drop adds complexity and potential for state bugs. Mitigation: Use a proven DnD library. **RESOLVED** — @hello-pangea/dnd selected (maintained fork of react-beautiful-dnd, better TS support). Optimistic updates with rollback on failure.
|
||||||
|
3. **Risk**: Mock data elimination may reveal backend API gaps or mismatches. Mitigation: Audit each API response shape against page needs during implementation. **RESOLVED** — All 3 mock-data pages wired successfully. No API gaps found.
|
||||||
|
4. ~~**Open**: Exact task status values for Kanban columns~~ **RESOLVED** — TaskStatus enum: NOT_STARTED, IN_PROGRESS, PAUSED, COMPLETED, ARCHIVED (5 columns).
|
||||||
|
5. ~~**Open**: Whether Workspace page should require project selection or show a default view~~ **RESOLVED** — Shows project selector when no project param, workspace detail when ?project=id.
|
||||||
|
6. ~~**Open**: File Manager page — should it be a direct mapping of Knowledge entries or a separate file abstraction?~~ **RESOLVED** — Direct mapping to Knowledge entries via /api/knowledge. API shape matches file manager needs.
|
||||||
|
|
||||||
|
## Existing Backend API Modules (Reference)
|
||||||
|
|
||||||
|
These 19 NestJS modules are already implemented with Prisma and available for frontend wiring:
|
||||||
|
|
||||||
|
| Module | Endpoint | Capabilities |
|
||||||
|
| ------------------ | ------------------------------ | --------------------- |
|
||||||
|
| Projects | `/api/projects` | Full CRUD |
|
||||||
|
| Tasks | `/api/tasks` | Full CRUD |
|
||||||
|
| Layouts | `/api/layouts` | Widget placement |
|
||||||
|
| Widgets | `/api/widgets` | Data endpoints |
|
||||||
|
| Activity | `/api/activity` | Audit logs |
|
||||||
|
| Dashboard | `/api/dashboard/summary` | Aggregated summary |
|
||||||
|
| Knowledge | `/api/knowledge` | Full CRUD + search |
|
||||||
|
| Ideas | `/api/ideas` | Capture/CRUD |
|
||||||
|
| Domains | `/api/domains` | CRUD |
|
||||||
|
| Events | `/api/events` | CRUD |
|
||||||
|
| Preferences | `/api/users/me/preferences` | User settings |
|
||||||
|
| Workspace Settings | `/api/workspaces/:id/settings` | LLM config |
|
||||||
|
| Runner Jobs | `/api/runner-jobs` | Job management |
|
||||||
|
| Job Steps | `/api/runner-jobs/:id/steps` | Step tracking |
|
||||||
|
| Agent Tasks | `/api/agent-tasks` | Agent task management |
|
||||||
|
| Credentials | `/api/credentials` | Encrypted storage |
|
||||||
|
| Brain/AI | `/api/brain` | Query/search |
|
||||||
|
| WebSocket | Real-time | Event broadcasting |
|
||||||
|
| LLM | `/api/llm/chat` | Chat + SSE streaming |
|
||||||
|
| Orchestrator Proxy | `/api/orchestrator/*` | Agent mgmt proxy |
|
||||||
|
| Telemetry | Internal | Logging/monitoring |
|
||||||
|
|
||||||
|
## Testing and Verification
|
||||||
|
|
||||||
|
1. Baseline: `pnpm lint && pnpm build` must pass
|
||||||
|
2. Situational: All sidebar links navigate without 404
|
||||||
|
3. Situational: Each new page renders with real API data
|
||||||
|
4. Situational: Theme toggle on each new page
|
||||||
|
5. Situational: Responsive verification at sm/md/lg/xl
|
||||||
|
6. E2E: Playwright tests for all page navigation (MS23)
|
||||||
|
7. E2E: Auth flow with Authentik (MS23)
|
||||||
|
8. Federation: Master-master and master-slave data access tests (MS21)
|
||||||
|
|
||||||
|
## Delivery/Milestone Intent
|
||||||
|
|
||||||
|
| Milestone | Version | Focus | Status |
|
||||||
|
| ------------------------------ | ------- | ----------------------------------------------------------------- | ----------- |
|
||||||
|
| MS15-DashboardShell | 0.0.15 | Design system + app shell + dashboard page | COMPLETE |
|
||||||
|
| Go-Live MVP | 0.0.16 | Dashboard polish, ingestion, agent visibility, deploy | COMPLETE |
|
||||||
|
| MS16+MS17-PagesDataIntegration | 0.0.17 | All pages built + wired to real API data | COMPLETE |
|
||||||
|
| MS18-ThemeWidgets | 0.0.18 | Theme package system, widget registry, WYSIWYG, Kanban filtering | COMPLETE |
|
||||||
|
| MS19-ChatTerminal | 0.0.19 | Global terminal, project chat, master chat session | COMPLETE |
|
||||||
|
| MS20-SiteStabilization | 0.0.20 | Runtime bug fixes, missing endpoints, orchestrator connectivity | IN PROGRESS |
|
||||||
|
| MS21-MultiTenant | 0.0.21 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth | NOT STARTED |
|
||||||
|
| MS22-Federation | 0.0.22 | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
|
||||||
|
| MS23-AgentTelemetry | 0.0.23 | Agent task mapping, telemetry, wide-event logging | NOT STARTED |
|
||||||
|
| MS24-Testing | 0.0.24 | Playwright E2E, federation tests, documentation finalization | NOT STARTED |
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
1. ASSUMPTION: Header spans full width including above sidebar area. The logo is in the header, not the sidebar. Rationale: User explicitly stated "The logo will NOT be part of the sidebar."
|
||||||
|
2. ASSUMPTION: Sidebar footer user card navigates to Profile page. Rationale: Matches reference design behavior.
|
||||||
|
3. ASSUMPTION: Initial implementation supports dark/light from reference design. Multi-theme package system is a future milestone. Rationale: Foundation must be solid before extensibility.
|
||||||
|
4. ASSUMPTION: MS16 and MS17 are combined into a single mission because 19 backend API modules already exist with real Prisma business logic. The remaining work is primarily frontend page creation and API wiring. Rationale: Backend audit on 2026-02-22 confirmed all required endpoints are implemented.
|
||||||
|
5. ASSUMPTION: File Manager page maps to Knowledge entries rather than a separate file system abstraction. Rationale: `/api/knowledge` provides full CRUD + search which matches file manager needs. Can be extended later if needed.
|
||||||
|
6. ASSUMPTION: Theme packages are code-level TypeScript files (not runtime-installable npm packages). Each theme exports CSS variable overrides. Rationale: Keeps the system simple for MS18; runtime package loading can be added in a future milestone.
|
||||||
|
7. ASSUMPTION: WYSIWYG editor uses Tiptap (ProseMirror-based, headless). Rationale: Headless approach integrates naturally with the CSS variable design system, excellent markdown import/export, TypeScript-first, battle-tested.
|
||||||
|
8. ASSUMPTION: MS18 includes WYSIWYG editing for knowledge entries and Kanban filtering enhancements in addition to themes and widgets. These were originally listed separately but are grouped into MS18 per PRD scope items 24-25. Rationale: All are frontend-focused enhancements that build on the existing page infrastructure.
|
||||||
|
9. ASSUMPTION: The `useWorkspaceId()` hook + auto-detect in `apiRequest` from PR #532 handles reads, but mutation endpoints on some pages don't pass workspace ID correctly. Rationale: GET requests work after PR #532 but POST/mutation requests still fail on domains and projects pages.
|
||||||
|
10. ASSUMPTION: Personalities API follows existing NestJS module patterns (controller + service + DTO + Prisma model). Rationale: Consistent with all other API modules in the codebase.
|
||||||
|
11. ASSUMPTION: User preferences endpoint is part of the existing users module but route is not registered. Rationale: PRD lists it as an existing endpoint.
|
||||||
|
12. ASSUMPTION: The orchestrator service container runs but the Next.js API proxy cannot reach it. Root cause is likely environment variable or network configuration in Docker Swarm. Rationale: The orchestrator container exists in the compose file and has Traefik labels.
|
||||||
|
13. ASSUMPTION: Terminal should have a dedicated page route `/terminal` that renders the terminal panel full-screen. Rationale: The sidebar has a Terminal link in the Operations section alongside Logs, implying it should be a navigable page.
|
||||||
|
14. ASSUMPTION: Credential CRUD frontend can use the existing `/api/credentials` API which was built during M7-CredentialSecurity. Rationale: Backend endpoints exist per audit.
|
||||||
645
docs/PRD.md
645
docs/PRD.md
@@ -1,566 +1,167 @@
|
|||||||
# PRD: Mosaic Stack Dashboard & Platform Implementation
|
# PRD: MS22 Phase 2 — Named Agent Fleet
|
||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
|
|
||||||
- Owner: Jason Woltje
|
- Owner: Jason Woltje
|
||||||
- Date: 2026-02-22
|
- Date: 2026-03-04
|
||||||
- Status: in-progress
|
- Status: in-progress
|
||||||
- Best-Guess Mode: true
|
- Mission: ms22-p2-named-agent-fleet-20260304
|
||||||
|
- Design Doc: `docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md`
|
||||||
|
- Depends On: MS22 Phase 1 (DB-Centric Architecture) — COMPLETE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Problem Statement
|
## Problem Statement
|
||||||
|
|
||||||
The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard that doesn't match the production-ready design vision. The reference design (dashboard.html) defines a comprehensive command center UI with sidebar navigation, topbar, terminal panel, and multiple page layouts. The current implementation uses mismatched design tokens (raw Tailwind colors vs CSS variables), has no collapsible sidebar, no global terminal, and lacks the polished design system from the reference.
|
Mosaic Stack has the infrastructure for per-user containers and a knowledge layer, but no predefined agent personalities. Users start with a blank slate. For Jason's personal use case, the system needs named agents — jarvis, builder, medic — with distinct roles, personalities, and tool access that can collaborate through the shared knowledge layer and respond in dedicated Discord channels.
|
||||||
|
|
||||||
## Objectives
|
Currently:
|
||||||
|
|
||||||
1. Implement the dashboard.html reference design as the production UI foundation
|
- No agent registry exists in the database
|
||||||
2. Establish a consistent CSS design token system that supports multiple themes
|
- No per-agent model configuration is possible
|
||||||
3. Build a responsive, accessible app shell with collapsible sidebar and full-width header
|
- Discord channels are not routed to specific agents
|
||||||
4. Create a theme system supporting installable theme packages
|
- Chat routing is model-agnostic (no agent context)
|
||||||
5. Build all dashboard pages (Dashboard, Projects, Workspace, Kanban, Files, Logs, Settings, Profile)
|
|
||||||
6. Implement real backend integration (no mock data)
|
|
||||||
7. Support multi-tenant configuration with RBAC
|
|
||||||
8. Implement federation (master-master and master-slave)
|
|
||||||
9. Build global terminal, project chat, and master chat session
|
|
||||||
10. Configure telemetry with opt-out support
|
|
||||||
|
|
||||||
## Completed Work
|
---
|
||||||
|
|
||||||
### MS15-DashboardShell (v0.0.15) — Complete
|
|
||||||
|
|
||||||
Design system + app shell + dashboard page. PRs #451-454.
|
|
||||||
|
|
||||||
- CSS design token system (colors, fonts, spacing, radii)
|
|
||||||
- App shell layout: collapsible sidebar + full-width header + main content
|
|
||||||
- Sidebar navigation with groups, icons, badges, active states, collapse/expand
|
|
||||||
- Responsive layout with hamburger at small breakpoints
|
|
||||||
- Light/dark theme matching reference design
|
|
||||||
- Mosaic logo spinner as global loading indicator
|
|
||||||
- Shared component updates in packages/ui
|
|
||||||
- Dashboard page: metrics strip, orchestrator sessions, quick actions, activity feed, token budget
|
|
||||||
- Grain overlay texture
|
|
||||||
|
|
||||||
### Go-Live MVP (v0.0.16) — Complete
|
|
||||||
|
|
||||||
Dashboard polish, task ingestion pipeline, agent cycle visibility, deploy + smoke test. PRs #458, #460, #462, #464.
|
|
||||||
|
|
||||||
- Fixed broken test suites and removed legacy unused widgets
|
|
||||||
- Visual + theme polish across all components
|
|
||||||
- Dashboard summary API endpoint (aggregated task counts, project counts, activity, jobs)
|
|
||||||
- Dashboard widgets wired to real API data (ActivityFeed, DashboardMetrics, OrchestratorSessions)
|
|
||||||
- WebSocket emits for job status/progress/step events
|
|
||||||
- Dashboard auto-refresh with polling + progress bars + step status indicators
|
|
||||||
- Deployed to mosaic.woltje.com, auth working via Authentik
|
|
||||||
- Release tag v0.0.16
|
|
||||||
|
|
||||||
### MS16+MS17-PagesDataIntegration (v0.0.17) — Complete
|
|
||||||
|
|
||||||
All pages built + wired to real API data. PRs #470-484 (15 PRs). Issues #466-469.
|
|
||||||
|
|
||||||
- Custom 404 pages (global + authenticated route groups)
|
|
||||||
- Settings root page with 4 category cards
|
|
||||||
- Tasks, Calendar, Knowledge pages wired to real API (238+ lines mock data removed)
|
|
||||||
- Projects list page with create/delete dialogs
|
|
||||||
- Project Workspace page with tabbed view (Tasks, Agent Sessions, Settings)
|
|
||||||
- Kanban board with drag-and-drop (@hello-pangea/dnd), 5 status columns, optimistic updates
|
|
||||||
- File Manager page with list/grid views, search, create/delete
|
|
||||||
- Logs & Telemetry page with auto-refresh, expandable rows, filters
|
|
||||||
- Profile page with user info and preferences
|
|
||||||
- All 5125 tests passing, CI pipeline #585 green
|
|
||||||
- Deployed and smoke-tested at mosaic.woltje.com
|
|
||||||
|
|
||||||
### MS18-ThemeWidgets (v0.0.18) — Complete
|
|
||||||
|
|
||||||
Theme package system, widget registry, WYSIWYG editor, Kanban filtering. PRs #493-505. Issues #487-491.
|
|
||||||
|
|
||||||
- 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TypeScript theme packages
|
|
||||||
- ThemeProvider with dynamic CSS variable application and instant switching
|
|
||||||
- Theme selection UI in Settings with live preview swatches
|
|
||||||
- Widget definition registry with configurable sizing and schemas
|
|
||||||
- WidgetGrid dashboard with drag-and-drop layout (react-grid-layout)
|
|
||||||
- Widget picker drawer for adding widgets from registry
|
|
||||||
- Per-widget configuration dialog driven by configSchema
|
|
||||||
- Layout save/load/rename/delete via UserLayout API
|
|
||||||
- Tiptap WYSIWYG editor for knowledge entries with toolbar
|
|
||||||
- Markdown round-trip (import/export)
|
|
||||||
- Kanban board filtering by project, assignee, priority, search with URL persistence
|
|
||||||
- 1,195 web tests, 3,243 API tests passing
|
|
||||||
|
|
||||||
### MS19-ChatTerminal (v0.0.19) — Complete
|
|
||||||
|
|
||||||
Real terminal with PTY backend, chat streaming, orchestrator integration. PRs #515-522. Issues #508-512.
|
|
||||||
|
|
||||||
- NestJS WebSocket gateway (/terminal namespace) with node-pty for real shell sessions
|
|
||||||
- Terminal session persistence in PostgreSQL (Prisma model: TerminalSession)
|
|
||||||
- xterm.js integration with FitAddon, WebLinksAddon, CSS variable theme support
|
|
||||||
- Multi-session terminal tabs: create/close/rename, tab switching, session recovery
|
|
||||||
- SSE chat streaming with token-by-token rendering, abort/cancel support
|
|
||||||
- Master chat polish: model selector dropdown, temperature/maxTokens config, ChatEmptyState
|
|
||||||
- Orchestrator command system: /status, /agents, /jobs, /pause, /resume, /help
|
|
||||||
- Agent output terminal: SSE streaming from orchestrator, lifecycle indicators, read-only view
|
|
||||||
- Command autocomplete with keyboard navigation in chat input
|
|
||||||
- 328 MS19-specific tests (268 web + 60 API), 4744 total passing
|
|
||||||
- Deployed and smoke-tested at mosaic.woltje.com (CI #635 green)
|
|
||||||
|
|
||||||
### Bugfix: API Global Prefix (post-MS18) — Complete
|
|
||||||
|
|
||||||
PR #507. Fixed systemic 404 on all data endpoints.
|
|
||||||
|
|
||||||
- Added `setGlobalPrefix("api")` to NestJS with exclusions for /health and /auth/\*
|
|
||||||
- Normalized 6 federation controllers to remove redundant api/ prefix
|
|
||||||
- Fixed rollup CVE (GHSA-mw96-cpmx-2vgc) via pnpm override
|
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
### In Scope (MS16+MS17 — Pages & Data Integration)
|
### In Scope
|
||||||
|
|
||||||
This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) are combined because the backend API modules already exist — the work is primarily frontend page creation and API wiring.
|
- `AgentTemplate` and `UserAgent` Prisma models
|
||||||
|
- Admin CRUD endpoints for managing agent templates
|
||||||
|
- User agent CRUD endpoints for personal agent instances
|
||||||
|
- Agent chat proxy routing (select agent by name)
|
||||||
|
- Discord channel → agent routing (#jarvis, #builder, #medic-alerts)
|
||||||
|
- WebUI agent selector and agent detail view
|
||||||
|
- Unit tests and E2E verification
|
||||||
|
|
||||||
1. Projects list page with CRUD (wire to existing `/api/projects`)
|
### Non-Goals
|
||||||
2. Project workspace/detail page (wire to `/api/projects/:id`, `/api/tasks`, `/api/runner-jobs`)
|
|
||||||
3. Kanban board page with status-based columns (wire to existing `/api/tasks`)
|
|
||||||
4. File Manager page with tree/list view and CRUD (wire to existing `/api/knowledge`)
|
|
||||||
5. Logs & Telemetry page with log viewer and filtering (wire to `/api/runner-jobs`, job steps, events)
|
|
||||||
6. Settings root/index page linking to existing subpages
|
|
||||||
7. Custom 404 page for unknown routes
|
|
||||||
8. Wire `/tasks` page to real API data (currently mock)
|
|
||||||
9. Wire `/calendar` page to real API data (currently mock)
|
|
||||||
10. Wire `/knowledge` pages to real API data (currently mock)
|
|
||||||
|
|
||||||
### In Scope (Future Milestones — Documented for Planning)
|
- Matrix observation rooms
|
||||||
|
- Cross-agent quality gates
|
||||||
|
- Team/multi-user agent sharing
|
||||||
|
- Agent-to-agent communication
|
||||||
|
- Token metering per agent
|
||||||
|
|
||||||
11. Theme system with installable theme packages (MS18)
|
---
|
||||||
12. Widget system with installable widget packages, customizable sizes (MS18)
|
|
||||||
13. Global terminal: project/orchestrator level, smart (MS19)
|
|
||||||
14. Project-level orchestrator chat (MS19)
|
|
||||||
15. Master chat session: collapsible sidebar/slideout, always available (MS19)
|
|
||||||
16. Site stabilization: workspace context propagation for mutations (MS20)
|
|
||||||
17. Site stabilization: personalities API + UI (MS20)
|
|
||||||
18. Site stabilization: user preferences API endpoint (MS20)
|
|
||||||
19. Site stabilization: orchestrator 502 and WebSocket connectivity (MS20)
|
|
||||||
20. Site stabilization: credential management UI (MS20)
|
|
||||||
21. Site stabilization: terminal page route (MS20)
|
|
||||||
22. Site stabilization: favicon, dark mode dropdown fix (MS20)
|
|
||||||
23. Settings page for ALL environment variables, dynamically configurable via webUI (MS21)
|
|
||||||
24. Multi-tenant configuration with admin user management (MS21)
|
|
||||||
25. Team management with shared data spaces and chat rooms (MS21)
|
|
||||||
26. RBAC for file access, resources, models (MS21)
|
|
||||||
27. Federation: master-master and master-slave with key exchange (MS22)
|
|
||||||
28. Federation testing: 3 instances on Portainer (woltje.com domain) (MS22)
|
|
||||||
29. Agent task mapping configuration: system-level defaults, user-level overrides (MS23)
|
|
||||||
30. Telemetry: opt-out, customizable endpoint, sanitized data (MS23)
|
|
||||||
31. File manager with WYSIWYG editing: system/user/project levels (MS18)
|
|
||||||
32. User-level and project-level Kanban with filtering (MS18)
|
|
||||||
33. Break-glass authentication user (MS20)
|
|
||||||
34. Playwright E2E tests for all pages (MS23)
|
|
||||||
35. API documentation via Swagger (MS23)
|
|
||||||
36. Backend endpoints for all dashboard data (MS17 — already complete for existing modules)
|
|
||||||
37. Profile page linked from user card (MS16)
|
|
||||||
|
|
||||||
### Out of Scope
|
## User Stories
|
||||||
|
|
||||||
1. Mobile native app
|
### US-001
|
||||||
2. Third-party marketplace for themes/widgets (initial implementation is local package management only)
|
|
||||||
3. Mobile native app deployment targets
|
|
||||||
4. Calendar system redesign (existing calendar implementation is retained)
|
|
||||||
|
|
||||||
## User/Stakeholder Requirements
|
As Jason, I can list my available agents in the WebUI so I know what's configured.
|
||||||
|
|
||||||
1. The `jarvis` user must be able to log into mosaic.woltje.com via Authentik as administrator with access to all pages
|
### US-002
|
||||||
2. A standard `jarvis-user` must operate at a lower permission level
|
|
||||||
3. A break-glass user must have access without Authentik authentication
|
As Jason, I can select an agent and chat with it directly via WebUI.
|
||||||
4. All pages must be navigable without errors (no 404s from sidebar links)
|
|
||||||
5. Light and dark themes must work across all pages and components
|
### US-003
|
||||||
6. Sidebar must be collapsible with open/close button; hidden by default at small breakpoints
|
|
||||||
7. Hamburger button visible at lower breakpoints for sidebar control
|
As Jason, I can send a message in #jarvis on Discord and Jarvis responds.
|
||||||
8. The Mosaic Stack logo icon must be the site-wide loading spinner
|
|
||||||
9. No mock data — all data pulled from backend APIs
|
### US-004
|
||||||
|
|
||||||
|
As Jason, I can send a message in #builder on Discord and Builder responds.
|
||||||
|
|
||||||
|
### US-005
|
||||||
|
|
||||||
|
As Jason, I can send a message in #medic-alerts and Medic responds.
|
||||||
|
|
||||||
|
### US-006
|
||||||
|
|
||||||
|
As an admin, I can create, update, and delete agent templates.
|
||||||
|
|
||||||
|
### US-007
|
||||||
|
|
||||||
|
As Jason, I can customize an agent's personality without affecting the shared template.
|
||||||
|
|
||||||
|
### US-008
|
||||||
|
|
||||||
|
As Jason, I can see which agents are active vs inactive at a glance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Functional Requirements
|
## Functional Requirements
|
||||||
|
|
||||||
### FR-001: Design Token System
|
- FR-1: The system SHALL provide an `AgentTemplate` model with: name, displayName, role, personality, primaryModel, fallbackModels, toolPermissions, discordChannel, isActive, isDefault
|
||||||
|
- FR-2: The system SHALL provide a `UserAgent` model that extends or customizes an AgentTemplate per user
|
||||||
|
- FR-3: The system SHALL seed three default templates on startup: jarvis (orchestrator/opus), builder (coding/codex), medic (monitoring/haiku)
|
||||||
|
- FR-4: Admin endpoints SHALL exist at `GET/POST/PATCH/DELETE /admin/agent-templates` protected by AdminGuard
|
||||||
|
- FR-5: User agent endpoints SHALL exist at `GET/POST/PATCH/DELETE /agents` protected by AuthGuard
|
||||||
|
- FR-6: Agent status endpoints SHALL return active/inactive state per agent for the authenticated user
|
||||||
|
- FR-7: The chat proxy SHALL route messages to the correct agent based on agent name or ID in the request
|
||||||
|
- FR-8: Incoming Discord messages in a configured channel SHALL be routed to the matching agent
|
||||||
|
- FR-9: The WebUI SHALL display a list of available agents with role, model, and status
|
||||||
|
- FR-10: The WebUI SHALL allow selecting an agent and opening a chat session with it
|
||||||
|
|
||||||
- CSS custom properties for all colors, spacing, typography, radii
|
---
|
||||||
- Dark theme as default (`:root`), light theme via `[data-theme="light"]`
|
|
||||||
- Fonts: Outfit (body), Fira Code (monospace)
|
|
||||||
- All components must use design tokens, never hardcoded colors
|
|
||||||
- **Status: COMPLETE (MS15)**
|
|
||||||
|
|
||||||
### FR-002: App Shell Layout
|
|
||||||
|
|
||||||
- CSS Grid: sidebar column + header row + main content
|
|
||||||
- Full-width header spanning above sidebar and content
|
|
||||||
- ASSUMPTION: Header spans full width including above sidebar area. The logo is in the header, not the sidebar. Rationale: User explicitly stated "The logo will NOT be part of the sidebar."
|
|
||||||
- **Status: COMPLETE (MS15)**
|
|
||||||
|
|
||||||
### FR-003: Sidebar Navigation
|
|
||||||
|
|
||||||
- Nav groups: Overview (Dashboard), Workspace (Projects, Project Workspace, Kanban, File Manager), Operations (Logs & Telemetry, Terminal), System (Settings)
|
|
||||||
- Collapsible: icon-only mode when collapsed
|
|
||||||
- Active state indicator (left border accent)
|
|
||||||
- User card in footer with avatar, name, role, online status
|
|
||||||
- ASSUMPTION: Sidebar footer user card navigates to Profile page. Rationale: Matches reference design behavior.
|
|
||||||
- **Status: COMPLETE (MS15+MS16) — Profile page added in PR #482.**
|
|
||||||
|
|
||||||
### FR-004: Header/Topbar
|
|
||||||
|
|
||||||
- Logo + brand wordmark (left)
|
|
||||||
- Search bar with keyboard shortcut hint
|
|
||||||
- System status indicator
|
|
||||||
- Terminal toggle button
|
|
||||||
- Notification bell with badge
|
|
||||||
- Theme toggle (sun/moon icon)
|
|
||||||
- User avatar button with dropdown (Profile, Account Settings, Sign Out)
|
|
||||||
- **Status: COMPLETE (MS15)**
|
|
||||||
|
|
||||||
### FR-005: Responsive Design
|
|
||||||
|
|
||||||
- Breakpoints: sm (640px), md (768px), lg (1024px), xl (1280px)
|
|
||||||
- Below md: sidebar hidden, hamburger button in header
|
|
||||||
- md-lg: sidebar can be toggled
|
|
||||||
- lg+: sidebar visible by default
|
|
||||||
- **Status: COMPLETE (MS15)**
|
|
||||||
|
|
||||||
### FR-006: Dashboard Page
|
|
||||||
|
|
||||||
- 6-cell metrics strip with colored top borders and trend indicators
|
|
||||||
- Active Orchestrator Sessions card with agent nodes
|
|
||||||
- Quick Actions 2x2 grid
|
|
||||||
- Activity Feed sidebar card
|
|
||||||
- Token Budget sidebar card with progress bars
|
|
||||||
- Wired to real API via `/api/dashboard/summary`
|
|
||||||
- **Status: COMPLETE (Go-Live MVP)**
|
|
||||||
|
|
||||||
### FR-007: Loading Spinner
|
|
||||||
|
|
||||||
- Mosaic logo icon (4 corner squares + center circle) with CSS rotation animation
|
|
||||||
- Used as global loading indicator across all pages
|
|
||||||
- Available as a shared component
|
|
||||||
- **Status: COMPLETE (MS15)**
|
|
||||||
|
|
||||||
### FR-008: Projects Page (MS16)
|
|
||||||
|
|
||||||
- Projects list view with card or table layout
|
|
||||||
- Project creation dialog/form
|
|
||||||
- Project detail view (name, description, status, created/updated timestamps)
|
|
||||||
- Wire to existing `/api/projects` (full CRUD already implemented)
|
|
||||||
- Navigate from sidebar → /projects
|
|
||||||
- **Status: COMPLETE (MS16) — PR #477. Card layout, create/delete dialogs, status badges.**
|
|
||||||
|
|
||||||
### FR-009: Project Workspace Page (MS16)
|
|
||||||
|
|
||||||
- Single-project view showing tasks, agent sessions, and project settings
|
|
||||||
- Task list for selected project
|
|
||||||
- Agent session history and status
|
|
||||||
- Wire to `/api/projects/:id`, `/api/tasks`, `/api/runner-jobs`
|
|
||||||
- Navigate from sidebar → /workspace (with project context)
|
|
||||||
- **Status: COMPLETE (MS16) — PR #479. Tabbed view (Tasks, Agent Sessions, Settings), project selector mode.**
|
|
||||||
|
|
||||||
### FR-010: Kanban Board Page (MS16)
|
|
||||||
|
|
||||||
- Drag-and-drop board with columns mapped to task status values
|
|
||||||
- Task cards showing title, assignee, priority, status
|
|
||||||
- Column headers with task counts
|
|
||||||
- Wire to existing `/api/tasks` (status field drives columns)
|
|
||||||
- Navigate from sidebar → /kanban
|
|
||||||
- **Status: COMPLETE (MS16) — PR #478. 5 columns (NOT_STARTED→ARCHIVED), @hello-pangea/dnd, optimistic updates.**
|
|
||||||
|
|
||||||
### FR-011: File Manager Page (MS16)
|
|
||||||
|
|
||||||
- Tree or list view of knowledge entries
|
|
||||||
- CRUD operations (create, read, update, delete)
|
|
||||||
- Search functionality
|
|
||||||
- Wire to existing `/api/knowledge` (full CRUD + search already implemented)
|
|
||||||
- Navigate from sidebar → /files
|
|
||||||
- **Status: COMPLETE (MS16) — PR #481. List+grid views, search, create/delete dialogs.**
|
|
||||||
|
|
||||||
### FR-012: Logs & Telemetry Page (MS16)
|
|
||||||
|
|
||||||
- Log viewer with timestamp, level, source, message columns
|
|
||||||
- Filtering by level, source, date range
|
|
||||||
- Auto-refresh for live logs
|
|
||||||
- Wire to existing runner-jobs, job steps, and events APIs
|
|
||||||
- Navigate from sidebar → /logs
|
|
||||||
- **Status: COMPLETE (MS16) — PR #480. Auto-refresh (5s polling), expandable rows, filters.**
|
|
||||||
|
|
||||||
### FR-013: Settings Root Page (MS16)
|
|
||||||
|
|
||||||
- Landing/index page for settings
|
|
||||||
- Category cards linking to existing subpages: Credentials, Domains, Personalities, Workspaces
|
|
||||||
- Navigate from sidebar → /settings (currently 404; subpages exist)
|
|
||||||
- **Status: COMPLETE (MS16) — PR #471. 4 category cards with icons and hover states.**
|
|
||||||
|
|
||||||
### FR-014: Custom 404 Page (MS16)
|
|
||||||
|
|
||||||
- Branded 404 page matching design system
|
|
||||||
- Helpful message and navigation link back to dashboard
|
|
||||||
- Applied to all unmatched routes within authenticated layout
|
|
||||||
- **Status: COMPLETE (MS16) — PR #472. Global + authenticated route-group 404 pages.**
|
|
||||||
|
|
||||||
### FR-015: Mock Data Elimination (MS16+MS17)
|
|
||||||
|
|
||||||
- `/tasks` page: replace mock data with `/api/tasks` calls
|
|
||||||
- `/calendar` page: replace mock data with `/api/events` calls
|
|
||||||
- `/knowledge` pages: replace mock data with `/api/knowledge` calls
|
|
||||||
- All pages must render real data from backend APIs
|
|
||||||
- **Status: COMPLETE (MS16+MS17) — PRs #473-#476. 238+ lines of mock data removed.**
|
|
||||||
|
|
||||||
### FR-016: Theme System (MS18) — COMPLETE
|
|
||||||
|
|
||||||
- 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TypeScript theme packages
|
|
||||||
- ThemeProvider loads themes dynamically, applies CSS variables, instant switching
|
|
||||||
- Theme selection UI in Settings with live preview swatches
|
|
||||||
- UserPreference.theme persists selection across sessions
|
|
||||||
- **Status: COMPLETE (MS18) — PRs #493-495**
|
|
||||||
|
|
||||||
### FR-017: Terminal Panel (MS19) — COMPLETE
|
|
||||||
|
|
||||||
- Bottom drawer panel, toggleable from header and sidebar
|
|
||||||
- Real xterm.js terminal with PTY backend via WebSocket
|
|
||||||
- Multiple tabs: shell sessions, orchestrator agent output, build logs
|
|
||||||
- Terminal session persistence (create/close/rename tabs)
|
|
||||||
- Smart terminal operating at project/orchestrator level
|
|
||||||
- ASSUMPTION: Terminal backend uses node-pty for PTY management, communicating via WebSocket namespace (/terminal). Rationale: node-pty is the standard for Node.js terminal emulation, used by VS Code.
|
|
||||||
- ASSUMPTION: Terminal sessions are workspace-scoped and stored in PostgreSQL for recovery. Rationale: Consistent with existing workspace isolation pattern.
|
|
||||||
- **Status: COMPLETE (MS19) — PRs #515 (gateway), #517 (persistence), #518 (xterm.js), #520 (tabs), #522 (agent tabs). 60 API + 176 web tests.**
|
|
||||||
|
|
||||||
### FR-018: Chat Streaming & Master Chat (MS19) — COMPLETE
|
|
||||||
|
|
||||||
- Complete SSE streaming for token-by-token chat rendering
|
|
||||||
- Master chat sidebar (ChatOverlay) polish: model selector, conversation search, keyboard shortcuts
|
|
||||||
- Chat persistence via Ideas API (already implemented)
|
|
||||||
- ASSUMPTION: Chat streaming uses existing SSE infrastructure in LLM controller. Frontend needs streamChatMessage() completion. Rationale: Backend SSE is already working, only frontend wiring is missing.
|
|
||||||
- **Status: COMPLETE (MS19) — PRs #516 (streaming), #519 (polish). Model selector, temperature/maxTokens config, ChatEmptyState, Cmd+N/L shortcuts. 78 web tests.**
|
|
||||||
|
|
||||||
### FR-019: Project-Level Orchestrator Chat (MS19) — COMPLETE
|
|
||||||
|
|
||||||
- Chat context scoped to active project
|
|
||||||
- Can trigger orchestrator actions: spawn agent, check status, view jobs
|
|
||||||
- Command prefix system (/spawn, /status, /jobs) parsed in chat
|
|
||||||
- Agent output viewable in terminal tabs
|
|
||||||
- ASSUMPTION: Orchestrator commands route through existing web proxy (/api/orchestrator/\*) to orchestrator service. Rationale: Proxy routes already exist and handle auth.
|
|
||||||
- **Status: COMPLETE (MS19) — PRs #521 (commands), #522 (agent terminal). /status, /agents, /jobs, /pause, /resume, /help commands. Agent output streaming via SSE. 113 web tests.**
|
|
||||||
|
|
||||||
### FR-020: Site Stabilization & Feature Gaps (MS20) — IN PROGRESS
|
|
||||||
|
|
||||||
Runtime bugs and feature gaps discovered during live testing of mosaic.woltje.com.
|
|
||||||
|
|
||||||
**Workspace Context Propagation:**
|
|
||||||
|
|
||||||
- Domains page: "Workspace ID is required" when creating domains
|
|
||||||
- Projects page: "Workspace ID is required" when creating projects
|
|
||||||
- Credentials page: unable to add credentials (button disabled, feature stub)
|
|
||||||
- ASSUMPTION: The `useWorkspaceId()` hook + auto-detect in `apiRequest` from PR #532 handles reads, but mutation endpoints on some pages don't pass workspace ID correctly. Rationale: GET requests work after PR #532 but POST/mutation requests still fail on domains and projects pages.
|
|
||||||
|
|
||||||
**Missing API Endpoints:**
|
|
||||||
|
|
||||||
- `/api/personalities` — no controller/service exists; frontend expects GET/POST/PATCH/DELETE
|
|
||||||
- `/users/me/preferences` — listed in PRD API table but returns 404; frontend profile page depends on it
|
|
||||||
- ASSUMPTION: Personalities API follows existing NestJS module patterns (controller + service + DTO + Prisma model). Rationale: Consistent with all other API modules in the codebase.
|
|
||||||
- ASSUMPTION: User preferences endpoint is part of the existing users module but route is not registered. Rationale: PRD lists it as an existing endpoint.
|
|
||||||
|
|
||||||
**Orchestrator Connectivity:**
|
|
||||||
|
|
||||||
- All orchestrator-proxied endpoints return HTTP 502
|
|
||||||
- Orchestrator WebSocket connection fails ("Reconnecting to server...")
|
|
||||||
- Dashboard widgets: Agent Status, Task Progress, Orchestrator Events all error
|
|
||||||
- ASSUMPTION: The orchestrator service container runs but the Next.js API proxy cannot reach it. Root cause is likely environment variable or network configuration in Docker Swarm. Rationale: The orchestrator container exists in the compose file and has Traefik labels.
|
|
||||||
|
|
||||||
**UI/UX Issues:**
|
|
||||||
|
|
||||||
- Dark mode theming on Formality Level dropdown in Personalities page incorrect
|
|
||||||
- favicon.ico missing (404)
|
|
||||||
- Terminal sidebar link uses `#terminal` anchor instead of page route
|
|
||||||
- `useWorkspaceId` warning in console: no workspace ID in localStorage on fresh sessions
|
|
||||||
- ASSUMPTION: Terminal should have a dedicated page route `/terminal` that renders the terminal panel full-screen. Rationale: The sidebar has a Terminal link in the Operations section alongside Logs, implying it should be a navigable page.
|
|
||||||
|
|
||||||
**Credential Management:**
|
|
||||||
|
|
||||||
- "Add Credential" button is `disabled` in code — feature was stubbed as "coming soon"
|
|
||||||
- Need to implement credential creation UI and wire to existing `/api/credentials` CRUD endpoints
|
|
||||||
- ASSUMPTION: Credential CRUD frontend can use the existing `/api/credentials` API which was built during M7-CredentialSecurity. Rationale: Backend endpoints exist per audit.
|
|
||||||
|
|
||||||
### FR-021: Settings Configuration (Future — MS21)
|
|
||||||
|
|
||||||
- All environment variables configurable via UI
|
|
||||||
- Minimal launch env vars, rest configurable dynamically
|
|
||||||
- Settings stored in DB with RLS
|
|
||||||
- Theme selection, widget management, federation config, telemetry config
|
|
||||||
|
|
||||||
## Non-Functional Requirements
|
## Non-Functional Requirements
|
||||||
|
|
||||||
1. Security: All API endpoints require authentication. RBAC enforced. No PII in telemetry. Secrets never hardcoded.
|
- All API endpoints must pass NestJS ValidationPipe (value imports for DTOs, not `import type`)
|
||||||
2. Performance: Dashboard loads in <2s. No layout shift during theme toggle. Sidebar toggle is instant (<100ms animation).
|
- All new modules must be registered in `app.module.ts`
|
||||||
3. Reliability: Break-glass auth ensures access when Authentik is down.
|
- Prisma schema changes must include a migration file
|
||||||
4. Observability: Telemetry with opt-out support. Wide-event logging. Customizable telemetry endpoint.
|
- Code must pass `pnpm turbo run lint typecheck build` before merge
|
||||||
|
- Tests must pass `pnpm turbo run test` with no regressions
|
||||||
|
- No direct pushes to main — PR + squash merge only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
### MS15-DashboardShell — COMPLETE
|
- [ ] `AgentTemplate` and `UserAgent` tables exist in production DB after migration
|
||||||
|
- [ ] `GET /admin/agent-templates` returns jarvis, builder, medic (seeded)
|
||||||
|
- [ ] `POST /admin/agent-templates` creates a new template (admin only, 403 for non-admin)
|
||||||
|
- [ ] `GET /agents` returns the authenticated user's agents
|
||||||
|
- [ ] `POST /agents` creates a user agent from a template
|
||||||
|
- [ ] `GET /agents/:id/chat` proxies to the correct agent
|
||||||
|
- [ ] Discord message in #jarvis channel routes to jarvis agent and responds
|
||||||
|
- [ ] Discord message in #builder channel routes to builder agent and responds
|
||||||
|
- [ ] WebUI shows agent list with name, role, model, status
|
||||||
|
- [ ] WebUI allows selecting an agent and sending a message
|
||||||
|
- [ ] All CI checks green on main after final PR merge
|
||||||
|
|
||||||
1. ~~Design tokens from dashboard.html are implemented in globals.css~~ DONE
|
---
|
||||||
2. ~~App shell shows full-width header with logo, collapsible sidebar, main content area~~ DONE
|
|
||||||
3. ~~Sidebar has all nav groups with icons, collapses to icon-only mode~~ DONE
|
|
||||||
4. ~~Hamburger button appears at mobile breakpoints, sidebar hidden by default~~ DONE
|
|
||||||
5. ~~Light/dark theme toggle works across all components~~ DONE
|
|
||||||
6. ~~Mosaic logo spinner is used as site-wide loading indicator~~ DONE
|
|
||||||
7. ~~Dashboard page shows metrics strip, orchestrator sessions, quick actions, activity feed, token budget~~ DONE
|
|
||||||
8. ~~All shared components in packages/ui use design tokens (no hardcoded colors)~~ DONE
|
|
||||||
9. ~~Lint, typecheck, and existing tests pass~~ DONE
|
|
||||||
10. ~~Grain overlay texture from reference is applied~~ DONE
|
|
||||||
|
|
||||||
### Go-Live MVP (v0.0.16) — COMPLETE
|
## Technical Considerations
|
||||||
|
|
||||||
11. ~~Dashboard widgets wired to real API data~~ DONE
|
- DTOs must use value imports (never `import type`) for NestJS ValidationPipe compatibility — see MEMORY.md
|
||||||
12. ~~WebSocket emits for agent job lifecycle~~ DONE
|
- `AgentTemplate.fallbackModels` and `toolPermissions` are stored as `Json` (Prisma) — treat as `string[]` in TypeScript
|
||||||
13. ~~Deployed to mosaic.woltje.com with auth working~~ DONE
|
- Discord routing requires mapping `discordChannel` string to a channel ID in OpenClaw config
|
||||||
|
- Chat proxy must be stateless — agent selection passed per-request
|
||||||
|
- Use `AdminGuard` from `src/auth/guards/admin.guard` for admin endpoints (existing pattern)
|
||||||
|
- Use `AuthGuard` from `src/auth/guards/auth.guard` for user endpoints (existing pattern)
|
||||||
|
|
||||||
### MS16+MS17 — Pages & Data Integration — COMPLETE
|
---
|
||||||
|
|
||||||
14. ~~All sidebar links navigate to functional pages (no 404s)~~ DONE
|
## Risks / Open Questions
|
||||||
15. ~~Projects page: list, create, view project details~~ DONE
|
|
||||||
16. ~~Workspace page: view single project with tasks and agent sessions~~ DONE
|
|
||||||
17. ~~Kanban page: drag-and-drop board with task status columns~~ DONE
|
|
||||||
18. ~~File Manager page: tree/list view with CRUD operations~~ DONE
|
|
||||||
19. ~~Logs page: log viewer with filtering and auto-refresh~~ DONE
|
|
||||||
20. ~~Settings root page: category index linking to subpages~~ DONE
|
|
||||||
21. ~~Custom 404 page for unknown routes~~ DONE
|
|
||||||
22. ~~`/tasks` page uses real API data (no mock)~~ DONE
|
|
||||||
23. ~~`/calendar` page uses real API data (no mock)~~ DONE
|
|
||||||
24. ~~`/knowledge` pages use real API data (no mock)~~ DONE
|
|
||||||
25. ~~All new pages support light/dark theme~~ DONE
|
|
||||||
26. ~~All new pages are responsive (sm/md/lg/xl breakpoints)~~ DONE
|
|
||||||
27. ~~Lint, typecheck, and tests pass~~ DONE
|
|
||||||
28. ~~Deployed and smoke-tested at mosaic.woltje.com~~ DONE
|
|
||||||
|
|
||||||
### MS18 — Theme & Widget System — COMPLETE
|
| Risk | Likelihood | Mitigation |
|
||||||
|
| ------------------------------------------------------------ | ---------- | ------------------------------------------------- |
|
||||||
|
| Discord channel ID mapping not yet configured in OpenClaw | Medium | Manual config step; document in MISSION-MANIFEST |
|
||||||
|
| Agent routing adds latency to chat proxy | Low | Agent lookup is a single DB read; cache if needed |
|
||||||
|
| `exactOptionalPropertyTypes` TS strictness on Prisma creates | Medium | Use conditional spread for optional fields |
|
||||||
|
| Seed idempotency failure (duplicate name) | Low | Use `upsert` — already implemented |
|
||||||
|
|
||||||
29. ~~5+ themes with live preview and instant switching~~ DONE
|
---
|
||||||
30. ~~Theme selection UI in Settings with swatches~~ DONE
|
|
||||||
31. ~~UserPreference.theme persists across sessions~~ DONE
|
|
||||||
32. ~~WidgetGrid dashboard with drag/resize/add/remove~~ DONE
|
|
||||||
33. ~~Widget picker UI from registry~~ DONE
|
|
||||||
34. ~~Per-widget configuration dialog~~ DONE
|
|
||||||
35. ~~Layout save/load/rename/delete via API~~ DONE
|
|
||||||
36. ~~Tiptap WYSIWYG editor for knowledge entries~~ DONE
|
|
||||||
37. ~~Markdown round-trip (import/export)~~ DONE
|
|
||||||
38. ~~Kanban filtering by project, assignee, priority, search~~ DONE
|
|
||||||
39. ~~All features support all themes~~ DONE
|
|
||||||
40. ~~Lint, typecheck, tests pass~~ DONE
|
|
||||||
|
|
||||||
### MS19 — Chat & Terminal — COMPLETE
|
## Success Metrics / Testing
|
||||||
|
|
||||||
41. ~~Terminal panel has real xterm.js with PTY backend~~ DONE — PR #518
|
- Unit tests cover: AgentTemplateService CRUD, UserAgentService CRUD, chat routing logic
|
||||||
42. ~~Terminal supports multiple named sessions (tabs)~~ DONE — PR #520
|
- E2E test: send Discord message in #jarvis → verify response comes from jarvis agent
|
||||||
43. ~~Terminal sessions persist and recover on reconnect~~ DONE — PR #517
|
- Manual smoke test: WebUI agent selector loads, chat works with selected agent
|
||||||
44. ~~Chat streaming renders tokens in real-time (SSE)~~ DONE — PR #516
|
- CI pipeline green on all three apps (api, web, orchestrator)
|
||||||
45. ~~Master chat sidebar accessible from any page (Cmd+Shift+J)~~ DONE — PR #519
|
|
||||||
46. ~~Master chat supports model selection and conversation management~~ DONE — PR #519
|
|
||||||
47. ~~Project-level chat can trigger orchestrator actions~~ DONE — PR #521
|
|
||||||
48. ~~Agent output viewable in terminal tabs~~ DONE — PR #522
|
|
||||||
49. ~~All features support all themes~~ DONE — CSS variables throughout
|
|
||||||
50. ~~Lint, typecheck, tests pass~~ DONE — 1441 web + 3303 API = 4744 total
|
|
||||||
51. ~~Deployed and smoke-tested~~ DONE — CI #635 green, web deployed to mosaic.woltje.com
|
|
||||||
|
|
||||||
### Full Project (All Milestones)
|
---
|
||||||
|
|
||||||
52. jarvis user logs in via Authentik, has admin access to all pages
|
## Milestones / Delivery
|
||||||
53. jarvis-user has standard access at lower permission level
|
|
||||||
54. Break-glass user has access without Authentik
|
|
||||||
55. Three Mosaic Stack instances on Portainer with federation testing
|
|
||||||
56. Playwright tests confirm all pages, functions, theming work
|
|
||||||
57. No errors during site navigation
|
|
||||||
58. API documented via Swagger with proper auth gating
|
|
||||||
59. Telemetry working locally with wide-event logging
|
|
||||||
60. Mosaic Telemetry properly reporting to telemetry endpoint
|
|
||||||
|
|
||||||
## Constraints and Dependencies
|
| Milestone | Tasks | Status | Target |
|
||||||
|
| ----------------- | -------------- | ----------------------- | ---------- |
|
||||||
1. Next.js 16 with App Router — all pages use server/client component patterns
|
| M1: Schema + Seed | P2-001, P2-002 | ✅ done (PR #675, #677) | 2026-03-04 |
|
||||||
2. Tailwind CSS 3.4 — design tokens must integrate with Tailwind's utility class system
|
| M2: Admin CRUD | P2-003 | ✅ done (PR #678) | 2026-03-04 |
|
||||||
3. BetterAuth for authentication — must maintain existing auth flow
|
| M3: User CRUD | P2-004 | ⬜ next | 2026-03-05 |
|
||||||
4. Authentik as IdP at auth.diversecanvas.com — must remain operational
|
| M4: Agent Routing | P2-005, P2-006 | ⬜ pending | 2026-03-05 |
|
||||||
5. PostgreSQL 17 with Prisma — all settings stored in DB
|
| M5: Discord + UI | P2-007, P2-008 | ⬜ pending | 2026-03-06 |
|
||||||
6. Portainer for deployment — 3 instances needed for federation testing
|
| M6: Verification | P2-009, P2-010 | ⬜ pending | 2026-03-06 |
|
||||||
7. packages/ui is shared across apps — changes affect all consumers
|
|
||||||
8. Backend API modules already exist for all page data needs — no new API endpoints required for MS16+MS17 scope
|
|
||||||
|
|
||||||
## Risks and Open Questions
|
|
||||||
|
|
||||||
1. **Risk**: Pages need to match the design system established in MS15. Inconsistency would degrade UX. Mitigation: Use existing design tokens and shared components exclusively. **RESOLVED** — All MS16+MS17 pages use design tokens consistently.
|
|
||||||
2. **Risk**: Kanban drag-and-drop adds complexity and potential for state bugs. Mitigation: Use a proven DnD library. **RESOLVED** — @hello-pangea/dnd selected (maintained fork of react-beautiful-dnd, better TS support). Optimistic updates with rollback on failure.
|
|
||||||
3. **Risk**: Mock data elimination may reveal backend API gaps or mismatches. Mitigation: Audit each API response shape against page needs during implementation. **RESOLVED** — All 3 mock-data pages wired successfully. No API gaps found.
|
|
||||||
4. ~~**Open**: Exact task status values for Kanban columns~~ **RESOLVED** — TaskStatus enum: NOT_STARTED, IN_PROGRESS, PAUSED, COMPLETED, ARCHIVED (5 columns).
|
|
||||||
5. ~~**Open**: Whether Workspace page should require project selection or show a default view~~ **RESOLVED** — Shows project selector when no project param, workspace detail when ?project=id.
|
|
||||||
6. ~~**Open**: File Manager page — should it be a direct mapping of Knowledge entries or a separate file abstraction?~~ **RESOLVED** — Direct mapping to Knowledge entries via /api/knowledge. API shape matches file manager needs.
|
|
||||||
|
|
||||||
## Existing Backend API Modules (Reference)
|
|
||||||
|
|
||||||
These 19 NestJS modules are already implemented with Prisma and available for frontend wiring:
|
|
||||||
|
|
||||||
| Module | Endpoint | Capabilities |
|
|
||||||
| ------------------ | ------------------------------ | --------------------- |
|
|
||||||
| Projects | `/api/projects` | Full CRUD |
|
|
||||||
| Tasks | `/api/tasks` | Full CRUD |
|
|
||||||
| Layouts | `/api/layouts` | Widget placement |
|
|
||||||
| Widgets | `/api/widgets` | Data endpoints |
|
|
||||||
| Activity | `/api/activity` | Audit logs |
|
|
||||||
| Dashboard | `/api/dashboard/summary` | Aggregated summary |
|
|
||||||
| Knowledge | `/api/knowledge` | Full CRUD + search |
|
|
||||||
| Ideas | `/api/ideas` | Capture/CRUD |
|
|
||||||
| Domains | `/api/domains` | CRUD |
|
|
||||||
| Events | `/api/events` | CRUD |
|
|
||||||
| Preferences | `/api/users/me/preferences` | User settings |
|
|
||||||
| Workspace Settings | `/api/workspaces/:id/settings` | LLM config |
|
|
||||||
| Runner Jobs | `/api/runner-jobs` | Job management |
|
|
||||||
| Job Steps | `/api/runner-jobs/:id/steps` | Step tracking |
|
|
||||||
| Agent Tasks | `/api/agent-tasks` | Agent task management |
|
|
||||||
| Credentials | `/api/credentials` | Encrypted storage |
|
|
||||||
| Brain/AI | `/api/brain` | Query/search |
|
|
||||||
| WebSocket | Real-time | Event broadcasting |
|
|
||||||
| LLM | `/api/llm/chat` | Chat + SSE streaming |
|
|
||||||
| Orchestrator Proxy | `/api/orchestrator/*` | Agent mgmt proxy |
|
|
||||||
| Telemetry | Internal | Logging/monitoring |
|
|
||||||
|
|
||||||
## Testing and Verification
|
|
||||||
|
|
||||||
1. Baseline: `pnpm lint && pnpm build` must pass
|
|
||||||
2. Situational: All sidebar links navigate without 404
|
|
||||||
3. Situational: Each new page renders with real API data
|
|
||||||
4. Situational: Theme toggle on each new page
|
|
||||||
5. Situational: Responsive verification at sm/md/lg/xl
|
|
||||||
6. E2E: Playwright tests for all page navigation (MS23)
|
|
||||||
7. E2E: Auth flow with Authentik (MS23)
|
|
||||||
8. Federation: Master-master and master-slave data access tests (MS21)
|
|
||||||
|
|
||||||
## Delivery/Milestone Intent
|
|
||||||
|
|
||||||
| Milestone | Version | Focus | Status |
|
|
||||||
| ------------------------------ | ------- | ----------------------------------------------------------------- | ----------- |
|
|
||||||
| MS15-DashboardShell | 0.0.15 | Design system + app shell + dashboard page | COMPLETE |
|
|
||||||
| Go-Live MVP | 0.0.16 | Dashboard polish, ingestion, agent visibility, deploy | COMPLETE |
|
|
||||||
| MS16+MS17-PagesDataIntegration | 0.0.17 | All pages built + wired to real API data | COMPLETE |
|
|
||||||
| MS18-ThemeWidgets | 0.0.18 | Theme package system, widget registry, WYSIWYG, Kanban filtering | COMPLETE |
|
|
||||||
| MS19-ChatTerminal | 0.0.19 | Global terminal, project chat, master chat session | COMPLETE |
|
|
||||||
| MS20-SiteStabilization | 0.0.20 | Runtime bug fixes, missing endpoints, orchestrator connectivity | IN PROGRESS |
|
|
||||||
| MS21-MultiTenant | 0.0.21 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth | NOT STARTED |
|
|
||||||
| MS22-Federation | 0.0.22 | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
|
|
||||||
| MS23-AgentTelemetry | 0.0.23 | Agent task mapping, telemetry, wide-event logging | NOT STARTED |
|
|
||||||
| MS24-Testing | 0.0.24 | Playwright E2E, federation tests, documentation finalization | NOT STARTED |
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
1. ASSUMPTION: Header spans full width including above sidebar area. The logo is in the header, not the sidebar. Rationale: User explicitly stated "The logo will NOT be part of the sidebar."
|
|
||||||
2. ASSUMPTION: Sidebar footer user card navigates to Profile page. Rationale: Matches reference design behavior.
|
|
||||||
3. ASSUMPTION: Initial implementation supports dark/light from reference design. Multi-theme package system is a future milestone. Rationale: Foundation must be solid before extensibility.
|
|
||||||
4. ASSUMPTION: MS16 and MS17 are combined into a single mission because 19 backend API modules already exist with real Prisma business logic. The remaining work is primarily frontend page creation and API wiring. Rationale: Backend audit on 2026-02-22 confirmed all required endpoints are implemented.
|
|
||||||
5. ASSUMPTION: File Manager page maps to Knowledge entries rather than a separate file system abstraction. Rationale: `/api/knowledge` provides full CRUD + search which matches file manager needs. Can be extended later if needed.
|
|
||||||
6. ASSUMPTION: Theme packages are code-level TypeScript files (not runtime-installable npm packages). Each theme exports CSS variable overrides. Rationale: Keeps the system simple for MS18; runtime package loading can be added in a future milestone.
|
|
||||||
7. ASSUMPTION: WYSIWYG editor uses Tiptap (ProseMirror-based, headless). Rationale: Headless approach integrates naturally with the CSS variable design system, excellent markdown import/export, TypeScript-first, battle-tested.
|
|
||||||
8. ASSUMPTION: MS18 includes WYSIWYG editing for knowledge entries and Kanban filtering enhancements in addition to themes and widgets. These were originally listed separately but are grouped into MS18 per PRD scope items 24-25. Rationale: All are frontend-focused enhancements that build on the existing page infrastructure.
|
|
||||||
9. ASSUMPTION: The `useWorkspaceId()` hook + auto-detect in `apiRequest` from PR #532 handles reads, but mutation endpoints on some pages don't pass workspace ID correctly. Rationale: GET requests work after PR #532 but POST/mutation requests still fail on domains and projects pages.
|
|
||||||
10. ASSUMPTION: Personalities API follows existing NestJS module patterns (controller + service + DTO + Prisma model). Rationale: Consistent with all other API modules in the codebase.
|
|
||||||
11. ASSUMPTION: User preferences endpoint is part of the existing users module but route is not registered. Rationale: PRD lists it as an existing endpoint.
|
|
||||||
12. ASSUMPTION: The orchestrator service container runs but the Next.js API proxy cannot reach it. Root cause is likely environment variable or network configuration in Docker Swarm. Rationale: The orchestrator container exists in the compose file and has Traefik labels.
|
|
||||||
13. ASSUMPTION: Terminal should have a dedicated page route `/terminal` that renders the terminal panel full-screen. Rationale: The sidebar has a Terminal link in the Operations section alongside Logs, implying it should be a navigable page.
|
|
||||||
14. ASSUMPTION: Credential CRUD frontend can use the existing `/api/credentials` API which was built during M7-CredentialSecurity. Rationale: Backend endpoints exist per audit.
|
|
||||||
|
|||||||
@@ -89,3 +89,20 @@ Design doc: `docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md`
|
|||||||
| MS22-P1i | done | phase-1i | Chat proxy: route WebUI chat to user's OpenClaw container (SSE) | — | api+web | feat/ms22-p1i-chat-proxy | P1c,P1d | — | — | — | — | 20K | — | |
|
| MS22-P1i | done | phase-1i | Chat proxy: route WebUI chat to user's OpenClaw container (SSE) | — | api+web | feat/ms22-p1i-chat-proxy | P1c,P1d | — | — | — | — | 20K | — | |
|
||||||
| MS22-P1j | done | phase-1j | Docker entrypoint + health checks + core compose | — | docker | feat/ms22-p1j-docker | P1c | — | — | — | — | 10K | — | |
|
| MS22-P1j | done | phase-1j | Docker entrypoint + health checks + core compose | — | docker | feat/ms22-p1j-docker | P1c | — | — | — | — | 10K | — | |
|
||||||
| MS22-P1k | done | phase-1k | Idle reaper cron: stop inactive user containers | — | api | feat/ms22-p1k-idle-reaper | P1d | — | — | — | — | 10K | — | |
|
| MS22-P1k | done | phase-1k | Idle reaper cron: stop inactive user containers | — | api | feat/ms22-p1k-idle-reaper | P1d | — | — | — | — | 10K | — | |
|
||||||
|
|
||||||
|
## MS22 Phase 2: Named Agent Fleet
|
||||||
|
|
||||||
|
PRD: `docs/PRD-MS22-P2-AGENT-FLEET.md`
|
||||||
|
|
||||||
|
| Task ID | Status | Phase | Description | Issue | Scope | Branch | Depends On | Blocks | Assigned Worker | Started | Completed | Est Tokens | Act Tokens | Notes |
|
||||||
|
| ----------- | ------ | -------- | -------------------------------------------- | -------- | ----- | --------------------------- | ------------- | ------------- | --------------- | ---------- | ---------- | ---------- | ---------- | --------------- |
|
||||||
|
| MS22-P2-001 | done | p2-fleet | Prisma schema: AgentTemplate, UserAgent | TASKS:P2 | api | feat/ms22-p2-agent-schema | MS22-P1a | P2-002,P2-003 | orchestrator | 2026-03-04 | 2026-03-04 | 10K | 3K | PR #675 merged |
|
||||||
|
| MS22-P2-002 | done | p2-fleet | Seed default agents (jarvis, builder, medic) | TASKS:P2 | api | feat/ms22-p2-agent-seed | P2-001 | P2-004 | orchestrator | 2026-03-04 | 2026-03-04 | 5K | 2K | PR #677 merged |
|
||||||
|
| MS22-P2-003 | done | p2-fleet | Agent template CRUD endpoints (admin) | TASKS:P2 | api | feat/ms22-p2-agent-crud | P2-001 | P2-005 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 5K | PR #678 merged |
|
||||||
|
| MS22-P2-004 | done | p2-fleet | User agent CRUD endpoints | TASKS:P2 | api | feat/ms22-p2-user-agents | P2-002,P2-003 | P2-006 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 8K | PR #682 merged |
|
||||||
|
| MS22-P2-005 | done | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-routing | P2-003 | P2-008 | orchestrator | 2026-03-04 | 2026-03-04 | 10K | 5K | PR #684 merged |
|
||||||
|
| MS22-P2-006 | done | p2-fleet | Agent chat routing (select agent by name) | TASKS:P2 | api | feat/ms22-p2-agent-routing | P2-004 | P2-007 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 5K | PR #684 merged |
|
||||||
|
| MS22-P2-007 | done | p2-fleet | Discord channel → agent routing | TASKS:P2 | api | feat/ms22-p2-discord-router | P2-006 | P2-010 | orchestrator | 2026-03-05 | 2026-03-05 | 15K | 8K | PR #688 |
|
||||||
|
| MS22-P2-008 | done | p2-fleet | Agent list/selector UI in WebUI | TASKS:P2 | web | feat/ms22-p2-agent-ui | P2-005 | — | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 8K | PR #685 merged |
|
||||||
|
| MS22-P2-009 | done | p2-fleet | Unit tests for agent services | TASKS:P2 | api | test/ms22-p2-agent-tests | P2-006 | P2-010 | orchestrator | 2026-03-04 | 2026-03-05 | 15K | 8K | PR #687 merged |
|
||||||
|
| MS22-P2-010 | done | p2-fleet | E2E verification: Discord → agent → response | TASKS:P2 | stack | — | P2-009 | — | orchestrator | 2026-03-05 | 2026-03-05 | 10K | 5K | All gates green |
|
||||||
|
|||||||
38
docs/scratchpads/ms22-p2-named-agent-fleet-20260304.md
Normal file
38
docs/scratchpads/ms22-p2-named-agent-fleet-20260304.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Mission Scratchpad — MS22-P2 Named Agent Fleet
|
||||||
|
|
||||||
|
> Append-only log. NEVER delete entries. NEVER overwrite sections.
|
||||||
|
> This is the orchestrator's working memory across sessions.
|
||||||
|
|
||||||
|
## Original Mission Prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
(Paste the mission prompt here on first session)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Planning Decisions
|
||||||
|
|
||||||
|
## Session Log
|
||||||
|
|
||||||
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
|
| ------- | ---------- | --------- | ---------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
|
| 5 | 2026-03-05 | M6 | P2-010 done | E2E verification: 3547 tests pass, all CI gates green. Mission complete. |
|
||||||
|
| 4 | 2026-03-05 | M5+M6 | P2-007 done | Discord channel→agent routing. Fixed lint/type errors. PR #688 merged. 9/10 tasks done. |
|
||||||
|
| 3 | 2026-03-05 | M4+M5 | P2-008 done | Fixed corrupted AgentSelector.tsx, integrated into Chat.tsx. PR #685 merged. 8/10 tasks done. |
|
||||||
|
| 2 | 2026-03-04 | M1+M2+M3 | P2-004 done | Fixed CI security audit, merged PRs #681, #678, #682. Milestones 1-3 complete. |
|
||||||
|
| 1 | 2026-03-04 | M1+M2 | P2-001, P2-002, P2-003 | Schema, seed, and Admin CRUD complete |
|
||||||
|
|
||||||
|
## Mission Complete
|
||||||
|
|
||||||
|
All 10 tasks completed. Success criteria verified:
|
||||||
|
|
||||||
|
1. ✅ AgentTemplate and UserAgent tables in Prisma schema
|
||||||
|
2. ✅ Admin CRUD at /admin/agent-templates
|
||||||
|
3. ✅ User CRUD at /api/agents
|
||||||
|
4. ✅ Chat proxy routes by agent name
|
||||||
|
5. ✅ Discord channel routing via DISCORD_AGENT_CHANNELS
|
||||||
|
6. ✅ WebUI AgentSelector component
|
||||||
|
7. ✅ 3547 tests passing, CI green
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
## Corrections
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
"undici": ">=6.23.0",
|
"undici": ">=6.23.0",
|
||||||
"rollup": ">=4.59.0",
|
"rollup": ">=4.59.0",
|
||||||
"serialize-javascript": ">=7.0.3",
|
"serialize-javascript": ">=7.0.3",
|
||||||
"multer": ">=2.1.0"
|
"multer": ">=2.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -17,7 +17,7 @@ overrides:
|
|||||||
undici: '>=6.23.0'
|
undici: '>=6.23.0'
|
||||||
rollup: '>=4.59.0'
|
rollup: '>=4.59.0'
|
||||||
serialize-javascript: '>=7.0.3'
|
serialize-javascript: '>=7.0.3'
|
||||||
multer: '>=2.1.0'
|
multer: '>=2.1.1'
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
@@ -1616,6 +1616,7 @@ packages:
|
|||||||
|
|
||||||
'@mosaicstack/telemetry-client@0.1.1':
|
'@mosaicstack/telemetry-client@0.1.1':
|
||||||
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
|
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@mrleebo/prisma-ast@0.13.1':
|
'@mrleebo/prisma-ast@0.13.1':
|
||||||
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
||||||
@@ -5863,8 +5864,8 @@ packages:
|
|||||||
msgpackr@1.11.5:
|
msgpackr@1.11.5:
|
||||||
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
|
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
|
||||||
|
|
||||||
multer@2.1.0:
|
multer@2.1.1:
|
||||||
resolution: {integrity: sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==}
|
resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==}
|
||||||
engines: {node: '>= 10.16.0'}
|
engines: {node: '>= 10.16.0'}
|
||||||
|
|
||||||
mute-stream@2.0.0:
|
mute-stream@2.0.0:
|
||||||
@@ -8004,7 +8005,7 @@ snapshots:
|
|||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
commander: 12.1.0
|
commander: 12.1.0
|
||||||
dotenv: 17.2.4
|
dotenv: 17.2.4
|
||||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
open: 10.2.0
|
open: 10.2.0
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prettier: 3.8.1
|
prettier: 3.8.1
|
||||||
@@ -8868,7 +8869,7 @@ snapshots:
|
|||||||
'@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
cors: 2.8.5
|
cors: 2.8.5
|
||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
multer: 2.1.0
|
multer: 2.1.1
|
||||||
path-to-regexp: 8.3.0
|
path-to-regexp: 8.3.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -11344,7 +11345,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
better-sqlite3: 12.6.2
|
better-sqlite3: 12.6.2
|
||||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
@@ -11369,7 +11370,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
better-sqlite3: 12.6.2
|
better-sqlite3: 12.6.2
|
||||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
@@ -12193,6 +12194,17 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@17.2.4: {}
|
dotenv@17.2.4: {}
|
||||||
|
|
||||||
|
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
||||||
|
optionalDependencies:
|
||||||
|
'@opentelemetry/api': 1.9.0
|
||||||
|
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
|
'@types/pg': 8.16.0
|
||||||
|
better-sqlite3: 12.6.2
|
||||||
|
kysely: 0.28.10
|
||||||
|
pg: 8.17.2
|
||||||
|
postgres: 3.4.8
|
||||||
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
|
|
||||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
@@ -12203,6 +12215,7 @@ snapshots:
|
|||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
postgres: 3.4.8
|
postgres: 3.4.8
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
|
optional: true
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -13473,7 +13486,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
msgpackr-extract: 3.0.3
|
msgpackr-extract: 3.0.3
|
||||||
|
|
||||||
multer@2.1.0:
|
multer@2.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
append-field: 1.0.0
|
append-field: 1.0.0
|
||||||
busboy: 1.6.0
|
busboy: 1.6.0
|
||||||
|
|||||||
Reference in New Issue
Block a user