Compare commits

..

1 Commits

Author SHA1 Message Date
5d66e00710 feat(api): ContainerLifecycleService for per-user OpenClaw (MS22-P1d)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-01 09:23:43 -06:00
214 changed files with 1738 additions and 21141 deletions

View File

@@ -343,11 +343,6 @@ RATE_LIMIT_STORAGE=redis
# DISCORD_CONTROL_CHANNEL_ID=channel-id-for-commands
# 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.
# All Discord commands will execute within this workspace context for proper
# multi-tenant isolation. Each Discord bot instance should be configured for

View File

@@ -1,56 +1,56 @@
{
"schema_version": 1,
"mission_id": "ms22-p2-named-agent-fleet-20260304",
"name": "MS22-P2 Named Agent Fleet",
"description": "",
"mission_id": "ms21-multi-tenant-rbac-data-migration-20260228",
"name": "MS21 Multi-Tenant RBAC Data Migration",
"description": "Build multi-tenant user/workspace/team management, break-glass auth, RBAC UI enforcement, and migrate jarvis-brain data into Mosaic Stack",
"project_path": "/home/jwoltje/src/mosaic-stack",
"created_at": "2026-03-05T01:53:28Z",
"created_at": "2026-02-28T17:10:22Z",
"status": "active",
"task_prefix": "",
"quality_gates": "",
"milestone_version": "0.0.1",
"task_prefix": "MS21",
"quality_gates": "pnpm lint && pnpm build && pnpm test",
"milestone_version": "0.0.21",
"milestones": [
{
"id": "phase-1",
"name": "Schema+Seed",
"name": "Schema and Admin API",
"status": "pending",
"branch": "schema-seed",
"branch": "schema-and-admin-api",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-2",
"name": "Admin CRUD",
"name": "Break-Glass Authentication",
"status": "pending",
"branch": "admin-crud",
"branch": "break-glass-authentication",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-3",
"name": "User CRUD",
"name": "Data Migration",
"status": "pending",
"branch": "user-crud",
"branch": "data-migration",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-4",
"name": "Agent Routing",
"name": "Admin UI",
"status": "pending",
"branch": "agent-routing",
"branch": "admin-ui",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-5",
"name": "Discord+UI",
"name": "RBAC UI Enforcement",
"status": "pending",
"branch": "discord-ui",
"branch": "rbac-ui-enforcement",
"issue_ref": "",
"started_at": "",
"completed_at": ""
@@ -65,5 +65,26 @@
"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": ""
}
]
}

View File

@@ -0,0 +1,8 @@
{
"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
View File

@@ -1,3 +1 @@
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
supportedArchitectures[libc][]=glibc
supportedArchitectures[cpu][]=x64

View File

@@ -1,27 +0,0 @@
when:
- event: manual
- event: cron
cron: weekly-base-image
variables:
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
steps:
build-base:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
commands:
- *kaniko_setup
- /kaniko/executor
--context .
--dockerfile docker/base.Dockerfile
--destination git.mosaicstack.dev/mosaic/node-base:24-slim
--destination git.mosaicstack.dev/mosaic/node-base:latest
--cache=true
--cache-repo git.mosaicstack.dev/mosaic/node-base/cache

View File

@@ -29,11 +29,9 @@ when:
- ".trivyignore"
variables:
- &node_image "node:24-slim"
- &node_image "node:24-alpine"
- &install_deps |
corepack enable
apt-get update && apt-get install -y --no-install-recommends python3 make g++
pnpm config set store-dir /root/.local/share/pnpm/store
pnpm install --frozen-lockfile
- &use_deps |
corepack enable
@@ -170,7 +168,7 @@ steps:
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
fi
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-api/cache $DESTINATIONS
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
@@ -195,7 +193,7 @@ steps:
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
fi
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-orchestrator/cache $DESTINATIONS
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
@@ -220,7 +218,7 @@ steps:
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
fi
/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
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
@@ -337,47 +335,3 @@ steps:
- security-trivy-api
- security-trivy-orchestrator
- 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

View File

@@ -1,7 +1,7 @@
# Base image for all stages
# Uses Debian slim (glibc) instead of Alpine (musl) because native Node.js addons
# (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries.
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
FROM node:24-slim AS base
# Install pnpm globally
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
@@ -19,9 +19,9 @@ COPY turbo.json ./
FROM base AS deps
# Install build tools for native addons (node-pty requires node-gyp compilation)
# Note: openssl and ca-certificates pre-installed in base image
# and OpenSSL for Prisma engine detection
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
python3 make g++ openssl \
&& rm -rf /var/lib/apt/lists/*
# Copy all package.json files for workspace resolution
@@ -30,9 +30,6 @@ COPY packages/ui/package.json ./packages/ui/
COPY packages/config/package.json ./packages/config/
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)
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
# scripts or fail to find prebuilt binaries for this Node.js version
@@ -64,14 +61,19 @@ RUN pnpm turbo build --filter=@mosaic/api --force
# ======================
# Production stage
# ======================
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
FROM node:24-slim AS production
# dumb-init, openssl, ca-certificates pre-installed in base image
# Install dumb-init for proper signal handling (static binary from GitHub,
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors)
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
# - Remove npm/npx to reduce image size (not used in production)
# - Create non-root user
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
# - openssl: Prisma engine detection requires libssl
# - No build tools needed here — native addons are compiled in the deps stage
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
&& chmod 755 /usr/local/bin/dumb-init \
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
WORKDIR /app

View File

@@ -36,7 +36,6 @@
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.1.12",
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/schedule": "^6.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.12",
"@opentelemetry/api": "^1.9.0",
@@ -62,7 +61,6 @@
"discord.js": "^14.25.1",
"dockerode": "^4.0.9",
"gray-matter": "^4.0.3",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"ioredis": "^5.9.2",
"jose": "^6.1.3",

View File

@@ -1,13 +0,0 @@
-- 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");

View File

@@ -1,83 +0,0 @@
-- CreateTable
CREATE TABLE "AgentConversationMessage" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"provider" TEXT NOT NULL DEFAULT 'internal',
"role" TEXT NOT NULL,
"content" TEXT NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"metadata" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "AgentConversationMessage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentSessionTree" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"parentSessionId" TEXT,
"provider" TEXT NOT NULL DEFAULT 'internal',
"missionId" TEXT,
"taskId" TEXT,
"taskSource" TEXT DEFAULT 'internal',
"agentType" TEXT,
"status" TEXT NOT NULL DEFAULT 'spawning',
"spawnedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
"metadata" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "AgentSessionTree_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentProviderConfig" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"gatewayUrl" TEXT NOT NULL,
"credentials" JSONB NOT NULL DEFAULT '{}',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AgentProviderConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OperatorAuditLog" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"action" TEXT NOT NULL,
"content" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OperatorAuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AgentConversationMessage_sessionId_timestamp_idx" ON "AgentConversationMessage"("sessionId", "timestamp");
-- CreateIndex
CREATE UNIQUE INDEX "AgentSessionTree_sessionId_key" ON "AgentSessionTree"("sessionId");
-- CreateIndex
CREATE INDEX "AgentSessionTree_parentSessionId_idx" ON "AgentSessionTree"("parentSessionId");
-- CreateIndex
CREATE INDEX "AgentSessionTree_missionId_idx" ON "AgentSessionTree"("missionId");
-- CreateIndex
CREATE UNIQUE INDEX "AgentProviderConfig_workspaceId_name_key" ON "AgentProviderConfig"("workspaceId", "name");
-- CreateIndex
CREATE INDEX "OperatorAuditLog_sessionId_idx" ON "OperatorAuditLog"("sessionId");
-- CreateIndex
CREATE INDEX "OperatorAuditLog_userId_idx" ON "OperatorAuditLog"("userId");
-- CreateIndex
CREATE INDEX "OperatorAuditLog_createdAt_idx" ON "OperatorAuditLog"("createdAt");

View File

@@ -1703,102 +1703,3 @@ model UserAgentConfig {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AgentTemplate {
id String @id @default(cuid())
name String @unique // "jarvis", "builder", "medic"
displayName String // "Jarvis", "Builder", "Medic"
role String // "orchestrator" | "coding" | "monitoring"
personality String // SOUL.md content (markdown)
primaryModel String // "opus", "codex", "haiku"
fallbackModels Json @default("[]") // ["sonnet", "haiku"]
toolPermissions Json @default("[]") // ["exec", "read", "write", ...]
discordChannel String? // "jarvis", "builder", "medic-alerts"
isActive Boolean @default(true)
isDefault Boolean @default(false) // Include in new user provisioning
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model UserAgent {
id String @id @default(cuid())
userId String
templateId String? // null = custom agent
name String // "jarvis", "builder", "medic" or custom
displayName String
role String
personality String // User can customize
primaryModel String?
fallbackModels Json @default("[]")
toolPermissions Json @default("[]")
discordChannel String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, name])
@@index([userId])
}
// MS23: Agent conversation messages for Mission Control streaming
model AgentConversationMessage {
id String @id @default(cuid())
sessionId String
provider String @default("internal")
role String
content String
timestamp DateTime @default(now())
metadata Json @default("{}")
@@index([sessionId, timestamp])
}
// MS23: Agent session tree for parent/child relationships
model AgentSessionTree {
id String @id @default(cuid())
sessionId String @unique
parentSessionId String?
provider String @default("internal")
missionId String?
taskId String?
taskSource String? @default("internal")
agentType String?
status String @default("spawning")
spawnedAt DateTime @default(now())
completedAt DateTime?
metadata Json @default("{}")
@@index([parentSessionId])
@@index([missionId])
}
// MS23: External agent provider configuration per workspace
model AgentProviderConfig {
id String @id @default(cuid())
workspaceId String
name String
provider String
gatewayUrl String
credentials Json @default("{}")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([workspaceId, name])
}
// MS23: Audit log for operator interventions
model OperatorAuditLog {
id String @id @default(cuid())
userId String
sessionId String
provider String
action String
content String?
metadata Json @default("{}")
createdAt DateTime @default(now())
@@index([sessionId])
@@index([userId])
@@index([createdAt])
}

View File

@@ -7,7 +7,6 @@ import {
EntryStatus,
Visibility,
} from "@prisma/client";
import { seedAgentTemplates } from "../src/seed/agent-templates.seed";
const prisma = new PrismaClient();
@@ -587,9 +586,6 @@ This is a draft document. See [[architecture-overview]] for current state.`,
console.log(`Created ${links.length} knowledge links`);
});
// Seed default agent templates (idempotent)
await seedAgentTemplates(prisma);
console.log("Seeding completed successfully!");
}

View File

@@ -1,7 +1,7 @@
import { Controller, Get, Query, Param, UseGuards } from "@nestjs/common";
import { ActivityService } from "./activity.service";
import { EntityType } from "@prisma/client";
import { QueryActivityLogDto } from "./dto";
import type { QueryActivityLogDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";

View File

@@ -117,13 +117,12 @@ export class ActivityService {
/**
* Get a single activity log by ID
*/
async findOne(id: string, workspaceId?: string): Promise<ActivityLogResult | null> {
const where: Prisma.ActivityLogWhereUniqueInput = { id };
if (workspaceId) {
where.workspaceId = workspaceId;
}
async findOne(id: string, workspaceId: string): Promise<ActivityLogResult | null> {
return await this.prisma.activityLog.findUnique({
where,
where: {
id,
workspaceId,
},
include: {
user: {
select: {

View File

@@ -384,18 +384,10 @@ describe("ActivityLoggingInterceptor", () => {
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// workspaceId is now optional, so logActivity should be called without it
expect(mockActivityService.logActivity).toHaveBeenCalled();
const callArgs = mockActivityService.logActivity.mock.calls[0][0];
expect(callArgs.userId).toBe("user-123");
expect(callArgs.entityId).toBe("task-123");
expect(callArgs.workspaceId).toBeUndefined();
// Should not call logActivity when workspaceId is missing
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
@@ -420,18 +412,10 @@ describe("ActivityLoggingInterceptor", () => {
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// workspaceId is now optional, so logActivity should be called without it
expect(mockActivityService.logActivity).toHaveBeenCalled();
const callArgs = mockActivityService.logActivity.mock.calls[0][0];
expect(callArgs.userId).toBe("user-123");
expect(callArgs.entityId).toBe("task-123");
expect(callArgs.workspaceId).toBeUndefined();
// Should not call logActivity when workspaceId is missing
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});

View File

@@ -4,7 +4,6 @@ import { tap } from "rxjs/operators";
import { ActivityService } from "../activity.service";
import { ActivityAction, EntityType } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import type { CreateActivityLogInput } from "../interfaces/activity.interface";
import type { AuthenticatedRequest } from "../../common/types/user.types";
/**
@@ -62,13 +61,10 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
// Extract entity information
const resultObj = result as Record<string, unknown> | undefined;
const entityId = params.id ?? (resultObj?.id as string | undefined);
// workspaceId is now optional - log events even when missing
const workspaceId = user.workspaceId ?? (body.workspaceId as string | undefined);
// Log with warning if entityId is missing, but still proceed with logging if workspaceId exists
if (!entityId) {
this.logger.warn("Cannot log activity: missing entityId");
if (!entityId || !workspaceId) {
this.logger.warn("Cannot log activity: missing entityId or workspaceId");
return;
}
@@ -96,8 +92,9 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
const userAgent =
typeof userAgentHeader === "string" ? userAgentHeader : userAgentHeader?.[0];
// Log the activity — workspaceId is optional
const activityInput: CreateActivityLogInput = {
// Log the activity
await this.activityService.logActivity({
workspaceId,
userId: user.id,
action,
entityType,
@@ -105,11 +102,7 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
details,
ipAddress: ip ?? undefined,
userAgent: userAgent ?? undefined,
};
if (workspaceId) {
activityInput.workspaceId = workspaceId;
}
await this.activityService.logActivity(activityInput);
});
} catch (error) {
// Don't fail the request if activity logging fails
this.logger.error(

View File

@@ -2,10 +2,9 @@ import type { ActivityAction, EntityType, Prisma } from "@prisma/client";
/**
* Interface for creating a new activity log entry
* workspaceId is optional - allows logging events without workspace context
*/
export interface CreateActivityLogInput {
workspaceId?: string | null;
workspaceId: string;
userId: string;
action: ActivityAction;
entityType: EntityType;

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import type { LlmProvider } from "@prisma/client";
import { createHash, timingSafeEqual } from "node:crypto";
import { timingSafeEqual } from "node:crypto";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "../crypto/crypto.service";
@@ -143,23 +143,21 @@ export class AgentConfigService {
}),
]);
let match: ContainerTokenValidation | null = null;
for (const container of userContainers) {
const storedToken = this.decryptContainerToken(container.gatewayToken);
if (!match && storedToken && this.tokensEqual(storedToken, token)) {
match = { type: "user", id: container.id };
if (storedToken && this.tokensEqual(storedToken, token)) {
return { type: "user", id: container.id };
}
}
for (const container of systemContainers) {
const storedToken = this.decryptContainerToken(container.gatewayToken);
if (!match && storedToken && this.tokensEqual(storedToken, token)) {
match = { type: "system", id: container.id };
if (storedToken && this.tokensEqual(storedToken, token)) {
return { type: "system", id: container.id };
}
}
return match;
return null;
}
private buildOpenClawConfig(
@@ -270,9 +268,14 @@ export class AgentConfigService {
}
private tokensEqual(left: string, right: string): boolean {
const leftDigest = createHash("sha256").update(left, "utf8").digest();
const rightDigest = createHash("sha256").update(right, "utf8").digest();
return timingSafeEqual(leftDigest, rightDigest);
const leftBuffer = Buffer.from(left, "utf8");
const rightBuffer = Buffer.from(right, "utf8");
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return timingSafeEqual(leftBuffer, rightBuffer);
}
private hasModelId(modelEntry: unknown): modelEntry is { id: string } {

View File

@@ -1,47 +0,0 @@
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);
}
}

View File

@@ -1,13 +0,0 @@
import { Module } from "@nestjs/common";
import { AgentTemplateService } from "./agent-template.service";
import { AgentTemplateController } from "./agent-template.controller";
import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
@Module({
imports: [PrismaModule, AuthModule],
controllers: [AgentTemplateController],
providers: [AgentTemplateService],
exports: [AgentTemplateService],
})
export class AgentTemplateModule {}

View File

@@ -1,57 +0,0 @@
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 } });
}
}

View File

@@ -1,43 +0,0 @@
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;
}

View File

@@ -1,4 +0,0 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreateAgentTemplateDto } from "./create-agent-template.dto";
export class UpdateAgentTemplateDto extends PartialType(CreateAgentTemplateDto) {}

View File

@@ -2,7 +2,6 @@ import { Module } from "@nestjs/common";
import { APP_INTERCEPTOR, APP_GUARD } from "@nestjs/core";
import { ThrottlerModule } from "@nestjs/throttler";
import { BullModule } from "@nestjs/bullmq";
import { ScheduleModule } from "@nestjs/schedule";
import { ThrottlerValkeyStorageService, ThrottlerApiKeyGuard } from "./common/throttler";
import { CsrfGuard } from "./common/guards/csrf.guard";
import { CsrfService } from "./common/services/csrf.service";
@@ -48,19 +47,12 @@ import { TerminalModule } from "./terminal/terminal.module";
import { PersonalitiesModule } from "./personalities/personalities.module";
import { WorkspacesModule } from "./workspaces/workspaces.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 { ImportModule } from "./import/import.module";
import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
import { AgentConfigModule } from "./agent-config/agent-config.module";
import { ContainerLifecycleModule } from "./container-lifecycle/container-lifecycle.module";
import { ContainerReaperModule } from "./container-reaper/container-reaper.module";
import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module";
import { OnboardingModule } from "./onboarding/onboarding.module";
import { ChatProxyModule } from "./chat-proxy/chat-proxy.module";
import { OrchestratorModule } from "./orchestrator/orchestrator.module";
@Module({
imports: [
@@ -91,7 +83,6 @@ import { OrchestratorModule } from "./orchestrator/orchestrator.module";
};
})(),
}),
ScheduleModule.forRoot(),
TelemetryModule,
PrismaModule,
DatabaseModule,
@@ -131,18 +122,11 @@ import { OrchestratorModule } from "./orchestrator/orchestrator.module";
PersonalitiesModule,
WorkspacesModule,
AdminModule,
AgentTemplateModule,
UserAgentModule,
TeamsModule,
ImportModule,
ConversationArchiveModule,
AgentConfigModule,
ContainerLifecycleModule,
ContainerReaperModule,
FleetSettingsModule,
OnboardingModule,
ChatProxyModule,
OrchestratorModule,
],
controllers: [AppController, CsrfController],
providers: [

View File

@@ -106,7 +106,7 @@ export class AuthController {
// @SkipCsrf avoids double-protection conflicts.
// See: https://www.better-auth.com/docs/reference/security
@SkipCsrf()
@Throttle({ default: { ttl: 60_000, limit: 5 } })
@Throttle({ strict: { limit: 10, ttl: 60000 } })
async handleAuth(@Req() req: ExpressRequest, @Res() res: ExpressResponse): Promise<void> {
// Extract client IP for logging
const clientIp = this.getClientIp(req);

View File

@@ -5,7 +5,6 @@ import { MatrixService } from "./matrix/matrix.service";
import { StitcherService } from "../stitcher/stitcher.service";
import { PrismaService } from "../prisma/prisma.service";
import { BullMqService } from "../bullmq/bullmq.service";
import { ChatProxyService } from "../chat-proxy/chat-proxy.service";
import { CHAT_PROVIDERS } from "./bridge.constants";
import type { IChatProvider } from "./interfaces";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
@@ -90,7 +89,6 @@ interface SavedEnvVars {
MATRIX_CONTROL_ROOM_ID?: string;
MATRIX_WORKSPACE_ID?: string;
ENCRYPTION_KEY?: string;
MOSAIC_SECRET_KEY?: string;
}
describe("BridgeModule", () => {
@@ -108,7 +106,6 @@ describe("BridgeModule", () => {
MATRIX_CONTROL_ROOM_ID: process.env.MATRIX_CONTROL_ROOM_ID,
MATRIX_WORKSPACE_ID: process.env.MATRIX_WORKSPACE_ID,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
MOSAIC_SECRET_KEY: process.env.MOSAIC_SECRET_KEY,
};
// Clear all bridge env vars
@@ -123,8 +120,6 @@ describe("BridgeModule", () => {
// Set encryption key (needed by StitcherService)
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
mockReadyCallbacks.length = 0;
@@ -154,10 +149,6 @@ describe("BridgeModule", () => {
.useValue({})
.overrideProvider(BullMqService)
.useValue({})
.overrideProvider(ChatProxyService)
.useValue({
proxyChat: vi.fn().mockResolvedValue(new Response()),
})
.compile();
}

View File

@@ -5,8 +5,6 @@ import { MatrixRoomService } from "./matrix/matrix-room.service";
import { MatrixStreamingService } from "./matrix/matrix-streaming.service";
import { CommandParserService } from "./parser/command-parser.service";
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 type { IChatProvider } from "./interfaces";
@@ -30,7 +28,7 @@ const logger = new Logger("BridgeModule");
* MatrixRoomService handles workspace-to-Matrix-room mapping.
*/
@Module({
imports: [StitcherModule, ChatProxyModule, PrismaModule],
imports: [StitcherModule],
providers: [
CommandParserService,
MatrixRoomService,

View File

@@ -1,8 +1,6 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DiscordService } from "./discord.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 { vi, describe, it, expect, beforeEach } from "vitest";
import type { ChatMessage, ChatCommand } from "../interfaces";
@@ -63,8 +61,6 @@ vi.mock("discord.js", () => {
describe("DiscordService", () => {
let service: DiscordService;
let stitcherService: StitcherService;
let chatProxyService: ChatProxyService;
let prismaService: PrismaService;
const mockStitcherService = {
dispatchJob: vi.fn().mockResolvedValue({
@@ -75,29 +71,12 @@ describe("DiscordService", () => {
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 () => {
// Set environment variables for testing
process.env.DISCORD_BOT_TOKEN = "test-token";
process.env.DISCORD_GUILD_ID = "test-guild-id";
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
process.env.DISCORD_WORKSPACE_ID = "test-workspace-id";
process.env.DISCORD_AGENT_CHANNELS = "jarvis-channel:jarvis,builder-channel:builder";
// Clear callbacks
mockReadyCallbacks.length = 0;
@@ -110,21 +89,11 @@ describe("DiscordService", () => {
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<DiscordService>(DiscordService);
stitcherService = module.get<StitcherService>(StitcherService);
chatProxyService = module.get<ChatProxyService>(ChatProxyService);
prismaService = module.get<PrismaService>(PrismaService);
// Clear all mocks
vi.clearAllMocks();
@@ -480,14 +449,6 @@ describe("DiscordService", () => {
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
@@ -509,14 +470,6 @@ describe("DiscordService", () => {
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
@@ -539,14 +492,6 @@ describe("DiscordService", () => {
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
@@ -709,150 +654,4 @@ describe("DiscordService", () => {
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");
});
});
});

View File

@@ -1,8 +1,6 @@
import { Injectable, Logger } from "@nestjs/common";
import { Client, Events, GatewayIntentBits, TextChannel, ThreadChannel } from "discord.js";
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 type {
IChatProvider,
@@ -19,7 +17,6 @@ import type {
* - Connect to Discord via bot token
* - Listen for commands in designated channels
* - Forward commands to stitcher
* - Route messages in agent channels to specific agents via ChatProxyService
* - Receive status updates from herald
* - Post updates to threads
*/
@@ -31,21 +28,12 @@ export class DiscordService implements IChatProvider {
private readonly botToken: string;
private readonly controlChannelId: string;
private readonly workspaceId: string;
private readonly agentChannels = new Map<string, string>();
private workspaceOwnerId: string | null = null;
constructor(
private readonly stitcherService: StitcherService,
private readonly chatProxyService: ChatProxyService,
private readonly prisma: PrismaService
) {
constructor(private readonly stitcherService: StitcherService) {
this.botToken = process.env.DISCORD_BOT_TOKEN ?? "";
this.controlChannelId = process.env.DISCORD_CONTROL_CHANNEL_ID ?? "";
this.workspaceId = process.env.DISCORD_WORKSPACE_ID ?? "";
// Load agent channel mappings from environment
this.loadAgentChannels();
// Initialize Discord client with required intents
this.client = new Client({
intents: [
@@ -58,51 +46,6 @@ export class DiscordService implements IChatProvider {
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
*/
@@ -117,6 +60,9 @@ export class DiscordService implements IChatProvider {
// Ignore bot messages
if (message.author.bot) return;
// Check if message is in control channel
if (message.channelId !== this.controlChannelId) return;
// Parse message into ChatMessage format
const chatMessage: ChatMessage = {
id: message.id,
@@ -128,16 +74,6 @@ export class DiscordService implements IChatProvider {
...(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
const command = this.parseCommand(chatMessage);
if (command) {
@@ -458,150 +394,4 @@ export class DiscordService implements IChatProvider {
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;
}
}

View File

@@ -28,7 +28,6 @@ import { StitcherService } from "../../stitcher/stitcher.service";
import { HeraldService } from "../../herald/herald.service";
import { PrismaService } from "../../prisma/prisma.service";
import { BullMqService } from "../../bullmq/bullmq.service";
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
import type { IChatProvider } from "../interfaces";
import { JOB_CREATED, JOB_STARTED } from "../../job-events/event-types";
@@ -193,7 +192,6 @@ function setDiscordEnv(): void {
function setEncryptionKey(): void {
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
process.env.MOSAIC_SECRET_KEY = "test-mosaic-secret-key-minimum-32-characters-long";
}
/**
@@ -207,10 +205,6 @@ async function compileBridgeModule(): Promise<TestingModule> {
.useValue({})
.overrideProvider(BullMqService)
.useValue({})
.overrideProvider(ChatProxyService)
.useValue({
proxyChat: vi.fn().mockResolvedValue(new Response()),
})
.compile();
}

View File

@@ -1,152 +0,0 @@
import { Body, Controller, HttpException, Logger, Post, Req, Res, UseGuards } from "@nestjs/common";
import type { Response } from "express";
import { AuthGuard } from "../auth/guards/auth.guard";
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
import type { MaybeAuthenticatedRequest } from "../auth/types/better-auth-request.interface";
import { ChatStreamDto } from "./chat-proxy.dto";
import { ChatProxyService } from "./chat-proxy.service";
@Controller("chat")
export class ChatProxyController {
private readonly logger = new Logger(ChatProxyController.name);
constructor(private readonly chatProxyService: ChatProxyService) {}
// POST /api/chat/guest
// Guest chat endpoint - no authentication required
// Uses a shared LLM configuration for unauthenticated users
@SkipCsrf()
@Post("guest")
async guestChat(
@Body() body: ChatStreamDto,
@Req() req: MaybeAuthenticatedRequest,
@Res() res: Response
): Promise<void> {
const abortController = new AbortController();
req.once("close", () => {
abortController.abort();
});
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
try {
const upstreamResponse = await this.chatProxyService.proxyGuestChat(
body.messages,
abortController.signal
);
const upstreamContentType = upstreamResponse.headers.get("content-type");
if (upstreamContentType) {
res.setHeader("Content-Type", upstreamContentType);
}
if (!upstreamResponse.body) {
throw new Error("LLM response did not include a stream body");
}
for await (const chunk of upstreamResponse.body as unknown as AsyncIterable<Uint8Array>) {
if (res.writableEnded || res.destroyed) {
break;
}
res.write(Buffer.from(chunk));
}
} catch (error: unknown) {
this.logStreamError(error);
if (!res.writableEnded && !res.destroyed) {
res.write("event: error\n");
res.write(`data: ${JSON.stringify({ error: this.toSafeClientMessage(error) })}\n\n`);
}
} finally {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
}
}
// POST /api/chat/stream
// Request: { messages: Array<{role, content}> }
// Response: SSE stream of chat completion events
// Requires authentication - uses user's personal OpenClaw container
@Post("stream")
@UseGuards(AuthGuard)
async streamChat(
@Body() body: ChatStreamDto,
@Req() req: MaybeAuthenticatedRequest,
@Res() res: Response
): Promise<void> {
const userId = req.user?.id;
if (!userId) {
this.logger.warn("streamChat called without user ID after AuthGuard");
throw new HttpException("Authentication required", 401);
}
const abortController = new AbortController();
req.once("close", () => {
abortController.abort();
});
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
try {
const upstreamResponse = await this.chatProxyService.proxyChat(
userId,
body.messages,
abortController.signal,
body.agent
);
const upstreamContentType = upstreamResponse.headers.get("content-type");
if (upstreamContentType) {
res.setHeader("Content-Type", upstreamContentType);
}
if (!upstreamResponse.body) {
throw new Error("OpenClaw response did not include a stream body");
}
for await (const chunk of upstreamResponse.body as unknown as AsyncIterable<Uint8Array>) {
if (res.writableEnded || res.destroyed) {
break;
}
res.write(Buffer.from(chunk));
}
} catch (error: unknown) {
this.logStreamError(error);
if (!res.writableEnded && !res.destroyed) {
res.write("event: error\n");
res.write(`data: ${JSON.stringify({ error: this.toSafeClientMessage(error) })}\n\n`);
}
} finally {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
}
}
private toSafeClientMessage(error: unknown): string {
if (error instanceof HttpException && error.getStatus() < 500) {
return "Chat request was rejected";
}
return "Chat stream failed";
}
private logStreamError(error: unknown): void {
if (error instanceof Error) {
this.logger.warn(`Chat stream failed: ${error.message}`);
return;
}
this.logger.warn(`Chat stream failed: ${String(error)}`);
}
}

View File

@@ -1,36 +0,0 @@
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
export interface ChatMessage {
role: string;
content: string;
}
export class ChatMessageDto implements ChatMessage {
@IsString({ message: "role must be a string" })
@IsNotEmpty({ message: "role is required" })
role!: string;
@IsString({ message: "content must be a string" })
@IsNotEmpty({ message: "content is required" })
content!: string;
}
export class ChatStreamDto {
@IsArray({ message: "messages must be an array" })
@ArrayMinSize(1, { message: "messages must contain at least one message" })
@ValidateNested({ each: true })
@Type(() => ChatMessageDto)
messages!: ChatMessageDto[];
@IsString({ message: "agent must be a string" })
@IsOptional()
agent?: string;
}

View File

@@ -1,16 +0,0 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AuthModule } from "../auth/auth.module";
import { AgentConfigModule } from "../agent-config/agent-config.module";
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
import { PrismaModule } from "../prisma/prisma.module";
import { ChatProxyController } from "./chat-proxy.controller";
import { ChatProxyService } from "./chat-proxy.service";
@Module({
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule, ConfigModule],
controllers: [ChatProxyController],
providers: [ChatProxyService],
exports: [ChatProxyService],
})
export class ChatProxyModule {}

View File

@@ -1,250 +0,0 @@
import {
ServiceUnavailableException,
NotFoundException,
BadGatewayException,
} from "@nestjs/common";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatProxyService } from "./chat-proxy.service";
describe("ChatProxyService", () => {
const userId = "user-123";
const prisma = {
userAgentConfig: {
findUnique: vi.fn(),
},
userAgent: {
findUnique: vi.fn(),
},
};
const containerLifecycle = {
ensureRunning: vi.fn(),
touch: vi.fn(),
};
const config = {
get: vi.fn(),
};
let service: ChatProxyService;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
service = new ChatProxyService(prisma as never, containerLifecycle as never, config as never);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("getContainerUrl", () => {
it("calls ensureRunning and touch for the user", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
const url = await service.getContainerUrl(userId);
expect(url).toBe("http://mosaic-user-user-123:19000");
expect(containerLifecycle.ensureRunning).toHaveBeenCalledWith(userId);
expect(containerLifecycle.touch).toHaveBeenCalledWith(userId);
});
});
describe("proxyChat", () => {
it("forwards the request to the user's OpenClaw container", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
const messages = [{ role: "user", content: "Hello from Mosaic" }];
const response = await service.proxyChat(userId, messages);
expect(response).toBeInstanceOf(Response);
expect(fetchMock).toHaveBeenCalledWith(
"http://mosaic-user-user-123:19000/v1/chat/completions",
expect.objectContaining({
method: "POST",
headers: {
Authorization: "Bearer gateway-token",
"Content-Type": "application/json",
},
})
);
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
const parsedBody = JSON.parse(String(request.body));
expect(parsedBody).toEqual({
messages,
model: "openclaw:default",
stream: true,
});
});
it("throws ServiceUnavailableException on connection refused errors", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
fetchMock.mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:19000"));
await expect(service.proxyChat(userId, [])).rejects.toBeInstanceOf(
ServiceUnavailableException
);
});
it("throws ServiceUnavailableException on timeout errors", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
fetchMock.mockRejectedValue(new Error("The operation was aborted due to timeout"));
await expect(service.proxyChat(userId, [])).rejects.toBeInstanceOf(
ServiceUnavailableException
);
});
});
describe("proxyChat with agent routing", () => {
it("includes agent config when agentName is specified", async () => {
const mockAgent = {
name: "jarvis",
displayName: "Jarvis",
personality: "Capable, direct, proactive.",
primaryModel: "opus",
isActive: true,
};
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
prisma.userAgent.findUnique.mockResolvedValue(mockAgent);
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
const messages = [{ role: "user", content: "Hello Jarvis" }];
await service.proxyChat(userId, messages, undefined, "jarvis");
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
const parsedBody = JSON.parse(String(request.body));
expect(parsedBody).toEqual({
messages,
model: "opus",
stream: true,
agent: "jarvis",
agent_personality: "Capable, direct, proactive.",
});
});
it("throws NotFoundException when agent not found", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
prisma.userAgent.findUnique.mockResolvedValue(null);
const messages = [{ role: "user", content: "Hello" }];
await expect(service.proxyChat(userId, messages, undefined, "nonexistent")).rejects.toThrow(
NotFoundException
);
});
it("throws NotFoundException when agent is not active", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
prisma.userAgent.findUnique.mockResolvedValue({
name: "inactive-agent",
displayName: "Inactive",
personality: "...",
primaryModel: null,
isActive: false,
});
const messages = [{ role: "user", content: "Hello" }];
await expect(
service.proxyChat(userId, messages, undefined, "inactive-agent")
).rejects.toThrow(NotFoundException);
});
it("falls back to default model when agent has no primaryModel", async () => {
const mockAgent = {
name: "jarvis",
displayName: "Jarvis",
personality: "Capable, direct, proactive.",
primaryModel: null,
isActive: true,
};
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
prisma.userAgent.findUnique.mockResolvedValue(mockAgent);
prisma.userAgentConfig.findUnique.mockResolvedValue(null);
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
const messages = [{ role: "user", content: "Hello" }];
await service.proxyChat(userId, messages, undefined, "jarvis");
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
const parsedBody = JSON.parse(String(request.body));
expect(parsedBody.model).toBe("openclaw:default");
});
});
describe("proxyGuestChat", () => {
it("uses environment variables for guest LLM configuration", async () => {
config.get.mockImplementation((key: string) => {
if (key === "GUEST_LLM_URL") return "http://10.1.1.42:11434/v1";
if (key === "GUEST_LLM_MODEL") return "llama3.2";
return undefined;
});
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
const messages = [{ role: "user", content: "Hello" }];
await service.proxyGuestChat(messages);
expect(fetchMock).toHaveBeenCalledWith(
"http://10.1.1.42:11434/v1/chat/completions",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
},
})
);
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
const parsedBody = JSON.parse(String(request.body));
expect(parsedBody.model).toBe("llama3.2");
});
it("throws BadGatewayException on guest LLM errors", async () => {
config.get.mockReturnValue(undefined);
fetchMock.mockResolvedValue(new Response("Internal Server Error", { status: 500 }));
const messages = [{ role: "user", content: "Hello" }];
await expect(service.proxyGuestChat(messages)).rejects.toThrow(BadGatewayException);
});
});
});

View File

@@ -1,226 +0,0 @@
import {
BadGatewayException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
import { PrismaService } from "../prisma/prisma.service";
import type { ChatMessage } from "./chat-proxy.dto";
const DEFAULT_OPENCLAW_MODEL = "openclaw:default";
const DEFAULT_GUEST_LLM_URL = "http://10.1.1.42:11434/v1";
const DEFAULT_GUEST_LLM_MODEL = "llama3.2";
interface ContainerConnection {
url: string;
token: string;
}
interface AgentConfig {
name: string;
displayName: string;
personality: string;
primaryModel: string | null;
}
@Injectable()
export class ChatProxyService {
private readonly logger = new Logger(ChatProxyService.name);
constructor(
private readonly prisma: PrismaService,
private readonly containerLifecycle: ContainerLifecycleService,
private readonly config: ConfigService
) {}
// Get the user's OpenClaw container URL and mark it active.
async getContainerUrl(userId: string): Promise<string> {
const { url } = await this.getContainerConnection(userId);
return url;
}
// Proxy chat request to OpenClaw.
async proxyChat(
userId: string,
messages: ChatMessage[],
signal?: AbortSignal,
agentName?: string
): Promise<Response> {
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId);
// Get agent config if specified
let agentConfig: AgentConfig | null = null;
if (agentName) {
agentConfig = await this.getAgentConfig(userId, agentName);
}
const model = agentConfig?.primaryModel ?? (await this.getPreferredModel(userId));
const requestBody: Record<string, unknown> = {
messages,
model,
stream: true,
};
// Add agent config if available
if (agentConfig) {
requestBody.agent = agentConfig.name;
requestBody.agent_personality = agentConfig.personality;
}
const requestInit: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${gatewayToken}`,
},
body: JSON.stringify(requestBody),
};
if (signal) {
requestInit.signal = signal;
}
try {
const response = await fetch(`${containerUrl}/v1/chat/completions`, requestInit);
if (!response.ok) {
const detail = await this.readResponseText(response);
const status = `${String(response.status)} ${response.statusText}`.trim();
this.logger.warn(
detail ? `OpenClaw returned ${status}: ${detail}` : `OpenClaw returned ${status}`
);
throw new BadGatewayException(`OpenClaw returned ${status}`);
}
return response;
} catch (error: unknown) {
if (error instanceof BadGatewayException) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to proxy chat request: ${message}`);
throw new ServiceUnavailableException("Failed to proxy chat to OpenClaw");
}
}
/**
* Proxy guest chat request to configured LLM endpoint.
* Uses environment variables for configuration:
* - GUEST_LLM_URL: OpenAI-compatible endpoint URL
* - GUEST_LLM_API_KEY: API key (optional, for cloud providers)
* - GUEST_LLM_MODEL: Model name to use
*/
async proxyGuestChat(messages: ChatMessage[], signal?: AbortSignal): Promise<Response> {
const llmUrl = this.config.get<string>("GUEST_LLM_URL") ?? DEFAULT_GUEST_LLM_URL;
const llmApiKey = this.config.get<string>("GUEST_LLM_API_KEY");
const llmModel = this.config.get<string>("GUEST_LLM_MODEL") ?? DEFAULT_GUEST_LLM_MODEL;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (llmApiKey) {
headers.Authorization = `Bearer ${llmApiKey}`;
}
const requestInit: RequestInit = {
method: "POST",
headers,
body: JSON.stringify({
messages,
model: llmModel,
stream: true,
}),
};
if (signal) {
requestInit.signal = signal;
}
try {
this.logger.debug(`Guest chat proxying to ${llmUrl} with model ${llmModel}`);
const response = await fetch(`${llmUrl}/chat/completions`, requestInit);
if (!response.ok) {
const detail = await this.readResponseText(response);
const status = `${String(response.status)} ${response.statusText}`.trim();
this.logger.warn(
detail ? `Guest LLM returned ${status}: ${detail}` : `Guest LLM returned ${status}`
);
throw new BadGatewayException(`Guest LLM returned ${status}`);
}
return response;
} catch (error: unknown) {
if (error instanceof BadGatewayException) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to proxy guest chat request: ${message}`);
throw new ServiceUnavailableException("Failed to proxy guest chat to LLM");
}
}
private async getContainerConnection(userId: string): Promise<ContainerConnection> {
const connection = await this.containerLifecycle.ensureRunning(userId);
await this.containerLifecycle.touch(userId);
return connection;
}
private async getPreferredModel(userId: string): Promise<string> {
const config = await this.prisma.userAgentConfig.findUnique({
where: { userId },
select: { primaryModel: true },
});
const primaryModel = config?.primaryModel?.trim();
if (!primaryModel) {
return DEFAULT_OPENCLAW_MODEL;
}
return primaryModel;
}
private async readResponseText(response: Response): Promise<string | null> {
try {
const text = (await response.text()).trim();
return text.length > 0 ? text : null;
} catch {
return null;
}
}
private async getAgentConfig(userId: string, agentName: string): Promise<AgentConfig> {
const agent = await this.prisma.userAgent.findUnique({
where: { userId_name: { userId, name: agentName } },
select: {
name: true,
displayName: true,
personality: true,
primaryModel: true,
isActive: true,
},
});
if (!agent) {
throw new NotFoundException(`Agent "${agentName}" not found for user`);
}
if (!agent.isActive) {
throw new NotFoundException(`Agent "${agentName}" is not active`);
}
return {
name: agent.name,
displayName: agent.displayName,
personality: agent.personality,
primaryModel: agent.primaryModel,
};
}
}

View File

@@ -87,17 +87,6 @@ describe("CsrfGuard", () => {
});
describe("State-changing methods requiring CSRF", () => {
it("should allow POST with Bearer auth without CSRF token", () => {
const context = createContext(
"POST",
{},
{ authorization: "Bearer api-token" },
false,
"user-123"
);
expect(guard.canActivate(context)).toBe(true);
});
it("should reject POST without CSRF token", () => {
const context = createContext("POST", {}, {}, false, "user-123");
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);

View File

@@ -57,11 +57,6 @@ export class CsrfGuard implements CanActivate {
return true;
}
const authHeader = request.headers.authorization;
if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
return true;
}
// Get CSRF token from cookie and header
const cookies = request.cookies as Record<string, string> | undefined;
const cookieToken = cookies?.["csrf-token"];
@@ -111,9 +106,14 @@ export class CsrfGuard implements CanActivate {
throw new ForbiddenException("CSRF token not bound to session");
}
} else {
this.logger.debug({
event: "CSRF_SKIP_SESSION_BINDING",
method: request.method,
path: request.path,
reason: "User context not yet available (global guard runs before AuthGuard)",
});
}
// Note: when userId is absent, the double-submit cookie check above is
// sufficient CSRF protection. AuthGuard populates request.user afterward.
return true;
}

View File

@@ -1,11 +1,10 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "../prisma/prisma.module";
import { CryptoModule } from "../crypto/crypto.module";
import { ContainerLifecycleService } from "./container-lifecycle.service";
@Module({
imports: [ConfigModule, PrismaModule, CryptoModule],
imports: [PrismaModule, CryptoModule],
providers: [ContainerLifecycleService],
exports: [ContainerLifecycleService],
})

View File

@@ -1,10 +0,0 @@
import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
import { ContainerReaperService } from "./container-reaper.service";
@Module({
imports: [ScheduleModule, ContainerLifecycleModule],
providers: [ContainerReaperService],
})
export class ContainerReaperModule {}

View File

@@ -1,45 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
import { ContainerReaperService } from "./container-reaper.service";
describe("ContainerReaperService", () => {
let service: ContainerReaperService;
let containerLifecycle: Pick<ContainerLifecycleService, "reapIdle">;
beforeEach(() => {
containerLifecycle = {
reapIdle: vi.fn(),
};
service = new ContainerReaperService(containerLifecycle as ContainerLifecycleService);
});
it("reapIdleContainers calls containerLifecycle.reapIdle()", async () => {
vi.mocked(containerLifecycle.reapIdle).mockResolvedValue({ stopped: [] });
await service.reapIdleContainers();
expect(containerLifecycle.reapIdle).toHaveBeenCalledTimes(1);
});
it("reapIdleContainers handles errors gracefully", async () => {
const error = new Error("reap failure");
vi.mocked(containerLifecycle.reapIdle).mockRejectedValue(error);
const loggerError = vi.spyOn(service["logger"], "error").mockImplementation(() => {});
await expect(service.reapIdleContainers()).resolves.toBeUndefined();
expect(loggerError).toHaveBeenCalledWith(
"Failed to reap idle containers",
expect.stringContaining("reap failure")
);
});
it("reapIdleContainers logs stopped container count", async () => {
vi.mocked(containerLifecycle.reapIdle).mockResolvedValue({ stopped: ["user-1", "user-2"] });
const loggerLog = vi.spyOn(service["logger"], "log").mockImplementation(() => {});
await service.reapIdleContainers();
expect(loggerLog).toHaveBeenCalledWith("Stopped 2 idle containers: user-1, user-2");
});
});

View File

@@ -1,30 +0,0 @@
import { Injectable, Logger } from "@nestjs/common";
import { Cron, CronExpression } from "@nestjs/schedule";
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
@Injectable()
export class ContainerReaperService {
private readonly logger = new Logger(ContainerReaperService.name);
constructor(private readonly containerLifecycle: ContainerLifecycleService) {}
@Cron(CronExpression.EVERY_5_MINUTES)
async reapIdleContainers(): Promise<void> {
this.logger.log("Running idle container reap cycle...");
try {
const result = await this.containerLifecycle.reapIdle();
if (result.stopped.length > 0) {
this.logger.log(
`Stopped ${String(result.stopped.length)} idle containers: ${result.stopped.join(", ")}`
);
} else {
this.logger.debug("No idle containers to stop");
}
} catch (error) {
this.logger.error(
"Failed to reap idle containers",
error instanceof Error ? error.stack : String(error)
);
}
}
}

View File

@@ -3,7 +3,7 @@ import { DashboardService } from "./dashboard.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
import { DashboardSummaryDto } from "./dto";
import type { DashboardSummaryDto } from "./dto";
/**
* Controller for dashboard endpoints.

View File

@@ -1,115 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Put,
UseGuards,
} from "@nestjs/common";
import type { AuthUser } from "@mosaic/shared";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import { AdminGuard } from "../auth/guards/admin.guard";
import { AuthGuard } from "../auth/guards/auth.guard";
import {
CreateProviderDto,
ResetPasswordDto,
UpdateAgentConfigDto,
UpdateOidcDto,
UpdateProviderDto,
} from "./fleet-settings.dto";
import { FleetSettingsService } from "./fleet-settings.service";
@Controller("fleet-settings")
@UseGuards(AuthGuard)
export class FleetSettingsController {
constructor(private readonly fleetSettingsService: FleetSettingsService) {}
// --- Provider endpoints (user-scoped) ---
// GET /api/fleet-settings/providers — list user's providers
@Get("providers")
async listProviders(@CurrentUser() user: AuthUser) {
return this.fleetSettingsService.listProviders(user.id);
}
// GET /api/fleet-settings/providers/:id — get single provider
@Get("providers/:id")
async getProvider(@CurrentUser() user: AuthUser, @Param("id") id: string) {
return this.fleetSettingsService.getProvider(user.id, id);
}
// POST /api/fleet-settings/providers — create provider
@Post("providers")
async createProvider(@CurrentUser() user: AuthUser, @Body() dto: CreateProviderDto) {
return this.fleetSettingsService.createProvider(user.id, dto);
}
// PATCH /api/fleet-settings/providers/:id — update provider
@Patch("providers/:id")
@HttpCode(HttpStatus.NO_CONTENT)
async updateProvider(
@CurrentUser() user: AuthUser,
@Param("id") id: string,
@Body() dto: UpdateProviderDto
) {
await this.fleetSettingsService.updateProvider(user.id, id, dto);
}
// DELETE /api/fleet-settings/providers/:id — delete provider
@Delete("providers/:id")
@HttpCode(HttpStatus.NO_CONTENT)
async deleteProvider(@CurrentUser() user: AuthUser, @Param("id") id: string) {
await this.fleetSettingsService.deleteProvider(user.id, id);
}
// --- Agent config endpoints (user-scoped) ---
// GET /api/fleet-settings/agent-config — get user's agent config
@Get("agent-config")
async getAgentConfig(@CurrentUser() user: AuthUser) {
return this.fleetSettingsService.getAgentConfig(user.id);
}
// PATCH /api/fleet-settings/agent-config — update user's agent config
@Patch("agent-config")
@HttpCode(HttpStatus.NO_CONTENT)
async updateAgentConfig(@CurrentUser() user: AuthUser, @Body() dto: UpdateAgentConfigDto) {
await this.fleetSettingsService.updateAgentConfig(user.id, dto);
}
// --- OIDC endpoints (admin only — use AdminGuard) ---
// GET /api/fleet-settings/oidc — get OIDC config
@Get("oidc")
@UseGuards(AdminGuard)
async getOidcConfig() {
return this.fleetSettingsService.getOidcConfig();
}
// PUT /api/fleet-settings/oidc — update OIDC config
@Put("oidc")
@UseGuards(AdminGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async updateOidcConfig(@Body() dto: UpdateOidcDto) {
await this.fleetSettingsService.updateOidcConfig(dto);
}
// DELETE /api/fleet-settings/oidc — remove OIDC config
@Delete("oidc")
@UseGuards(AdminGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async deleteOidcConfig() {
await this.fleetSettingsService.deleteOidcConfig();
}
// --- Breakglass endpoints (admin only) ---
// POST /api/fleet-settings/breakglass/reset-password — reset admin password
@Post("breakglass/reset-password")
@UseGuards(AdminGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async resetBreakglassPassword(@Body() dto: ResetPasswordDto) {
await this.fleetSettingsService.resetBreakglassPassword(dto.username, dto.newPassword);
}
}

View File

@@ -1,122 +0,0 @@
import {
ArrayNotEmpty,
IsArray,
IsBoolean,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUrl,
MaxLength,
MinLength,
} from "class-validator";
export class CreateProviderDto {
@IsString({ message: "name must be a string" })
@IsNotEmpty({ message: "name is required" })
@MaxLength(100, { message: "name must not exceed 100 characters" })
name!: string;
@IsString({ message: "displayName must be a string" })
@IsNotEmpty({ message: "displayName is required" })
@MaxLength(255, { message: "displayName must not exceed 255 characters" })
displayName!: string;
@IsString({ message: "type must be a string" })
@IsNotEmpty({ message: "type is required" })
@MaxLength(100, { message: "type must not exceed 100 characters" })
type!: string;
@IsOptional()
@IsUrl(
{ require_tld: false },
{ message: "baseUrl must be a valid URL (for example: https://api.example.com/v1)" }
)
baseUrl?: string;
@IsOptional()
@IsString({ message: "apiKey must be a string" })
apiKey?: string;
@IsOptional()
@IsString({ message: "apiType must be a string" })
@MaxLength(100, { message: "apiType must not exceed 100 characters" })
apiType?: string;
@IsOptional()
@IsArray({ message: "models must be an array" })
@IsObject({ each: true, message: "each model must be an object" })
models?: Record<string, unknown>[];
}
export class UpdateProviderDto {
@IsOptional()
@IsString({ message: "displayName must be a string" })
@MaxLength(255, { message: "displayName must not exceed 255 characters" })
displayName?: string;
@IsOptional()
@IsUrl(
{ require_tld: false },
{ message: "baseUrl must be a valid URL (for example: https://api.example.com/v1)" }
)
baseUrl?: string;
@IsOptional()
@IsString({ message: "apiKey must be a string" })
apiKey?: string;
@IsOptional()
@IsBoolean({ message: "isActive must be a boolean" })
isActive?: boolean;
@IsOptional()
@IsArray({ message: "models must be an array" })
@IsObject({ each: true, message: "each model must be an object" })
models?: Record<string, unknown>[];
}
export class UpdateAgentConfigDto {
@IsOptional()
@IsString({ message: "primaryModel must be a string" })
@MaxLength(255, { message: "primaryModel must not exceed 255 characters" })
primaryModel?: string;
@IsOptional()
@IsArray({ message: "fallbackModels must be an array" })
@ArrayNotEmpty({ message: "fallbackModels cannot be empty" })
@IsString({ each: true, message: "each fallback model must be a string" })
fallbackModels?: string[];
@IsOptional()
@IsString({ message: "personality must be a string" })
personality?: string;
}
export class UpdateOidcDto {
@IsString({ message: "issuerUrl must be a string" })
@IsNotEmpty({ message: "issuerUrl is required" })
@IsUrl(
{ require_tld: false },
{ message: "issuerUrl must be a valid URL (for example: https://issuer.example.com)" }
)
issuerUrl!: string;
@IsString({ message: "clientId must be a string" })
@IsNotEmpty({ message: "clientId is required" })
clientId!: string;
@IsString({ message: "clientSecret must be a string" })
@IsNotEmpty({ message: "clientSecret is required" })
clientSecret!: string;
}
export class ResetPasswordDto {
@IsString({ message: "username must be a string" })
@IsNotEmpty({ message: "username is required" })
username!: string;
@IsString({ message: "newPassword must be a string" })
@MinLength(8, { message: "newPassword must be at least 8 characters" })
newPassword!: string;
}

View File

@@ -1,14 +0,0 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { PrismaModule } from "../prisma/prisma.module";
import { CryptoModule } from "../crypto/crypto.module";
import { FleetSettingsController } from "./fleet-settings.controller";
import { FleetSettingsService } from "./fleet-settings.service";
@Module({
imports: [AuthModule, PrismaModule, CryptoModule],
controllers: [FleetSettingsController],
providers: [FleetSettingsService],
exports: [FleetSettingsService],
})
export class FleetSettingsModule {}

View File

@@ -1,200 +0,0 @@
import { NotFoundException } from "@nestjs/common";
import { compare } from "bcryptjs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FleetSettingsService } from "./fleet-settings.service";
import type { PrismaService } from "../prisma/prisma.service";
import type { CryptoService } from "../crypto/crypto.service";
describe("FleetSettingsService", () => {
let service: FleetSettingsService;
const mockPrisma = {
llmProvider: {
findMany: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
userAgentConfig: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
systemConfig: {
findMany: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
breakglassUser: {
findUnique: vi.fn(),
update: vi.fn(),
},
};
const mockCrypto = {
encrypt: vi.fn((value: string) => `enc:${value}`),
};
beforeEach(() => {
vi.clearAllMocks();
service = new FleetSettingsService(
mockPrisma as unknown as PrismaService,
mockCrypto as unknown as CryptoService
);
});
it("listProviders returns only providers for the given userId", async () => {
mockPrisma.llmProvider.findMany.mockResolvedValue([
{
id: "prov-1",
name: "openai-main",
displayName: "OpenAI",
type: "openai",
baseUrl: "https://api.openai.com/v1",
isActive: true,
models: [{ id: "gpt-4.1" }],
},
]);
const result = await service.listProviders("user-1");
expect(mockPrisma.llmProvider.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
select: {
id: true,
name: true,
displayName: true,
type: true,
baseUrl: true,
isActive: true,
models: true,
},
orderBy: { createdAt: "asc" },
});
expect(result).toEqual([
{
id: "prov-1",
name: "openai-main",
displayName: "OpenAI",
type: "openai",
baseUrl: "https://api.openai.com/v1",
isActive: true,
models: [{ id: "gpt-4.1" }],
},
]);
});
it("createProvider encrypts apiKey", async () => {
mockPrisma.llmProvider.create.mockResolvedValue({
id: "prov-2",
});
const result = await service.createProvider("user-1", {
name: "zai-main",
displayName: "Z.ai",
type: "zai",
apiKey: "plaintext-key",
models: [],
});
expect(mockCrypto.encrypt).toHaveBeenCalledWith("plaintext-key");
expect(mockPrisma.llmProvider.create).toHaveBeenCalledWith({
data: {
userId: "user-1",
name: "zai-main",
displayName: "Z.ai",
type: "zai",
baseUrl: null,
apiKey: "enc:plaintext-key",
apiType: "openai-completions",
models: [],
},
select: {
id: true,
},
});
expect(result).toEqual({ id: "prov-2" });
});
it("updateProvider rejects if not owned by user", async () => {
mockPrisma.llmProvider.findFirst.mockResolvedValue(null);
await expect(
service.updateProvider("user-1", "provider-1", {
displayName: "New Name",
})
).rejects.toBeInstanceOf(NotFoundException);
expect(mockPrisma.llmProvider.update).not.toHaveBeenCalled();
});
it("deleteProvider rejects if not owned by user", async () => {
mockPrisma.llmProvider.findFirst.mockResolvedValue(null);
await expect(service.deleteProvider("user-1", "provider-1")).rejects.toBeInstanceOf(
NotFoundException
);
expect(mockPrisma.llmProvider.delete).not.toHaveBeenCalled();
});
it("getOidcConfig never returns clientSecret", async () => {
mockPrisma.systemConfig.findMany.mockResolvedValue([
{
key: "oidc.issuerUrl",
value: "https://issuer.example.com",
},
{
key: "oidc.clientId",
value: "client-id-1",
},
{
key: "oidc.clientSecret",
value: "enc:very-secret",
},
]);
const result = await service.getOidcConfig();
expect(result).toEqual({
issuerUrl: "https://issuer.example.com",
clientId: "client-id-1",
configured: true,
});
expect(result).not.toHaveProperty("clientSecret");
});
it("updateOidcConfig encrypts clientSecret", async () => {
await service.updateOidcConfig({
issuerUrl: "https://issuer.example.com",
clientId: "client-id-1",
clientSecret: "super-secret",
});
expect(mockCrypto.encrypt).toHaveBeenCalledWith("super-secret");
expect(mockPrisma.systemConfig.upsert).toHaveBeenCalledTimes(3);
expect(mockPrisma.systemConfig.upsert).toHaveBeenCalledWith({
where: { key: "oidc.clientSecret" },
update: { value: "enc:super-secret", encrypted: true },
create: { key: "oidc.clientSecret", value: "enc:super-secret", encrypted: true },
});
});
it("resetBreakglassPassword hashes new password", async () => {
mockPrisma.breakglassUser.findUnique.mockResolvedValue({
id: "bg-1",
username: "admin",
passwordHash: "old-hash",
});
await service.resetBreakglassPassword("admin", "new-password-123");
expect(mockPrisma.breakglassUser.update).toHaveBeenCalledOnce();
const updateCall = mockPrisma.breakglassUser.update.mock.calls[0]?.[0];
const newHash = updateCall?.data?.passwordHash;
expect(newHash).toBeTypeOf("string");
expect(newHash).not.toBe("new-password-123");
expect(await compare("new-password-123", newHash as string)).toBe(true);
});
});

View File

@@ -1,296 +0,0 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { hash } from "bcryptjs";
import type { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "../crypto/crypto.service";
import type {
CreateProviderDto,
ResetPasswordDto,
UpdateAgentConfigDto,
UpdateOidcDto,
UpdateProviderDto,
} from "./fleet-settings.dto";
const BCRYPT_ROUNDS = 12;
const DEFAULT_PROVIDER_API_TYPE = "openai-completions";
const OIDC_ISSUER_KEY = "oidc.issuerUrl";
const OIDC_CLIENT_ID_KEY = "oidc.clientId";
const OIDC_CLIENT_SECRET_KEY = "oidc.clientSecret";
const OIDC_KEYS = [OIDC_ISSUER_KEY, OIDC_CLIENT_ID_KEY, OIDC_CLIENT_SECRET_KEY] as const;
export interface FleetProviderResponse {
id: string;
name: string;
displayName: string;
type: string;
baseUrl: string | null;
isActive: boolean;
models: unknown;
}
export interface FleetAgentConfigResponse {
primaryModel: string | null;
fallbackModels: unknown[];
personality: string | null;
}
export interface OidcConfigResponse {
issuerUrl?: string;
clientId?: string;
configured: boolean;
}
@Injectable()
export class FleetSettingsService {
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService
) {}
// --- LLM Provider CRUD (per-user scoped) ---
async listProviders(userId: string): Promise<FleetProviderResponse[]> {
return this.prisma.llmProvider.findMany({
where: { userId },
select: {
id: true,
name: true,
displayName: true,
type: true,
baseUrl: true,
isActive: true,
models: true,
},
orderBy: { createdAt: "asc" },
});
}
async getProvider(userId: string, providerId: string): Promise<FleetProviderResponse> {
const provider = await this.prisma.llmProvider.findFirst({
where: {
id: providerId,
userId,
},
select: {
id: true,
name: true,
displayName: true,
type: true,
baseUrl: true,
isActive: true,
models: true,
},
});
if (!provider) {
throw new NotFoundException(`Provider ${providerId} not found`);
}
return provider;
}
async createProvider(userId: string, data: CreateProviderDto): Promise<{ id: string }> {
const provider = await this.prisma.llmProvider.create({
data: {
userId,
name: data.name,
displayName: data.displayName,
type: data.type,
baseUrl: data.baseUrl ?? null,
apiKey: data.apiKey ? this.crypto.encrypt(data.apiKey) : null,
apiType: data.apiType ?? DEFAULT_PROVIDER_API_TYPE,
models: (data.models ?? []) as Prisma.InputJsonValue,
},
select: {
id: true,
},
});
return provider;
}
async updateProvider(userId: string, providerId: string, data: UpdateProviderDto): Promise<void> {
await this.assertProviderOwnership(userId, providerId);
const updateData: Prisma.LlmProviderUpdateInput = {};
if (data.displayName !== undefined) {
updateData.displayName = data.displayName;
}
if (data.baseUrl !== undefined) {
updateData.baseUrl = data.baseUrl;
}
if (data.isActive !== undefined) {
updateData.isActive = data.isActive;
}
if (data.models !== undefined) {
updateData.models = data.models as Prisma.InputJsonValue;
}
if (data.apiKey !== undefined) {
updateData.apiKey = data.apiKey.length > 0 ? this.crypto.encrypt(data.apiKey) : null;
}
await this.prisma.llmProvider.update({
where: { id: providerId },
data: updateData,
});
}
async deleteProvider(userId: string, providerId: string): Promise<void> {
await this.assertProviderOwnership(userId, providerId);
await this.prisma.llmProvider.delete({
where: { id: providerId },
});
}
// --- User Agent Config ---
async getAgentConfig(userId: string): Promise<FleetAgentConfigResponse> {
const config = await this.prisma.userAgentConfig.findUnique({
where: { userId },
select: {
primaryModel: true,
fallbackModels: true,
personality: true,
},
});
if (!config) {
return {
primaryModel: null,
fallbackModels: [],
personality: null,
};
}
return {
primaryModel: config.primaryModel,
fallbackModels: this.normalizeJsonArray(config.fallbackModels),
personality: config.personality,
};
}
async updateAgentConfig(userId: string, data: UpdateAgentConfigDto): Promise<void> {
const updateData: Prisma.UserAgentConfigUpdateInput = {};
if (data.primaryModel !== undefined) {
updateData.primaryModel = data.primaryModel;
}
if (data.personality !== undefined) {
updateData.personality = data.personality;
}
if (data.fallbackModels !== undefined) {
updateData.fallbackModels = data.fallbackModels as Prisma.InputJsonValue;
}
const createData: Prisma.UserAgentConfigCreateInput = {
userId,
fallbackModels: (data.fallbackModels ?? []) as Prisma.InputJsonValue,
...(data.primaryModel !== undefined ? { primaryModel: data.primaryModel } : {}),
...(data.personality !== undefined ? { personality: data.personality } : {}),
};
await this.prisma.userAgentConfig.upsert({
where: { userId },
create: createData,
update: updateData,
});
}
// --- OIDC Config (admin only) ---
async getOidcConfig(): Promise<OidcConfigResponse> {
const entries = await this.prisma.systemConfig.findMany({
where: {
key: {
in: [...OIDC_KEYS],
},
},
select: {
key: true,
value: true,
},
});
const byKey = new Map(entries.map((entry) => [entry.key, entry.value]));
const issuerUrl = byKey.get(OIDC_ISSUER_KEY);
const clientId = byKey.get(OIDC_CLIENT_ID_KEY);
const hasSecret = byKey.has(OIDC_CLIENT_SECRET_KEY);
return {
...(issuerUrl ? { issuerUrl } : {}),
...(clientId ? { clientId } : {}),
configured: Boolean(issuerUrl && clientId && hasSecret),
};
}
async updateOidcConfig(data: UpdateOidcDto): Promise<void> {
const encryptedSecret = this.crypto.encrypt(data.clientSecret);
await Promise.all([
this.upsertSystemConfig(OIDC_ISSUER_KEY, data.issuerUrl, false),
this.upsertSystemConfig(OIDC_CLIENT_ID_KEY, data.clientId, false),
this.upsertSystemConfig(OIDC_CLIENT_SECRET_KEY, encryptedSecret, true),
]);
}
async deleteOidcConfig(): Promise<void> {
await this.prisma.systemConfig.deleteMany({
where: {
key: {
in: [...OIDC_KEYS],
},
},
});
}
// --- Breakglass (admin only) ---
async resetBreakglassPassword(
username: ResetPasswordDto["username"],
newPassword: ResetPasswordDto["newPassword"]
): Promise<void> {
const user = await this.prisma.breakglassUser.findUnique({
where: { username },
select: { id: true },
});
if (!user) {
throw new NotFoundException(`Breakglass user ${username} not found`);
}
const passwordHash = await hash(newPassword, BCRYPT_ROUNDS);
await this.prisma.breakglassUser.update({
where: { id: user.id },
data: { passwordHash },
});
}
private async assertProviderOwnership(userId: string, providerId: string): Promise<void> {
const provider = await this.prisma.llmProvider.findFirst({
where: {
id: providerId,
userId,
},
select: {
id: true,
},
});
if (!provider) {
throw new NotFoundException(`Provider ${providerId} not found`);
}
}
private async upsertSystemConfig(key: string, value: string, encrypted: boolean): Promise<void> {
await this.prisma.systemConfig.upsert({
where: { key },
update: { value, encrypted },
create: { key, value, encrypted },
});
}
private normalizeJsonArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
}

View File

@@ -1,7 +1,7 @@
import { Controller, Get, Param, Query } from "@nestjs/common";
import type { LlmUsageLog } from "@prisma/client";
import { LlmUsageService } from "./llm-usage.service";
import { UsageAnalyticsQueryDto, UsageAnalyticsResponseDto } from "./dto";
import type { UsageAnalyticsQueryDto, UsageAnalyticsResponseDto } from "./dto";
/**
* LLM Usage Controller

View File

@@ -1,7 +1,6 @@
import { NestFactory } from "@nestjs/core";
import { RequestMethod, ValidationPipe } from "@nestjs/common";
import cookieParser from "cookie-parser";
import helmet from "helmet";
import { AppModule } from "./app.module";
import { getTrustedOrigins } from "./auth/auth.config";
import { GlobalExceptionFilter } from "./filters/global-exception.filter";
@@ -34,14 +33,6 @@ async function bootstrap() {
// Enable cookie parser for session handling
app.use(cookieParser());
// Enable helmet security headers
app.use(
helmet({
contentSecurityPolicy: false, // Let Next.js handle CSP
crossOriginEmbedderPolicy: false,
})
);
// Enable global validation pipe with transformation
app.useGlobalPipes(
new ValidationPipe({

View File

@@ -1,63 +0,0 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from "@nestjs/common";
import {
AddProviderDto,
ConfigureOidcDto,
CreateBreakglassDto,
TestProviderDto,
} from "./onboarding.dto";
import { OnboardingGuard } from "./onboarding.guard";
import { OnboardingService } from "./onboarding.service";
@Controller("onboarding")
export class OnboardingController {
constructor(private readonly onboardingService: OnboardingService) {}
// GET /api/onboarding/status — returns { completed: boolean }
@Get("status")
async status(): Promise<{ completed: boolean }> {
return {
completed: await this.onboardingService.isCompleted(),
};
}
// POST /api/onboarding/breakglass — body: { username, password } → create admin
@Post("breakglass")
@UseGuards(OnboardingGuard)
async createBreakglass(
@Body() body: CreateBreakglassDto
): Promise<{ id: string; username: string }> {
return this.onboardingService.createBreakglassUser(body.username, body.password);
}
// POST /api/onboarding/oidc — body: { issuerUrl, clientId, clientSecret } → save OIDC
@Post("oidc")
@UseGuards(OnboardingGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async configureOidc(@Body() body: ConfigureOidcDto): Promise<void> {
await this.onboardingService.configureOidc(body.issuerUrl, body.clientId, body.clientSecret);
}
// POST /api/onboarding/provider — body: { name, displayName, type, baseUrl?, apiKey?, models? } → add provider
@Post("provider")
@UseGuards(OnboardingGuard)
async addProvider(@Body() body: AddProviderDto): Promise<{ id: string }> {
const userId = await this.onboardingService.getBreakglassUserId();
return this.onboardingService.addProvider(userId, body);
}
// POST /api/onboarding/provider/test — body: { type, baseUrl?, apiKey? } → test connection
@Post("provider/test")
@UseGuards(OnboardingGuard)
async testProvider(@Body() body: TestProviderDto): Promise<{ success: boolean; error?: string }> {
return this.onboardingService.testProvider(body.type, body.baseUrl, body.apiKey);
}
// POST /api/onboarding/complete — mark done
@Post("complete")
@UseGuards(OnboardingGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async complete(): Promise<void> {
await this.onboardingService.complete();
}
}

View File

@@ -1,71 +0,0 @@
import { Type } from "class-transformer";
import { IsArray, IsOptional, IsString, IsUrl, MinLength, ValidateNested } from "class-validator";
export class CreateBreakglassDto {
@IsString()
@MinLength(3)
username!: string;
@IsString()
@MinLength(8)
password!: string;
}
export class ConfigureOidcDto {
@IsString()
@IsUrl({ require_tld: false })
issuerUrl!: string;
@IsString()
clientId!: string;
@IsString()
clientSecret!: string;
}
export class ProviderModelDto {
@IsString()
id!: string;
@IsOptional()
@IsString()
name?: string;
}
export class AddProviderDto {
@IsString()
name!: string;
@IsString()
displayName!: string;
@IsString()
type!: string;
@IsOptional()
@IsString()
baseUrl?: string;
@IsOptional()
@IsString()
apiKey?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProviderModelDto)
models?: ProviderModelDto[];
}
export class TestProviderDto {
@IsString()
type!: string;
@IsOptional()
@IsString()
baseUrl?: string;
@IsOptional()
@IsString()
apiKey?: string;
}

View File

@@ -1,17 +0,0 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { OnboardingService } from "./onboarding.service";
@Injectable()
export class OnboardingGuard implements CanActivate {
constructor(private readonly onboardingService: OnboardingService) {}
async canActivate(_context: ExecutionContext): Promise<boolean> {
const completed = await this.onboardingService.isCompleted();
if (completed) {
throw new ForbiddenException("Onboarding already completed");
}
return true;
}
}

View File

@@ -1,15 +0,0 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "../prisma/prisma.module";
import { CryptoModule } from "../crypto/crypto.module";
import { OnboardingController } from "./onboarding.controller";
import { OnboardingService } from "./onboarding.service";
import { OnboardingGuard } from "./onboarding.guard";
@Module({
imports: [PrismaModule, CryptoModule, ConfigModule],
controllers: [OnboardingController],
providers: [OnboardingService, OnboardingGuard],
exports: [OnboardingService],
})
export class OnboardingModule {}

View File

@@ -1,206 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hash } from "bcryptjs";
import { OnboardingService } from "./onboarding.service";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "../crypto/crypto.service";
vi.mock("bcryptjs", () => ({
hash: vi.fn(),
}));
describe("OnboardingService", () => {
let service: OnboardingService;
const mockPrismaService = {
systemConfig: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
breakglassUser: {
count: vi.fn(),
create: vi.fn(),
findFirst: vi.fn(),
},
llmProvider: {
create: vi.fn(),
},
};
const mockCryptoService = {
encrypt: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
service = new OnboardingService(
mockPrismaService as unknown as PrismaService,
mockCryptoService as unknown as CryptoService
);
});
it("isCompleted returns false when no config exists", async () => {
mockPrismaService.systemConfig.findUnique.mockResolvedValue(null);
await expect(service.isCompleted()).resolves.toBe(false);
expect(mockPrismaService.systemConfig.findUnique).toHaveBeenCalledWith({
where: { key: "onboarding.completed" },
});
});
it("isCompleted returns true when completed", async () => {
mockPrismaService.systemConfig.findUnique.mockResolvedValue({
id: "cfg-1",
key: "onboarding.completed",
value: "true",
encrypted: false,
updatedAt: new Date(),
});
await expect(service.isCompleted()).resolves.toBe(true);
});
it("createBreakglassUser hashes password and creates record", async () => {
const mockedHash = vi.mocked(hash);
mockedHash.mockResolvedValue("hashed-password");
mockPrismaService.breakglassUser.count.mockResolvedValue(0);
mockPrismaService.breakglassUser.create.mockResolvedValue({
id: "breakglass-1",
username: "admin",
});
const result = await service.createBreakglassUser("admin", "supersecret123");
expect(mockedHash).toHaveBeenCalledWith("supersecret123", 12);
expect(mockPrismaService.breakglassUser.create).toHaveBeenCalledWith({
data: {
username: "admin",
passwordHash: "hashed-password",
},
select: {
id: true,
username: true,
},
});
expect(result).toEqual({ id: "breakglass-1", username: "admin" });
});
it("createBreakglassUser rejects if user already exists", async () => {
mockPrismaService.breakglassUser.count.mockResolvedValue(1);
await expect(service.createBreakglassUser("admin", "supersecret123")).rejects.toThrow(
"Breakglass user already exists"
);
});
it("configureOidc encrypts secret and saves to SystemConfig", async () => {
mockCryptoService.encrypt.mockReturnValue("enc:oidc-secret");
mockPrismaService.systemConfig.upsert.mockResolvedValue({
id: "cfg",
key: "oidc.clientSecret",
value: "enc:oidc-secret",
encrypted: true,
updatedAt: new Date(),
});
await service.configureOidc("https://auth.example.com", "client-id", "client-secret");
expect(mockCryptoService.encrypt).toHaveBeenCalledWith("client-secret");
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledTimes(3);
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
where: { key: "oidc.issuerUrl" },
create: {
key: "oidc.issuerUrl",
value: "https://auth.example.com",
encrypted: false,
},
update: {
value: "https://auth.example.com",
encrypted: false,
},
});
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
where: { key: "oidc.clientId" },
create: {
key: "oidc.clientId",
value: "client-id",
encrypted: false,
},
update: {
value: "client-id",
encrypted: false,
},
});
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
where: { key: "oidc.clientSecret" },
create: {
key: "oidc.clientSecret",
value: "enc:oidc-secret",
encrypted: true,
},
update: {
value: "enc:oidc-secret",
encrypted: true,
},
});
});
it("addProvider encrypts apiKey and creates LlmProvider", async () => {
mockCryptoService.encrypt.mockReturnValue("enc:api-key");
mockPrismaService.llmProvider.create.mockResolvedValue({
id: "provider-1",
});
const result = await service.addProvider("breakglass-1", {
name: "my-openai",
displayName: "OpenAI",
type: "openai",
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-test",
models: [{ id: "gpt-4o-mini", name: "GPT-4o Mini" }],
});
expect(mockCryptoService.encrypt).toHaveBeenCalledWith("sk-test");
expect(mockPrismaService.llmProvider.create).toHaveBeenCalledWith({
data: {
userId: "breakglass-1",
name: "my-openai",
displayName: "OpenAI",
type: "openai",
baseUrl: "https://api.openai.com/v1",
apiKey: "enc:api-key",
models: [{ id: "gpt-4o-mini", name: "GPT-4o Mini" }],
},
select: {
id: true,
},
});
expect(result).toEqual({ id: "provider-1" });
});
it("complete sets SystemConfig flag", async () => {
mockPrismaService.systemConfig.upsert.mockResolvedValue({
id: "cfg-1",
key: "onboarding.completed",
value: "true",
encrypted: false,
updatedAt: new Date(),
});
await service.complete();
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
where: { key: "onboarding.completed" },
create: {
key: "onboarding.completed",
value: "true",
encrypted: false,
},
update: {
value: "true",
encrypted: false,
},
});
});
});

View File

@@ -1,191 +0,0 @@
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
import type { InputJsonValue } from "@prisma/client/runtime/library";
import { hash } from "bcryptjs";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "../crypto/crypto.service";
const BCRYPT_ROUNDS = 12;
const TEST_PROVIDER_TIMEOUT_MS = 8000;
const ONBOARDING_COMPLETED_KEY = "onboarding.completed";
const OIDC_ISSUER_URL_KEY = "oidc.issuerUrl";
const OIDC_CLIENT_ID_KEY = "oidc.clientId";
const OIDC_CLIENT_SECRET_KEY = "oidc.clientSecret";
interface ProviderModelInput {
id: string;
name?: string;
}
interface AddProviderInput {
name: string;
displayName: string;
type: string;
baseUrl?: string;
apiKey?: string;
models?: ProviderModelInput[];
}
@Injectable()
export class OnboardingService {
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService
) {}
// Check if onboarding is completed
async isCompleted(): Promise<boolean> {
const completedFlag = await this.prisma.systemConfig.findUnique({
where: { key: ONBOARDING_COMPLETED_KEY },
});
return completedFlag?.value === "true";
}
// Step 1: Create breakglass admin user
async createBreakglassUser(
username: string,
password: string
): Promise<{ id: string; username: string }> {
const breakglassCount = await this.prisma.breakglassUser.count();
if (breakglassCount > 0) {
throw new ConflictException("Breakglass user already exists");
}
const passwordHash = await hash(password, BCRYPT_ROUNDS);
return this.prisma.breakglassUser.create({
data: {
username,
passwordHash,
},
select: {
id: true,
username: true,
},
});
}
// Step 2: Configure OIDC provider (optional)
async configureOidc(issuerUrl: string, clientId: string, clientSecret: string): Promise<void> {
const encryptedSecret = this.crypto.encrypt(clientSecret);
await Promise.all([
this.upsertSystemConfig(OIDC_ISSUER_URL_KEY, issuerUrl, false),
this.upsertSystemConfig(OIDC_CLIENT_ID_KEY, clientId, false),
this.upsertSystemConfig(OIDC_CLIENT_SECRET_KEY, encryptedSecret, true),
]);
}
// Step 3: Add first LLM provider
async addProvider(userId: string, data: AddProviderInput): Promise<{ id: string }> {
const encryptedApiKey = data.apiKey ? this.crypto.encrypt(data.apiKey) : undefined;
return this.prisma.llmProvider.create({
data: {
userId,
name: data.name,
displayName: data.displayName,
type: data.type,
baseUrl: data.baseUrl ?? null,
apiKey: encryptedApiKey ?? null,
models: (data.models ?? []) as unknown as InputJsonValue,
},
select: {
id: true,
},
});
}
// Step 3b: Test LLM provider connection
async testProvider(
type: string,
baseUrl?: string,
apiKey?: string
): Promise<{ success: boolean; error?: string }> {
const normalizedType = type.trim().toLowerCase();
if (!normalizedType) {
return { success: false, error: "Provider type is required" };
}
let probeUrl: string;
try {
probeUrl = this.buildProbeUrl(normalizedType, baseUrl);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
const headers: Record<string, string> = {
Accept: "application/json",
};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
try {
const response = await fetch(probeUrl, {
method: "GET",
headers,
signal: AbortSignal.timeout(TEST_PROVIDER_TIMEOUT_MS),
});
if (!response.ok) {
return {
success: false,
error: `Provider returned ${String(response.status)} ${response.statusText}`.trim(),
};
}
return { success: true };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
}
// Step 4: Mark onboarding complete
async complete(): Promise<void> {
await this.upsertSystemConfig(ONBOARDING_COMPLETED_KEY, "true", false);
}
async getBreakglassUserId(): Promise<string> {
const user = await this.prisma.breakglassUser.findFirst({
where: { isActive: true },
orderBy: { createdAt: "asc" },
select: { id: true },
});
if (!user) {
throw new BadRequestException("Create a breakglass user before adding a provider");
}
return user.id;
}
private async upsertSystemConfig(key: string, value: string, encrypted: boolean): Promise<void> {
await this.prisma.systemConfig.upsert({
where: { key },
create: { key, value, encrypted },
update: { value, encrypted },
});
}
private buildProbeUrl(type: string, baseUrl?: string): string {
const resolvedBaseUrl = baseUrl ?? this.getDefaultProviderBaseUrl(type);
const normalizedBaseUrl = resolvedBaseUrl.endsWith("/")
? resolvedBaseUrl
: `${resolvedBaseUrl}/`;
const endpointPath = type === "ollama" ? "api/tags" : "models";
return new URL(endpointPath, normalizedBaseUrl).toString();
}
private getDefaultProviderBaseUrl(type: string): string {
if (type === "ollama") {
return "http://localhost:11434";
}
return "https://api.openai.com/v1";
}
}

View File

@@ -1,194 +0,0 @@
import { beforeEach, describe, expect, it, vi, afterEach } from "vitest";
import type { Response } from "express";
import { AgentStatus } from "@prisma/client";
import { OrchestratorController } from "./orchestrator.controller";
import { PrismaService } from "../prisma/prisma.service";
import { AuthGuard } from "../auth/guards/auth.guard";
describe("OrchestratorController", () => {
const mockPrismaService = {
agent: {
findMany: vi.fn(),
},
};
let controller: OrchestratorController;
beforeEach(() => {
vi.clearAllMocks();
controller = new OrchestratorController(mockPrismaService as unknown as PrismaService);
});
afterEach(() => {
vi.useRealTimers();
});
describe("getAgents", () => {
it("returns active agents with API widget shape", async () => {
mockPrismaService.agent.findMany.mockResolvedValue([
{
id: "agent-1",
name: "Planner",
status: AgentStatus.WORKING,
role: "planner",
createdAt: new Date("2026-02-28T10:00:00.000Z"),
},
]);
const result = await controller.getAgents();
expect(result).toEqual([
{
id: "agent-1",
name: "Planner",
status: AgentStatus.WORKING,
type: "planner",
createdAt: new Date("2026-02-28T10:00:00.000Z"),
},
]);
expect(mockPrismaService.agent.findMany).toHaveBeenCalledWith({
where: {
status: {
not: AgentStatus.TERMINATED,
},
},
orderBy: {
createdAt: "desc",
},
select: {
id: true,
name: true,
status: true,
role: true,
createdAt: true,
},
});
});
it("falls back to type=agent when role is missing", async () => {
mockPrismaService.agent.findMany.mockResolvedValue([
{
id: "agent-2",
name: null,
status: AgentStatus.IDLE,
role: null,
createdAt: new Date("2026-02-28T11:00:00.000Z"),
},
]);
const result = await controller.getAgents();
expect(result[0]).toMatchObject({
id: "agent-2",
type: "agent",
});
});
});
describe("streamEvents", () => {
it("sets SSE headers and writes initial data payload", async () => {
const onHandlers: Record<string, (() => void) | undefined> = {};
const mockRes = {
setHeader: vi.fn(),
write: vi.fn(),
end: vi.fn(),
on: vi.fn((event: string, handler: () => void) => {
onHandlers[event] = handler;
return mockRes;
}),
} as unknown as Response;
mockPrismaService.agent.findMany.mockResolvedValue([
{
id: "agent-1",
name: "Worker",
status: AgentStatus.WORKING,
role: "worker",
createdAt: new Date("2026-02-28T12:00:00.000Z"),
},
]);
await controller.streamEvents(mockRes);
expect(mockRes.setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream");
expect(mockRes.setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache");
expect(mockRes.setHeader).toHaveBeenCalledWith("Connection", "keep-alive");
expect(mockRes.setHeader).toHaveBeenCalledWith("X-Accel-Buffering", "no");
expect(mockRes.write).toHaveBeenCalledWith(
expect.stringContaining('"type":"agents:updated"')
);
expect(typeof onHandlers.close).toBe("function");
});
it("polls every 5 seconds and only emits when payload changes", async () => {
vi.useFakeTimers();
const onHandlers: Record<string, (() => void) | undefined> = {};
const mockRes = {
setHeader: vi.fn(),
write: vi.fn(),
end: vi.fn(),
on: vi.fn((event: string, handler: () => void) => {
onHandlers[event] = handler;
return mockRes;
}),
} as unknown as Response;
const firstPayload = [
{
id: "agent-1",
name: "Worker",
status: AgentStatus.WORKING,
role: "worker",
createdAt: new Date("2026-02-28T12:00:00.000Z"),
},
];
const secondPayload = [
{
id: "agent-1",
name: "Worker",
status: AgentStatus.WAITING,
role: "worker",
createdAt: new Date("2026-02-28T12:00:00.000Z"),
},
];
mockPrismaService.agent.findMany
.mockResolvedValueOnce(firstPayload)
.mockResolvedValueOnce(firstPayload)
.mockResolvedValueOnce(secondPayload);
await controller.streamEvents(mockRes);
// 1 initial data event
const getDataEventCalls = () =>
mockRes.write.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].startsWith("data: ")
);
expect(getDataEventCalls()).toHaveLength(1);
// No change after first poll => no new data event
await vi.advanceTimersByTimeAsync(5000);
expect(getDataEventCalls()).toHaveLength(1);
// Status changed on second poll => emits new data event
await vi.advanceTimersByTimeAsync(5000);
expect(getDataEventCalls()).toHaveLength(2);
onHandlers.close?.();
expect(mockRes.end).toHaveBeenCalledTimes(1);
});
});
describe("security", () => {
it("uses AuthGuard at the controller level", () => {
const guards = Reflect.getMetadata("__guards__", OrchestratorController) as unknown[];
const guardClasses = guards.map((guard) => guard);
expect(guardClasses).toContain(AuthGuard);
});
});
});

View File

@@ -1,211 +0,0 @@
import { Controller, Get, Query, Res, UseGuards } from "@nestjs/common";
import { AgentStatus } from "@prisma/client";
import type { Response } from "express";
import { AuthGuard } from "../auth/guards/auth.guard";
import { PrismaService } from "../prisma/prisma.service";
const AGENT_POLL_INTERVAL_MS = 5_000;
const SSE_HEARTBEAT_MS = 15_000;
const DEFAULT_EVENTS_LIMIT = 25;
interface OrchestratorAgentDto {
id: string;
name: string | null;
status: AgentStatus;
type: string;
createdAt: Date;
}
interface OrchestratorEventDto {
type: string;
timestamp: string;
agentId?: string;
taskId?: string;
data?: Record<string, unknown>;
}
interface OrchestratorHealthDto {
status: "healthy" | "degraded" | "unhealthy";
database: "connected" | "disconnected";
agents: {
total: number;
working: number;
idle: number;
errored: number;
};
timestamp: string;
}
@Controller("orchestrator")
@UseGuards(AuthGuard)
export class OrchestratorController {
constructor(private readonly prisma: PrismaService) {}
@Get("agents")
async getAgents(): Promise<OrchestratorAgentDto[]> {
return this.fetchActiveAgents();
}
@Get("events/recent")
async getRecentEvents(
@Query("limit") limit?: string
): Promise<{ events: OrchestratorEventDto[] }> {
const eventsLimit = limit ? parseInt(limit, 10) : DEFAULT_EVENTS_LIMIT;
const safeLimit = Math.min(Math.max(eventsLimit, 1), 100);
// Fetch recent agent activity to derive events
const agents = await this.prisma.agent.findMany({
where: {
status: {
not: AgentStatus.TERMINATED,
},
},
orderBy: {
createdAt: "desc",
},
take: safeLimit,
});
// Derive events from agent status changes
const events: OrchestratorEventDto[] = agents.map((agent) => ({
type: `agent:${agent.status.toLowerCase()}`,
timestamp: agent.createdAt.toISOString(),
agentId: agent.id,
data: {
name: agent.name,
role: agent.role,
model: agent.model,
},
}));
return { events };
}
@Get("health")
async getHealth(): Promise<OrchestratorHealthDto> {
let databaseConnected = false;
let agents: OrchestratorAgentDto[] = [];
try {
// Check database connectivity
await this.prisma.$queryRaw`SELECT 1`;
databaseConnected = true;
// Get agent counts
agents = await this.fetchActiveAgents();
} catch {
databaseConnected = false;
}
const working = agents.filter((a) => a.status === AgentStatus.WORKING).length;
const idle = agents.filter((a) => a.status === AgentStatus.IDLE).length;
const errored = agents.filter((a) => a.status === AgentStatus.ERROR).length;
let status: OrchestratorHealthDto["status"] = "healthy";
if (!databaseConnected) {
status = "unhealthy";
} else if (errored > 0) {
status = "degraded";
}
return {
status,
database: databaseConnected ? "connected" : "disconnected",
agents: {
total: agents.length,
working,
idle,
errored,
},
timestamp: new Date().toISOString(),
};
}
@Get("events")
async streamEvents(@Res() res: Response): Promise<void> {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
if (typeof res.flushHeaders === "function") {
res.flushHeaders();
}
let isClosed = false;
let previousSnapshot = "";
const emitSnapshotIfChanged = async (): Promise<void> => {
if (isClosed) {
return;
}
try {
const agents = await this.fetchActiveAgents();
const snapshot = JSON.stringify(agents);
if (snapshot !== previousSnapshot) {
previousSnapshot = snapshot;
res.write(
`data: ${JSON.stringify({
type: "agents:updated",
agents,
timestamp: new Date().toISOString(),
})}\n\n`
);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
res.write(`event: error\n`);
res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
}
};
await emitSnapshotIfChanged();
const pollInterval = setInterval(() => {
void emitSnapshotIfChanged();
}, AGENT_POLL_INTERVAL_MS);
const heartbeatInterval = setInterval(() => {
if (!isClosed) {
res.write(": keepalive\n\n");
}
}, SSE_HEARTBEAT_MS);
res.on("close", () => {
isClosed = true;
clearInterval(pollInterval);
clearInterval(heartbeatInterval);
res.end();
});
}
private async fetchActiveAgents(): Promise<OrchestratorAgentDto[]> {
const agents = await this.prisma.agent.findMany({
where: {
status: {
not: AgentStatus.TERMINATED,
},
},
orderBy: {
createdAt: "desc",
},
select: {
id: true,
name: true,
status: true,
role: true,
createdAt: true,
},
});
return agents.map((agent) => ({
id: agent.id,
name: agent.name,
status: agent.status,
type: agent.role ?? "agent",
createdAt: agent.createdAt,
}));
}
}

View File

@@ -1,10 +0,0 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { PrismaModule } from "../prisma/prisma.module";
import { OrchestratorController } from "./orchestrator.controller";
@Module({
imports: [AuthModule, PrismaModule],
controllers: [OrchestratorController],
})
export class OrchestratorModule {}

View File

@@ -8,7 +8,6 @@ import {
MinLength,
MaxLength,
Matches,
IsUUID,
} from "class-validator";
/**
@@ -44,10 +43,6 @@ export class CreateProjectDto {
})
color?: string;
@IsOptional()
@IsUUID("4", { message: "domainId must be a valid UUID" })
domainId?: string;
@IsOptional()
@IsObject({ message: "metadata must be an object" })
metadata?: Record<string, unknown>;

View File

@@ -8,7 +8,6 @@ import {
MinLength,
MaxLength,
Matches,
IsUUID,
} from "class-validator";
/**
@@ -46,10 +45,6 @@ export class UpdateProjectDto {
})
color?: string | null;
@IsOptional()
@IsUUID("4", { message: "domainId must be a valid UUID" })
domainId?: string | null;
@IsOptional()
@IsObject({ message: "metadata must be an object" })
metadata?: Record<string, unknown>;

View File

@@ -47,9 +47,6 @@ export class ProjectsService {
createProjectDto: CreateProjectDto
): Promise<ProjectWithRelations> {
const data: Prisma.ProjectCreateInput = {
...(createProjectDto.domainId
? { domain: { connect: { id: createProjectDto.domainId } } }
: {}),
name: createProjectDto.name,
description: createProjectDto.description ?? null,
color: createProjectDto.color ?? null,
@@ -224,18 +221,6 @@ export class ProjectsService {
if (updateProjectDto.startDate !== undefined) updateData.startDate = updateProjectDto.startDate;
if (updateProjectDto.endDate !== undefined) updateData.endDate = updateProjectDto.endDate;
if (updateProjectDto.color !== undefined) updateData.color = updateProjectDto.color;
if (updateProjectDto.domainId !== undefined)
updateData.domain = updateProjectDto.domainId
? { connect: { id: updateProjectDto.domainId } }
: { disconnect: true };
if (updateProjectDto.domainId !== undefined)
updateData.domain = updateProjectDto.domainId
? {
connect: {
id: updateProjectDto.domainId,
},
}
: { disconnect: true };
if (updateProjectDto.metadata !== undefined) {
updateData.metadata = updateProjectDto.metadata as unknown as Prisma.InputJsonValue;
}

View File

@@ -1,62 +0,0 @@
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(", "));
}

View File

@@ -66,9 +66,7 @@ interface StartTranscriptionPayload {
@WSGateway({
namespace: "/speech",
cors: {
origin: (process.env.TRUSTED_ORIGINS ?? process.env.WEB_URL ?? "http://localhost:3000")
.split(",")
.map((s) => s.trim()),
origin: process.env.WEB_URL ?? "http://localhost:3000",
credentials: true,
},
})

View File

@@ -63,9 +63,7 @@ interface AuthenticatedSocket extends Socket {
@WSGateway({
namespace: "/terminal",
cors: {
origin: (process.env.TRUSTED_ORIGINS ?? process.env.WEB_URL ?? "http://localhost:3000")
.split(",")
.map((s) => s.trim()),
origin: process.env.WEB_URL ?? "http://localhost:3000",
credentials: true,
},
})

View File

@@ -1,43 +0,0 @@
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;
}

View File

@@ -1,4 +0,0 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreateUserAgentDto } from "./create-user-agent.dto";
export class UpdateUserAgentDto extends PartialType(CreateUserAgentDto) {}

View File

@@ -1,70 +0,0 @@
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);
}
}

View File

@@ -1,13 +0,0 @@
import { Module } from "@nestjs/common";
import { UserAgentService } from "./user-agent.service";
import { UserAgentController } from "./user-agent.controller";
import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
@Module({
imports: [PrismaModule, AuthModule],
controllers: [UserAgentController],
providers: [UserAgentService],
exports: [UserAgentService],
})
export class UserAgentModule {}

View File

@@ -1,300 +0,0 @@
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,
});
});
});
});

View File

@@ -1,153 +0,0 @@
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,
}));
}
}

View File

@@ -1,31 +0,0 @@
import { describe, expect, it } from "vitest";
import { WidgetsController } from "./widgets.controller";
const THROTTLER_SKIP_DEFAULT_KEY = "THROTTLER:SKIPdefault";
describe("WidgetsController throttler metadata", () => {
it("marks widget data polling endpoints to skip throttling", () => {
const pollingHandlers = [
WidgetsController.prototype.getStatCardData,
WidgetsController.prototype.getChartData,
WidgetsController.prototype.getListData,
WidgetsController.prototype.getCalendarPreviewData,
WidgetsController.prototype.getActiveProjectsData,
WidgetsController.prototype.getAgentChainsData,
];
for (const handler of pollingHandlers) {
expect(Reflect.getMetadata(THROTTLER_SKIP_DEFAULT_KEY, handler)).toBe(true);
}
});
it("does not skip throttling for non-polling widget routes", () => {
expect(
Reflect.getMetadata(THROTTLER_SKIP_DEFAULT_KEY, WidgetsController.prototype.findAll)
).toBe(undefined);
expect(
Reflect.getMetadata(THROTTLER_SKIP_DEFAULT_KEY, WidgetsController.prototype.findByName)
).toBe(undefined);
});
});

View File

@@ -1,10 +1,9 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
import { SkipThrottle as SkipThrottler } from "@nestjs/throttler";
import { WidgetsService } from "./widgets.service";
import { WidgetDataService } from "./widget-data.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard } from "../common/guards/workspace.guard";
import { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
import type { RequestWithWorkspace } from "../common/types/user.types";
/**
@@ -44,7 +43,6 @@ export class WidgetsController {
* Get stat card widget data
*/
@Post("data/stat-card")
@SkipThrottler()
@UseGuards(WorkspaceGuard)
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
return this.widgetDataService.getStatCardData(req.workspace.id, query);
@@ -55,7 +53,6 @@ export class WidgetsController {
* Get chart widget data
*/
@Post("data/chart")
@SkipThrottler()
@UseGuards(WorkspaceGuard)
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
return this.widgetDataService.getChartData(req.workspace.id, query);
@@ -66,7 +63,6 @@ export class WidgetsController {
* Get list widget data
*/
@Post("data/list")
@SkipThrottler()
@UseGuards(WorkspaceGuard)
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
return this.widgetDataService.getListData(req.workspace.id, query);
@@ -77,7 +73,6 @@ export class WidgetsController {
* Get calendar preview widget data
*/
@Post("data/calendar-preview")
@SkipThrottler()
@UseGuards(WorkspaceGuard)
async getCalendarPreviewData(
@Request() req: RequestWithWorkspace,
@@ -91,7 +86,6 @@ export class WidgetsController {
* Get active projects widget data
*/
@Post("data/active-projects")
@SkipThrottler()
@UseGuards(WorkspaceGuard)
async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
return this.widgetDataService.getActiveProjectsData(req.workspace.id);
@@ -102,7 +96,6 @@ export class WidgetsController {
* Get agent chains widget data (active agent sessions)
*/
@Post("data/agent-chains")
@SkipThrottler()
@UseGuards(WorkspaceGuard)
async getAgentChainsData(@Request() req: RequestWithWorkspace) {
return this.widgetDataService.getAgentChainsData(req.workspace.id);

View File

@@ -6,7 +6,7 @@ import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Permission, RequirePermission } from "../common/decorators";
import type { WorkspaceMember } from "@prisma/client";
import type { AuthenticatedUser } from "../common/types/user.types";
import { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto";
import type { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto";
/**
* User-scoped workspace operations.
@@ -29,25 +29,6 @@ export class WorkspacesController {
return this.workspacesService.getUserWorkspaces(user.id);
}
/**
* GET /api/workspaces/:workspaceId/stats
* Returns member, project, and domain counts for a workspace.
*/
@Get(":workspaceId/stats")
async getStats(@Param("workspaceId") workspaceId: string) {
return this.workspacesService.getStats(workspaceId);
}
/**
* GET /api/workspaces/:workspaceId/members
* Returns the list of members for a workspace.
*/
@Get(":workspaceId/members")
@UseGuards(WorkspaceGuard)
async getMembers(@Param("workspaceId") workspaceId: string) {
return this.workspacesService.getMembers(workspaceId);
}
/**
* POST /api/workspaces/:workspaceId/members
* Add a member to a workspace with the specified role.

View File

@@ -321,18 +321,6 @@ export class WorkspacesService {
});
}
/**
* Get members of a workspace.
*/
async getMembers(workspaceId: string) {
return this.prisma.workspaceMember.findMany({
where: { workspaceId },
include: {
user: { select: { id: true, name: true, email: true, createdAt: true } },
},
orderBy: { joinedAt: "asc" },
});
}
private assertCanAssignRole(
actorRole: WorkspaceMemberRole,
requestedRole: WorkspaceMemberRole
@@ -354,15 +342,4 @@ export class WorkspacesService {
private isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
}
async getStats(
workspaceId: string
): Promise<{ memberCount: number; projectCount: number; domainCount: number }> {
const [memberCount, projectCount, domainCount] = await Promise.all([
this.prisma.workspaceMember.count({ where: { workspaceId } }),
this.prisma.project.count({ where: { workspaceId } }),
this.prisma.domain.count({ where: { workspaceId } }),
]);
return { memberCount, projectCount, domainCount };
}
}

View File

@@ -601,21 +601,9 @@ class TestCoordinatorIntegration:
coordinator = Coordinator(queue_manager=queue_manager, poll_interval=0.02)
task = asyncio.create_task(coordinator.start())
# 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 asyncio.sleep(0.5) # Allow time for processing
await coordinator.stop()
task.cancel()
try:
await task

View File

@@ -1,6 +1,6 @@
# Base image for all stages
# Uses Debian slim (glibc) instead of Alpine (musl) for native addon compatibility.
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
FROM node:24-slim AS base
# Install pnpm globally
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
@@ -21,10 +21,6 @@ FROM base AS deps
COPY packages/shared/package.json ./packages/shared/
COPY packages/config/package.json ./packages/config/
COPY apps/orchestrator/package.json ./apps/orchestrator/
# API schema is available via apps/orchestrator/prisma/schema.prisma symlink
# Copy npm configuration for native binary architecture hints
COPY .npmrc ./
# Install ALL dependencies (not just production)
# No cache mount — Kaniko builds are ephemeral in CI
@@ -47,15 +43,6 @@ COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_module
COPY --from=deps /app/packages/config/node_modules ./packages/config/node_modules
COPY --from=deps /app/apps/orchestrator/node_modules ./apps/orchestrator/node_modules
# The repo has apps/orchestrator/prisma/schema.prisma as a symlink for CI use.
# Kaniko resolves destination symlinks on COPY, which fails because the symlink
# target (../../api/prisma/schema.prisma) doesn't exist in the container.
# Fix: remove the dangling symlink first, then copy the real schema file there.
RUN rm -f apps/orchestrator/prisma/schema.prisma
COPY apps/api/prisma/schema.prisma ./apps/orchestrator/prisma/schema.prisma
# pnpm turbo build runs prisma:generate (--schema=./prisma/schema.prisma) from the
# orchestrator package context — no cross-package project-root issues.
# Build the orchestrator app using TurboRepo
RUN pnpm turbo build --filter=@mosaic/orchestrator
@@ -67,7 +54,7 @@ RUN find ./apps/orchestrator/dist \( -name '*.spec.js' -o -name '*.spec.js.map'
# ======================
# Production stage
# ======================
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
FROM node:24-slim AS production
# Add metadata labels
LABEL maintainer="mosaic-team@mosaicstack.dev"
@@ -78,12 +65,13 @@ LABEL org.opencontainers.image.vendor="Mosaic Stack"
LABEL org.opencontainers.image.title="Mosaic Orchestrator"
LABEL org.opencontainers.image.description="Agent orchestration service for Mosaic Stack"
# dumb-init, ca-certificates pre-installed in base image
# Install dumb-init for proper signal handling (static binary from GitHub,
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors)
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
# - Remove npm/npx to reduce image size (not used in production)
# - Create non-root user
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
&& chmod 755 /usr/local/bin/dumb-init \
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
WORKDIR /app

View File

@@ -3,33 +3,30 @@
"version": "0.0.20",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"prisma:generate": "prisma generate --schema=./prisma/schema.prisma",
"build": "nest build",
"start": "node dist/main.js",
"start:debug": "nest start --debug --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main.js",
"test": "vitest",
"test:watch": "vitest watch",
"test:e2e": "vitest run --config tests/integration/vitest.config.ts",
"test:perf": "vitest run --config tests/performance/vitest.config.ts",
"test:watch": "vitest watch",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.72.1",
"@mosaic/config": "workspace:*",
"@mosaic/shared": "workspace:*",
"@nestjs/axios": "^4.0.1",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.12",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.12",
"@nestjs/platform-express": "^11.1.12",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.19.2",
"bullmq": "^5.67.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
@@ -48,7 +45,6 @@
"@types/express": "^5.0.1",
"@types/node": "^22.13.4",
"@vitest/coverage-v8": "^4.0.18",
"prisma": "^6.19.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.8.2",

View File

@@ -1 +0,0 @@
../../api/prisma/schema.prisma

View File

@@ -1,10 +0,0 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module";
import { AgentIngestionService } from "./agent-ingestion.service";
@Module({
imports: [PrismaModule],
providers: [AgentIngestionService],
exports: [AgentIngestionService],
})
export class AgentIngestionModule {}

View File

@@ -1,141 +0,0 @@
import { Injectable, Logger } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
export type AgentConversationRole = "agent" | "user" | "system" | "operator";
@Injectable()
export class AgentIngestionService {
private readonly logger = new Logger(AgentIngestionService.name);
constructor(private readonly prisma: PrismaService) {}
private toJsonValue(value: Record<string, unknown>): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue;
}
async recordAgentSpawned(
agentId: string,
parentAgentId?: string,
missionId?: string,
taskId?: string,
agentType?: string
): Promise<void> {
await this.prisma.agentSessionTree.upsert({
where: { sessionId: agentId },
create: {
sessionId: agentId,
parentSessionId: parentAgentId ?? null,
missionId,
taskId,
agentType,
status: "spawning",
},
update: {
parentSessionId: parentAgentId ?? null,
missionId,
taskId,
agentType,
status: "spawning",
completedAt: null,
},
});
this.logger.debug(`Recorded spawned state for agent ${agentId}`);
}
async recordAgentStarted(agentId: string): Promise<void> {
await this.prisma.agentSessionTree.upsert({
where: { sessionId: agentId },
create: {
sessionId: agentId,
status: "running",
},
update: {
status: "running",
},
});
this.logger.debug(`Recorded running state for agent ${agentId}`);
}
async recordAgentCompleted(agentId: string): Promise<void> {
const completedAt = new Date();
await this.prisma.agentSessionTree.upsert({
where: { sessionId: agentId },
create: {
sessionId: agentId,
status: "completed",
completedAt,
},
update: {
status: "completed",
completedAt,
},
});
this.logger.debug(`Recorded completed state for agent ${agentId}`);
}
async recordAgentFailed(agentId: string, error?: string): Promise<void> {
const completedAt = new Date();
const metadata = error ? this.toJsonValue({ error }) : undefined;
await this.prisma.agentSessionTree.upsert({
where: { sessionId: agentId },
create: {
sessionId: agentId,
status: "failed",
completedAt,
...(metadata && { metadata }),
},
update: {
status: "failed",
completedAt,
...(metadata && { metadata }),
},
});
this.logger.debug(`Recorded failed state for agent ${agentId}`);
}
async recordAgentKilled(agentId: string): Promise<void> {
const completedAt = new Date();
await this.prisma.agentSessionTree.upsert({
where: { sessionId: agentId },
create: {
sessionId: agentId,
status: "killed",
completedAt,
},
update: {
status: "killed",
completedAt,
},
});
this.logger.debug(`Recorded killed state for agent ${agentId}`);
}
async recordMessage(
sessionId: string,
role: AgentConversationRole,
content: string,
provider = "internal",
metadata?: Record<string, unknown>
): Promise<void> {
await this.prisma.agentConversationMessage.create({
data: {
sessionId,
role,
content,
provider,
...(metadata && { metadata: this.toJsonValue(metadata) }),
},
});
this.logger.debug(`Recorded message for session ${sessionId}`);
}
}

View File

@@ -1,54 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
UsePipes,
ValidationPipe,
} from "@nestjs/common";
import type { AgentProviderConfig } from "@prisma/client";
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
import { OrchestratorThrottlerGuard } from "../../common/guards/throttler.guard";
import { AgentProvidersService } from "./agent-providers.service";
import { CreateAgentProviderDto } from "./dto/create-agent-provider.dto";
import { UpdateAgentProviderDto } from "./dto/update-agent-provider.dto";
@Controller("agent-providers")
@UseGuards(OrchestratorApiKeyGuard, OrchestratorThrottlerGuard)
export class AgentProvidersController {
constructor(private readonly agentProvidersService: AgentProvidersService) {}
@Get()
async list(): Promise<AgentProviderConfig[]> {
return this.agentProvidersService.list();
}
@Get(":id")
async getById(@Param("id") id: string): Promise<AgentProviderConfig> {
return this.agentProvidersService.getById(id);
}
@Post()
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async create(@Body() dto: CreateAgentProviderDto): Promise<AgentProviderConfig> {
return this.agentProvidersService.create(dto);
}
@Patch(":id")
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async update(
@Param("id") id: string,
@Body() dto: UpdateAgentProviderDto
): Promise<AgentProviderConfig> {
return this.agentProvidersService.update(id, dto);
}
@Delete(":id")
async delete(@Param("id") id: string): Promise<AgentProviderConfig> {
return this.agentProvidersService.delete(id);
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../../prisma/prisma.module";
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
import { AgentProvidersController } from "./agent-providers.controller";
import { AgentProvidersService } from "./agent-providers.service";
@Module({
imports: [PrismaModule],
controllers: [AgentProvidersController],
providers: [OrchestratorApiKeyGuard, AgentProvidersService],
})
export class AgentProvidersModule {}

View File

@@ -1,211 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NotFoundException } from "@nestjs/common";
import { AgentProvidersService } from "./agent-providers.service";
import { PrismaService } from "../../prisma/prisma.service";
describe("AgentProvidersService", () => {
let service: AgentProvidersService;
let prisma: {
agentProviderConfig: {
findMany: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
};
beforeEach(() => {
prisma = {
agentProviderConfig: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
};
service = new AgentProvidersService(prisma as unknown as PrismaService);
});
it("lists all provider configs", async () => {
const expected = [
{
id: "cfg-1",
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
name: "Primary",
provider: "openai",
gatewayUrl: "https://gateway.example.com",
credentials: {},
isActive: true,
createdAt: new Date("2026-03-07T18:00:00.000Z"),
updatedAt: new Date("2026-03-07T18:00:00.000Z"),
},
];
prisma.agentProviderConfig.findMany.mockResolvedValue(expected);
const result = await service.list();
expect(prisma.agentProviderConfig.findMany).toHaveBeenCalledWith({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
expect(result).toEqual(expected);
});
it("returns a single provider config", async () => {
const expected = {
id: "cfg-1",
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
name: "Primary",
provider: "openai",
gatewayUrl: "https://gateway.example.com",
credentials: { apiKeyRef: "vault:openai" },
isActive: true,
createdAt: new Date("2026-03-07T18:00:00.000Z"),
updatedAt: new Date("2026-03-07T18:00:00.000Z"),
};
prisma.agentProviderConfig.findUnique.mockResolvedValue(expected);
const result = await service.getById("cfg-1");
expect(prisma.agentProviderConfig.findUnique).toHaveBeenCalledWith({
where: { id: "cfg-1" },
});
expect(result).toEqual(expected);
});
it("throws NotFoundException when provider config is missing", async () => {
prisma.agentProviderConfig.findUnique.mockResolvedValue(null);
await expect(service.getById("missing")).rejects.toBeInstanceOf(NotFoundException);
});
it("creates a provider config with default credentials", async () => {
const created = {
id: "cfg-created",
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
name: "New Provider",
provider: "claude",
gatewayUrl: "https://gateway.example.com",
credentials: {},
isActive: true,
createdAt: new Date("2026-03-07T18:00:00.000Z"),
updatedAt: new Date("2026-03-07T18:00:00.000Z"),
};
prisma.agentProviderConfig.create.mockResolvedValue(created);
const result = await service.create({
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
name: "New Provider",
provider: "claude",
gatewayUrl: "https://gateway.example.com",
});
expect(prisma.agentProviderConfig.create).toHaveBeenCalledWith({
data: {
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
name: "New Provider",
provider: "claude",
gatewayUrl: "https://gateway.example.com",
credentials: {},
},
});
expect(result).toEqual(created);
});
it("updates a provider config", async () => {
prisma.agentProviderConfig.findUnique.mockResolvedValue({
id: "cfg-1",
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
name: "Primary",
provider: "openai",
gatewayUrl: "https://gateway.example.com",
credentials: {},
isActive: true,
createdAt: new Date("2026-03-07T18:00:00.000Z"),
updatedAt: new Date("2026-03-07T18:00:00.000Z"),
});
const updated = {
id: "cfg-1",
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
name: "Secondary",
provider: "openai",
gatewayUrl: "https://gateway2.example.com",
credentials: { apiKeyRef: "vault:new" },
isActive: false,
createdAt: new Date("2026-03-07T18:00:00.000Z"),
updatedAt: new Date("2026-03-07T19:00:00.000Z"),
};
prisma.agentProviderConfig.update.mockResolvedValue(updated);
const result = await service.update("cfg-1", {
name: "Secondary",
gatewayUrl: "https://gateway2.example.com",
credentials: { apiKeyRef: "vault:new" },
isActive: false,
});
expect(prisma.agentProviderConfig.update).toHaveBeenCalledWith({
where: { id: "cfg-1" },
data: {
name: "Secondary",
gatewayUrl: "https://gateway2.example.com",
credentials: { apiKeyRef: "vault:new" },
isActive: false,
},
});
expect(result).toEqual(updated);
});
it("throws NotFoundException when updating a missing provider config", async () => {
prisma.agentProviderConfig.findUnique.mockResolvedValue(null);
await expect(service.update("missing", { name: "Updated" })).rejects.toBeInstanceOf(
NotFoundException
);
expect(prisma.agentProviderConfig.update).not.toHaveBeenCalled();
});
it("deletes a provider config", async () => {
prisma.agentProviderConfig.findUnique.mockResolvedValue({
id: "cfg-1",
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
name: "Primary",
provider: "openai",
gatewayUrl: "https://gateway.example.com",
credentials: {},
isActive: true,
createdAt: new Date("2026-03-07T18:00:00.000Z"),
updatedAt: new Date("2026-03-07T18:00:00.000Z"),
});
const deleted = {
id: "cfg-1",
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
name: "Primary",
provider: "openai",
gatewayUrl: "https://gateway.example.com",
credentials: {},
isActive: true,
createdAt: new Date("2026-03-07T18:00:00.000Z"),
updatedAt: new Date("2026-03-07T18:00:00.000Z"),
};
prisma.agentProviderConfig.delete.mockResolvedValue(deleted);
const result = await service.delete("cfg-1");
expect(prisma.agentProviderConfig.delete).toHaveBeenCalledWith({
where: { id: "cfg-1" },
});
expect(result).toEqual(deleted);
});
it("throws NotFoundException when deleting a missing provider config", async () => {
prisma.agentProviderConfig.findUnique.mockResolvedValue(null);
await expect(service.delete("missing")).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.agentProviderConfig.delete).not.toHaveBeenCalled();
});
});

View File

@@ -1,71 +0,0 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import type { AgentProviderConfig, Prisma } from "@prisma/client";
import { PrismaService } from "../../prisma/prisma.service";
import { CreateAgentProviderDto } from "./dto/create-agent-provider.dto";
import { UpdateAgentProviderDto } from "./dto/update-agent-provider.dto";
@Injectable()
export class AgentProvidersService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<AgentProviderConfig[]> {
return this.prisma.agentProviderConfig.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
}
async getById(id: string): Promise<AgentProviderConfig> {
const providerConfig = await this.prisma.agentProviderConfig.findUnique({
where: { id },
});
if (!providerConfig) {
throw new NotFoundException(`Agent provider config with id ${id} not found`);
}
return providerConfig;
}
async create(dto: CreateAgentProviderDto): Promise<AgentProviderConfig> {
return this.prisma.agentProviderConfig.create({
data: {
workspaceId: dto.workspaceId,
name: dto.name,
provider: dto.provider,
gatewayUrl: dto.gatewayUrl,
credentials: this.toJsonValue(dto.credentials ?? {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
},
});
}
async update(id: string, dto: UpdateAgentProviderDto): Promise<AgentProviderConfig> {
await this.getById(id);
const data: Prisma.AgentProviderConfigUpdateInput = {
...(dto.workspaceId !== undefined ? { workspaceId: dto.workspaceId } : {}),
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.provider !== undefined ? { provider: dto.provider } : {}),
...(dto.gatewayUrl !== undefined ? { gatewayUrl: dto.gatewayUrl } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...(dto.credentials !== undefined ? { credentials: this.toJsonValue(dto.credentials) } : {}),
};
return this.prisma.agentProviderConfig.update({
where: { id },
data,
});
}
async delete(id: string): Promise<AgentProviderConfig> {
await this.getById(id);
return this.prisma.agentProviderConfig.delete({
where: { id },
});
}
private toJsonValue(value: Record<string, unknown>): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue;
}
}

View File

@@ -1,26 +0,0 @@
import { IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID } from "class-validator";
export class CreateAgentProviderDto {
@IsUUID()
workspaceId!: string;
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@IsNotEmpty()
provider!: string;
@IsString()
@IsNotEmpty()
gatewayUrl!: string;
@IsOptional()
@IsObject()
credentials?: Record<string, unknown>;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -1,30 +0,0 @@
import { IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID } from "class-validator";
export class UpdateAgentProviderDto {
@IsOptional()
@IsUUID()
workspaceId?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
name?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
provider?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
gatewayUrl?: string;
@IsOptional()
@IsObject()
credentials?: Record<string, unknown>;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -1,172 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AgentControlService } from "./agent-control.service";
import { PrismaService } from "../../prisma/prisma.service";
import { KillswitchService } from "../../killswitch/killswitch.service";
describe("AgentControlService", () => {
let service: AgentControlService;
let prisma: {
agentSessionTree: {
findUnique: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
};
agentConversationMessage: {
create: ReturnType<typeof vi.fn>;
};
operatorAuditLog: {
create: ReturnType<typeof vi.fn>;
};
};
let killswitchService: {
killAgent: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
prisma = {
agentSessionTree: {
findUnique: vi.fn(),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
agentConversationMessage: {
create: vi.fn().mockResolvedValue(undefined),
},
operatorAuditLog: {
create: vi.fn().mockResolvedValue(undefined),
},
};
killswitchService = {
killAgent: vi.fn().mockResolvedValue(undefined),
};
service = new AgentControlService(
prisma as unknown as PrismaService,
killswitchService as unknown as KillswitchService
);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("injectMessage", () => {
it("creates conversation message and audit log when tree entry exists", async () => {
prisma.agentSessionTree.findUnique.mockResolvedValue({ id: "tree-1" });
await service.injectMessage("agent-123", "operator-abc", "Please continue");
expect(prisma.agentSessionTree.findUnique).toHaveBeenCalledWith({
where: { sessionId: "agent-123" },
select: { id: true },
});
expect(prisma.agentConversationMessage.create).toHaveBeenCalledWith({
data: {
sessionId: "agent-123",
role: "operator",
content: "Please continue",
provider: "internal",
metadata: {},
},
});
expect(prisma.operatorAuditLog.create).toHaveBeenCalledWith({
data: {
sessionId: "agent-123",
userId: "operator-abc",
provider: "internal",
action: "inject",
metadata: {
payload: {
message: "Please continue",
},
},
},
});
});
it("creates only audit log when no tree entry exists", async () => {
prisma.agentSessionTree.findUnique.mockResolvedValue(null);
await service.injectMessage("agent-456", "operator-def", "Nudge message");
expect(prisma.agentConversationMessage.create).not.toHaveBeenCalled();
expect(prisma.operatorAuditLog.create).toHaveBeenCalledWith({
data: {
sessionId: "agent-456",
userId: "operator-def",
provider: "internal",
action: "inject",
metadata: {
payload: {
message: "Nudge message",
},
},
},
});
});
});
describe("pauseAgent", () => {
it("updates tree status to paused and creates audit log", async () => {
await service.pauseAgent("agent-789", "operator-pause");
expect(prisma.agentSessionTree.updateMany).toHaveBeenCalledWith({
where: { sessionId: "agent-789" },
data: { status: "paused" },
});
expect(prisma.operatorAuditLog.create).toHaveBeenCalledWith({
data: {
sessionId: "agent-789",
userId: "operator-pause",
provider: "internal",
action: "pause",
metadata: {
payload: {},
},
},
});
});
});
describe("resumeAgent", () => {
it("updates tree status to running and creates audit log", async () => {
await service.resumeAgent("agent-321", "operator-resume");
expect(prisma.agentSessionTree.updateMany).toHaveBeenCalledWith({
where: { sessionId: "agent-321" },
data: { status: "running" },
});
expect(prisma.operatorAuditLog.create).toHaveBeenCalledWith({
data: {
sessionId: "agent-321",
userId: "operator-resume",
provider: "internal",
action: "resume",
metadata: {
payload: {},
},
},
});
});
});
describe("killAgent", () => {
it("delegates kill to killswitch and logs audit", async () => {
await service.killAgent("agent-654", "operator-kill", false);
expect(killswitchService.killAgent).toHaveBeenCalledWith("agent-654");
expect(prisma.operatorAuditLog.create).toHaveBeenCalledWith({
data: {
sessionId: "agent-654",
userId: "operator-kill",
provider: "internal",
action: "kill",
metadata: {
payload: {
force: false,
},
},
},
});
});
});
});

View File

@@ -1,77 +0,0 @@
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import { KillswitchService } from "../../killswitch/killswitch.service";
import { PrismaService } from "../../prisma/prisma.service";
@Injectable()
export class AgentControlService {
constructor(
private readonly prisma: PrismaService,
private readonly killswitchService: KillswitchService
) {}
private toJsonValue(value: Record<string, unknown>): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue;
}
private async createOperatorAuditLog(
agentId: string,
operatorId: string,
action: "inject" | "pause" | "resume" | "kill",
payload: Record<string, unknown>
): Promise<void> {
await this.prisma.operatorAuditLog.create({
data: {
sessionId: agentId,
userId: operatorId,
provider: "internal",
action,
metadata: this.toJsonValue({ payload }),
},
});
}
async injectMessage(agentId: string, operatorId: string, message: string): Promise<void> {
const treeEntry = await this.prisma.agentSessionTree.findUnique({
where: { sessionId: agentId },
select: { id: true },
});
if (treeEntry) {
await this.prisma.agentConversationMessage.create({
data: {
sessionId: agentId,
role: "operator",
content: message,
provider: "internal",
metadata: this.toJsonValue({}),
},
});
}
await this.createOperatorAuditLog(agentId, operatorId, "inject", { message });
}
async pauseAgent(agentId: string, operatorId: string): Promise<void> {
await this.prisma.agentSessionTree.updateMany({
where: { sessionId: agentId },
data: { status: "paused" },
});
await this.createOperatorAuditLog(agentId, operatorId, "pause", {});
}
async resumeAgent(agentId: string, operatorId: string): Promise<void> {
await this.prisma.agentSessionTree.updateMany({
where: { sessionId: agentId },
data: { status: "running" },
});
await this.createOperatorAuditLog(agentId, operatorId, "resume", {});
}
async killAgent(agentId: string, operatorId: string, force = true): Promise<void> {
await this.killswitchService.killAgent(agentId);
await this.createOperatorAuditLog(agentId, operatorId, "kill", { force });
}
}

View File

@@ -1,103 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AgentMessagesService } from "./agent-messages.service";
import { PrismaService } from "../../prisma/prisma.service";
describe("AgentMessagesService", () => {
let service: AgentMessagesService;
let prisma: {
agentConversationMessage: {
findMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
};
beforeEach(() => {
prisma = {
agentConversationMessage: {
findMany: vi.fn(),
count: vi.fn(),
},
};
service = new AgentMessagesService(prisma as unknown as PrismaService);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("getMessages", () => {
it("returns paginated messages from Prisma", async () => {
const sessionId = "agent-123";
const messages = [
{
id: "msg-1",
sessionId,
provider: "internal",
role: "assistant",
content: "First message",
timestamp: new Date("2026-03-07T16:00:00.000Z"),
metadata: {},
},
{
id: "msg-2",
sessionId,
provider: "internal",
role: "user",
content: "Second message",
timestamp: new Date("2026-03-07T15:59:00.000Z"),
metadata: {},
},
];
prisma.agentConversationMessage.findMany.mockResolvedValue(messages);
prisma.agentConversationMessage.count.mockResolvedValue(2);
const result = await service.getMessages(sessionId, 50, 0);
expect(prisma.agentConversationMessage.findMany).toHaveBeenCalledWith({
where: { sessionId },
orderBy: { timestamp: "desc" },
take: 50,
skip: 0,
});
expect(prisma.agentConversationMessage.count).toHaveBeenCalledWith({ where: { sessionId } });
expect(result).toEqual({
messages,
total: 2,
});
});
it("applies limit and cursor (skip) correctly", async () => {
const sessionId = "agent-456";
const limit = 10;
const cursor = 20;
prisma.agentConversationMessage.findMany.mockResolvedValue([]);
prisma.agentConversationMessage.count.mockResolvedValue(42);
await service.getMessages(sessionId, limit, cursor);
expect(prisma.agentConversationMessage.findMany).toHaveBeenCalledWith({
where: { sessionId },
orderBy: { timestamp: "desc" },
take: limit,
skip: cursor,
});
});
it("returns empty messages array when no messages exist", async () => {
const sessionId = "agent-empty";
prisma.agentConversationMessage.findMany.mockResolvedValue([]);
prisma.agentConversationMessage.count.mockResolvedValue(0);
const result = await service.getMessages(sessionId, 25, 0);
expect(result).toEqual({
messages: [],
total: 0,
});
});
});
});

View File

@@ -1,84 +0,0 @@
import { Injectable } from "@nestjs/common";
import { type AgentConversationMessage, type Prisma } from "@prisma/client";
import { PrismaService } from "../../prisma/prisma.service";
@Injectable()
export class AgentMessagesService {
constructor(private readonly prisma: PrismaService) {}
async getMessages(
sessionId: string,
limit: number,
skip: number
): Promise<{
messages: AgentConversationMessage[];
total: number;
}> {
const where = { sessionId };
const [messages, total] = await Promise.all([
this.prisma.agentConversationMessage.findMany({
where,
orderBy: {
timestamp: "desc",
},
take: limit,
skip,
}),
this.prisma.agentConversationMessage.count({ where }),
]);
return {
messages,
total,
};
}
async getReplayMessages(sessionId: string, limit = 50): Promise<AgentConversationMessage[]> {
const messages = await this.prisma.agentConversationMessage.findMany({
where: { sessionId },
orderBy: {
timestamp: "desc",
},
take: limit,
});
return messages.reverse();
}
async getMessagesAfter(
sessionId: string,
lastSeenTimestamp: Date,
lastSeenMessageId: string | null
): Promise<AgentConversationMessage[]> {
const where: Prisma.AgentConversationMessageWhereInput = {
sessionId,
...(lastSeenMessageId
? {
OR: [
{
timestamp: {
gt: lastSeenTimestamp,
},
},
{
timestamp: lastSeenTimestamp,
id: {
gt: lastSeenMessageId,
},
},
],
}
: {
timestamp: {
gt: lastSeenTimestamp,
},
}),
};
return this.prisma.agentConversationMessage.findMany({
where,
orderBy: [{ timestamp: "asc" }, { id: "asc" }],
});
}
}

View File

@@ -1,202 +0,0 @@
import { Logger } from "@nestjs/common";
import type {
AgentMessage,
AgentSession,
AgentSessionList,
IAgentProvider,
InjectResult,
} from "@mosaic/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentProviderRegistry } from "./agent-provider.registry";
import { InternalAgentProvider } from "./internal-agent.provider";
type MockProvider = IAgentProvider & {
listSessions: ReturnType<typeof vi.fn>;
getSession: ReturnType<typeof vi.fn>;
};
const emptyMessageStream = async function* (): AsyncIterable<AgentMessage> {
return;
};
const createProvider = (providerId: string, sessions: AgentSession[] = []): MockProvider => {
return {
providerId,
providerType: providerId,
displayName: providerId,
listSessions: vi.fn().mockResolvedValue({
sessions,
total: sessions.length,
} as AgentSessionList),
getSession: vi.fn().mockResolvedValue(null),
getMessages: vi.fn().mockResolvedValue([]),
injectMessage: vi.fn().mockResolvedValue({ accepted: true } as InjectResult),
pauseSession: vi.fn().mockResolvedValue(undefined),
resumeSession: vi.fn().mockResolvedValue(undefined),
killSession: vi.fn().mockResolvedValue(undefined),
streamMessages: vi.fn().mockReturnValue(emptyMessageStream()),
isAvailable: vi.fn().mockResolvedValue(true),
};
};
describe("AgentProviderRegistry", () => {
let registry: AgentProviderRegistry;
let internalProvider: MockProvider;
beforeEach(() => {
internalProvider = createProvider("internal");
registry = new AgentProviderRegistry(internalProvider as unknown as InternalAgentProvider);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("registers InternalAgentProvider on module init", () => {
registry.onModuleInit();
expect(registry.getProvider("internal")).toBe(internalProvider);
});
it("registers providers and returns null for unknown provider ids", () => {
const externalProvider = createProvider("openclaw");
registry.registerProvider(externalProvider);
expect(registry.getProvider("openclaw")).toBe(externalProvider);
expect(registry.getProvider("missing")).toBeNull();
});
it("aggregates and sorts sessions from all providers", async () => {
const internalSessions: AgentSession[] = [
{
id: "session-older",
providerId: "internal",
providerType: "internal",
status: "active",
createdAt: new Date("2026-03-07T10:00:00.000Z"),
updatedAt: new Date("2026-03-07T10:10:00.000Z"),
},
];
const externalSessions: AgentSession[] = [
{
id: "session-newer",
providerId: "openclaw",
providerType: "external",
status: "paused",
createdAt: new Date("2026-03-07T09:00:00.000Z"),
updatedAt: new Date("2026-03-07T10:20:00.000Z"),
},
];
internalProvider.listSessions.mockResolvedValue({
sessions: internalSessions,
total: internalSessions.length,
} as AgentSessionList);
const externalProvider = createProvider("openclaw", externalSessions);
registry.onModuleInit();
registry.registerProvider(externalProvider);
const result = await registry.listAllSessions();
expect(result.map((session) => session.id)).toEqual(["session-newer", "session-older"]);
expect(internalProvider.listSessions).toHaveBeenCalledTimes(1);
expect(externalProvider.listSessions).toHaveBeenCalledTimes(1);
});
it("skips provider failures and logs warning", async () => {
const warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined);
const healthyProvider = createProvider("healthy", [
{
id: "session-1",
providerId: "healthy",
providerType: "external",
status: "active",
createdAt: new Date("2026-03-07T11:00:00.000Z"),
updatedAt: new Date("2026-03-07T11:00:00.000Z"),
},
]);
const failingProvider = createProvider("failing");
failingProvider.listSessions.mockRejectedValue(new Error("provider offline"));
registry.onModuleInit();
registry.registerProvider(healthyProvider);
registry.registerProvider(failingProvider);
const result = await registry.listAllSessions();
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe("session-1");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to list sessions for provider failing")
);
});
it("finds a provider for an existing session", async () => {
const targetSession: AgentSession = {
id: "session-found",
providerId: "openclaw",
providerType: "external",
status: "active",
createdAt: new Date("2026-03-07T12:00:00.000Z"),
updatedAt: new Date("2026-03-07T12:10:00.000Z"),
};
const openclawProvider = createProvider("openclaw");
openclawProvider.getSession.mockResolvedValue(targetSession);
registry.onModuleInit();
registry.registerProvider(openclawProvider);
const result = await registry.getProviderForSession(targetSession.id);
expect(result).toEqual({
provider: openclawProvider,
session: targetSession,
});
expect(internalProvider.getSession).toHaveBeenCalledWith(targetSession.id);
expect(openclawProvider.getSession).toHaveBeenCalledWith(targetSession.id);
});
it("returns null when no provider has the requested session", async () => {
const openclawProvider = createProvider("openclaw");
registry.onModuleInit();
registry.registerProvider(openclawProvider);
await expect(registry.getProviderForSession("missing-session")).resolves.toBeNull();
});
it("continues searching providers when getSession throws", async () => {
const warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined);
const failingProvider = createProvider("failing");
failingProvider.getSession.mockRejectedValue(new Error("provider timeout"));
const healthySession: AgentSession = {
id: "session-healthy",
providerId: "healthy",
providerType: "external",
status: "active",
createdAt: new Date("2026-03-07T12:15:00.000Z"),
updatedAt: new Date("2026-03-07T12:16:00.000Z"),
};
const healthyProvider = createProvider("healthy");
healthyProvider.getSession.mockResolvedValue(healthySession);
registry.onModuleInit();
registry.registerProvider(failingProvider);
registry.registerProvider(healthyProvider);
const result = await registry.getProviderForSession(healthySession.id);
expect(result).toEqual({ provider: healthyProvider, session: healthySession });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to get session session-healthy for provider failing")
);
});
});

View File

@@ -1,79 +0,0 @@
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import type { AgentSession, IAgentProvider } from "@mosaic/shared";
import { InternalAgentProvider } from "./internal-agent.provider";
@Injectable()
export class AgentProviderRegistry implements OnModuleInit {
private readonly logger = new Logger(AgentProviderRegistry.name);
private readonly providers = new Map<string, IAgentProvider>();
constructor(private readonly internalProvider: InternalAgentProvider) {}
onModuleInit(): void {
this.registerProvider(this.internalProvider);
}
registerProvider(provider: IAgentProvider): void {
const existingProvider = this.providers.get(provider.providerId);
if (existingProvider !== undefined) {
this.logger.warn(`Replacing existing provider registration for ${provider.providerId}`);
}
this.providers.set(provider.providerId, provider);
}
getProvider(providerId: string): IAgentProvider | null {
return this.providers.get(providerId) ?? null;
}
async getProviderForSession(
sessionId: string
): Promise<{ provider: IAgentProvider; session: AgentSession } | null> {
for (const provider of this.providers.values()) {
try {
const session = await provider.getSession(sessionId);
if (session !== null) {
return {
provider,
session,
};
}
} catch (error) {
this.logger.warn(
`Failed to get session ${sessionId} for provider ${provider.providerId}: ${this.toErrorMessage(error)}`
);
}
}
return null;
}
async listAllSessions(): Promise<AgentSession[]> {
const providers = [...this.providers.values()];
const sessionsByProvider = await Promise.all(
providers.map(async (provider) => {
try {
const { sessions } = await provider.listSessions();
return sessions;
} catch (error) {
this.logger.warn(
`Failed to list sessions for provider ${provider.providerId}: ${this.toErrorMessage(error)}`
);
return [];
}
})
);
return sessionsByProvider
.flat()
.sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime());
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
}

View File

@@ -1,245 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AgentTreeService } from "./agent-tree.service";
import { PrismaService } from "../../prisma/prisma.service";
describe("AgentTreeService", () => {
let service: AgentTreeService;
let prisma: {
agentSessionTree: {
findMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>;
};
};
beforeEach(() => {
prisma = {
agentSessionTree: {
findMany: vi.fn(),
count: vi.fn(),
findUnique: vi.fn(),
},
};
service = new AgentTreeService(prisma as unknown as PrismaService);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("listSessions", () => {
it("returns paginated sessions and cursor", async () => {
const sessions = [
{
id: "tree-2",
sessionId: "agent-2",
parentSessionId: null,
provider: "internal",
missionId: null,
taskId: "task-2",
taskSource: "queue",
agentType: "worker",
status: "running",
spawnedAt: new Date("2026-03-07T11:00:00.000Z"),
completedAt: null,
metadata: {},
},
{
id: "tree-1",
sessionId: "agent-1",
parentSessionId: null,
provider: "internal",
missionId: null,
taskId: "task-1",
taskSource: "queue",
agentType: "worker",
status: "running",
spawnedAt: new Date("2026-03-07T10:00:00.000Z"),
completedAt: null,
metadata: {},
},
];
prisma.agentSessionTree.findMany.mockResolvedValue(sessions);
prisma.agentSessionTree.count.mockResolvedValue(7);
const result = await service.listSessions(undefined, 2);
expect(prisma.agentSessionTree.findMany).toHaveBeenCalledWith({
where: undefined,
orderBy: [{ spawnedAt: "desc" }, { sessionId: "desc" }],
take: 2,
});
expect(prisma.agentSessionTree.count).toHaveBeenCalledWith();
expect(result.sessions).toEqual(sessions);
expect(result.total).toBe(7);
expect(result.cursor).toBeTypeOf("string");
});
it("applies cursor filter when provided", async () => {
prisma.agentSessionTree.findMany.mockResolvedValue([]);
prisma.agentSessionTree.count.mockResolvedValue(0);
const cursorDate = "2026-03-07T10:00:00.000Z";
const cursorSessionId = "agent-5";
const cursor = Buffer.from(
JSON.stringify({
spawnedAt: cursorDate,
sessionId: cursorSessionId,
}),
"utf8"
).toString("base64url");
await service.listSessions(cursor, 25);
expect(prisma.agentSessionTree.findMany).toHaveBeenCalledWith({
where: {
OR: [
{
spawnedAt: {
lt: new Date(cursorDate),
},
},
{
spawnedAt: new Date(cursorDate),
sessionId: {
lt: cursorSessionId,
},
},
],
},
orderBy: [{ spawnedAt: "desc" }, { sessionId: "desc" }],
take: 25,
});
});
it("ignores invalid cursor values", async () => {
prisma.agentSessionTree.findMany.mockResolvedValue([]);
prisma.agentSessionTree.count.mockResolvedValue(0);
await service.listSessions("invalid-cursor", 10);
expect(prisma.agentSessionTree.findMany).toHaveBeenCalledWith({
where: undefined,
orderBy: [{ spawnedAt: "desc" }, { sessionId: "desc" }],
take: 10,
});
});
});
describe("getSession", () => {
it("returns matching session entry", async () => {
const session = {
id: "tree-1",
sessionId: "agent-123",
parentSessionId: null,
provider: "internal",
missionId: null,
taskId: "task-1",
taskSource: "queue",
agentType: "worker",
status: "running",
spawnedAt: new Date("2026-03-07T11:00:00.000Z"),
completedAt: null,
metadata: {},
};
prisma.agentSessionTree.findUnique.mockResolvedValue(session);
const result = await service.getSession("agent-123");
expect(prisma.agentSessionTree.findUnique).toHaveBeenCalledWith({
where: { sessionId: "agent-123" },
});
expect(result).toEqual(session);
});
it("returns null when session does not exist", async () => {
prisma.agentSessionTree.findUnique.mockResolvedValue(null);
const result = await service.getSession("agent-missing");
expect(result).toBeNull();
});
});
describe("getTree", () => {
it("returns mapped entries from Prisma", async () => {
prisma.agentSessionTree.findMany.mockResolvedValue([
{
id: "tree-1",
sessionId: "agent-1",
parentSessionId: "agent-root",
provider: "internal",
missionId: "mission-1",
taskId: "task-1",
taskSource: "queue",
agentType: "worker",
status: "running",
spawnedAt: new Date("2026-03-07T10:00:00.000Z"),
completedAt: new Date("2026-03-07T11:00:00.000Z"),
metadata: {},
},
]);
const result = await service.getTree();
expect(prisma.agentSessionTree.findMany).toHaveBeenCalledWith({
orderBy: { spawnedAt: "desc" },
take: 200,
});
expect(result).toEqual([
{
sessionId: "agent-1",
parentSessionId: "agent-root",
status: "running",
agentType: "worker",
taskSource: "queue",
spawnedAt: "2026-03-07T10:00:00.000Z",
completedAt: "2026-03-07T11:00:00.000Z",
},
]);
});
it("returns empty array when no entries exist", async () => {
prisma.agentSessionTree.findMany.mockResolvedValue([]);
const result = await service.getTree();
expect(result).toEqual([]);
});
it("maps null parentSessionId and completedAt correctly", async () => {
prisma.agentSessionTree.findMany.mockResolvedValue([
{
id: "tree-2",
sessionId: "agent-root",
parentSessionId: null,
provider: "internal",
missionId: null,
taskId: null,
taskSource: null,
agentType: null,
status: "spawning",
spawnedAt: new Date("2026-03-07T09:00:00.000Z"),
completedAt: null,
metadata: {},
},
]);
const result = await service.getTree();
expect(result).toEqual([
{
sessionId: "agent-root",
parentSessionId: null,
status: "spawning",
agentType: null,
taskSource: null,
spawnedAt: "2026-03-07T09:00:00.000Z",
completedAt: null,
},
]);
});
});
});

View File

@@ -1,146 +0,0 @@
import { Injectable } from "@nestjs/common";
import type { AgentSessionTree, Prisma } from "@prisma/client";
import { AgentTreeResponseDto } from "./dto/agent-tree-response.dto";
import { PrismaService } from "../../prisma/prisma.service";
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 200;
interface SessionCursor {
spawnedAt: Date;
sessionId: string;
}
export interface AgentSessionTreeListResult {
sessions: AgentSessionTree[];
total: number;
cursor?: string;
}
@Injectable()
export class AgentTreeService {
constructor(private readonly prisma: PrismaService) {}
async listSessions(
cursor?: string,
limit = DEFAULT_PAGE_LIMIT
): Promise<AgentSessionTreeListResult> {
const safeLimit = this.normalizeLimit(limit);
const parsedCursor = this.parseCursor(cursor);
const where: Prisma.AgentSessionTreeWhereInput | undefined = parsedCursor
? {
OR: [
{
spawnedAt: {
lt: parsedCursor.spawnedAt,
},
},
{
spawnedAt: parsedCursor.spawnedAt,
sessionId: {
lt: parsedCursor.sessionId,
},
},
],
}
: undefined;
const [sessions, total] = await Promise.all([
this.prisma.agentSessionTree.findMany({
where,
orderBy: [{ spawnedAt: "desc" }, { sessionId: "desc" }],
take: safeLimit,
}),
this.prisma.agentSessionTree.count(),
]);
const nextCursor =
sessions.length === safeLimit
? this.serializeCursor(sessions[sessions.length - 1])
: undefined;
return {
sessions,
total,
...(nextCursor !== undefined ? { cursor: nextCursor } : {}),
};
}
async getSession(sessionId: string): Promise<AgentSessionTree | null> {
return this.prisma.agentSessionTree.findUnique({
where: { sessionId },
});
}
async getTree(): Promise<AgentTreeResponseDto[]> {
const entries = await this.prisma.agentSessionTree.findMany({
orderBy: { spawnedAt: "desc" },
take: 200,
});
const response: AgentTreeResponseDto[] = [];
for (const entry of entries) {
response.push({
sessionId: entry.sessionId,
parentSessionId: entry.parentSessionId ?? null,
status: entry.status,
agentType: entry.agentType ?? null,
taskSource: entry.taskSource ?? null,
spawnedAt: entry.spawnedAt.toISOString(),
completedAt: entry.completedAt?.toISOString() ?? null,
});
}
return response;
}
private normalizeLimit(limit: number): number {
const normalized = Number.isFinite(limit) ? Math.trunc(limit) : DEFAULT_PAGE_LIMIT;
if (normalized < 1) {
return 1;
}
return Math.min(normalized, MAX_PAGE_LIMIT);
}
private serializeCursor(entry: Pick<AgentSessionTree, "spawnedAt" | "sessionId">): string {
return Buffer.from(
JSON.stringify({
spawnedAt: entry.spawnedAt.toISOString(),
sessionId: entry.sessionId,
}),
"utf8"
).toString("base64url");
}
private parseCursor(cursor?: string): SessionCursor | null {
if (!cursor) {
return null;
}
try {
const decoded = Buffer.from(cursor, "base64url").toString("utf8");
const parsed = JSON.parse(decoded) as {
spawnedAt?: string;
sessionId?: string;
};
if (typeof parsed.spawnedAt !== "string" || typeof parsed.sessionId !== "string") {
return null;
}
const spawnedAt = new Date(parsed.spawnedAt);
if (Number.isNaN(spawnedAt.getTime())) {
return null;
}
return {
spawnedAt,
sessionId: parsed.sessionId,
};
} catch {
return null;
}
}
}

View File

@@ -5,9 +5,6 @@ import { AgentSpawnerService } from "../../spawner/agent-spawner.service";
import { AgentLifecycleService } from "../../spawner/agent-lifecycle.service";
import { KillswitchService } from "../../killswitch/killswitch.service";
import { AgentEventsService } from "./agent-events.service";
import { AgentMessagesService } from "./agent-messages.service";
import { AgentControlService } from "./agent-control.service";
import { AgentTreeService } from "./agent-tree.service";
import type { KillAllResult } from "../../killswitch/killswitch.service";
describe("AgentsController - Killswitch Endpoints", () => {
@@ -30,20 +27,6 @@ describe("AgentsController - Killswitch Endpoints", () => {
subscribe: ReturnType<typeof vi.fn>;
getInitialSnapshot: ReturnType<typeof vi.fn>;
createHeartbeat: ReturnType<typeof vi.fn>;
getRecentEvents: ReturnType<typeof vi.fn>;
};
let mockMessagesService: {
getMessages: ReturnType<typeof vi.fn>;
getReplayMessages: ReturnType<typeof vi.fn>;
getMessagesAfter: ReturnType<typeof vi.fn>;
};
let mockControlService: {
injectMessage: ReturnType<typeof vi.fn>;
pauseAgent: ReturnType<typeof vi.fn>;
resumeAgent: ReturnType<typeof vi.fn>;
};
let mockTreeService: {
getTree: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
@@ -78,23 +61,6 @@ describe("AgentsController - Killswitch Endpoints", () => {
timestamp: new Date().toISOString(),
data: { heartbeat: true },
}),
getRecentEvents: vi.fn().mockReturnValue([]),
};
mockMessagesService = {
getMessages: vi.fn(),
getReplayMessages: vi.fn().mockResolvedValue([]),
getMessagesAfter: vi.fn().mockResolvedValue([]),
};
mockControlService = {
injectMessage: vi.fn().mockResolvedValue(undefined),
pauseAgent: vi.fn().mockResolvedValue(undefined),
resumeAgent: vi.fn().mockResolvedValue(undefined),
};
mockTreeService = {
getTree: vi.fn().mockResolvedValue([]),
};
controller = new AgentsController(
@@ -102,10 +68,7 @@ describe("AgentsController - Killswitch Endpoints", () => {
mockSpawnerService as unknown as AgentSpawnerService,
mockLifecycleService as unknown as AgentLifecycleService,
mockKillswitchService as unknown as KillswitchService,
mockEventsService as unknown as AgentEventsService,
mockMessagesService as unknown as AgentMessagesService,
mockControlService as unknown as AgentControlService,
mockTreeService as unknown as AgentTreeService
mockEventsService as unknown as AgentEventsService
);
});

View File

@@ -4,9 +4,6 @@ import { AgentSpawnerService } from "../../spawner/agent-spawner.service";
import { AgentLifecycleService } from "../../spawner/agent-lifecycle.service";
import { KillswitchService } from "../../killswitch/killswitch.service";
import { AgentEventsService } from "./agent-events.service";
import { AgentMessagesService } from "./agent-messages.service";
import { AgentControlService } from "./agent-control.service";
import { AgentTreeService } from "./agent-tree.service";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
describe("AgentsController", () => {
@@ -33,19 +30,6 @@ describe("AgentsController", () => {
createHeartbeat: ReturnType<typeof vi.fn>;
getRecentEvents: ReturnType<typeof vi.fn>;
};
let messagesService: {
getMessages: ReturnType<typeof vi.fn>;
getReplayMessages: ReturnType<typeof vi.fn>;
getMessagesAfter: ReturnType<typeof vi.fn>;
};
let controlService: {
injectMessage: ReturnType<typeof vi.fn>;
pauseAgent: ReturnType<typeof vi.fn>;
resumeAgent: ReturnType<typeof vi.fn>;
};
let treeService: {
getTree: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
// Create mock services
@@ -85,32 +69,13 @@ describe("AgentsController", () => {
getRecentEvents: vi.fn().mockReturnValue([]),
};
messagesService = {
getMessages: vi.fn(),
getReplayMessages: vi.fn().mockResolvedValue([]),
getMessagesAfter: vi.fn().mockResolvedValue([]),
};
controlService = {
injectMessage: vi.fn().mockResolvedValue(undefined),
pauseAgent: vi.fn().mockResolvedValue(undefined),
resumeAgent: vi.fn().mockResolvedValue(undefined),
};
treeService = {
getTree: vi.fn().mockResolvedValue([]),
};
// Create controller with mocked services
controller = new AgentsController(
queueService as unknown as QueueService,
spawnerService as unknown as AgentSpawnerService,
lifecycleService as unknown as AgentLifecycleService,
killswitchService as unknown as KillswitchService,
eventsService as unknown as AgentEventsService,
messagesService as unknown as AgentMessagesService,
controlService as unknown as AgentControlService,
treeService as unknown as AgentTreeService
eventsService as unknown as AgentEventsService
);
});
@@ -122,27 +87,6 @@ describe("AgentsController", () => {
expect(controller).toBeDefined();
});
describe("getAgentTree", () => {
it("should return tree entries", async () => {
const entries = [
{
sessionId: "agent-1",
parentSessionId: null,
status: "running",
agentType: "worker",
taskSource: "internal",
spawnedAt: "2026-03-07T00:00:00.000Z",
completedAt: null,
},
];
treeService.getTree.mockResolvedValue(entries);
await expect(controller.getAgentTree()).resolves.toEqual(entries);
expect(treeService.getTree).toHaveBeenCalledTimes(1);
});
});
describe("listAgents", () => {
it("should return empty array when no agents exist", () => {
// Arrange
@@ -421,93 +365,6 @@ describe("AgentsController", () => {
});
});
describe("agent control endpoints", () => {
const agentId = "0b64079f-4487-42b9-92eb-cf8ea0042a64";
it("should inject an operator message", async () => {
const req = { apiKey: "control-key" };
const result = await controller.injectAgentMessage(
agentId,
{ message: "pause and summarize" },
req
);
expect(controlService.injectMessage).toHaveBeenCalledWith(
agentId,
"control-key",
"pause and summarize"
);
expect(result).toEqual({ message: `Message injected into agent ${agentId}` });
});
it("should default operator id when request api key is missing", async () => {
await controller.injectAgentMessage(agentId, { message: "continue" }, {});
expect(controlService.injectMessage).toHaveBeenCalledWith(agentId, "operator", "continue");
});
it("should pause an agent", async () => {
const result = await controller.pauseAgent(agentId, {}, { apiKey: "ops-user" });
expect(controlService.pauseAgent).toHaveBeenCalledWith(agentId, "ops-user");
expect(result).toEqual({ message: `Agent ${agentId} paused` });
});
it("should resume an agent", async () => {
const result = await controller.resumeAgent(agentId, {}, { apiKey: "ops-user" });
expect(controlService.resumeAgent).toHaveBeenCalledWith(agentId, "ops-user");
expect(result).toEqual({ message: `Agent ${agentId} resumed` });
});
});
describe("getAgentMessages", () => {
it("should return paginated message history", async () => {
const agentId = "0b64079f-4487-42b9-92eb-cf8ea0042a64";
const query = {
limit: 25,
skip: 10,
};
const response = {
messages: [
{
id: "msg-1",
sessionId: agentId,
role: "agent",
content: "hello",
provider: "internal",
timestamp: new Date("2026-03-07T03:00:00.000Z"),
metadata: {},
},
],
total: 101,
};
messagesService.getMessages.mockResolvedValue(response);
const result = await controller.getAgentMessages(agentId, query);
expect(messagesService.getMessages).toHaveBeenCalledWith(agentId, 25, 10);
expect(result).toEqual(response);
});
it("should use default pagination values", async () => {
const agentId = "0b64079f-4487-42b9-92eb-cf8ea0042a64";
const query = {
limit: 50,
skip: 0,
};
messagesService.getMessages.mockResolvedValue({ messages: [], total: 0 });
await controller.getAgentMessages(agentId, query);
expect(messagesService.getMessages).toHaveBeenCalledWith(agentId, 50, 0);
});
});
describe("getRecentEvents", () => {
it("should return recent events with default limit", () => {
eventsService.getRecentEvents.mockReturnValue([

View File

@@ -14,9 +14,7 @@ import {
Sse,
MessageEvent,
Query,
Request,
} from "@nestjs/common";
import type { AgentConversationMessage } from "@prisma/client";
import { Throttle } from "@nestjs/throttler";
import { Observable } from "rxjs";
import { QueueService } from "../../queue/queue.service";
@@ -27,13 +25,6 @@ import { SpawnAgentDto, SpawnAgentResponseDto } from "./dto/spawn-agent.dto";
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
import { OrchestratorThrottlerGuard } from "../../common/guards/throttler.guard";
import { AgentEventsService } from "./agent-events.service";
import { GetMessagesQueryDto } from "./dto/get-messages-query.dto";
import { AgentMessagesService } from "./agent-messages.service";
import { AgentControlService } from "./agent-control.service";
import { AgentTreeService } from "./agent-tree.service";
import { AgentTreeResponseDto } from "./dto/agent-tree-response.dto";
import { InjectAgentDto } from "./dto/inject-agent.dto";
import { PauseAgentDto, ResumeAgentDto } from "./dto/control-agent.dto";
/**
* Controller for agent management endpoints
@@ -56,10 +47,7 @@ export class AgentsController {
private readonly spawnerService: AgentSpawnerService,
private readonly lifecycleService: AgentLifecycleService,
private readonly killswitchService: KillswitchService,
private readonly eventsService: AgentEventsService,
private readonly messagesService: AgentMessagesService,
private readonly agentControlService: AgentControlService,
private readonly agentTreeService: AgentTreeService
private readonly eventsService: AgentEventsService
) {}
/**
@@ -81,7 +69,6 @@ export class AgentsController {
// Spawn agent using spawner service
const spawnResponse = this.spawnerService.spawnAgent({
taskId: dto.taskId,
...(dto.parentAgentId !== undefined ? { parentAgentId: dto.parentAgentId } : {}),
agentType: dto.agentType,
context: dto.context,
});
@@ -156,13 +143,6 @@ export class AgentsController {
};
}
@Get("tree")
@UseGuards(OrchestratorApiKeyGuard)
@Throttle({ default: { limit: 200, ttl: 60000 } })
async getAgentTree(): Promise<AgentTreeResponseDto[]> {
return this.agentTreeService.getTree();
}
/**
* List all agents
* @returns Array of all agent sessions with their status
@@ -205,107 +185,6 @@ export class AgentsController {
}
}
/**
* Get paginated message history for an agent.
*/
@Get(":agentId/messages")
@Throttle({ status: { limit: 200, ttl: 60000 } })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getAgentMessages(
@Param("agentId", ParseUUIDPipe) agentId: string,
@Query() query: GetMessagesQueryDto
): Promise<{
messages: AgentConversationMessage[];
total: number;
}> {
return this.messagesService.getMessages(agentId, query.limit, query.skip);
}
/**
* Stream per-agent conversation messages as server-sent events (SSE).
*/
@Sse(":agentId/messages/stream")
@Throttle({ status: { limit: 200, ttl: 60000 } })
streamAgentMessages(@Param("agentId", ParseUUIDPipe) agentId: string): Observable<MessageEvent> {
return new Observable<MessageEvent>((subscriber) => {
let isClosed = false;
let lastSeenTimestamp = new Date();
let lastSeenMessageId: string | null = null;
const emitMessage = (message: AgentConversationMessage): void => {
if (isClosed) {
return;
}
subscriber.next({
data: this.toMessageStreamPayload(message),
});
lastSeenTimestamp = message.timestamp;
lastSeenMessageId = message.id;
};
void this.messagesService
.getReplayMessages(agentId, 50)
.then((messages) => {
if (isClosed) {
return;
}
messages.forEach((message) => {
emitMessage(message);
});
if (messages.length === 0) {
lastSeenTimestamp = new Date();
lastSeenMessageId = null;
}
})
.catch((error: unknown) => {
this.logger.error(
`Failed to load replay messages for ${agentId}: ${error instanceof Error ? error.message : String(error)}`
);
lastSeenTimestamp = new Date();
lastSeenMessageId = null;
});
const pollInterval = setInterval(() => {
if (isClosed) {
return;
}
void this.messagesService
.getMessagesAfter(agentId, lastSeenTimestamp, lastSeenMessageId)
.then((messages) => {
if (isClosed || messages.length === 0) {
return;
}
messages.forEach((message) => {
emitMessage(message);
});
})
.catch((error: unknown) => {
this.logger.error(
`Failed to poll messages for ${agentId}: ${error instanceof Error ? error.message : String(error)}`
);
});
}, 1000);
const heartbeat = setInterval(() => {
if (!isClosed) {
subscriber.next({ data: { type: "heartbeat" } });
}
}, 15000);
return () => {
isClosed = true;
clearInterval(pollInterval);
clearInterval(heartbeat);
};
});
}
/**
* Get agent status
* @param agentId Agent ID to query
@@ -390,57 +269,6 @@ export class AgentsController {
}
}
@Post(":agentId/inject")
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(200)
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async injectAgentMessage(
@Param("agentId", ParseUUIDPipe) agentId: string,
@Body() dto: InjectAgentDto,
@Request() req: { apiKey?: string }
): Promise<{ message: string }> {
const operatorId = req.apiKey ?? "operator";
await this.agentControlService.injectMessage(agentId, operatorId, dto.message);
return {
message: `Message injected into agent ${agentId}`,
};
}
@Post(":agentId/pause")
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(200)
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async pauseAgent(
@Param("agentId", ParseUUIDPipe) agentId: string,
@Body() _dto: PauseAgentDto,
@Request() req: { apiKey?: string }
): Promise<{ message: string }> {
const operatorId = req.apiKey ?? "operator";
await this.agentControlService.pauseAgent(agentId, operatorId);
return {
message: `Agent ${agentId} paused`,
};
}
@Post(":agentId/resume")
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(200)
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async resumeAgent(
@Param("agentId", ParseUUIDPipe) agentId: string,
@Body() _dto: ResumeAgentDto,
@Request() req: { apiKey?: string }
): Promise<{ message: string }> {
const operatorId = req.apiKey ?? "operator";
await this.agentControlService.resumeAgent(agentId, operatorId);
return {
message: `Agent ${agentId} resumed`,
};
}
/**
* Kill all active agents
* @returns Summary of kill operation
@@ -473,24 +301,4 @@ export class AgentsController {
throw error;
}
}
private toMessageStreamPayload(message: AgentConversationMessage): {
messageId: string;
sessionId: string;
role: string;
content: string;
provider: string;
timestamp: string;
metadata: unknown;
} {
return {
messageId: message.id,
sessionId: message.sessionId,
role: message.role,
content: message.content,
provider: message.provider,
timestamp: message.timestamp.toISOString(),
metadata: message.metadata,
};
}
}

View File

@@ -6,25 +6,10 @@ import { KillswitchModule } from "../../killswitch/killswitch.module";
import { ValkeyModule } from "../../valkey/valkey.module";
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
import { AgentEventsService } from "./agent-events.service";
import { PrismaModule } from "../../prisma/prisma.module";
import { AgentMessagesService } from "./agent-messages.service";
import { AgentControlService } from "./agent-control.service";
import { AgentTreeService } from "./agent-tree.service";
import { InternalAgentProvider } from "./internal-agent.provider";
import { AgentProviderRegistry } from "./agent-provider.registry";
@Module({
imports: [QueueModule, SpawnerModule, KillswitchModule, ValkeyModule, PrismaModule],
imports: [QueueModule, SpawnerModule, KillswitchModule, ValkeyModule],
controllers: [AgentsController],
providers: [
OrchestratorApiKeyGuard,
AgentEventsService,
AgentMessagesService,
AgentControlService,
AgentTreeService,
InternalAgentProvider,
AgentProviderRegistry,
],
exports: [InternalAgentProvider, AgentProviderRegistry],
providers: [OrchestratorApiKeyGuard, AgentEventsService],
})
export class AgentsModule {}

View File

@@ -1,9 +0,0 @@
export class AgentTreeResponseDto {
sessionId!: string;
parentSessionId!: string | null;
status!: string;
agentType!: string | null;
taskSource!: string | null;
spawnedAt!: string;
completedAt!: string | null;
}

View File

@@ -1,3 +0,0 @@
export class PauseAgentDto {}
export class ResumeAgentDto {}

Some files were not shown because too many files have changed in this diff Show More