Compare commits
1 Commits
fix/ms23-o
...
fix/csrf-b
| Author | SHA1 | Date | |
|---|---|---|---|
| 97b14edbaa |
@@ -343,11 +343,6 @@ RATE_LIMIT_STORAGE=redis
|
|||||||
# DISCORD_CONTROL_CHANNEL_ID=channel-id-for-commands
|
# DISCORD_CONTROL_CHANNEL_ID=channel-id-for-commands
|
||||||
# DISCORD_WORKSPACE_ID=your-workspace-uuid
|
# DISCORD_WORKSPACE_ID=your-workspace-uuid
|
||||||
#
|
#
|
||||||
# Agent channel routing: Maps Discord channels to specific agents.
|
|
||||||
# Format: <channelId>:<agentName>,<channelId>:<agentName>
|
|
||||||
# Example: 123456789:jarvis,987654321:builder
|
|
||||||
# DISCORD_AGENT_CHANNELS=
|
|
||||||
#
|
|
||||||
# SECURITY: DISCORD_WORKSPACE_ID must be a valid workspace UUID from your database.
|
# SECURITY: DISCORD_WORKSPACE_ID must be a valid workspace UUID from your database.
|
||||||
# All Discord commands will execute within this workspace context for proper
|
# All Discord commands will execute within this workspace context for proper
|
||||||
# multi-tenant isolation. Each Discord bot instance should be configured for
|
# multi-tenant isolation. Each Discord bot instance should be configured for
|
||||||
|
|||||||
@@ -1,56 +1,56 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"mission_id": "ms22-p2-named-agent-fleet-20260304",
|
"mission_id": "ms21-multi-tenant-rbac-data-migration-20260228",
|
||||||
"name": "MS22-P2 Named Agent Fleet",
|
"name": "MS21 Multi-Tenant RBAC Data Migration",
|
||||||
"description": "",
|
"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",
|
"project_path": "/home/jwoltje/src/mosaic-stack",
|
||||||
"created_at": "2026-03-05T01:53:28Z",
|
"created_at": "2026-02-28T17:10:22Z",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"task_prefix": "",
|
"task_prefix": "MS21",
|
||||||
"quality_gates": "",
|
"quality_gates": "pnpm lint && pnpm build && pnpm test",
|
||||||
"milestone_version": "0.0.1",
|
"milestone_version": "0.0.21",
|
||||||
"milestones": [
|
"milestones": [
|
||||||
{
|
{
|
||||||
"id": "phase-1",
|
"id": "phase-1",
|
||||||
"name": "Schema+Seed",
|
"name": "Schema and Admin API",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"branch": "schema-seed",
|
"branch": "schema-and-admin-api",
|
||||||
"issue_ref": "",
|
"issue_ref": "",
|
||||||
"started_at": "",
|
"started_at": "",
|
||||||
"completed_at": ""
|
"completed_at": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "phase-2",
|
"id": "phase-2",
|
||||||
"name": "Admin CRUD",
|
"name": "Break-Glass Authentication",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"branch": "admin-crud",
|
"branch": "break-glass-authentication",
|
||||||
"issue_ref": "",
|
"issue_ref": "",
|
||||||
"started_at": "",
|
"started_at": "",
|
||||||
"completed_at": ""
|
"completed_at": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "phase-3",
|
"id": "phase-3",
|
||||||
"name": "User CRUD",
|
"name": "Data Migration",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"branch": "user-crud",
|
"branch": "data-migration",
|
||||||
"issue_ref": "",
|
"issue_ref": "",
|
||||||
"started_at": "",
|
"started_at": "",
|
||||||
"completed_at": ""
|
"completed_at": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "phase-4",
|
"id": "phase-4",
|
||||||
"name": "Agent Routing",
|
"name": "Admin UI",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"branch": "agent-routing",
|
"branch": "admin-ui",
|
||||||
"issue_ref": "",
|
"issue_ref": "",
|
||||||
"started_at": "",
|
"started_at": "",
|
||||||
"completed_at": ""
|
"completed_at": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "phase-5",
|
"id": "phase-5",
|
||||||
"name": "Discord+UI",
|
"name": "RBAC UI Enforcement",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"branch": "discord-ui",
|
"branch": "rbac-ui-enforcement",
|
||||||
"issue_ref": "",
|
"issue_ref": "",
|
||||||
"started_at": "",
|
"started_at": "",
|
||||||
"completed_at": ""
|
"completed_at": ""
|
||||||
@@ -65,5 +65,26 @@
|
|||||||
"completed_at": ""
|
"completed_at": ""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"sessions": []
|
"sessions": [
|
||||||
|
{
|
||||||
|
"session_id": "sess-001",
|
||||||
|
"runtime": "unknown",
|
||||||
|
"started_at": "2026-02-28T17:48:51Z",
|
||||||
|
"ended_at": "",
|
||||||
|
"ended_reason": "",
|
||||||
|
"milestone_at_end": "",
|
||||||
|
"tasks_completed": [],
|
||||||
|
"last_task_id": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "sess-002",
|
||||||
|
"runtime": "unknown",
|
||||||
|
"started_at": "2026-02-28T20:30:13Z",
|
||||||
|
"ended_at": "",
|
||||||
|
"ended_reason": "",
|
||||||
|
"milestone_at_end": "",
|
||||||
|
"tasks_completed": [],
|
||||||
|
"last_task_id": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
8
.mosaic/orchestrator/session.lock
Normal file
8
.mosaic/orchestrator/session.lock
Normal 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
2
.npmrc
@@ -1,3 +1 @@
|
|||||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
|
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
|
||||||
supportedArchitectures[libc][]=glibc
|
|
||||||
supportedArchitectures[cpu][]=x64
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -29,11 +29,9 @@ when:
|
|||||||
- ".trivyignore"
|
- ".trivyignore"
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
- &node_image "node:24-slim"
|
- &node_image "node:24-alpine"
|
||||||
- &install_deps |
|
- &install_deps |
|
||||||
corepack enable
|
corepack enable
|
||||||
apt-get update && apt-get install -y --no-install-recommends python3 make g++
|
|
||||||
pnpm config set store-dir /root/.local/share/pnpm/store
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
- &use_deps |
|
- &use_deps |
|
||||||
corepack enable
|
corepack enable
|
||||||
@@ -170,7 +168,7 @@ steps:
|
|||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-api/cache $DESTINATIONS
|
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
@@ -195,7 +193,7 @@ steps:
|
|||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-orchestrator/cache $DESTINATIONS
|
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
@@ -220,7 +218,7 @@ steps:
|
|||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --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:
|
when:
|
||||||
- branch: [main]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
@@ -337,47 +335,3 @@ steps:
|
|||||||
- security-trivy-api
|
- security-trivy-api
|
||||||
- security-trivy-orchestrator
|
- security-trivy-orchestrator
|
||||||
- security-trivy-web
|
- security-trivy-web
|
||||||
|
|
||||||
# ─── Deploy to Docker Swarm via Portainer API (main only) ─────────────────────
|
|
||||||
|
|
||||||
deploy-swarm:
|
|
||||||
image: alpine:3
|
|
||||||
failure: ignore
|
|
||||||
environment:
|
|
||||||
PORTAINER_URL:
|
|
||||||
from_secret: portainer_url
|
|
||||||
PORTAINER_API_KEY:
|
|
||||||
from_secret: portainer_api_key
|
|
||||||
PORTAINER_STACK_ID: "121"
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache curl
|
|
||||||
- |
|
|
||||||
set -e
|
|
||||||
echo "🚀 Deploying to Docker Swarm via Portainer API..."
|
|
||||||
|
|
||||||
# Use Portainer API to update the stack (forces pull of new images)
|
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
|
||||||
-H "X-API-Key: $PORTAINER_API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PORTAINER_URL/api/stacks/$PORTAINER_STACK_ID/git/redeploy")
|
|
||||||
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
|
||||||
BODY=$(echo "$RESPONSE" | head -n -1)
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "202" ]; then
|
|
||||||
echo "✅ Stack update triggered successfully"
|
|
||||||
else
|
|
||||||
echo "❌ Stack update failed (HTTP $HTTP_CODE)"
|
|
||||||
echo "$BODY"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wait for services to converge
|
|
||||||
echo "⏳ Waiting for services to converge..."
|
|
||||||
sleep 30
|
|
||||||
echo "✅ Deploy complete"
|
|
||||||
when:
|
|
||||||
- branch: [main]
|
|
||||||
event: [push, manual, tag]
|
|
||||||
depends_on:
|
|
||||||
- link-packages
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Base image for all stages
|
# Base image for all stages
|
||||||
# Uses Debian slim (glibc) instead of Alpine (musl) because native Node.js addons
|
# Uses Debian slim (glibc) instead of Alpine (musl) because native Node.js addons
|
||||||
# (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries.
|
# (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries.
|
||||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
|
FROM node:24-slim AS base
|
||||||
|
|
||||||
# Install pnpm globally
|
# Install pnpm globally
|
||||||
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
||||||
@@ -19,9 +19,9 @@ COPY turbo.json ./
|
|||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|
||||||
# Install build tools for native addons (node-pty requires node-gyp compilation)
|
# Install build tools for native addons (node-pty requires node-gyp compilation)
|
||||||
# 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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
python3 make g++ \
|
python3 make g++ openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy all package.json files for workspace resolution
|
# Copy all package.json files for workspace resolution
|
||||||
@@ -30,9 +30,6 @@ COPY packages/ui/package.json ./packages/ui/
|
|||||||
COPY packages/config/package.json ./packages/config/
|
COPY packages/config/package.json ./packages/config/
|
||||||
COPY apps/api/package.json ./apps/api/
|
COPY apps/api/package.json ./apps/api/
|
||||||
|
|
||||||
# Copy npm configuration for native binary architecture hints
|
|
||||||
COPY .npmrc ./
|
|
||||||
|
|
||||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
||||||
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
|
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
|
||||||
# scripts or fail to find prebuilt binaries for this Node.js version
|
# scripts or fail to find prebuilt binaries for this Node.js version
|
||||||
@@ -64,14 +61,19 @@ RUN pnpm turbo build --filter=@mosaic/api --force
|
|||||||
# ======================
|
# ======================
|
||||||
# Production stage
|
# 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)
|
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||||
# - Remove npm/npx to reduce image size (not used in production)
|
# - openssl: Prisma engine detection requires libssl
|
||||||
# - Create non-root user
|
# - No build tools needed here — native addons are compiled in the deps stage
|
||||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
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
|
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -62,7 +62,6 @@
|
|||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dockerode": "^4.0.9",
|
"dockerode": "^4.0.9",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"ioredis": "^5.9.2",
|
"ioredis": "^5.9.2",
|
||||||
"jose": "^6.1.3",
|
"jose": "^6.1.3",
|
||||||
|
|||||||
@@ -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");
|
|
||||||
@@ -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");
|
|
||||||
@@ -1703,102 +1703,3 @@ model UserAgentConfig {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model AgentTemplate {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String @unique // "jarvis", "builder", "medic"
|
|
||||||
displayName String // "Jarvis", "Builder", "Medic"
|
|
||||||
role String // "orchestrator" | "coding" | "monitoring"
|
|
||||||
personality String // SOUL.md content (markdown)
|
|
||||||
primaryModel String // "opus", "codex", "haiku"
|
|
||||||
fallbackModels Json @default("[]") // ["sonnet", "haiku"]
|
|
||||||
toolPermissions Json @default("[]") // ["exec", "read", "write", ...]
|
|
||||||
discordChannel String? // "jarvis", "builder", "medic-alerts"
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
isDefault Boolean @default(false) // Include in new user provisioning
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model UserAgent {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String
|
|
||||||
templateId String? // null = custom agent
|
|
||||||
name String // "jarvis", "builder", "medic" or custom
|
|
||||||
displayName String
|
|
||||||
role String
|
|
||||||
personality String // User can customize
|
|
||||||
primaryModel String?
|
|
||||||
fallbackModels Json @default("[]")
|
|
||||||
toolPermissions Json @default("[]")
|
|
||||||
discordChannel String?
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([userId, name])
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
EntryStatus,
|
EntryStatus,
|
||||||
Visibility,
|
Visibility,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
import { seedAgentTemplates } from "../src/seed/agent-templates.seed";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -587,9 +586,6 @@ This is a draft document. See [[architecture-overview]] for current state.`,
|
|||||||
|
|
||||||
console.log(`Created ${links.length} knowledge links`);
|
console.log(`Created ${links.length} knowledge links`);
|
||||||
});
|
});
|
||||||
// Seed default agent templates (idempotent)
|
|
||||||
await seedAgentTemplates(prisma);
|
|
||||||
|
|
||||||
console.log("Seeding completed successfully!");
|
console.log("Seeding completed successfully!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller, Get, Query, Param, UseGuards } from "@nestjs/common";
|
import { Controller, Get, Query, Param, UseGuards } from "@nestjs/common";
|
||||||
import { ActivityService } from "./activity.service";
|
import { ActivityService } from "./activity.service";
|
||||||
import { EntityType } from "@prisma/client";
|
import { EntityType } from "@prisma/client";
|
||||||
import { QueryActivityLogDto } from "./dto";
|
import type { QueryActivityLogDto } from "./dto";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||||
|
|||||||
@@ -117,13 +117,12 @@ export class ActivityService {
|
|||||||
/**
|
/**
|
||||||
* Get a single activity log by ID
|
* Get a single activity log by ID
|
||||||
*/
|
*/
|
||||||
async findOne(id: string, workspaceId?: string): Promise<ActivityLogResult | null> {
|
async findOne(id: string, workspaceId: string): Promise<ActivityLogResult | null> {
|
||||||
const where: Prisma.ActivityLogWhereUniqueInput = { id };
|
|
||||||
if (workspaceId) {
|
|
||||||
where.workspaceId = workspaceId;
|
|
||||||
}
|
|
||||||
return await this.prisma.activityLog.findUnique({
|
return await this.prisma.activityLog.findUnique({
|
||||||
where,
|
where: {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -384,18 +384,10 @@ describe("ActivityLoggingInterceptor", () => {
|
|||||||
const context = createMockExecutionContext("POST", {}, body, user);
|
const context = createMockExecutionContext("POST", {}, body, user);
|
||||||
const next = createMockCallHandler(result);
|
const next = createMockCallHandler(result);
|
||||||
|
|
||||||
mockActivityService.logActivity.mockResolvedValue({
|
|
||||||
id: "activity-123",
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
interceptor.intercept(context, next).subscribe(() => {
|
interceptor.intercept(context, next).subscribe(() => {
|
||||||
// workspaceId is now optional, so logActivity should be called without it
|
// Should not call logActivity when workspaceId is missing
|
||||||
expect(mockActivityService.logActivity).toHaveBeenCalled();
|
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||||
const callArgs = mockActivityService.logActivity.mock.calls[0][0];
|
|
||||||
expect(callArgs.userId).toBe("user-123");
|
|
||||||
expect(callArgs.entityId).toBe("task-123");
|
|
||||||
expect(callArgs.workspaceId).toBeUndefined();
|
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -420,18 +412,10 @@ describe("ActivityLoggingInterceptor", () => {
|
|||||||
const context = createMockExecutionContext("POST", {}, body, user);
|
const context = createMockExecutionContext("POST", {}, body, user);
|
||||||
const next = createMockCallHandler(result);
|
const next = createMockCallHandler(result);
|
||||||
|
|
||||||
mockActivityService.logActivity.mockResolvedValue({
|
|
||||||
id: "activity-123",
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
interceptor.intercept(context, next).subscribe(() => {
|
interceptor.intercept(context, next).subscribe(() => {
|
||||||
// workspaceId is now optional, so logActivity should be called without it
|
// Should not call logActivity when workspaceId is missing
|
||||||
expect(mockActivityService.logActivity).toHaveBeenCalled();
|
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||||
const callArgs = mockActivityService.logActivity.mock.calls[0][0];
|
|
||||||
expect(callArgs.userId).toBe("user-123");
|
|
||||||
expect(callArgs.entityId).toBe("task-123");
|
|
||||||
expect(callArgs.workspaceId).toBeUndefined();
|
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { tap } from "rxjs/operators";
|
|||||||
import { ActivityService } from "../activity.service";
|
import { ActivityService } from "../activity.service";
|
||||||
import { ActivityAction, EntityType } from "@prisma/client";
|
import { ActivityAction, EntityType } from "@prisma/client";
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import type { CreateActivityLogInput } from "../interfaces/activity.interface";
|
|
||||||
import type { AuthenticatedRequest } from "../../common/types/user.types";
|
import type { AuthenticatedRequest } from "../../common/types/user.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,13 +61,10 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
|
|||||||
// Extract entity information
|
// Extract entity information
|
||||||
const resultObj = result as Record<string, unknown> | undefined;
|
const resultObj = result as Record<string, unknown> | undefined;
|
||||||
const entityId = params.id ?? (resultObj?.id as string | undefined);
|
const entityId = params.id ?? (resultObj?.id as string | undefined);
|
||||||
|
|
||||||
// workspaceId is now optional - log events even when missing
|
|
||||||
const workspaceId = user.workspaceId ?? (body.workspaceId as string | undefined);
|
const workspaceId = user.workspaceId ?? (body.workspaceId as string | undefined);
|
||||||
|
|
||||||
// Log with warning if entityId is missing, but still proceed with logging if workspaceId exists
|
if (!entityId || !workspaceId) {
|
||||||
if (!entityId) {
|
this.logger.warn("Cannot log activity: missing entityId or workspaceId");
|
||||||
this.logger.warn("Cannot log activity: missing entityId");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +92,9 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
|
|||||||
const userAgent =
|
const userAgent =
|
||||||
typeof userAgentHeader === "string" ? userAgentHeader : userAgentHeader?.[0];
|
typeof userAgentHeader === "string" ? userAgentHeader : userAgentHeader?.[0];
|
||||||
|
|
||||||
// Log the activity — workspaceId is optional
|
// Log the activity
|
||||||
const activityInput: CreateActivityLogInput = {
|
await this.activityService.logActivity({
|
||||||
|
workspaceId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
action,
|
action,
|
||||||
entityType,
|
entityType,
|
||||||
@@ -105,11 +102,7 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
|
|||||||
details,
|
details,
|
||||||
ipAddress: ip ?? undefined,
|
ipAddress: ip ?? undefined,
|
||||||
userAgent: userAgent ?? undefined,
|
userAgent: userAgent ?? undefined,
|
||||||
};
|
});
|
||||||
if (workspaceId) {
|
|
||||||
activityInput.workspaceId = workspaceId;
|
|
||||||
}
|
|
||||||
await this.activityService.logActivity(activityInput);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Don't fail the request if activity logging fails
|
// Don't fail the request if activity logging fails
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import type { ActivityAction, EntityType, Prisma } from "@prisma/client";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for creating a new activity log entry
|
* Interface for creating a new activity log entry
|
||||||
* workspaceId is optional - allows logging events without workspace context
|
|
||||||
*/
|
*/
|
||||||
export interface CreateActivityLogInput {
|
export interface CreateActivityLogInput {
|
||||||
workspaceId?: string | null;
|
workspaceId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
action: ActivityAction;
|
action: ActivityAction;
|
||||||
entityType: EntityType;
|
entityType: EntityType;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import type { LlmProvider } from "@prisma/client";
|
import type { LlmProvider } from "@prisma/client";
|
||||||
import { createHash, timingSafeEqual } from "node:crypto";
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { CryptoService } from "../crypto/crypto.service";
|
import { CryptoService } from "../crypto/crypto.service";
|
||||||
|
|
||||||
@@ -143,23 +143,21 @@ export class AgentConfigService {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let match: ContainerTokenValidation | null = null;
|
|
||||||
|
|
||||||
for (const container of userContainers) {
|
for (const container of userContainers) {
|
||||||
const storedToken = this.decryptContainerToken(container.gatewayToken);
|
const storedToken = this.decryptContainerToken(container.gatewayToken);
|
||||||
if (!match && storedToken && this.tokensEqual(storedToken, token)) {
|
if (storedToken && this.tokensEqual(storedToken, token)) {
|
||||||
match = { type: "user", id: container.id };
|
return { type: "user", id: container.id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const container of systemContainers) {
|
for (const container of systemContainers) {
|
||||||
const storedToken = this.decryptContainerToken(container.gatewayToken);
|
const storedToken = this.decryptContainerToken(container.gatewayToken);
|
||||||
if (!match && storedToken && this.tokensEqual(storedToken, token)) {
|
if (storedToken && this.tokensEqual(storedToken, token)) {
|
||||||
match = { type: "system", id: container.id };
|
return { type: "system", id: container.id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return match;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildOpenClawConfig(
|
private buildOpenClawConfig(
|
||||||
@@ -270,9 +268,14 @@ export class AgentConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private tokensEqual(left: string, right: string): boolean {
|
private tokensEqual(left: string, right: string): boolean {
|
||||||
const leftDigest = createHash("sha256").update(left, "utf8").digest();
|
const leftBuffer = Buffer.from(left, "utf8");
|
||||||
const rightDigest = createHash("sha256").update(right, "utf8").digest();
|
const rightBuffer = Buffer.from(right, "utf8");
|
||||||
return timingSafeEqual(leftDigest, rightDigest);
|
|
||||||
|
if (leftBuffer.length !== rightBuffer.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(leftBuffer, rightBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasModelId(modelEntry: unknown): modelEntry is { id: string } {
|
private hasModelId(modelEntry: unknown): modelEntry is { id: string } {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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 } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { PartialType } from "@nestjs/mapped-types";
|
|
||||||
import { CreateAgentTemplateDto } from "./create-agent-template.dto";
|
|
||||||
|
|
||||||
export class UpdateAgentTemplateDto extends PartialType(CreateAgentTemplateDto) {}
|
|
||||||
@@ -48,8 +48,6 @@ import { TerminalModule } from "./terminal/terminal.module";
|
|||||||
import { PersonalitiesModule } from "./personalities/personalities.module";
|
import { PersonalitiesModule } from "./personalities/personalities.module";
|
||||||
import { WorkspacesModule } from "./workspaces/workspaces.module";
|
import { WorkspacesModule } from "./workspaces/workspaces.module";
|
||||||
import { AdminModule } from "./admin/admin.module";
|
import { AdminModule } from "./admin/admin.module";
|
||||||
import { AgentTemplateModule } from "./agent-template/agent-template.module";
|
|
||||||
import { UserAgentModule } from "./user-agent/user-agent.module";
|
|
||||||
import { TeamsModule } from "./teams/teams.module";
|
import { TeamsModule } from "./teams/teams.module";
|
||||||
import { ImportModule } from "./import/import.module";
|
import { ImportModule } from "./import/import.module";
|
||||||
import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module";
|
import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module";
|
||||||
@@ -60,7 +58,6 @@ import { ContainerReaperModule } from "./container-reaper/container-reaper.modul
|
|||||||
import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module";
|
import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module";
|
||||||
import { OnboardingModule } from "./onboarding/onboarding.module";
|
import { OnboardingModule } from "./onboarding/onboarding.module";
|
||||||
import { ChatProxyModule } from "./chat-proxy/chat-proxy.module";
|
import { ChatProxyModule } from "./chat-proxy/chat-proxy.module";
|
||||||
import { OrchestratorModule } from "./orchestrator/orchestrator.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -131,8 +128,6 @@ import { OrchestratorModule } from "./orchestrator/orchestrator.module";
|
|||||||
PersonalitiesModule,
|
PersonalitiesModule,
|
||||||
WorkspacesModule,
|
WorkspacesModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AgentTemplateModule,
|
|
||||||
UserAgentModule,
|
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
ConversationArchiveModule,
|
ConversationArchiveModule,
|
||||||
@@ -142,7 +137,6 @@ import { OrchestratorModule } from "./orchestrator/orchestrator.module";
|
|||||||
FleetSettingsModule,
|
FleetSettingsModule,
|
||||||
OnboardingModule,
|
OnboardingModule,
|
||||||
ChatProxyModule,
|
ChatProxyModule,
|
||||||
OrchestratorModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export class AuthController {
|
|||||||
// @SkipCsrf avoids double-protection conflicts.
|
// @SkipCsrf avoids double-protection conflicts.
|
||||||
// See: https://www.better-auth.com/docs/reference/security
|
// See: https://www.better-auth.com/docs/reference/security
|
||||||
@SkipCsrf()
|
@SkipCsrf()
|
||||||
@Throttle({ default: { ttl: 60_000, limit: 5 } })
|
@Throttle({ strict: { limit: 10, ttl: 60000 } })
|
||||||
async handleAuth(@Req() req: ExpressRequest, @Res() res: ExpressResponse): Promise<void> {
|
async handleAuth(@Req() req: ExpressRequest, @Res() res: ExpressResponse): Promise<void> {
|
||||||
// Extract client IP for logging
|
// Extract client IP for logging
|
||||||
const clientIp = this.getClientIp(req);
|
const clientIp = this.getClientIp(req);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { MatrixService } from "./matrix/matrix.service";
|
|||||||
import { StitcherService } from "../stitcher/stitcher.service";
|
import { StitcherService } from "../stitcher/stitcher.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { BullMqService } from "../bullmq/bullmq.service";
|
import { BullMqService } from "../bullmq/bullmq.service";
|
||||||
import { ChatProxyService } from "../chat-proxy/chat-proxy.service";
|
|
||||||
import { CHAT_PROVIDERS } from "./bridge.constants";
|
import { CHAT_PROVIDERS } from "./bridge.constants";
|
||||||
import type { IChatProvider } from "./interfaces";
|
import type { IChatProvider } from "./interfaces";
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
@@ -90,7 +89,6 @@ interface SavedEnvVars {
|
|||||||
MATRIX_CONTROL_ROOM_ID?: string;
|
MATRIX_CONTROL_ROOM_ID?: string;
|
||||||
MATRIX_WORKSPACE_ID?: string;
|
MATRIX_WORKSPACE_ID?: string;
|
||||||
ENCRYPTION_KEY?: string;
|
ENCRYPTION_KEY?: string;
|
||||||
MOSAIC_SECRET_KEY?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("BridgeModule", () => {
|
describe("BridgeModule", () => {
|
||||||
@@ -108,7 +106,6 @@ describe("BridgeModule", () => {
|
|||||||
MATRIX_CONTROL_ROOM_ID: process.env.MATRIX_CONTROL_ROOM_ID,
|
MATRIX_CONTROL_ROOM_ID: process.env.MATRIX_CONTROL_ROOM_ID,
|
||||||
MATRIX_WORKSPACE_ID: process.env.MATRIX_WORKSPACE_ID,
|
MATRIX_WORKSPACE_ID: process.env.MATRIX_WORKSPACE_ID,
|
||||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||||
MOSAIC_SECRET_KEY: process.env.MOSAIC_SECRET_KEY,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clear all bridge env vars
|
// Clear all bridge env vars
|
||||||
@@ -123,8 +120,6 @@ describe("BridgeModule", () => {
|
|||||||
|
|
||||||
// Set encryption key (needed by StitcherService)
|
// Set encryption key (needed by StitcherService)
|
||||||
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
// Set MOSAIC_SECRET_KEY (needed by CryptoService via ChatProxyModule)
|
|
||||||
process.env.MOSAIC_SECRET_KEY = "test-mosaic-secret-key-minimum-32-characters-long";
|
|
||||||
|
|
||||||
// Clear ready callbacks
|
// Clear ready callbacks
|
||||||
mockReadyCallbacks.length = 0;
|
mockReadyCallbacks.length = 0;
|
||||||
@@ -154,10 +149,6 @@ describe("BridgeModule", () => {
|
|||||||
.useValue({})
|
.useValue({})
|
||||||
.overrideProvider(BullMqService)
|
.overrideProvider(BullMqService)
|
||||||
.useValue({})
|
.useValue({})
|
||||||
.overrideProvider(ChatProxyService)
|
|
||||||
.useValue({
|
|
||||||
proxyChat: vi.fn().mockResolvedValue(new Response()),
|
|
||||||
})
|
|
||||||
.compile();
|
.compile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { MatrixRoomService } from "./matrix/matrix-room.service";
|
|||||||
import { MatrixStreamingService } from "./matrix/matrix-streaming.service";
|
import { MatrixStreamingService } from "./matrix/matrix-streaming.service";
|
||||||
import { CommandParserService } from "./parser/command-parser.service";
|
import { CommandParserService } from "./parser/command-parser.service";
|
||||||
import { StitcherModule } from "../stitcher/stitcher.module";
|
import { StitcherModule } from "../stitcher/stitcher.module";
|
||||||
import { ChatProxyModule } from "../chat-proxy/chat-proxy.module";
|
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
|
||||||
import { CHAT_PROVIDERS } from "./bridge.constants";
|
import { CHAT_PROVIDERS } from "./bridge.constants";
|
||||||
import type { IChatProvider } from "./interfaces";
|
import type { IChatProvider } from "./interfaces";
|
||||||
|
|
||||||
@@ -30,7 +28,7 @@ const logger = new Logger("BridgeModule");
|
|||||||
* MatrixRoomService handles workspace-to-Matrix-room mapping.
|
* MatrixRoomService handles workspace-to-Matrix-room mapping.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [StitcherModule, ChatProxyModule, PrismaModule],
|
imports: [StitcherModule],
|
||||||
providers: [
|
providers: [
|
||||||
CommandParserService,
|
CommandParserService,
|
||||||
MatrixRoomService,
|
MatrixRoomService,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { DiscordService } from "./discord.service";
|
import { DiscordService } from "./discord.service";
|
||||||
import { StitcherService } from "../../stitcher/stitcher.service";
|
import { StitcherService } from "../../stitcher/stitcher.service";
|
||||||
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
|
|
||||||
import { PrismaService } from "../../prisma/prisma.service";
|
|
||||||
import { Client, Events, GatewayIntentBits, Message } from "discord.js";
|
import { Client, Events, GatewayIntentBits, Message } from "discord.js";
|
||||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||||
import type { ChatMessage, ChatCommand } from "../interfaces";
|
import type { ChatMessage, ChatCommand } from "../interfaces";
|
||||||
@@ -63,8 +61,6 @@ vi.mock("discord.js", () => {
|
|||||||
describe("DiscordService", () => {
|
describe("DiscordService", () => {
|
||||||
let service: DiscordService;
|
let service: DiscordService;
|
||||||
let stitcherService: StitcherService;
|
let stitcherService: StitcherService;
|
||||||
let chatProxyService: ChatProxyService;
|
|
||||||
let prismaService: PrismaService;
|
|
||||||
|
|
||||||
const mockStitcherService = {
|
const mockStitcherService = {
|
||||||
dispatchJob: vi.fn().mockResolvedValue({
|
dispatchJob: vi.fn().mockResolvedValue({
|
||||||
@@ -75,29 +71,12 @@ describe("DiscordService", () => {
|
|||||||
trackJobEvent: vi.fn().mockResolvedValue(undefined),
|
trackJobEvent: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockChatProxyService = {
|
|
||||||
proxyChat: vi.fn().mockResolvedValue(
|
|
||||||
new Response('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\ndata: [DONE]\n\n', {
|
|
||||||
headers: { "Content-Type": "text/event-stream" },
|
|
||||||
})
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockPrismaService = {
|
|
||||||
workspace: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
|
||||||
ownerId: "owner-user-id",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Set environment variables for testing
|
// Set environment variables for testing
|
||||||
process.env.DISCORD_BOT_TOKEN = "test-token";
|
process.env.DISCORD_BOT_TOKEN = "test-token";
|
||||||
process.env.DISCORD_GUILD_ID = "test-guild-id";
|
process.env.DISCORD_GUILD_ID = "test-guild-id";
|
||||||
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
|
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
|
||||||
process.env.DISCORD_WORKSPACE_ID = "test-workspace-id";
|
process.env.DISCORD_WORKSPACE_ID = "test-workspace-id";
|
||||||
process.env.DISCORD_AGENT_CHANNELS = "jarvis-channel:jarvis,builder-channel:builder";
|
|
||||||
|
|
||||||
// Clear callbacks
|
// Clear callbacks
|
||||||
mockReadyCallbacks.length = 0;
|
mockReadyCallbacks.length = 0;
|
||||||
@@ -110,21 +89,11 @@ describe("DiscordService", () => {
|
|||||||
provide: StitcherService,
|
provide: StitcherService,
|
||||||
useValue: mockStitcherService,
|
useValue: mockStitcherService,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: ChatProxyService,
|
|
||||||
useValue: mockChatProxyService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PrismaService,
|
|
||||||
useValue: mockPrismaService,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<DiscordService>(DiscordService);
|
service = module.get<DiscordService>(DiscordService);
|
||||||
stitcherService = module.get<StitcherService>(StitcherService);
|
stitcherService = module.get<StitcherService>(StitcherService);
|
||||||
chatProxyService = module.get<ChatProxyService>(ChatProxyService);
|
|
||||||
prismaService = module.get<PrismaService>(PrismaService);
|
|
||||||
|
|
||||||
// Clear all mocks
|
// Clear all mocks
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -480,14 +449,6 @@ describe("DiscordService", () => {
|
|||||||
provide: StitcherService,
|
provide: StitcherService,
|
||||||
useValue: mockStitcherService,
|
useValue: mockStitcherService,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: ChatProxyService,
|
|
||||||
useValue: mockChatProxyService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PrismaService,
|
|
||||||
useValue: mockPrismaService,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -509,14 +470,6 @@ describe("DiscordService", () => {
|
|||||||
provide: StitcherService,
|
provide: StitcherService,
|
||||||
useValue: mockStitcherService,
|
useValue: mockStitcherService,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: ChatProxyService,
|
|
||||||
useValue: mockChatProxyService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PrismaService,
|
|
||||||
useValue: mockPrismaService,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -539,14 +492,6 @@ describe("DiscordService", () => {
|
|||||||
provide: StitcherService,
|
provide: StitcherService,
|
||||||
useValue: mockStitcherService,
|
useValue: mockStitcherService,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: ChatProxyService,
|
|
||||||
useValue: mockChatProxyService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PrismaService,
|
|
||||||
useValue: mockPrismaService,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -709,150 +654,4 @@ describe("DiscordService", () => {
|
|||||||
expect(loggedError.statusCode).toBe(408);
|
expect(loggedError.statusCode).toBe(408);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Agent Channel Routing", () => {
|
|
||||||
it("should load agent channel mappings from environment", () => {
|
|
||||||
// The service should have loaded the agent channels from DISCORD_AGENT_CHANNELS
|
|
||||||
expect((service as any).agentChannels.size).toBe(2);
|
|
||||||
expect((service as any).agentChannels.get("jarvis-channel")).toBe("jarvis");
|
|
||||||
expect((service as any).agentChannels.get("builder-channel")).toBe("builder");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty agent channels config", async () => {
|
|
||||||
delete process.env.DISCORD_AGENT_CHANNELS;
|
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
DiscordService,
|
|
||||||
{
|
|
||||||
provide: StitcherService,
|
|
||||||
useValue: mockStitcherService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: ChatProxyService,
|
|
||||||
useValue: mockChatProxyService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PrismaService,
|
|
||||||
useValue: mockPrismaService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
const newService = module.get<DiscordService>(DiscordService);
|
|
||||||
expect((newService as any).agentChannels.size).toBe(0);
|
|
||||||
|
|
||||||
// Restore for other tests
|
|
||||||
process.env.DISCORD_AGENT_CHANNELS = "jarvis-channel:jarvis,builder-channel:builder";
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should route messages in agent channels to ChatProxyService", async () => {
|
|
||||||
const mockChannel = {
|
|
||||||
send: vi.fn().mockResolvedValue({}),
|
|
||||||
isTextBased: () => true,
|
|
||||||
sendTyping: vi.fn(),
|
|
||||||
};
|
|
||||||
(mockClient.channels.fetch as any).mockResolvedValue(mockChannel);
|
|
||||||
|
|
||||||
// Create a mock streaming response
|
|
||||||
const mockStreamResponse = new Response(
|
|
||||||
'data: {"choices":[{"delta":{"content":"Test response"}}]}\n\ndata: [DONE]\n\n',
|
|
||||||
{ headers: { "Content-Type": "text/event-stream" } }
|
|
||||||
);
|
|
||||||
mockChatProxyService.proxyChat.mockResolvedValue(mockStreamResponse);
|
|
||||||
|
|
||||||
await service.connect();
|
|
||||||
|
|
||||||
// Simulate a message in the jarvis channel
|
|
||||||
const message: ChatMessage = {
|
|
||||||
id: "msg-agent-1",
|
|
||||||
channelId: "jarvis-channel",
|
|
||||||
authorId: "user-1",
|
|
||||||
authorName: "TestUser",
|
|
||||||
content: "Hello Jarvis!",
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Call handleAgentChat directly
|
|
||||||
await (service as any).handleAgentChat(message, "jarvis");
|
|
||||||
|
|
||||||
// Verify ChatProxyService was called with workspace owner's ID and agent name
|
|
||||||
expect(mockChatProxyService.proxyChat).toHaveBeenCalledWith(
|
|
||||||
"owner-user-id",
|
|
||||||
[{ role: "user", content: "Hello Jarvis!" }],
|
|
||||||
undefined,
|
|
||||||
"jarvis"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify response was sent to channel
|
|
||||||
expect(mockChannel.send).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not route empty messages", async () => {
|
|
||||||
const message: ChatMessage = {
|
|
||||||
id: "msg-empty",
|
|
||||||
channelId: "jarvis-channel",
|
|
||||||
authorId: "user-1",
|
|
||||||
authorName: "TestUser",
|
|
||||||
content: " ",
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await (service as any).handleAgentChat(message, "jarvis");
|
|
||||||
|
|
||||||
expect(mockChatProxyService.proxyChat).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle ChatProxyService errors gracefully", async () => {
|
|
||||||
const mockChannel = {
|
|
||||||
send: vi.fn().mockResolvedValue({}),
|
|
||||||
isTextBased: () => true,
|
|
||||||
sendTyping: vi.fn(),
|
|
||||||
};
|
|
||||||
(mockClient.channels.fetch as any).mockResolvedValue(mockChannel);
|
|
||||||
|
|
||||||
mockChatProxyService.proxyChat.mockRejectedValue(new Error("Agent not found"));
|
|
||||||
|
|
||||||
await service.connect();
|
|
||||||
|
|
||||||
const message: ChatMessage = {
|
|
||||||
id: "msg-error",
|
|
||||||
channelId: "jarvis-channel",
|
|
||||||
authorId: "user-1",
|
|
||||||
authorName: "TestUser",
|
|
||||||
content: "Hello",
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await (service as any).handleAgentChat(message, "jarvis");
|
|
||||||
|
|
||||||
// Should send error message to channel
|
|
||||||
expect(mockChannel.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Failed to get response from jarvis")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should split long messages for Discord", () => {
|
|
||||||
const longContent = "A".repeat(5000);
|
|
||||||
const chunks = (service as any).splitMessageForDiscord(longContent);
|
|
||||||
|
|
||||||
// Should split into chunks of 2000 or less
|
|
||||||
expect(chunks.length).toBeGreaterThan(1);
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
expect(chunk.length).toBeLessThanOrEqual(2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reassembled content should match original
|
|
||||||
expect(chunks.join("")).toBe(longContent.trim());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should prefer paragraph breaks when splitting messages", () => {
|
|
||||||
const content = "A".repeat(1500) + "\n\n" + "B".repeat(1500);
|
|
||||||
const chunks = (service as any).splitMessageForDiscord(content);
|
|
||||||
|
|
||||||
expect(chunks.length).toBe(2);
|
|
||||||
expect(chunks[0]).toContain("A");
|
|
||||||
expect(chunks[1]).toContain("B");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { Client, Events, GatewayIntentBits, TextChannel, ThreadChannel } from "discord.js";
|
import { Client, Events, GatewayIntentBits, TextChannel, ThreadChannel } from "discord.js";
|
||||||
import { StitcherService } from "../../stitcher/stitcher.service";
|
import { StitcherService } from "../../stitcher/stitcher.service";
|
||||||
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
|
|
||||||
import { PrismaService } from "../../prisma/prisma.service";
|
|
||||||
import { sanitizeForLogging } from "../../common/utils";
|
import { sanitizeForLogging } from "../../common/utils";
|
||||||
import type {
|
import type {
|
||||||
IChatProvider,
|
IChatProvider,
|
||||||
@@ -19,7 +17,6 @@ import type {
|
|||||||
* - Connect to Discord via bot token
|
* - Connect to Discord via bot token
|
||||||
* - Listen for commands in designated channels
|
* - Listen for commands in designated channels
|
||||||
* - Forward commands to stitcher
|
* - Forward commands to stitcher
|
||||||
* - Route messages in agent channels to specific agents via ChatProxyService
|
|
||||||
* - Receive status updates from herald
|
* - Receive status updates from herald
|
||||||
* - Post updates to threads
|
* - Post updates to threads
|
||||||
*/
|
*/
|
||||||
@@ -31,21 +28,12 @@ export class DiscordService implements IChatProvider {
|
|||||||
private readonly botToken: string;
|
private readonly botToken: string;
|
||||||
private readonly controlChannelId: string;
|
private readonly controlChannelId: string;
|
||||||
private readonly workspaceId: string;
|
private readonly workspaceId: string;
|
||||||
private readonly agentChannels = new Map<string, string>();
|
|
||||||
private workspaceOwnerId: string | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly stitcherService: StitcherService) {
|
||||||
private readonly stitcherService: StitcherService,
|
|
||||||
private readonly chatProxyService: ChatProxyService,
|
|
||||||
private readonly prisma: PrismaService
|
|
||||||
) {
|
|
||||||
this.botToken = process.env.DISCORD_BOT_TOKEN ?? "";
|
this.botToken = process.env.DISCORD_BOT_TOKEN ?? "";
|
||||||
this.controlChannelId = process.env.DISCORD_CONTROL_CHANNEL_ID ?? "";
|
this.controlChannelId = process.env.DISCORD_CONTROL_CHANNEL_ID ?? "";
|
||||||
this.workspaceId = process.env.DISCORD_WORKSPACE_ID ?? "";
|
this.workspaceId = process.env.DISCORD_WORKSPACE_ID ?? "";
|
||||||
|
|
||||||
// Load agent channel mappings from environment
|
|
||||||
this.loadAgentChannels();
|
|
||||||
|
|
||||||
// Initialize Discord client with required intents
|
// Initialize Discord client with required intents
|
||||||
this.client = new Client({
|
this.client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
@@ -58,51 +46,6 @@ export class DiscordService implements IChatProvider {
|
|||||||
this.setupEventHandlers();
|
this.setupEventHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load agent channel mappings from environment variables.
|
|
||||||
* Format: DISCORD_AGENT_CHANNELS=<channelId>:<agentName>,<channelId>:<agentName>
|
|
||||||
* Example: DISCORD_AGENT_CHANNELS=123456:jarvis,789012:builder
|
|
||||||
*/
|
|
||||||
private loadAgentChannels(): void {
|
|
||||||
const channelsConfig = process.env.DISCORD_AGENT_CHANNELS ?? "";
|
|
||||||
if (!channelsConfig) {
|
|
||||||
this.logger.debug("No agent channels configured (DISCORD_AGENT_CHANNELS not set)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channels = channelsConfig.split(",").map((pair) => pair.trim());
|
|
||||||
for (const channel of channels) {
|
|
||||||
const [channelId, agentName] = channel.split(":");
|
|
||||||
if (channelId && agentName) {
|
|
||||||
this.agentChannels.set(channelId.trim(), agentName.trim());
|
|
||||||
this.logger.log(`Agent channel mapped: ${channelId.trim()} → ${agentName.trim()}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the workspace owner's user ID for chat proxy routing.
|
|
||||||
* Caches the result after first lookup.
|
|
||||||
*/
|
|
||||||
private async getWorkspaceOwnerId(): Promise<string> {
|
|
||||||
if (this.workspaceOwnerId) {
|
|
||||||
return this.workspaceOwnerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = await this.prisma.workspace.findUnique({
|
|
||||||
where: { id: this.workspaceId },
|
|
||||||
select: { ownerId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
throw new Error(`Workspace not found: ${this.workspaceId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.workspaceOwnerId = workspace.ownerId;
|
|
||||||
this.logger.debug(`Workspace owner resolved: ${workspace.ownerId}`);
|
|
||||||
return workspace.ownerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup event handlers for Discord client
|
* Setup event handlers for Discord client
|
||||||
*/
|
*/
|
||||||
@@ -117,6 +60,9 @@ export class DiscordService implements IChatProvider {
|
|||||||
// Ignore bot messages
|
// Ignore bot messages
|
||||||
if (message.author.bot) return;
|
if (message.author.bot) return;
|
||||||
|
|
||||||
|
// Check if message is in control channel
|
||||||
|
if (message.channelId !== this.controlChannelId) return;
|
||||||
|
|
||||||
// Parse message into ChatMessage format
|
// Parse message into ChatMessage format
|
||||||
const chatMessage: ChatMessage = {
|
const chatMessage: ChatMessage = {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@@ -128,16 +74,6 @@ export class DiscordService implements IChatProvider {
|
|||||||
...(message.channel.isThread() && { threadId: message.channelId }),
|
...(message.channel.isThread() && { threadId: message.channelId }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if message is in an agent channel
|
|
||||||
const agentName = this.agentChannels.get(message.channelId);
|
|
||||||
if (agentName) {
|
|
||||||
void this.handleAgentChat(chatMessage, agentName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if message is in control channel for commands
|
|
||||||
if (message.channelId !== this.controlChannelId) return;
|
|
||||||
|
|
||||||
// Parse command
|
// Parse command
|
||||||
const command = this.parseCommand(chatMessage);
|
const command = this.parseCommand(chatMessage);
|
||||||
if (command) {
|
if (command) {
|
||||||
@@ -458,150 +394,4 @@ export class DiscordService implements IChatProvider {
|
|||||||
|
|
||||||
await this.sendMessage(message.channelId, helpMessage);
|
await this.sendMessage(message.channelId, helpMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle agent chat - Route message to specific agent via ChatProxyService
|
|
||||||
* Messages in agent channels are sent directly to the agent without requiring @mosaic prefix.
|
|
||||||
*/
|
|
||||||
private async handleAgentChat(message: ChatMessage, agentName: string): Promise<void> {
|
|
||||||
this.logger.log(
|
|
||||||
`Routing message from ${message.authorName} to agent "${agentName}" in channel ${message.channelId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ignore empty messages
|
|
||||||
if (!message.content.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get workspace owner ID for routing
|
|
||||||
const userId = await this.getWorkspaceOwnerId();
|
|
||||||
|
|
||||||
// Build message history (just the user's message for now)
|
|
||||||
const messages = [{ role: "user" as const, content: message.content }];
|
|
||||||
|
|
||||||
// Send typing indicator while waiting for response
|
|
||||||
const channel = await this.client.channels.fetch(message.channelId);
|
|
||||||
if (channel?.isTextBased()) {
|
|
||||||
void (channel as TextChannel).sendTyping();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxy to agent
|
|
||||||
const response = await this.chatProxyService.proxyChat(
|
|
||||||
userId,
|
|
||||||
messages,
|
|
||||||
undefined,
|
|
||||||
agentName
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stream the response to channel
|
|
||||||
await this.streamResponseToChannel(message.channelId, response);
|
|
||||||
|
|
||||||
this.logger.debug(`Agent "${agentName}" response sent to channel ${message.channelId}`);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
this.logger.error(`Failed to route message to agent "${agentName}": ${errorMessage}`);
|
|
||||||
await this.sendMessage(
|
|
||||||
message.channelId,
|
|
||||||
`Failed to get response from ${agentName}. Please try again later.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stream SSE response from chat proxy and send to Discord channel.
|
|
||||||
* Collects the full response and sends as a single message for reliability.
|
|
||||||
*/
|
|
||||||
private async streamResponseToChannel(channelId: string, response: Response): Promise<string> {
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
if (!reader) {
|
|
||||||
throw new Error("Response body is not readable");
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let fullContent = "";
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
let readResult = await reader.read();
|
|
||||||
while (!readResult.done) {
|
|
||||||
const { value } = readResult;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
buffer = lines.pop() ?? "";
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith("data: ")) {
|
|
||||||
const data = line.slice(6);
|
|
||||||
if (data === "[DONE]") continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data) as {
|
|
||||||
choices?: { delta?: { content?: string } }[];
|
|
||||||
};
|
|
||||||
const content = parsed.choices?.[0]?.delta?.content;
|
|
||||||
if (content) {
|
|
||||||
fullContent += content;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip invalid JSON
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
readResult = await reader.read();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the full response to Discord
|
|
||||||
if (fullContent.trim()) {
|
|
||||||
// Discord has a 2000 character limit, split if needed
|
|
||||||
const chunks = this.splitMessageForDiscord(fullContent);
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
await this.sendMessage(channelId, chunk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fullContent;
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Split a message into chunks that fit within Discord's 2000 character limit.
|
|
||||||
* Tries to split on paragraph or sentence boundaries when possible.
|
|
||||||
*/
|
|
||||||
private splitMessageForDiscord(content: string, maxLength = 2000): string[] {
|
|
||||||
if (content.length <= maxLength) {
|
|
||||||
return [content];
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks: string[] = [];
|
|
||||||
let remaining = content;
|
|
||||||
|
|
||||||
while (remaining.length > maxLength) {
|
|
||||||
// Try to find a good break point
|
|
||||||
let breakPoint = remaining.lastIndexOf("\n\n", maxLength);
|
|
||||||
if (breakPoint < maxLength * 0.5) {
|
|
||||||
breakPoint = remaining.lastIndexOf("\n", maxLength);
|
|
||||||
}
|
|
||||||
if (breakPoint < maxLength * 0.5) {
|
|
||||||
breakPoint = remaining.lastIndexOf(". ", maxLength);
|
|
||||||
}
|
|
||||||
if (breakPoint < maxLength * 0.5) {
|
|
||||||
breakPoint = remaining.lastIndexOf(" ", maxLength);
|
|
||||||
}
|
|
||||||
if (breakPoint < maxLength * 0.5) {
|
|
||||||
breakPoint = maxLength - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
chunks.push(remaining.slice(0, breakPoint + 1).trim());
|
|
||||||
remaining = remaining.slice(breakPoint + 1).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remaining.length > 0) {
|
|
||||||
chunks.push(remaining);
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { StitcherService } from "../../stitcher/stitcher.service";
|
|||||||
import { HeraldService } from "../../herald/herald.service";
|
import { HeraldService } from "../../herald/herald.service";
|
||||||
import { PrismaService } from "../../prisma/prisma.service";
|
import { PrismaService } from "../../prisma/prisma.service";
|
||||||
import { BullMqService } from "../../bullmq/bullmq.service";
|
import { BullMqService } from "../../bullmq/bullmq.service";
|
||||||
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
|
|
||||||
import type { IChatProvider } from "../interfaces";
|
import type { IChatProvider } from "../interfaces";
|
||||||
import { JOB_CREATED, JOB_STARTED } from "../../job-events/event-types";
|
import { JOB_CREATED, JOB_STARTED } from "../../job-events/event-types";
|
||||||
|
|
||||||
@@ -193,7 +192,6 @@ function setDiscordEnv(): void {
|
|||||||
|
|
||||||
function setEncryptionKey(): void {
|
function setEncryptionKey(): void {
|
||||||
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
process.env.MOSAIC_SECRET_KEY = "test-mosaic-secret-key-minimum-32-characters-long";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,10 +205,6 @@ async function compileBridgeModule(): Promise<TestingModule> {
|
|||||||
.useValue({})
|
.useValue({})
|
||||||
.overrideProvider(BullMqService)
|
.overrideProvider(BullMqService)
|
||||||
.useValue({})
|
.useValue({})
|
||||||
.overrideProvider(ChatProxyService)
|
|
||||||
.useValue({
|
|
||||||
proxyChat: vi.fn().mockResolvedValue(new Response()),
|
|
||||||
})
|
|
||||||
.compile();
|
.compile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +1,19 @@
|
|||||||
import { Body, Controller, HttpException, Logger, Post, Req, Res, UseGuards } from "@nestjs/common";
|
import { Body, Controller, Post, Req, Res, UnauthorizedException, UseGuards } from "@nestjs/common";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
|
|
||||||
import type { MaybeAuthenticatedRequest } from "../auth/types/better-auth-request.interface";
|
import type { MaybeAuthenticatedRequest } from "../auth/types/better-auth-request.interface";
|
||||||
import { ChatStreamDto } from "./chat-proxy.dto";
|
import { ChatStreamDto } from "./chat-proxy.dto";
|
||||||
import { ChatProxyService } from "./chat-proxy.service";
|
import { ChatProxyService } from "./chat-proxy.service";
|
||||||
|
|
||||||
@Controller("chat")
|
@Controller("chat")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
export class ChatProxyController {
|
export class ChatProxyController {
|
||||||
private readonly logger = new Logger(ChatProxyController.name);
|
|
||||||
|
|
||||||
constructor(private readonly chatProxyService: ChatProxyService) {}
|
constructor(private readonly chatProxyService: ChatProxyService) {}
|
||||||
|
|
||||||
// POST /api/chat/guest
|
|
||||||
// Guest chat endpoint - no authentication required
|
|
||||||
// Uses a shared LLM configuration for unauthenticated users
|
|
||||||
@SkipCsrf()
|
|
||||||
@Post("guest")
|
|
||||||
async guestChat(
|
|
||||||
@Body() body: ChatStreamDto,
|
|
||||||
@Req() req: MaybeAuthenticatedRequest,
|
|
||||||
@Res() res: Response
|
|
||||||
): Promise<void> {
|
|
||||||
const abortController = new AbortController();
|
|
||||||
req.once("close", () => {
|
|
||||||
abortController.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
res.setHeader("Content-Type", "text/event-stream");
|
|
||||||
res.setHeader("Cache-Control", "no-cache");
|
|
||||||
res.setHeader("Connection", "keep-alive");
|
|
||||||
res.setHeader("X-Accel-Buffering", "no");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const upstreamResponse = await this.chatProxyService.proxyGuestChat(
|
|
||||||
body.messages,
|
|
||||||
abortController.signal
|
|
||||||
);
|
|
||||||
|
|
||||||
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
|
||||||
if (upstreamContentType) {
|
|
||||||
res.setHeader("Content-Type", upstreamContentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!upstreamResponse.body) {
|
|
||||||
throw new Error("LLM response did not include a stream body");
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (const chunk of upstreamResponse.body as unknown as AsyncIterable<Uint8Array>) {
|
|
||||||
if (res.writableEnded || res.destroyed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.write(Buffer.from(chunk));
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.logStreamError(error);
|
|
||||||
|
|
||||||
if (!res.writableEnded && !res.destroyed) {
|
|
||||||
res.write("event: error\n");
|
|
||||||
res.write(`data: ${JSON.stringify({ error: this.toSafeClientMessage(error) })}\n\n`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!res.writableEnded && !res.destroyed) {
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/chat/stream
|
// POST /api/chat/stream
|
||||||
// Request: { messages: Array<{role, content}> }
|
// Request: { messages: Array<{role, content}> }
|
||||||
// Response: SSE stream of chat completion events
|
// Response: SSE stream of chat completion events
|
||||||
// Requires authentication - uses user's personal OpenClaw container
|
|
||||||
@Post("stream")
|
@Post("stream")
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
async streamChat(
|
async streamChat(
|
||||||
@Body() body: ChatStreamDto,
|
@Body() body: ChatStreamDto,
|
||||||
@Req() req: MaybeAuthenticatedRequest,
|
@Req() req: MaybeAuthenticatedRequest,
|
||||||
@@ -81,8 +21,7 @@ export class ChatProxyController {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
this.logger.warn("streamChat called without user ID after AuthGuard");
|
throw new UnauthorizedException("No authenticated user found on request");
|
||||||
throw new HttpException("Authentication required", 401);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
@@ -99,8 +38,7 @@ export class ChatProxyController {
|
|||||||
const upstreamResponse = await this.chatProxyService.proxyChat(
|
const upstreamResponse = await this.chatProxyService.proxyChat(
|
||||||
userId,
|
userId,
|
||||||
body.messages,
|
body.messages,
|
||||||
abortController.signal,
|
abortController.signal
|
||||||
body.agent
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
||||||
@@ -120,11 +58,10 @@ export class ChatProxyController {
|
|||||||
res.write(Buffer.from(chunk));
|
res.write(Buffer.from(chunk));
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.logStreamError(error);
|
|
||||||
|
|
||||||
if (!res.writableEnded && !res.destroyed) {
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
res.write("event: error\n");
|
res.write("event: error\n");
|
||||||
res.write(`data: ${JSON.stringify({ error: this.toSafeClientMessage(error) })}\n\n`);
|
res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!res.writableEnded && !res.destroyed) {
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
@@ -132,21 +69,4 @@ export class ChatProxyController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import {
|
import { ArrayMinSize, IsArray, IsNotEmpty, IsString, ValidateNested } from "class-validator";
|
||||||
ArrayMinSize,
|
|
||||||
IsArray,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
ValidateNested,
|
|
||||||
} from "class-validator";
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: string;
|
role: string;
|
||||||
@@ -29,8 +22,4 @@ export class ChatStreamDto {
|
|||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => ChatMessageDto)
|
@Type(() => ChatMessageDto)
|
||||||
messages!: ChatMessageDto[];
|
messages!: ChatMessageDto[];
|
||||||
|
|
||||||
@IsString({ message: "agent must be a string" })
|
|
||||||
@IsOptional()
|
|
||||||
agent?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { AgentConfigModule } from "../agent-config/agent-config.module";
|
import { AgentConfigModule } from "../agent-config/agent-config.module";
|
||||||
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
|
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
|
||||||
@@ -8,7 +7,7 @@ import { ChatProxyController } from "./chat-proxy.controller";
|
|||||||
import { ChatProxyService } from "./chat-proxy.service";
|
import { ChatProxyService } from "./chat-proxy.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule, ConfigModule],
|
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule],
|
||||||
controllers: [ChatProxyController],
|
controllers: [ChatProxyController],
|
||||||
providers: [ChatProxyService],
|
providers: [ChatProxyService],
|
||||||
exports: [ChatProxyService],
|
exports: [ChatProxyService],
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { ServiceUnavailableException } from "@nestjs/common";
|
||||||
ServiceUnavailableException,
|
|
||||||
NotFoundException,
|
|
||||||
BadGatewayException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ChatProxyService } from "./chat-proxy.service";
|
import { ChatProxyService } from "./chat-proxy.service";
|
||||||
|
|
||||||
@@ -13,9 +9,6 @@ describe("ChatProxyService", () => {
|
|||||||
userAgentConfig: {
|
userAgentConfig: {
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
},
|
},
|
||||||
userAgent: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerLifecycle = {
|
const containerLifecycle = {
|
||||||
@@ -23,17 +16,13 @@ describe("ChatProxyService", () => {
|
|||||||
touch: vi.fn(),
|
touch: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = {
|
|
||||||
get: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let service: ChatProxyService;
|
let service: ChatProxyService;
|
||||||
let fetchMock: ReturnType<typeof vi.fn>;
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchMock = vi.fn();
|
fetchMock = vi.fn();
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
service = new ChatProxyService(prisma as never, containerLifecycle as never, config as never);
|
service = new ChatProxyService(prisma as never, containerLifecycle as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -75,7 +64,6 @@ describe("ChatProxyService", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer gateway-token",
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -116,135 +104,4 @@ describe("ChatProxyService", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("proxyChat with agent routing", () => {
|
|
||||||
it("includes agent config when agentName is specified", async () => {
|
|
||||||
const mockAgent = {
|
|
||||||
name: "jarvis",
|
|
||||||
displayName: "Jarvis",
|
|
||||||
personality: "Capable, direct, proactive.",
|
|
||||||
primaryModel: "opus",
|
|
||||||
isActive: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
containerLifecycle.ensureRunning.mockResolvedValue({
|
|
||||||
url: "http://mosaic-user-user-123:19000",
|
|
||||||
token: "gateway-token",
|
|
||||||
});
|
|
||||||
containerLifecycle.touch.mockResolvedValue(undefined);
|
|
||||||
prisma.userAgent.findUnique.mockResolvedValue(mockAgent);
|
|
||||||
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
|
|
||||||
|
|
||||||
const messages = [{ role: "user", content: "Hello Jarvis" }];
|
|
||||||
await service.proxyChat(userId, messages, undefined, "jarvis");
|
|
||||||
|
|
||||||
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
||||||
const parsedBody = JSON.parse(String(request.body));
|
|
||||||
|
|
||||||
expect(parsedBody).toEqual({
|
|
||||||
messages,
|
|
||||||
model: "opus",
|
|
||||||
stream: true,
|
|
||||||
agent: "jarvis",
|
|
||||||
agent_personality: "Capable, direct, proactive.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws NotFoundException when agent not found", async () => {
|
|
||||||
containerLifecycle.ensureRunning.mockResolvedValue({
|
|
||||||
url: "http://mosaic-user-user-123:19000",
|
|
||||||
token: "gateway-token",
|
|
||||||
});
|
|
||||||
containerLifecycle.touch.mockResolvedValue(undefined);
|
|
||||||
prisma.userAgent.findUnique.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const messages = [{ role: "user", content: "Hello" }];
|
|
||||||
await expect(service.proxyChat(userId, messages, undefined, "nonexistent")).rejects.toThrow(
|
|
||||||
NotFoundException
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws NotFoundException when agent is not active", async () => {
|
|
||||||
containerLifecycle.ensureRunning.mockResolvedValue({
|
|
||||||
url: "http://mosaic-user-user-123:19000",
|
|
||||||
token: "gateway-token",
|
|
||||||
});
|
|
||||||
containerLifecycle.touch.mockResolvedValue(undefined);
|
|
||||||
prisma.userAgent.findUnique.mockResolvedValue({
|
|
||||||
name: "inactive-agent",
|
|
||||||
displayName: "Inactive",
|
|
||||||
personality: "...",
|
|
||||||
primaryModel: null,
|
|
||||||
isActive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const messages = [{ role: "user", content: "Hello" }];
|
|
||||||
await expect(
|
|
||||||
service.proxyChat(userId, messages, undefined, "inactive-agent")
|
|
||||||
).rejects.toThrow(NotFoundException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to default model when agent has no primaryModel", async () => {
|
|
||||||
const mockAgent = {
|
|
||||||
name: "jarvis",
|
|
||||||
displayName: "Jarvis",
|
|
||||||
personality: "Capable, direct, proactive.",
|
|
||||||
primaryModel: null,
|
|
||||||
isActive: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
containerLifecycle.ensureRunning.mockResolvedValue({
|
|
||||||
url: "http://mosaic-user-user-123:19000",
|
|
||||||
token: "gateway-token",
|
|
||||||
});
|
|
||||||
containerLifecycle.touch.mockResolvedValue(undefined);
|
|
||||||
prisma.userAgent.findUnique.mockResolvedValue(mockAgent);
|
|
||||||
prisma.userAgentConfig.findUnique.mockResolvedValue(null);
|
|
||||||
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
|
|
||||||
|
|
||||||
const messages = [{ role: "user", content: "Hello" }];
|
|
||||||
await service.proxyChat(userId, messages, undefined, "jarvis");
|
|
||||||
|
|
||||||
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
||||||
const parsedBody = JSON.parse(String(request.body));
|
|
||||||
|
|
||||||
expect(parsedBody.model).toBe("openclaw:default");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("proxyGuestChat", () => {
|
|
||||||
it("uses environment variables for guest LLM configuration", async () => {
|
|
||||||
config.get.mockImplementation((key: string) => {
|
|
||||||
if (key === "GUEST_LLM_URL") return "http://10.1.1.42:11434/v1";
|
|
||||||
if (key === "GUEST_LLM_MODEL") return "llama3.2";
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
|
|
||||||
|
|
||||||
const messages = [{ role: "user", content: "Hello" }];
|
|
||||||
await service.proxyGuestChat(messages);
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
"http://10.1.1.42:11434/v1/chat/completions",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
||||||
const parsedBody = JSON.parse(String(request.body));
|
|
||||||
expect(parsedBody.model).toBe("llama3.2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws BadGatewayException on guest LLM errors", async () => {
|
|
||||||
config.get.mockReturnValue(undefined);
|
|
||||||
fetchMock.mockResolvedValue(new Response("Internal Server Error", { status: 500 }));
|
|
||||||
|
|
||||||
const messages = [{ role: "user", content: "Hello" }];
|
|
||||||
await expect(service.proxyGuestChat(messages)).rejects.toThrow(BadGatewayException);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,44 +1,21 @@
|
|||||||
import {
|
import { BadGatewayException, Injectable, ServiceUnavailableException } from "@nestjs/common";
|
||||||
BadGatewayException,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
|
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import type { ChatMessage } from "./chat-proxy.dto";
|
import type { ChatMessage } from "./chat-proxy.dto";
|
||||||
|
|
||||||
const DEFAULT_OPENCLAW_MODEL = "openclaw:default";
|
const DEFAULT_OPENCLAW_MODEL = "openclaw:default";
|
||||||
const DEFAULT_GUEST_LLM_URL = "http://10.1.1.42:11434/v1";
|
|
||||||
const DEFAULT_GUEST_LLM_MODEL = "llama3.2";
|
|
||||||
|
|
||||||
interface ContainerConnection {
|
|
||||||
url: string;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgentConfig {
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
personality: string;
|
|
||||||
primaryModel: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ChatProxyService {
|
export class ChatProxyService {
|
||||||
private readonly logger = new Logger(ChatProxyService.name);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly containerLifecycle: ContainerLifecycleService,
|
private readonly containerLifecycle: ContainerLifecycleService
|
||||||
private readonly config: ConfigService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Get the user's OpenClaw container URL and mark it active.
|
// Get the user's OpenClaw container URL and mark it active.
|
||||||
async getContainerUrl(userId: string): Promise<string> {
|
async getContainerUrl(userId: string): Promise<string> {
|
||||||
const { url } = await this.getContainerConnection(userId);
|
const { url } = await this.containerLifecycle.ensureRunning(userId);
|
||||||
|
await this.containerLifecycle.touch(userId);
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,38 +23,18 @@ export class ChatProxyService {
|
|||||||
async proxyChat(
|
async proxyChat(
|
||||||
userId: string,
|
userId: string,
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal
|
||||||
agentName?: string
|
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId);
|
const containerUrl = await this.getContainerUrl(userId);
|
||||||
|
const model = await this.getPreferredModel(userId);
|
||||||
// Get agent config if specified
|
const requestInit: RequestInit = {
|
||||||
let agentConfig: AgentConfig | null = null;
|
method: "POST",
|
||||||
if (agentName) {
|
headers: { "Content-Type": "application/json" },
|
||||||
agentConfig = await this.getAgentConfig(userId, agentName);
|
body: JSON.stringify({
|
||||||
}
|
|
||||||
|
|
||||||
const model = agentConfig?.primaryModel ?? (await this.getPreferredModel(userId));
|
|
||||||
|
|
||||||
const requestBody: Record<string, unknown> = {
|
|
||||||
messages,
|
messages,
|
||||||
model,
|
model,
|
||||||
stream: true,
|
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) {
|
if (signal) {
|
||||||
@@ -90,10 +47,10 @@ export class ChatProxyService {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = await this.readResponseText(response);
|
const detail = await this.readResponseText(response);
|
||||||
const status = `${String(response.status)} ${response.statusText}`.trim();
|
const status = `${String(response.status)} ${response.statusText}`.trim();
|
||||||
this.logger.warn(
|
const message = detail
|
||||||
detail ? `OpenClaw returned ${status}: ${detail}` : `OpenClaw returned ${status}`
|
? `OpenClaw returned ${status}: ${detail}`
|
||||||
);
|
: `OpenClaw returned ${status}`;
|
||||||
throw new BadGatewayException(`OpenClaw returned ${status}`);
|
throw new BadGatewayException(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -103,76 +60,10 @@ export class ChatProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = error instanceof Error ? error.message : String(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: ${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> {
|
private async getPreferredModel(userId: string): Promise<string> {
|
||||||
const config = await this.prisma.userAgentConfig.findUnique({
|
const config = await this.prisma.userAgentConfig.findUnique({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -195,32 +86,4 @@ export class ChatProxyService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAgentConfig(userId: string, agentName: string): Promise<AgentConfig> {
|
|
||||||
const agent = await this.prisma.userAgent.findUnique({
|
|
||||||
where: { userId_name: { userId, name: agentName } },
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
displayName: true,
|
|
||||||
personality: true,
|
|
||||||
primaryModel: true,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!agent) {
|
|
||||||
throw new NotFoundException(`Agent "${agentName}" not found for user`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!agent.isActive) {
|
|
||||||
throw new NotFoundException(`Agent "${agentName}" is not active`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: agent.name,
|
|
||||||
displayName: agent.displayName,
|
|
||||||
personality: agent.personality,
|
|
||||||
primaryModel: agent.primaryModel,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,9 +111,14 @@ export class CsrfGuard implements CanActivate {
|
|||||||
|
|
||||||
throw new ForbiddenException("CSRF token not bound to session");
|
throw new ForbiddenException("CSRF token not bound to session");
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.debug({
|
||||||
|
event: "CSRF_SKIP_SESSION_BINDING",
|
||||||
|
method: request.method,
|
||||||
|
path: request.path,
|
||||||
|
reason: "User context not yet available (global guard runs before AuthGuard)",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Note: when userId is absent, the double-submit cookie check above is
|
|
||||||
// sufficient CSRF protection. AuthGuard populates request.user afterward.
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { DashboardService } from "./dashboard.service";
|
|||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||||
import { DashboardSummaryDto } from "./dto";
|
import type { DashboardSummaryDto } from "./dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller for dashboard endpoints.
|
* Controller for dashboard endpoints.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type { AuthUser } from "@mosaic/shared";
|
|||||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||||
import { AdminGuard } from "../auth/guards/admin.guard";
|
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import {
|
import type {
|
||||||
CreateProviderDto,
|
CreateProviderDto,
|
||||||
ResetPasswordDto,
|
ResetPasswordDto,
|
||||||
UpdateAgentConfigDto,
|
UpdateAgentConfigDto,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller, Get, Param, Query } from "@nestjs/common";
|
import { Controller, Get, Param, Query } from "@nestjs/common";
|
||||||
import type { LlmUsageLog } from "@prisma/client";
|
import type { LlmUsageLog } from "@prisma/client";
|
||||||
import { LlmUsageService } from "./llm-usage.service";
|
import { LlmUsageService } from "./llm-usage.service";
|
||||||
import { UsageAnalyticsQueryDto, UsageAnalyticsResponseDto } from "./dto";
|
import type { UsageAnalyticsQueryDto, UsageAnalyticsResponseDto } from "./dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LLM Usage Controller
|
* LLM Usage Controller
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { RequestMethod, ValidationPipe } from "@nestjs/common";
|
import { RequestMethod, ValidationPipe } from "@nestjs/common";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import helmet from "helmet";
|
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { getTrustedOrigins } from "./auth/auth.config";
|
import { getTrustedOrigins } from "./auth/auth.config";
|
||||||
import { GlobalExceptionFilter } from "./filters/global-exception.filter";
|
import { GlobalExceptionFilter } from "./filters/global-exception.filter";
|
||||||
@@ -34,14 +33,6 @@ async function bootstrap() {
|
|||||||
// Enable cookie parser for session handling
|
// Enable cookie parser for session handling
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
// Enable helmet security headers
|
|
||||||
app.use(
|
|
||||||
helmet({
|
|
||||||
contentSecurityPolicy: false, // Let Next.js handle CSP
|
|
||||||
crossOriginEmbedderPolicy: false,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enable global validation pipe with transformation
|
// Enable global validation pipe with transformation
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Matches,
|
Matches,
|
||||||
IsUUID,
|
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,10 +43,6 @@ export class CreateProjectDto {
|
|||||||
})
|
})
|
||||||
color?: string;
|
color?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID("4", { message: "domainId must be a valid UUID" })
|
|
||||||
domainId?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject({ message: "metadata must be an object" })
|
@IsObject({ message: "metadata must be an object" })
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Matches,
|
Matches,
|
||||||
IsUUID,
|
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,10 +45,6 @@ export class UpdateProjectDto {
|
|||||||
})
|
})
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID("4", { message: "domainId must be a valid UUID" })
|
|
||||||
domainId?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject({ message: "metadata must be an object" })
|
@IsObject({ message: "metadata must be an object" })
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ export class ProjectsService {
|
|||||||
createProjectDto: CreateProjectDto
|
createProjectDto: CreateProjectDto
|
||||||
): Promise<ProjectWithRelations> {
|
): Promise<ProjectWithRelations> {
|
||||||
const data: Prisma.ProjectCreateInput = {
|
const data: Prisma.ProjectCreateInput = {
|
||||||
...(createProjectDto.domainId
|
|
||||||
? { domain: { connect: { id: createProjectDto.domainId } } }
|
|
||||||
: {}),
|
|
||||||
name: createProjectDto.name,
|
name: createProjectDto.name,
|
||||||
description: createProjectDto.description ?? null,
|
description: createProjectDto.description ?? null,
|
||||||
color: createProjectDto.color ?? null,
|
color: createProjectDto.color ?? null,
|
||||||
@@ -224,18 +221,6 @@ export class ProjectsService {
|
|||||||
if (updateProjectDto.startDate !== undefined) updateData.startDate = updateProjectDto.startDate;
|
if (updateProjectDto.startDate !== undefined) updateData.startDate = updateProjectDto.startDate;
|
||||||
if (updateProjectDto.endDate !== undefined) updateData.endDate = updateProjectDto.endDate;
|
if (updateProjectDto.endDate !== undefined) updateData.endDate = updateProjectDto.endDate;
|
||||||
if (updateProjectDto.color !== undefined) updateData.color = updateProjectDto.color;
|
if (updateProjectDto.color !== undefined) updateData.color = updateProjectDto.color;
|
||||||
if (updateProjectDto.domainId !== undefined)
|
|
||||||
updateData.domain = updateProjectDto.domainId
|
|
||||||
? { connect: { id: updateProjectDto.domainId } }
|
|
||||||
: { disconnect: true };
|
|
||||||
if (updateProjectDto.domainId !== undefined)
|
|
||||||
updateData.domain = updateProjectDto.domainId
|
|
||||||
? {
|
|
||||||
connect: {
|
|
||||||
id: updateProjectDto.domainId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: { disconnect: true };
|
|
||||||
if (updateProjectDto.metadata !== undefined) {
|
if (updateProjectDto.metadata !== undefined) {
|
||||||
updateData.metadata = updateProjectDto.metadata as unknown as Prisma.InputJsonValue;
|
updateData.metadata = updateProjectDto.metadata as unknown as Prisma.InputJsonValue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(", "));
|
|
||||||
}
|
|
||||||
@@ -66,9 +66,7 @@ interface StartTranscriptionPayload {
|
|||||||
@WSGateway({
|
@WSGateway({
|
||||||
namespace: "/speech",
|
namespace: "/speech",
|
||||||
cors: {
|
cors: {
|
||||||
origin: (process.env.TRUSTED_ORIGINS ?? process.env.WEB_URL ?? "http://localhost:3000")
|
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim()),
|
|
||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,9 +63,7 @@ interface AuthenticatedSocket extends Socket {
|
|||||||
@WSGateway({
|
@WSGateway({
|
||||||
namespace: "/terminal",
|
namespace: "/terminal",
|
||||||
cors: {
|
cors: {
|
||||||
origin: (process.env.TRUSTED_ORIGINS ?? process.env.WEB_URL ?? "http://localhost:3000")
|
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim()),
|
|
||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { PartialType } from "@nestjs/mapped-types";
|
|
||||||
import { CreateUserAgentDto } from "./create-user-agent.dto";
|
|
||||||
|
|
||||||
export class UpdateUserAgentDto extends PartialType(CreateUserAgentDto) {}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
|
import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
|
||||||
import { SkipThrottle as SkipThrottler } from "@nestjs/throttler";
|
|
||||||
import { WidgetsService } from "./widgets.service";
|
import { WidgetsService } from "./widgets.service";
|
||||||
import { WidgetDataService } from "./widget-data.service";
|
import { WidgetDataService } from "./widget-data.service";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||||
import { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
|
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
|
||||||
import type { RequestWithWorkspace } from "../common/types/user.types";
|
import type { RequestWithWorkspace } from "../common/types/user.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +43,6 @@ export class WidgetsController {
|
|||||||
* Get stat card widget data
|
* Get stat card widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/stat-card")
|
@Post("data/stat-card")
|
||||||
@SkipThrottler()
|
|
||||||
@UseGuards(WorkspaceGuard)
|
@UseGuards(WorkspaceGuard)
|
||||||
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
|
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
|
||||||
return this.widgetDataService.getStatCardData(req.workspace.id, query);
|
return this.widgetDataService.getStatCardData(req.workspace.id, query);
|
||||||
@@ -55,7 +53,6 @@ export class WidgetsController {
|
|||||||
* Get chart widget data
|
* Get chart widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/chart")
|
@Post("data/chart")
|
||||||
@SkipThrottler()
|
|
||||||
@UseGuards(WorkspaceGuard)
|
@UseGuards(WorkspaceGuard)
|
||||||
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
|
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
|
||||||
return this.widgetDataService.getChartData(req.workspace.id, query);
|
return this.widgetDataService.getChartData(req.workspace.id, query);
|
||||||
@@ -66,7 +63,6 @@ export class WidgetsController {
|
|||||||
* Get list widget data
|
* Get list widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/list")
|
@Post("data/list")
|
||||||
@SkipThrottler()
|
|
||||||
@UseGuards(WorkspaceGuard)
|
@UseGuards(WorkspaceGuard)
|
||||||
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
|
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
|
||||||
return this.widgetDataService.getListData(req.workspace.id, query);
|
return this.widgetDataService.getListData(req.workspace.id, query);
|
||||||
@@ -77,7 +73,6 @@ export class WidgetsController {
|
|||||||
* Get calendar preview widget data
|
* Get calendar preview widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/calendar-preview")
|
@Post("data/calendar-preview")
|
||||||
@SkipThrottler()
|
|
||||||
@UseGuards(WorkspaceGuard)
|
@UseGuards(WorkspaceGuard)
|
||||||
async getCalendarPreviewData(
|
async getCalendarPreviewData(
|
||||||
@Request() req: RequestWithWorkspace,
|
@Request() req: RequestWithWorkspace,
|
||||||
@@ -91,7 +86,6 @@ export class WidgetsController {
|
|||||||
* Get active projects widget data
|
* Get active projects widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/active-projects")
|
@Post("data/active-projects")
|
||||||
@SkipThrottler()
|
|
||||||
@UseGuards(WorkspaceGuard)
|
@UseGuards(WorkspaceGuard)
|
||||||
async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
|
async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
|
||||||
return this.widgetDataService.getActiveProjectsData(req.workspace.id);
|
return this.widgetDataService.getActiveProjectsData(req.workspace.id);
|
||||||
@@ -102,7 +96,6 @@ export class WidgetsController {
|
|||||||
* Get agent chains widget data (active agent sessions)
|
* Get agent chains widget data (active agent sessions)
|
||||||
*/
|
*/
|
||||||
@Post("data/agent-chains")
|
@Post("data/agent-chains")
|
||||||
@SkipThrottler()
|
|
||||||
@UseGuards(WorkspaceGuard)
|
@UseGuards(WorkspaceGuard)
|
||||||
async getAgentChainsData(@Request() req: RequestWithWorkspace) {
|
async getAgentChainsData(@Request() req: RequestWithWorkspace) {
|
||||||
return this.widgetDataService.getAgentChainsData(req.workspace.id);
|
return this.widgetDataService.getAgentChainsData(req.workspace.id);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
|||||||
import { Permission, RequirePermission } from "../common/decorators";
|
import { Permission, RequirePermission } from "../common/decorators";
|
||||||
import type { WorkspaceMember } from "@prisma/client";
|
import type { WorkspaceMember } from "@prisma/client";
|
||||||
import type { AuthenticatedUser } from "../common/types/user.types";
|
import type { AuthenticatedUser } from "../common/types/user.types";
|
||||||
import { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto";
|
import type { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-scoped workspace operations.
|
* User-scoped workspace operations.
|
||||||
@@ -29,25 +29,6 @@ export class WorkspacesController {
|
|||||||
return this.workspacesService.getUserWorkspaces(user.id);
|
return this.workspacesService.getUserWorkspaces(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/workspaces/:workspaceId/stats
|
|
||||||
* Returns member, project, and domain counts for a workspace.
|
|
||||||
*/
|
|
||||||
@Get(":workspaceId/stats")
|
|
||||||
async getStats(@Param("workspaceId") workspaceId: string) {
|
|
||||||
return this.workspacesService.getStats(workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/workspaces/:workspaceId/members
|
|
||||||
* Returns the list of members for a workspace.
|
|
||||||
*/
|
|
||||||
@Get(":workspaceId/members")
|
|
||||||
@UseGuards(WorkspaceGuard)
|
|
||||||
async getMembers(@Param("workspaceId") workspaceId: string) {
|
|
||||||
return this.workspacesService.getMembers(workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/workspaces/:workspaceId/members
|
* POST /api/workspaces/:workspaceId/members
|
||||||
* Add a member to a workspace with the specified role.
|
* Add a member to a workspace with the specified role.
|
||||||
|
|||||||
@@ -321,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(
|
private assertCanAssignRole(
|
||||||
actorRole: WorkspaceMemberRole,
|
actorRole: WorkspaceMemberRole,
|
||||||
requestedRole: WorkspaceMemberRole
|
requestedRole: WorkspaceMemberRole
|
||||||
@@ -354,15 +342,4 @@ export class WorkspacesService {
|
|||||||
private isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
|
private isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
|
||||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
|
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStats(
|
|
||||||
workspaceId: string
|
|
||||||
): Promise<{ memberCount: number; projectCount: number; domainCount: number }> {
|
|
||||||
const [memberCount, projectCount, domainCount] = await Promise.all([
|
|
||||||
this.prisma.workspaceMember.count({ where: { workspaceId } }),
|
|
||||||
this.prisma.project.count({ where: { workspaceId } }),
|
|
||||||
this.prisma.domain.count({ where: { workspaceId } }),
|
|
||||||
]);
|
|
||||||
return { memberCount, projectCount, domainCount };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -601,21 +601,9 @@ class TestCoordinatorIntegration:
|
|||||||
coordinator = Coordinator(queue_manager=queue_manager, poll_interval=0.02)
|
coordinator = Coordinator(queue_manager=queue_manager, poll_interval=0.02)
|
||||||
|
|
||||||
task = asyncio.create_task(coordinator.start())
|
task = asyncio.create_task(coordinator.start())
|
||||||
|
await asyncio.sleep(0.5) # Allow time for processing
|
||||||
# Poll for completion with timeout instead of fixed sleep
|
|
||||||
deadline = asyncio.get_event_loop().time() + 5.0 # 5 second timeout
|
|
||||||
while asyncio.get_event_loop().time() < deadline:
|
|
||||||
all_completed = True
|
|
||||||
for i in range(157, 162):
|
|
||||||
item = queue_manager.get_item(i)
|
|
||||||
if item is None or item.status != QueueItemStatus.COMPLETED:
|
|
||||||
all_completed = False
|
|
||||||
break
|
|
||||||
if all_completed:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
|
|
||||||
await coordinator.stop()
|
await coordinator.stop()
|
||||||
|
|
||||||
task.cancel()
|
task.cancel()
|
||||||
try:
|
try:
|
||||||
await task
|
await task
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Base image for all stages
|
# Base image for all stages
|
||||||
# Uses Debian slim (glibc) instead of Alpine (musl) for native addon compatibility.
|
# Uses Debian slim (glibc) instead of Alpine (musl) for native addon compatibility.
|
||||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
|
FROM node:24-slim AS base
|
||||||
|
|
||||||
# Install pnpm globally
|
# Install pnpm globally
|
||||||
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
||||||
@@ -21,11 +21,6 @@ FROM base AS deps
|
|||||||
COPY packages/shared/package.json ./packages/shared/
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
COPY packages/config/package.json ./packages/config/
|
COPY packages/config/package.json ./packages/config/
|
||||||
COPY apps/orchestrator/package.json ./apps/orchestrator/
|
COPY apps/orchestrator/package.json ./apps/orchestrator/
|
||||||
# Copy API prisma schema so prisma generate can run in the orchestrator build
|
|
||||||
COPY apps/api/prisma ./apps/api/prisma
|
|
||||||
|
|
||||||
# Copy npm configuration for native binary architecture hints
|
|
||||||
COPY .npmrc ./
|
|
||||||
|
|
||||||
# Install ALL dependencies (not just production)
|
# Install ALL dependencies (not just production)
|
||||||
# No cache mount — Kaniko builds are ephemeral in CI
|
# No cache mount — Kaniko builds are ephemeral in CI
|
||||||
@@ -48,10 +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/packages/config/node_modules ./packages/config/node_modules
|
||||||
COPY --from=deps /app/apps/orchestrator/node_modules ./apps/orchestrator/node_modules
|
COPY --from=deps /app/apps/orchestrator/node_modules ./apps/orchestrator/node_modules
|
||||||
|
|
||||||
# Copy API prisma schema and generate the Prisma client for the orchestrator
|
|
||||||
COPY apps/api/prisma ./apps/api/prisma
|
|
||||||
RUN pnpm --filter=@mosaic/orchestrator prisma:generate
|
|
||||||
|
|
||||||
# Build the orchestrator app using TurboRepo
|
# Build the orchestrator app using TurboRepo
|
||||||
RUN pnpm turbo build --filter=@mosaic/orchestrator
|
RUN pnpm turbo build --filter=@mosaic/orchestrator
|
||||||
|
|
||||||
@@ -63,7 +54,7 @@ RUN find ./apps/orchestrator/dist \( -name '*.spec.js' -o -name '*.spec.js.map'
|
|||||||
# ======================
|
# ======================
|
||||||
# Production stage
|
# Production stage
|
||||||
# ======================
|
# ======================
|
||||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
|
FROM node:24-slim AS production
|
||||||
|
|
||||||
# Add metadata labels
|
# Add metadata labels
|
||||||
LABEL maintainer="mosaic-team@mosaicstack.dev"
|
LABEL maintainer="mosaic-team@mosaicstack.dev"
|
||||||
@@ -74,12 +65,13 @@ LABEL org.opencontainers.image.vendor="Mosaic Stack"
|
|||||||
LABEL org.opencontainers.image.title="Mosaic Orchestrator"
|
LABEL org.opencontainers.image.title="Mosaic Orchestrator"
|
||||||
LABEL org.opencontainers.image.description="Agent orchestration service for Mosaic Stack"
|
LABEL org.opencontainers.image.description="Agent orchestration service for Mosaic Stack"
|
||||||
|
|
||||||
# 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)
|
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||||
# - Remove npm/npx to reduce image size (not used in production)
|
|
||||||
# - Create non-root user
|
|
||||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||||
|
&& chmod 755 /usr/local/bin/dumb-init \
|
||||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -3,20 +3,19 @@
|
|||||||
"version": "0.0.20",
|
"version": "0.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
"lint": "eslint src/",
|
"build": "nest build",
|
||||||
"lint:fix": "eslint src/ --fix",
|
|
||||||
"prisma:generate": "prisma generate --schema=../api/prisma/schema.prisma",
|
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"start:debug": "nest start --debug --watch",
|
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main.js",
|
"start:prod": "node dist/main.js",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
|
"test:watch": "vitest watch",
|
||||||
"test:e2e": "vitest run --config tests/integration/vitest.config.ts",
|
"test:e2e": "vitest run --config tests/integration/vitest.config.ts",
|
||||||
"test:perf": "vitest run --config tests/performance/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": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.72.1",
|
"@anthropic-ai/sdk": "^0.72.1",
|
||||||
@@ -28,7 +27,6 @@
|
|||||||
"@nestjs/core": "^11.1.12",
|
"@nestjs/core": "^11.1.12",
|
||||||
"@nestjs/platform-express": "^11.1.12",
|
"@nestjs/platform-express": "^11.1.12",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/client": "^6.19.2",
|
|
||||||
"bullmq": "^5.67.2",
|
"bullmq": "^5.67.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
|||||||
@@ -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 {}
|
|
||||||
@@ -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,
|
|
||||||
missionId,
|
|
||||||
taskId,
|
|
||||||
agentType,
|
|
||||||
status: "spawning",
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
parentSessionId: parentAgentId,
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Global, Module } from "@nestjs/common";
|
|
||||||
import { PrismaService } from "./prisma.service";
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [PrismaService],
|
|
||||||
exports: [PrismaService],
|
|
||||||
})
|
|
||||||
export class PrismaModule {}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight Prisma service for orchestrator ingestion persistence.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
|
||||||
private readonly logger = new Logger(PrismaService.name);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super({
|
|
||||||
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
|
||||||
await this.$connect();
|
|
||||||
this.logger.log("Database connection established");
|
|
||||||
}
|
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
|
||||||
await this.$disconnect();
|
|
||||||
this.logger.log("Database connection closed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Injectable, Logger, Inject, Optional, forwardRef } from "@nestjs/common";
|
import { Injectable, Logger, Inject, forwardRef } from "@nestjs/common";
|
||||||
import { ValkeyService } from "../valkey/valkey.service";
|
import { ValkeyService } from "../valkey/valkey.service";
|
||||||
import { AgentSpawnerService } from "./agent-spawner.service";
|
import { AgentSpawnerService } from "./agent-spawner.service";
|
||||||
import { AgentIngestionService } from "../agent-ingestion/agent-ingestion.service";
|
|
||||||
import type { AgentState, AgentStatus, AgentEvent } from "../valkey/types";
|
import type { AgentState, AgentStatus, AgentEvent } from "../valkey/types";
|
||||||
import { isValidAgentTransition } from "../valkey/types/state.types";
|
import { isValidAgentTransition } from "../valkey/types/state.types";
|
||||||
|
|
||||||
@@ -33,8 +32,7 @@ export class AgentLifecycleService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly valkeyService: ValkeyService,
|
private readonly valkeyService: ValkeyService,
|
||||||
@Inject(forwardRef(() => AgentSpawnerService))
|
@Inject(forwardRef(() => AgentSpawnerService))
|
||||||
private readonly spawnerService: AgentSpawnerService,
|
private readonly spawnerService: AgentSpawnerService
|
||||||
@Optional() private readonly agentIngestionService?: AgentIngestionService
|
|
||||||
) {
|
) {
|
||||||
this.logger.log("AgentLifecycleService initialized");
|
this.logger.log("AgentLifecycleService initialized");
|
||||||
}
|
}
|
||||||
@@ -57,25 +55,6 @@ export class AgentLifecycleService {
|
|||||||
return createdState;
|
return createdState;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async recordLifecycleIngestion(
|
|
||||||
agentId: string,
|
|
||||||
event: "started" | "completed" | "failed" | "killed",
|
|
||||||
record: (ingestionService: AgentIngestionService) => Promise<void>
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.agentIngestionService) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await record(this.agentIngestionService);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to record agent ${event} ingestion for ${agentId}: ${errorMessage}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acquire a per-agent mutex to serialize state transitions.
|
* Acquire a per-agent mutex to serialize state transitions.
|
||||||
* Uses promise chaining: each caller chains onto the previous lock,
|
* Uses promise chaining: each caller chains onto the previous lock,
|
||||||
@@ -139,10 +118,6 @@ export class AgentLifecycleService {
|
|||||||
// Emit event
|
// Emit event
|
||||||
await this.publishStateChangeEvent("agent.running", updatedState);
|
await this.publishStateChangeEvent("agent.running", updatedState);
|
||||||
|
|
||||||
await this.recordLifecycleIngestion(agentId, "started", (ingestionService) =>
|
|
||||||
ingestionService.recordAgentStarted(agentId)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Agent ${agentId} transitioned to running`);
|
this.logger.log(`Agent ${agentId} transitioned to running`);
|
||||||
return updatedState;
|
return updatedState;
|
||||||
});
|
});
|
||||||
@@ -180,10 +155,6 @@ export class AgentLifecycleService {
|
|||||||
// Emit event
|
// Emit event
|
||||||
await this.publishStateChangeEvent("agent.completed", updatedState);
|
await this.publishStateChangeEvent("agent.completed", updatedState);
|
||||||
|
|
||||||
await this.recordLifecycleIngestion(agentId, "completed", (ingestionService) =>
|
|
||||||
ingestionService.recordAgentCompleted(agentId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Schedule session cleanup
|
// Schedule session cleanup
|
||||||
this.spawnerService.scheduleSessionCleanup(agentId);
|
this.spawnerService.scheduleSessionCleanup(agentId);
|
||||||
|
|
||||||
@@ -221,10 +192,6 @@ export class AgentLifecycleService {
|
|||||||
// Emit event
|
// Emit event
|
||||||
await this.publishStateChangeEvent("agent.failed", updatedState, error);
|
await this.publishStateChangeEvent("agent.failed", updatedState, error);
|
||||||
|
|
||||||
await this.recordLifecycleIngestion(agentId, "failed", (ingestionService) =>
|
|
||||||
ingestionService.recordAgentFailed(agentId, error)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Schedule session cleanup
|
// Schedule session cleanup
|
||||||
this.spawnerService.scheduleSessionCleanup(agentId);
|
this.spawnerService.scheduleSessionCleanup(agentId);
|
||||||
|
|
||||||
@@ -261,10 +228,6 @@ export class AgentLifecycleService {
|
|||||||
// Emit event
|
// Emit event
|
||||||
await this.publishStateChangeEvent("agent.killed", updatedState);
|
await this.publishStateChangeEvent("agent.killed", updatedState);
|
||||||
|
|
||||||
await this.recordLifecycleIngestion(agentId, "killed", (ingestionService) =>
|
|
||||||
ingestionService.recordAgentKilled(agentId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Schedule session cleanup
|
// Schedule session cleanup
|
||||||
this.spawnerService.scheduleSessionCleanup(agentId);
|
this.spawnerService.scheduleSessionCleanup(agentId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { Injectable, Logger, HttpException, HttpStatus, OnModuleDestroy } from "@nestjs/common";
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
HttpException,
|
|
||||||
HttpStatus,
|
|
||||||
OnModuleDestroy,
|
|
||||||
Optional,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import Anthropic from "@anthropic-ai/sdk";
|
import Anthropic from "@anthropic-ai/sdk";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
@@ -15,7 +8,6 @@ import {
|
|||||||
AgentSession,
|
AgentSession,
|
||||||
AgentType,
|
AgentType,
|
||||||
} from "./types/agent-spawner.types";
|
} from "./types/agent-spawner.types";
|
||||||
import { AgentIngestionService } from "../agent-ingestion/agent-ingestion.service";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default delay in milliseconds before cleaning up sessions after terminal states
|
* Default delay in milliseconds before cleaning up sessions after terminal states
|
||||||
@@ -38,10 +30,7 @@ export class AgentSpawnerService implements OnModuleDestroy {
|
|||||||
private readonly sessionCleanupDelayMs: number;
|
private readonly sessionCleanupDelayMs: number;
|
||||||
private readonly cleanupTimers = new Map<string, NodeJS.Timeout>();
|
private readonly cleanupTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly configService: ConfigService) {
|
||||||
private readonly configService: ConfigService,
|
|
||||||
@Optional() private readonly agentIngestionService?: AgentIngestionService
|
|
||||||
) {
|
|
||||||
const configuredProvider = this.configService.get<string>("orchestrator.aiProvider");
|
const configuredProvider = this.configService.get<string>("orchestrator.aiProvider");
|
||||||
this.aiProvider = this.normalizeAiProvider(configuredProvider);
|
this.aiProvider = this.normalizeAiProvider(configuredProvider);
|
||||||
|
|
||||||
@@ -109,19 +98,6 @@ export class AgentSpawnerService implements OnModuleDestroy {
|
|||||||
this.cleanupTimers.clear();
|
this.cleanupTimers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private recordSpawnedAgentIngestion(agentId: string, request: SpawnAgentRequest): void {
|
|
||||||
if (!this.agentIngestionService) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void this.agentIngestionService
|
|
||||||
.recordAgentSpawned(agentId, undefined, undefined, request.taskId, request.agentType)
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
this.logger.error(`Failed to record spawned ingestion for ${agentId}: ${errorMessage}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawn a new agent with the given configuration
|
* Spawn a new agent with the given configuration
|
||||||
* @param request Agent spawn request
|
* @param request Agent spawn request
|
||||||
@@ -154,8 +130,6 @@ export class AgentSpawnerService implements OnModuleDestroy {
|
|||||||
// Store session
|
// Store session
|
||||||
this.sessions.set(agentId, session);
|
this.sessions.set(agentId, session);
|
||||||
|
|
||||||
this.recordSpawnedAgentIngestion(agentId, request);
|
|
||||||
|
|
||||||
this.logger.log(`Agent spawned successfully: ${agentId} (type: ${request.agentType})`);
|
this.logger.log(`Agent spawned successfully: ${agentId} (type: ${request.agentType})`);
|
||||||
|
|
||||||
// NOTE: Actual Claude SDK integration will be implemented in next iteration (see issue #TBD)
|
// NOTE: Actual Claude SDK integration will be implemented in next iteration (see issue #TBD)
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import { AgentSpawnerService } from "./agent-spawner.service";
|
|||||||
import { AgentLifecycleService } from "./agent-lifecycle.service";
|
import { AgentLifecycleService } from "./agent-lifecycle.service";
|
||||||
import { DockerSandboxService } from "./docker-sandbox.service";
|
import { DockerSandboxService } from "./docker-sandbox.service";
|
||||||
import { ValkeyModule } from "../valkey/valkey.module";
|
import { ValkeyModule } from "../valkey/valkey.module";
|
||||||
import { AgentIngestionModule } from "../agent-ingestion/agent-ingestion.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ValkeyModule, AgentIngestionModule],
|
imports: [ValkeyModule],
|
||||||
providers: [AgentSpawnerService, AgentLifecycleService, DockerSandboxService],
|
providers: [AgentSpawnerService, AgentLifecycleService, DockerSandboxService],
|
||||||
exports: [AgentSpawnerService, AgentLifecycleService, DockerSandboxService],
|
exports: [AgentSpawnerService, AgentLifecycleService, DockerSandboxService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "node",
|
environment: "node",
|
||||||
setupFiles: ["reflect-metadata"],
|
|
||||||
exclude: ["**/node_modules/**", "**/dist/**", "**/tests/integration/**"],
|
exclude: ["**/node_modules/**", "**/dist/**", "**/tests/integration/**"],
|
||||||
include: ["src/**/*.spec.ts", "src/**/*.test.ts"],
|
include: ["src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||||
coverage: {
|
coverage: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Base image for all stages
|
# Base image for all stages
|
||||||
# Uses Debian slim (glibc) for consistency with API/orchestrator and to prevent
|
# Uses Debian slim (glibc) for consistency with API/orchestrator and to prevent
|
||||||
# future native addon compatibility issues with Alpine's musl libc.
|
# future native addon compatibility issues with Alpine's musl libc.
|
||||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
|
FROM node:24-slim AS base
|
||||||
|
|
||||||
# Install pnpm globally
|
# Install pnpm globally
|
||||||
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
||||||
@@ -24,9 +24,6 @@ COPY packages/ui/package.json ./packages/ui/
|
|||||||
COPY packages/config/package.json ./packages/config/
|
COPY packages/config/package.json ./packages/config/
|
||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
|
||||||
# Copy npm configuration for native binary architecture hints
|
|
||||||
COPY .npmrc ./
|
|
||||||
|
|
||||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -41,9 +38,6 @@ COPY packages/ui/package.json ./packages/ui/
|
|||||||
COPY packages/config/package.json ./packages/config/
|
COPY packages/config/package.json ./packages/config/
|
||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
|
||||||
# Copy npm configuration for native binary architecture hints
|
|
||||||
COPY .npmrc ./
|
|
||||||
|
|
||||||
# Install production dependencies only
|
# Install production dependencies only
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
@@ -93,14 +87,15 @@ RUN mkdir -p ./apps/web/public
|
|||||||
# ======================
|
# ======================
|
||||||
# Production stage
|
# Production stage
|
||||||
# ======================
|
# ======================
|
||||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
|
FROM node:24-slim AS production
|
||||||
|
|
||||||
# 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)
|
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||||
# - Remove npm/npx to reduce image size (not used in production)
|
|
||||||
# - Create non-root user
|
|
||||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||||
|
&& chmod 755 /usr/local/bin/dumb-init \
|
||||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nextjs
|
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nextjs
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared";
|
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
||||||
import { EntryStatus, Visibility } from "@mosaic/shared";
|
import { EntryStatus, Visibility } from "@mosaic/shared";
|
||||||
|
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { fetchEntries, createEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge";
|
import { fetchEntries, createEntry, deleteEntry } from "@/lib/api/knowledge";
|
||||||
import type { EntriesResponse, CreateEntryData, EntryFilters } from "@/lib/api/knowledge";
|
import type { EntriesResponse, CreateEntryData, EntryFilters } from "@/lib/api/knowledge";
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
@@ -421,26 +421,6 @@ function CreateEntryDialog({
|
|||||||
const [visibility, setVisibility] = useState<Visibility>(Visibility.PRIVATE);
|
const [visibility, setVisibility] = useState<Visibility>(Visibility.PRIVATE);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Tag state
|
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
||||||
const [tagInput, setTagInput] = useState("");
|
|
||||||
const [availableTags, setAvailableTags] = useState<KnowledgeTag[]>([]);
|
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
||||||
const tagInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Load available tags when dialog opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
fetchTags()
|
|
||||||
.then((tags) => {
|
|
||||||
setAvailableTags(tags);
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
console.error("Failed to load tags:", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
function resetForm(): void {
|
function resetForm(): void {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setContent("");
|
setContent("");
|
||||||
@@ -448,9 +428,6 @@ function CreateEntryDialog({
|
|||||||
setStatus(EntryStatus.DRAFT);
|
setStatus(EntryStatus.DRAFT);
|
||||||
setVisibility(Visibility.PRIVATE);
|
setVisibility(Visibility.PRIVATE);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setSelectedTags([]);
|
|
||||||
setTagInput("");
|
|
||||||
setShowSuggestions(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
||||||
@@ -475,7 +452,6 @@ function CreateEntryDialog({
|
|||||||
content: trimmedContent,
|
content: trimmedContent,
|
||||||
status,
|
status,
|
||||||
visibility,
|
visibility,
|
||||||
tags: selectedTags,
|
|
||||||
};
|
};
|
||||||
const trimmedSummary = summary.trim();
|
const trimmedSummary = summary.trim();
|
||||||
if (trimmedSummary) {
|
if (trimmedSummary) {
|
||||||
@@ -634,212 +610,6 @@ function CreateEntryDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label
|
|
||||||
htmlFor="entry-tags"
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text-2)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Tags
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
minHeight: 38,
|
|
||||||
padding: "6px 8px",
|
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 4,
|
|
||||||
alignItems: "center",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Selected tag chips */}
|
|
||||||
{selectedTags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
padding: "2px 8px",
|
|
||||||
background: "var(--surface-2)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-sm)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "var(--text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedTags((prev) => prev.filter((t) => t !== tag));
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
padding: 0,
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--muted)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{/* Tag text input */}
|
|
||||||
<input
|
|
||||||
ref={tagInputRef}
|
|
||||||
id="entry-tags"
|
|
||||||
type="text"
|
|
||||||
value={tagInput}
|
|
||||||
onChange={(e) => {
|
|
||||||
setTagInput(e.target.value);
|
|
||||||
setShowSuggestions(e.target.value.length > 0);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === ",") {
|
|
||||||
e.preventDefault();
|
|
||||||
const trimmed = tagInput.trim();
|
|
||||||
if (trimmed && !selectedTags.includes(trimmed)) {
|
|
||||||
setSelectedTags((prev) => [...prev, trimmed]);
|
|
||||||
setTagInput("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e.key === "Backspace" && tagInput === "" && selectedTags.length > 0) {
|
|
||||||
setSelectedTags((prev) => prev.slice(0, -1));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
// Delay to allow click on suggestion
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowSuggestions(false);
|
|
||||||
}, 150);
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
if (tagInput.length > 0) setShowSuggestions(true);
|
|
||||||
}}
|
|
||||||
placeholder={selectedTags.length === 0 ? "Add tags..." : ""}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 80,
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
outline: "none",
|
|
||||||
padding: "2px 0",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Autocomplete suggestions */}
|
|
||||||
{showSuggestions && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "100%",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
marginTop: 4,
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
|
||||||
maxHeight: 150,
|
|
||||||
overflowY: "auto",
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{availableTags
|
|
||||||
.filter(
|
|
||||||
(t) =>
|
|
||||||
t.name.toLowerCase().includes(tagInput.toLowerCase()) &&
|
|
||||||
!selectedTags.includes(t.name)
|
|
||||||
)
|
|
||||||
.slice(0, 5)
|
|
||||||
.map((tag) => (
|
|
||||||
<button
|
|
||||||
key={tag.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!selectedTags.includes(tag.name)) {
|
|
||||||
setSelectedTags((prev) => [...prev, tag.name]);
|
|
||||||
}
|
|
||||||
setTagInput("");
|
|
||||||
setShowSuggestions(false);
|
|
||||||
tagInputRef.current?.focus();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
textAlign: "left",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = "var(--surface-2)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{availableTags.filter(
|
|
||||||
(t) =>
|
|
||||||
t.name.toLowerCase().includes(tagInput.toLowerCase()) &&
|
|
||||||
!selectedTags.includes(t.name)
|
|
||||||
).length === 0 &&
|
|
||||||
tagInput.trim() &&
|
|
||||||
!selectedTags.includes(tagInput.trim()) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const trimmed = tagInput.trim();
|
|
||||||
if (trimmed && !selectedTags.includes(trimmed)) {
|
|
||||||
setSelectedTags((prev) => [...prev, trimmed]);
|
|
||||||
}
|
|
||||||
setTagInput("");
|
|
||||||
setShowSuggestions(false);
|
|
||||||
tagInputRef.current?.focus();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
textAlign: "left",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontStyle: "italic",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create "{tagInput.trim()}"
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status + Visibility row */}
|
{/* Status + Visibility row */}
|
||||||
<div style={{ display: "flex", gap: 16, marginBottom: 16 }}>
|
<div style={{ display: "flex", gap: 16, marginBottom: 16 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import type { Task } from "@mosaic/shared";
|
|
||||||
import { TaskPriority, TaskStatus } from "@mosaic/shared";
|
|
||||||
import KanbanPage from "./page";
|
|
||||||
|
|
||||||
const mockReplace = vi.fn();
|
|
||||||
let mockSearchParams = new URLSearchParams();
|
|
||||||
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: (): { replace: typeof mockReplace } => ({ replace: mockReplace }),
|
|
||||||
useSearchParams: (): URLSearchParams => mockSearchParams,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@hello-pangea/dnd", () => ({
|
|
||||||
DragDropContext: ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
|
||||||
<div data-testid="mock-dnd-context">{children}</div>
|
|
||||||
),
|
|
||||||
Droppable: ({
|
|
||||||
children,
|
|
||||||
droppableId,
|
|
||||||
}: {
|
|
||||||
children: (provided: {
|
|
||||||
innerRef: (el: HTMLElement | null) => void;
|
|
||||||
droppableProps: Record<string, never>;
|
|
||||||
placeholder: React.ReactNode;
|
|
||||||
}) => React.ReactNode;
|
|
||||||
droppableId: string;
|
|
||||||
}): React.JSX.Element => (
|
|
||||||
<div data-testid={`mock-droppable-${droppableId}`}>
|
|
||||||
{children({
|
|
||||||
innerRef: () => {
|
|
||||||
/* noop */
|
|
||||||
},
|
|
||||||
droppableProps: {},
|
|
||||||
placeholder: null,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
Draggable: ({
|
|
||||||
children,
|
|
||||||
draggableId,
|
|
||||||
}: {
|
|
||||||
children: (
|
|
||||||
provided: {
|
|
||||||
innerRef: (el: HTMLElement | null) => void;
|
|
||||||
draggableProps: { style: Record<string, string> };
|
|
||||||
dragHandleProps: Record<string, string>;
|
|
||||||
},
|
|
||||||
snapshot: { isDragging: boolean }
|
|
||||||
) => React.ReactNode;
|
|
||||||
draggableId: string;
|
|
||||||
index: number;
|
|
||||||
}): React.JSX.Element => (
|
|
||||||
<div data-testid={`mock-draggable-${draggableId}`}>
|
|
||||||
{children(
|
|
||||||
{
|
|
||||||
innerRef: () => {
|
|
||||||
/* noop */
|
|
||||||
},
|
|
||||||
draggableProps: { style: {} },
|
|
||||||
dragHandleProps: {},
|
|
||||||
},
|
|
||||||
{ isDragging: false }
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/MosaicSpinner", () => ({
|
|
||||||
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
|
|
||||||
<div data-testid="mosaic-spinner">{label ?? "Loading..."}</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockUseWorkspaceId = vi.fn<() => string | null>();
|
|
||||||
vi.mock("@/lib/hooks", () => ({
|
|
||||||
useWorkspaceId: (): string | null => mockUseWorkspaceId(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockFetchTasks = vi.fn<() => Promise<Task[]>>();
|
|
||||||
const mockUpdateTask = vi.fn<() => Promise<unknown>>();
|
|
||||||
const mockCreateTask = vi.fn<() => Promise<Task>>();
|
|
||||||
vi.mock("@/lib/api/tasks", () => ({
|
|
||||||
fetchTasks: (...args: unknown[]): Promise<Task[]> => mockFetchTasks(...(args as [])),
|
|
||||||
updateTask: (...args: unknown[]): Promise<unknown> => mockUpdateTask(...(args as [])),
|
|
||||||
createTask: (...args: unknown[]): Promise<Task> => mockCreateTask(...(args as [])),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockFetchProjects = vi.fn<() => Promise<unknown[]>>();
|
|
||||||
vi.mock("@/lib/api/projects", () => ({
|
|
||||||
fetchProjects: (...args: unknown[]): Promise<unknown[]> => mockFetchProjects(...(args as [])),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const createdTask: Task = {
|
|
||||||
id: "task-new-1",
|
|
||||||
title: "Ship Kanban add task flow",
|
|
||||||
description: null,
|
|
||||||
status: TaskStatus.NOT_STARTED,
|
|
||||||
priority: TaskPriority.MEDIUM,
|
|
||||||
dueDate: null,
|
|
||||||
creatorId: "user-1",
|
|
||||||
assigneeId: null,
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
projectId: "project-42",
|
|
||||||
parentId: null,
|
|
||||||
sortOrder: 0,
|
|
||||||
metadata: {},
|
|
||||||
completedAt: null,
|
|
||||||
createdAt: new Date("2026-03-01"),
|
|
||||||
updatedAt: new Date("2026-03-01"),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("KanbanPage add task flow", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockSearchParams = new URLSearchParams("project=project-42");
|
|
||||||
mockUseWorkspaceId.mockReturnValue("ws-1");
|
|
||||||
mockFetchTasks.mockResolvedValue([]);
|
|
||||||
mockFetchProjects.mockResolvedValue([]);
|
|
||||||
mockCreateTask.mockResolvedValue(createdTask);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens add-task form in a column and creates a task via API", async (): Promise<void> => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
|
|
||||||
render(<KanbanPage />);
|
|
||||||
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(screen.getByText("Kanban Board")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click the "+ Add task" button in the To Do column
|
|
||||||
const addTaskButtons = screen.getAllByRole("button", { name: /\+ Add task/i });
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
await user.click(addTaskButtons[0]!); // First column is "To Do"
|
|
||||||
|
|
||||||
// Type in the title input
|
|
||||||
const titleInput = screen.getByPlaceholderText("Task title...");
|
|
||||||
await user.type(titleInput, createdTask.title);
|
|
||||||
|
|
||||||
// Click the Add button
|
|
||||||
await user.click(screen.getByRole("button", { name: /✓ Add/i }));
|
|
||||||
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(mockCreateTask).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
title: createdTask.title,
|
|
||||||
status: TaskStatus.NOT_STARTED,
|
|
||||||
projectId: "project-42",
|
|
||||||
}),
|
|
||||||
"ws-1"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels add-task form when pressing Escape", async (): Promise<void> => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
|
|
||||||
render(<KanbanPage />);
|
|
||||||
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(screen.getByText("Kanban Board")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click the "+ Add task" button
|
|
||||||
const addTaskButtons = screen.getAllByRole("button", { name: /\+ Add task/i });
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
await user.click(addTaskButtons[0]!);
|
|
||||||
|
|
||||||
// Type in the title input
|
|
||||||
const titleInput = screen.getByPlaceholderText("Task title...");
|
|
||||||
await user.type(titleInput, "Test task");
|
|
||||||
|
|
||||||
// Press Escape to cancel
|
|
||||||
await user.keyboard("{Escape}");
|
|
||||||
|
|
||||||
// Form should be closed, back to "+ Add task" button
|
|
||||||
await waitFor((): void => {
|
|
||||||
const buttons = screen.getAllByRole("button", { name: /\+ Add task/i });
|
|
||||||
expect(buttons.length).toBe(5); // One per column
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should not have called createTask
|
|
||||||
expect(mockCreateTask).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
} from "@hello-pangea/dnd";
|
} from "@hello-pangea/dnd";
|
||||||
|
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
import { fetchTasks, updateTask, createTask, type TaskFilters } from "@/lib/api/tasks";
|
import { fetchTasks, updateTask, type TaskFilters } from "@/lib/api/tasks";
|
||||||
import { fetchProjects, type Project } from "@/lib/api/projects";
|
import { fetchProjects, type Project } from "@/lib/api/projects";
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
import type { Task } from "@mosaic/shared";
|
import type { Task } from "@mosaic/shared";
|
||||||
@@ -184,48 +184,9 @@ function TaskCard({ task, provided, snapshot, columnAccent }: TaskCardProps): Re
|
|||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
config: ColumnConfig;
|
config: ColumnConfig;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
onAddTask: (status: TaskStatus, title: string, projectId?: string) => Promise<void>;
|
|
||||||
projectId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanColumn({ config, tasks, onAddTask, projectId }: KanbanColumnProps): ReactElement {
|
function KanbanColumn({ config, tasks }: KanbanColumnProps): ReactElement {
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
|
||||||
const [inputValue, setInputValue] = useState("");
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Focus input when form is shown
|
|
||||||
useEffect(() => {
|
|
||||||
if (showAddForm && inputRef.current) {
|
|
||||||
inputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, [showAddForm]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.SyntheticEvent): Promise<void> => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!inputValue.trim() || isSubmitting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
await onAddTask(config.status, inputValue.trim(), projectId);
|
|
||||||
setInputValue("");
|
|
||||||
setShowAddForm(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[KanbanColumn] Failed to add task:", err);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setShowAddForm(false);
|
|
||||||
setInputValue("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -307,128 +268,6 @@ function KanbanColumn({ config, tasks, onAddTask, projectId }: KanbanColumnProps
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Droppable>
|
</Droppable>
|
||||||
|
|
||||||
{/* Add Task Form */}
|
|
||||||
{!showAddForm ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddForm(true);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: "10px 16px",
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
cursor: "pointer",
|
|
||||||
textAlign: "left",
|
|
||||||
transition: "color 0.15s",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.color = "var(--text)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.color = "var(--muted)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+ Add task
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
style={{ padding: "8px 12px 12px", borderTop: "1px solid var(--border)" }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => {
|
|
||||||
setInputValue(e.target.value);
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Task title..."
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 10px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
border: `1px solid ${inputValue ? "var(--primary)" : "var(--border)"}`,
|
|
||||||
background: "var(--surface)",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
outline: "none",
|
|
||||||
opacity: isSubmitting ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting || !inputValue.trim()}
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
border: "1px solid var(--primary)",
|
|
||||||
background: "var(--primary)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: isSubmitting || !inputValue.trim() ? "not-allowed" : "pointer",
|
|
||||||
opacity: isSubmitting || !inputValue.trim() ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✓ Add
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddForm(false);
|
|
||||||
setInputValue("");
|
|
||||||
}}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
background: "transparent",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
|
||||||
opacity: isSubmitting ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: 6, fontSize: "0.75rem", color: "var(--muted)" }}>
|
|
||||||
Press{" "}
|
|
||||||
<kbd
|
|
||||||
style={{
|
|
||||||
padding: "2px 4px",
|
|
||||||
background: "var(--bg-mid)",
|
|
||||||
borderRadius: "2px",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Enter
|
|
||||||
</kbd>{" "}
|
|
||||||
to save,{" "}
|
|
||||||
<kbd
|
|
||||||
style={{
|
|
||||||
padding: "2px 4px",
|
|
||||||
background: "var(--bg-mid)",
|
|
||||||
borderRadius: "2px",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Escape
|
|
||||||
</kbd>{" "}
|
|
||||||
to cancel
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -782,31 +621,6 @@ export default function KanbanPage(): ReactElement {
|
|||||||
void loadTasks(workspaceId);
|
void loadTasks(workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- add task handler --- */
|
|
||||||
|
|
||||||
const handleAddTask = useCallback(
|
|
||||||
async (status: TaskStatus, title: string, projectId?: string) => {
|
|
||||||
try {
|
|
||||||
const wsId = workspaceId ?? undefined;
|
|
||||||
const taskData: { title: string; status: TaskStatus; projectId?: string } = {
|
|
||||||
title,
|
|
||||||
status,
|
|
||||||
};
|
|
||||||
if (projectId) {
|
|
||||||
taskData.projectId = projectId;
|
|
||||||
}
|
|
||||||
const newTask = await createTask(taskData, wsId);
|
|
||||||
// Optimistically add to local state
|
|
||||||
setTasks((prev) => [...prev, newTask]);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("[Kanban] Failed to create task:", err);
|
|
||||||
// Re-fetch on error to get consistent state
|
|
||||||
void loadTasks(workspaceId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[workspaceId, loadTasks]
|
|
||||||
);
|
|
||||||
|
|
||||||
/* --- render --- */
|
/* --- render --- */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -913,8 +727,23 @@ export default function KanbanPage(): ReactElement {
|
|||||||
Clear filters
|
Clear filters
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
/* Empty state */
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: 48,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
|
||||||
|
No tasks yet. Create some tasks to see them here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Board (always render columns to allow adding first task) */
|
/* Board */
|
||||||
<DragDropContext onDragEnd={handleDragEnd}>
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -926,13 +755,7 @@ export default function KanbanPage(): ReactElement {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{COLUMNS.map((col) => (
|
{COLUMNS.map((col) => (
|
||||||
<KanbanColumn
|
<KanbanColumn key={col.status} config={col} tasks={grouped[col.status]} />
|
||||||
key={col.status}
|
|
||||||
config={col}
|
|
||||||
tasks={grouped[col.status]}
|
|
||||||
onAddTask={handleAddTask}
|
|
||||||
projectId={filterProject}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
|
|||||||
@@ -4,39 +4,21 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
import {
|
import { fetchRunnerJobs, fetchJobSteps, RunnerJobStatus } from "@/lib/api/runner-jobs";
|
||||||
fetchActivityLogs,
|
import type { RunnerJob, JobStep } from "@/lib/api/runner-jobs";
|
||||||
ActivityAction,
|
|
||||||
EntityType,
|
|
||||||
type ActivityLog,
|
|
||||||
type ActivityLogFilters,
|
|
||||||
} from "@/lib/api/activity";
|
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type ActionFilter = "all" | ActivityAction;
|
type StatusFilter = "all" | "running" | "completed" | "failed" | "queued";
|
||||||
type EntityFilter = "all" | EntityType;
|
|
||||||
type DateRange = "24h" | "7d" | "30d" | "all";
|
type DateRange = "24h" | "7d" | "30d" | "all";
|
||||||
|
|
||||||
const ACTION_OPTIONS: { value: ActionFilter; label: string }[] = [
|
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||||
{ value: "all", label: "All actions" },
|
{ value: "all", label: "All statuses" },
|
||||||
{ value: ActivityAction.CREATED, label: "Created" },
|
{ value: "running", label: "Running" },
|
||||||
{ value: ActivityAction.UPDATED, label: "Updated" },
|
{ value: "completed", label: "Completed" },
|
||||||
{ value: ActivityAction.DELETED, label: "Deleted" },
|
{ value: "failed", label: "Failed" },
|
||||||
{ value: ActivityAction.COMPLETED, label: "Completed" },
|
{ value: "queued", label: "Queued" },
|
||||||
{ value: ActivityAction.ASSIGNED, label: "Assigned" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ENTITY_OPTIONS: { value: EntityFilter; label: string }[] = [
|
|
||||||
{ value: "all", label: "All entities" },
|
|
||||||
{ value: EntityType.TASK, label: "Tasks" },
|
|
||||||
{ value: EntityType.EVENT, label: "Events" },
|
|
||||||
{ value: EntityType.PROJECT, label: "Projects" },
|
|
||||||
{ value: EntityType.WORKSPACE, label: "Workspaces" },
|
|
||||||
{ value: EntityType.USER, label: "Users" },
|
|
||||||
{ value: EntityType.DOMAIN, label: "Domains" },
|
|
||||||
{ value: EntityType.IDEA, label: "Ideas" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const DATE_RANGES: { value: DateRange; label: string }[] = [
|
const DATE_RANGES: { value: DateRange; label: string }[] = [
|
||||||
@@ -46,37 +28,37 @@ const DATE_RANGES: { value: DateRange; label: string }[] = [
|
|||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const STATUS_FILTER_TO_ENUM: Record<StatusFilter, RunnerJobStatus[] | undefined> = {
|
||||||
|
all: undefined,
|
||||||
|
running: [RunnerJobStatus.RUNNING],
|
||||||
|
completed: [RunnerJobStatus.COMPLETED],
|
||||||
|
failed: [RunnerJobStatus.FAILED],
|
||||||
|
queued: [RunnerJobStatus.QUEUED, RunnerJobStatus.PENDING],
|
||||||
|
};
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 5_000;
|
const POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ACTION_COLORS: Record<string, string> = {
|
function getStatusColor(status: string): string {
|
||||||
[ActivityAction.CREATED]: "var(--ms-teal-400)",
|
switch (status) {
|
||||||
[ActivityAction.UPDATED]: "var(--ms-blue-400)",
|
case "RUNNING":
|
||||||
[ActivityAction.DELETED]: "var(--danger)",
|
return "var(--ms-amber-400)";
|
||||||
[ActivityAction.COMPLETED]: "var(--ms-emerald-400)",
|
case "COMPLETED":
|
||||||
[ActivityAction.ASSIGNED]: "var(--ms-amber-400)",
|
return "var(--ms-teal-400)";
|
||||||
};
|
case "FAILED":
|
||||||
|
case "CANCELLED":
|
||||||
function getActionColor(action: string): string {
|
return "var(--danger)";
|
||||||
return ACTION_COLORS[action] ?? "var(--muted)";
|
case "QUEUED":
|
||||||
|
case "PENDING":
|
||||||
|
return "var(--ms-blue-400)";
|
||||||
|
default:
|
||||||
|
return "var(--muted)";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENTITY_LABELS: Record<string, string> = {
|
function formatRelativeTime(dateStr: string | null): string {
|
||||||
[EntityType.TASK]: "Task",
|
if (!dateStr) return "\u2014";
|
||||||
[EntityType.EVENT]: "Event",
|
|
||||||
[EntityType.PROJECT]: "Project",
|
|
||||||
[EntityType.WORKSPACE]: "Workspace",
|
|
||||||
[EntityType.USER]: "User",
|
|
||||||
[EntityType.DOMAIN]: "Domain",
|
|
||||||
[EntityType.IDEA]: "Idea",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getEntityTypeLabel(entityType: string): string {
|
|
||||||
return ENTITY_LABELS[entityType] ?? entityType;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const diffMs = now - date.getTime();
|
const diffMs = now - date.getTime();
|
||||||
@@ -92,6 +74,29 @@ function formatRelativeTime(dateStr: string): string {
|
|||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(startedAt: string | null, completedAt: string | null): string {
|
||||||
|
if (!startedAt) return "\u2014";
|
||||||
|
const start = new Date(startedAt).getTime();
|
||||||
|
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||||
|
const ms = end - start;
|
||||||
|
if (ms < 1_000) return `${String(ms)}ms`;
|
||||||
|
const sec = Math.floor(ms / 1_000);
|
||||||
|
if (sec < 60) return `${String(sec)}s`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
const remainSec = sec % 60;
|
||||||
|
return `${String(min)}m ${String(remainSec)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStepDuration(durationMs: number | null): string {
|
||||||
|
if (durationMs === null) return "\u2014";
|
||||||
|
if (durationMs < 1_000) return `${String(durationMs)}ms`;
|
||||||
|
const sec = Math.floor(durationMs / 1_000);
|
||||||
|
if (sec < 60) return `${String(sec)}s`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
const remainSec = sec % 60;
|
||||||
|
return `${String(min)}m ${String(remainSec)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
function isWithinDateRange(dateStr: string, range: DateRange): boolean {
|
function isWithinDateRange(dateStr: string, range: DateRange): boolean {
|
||||||
if (range === "all") return true;
|
if (range === "all") return true;
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
@@ -100,16 +105,18 @@ function isWithinDateRange(dateStr: string, range: DateRange): boolean {
|
|||||||
return now - date.getTime() < hours * 60 * 60 * 1_000;
|
return now - date.getTime() < hours * 60 * 60 * 1_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Action Badge ─────────────────────────────────────────────────────
|
// ─── Status Badge ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ActionBadge({ action }: { action: string }): ReactElement {
|
function StatusBadge({ status }: { status: string }): ReactElement {
|
||||||
const color = getActionColor(action);
|
const color = getStatusColor(status);
|
||||||
|
const isRunning = status === "RUNNING";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
padding: "2px 10px",
|
padding: "2px 10px",
|
||||||
borderRadius: 9999,
|
borderRadius: 9999,
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
@@ -120,7 +127,18 @@ function ActionBadge({ action }: { action: string }): ReactElement {
|
|||||||
textTransform: "capitalize",
|
textTransform: "capitalize",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{action.toLowerCase()}
|
{isRunning && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: color,
|
||||||
|
animation: "pulse 1.5s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{status.toLowerCase()}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -131,55 +149,59 @@ export default function LogsPage(): ReactElement {
|
|||||||
const workspaceId = useWorkspaceId();
|
const workspaceId = useWorkspaceId();
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
const [activities, setActivities] = useState<ActivityLog[]>([]);
|
const [jobs, setJobs] = useState<RunnerJob[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Expanded job and steps
|
||||||
|
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
||||||
|
const [jobStepsMap, setJobStepsMap] = useState<Record<string, JobStep[]>>({});
|
||||||
|
const [stepsLoading, setStepsLoading] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [actionFilter, setActionFilter] = useState<ActionFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||||
const [entityFilter, setEntityFilter] = useState<EntityFilter>("all");
|
|
||||||
const [dateRange, setDateRange] = useState<DateRange>("7d");
|
const [dateRange, setDateRange] = useState<DateRange>("7d");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
// Auto-refresh
|
// Auto-refresh
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// Hover state
|
||||||
|
const [hoveredRowId, setHoveredRowId] = useState<string | null>(null);
|
||||||
|
|
||||||
// ─── Data Loading ─────────────────────────────────────────────────
|
// ─── Data Loading ─────────────────────────────────────────────────
|
||||||
|
|
||||||
const loadActivities = useCallback(async (): Promise<void> => {
|
const loadJobs = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const filters: ActivityLogFilters = {};
|
const statusEnums = STATUS_FILTER_TO_ENUM[statusFilter];
|
||||||
|
const filters: Parameters<typeof fetchRunnerJobs>[0] = {};
|
||||||
if (workspaceId) {
|
if (workspaceId) {
|
||||||
filters.workspaceId = workspaceId;
|
filters.workspaceId = workspaceId;
|
||||||
}
|
}
|
||||||
if (actionFilter !== "all") {
|
if (statusEnums) {
|
||||||
filters.action = actionFilter;
|
filters.status = statusEnums;
|
||||||
}
|
|
||||||
if (entityFilter !== "all") {
|
|
||||||
filters.entityType = entityFilter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response: Awaited<ReturnType<typeof fetchActivityLogs>> =
|
const data = await fetchRunnerJobs(filters);
|
||||||
await fetchActivityLogs(filters);
|
setJobs(data);
|
||||||
setActivities(response);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error("[Logs] Failed to fetch activity logs:", err);
|
console.error("[Logs] Failed to fetch runner jobs:", err);
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: "We had trouble loading activity logs. Please try again when you're ready."
|
: "We had trouble loading jobs. Please try again when you're ready."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [workspaceId, actionFilter, entityFilter]);
|
}, [workspaceId, statusFilter]);
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
loadActivities()
|
loadJobs()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -194,13 +216,13 @@ export default function LogsPage(): ReactElement {
|
|||||||
return (): void => {
|
return (): void => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [loadActivities]);
|
}, [loadJobs]);
|
||||||
|
|
||||||
// Auto-refresh polling
|
// Auto-refresh polling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) {
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
void loadActivities();
|
void loadJobs();
|
||||||
}, POLL_INTERVAL_MS);
|
}, POLL_INTERVAL_MS);
|
||||||
} else if (intervalRef.current) {
|
} else if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
@@ -213,22 +235,55 @@ export default function LogsPage(): ReactElement {
|
|||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [autoRefresh, loadActivities]);
|
}, [autoRefresh, loadJobs]);
|
||||||
|
|
||||||
|
// ─── Steps Loading ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const toggleExpand = useCallback(
|
||||||
|
(jobId: string) => {
|
||||||
|
if (expandedJobId === jobId) {
|
||||||
|
setExpandedJobId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpandedJobId(jobId);
|
||||||
|
|
||||||
|
// Load steps if not already loaded
|
||||||
|
if (!jobStepsMap[jobId] && !stepsLoading.has(jobId)) {
|
||||||
|
setStepsLoading((prev) => new Set(prev).add(jobId));
|
||||||
|
|
||||||
|
fetchJobSteps(jobId, workspaceId ?? undefined)
|
||||||
|
.then((steps) => {
|
||||||
|
setJobStepsMap((prev) => ({ ...prev, [jobId]: steps }));
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("[Logs] Failed to fetch steps for job:", jobId, err);
|
||||||
|
setJobStepsMap((prev) => ({ ...prev, [jobId]: [] }));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setStepsLoading((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(jobId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[expandedJobId, jobStepsMap, stepsLoading, workspaceId]
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Filtering ────────────────────────────────────────────────────
|
// ─── Filtering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const filteredActivities = activities.filter((activity) => {
|
const filteredJobs = jobs.filter((job) => {
|
||||||
// Date range filter
|
// Date range filter
|
||||||
if (!isWithinDateRange(activity.createdAt, dateRange)) return false;
|
if (!isWithinDateRange(job.createdAt, dateRange)) return false;
|
||||||
|
|
||||||
// Search filter
|
// Search filter
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
const matchesEntity = getEntityTypeLabel(activity.entityType).toLowerCase().includes(q);
|
const matchesType = job.type.toLowerCase().includes(q);
|
||||||
const matchesId = activity.entityId.toLowerCase().includes(q);
|
const matchesId = job.id.toLowerCase().includes(q);
|
||||||
const matchesUser = activity.user?.name?.toLowerCase().includes(q);
|
if (!matchesType && !matchesId) return false;
|
||||||
const matchesEmail = activity.user?.email.toLowerCase().includes(q);
|
|
||||||
if (!matchesEntity && !matchesId && !matchesUser && !matchesEmail) return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -238,7 +293,7 @@ export default function LogsPage(): ReactElement {
|
|||||||
|
|
||||||
const handleManualRefresh = (): void => {
|
const handleManualRefresh = (): void => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
void loadActivities().finally(() => {
|
void loadJobs().finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -252,12 +307,16 @@ export default function LogsPage(): ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
{/* Pulse animation for auto-refresh */}
|
{/* Pulse animation for running status */}
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.4; }
|
50% { opacity: 0.4; }
|
||||||
}
|
}
|
||||||
|
@keyframes auto-refresh-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* ─── Header ─────────────────────────────────────────────── */}
|
{/* ─── Header ─────────────────────────────────────────────── */}
|
||||||
@@ -273,10 +332,10 @@ export default function LogsPage(): ReactElement {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
Activity Logs
|
Logs & Telemetry
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1" style={{ color: "var(--text-muted)" }}>
|
<p className="mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
Audit trail and activity history
|
Runner job history and step-level detail
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -349,11 +408,11 @@ export default function LogsPage(): ReactElement {
|
|||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Action filter */}
|
{/* Status filter */}
|
||||||
<select
|
<select
|
||||||
value={actionFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setActionFilter(e.target.value as ActionFilter);
|
setStatusFilter(e.target.value as StatusFilter);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: "8px 12px",
|
padding: "8px 12px",
|
||||||
@@ -366,31 +425,7 @@ export default function LogsPage(): ReactElement {
|
|||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ACTION_OPTIONS.map((opt) => (
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Entity filter */}
|
|
||||||
<select
|
|
||||||
value={entityFilter}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEntityFilter(e.target.value as EntityFilter);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
background: "var(--surface)",
|
|
||||||
color: "var(--text)",
|
|
||||||
cursor: "pointer",
|
|
||||||
minWidth: 140,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ENTITY_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</option>
|
</option>
|
||||||
@@ -432,7 +467,7 @@ export default function LogsPage(): ReactElement {
|
|||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by entity or user..."
|
placeholder="Search by job type..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
@@ -452,9 +487,9 @@ export default function LogsPage(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Content ────────────────────────────────────────────── */}
|
{/* ─── Content ────────────────────────────────────────────── */}
|
||||||
{isLoading && activities.length === 0 ? (
|
{isLoading && jobs.length === 0 ? (
|
||||||
<div className="flex justify-center py-16">
|
<div className="flex justify-center py-16">
|
||||||
<MosaicSpinner label="Loading activity logs..." />
|
<MosaicSpinner label="Loading jobs..." />
|
||||||
</div>
|
</div>
|
||||||
) : error !== null ? (
|
) : error !== null ? (
|
||||||
<div
|
<div
|
||||||
@@ -473,7 +508,7 @@ export default function LogsPage(): ReactElement {
|
|||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : filteredActivities.length === 0 ? (
|
) : filteredJobs.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg p-8 text-center"
|
className="rounded-lg p-8 text-center"
|
||||||
style={{
|
style={{
|
||||||
@@ -481,10 +516,10 @@ export default function LogsPage(): ReactElement {
|
|||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p style={{ color: "var(--text-muted)" }}>No activity logs found</p>
|
<p style={{ color: "var(--text-muted)" }}>No jobs found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ─── Activity Table ──────────────────────────────────────── */
|
/* ─── Job Table ──────────────────────────────────────────── */
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@@ -500,7 +535,7 @@ export default function LogsPage(): ReactElement {
|
|||||||
background: "var(--bg-mid)",
|
background: "var(--bg-mid)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{["Action", "Entity", "User", "Details", "Time"].map((header) => (
|
{["Job Type", "Status", "Started", "Duration", "Steps"].map((header) => (
|
||||||
<th
|
<th
|
||||||
key={header}
|
key={header}
|
||||||
style={{
|
style={{
|
||||||
@@ -521,9 +556,32 @@ export default function LogsPage(): ReactElement {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredActivities.map((activity) => (
|
{filteredJobs.map((job) => {
|
||||||
<ActivityRow key={activity.id} activity={activity} />
|
const isExpanded = expandedJobId === job.id;
|
||||||
))}
|
const isHovered = hoveredRowId === job.id;
|
||||||
|
const steps = jobStepsMap[job.id];
|
||||||
|
const isStepsLoading = stepsLoading.has(job.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JobRow
|
||||||
|
key={job.id}
|
||||||
|
job={job}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isHovered={isHovered}
|
||||||
|
steps={steps}
|
||||||
|
isStepsLoading={isStepsLoading}
|
||||||
|
onToggle={() => {
|
||||||
|
toggleExpand(job.id);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHoveredRowId(job.id);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHoveredRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -533,91 +591,260 @@ export default function LogsPage(): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Activity Row Component ───────────────────────────────────────────
|
// ─── Job Row Component ────────────────────────────────────────────────
|
||||||
|
|
||||||
function ActivityRow({ activity }: { activity: ActivityLog }): ReactElement {
|
function JobRow({
|
||||||
|
job,
|
||||||
|
isExpanded,
|
||||||
|
isHovered,
|
||||||
|
steps,
|
||||||
|
isStepsLoading,
|
||||||
|
onToggle,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
}: {
|
||||||
|
job: RunnerJob;
|
||||||
|
isExpanded: boolean;
|
||||||
|
isHovered: boolean;
|
||||||
|
steps: JobStep[] | undefined;
|
||||||
|
isStepsLoading: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onMouseEnter: () => void;
|
||||||
|
onMouseLeave: () => void;
|
||||||
|
}): ReactElement {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<tr
|
<tr
|
||||||
|
onClick={onToggle}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
style={{
|
style={{
|
||||||
background: "var(--surface)",
|
background: isExpanded
|
||||||
borderBottom: "1px solid var(--border)",
|
? "var(--surface-2)"
|
||||||
|
: isHovered
|
||||||
|
? "var(--surface-2)"
|
||||||
|
: "var(--surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderBottom: isExpanded ? "none" : "1px solid var(--border)",
|
||||||
transition: "background 100ms ease",
|
transition: "background 100ms ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td style={{ padding: "12px 16px" }}>
|
|
||||||
<ActionBadge action={activity.action} />
|
|
||||||
</td>
|
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 16px",
|
padding: "12px 16px",
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.85rem",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: "var(--text)",
|
color: "var(--text)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||||
<span>{getEntityTypeLabel(activity.entityType)}</span>
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.75rem",
|
display: "inline-block",
|
||||||
|
width: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.7rem",
|
||||||
color: "var(--muted)",
|
color: "var(--muted)",
|
||||||
fontFamily: "var(--mono)",
|
transition: "transform 150ms ease",
|
||||||
|
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activity.entityId}
|
▶
|
||||||
</span>
|
</span>
|
||||||
</div>
|
{job.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px 16px" }}>
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 16px",
|
padding: "12px 16px",
|
||||||
fontSize: "0.82rem",
|
fontSize: "0.82rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatRelativeTime(job.startedAt ?? job.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(job.startedAt, job.completedAt)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{steps ? String(steps.length) : "\u2014"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expanded Steps Section */}
|
||||||
|
{isExpanded && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
padding: "12px 16px 12px 48px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isStepsLoading ? (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
|
||||||
|
<MosaicSpinner size={24} label="Loading steps..." />
|
||||||
|
</div>
|
||||||
|
) : !steps || steps.length === 0 ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
padding: "8px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No steps recorded for this job
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{["#", "Name", "Phase", "Status", "Duration"].map((header) => (
|
||||||
|
<th
|
||||||
|
key={header}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{steps
|
||||||
|
.sort((a, b) => a.ordinal - b.ordinal)
|
||||||
|
.map((step) => (
|
||||||
|
<StepRow key={step.id} step={step} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Job error message if failed */}
|
||||||
|
{job.error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
background: "color-mix(in srgb, var(--danger) 8%, transparent)",
|
||||||
|
border: "1px solid color-mix(in srgb, var(--danger) 20%, transparent)",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{job.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step Row Component ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StepRow({ step }: { step: JobStep }): ReactElement {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: hovered ? "color-mix(in srgb, var(--surface) 50%, transparent)" : "transparent",
|
||||||
|
borderBottom: "1px solid color-mix(in srgb, var(--border) 50%, transparent)",
|
||||||
|
transition: "background 100ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(step.ordinal)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: "0.8rem",
|
||||||
color: "var(--text)",
|
color: "var(--text)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activity.user ? (
|
{step.name}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
</td>
|
||||||
<span>{activity.user.name ?? activity.user.email}</span>
|
<td
|
||||||
{activity.user.name && (
|
|
||||||
<span
|
|
||||||
style={{
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
color: "var(--muted)",
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
textTransform: "lowercase",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activity.user.email}
|
{step.phase}
|
||||||
</span>
|
</td>
|
||||||
)}
|
<td style={{ padding: "6px 12px" }}>
|
||||||
</div>
|
<StatusBadge status={step.status} />
|
||||||
) : (
|
|
||||||
<span style={{ color: "var(--muted)" }}>—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 16px",
|
padding: "6px 12px",
|
||||||
fontSize: "0.78rem",
|
fontSize: "0.78rem",
|
||||||
color: "var(--text-muted)",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
maxWidth: 300,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
title={activity.details ? JSON.stringify(activity.details) : undefined}
|
|
||||||
>
|
|
||||||
{activity.details ? JSON.stringify(activity.details) : "—"}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontFamily: "var(--mono)",
|
fontFamily: "var(--mono)",
|
||||||
color: "var(--text-muted)",
|
color: "var(--text-muted)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatRelativeTime(activity.createdAt)}
|
{formatStepDuration(step.durationMs)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,491 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import type { ReactElement } from "react";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
|
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
|
||||||
import { fetchProject, type ProjectDetail } from "@/lib/api/projects";
|
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
|
||||||
|
|
||||||
interface BadgeStyle {
|
|
||||||
label: string;
|
|
||||||
bg: string;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
style: BadgeStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MetaItemProps {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProjectStatusStyle(status: string): BadgeStyle {
|
|
||||||
switch (status) {
|
|
||||||
case "PLANNING":
|
|
||||||
return { label: "Planning", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" };
|
|
||||||
case "ACTIVE":
|
|
||||||
return { label: "Active", bg: "rgba(20,184,166,0.15)", color: "var(--success)" };
|
|
||||||
case "PAUSED":
|
|
||||||
return { label: "Paused", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
|
||||||
case "COMPLETED":
|
|
||||||
return { label: "Completed", bg: "rgba(139,92,246,0.15)", color: "var(--purple)" };
|
|
||||||
case "ARCHIVED":
|
|
||||||
return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
|
||||||
default:
|
|
||||||
return { label: status, bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPriorityStyle(priority: string | null | undefined): BadgeStyle {
|
|
||||||
switch (priority) {
|
|
||||||
case "HIGH":
|
|
||||||
return { label: "High", bg: "rgba(229,72,77,0.15)", color: "var(--danger)" };
|
|
||||||
case "MEDIUM":
|
|
||||||
return { label: "Medium", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
|
||||||
case "LOW":
|
|
||||||
return { label: "Low", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
|
||||||
default:
|
|
||||||
return { label: "Unspecified", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTaskStatusStyle(status: string): BadgeStyle {
|
|
||||||
switch (status) {
|
|
||||||
case "NOT_STARTED":
|
|
||||||
return { label: "Not Started", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" };
|
|
||||||
case "IN_PROGRESS":
|
|
||||||
return { label: "In Progress", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
|
||||||
case "PAUSED":
|
|
||||||
return { label: "Paused", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
|
||||||
case "COMPLETED":
|
|
||||||
return { label: "Completed", bg: "rgba(20,184,166,0.15)", color: "var(--success)" };
|
|
||||||
case "ARCHIVED":
|
|
||||||
return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
|
||||||
default:
|
|
||||||
return { label: status, bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string | null | undefined): string {
|
|
||||||
if (!iso) return "Not set";
|
|
||||||
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(iso: string | null | undefined): string {
|
|
||||||
if (!iso) return "Not set";
|
|
||||||
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toFriendlyErrorMessage(error: unknown): string {
|
|
||||||
const fallback = "We had trouble loading this project. Please try again when you're ready.";
|
|
||||||
|
|
||||||
if (!(error instanceof Error)) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = error.message.trim();
|
|
||||||
if (message.toLowerCase().includes("not found")) {
|
|
||||||
return "Project not found. It may have been deleted or you may not have access to it.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return message || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ style: statusStyle }: StatusBadgeProps): ReactElement {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: "2px 10px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
background: statusStyle.bg,
|
|
||||||
color: statusStyle.color,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{statusStyle.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetaItem({ label, value }: MetaItemProps): ReactElement {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
padding: "10px 12px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ margin: "0 0 4px", fontSize: "0.75rem", color: "var(--muted)" }}>{label}</p>
|
|
||||||
<p style={{ margin: 0, fontSize: "0.85rem", color: "var(--text)" }}>{value}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectDetailPage(): ReactElement {
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useParams<{ id: string | string[] }>();
|
|
||||||
const workspaceId = useWorkspaceId();
|
|
||||||
const rawProjectId = params.id;
|
|
||||||
const projectId = Array.isArray(rawProjectId) ? (rawProjectId[0] ?? null) : rawProjectId;
|
|
||||||
|
|
||||||
const [project, setProject] = useState<ProjectDetail | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadProject = useCallback(async (id: string, wsId: string): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const data = await fetchProject(id, wsId);
|
|
||||||
setProject(data);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("[ProjectDetail] Failed to fetch project:", err);
|
|
||||||
setProject(null);
|
|
||||||
setError(toFriendlyErrorMessage(err));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectId) {
|
|
||||||
setProject(null);
|
|
||||||
setError("The project link is invalid. Please return to the projects page.");
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workspaceId) {
|
|
||||||
setProject(null);
|
|
||||||
setError("Select a workspace to view this project.");
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = projectId;
|
|
||||||
const wsId = workspaceId;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const data = await fetchProject(id, wsId);
|
|
||||||
if (!cancelled) {
|
|
||||||
setProject(data);
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("[ProjectDetail] Failed to fetch project:", err);
|
|
||||||
if (!cancelled) {
|
|
||||||
setProject(null);
|
|
||||||
setError(toFriendlyErrorMessage(err));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void load();
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [projectId, workspaceId]);
|
|
||||||
|
|
||||||
function handleRetry(): void {
|
|
||||||
if (!projectId || !workspaceId) return;
|
|
||||||
void loadProject(projectId, workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBack(): void {
|
|
||||||
router.push("/projects");
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectStatus = project ? getProjectStatusStyle(project.status) : null;
|
|
||||||
const projectPriority = project ? getPriorityStyle(project.priority) : null;
|
|
||||||
const dueDate = project?.dueDate ?? project?.endDate;
|
|
||||||
const creator =
|
|
||||||
project?.creator.name && project.creator.name.trim().length > 0
|
|
||||||
? `${project.creator.name} (${project.creator.email})`
|
|
||||||
: (project?.creator.email ?? "Unknown");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="container mx-auto px-4 py-8" style={{ maxWidth: 960 }}>
|
|
||||||
<button
|
|
||||||
onClick={handleBack}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 20,
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
background: "var(--surface)",
|
|
||||||
color: "var(--text-2)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowLeft size={16} />
|
|
||||||
Back to projects
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex justify-center py-16">
|
|
||||||
<MosaicSpinner label="Loading project..." />
|
|
||||||
</div>
|
|
||||||
) : error !== null ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 32,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--danger)", margin: "0 0 20px" }}>{error}</p>
|
|
||||||
<div style={{ display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap" }}>
|
|
||||||
<button
|
|
||||||
onClick={handleBack}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "transparent",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "var(--text-2)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back to projects
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleRetry}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "var(--danger)",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : project === null ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 32,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--muted)", margin: 0 }}>Project details are not available.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
gap: 12,
|
|
||||||
flexWrap: "wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<h1
|
|
||||||
style={{ margin: 0, fontSize: "1.875rem", fontWeight: 700, color: "var(--text)" }}
|
|
||||||
>
|
|
||||||
{project.name}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
||||||
{projectStatus && <StatusBadge style={projectStatus} />}
|
|
||||||
{projectPriority && <StatusBadge style={projectPriority} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{project.description ? (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
margin: "14px 0 0",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
margin: "14px 0 0",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
lineHeight: 1.6,
|
|
||||||
fontStyle: "italic",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No description provided.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3" style={{ marginTop: 18 }}>
|
|
||||||
<MetaItem label="Start date" value={formatDate(project.startDate)} />
|
|
||||||
<MetaItem label="Due date" value={formatDate(dueDate)} />
|
|
||||||
<MetaItem label="Created" value={formatDateTime(project.createdAt)} />
|
|
||||||
<MetaItem label="Updated" value={formatDateTime(project.updatedAt)} />
|
|
||||||
<MetaItem label="Creator" value={creator} />
|
|
||||||
<MetaItem
|
|
||||||
label="Work items"
|
|
||||||
value={`${String(project._count.tasks)} tasks · ${String(project._count.events)} events`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 style={{ margin: "0 0 12px", fontSize: "1.1rem", color: "var(--text)" }}>
|
|
||||||
Tasks ({String(project._count.tasks)})
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{project.tasks.length === 0 ? (
|
|
||||||
<p style={{ margin: 0, color: "var(--muted)", fontSize: "0.9rem" }}>
|
|
||||||
No tasks yet for this project.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{project.tasks.map((task, index) => (
|
|
||||||
<div
|
|
||||||
key={task.id}
|
|
||||||
style={{
|
|
||||||
padding: "12px 0",
|
|
||||||
borderTop: index === 0 ? "none" : "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 12,
|
|
||||||
flexWrap: "wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<p style={{ margin: 0, color: "var(--text)", fontWeight: 500 }}>
|
|
||||||
{task.title}
|
|
||||||
</p>
|
|
||||||
<p style={{ margin: "4px 0 0", color: "var(--muted)", fontSize: "0.8rem" }}>
|
|
||||||
Due: {formatDate(task.dueDate)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
||||||
<StatusBadge style={getTaskStatusStyle(task.status)} />
|
|
||||||
<StatusBadge style={getPriorityStyle(task.priority)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 style={{ margin: "0 0 12px", fontSize: "1.1rem", color: "var(--text)" }}>
|
|
||||||
Events ({String(project._count.events)})
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{project.events.length === 0 ? (
|
|
||||||
<p style={{ margin: 0, color: "var(--muted)", fontSize: "0.9rem" }}>
|
|
||||||
No events scheduled for this project.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{project.events.map((event, index) => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
style={{
|
|
||||||
padding: "12px 0",
|
|
||||||
borderTop: index === 0 ? "none" : "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ margin: 0, color: "var(--text)", fontWeight: 500 }}>
|
|
||||||
{event.title}
|
|
||||||
</p>
|
|
||||||
<p style={{ margin: "4px 0 0", color: "var(--muted)", fontSize: "0.8rem" }}>
|
|
||||||
{formatDateTime(event.startTime)} - {formatDateTime(event.endTime)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -17,8 +17,6 @@ import {
|
|||||||
import { fetchProjects, createProject, deleteProject, ProjectStatus } from "@/lib/api/projects";
|
import { fetchProjects, createProject, deleteProject, ProjectStatus } from "@/lib/api/projects";
|
||||||
import type { Project, CreateProjectDto } from "@/lib/api/projects";
|
import type { Project, CreateProjectDto } from "@/lib/api/projects";
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
import { fetchDomains } from "@/lib/api/domains";
|
|
||||||
import type { Domain } from "@mosaic/shared";
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
Status badge helpers
|
Status badge helpers
|
||||||
@@ -67,14 +65,11 @@ interface ProjectCardProps {
|
|||||||
project: Project;
|
project: Project;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onClick: (id: string) => void;
|
onClick: (id: string) => void;
|
||||||
domains: Domain[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectCard({ project, onDelete, onClick, domains }: ProjectCardProps): ReactElement {
|
function ProjectCard({ project, onDelete, onClick }: ProjectCardProps): ReactElement {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const status = getStatusStyle(project.status);
|
const status = getStatusStyle(project.status);
|
||||||
// Find domain if project has a domainId
|
|
||||||
const domain = project.domainId ? domains.find((d) => d.id === project.domainId) : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -209,22 +204,6 @@ function ProjectCard({ project, onDelete, onClick, domains }: ProjectCardProps):
|
|||||||
>
|
>
|
||||||
{status.label}
|
{status.label}
|
||||||
</span>
|
</span>
|
||||||
{domain && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
padding: "2px 10px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
background: "rgba(139,92,246,0.15)",
|
|
||||||
color: "var(--purple)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
marginLeft: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{domain.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timestamps */}
|
{/* Timestamps */}
|
||||||
<span
|
<span
|
||||||
@@ -250,7 +229,6 @@ interface CreateDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSubmit: (data: CreateProjectDto) => Promise<void>;
|
onSubmit: (data: CreateProjectDto) => Promise<void>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
domains: Domain[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateProjectDialog({
|
function CreateProjectDialog({
|
||||||
@@ -258,24 +236,20 @@ function CreateProjectDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
domains,
|
|
||||||
}: CreateDialogProps): ReactElement {
|
}: CreateDialogProps): ReactElement {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [domainId, setDomainId] = useState("");
|
|
||||||
|
|
||||||
function resetForm(): void {
|
function resetForm(): void {
|
||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setDomainId("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setDomainId("");
|
|
||||||
|
|
||||||
const trimmedName = name.trim();
|
const trimmedName = name.trim();
|
||||||
if (!trimmedName) {
|
if (!trimmedName) {
|
||||||
@@ -289,9 +263,6 @@ function CreateProjectDialog({
|
|||||||
if (trimmedDesc) {
|
if (trimmedDesc) {
|
||||||
payload.description = trimmedDesc;
|
payload.description = trimmedDesc;
|
||||||
}
|
}
|
||||||
if (domainId) {
|
|
||||||
payload.domainId = domainId;
|
|
||||||
}
|
|
||||||
await onSubmit(payload);
|
await onSubmit(payload);
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -411,47 +382,6 @@ function CreateProjectDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Domain */}
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label
|
|
||||||
htmlFor="project-domain"
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text-2)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Domain (optional)
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="project-domain"
|
|
||||||
value={domainId}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDomainId(e.target.value);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
outline: "none",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">None</option>
|
|
||||||
{domains.map((d) => (
|
|
||||||
<option key={d.id} value={d.id}>
|
|
||||||
{d.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form error */}
|
{/* Form error */}
|
||||||
{formError !== null && (
|
{formError !== null && (
|
||||||
<p style={{ color: "var(--danger)", fontSize: "0.85rem", margin: "0 0 12px" }}>
|
<p style={{ color: "var(--danger)", fontSize: "0.85rem", margin: "0 0 12px" }}>
|
||||||
@@ -602,7 +532,6 @@ export default function ProjectsPage(): ReactElement {
|
|||||||
const workspaceId = useWorkspaceId();
|
const workspaceId = useWorkspaceId();
|
||||||
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [domains, setDomains] = useState<Domain[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -672,33 +601,6 @@ export default function ProjectsPage(): ReactElement {
|
|||||||
};
|
};
|
||||||
}, [workspaceId]);
|
}, [workspaceId]);
|
||||||
|
|
||||||
// Load domains
|
|
||||||
useEffect(() => {
|
|
||||||
if (!workspaceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const wsId = workspaceId;
|
|
||||||
|
|
||||||
async function loadDomains(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const response = await fetchDomains(undefined, wsId);
|
|
||||||
if (!cancelled) {
|
|
||||||
setDomains(response.data);
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("[Projects] Failed to fetch domains:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadDomains();
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [workspaceId]);
|
|
||||||
|
|
||||||
function handleRetry(): void {
|
function handleRetry(): void {
|
||||||
void loadProjects(workspaceId);
|
void loadProjects(workspaceId);
|
||||||
}
|
}
|
||||||
@@ -877,7 +779,6 @@ export default function ProjectsPage(): ReactElement {
|
|||||||
project={project}
|
project={project}
|
||||||
onDelete={handleDeleteRequest}
|
onDelete={handleDeleteRequest}
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
domains={domains}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -889,7 +790,6 @@ export default function ProjectsPage(): ReactElement {
|
|||||||
onOpenChange={setCreateOpen}
|
onOpenChange={setCreateOpen}
|
||||||
onSubmit={handleCreate}
|
onSubmit={handleCreate}
|
||||||
isSubmitting={isCreating}
|
isSubmitting={isCreating}
|
||||||
domains={domains}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ const INITIAL_FORM: ProviderFormState = {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error && error.message.trim().length > 0) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function buildProviderName(displayName: string, type: string): string {
|
function buildProviderName(displayName: string, type: string): string {
|
||||||
const slug = displayName
|
const slug = displayName
|
||||||
.trim()
|
.trim()
|
||||||
@@ -97,14 +105,6 @@ function buildProviderName(displayName: string, type: string): string {
|
|||||||
return candidate.slice(0, 100);
|
return candidate.slice(0, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: unknown, fallback: string): string {
|
|
||||||
if (error instanceof Error && error.message.trim().length > 0) {
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProviderModels(models: unknown): FleetProviderModel[] {
|
function normalizeProviderModels(models: unknown): FleetProviderModel[] {
|
||||||
if (!Array.isArray(models)) {
|
if (!Array.isArray(models)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -153,11 +153,11 @@ function modelsToEditorText(models: unknown): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseModelsText(value: string): string[] {
|
function parseModelsText(value: string): FleetProviderModel[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
return value
|
return value
|
||||||
.split(/\r?\n/g)
|
.split(/\n|,/g)
|
||||||
.map((segment) => segment.trim())
|
.map((segment) => segment.trim())
|
||||||
.filter((segment) => segment.length > 0)
|
.filter((segment) => segment.length > 0)
|
||||||
.filter((segment) => {
|
.filter((segment) => {
|
||||||
@@ -166,7 +166,8 @@ function parseModelsText(value: string): string[] {
|
|||||||
}
|
}
|
||||||
seen.add(segment);
|
seen.add(segment);
|
||||||
return true;
|
return true;
|
||||||
});
|
})
|
||||||
|
.map((id) => ({ id, name: id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function maskApiKey(value: string): string {
|
function maskApiKey(value: string): string {
|
||||||
@@ -278,7 +279,6 @@ export default function ProvidersSettingsPage(): ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const models = parseModelsText(form.modelsText);
|
const models = parseModelsText(form.modelsText);
|
||||||
const providerModels = models.map((id) => ({ id, name: id }));
|
|
||||||
const baseUrl = form.baseUrl.trim();
|
const baseUrl = form.baseUrl.trim();
|
||||||
const apiKey = form.apiKey.trim();
|
const apiKey = form.apiKey.trim();
|
||||||
|
|
||||||
@@ -289,7 +289,7 @@ export default function ProvidersSettingsPage(): ReactElement {
|
|||||||
const updatePayload: UpdateFleetProviderRequest = {
|
const updatePayload: UpdateFleetProviderRequest = {
|
||||||
displayName,
|
displayName,
|
||||||
isActive: form.isActive,
|
isActive: form.isActive,
|
||||||
models: providerModels,
|
models,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (baseUrl.length > 0) {
|
if (baseUrl.length > 0) {
|
||||||
@@ -307,6 +307,7 @@ export default function ProvidersSettingsPage(): ReactElement {
|
|||||||
name: buildProviderName(displayName, form.type),
|
name: buildProviderName(displayName, form.type),
|
||||||
displayName,
|
displayName,
|
||||||
type: form.type,
|
type: form.type,
|
||||||
|
models,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (baseUrl.length > 0) {
|
if (baseUrl.length > 0) {
|
||||||
@@ -317,10 +318,6 @@ export default function ProvidersSettingsPage(): ReactElement {
|
|||||||
createPayload.apiKey = apiKey;
|
createPayload.apiKey = apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (providerModels.length > 0) {
|
|
||||||
createPayload.models = providerModels;
|
|
||||||
}
|
|
||||||
|
|
||||||
await createFleetProvider(createPayload);
|
await createFleetProvider(createPayload);
|
||||||
setSuccessMessage(`Added provider "${displayName}".`);
|
setSuccessMessage(`Added provider "${displayName}".`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface AgentSelectorProps {
|
|
||||||
selectedAgent?: string | null;
|
|
||||||
onChange?: (agent: string | null) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AGENT_CONFIG = {
|
|
||||||
jarvis: {
|
|
||||||
displayName: "Jarvis",
|
|
||||||
role: "Orchestrator",
|
|
||||||
color: "#3498db",
|
|
||||||
},
|
|
||||||
builder: {
|
|
||||||
displayName: "Builder",
|
|
||||||
role: "Coding Agent",
|
|
||||||
color: "#3b82f6",
|
|
||||||
},
|
|
||||||
medic: {
|
|
||||||
displayName: "Medic",
|
|
||||||
role: "Health Monitor",
|
|
||||||
color: "#10b981",
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function JarvisIcon({ className }: { className?: string }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={`w-3 h-3 ${className ?? ""}`}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
<path d="M12 2v4M12 22v-4" />
|
|
||||||
<path d="M2 12h4M22 12h-4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BuilderIcon({ className }: { className?: string }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={`w-3 h-3 ${className ?? ""}`}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
>
|
|
||||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MedicIcon({ className }: { className?: string }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={`w-3 h-3 ${className ?? ""}`}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
>
|
|
||||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const AGENT_ICONS: Record<string, React.FC<{ className?: string }>> = {
|
|
||||||
jarvis: JarvisIcon,
|
|
||||||
builder: BuilderIcon,
|
|
||||||
medic: MedicIcon,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AgentSelector({
|
|
||||||
selectedAgent,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
}: AgentSelectorProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs font-medium" style={{ color: "rgb(var(--text-muted))" }}>
|
|
||||||
Agent
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{Object.entries(AGENT_CONFIG).map(([name, config]) => {
|
|
||||||
const Icon = AGENT_ICONS[name];
|
|
||||||
const isSelected = selectedAgent === name;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange?.(isSelected ? null : name)}
|
|
||||||
disabled={disabled}
|
|
||||||
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg border transition-all text-xs ${
|
|
||||||
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
|
|
||||||
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
|
||||||
style={{
|
|
||||||
borderColor: isSelected
|
|
||||||
? "rgb(var(--accent-primary))"
|
|
||||||
: "rgb(var(--border-default))",
|
|
||||||
color: isSelected ? "rgb(var(--accent-primary))" : "rgb(var(--text-primary))",
|
|
||||||
}}
|
|
||||||
title={`${config.displayName} — ${config.role}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: config.color,
|
|
||||||
width: "8px",
|
|
||||||
height: "8px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{Icon && <Icon />}
|
|
||||||
<span className="font-medium">{config.displayName}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import { useWorkspaceId } from "@/lib/hooks";
|
|||||||
import { MessageList } from "./MessageList";
|
import { MessageList } from "./MessageList";
|
||||||
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
|
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
|
||||||
import { ChatEmptyState } from "./ChatEmptyState";
|
import { ChatEmptyState } from "./ChatEmptyState";
|
||||||
import { AgentSelector } from "./AgentSelector";
|
|
||||||
import type { Message } from "@/hooks/useChat";
|
import type { Message } from "@/hooks/useChat";
|
||||||
|
|
||||||
export interface ChatRef {
|
export interface ChatRef {
|
||||||
@@ -67,7 +66,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2");
|
const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2");
|
||||||
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
|
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
|
||||||
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
|
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
|
||||||
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Suggestion fill value: controls ChatInput's textarea content
|
// Suggestion fill value: controls ChatInput's textarea content
|
||||||
const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined);
|
const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined);
|
||||||
@@ -90,7 +88,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
temperature,
|
temperature,
|
||||||
maxTokens,
|
maxTokens,
|
||||||
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
||||||
...(selectedAgent !== null && { agent: selectedAgent }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read workspace ID from localStorage (set by auth-context after session check).
|
// Read workspace ID from localStorage (set by auth-context after session check).
|
||||||
@@ -345,31 +342,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
{!user && (
|
|
||||||
<div className="mx-4 mb-2 lg:mx-auto lg:max-w-4xl lg:px-8">
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-center gap-2 rounded-lg border px-4 py-3 text-center"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "rgb(var(--surface-1))",
|
|
||||||
borderColor: "rgb(var(--border-default))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4"
|
|
||||||
style={{ color: "rgb(var(--text-secondary))" }}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm" style={{ color: "rgb(var(--text-secondary))" }}>
|
|
||||||
Sign in to chat with Jarvis
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className="sticky bottom-0 border-t"
|
className="sticky bottom-0 border-t"
|
||||||
style={{
|
style={{
|
||||||
@@ -378,13 +350,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
|
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
|
||||||
<div className="mb-3">
|
|
||||||
<AgentSelector
|
|
||||||
selectedAgent={selectedAgent}
|
|
||||||
onChange={setSelectedAgent}
|
|
||||||
disabled={isChatLoading || isStreaming || !user}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
disabled={isChatLoading || !user}
|
disabled={isChatLoading || !user}
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export function ChatOverlay(): React.JSX.Element {
|
|||||||
onClick={open}
|
onClick={open}
|
||||||
className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 lg:bottom-8 lg:right-8"
|
className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 lg:bottom-8 lg:right-8"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--accent-primary, #10b981)",
|
backgroundColor: "rgb(var(--accent-primary))",
|
||||||
color: "var(--text-on-accent, #ffffff)",
|
color: "rgb(var(--text-on-accent))",
|
||||||
}}
|
}}
|
||||||
aria-label="Open chat"
|
aria-label="Open chat"
|
||||||
title="Open Jarvis chat (Cmd+Shift+J)"
|
title="Open Jarvis chat (Cmd+Shift+J)"
|
||||||
@@ -78,18 +78,18 @@ export function ChatOverlay(): React.JSX.Element {
|
|||||||
if (isMinimized) {
|
if (isMinimized) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed bottom-0 right-0 z-40 w-full shadow-2xl sm:w-96"
|
className="fixed bottom-0 right-0 z-40 w-full sm:w-96"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--surface-0, #ffffff)",
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
borderColor: "var(--border-default, #e5e7eb)",
|
borderColor: "rgb(var(--border-default))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={expand}
|
onClick={expand}
|
||||||
className="flex w-full items-center justify-between border-t px-4 py-3 text-left transition-colors hover:bg-black/5 focus:outline-none focus:ring-2 focus:ring-inset"
|
className="flex w-full items-center justify-between border-t px-4 py-3 text-left transition-colors hover:bg-black/5 focus:outline-none focus:ring-2 focus:ring-inset"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "var(--border-default, #e5e7eb)",
|
borderColor: "rgb(var(--border-default))",
|
||||||
backgroundColor: "var(--surface-0, #ffffff)",
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
}}
|
}}
|
||||||
aria-label="Expand chat"
|
aria-label="Expand chat"
|
||||||
>
|
>
|
||||||
@@ -135,10 +135,10 @@ export function ChatOverlay(): React.JSX.Element {
|
|||||||
|
|
||||||
{/* Chat Panel */}
|
{/* Chat Panel */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-y-0 right-0 z-40 flex w-full flex-col border-l shadow-2xl sm:w-96 lg:inset-y-16"
|
className="fixed inset-y-0 right-0 z-40 flex w-full flex-col border-l sm:w-96 lg:inset-y-16"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--surface-0, #ffffff)",
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
borderColor: "var(--border-default, #e5e7eb)",
|
borderColor: "rgb(var(--border-default))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
import { UsageWidget } from "@/components/ui/UsageWidget";
|
|
||||||
import { useSidebar } from "./SidebarContext";
|
import { useSidebar } from "./SidebarContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -351,9 +350,6 @@ export function AppHeader(): React.JSX.Element {
|
|||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
{/* Usage Widget */}
|
|
||||||
<UsageWidget />
|
|
||||||
|
|
||||||
{/* User Avatar + Dropdown */}
|
{/* User Avatar + Dropdown */}
|
||||||
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
|
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
import { fetchUsageSummary, type UsageSummary } from "@/lib/api/telemetry";
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface UsageTier {
|
|
||||||
name: string;
|
|
||||||
tokens: number;
|
|
||||||
limit: number;
|
|
||||||
percentage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function getUsageColor(percentage: number): string {
|
|
||||||
if (percentage < 60) return "var(--success)";
|
|
||||||
if (percentage < 80) return "var(--warn)";
|
|
||||||
return "var(--danger)";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTokens(value: number): string {
|
|
||||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
|
||||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
|
||||||
return value.toFixed(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function UsageWidget(): React.JSX.Element {
|
|
||||||
const [summary, setSummary] = useState<UsageSummary | null>(null);
|
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const tiers: UsageTier[] = summary
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: "Session",
|
|
||||||
tokens: summary.totalTokens,
|
|
||||||
limit: 100_000,
|
|
||||||
percentage: (summary.totalTokens / 100_000) * 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Daily",
|
|
||||||
tokens: summary.totalTokens,
|
|
||||||
limit: 500_000,
|
|
||||||
percentage: (summary.totalTokens / 500_000) * 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Monthly",
|
|
||||||
tokens: summary.totalTokens,
|
|
||||||
limit: 2_000_000,
|
|
||||||
percentage: (summary.totalTokens / 2_000_000) * 100,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const currentTier = tiers[0];
|
|
||||||
const usageColor = currentTier ? getUsageColor(currentTier.percentage) : "var(--muted)";
|
|
||||||
|
|
||||||
const loadSummary = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await fetchUsageSummary("30d");
|
|
||||||
setSummary(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load usage summary:", err);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadSummary();
|
|
||||||
}, [loadSummary]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(event: MouseEvent): void {
|
|
||||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
|
||||||
setPopoverOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!popoverOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return (): void => {
|
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [popoverOpen]);
|
|
||||||
|
|
||||||
const pct = currentTier ? Math.min(currentTier.percentage, 100) : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={popoverRef} style={{ position: "relative" }}>
|
|
||||||
<button
|
|
||||||
onClick={(): void => {
|
|
||||||
setPopoverOpen((prev) => !prev);
|
|
||||||
}}
|
|
||||||
aria-label="Usage widget"
|
|
||||||
aria-expanded={popoverOpen}
|
|
||||||
aria-haspopup="true"
|
|
||||||
className="hidden lg:flex items-center"
|
|
||||||
style={{
|
|
||||||
gap: 6,
|
|
||||||
padding: "5px 10px",
|
|
||||||
borderRadius: 6,
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: `1px solid ${popoverOpen ? usageColor : "var(--border)"}`,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text-2)",
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "border-color 0.15s, color 0.15s",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e): void => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.borderColor = usageColor;
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e): void => {
|
|
||||||
if (!popoverOpen) {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.borderColor = "var(--border)";
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
style={{ color: usageColor, flexShrink: 0 }}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M9 1L3 9h5l-1 6 6-8H8l1-6z" />
|
|
||||||
</svg>
|
|
||||||
<span style={{ fontWeight: 500, color: "var(--text-2)" }}>
|
|
||||||
{isLoading ? "..." : summary ? formatTokens(summary.totalTokens) : "0"}
|
|
||||||
</span>
|
|
||||||
{!isLoading && currentTier && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 24,
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
background: "var(--bg-mid)",
|
|
||||||
overflow: "hidden",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: `${String(pct)}%`,
|
|
||||||
height: "100%",
|
|
||||||
background: usageColor,
|
|
||||||
borderRadius: 2,
|
|
||||||
transition: "width 0.3s ease-out",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoading && currentTier && (
|
|
||||||
<span style={{ fontWeight: 600, color: usageColor, minWidth: 32, textAlign: "right" }}>
|
|
||||||
{Math.round(currentTier.percentage)}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{popoverOpen && (
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Usage details"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "calc(100% + 8px)",
|
|
||||||
right: 0,
|
|
||||||
width: 280,
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
|
|
||||||
zIndex: 200,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "0.83rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text)",
|
|
||||||
marginBottom: 12,
|
|
||||||
paddingBottom: 8,
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Token Usage
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
padding: "20px 0",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Loading usage data…
|
|
||||||
</div>
|
|
||||||
) : summary ? (
|
|
||||||
<>
|
|
||||||
<div style={{ marginBottom: 12, display: "flex", flexDirection: "column", gap: 8 }}>
|
|
||||||
<div
|
|
||||||
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
|
|
||||||
>
|
|
||||||
<span style={{ color: "var(--muted)" }}>Total Tokens</span>
|
|
||||||
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
|
|
||||||
{formatTokens(summary.totalTokens)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
|
|
||||||
>
|
|
||||||
<span style={{ color: "var(--muted)" }}>Estimated Cost</span>
|
|
||||||
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
|
|
||||||
${summary.totalCost.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
|
|
||||||
>
|
|
||||||
<span style={{ color: "var(--muted)" }}>Tasks</span>
|
|
||||||
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
|
|
||||||
{summary.taskCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
|
||||||
{tiers.map((tier) => {
|
|
||||||
const tierPct = Math.min(tier.percentage, 100);
|
|
||||||
return (
|
|
||||||
<div key={tier.name}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: "var(--text-2)" }}>{tier.name}</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: getUsageColor(tier.percentage),
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatTokens(tier.tokens)} / {formatTokens(tier.limit)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: 6,
|
|
||||||
borderRadius: 3,
|
|
||||||
background: "var(--bg-mid)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: `${String(tierPct)}%`,
|
|
||||||
height: "100%",
|
|
||||||
background: getUsageColor(tier.percentage),
|
|
||||||
borderRadius: 3,
|
|
||||||
transition: "width 0.3s ease-out",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/usage"
|
|
||||||
onClick={(): void => {
|
|
||||||
setPopoverOpen(false);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
marginTop: 12,
|
|
||||||
paddingTop: 8,
|
|
||||||
borderTop: "1px solid var(--border)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "var(--primary)",
|
|
||||||
textDecoration: "none",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e): void => {
|
|
||||||
(e.currentTarget as HTMLAnchorElement).style.textDecoration = "underline";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e): void => {
|
|
||||||
(e.currentTarget as HTMLAnchorElement).style.textDecoration = "none";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View detailed usage →
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
padding: "20px 0",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No usage data available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -16,21 +16,6 @@ interface Agent {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWorking(status: string): boolean {
|
|
||||||
const s = status.toLowerCase();
|
|
||||||
return s === "running" || s === "working";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIdle(status: string): boolean {
|
|
||||||
const s = status.toLowerCase();
|
|
||||||
return s === "idle" || s === "spawning" || s === "waiting" || s === "queued";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isErrored(status: string): boolean {
|
|
||||||
const s = status.toLowerCase();
|
|
||||||
return s === "failed" || s === "error";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -89,20 +74,25 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
|||||||
}, [fetchAgents]);
|
}, [fetchAgents]);
|
||||||
|
|
||||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||||
if (isWorking(status)) {
|
const statusLower = status.toLowerCase();
|
||||||
|
switch (statusLower) {
|
||||||
|
case "running":
|
||||||
|
case "working":
|
||||||
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
|
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
|
||||||
}
|
case "spawning":
|
||||||
if (isIdle(status)) {
|
case "queued":
|
||||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||||
}
|
case "completed":
|
||||||
if (isErrored(status)) {
|
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||||
|
case "failed":
|
||||||
|
case "error":
|
||||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||||
}
|
case "terminated":
|
||||||
const s = status.toLowerCase();
|
case "killed":
|
||||||
if (s === "completed" || s === "terminated" || s === "killed") {
|
|
||||||
return <CheckCircle className="w-4 h-4 text-gray-500" />;
|
return <CheckCircle className="w-4 h-4 text-gray-500" />;
|
||||||
}
|
default:
|
||||||
return <Clock className="w-4 h-4 text-gray-400" />;
|
return <Clock className="w-4 h-4 text-gray-400" />;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusText = (status: string): string => {
|
const getStatusText = (status: string): string => {
|
||||||
@@ -131,9 +121,9 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
|||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: agents.length,
|
total: agents.length,
|
||||||
working: agents.filter((a) => isWorking(a.status)).length,
|
working: agents.filter((a) => a.status.toLowerCase() === "running").length,
|
||||||
idle: agents.filter((a) => isIdle(a.status)).length,
|
idle: agents.filter((a) => a.status.toLowerCase() === "spawning").length,
|
||||||
error: agents.filter((a) => isErrored(a.status)).length,
|
error: agents.filter((a) => a.status.toLowerCase() === "failed").length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -186,9 +176,9 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
|||||||
<div
|
<div
|
||||||
key={agent.agentId}
|
key={agent.agentId}
|
||||||
className={`p-3 rounded-lg border ${
|
className={`p-3 rounded-lg border ${
|
||||||
isErrored(agent.status)
|
agent.status.toLowerCase() === "failed"
|
||||||
? "bg-red-50 border-red-200"
|
? "bg-red-50 border-red-200"
|
||||||
: isWorking(agent.status)
|
: agent.status.toLowerCase() === "running"
|
||||||
? "bg-blue-50 border-blue-200"
|
? "bg-blue-50 border-blue-200"
|
||||||
: "bg-gray-50 border-gray-200"
|
: "bg-gray-50 border-gray-200"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -4,43 +4,61 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Calendar as CalendarIcon, Clock, MapPin } from "lucide-react";
|
import { Calendar as CalendarIcon, Clock, MapPin } from "lucide-react";
|
||||||
import type { WidgetProps, Event } from "@mosaic/shared";
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
import { fetchEvents } from "@/lib/api/events";
|
|
||||||
|
interface Event {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime?: string;
|
||||||
|
location?: string;
|
||||||
|
allDay: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Mock data for now - will fetch from API later
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadEvents = async (): Promise<void> => {
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
const now = new Date();
|
||||||
const data = await fetchEvents();
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
if (isMounted) {
|
const tomorrow = new Date(today);
|
||||||
setEvents(data);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
}
|
|
||||||
} catch {
|
setTimeout(() => {
|
||||||
if (isMounted) {
|
setEvents([
|
||||||
setEvents([]);
|
{
|
||||||
}
|
id: "1",
|
||||||
} finally {
|
title: "Team Standup",
|
||||||
if (isMounted) {
|
startTime: new Date(today.setHours(9, 0, 0, 0)).toISOString(),
|
||||||
|
endTime: new Date(today.setHours(9, 30, 0, 0)).toISOString(),
|
||||||
|
location: "Zoom",
|
||||||
|
allDay: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "Project Review",
|
||||||
|
startTime: new Date(today.setHours(14, 0, 0, 0)).toISOString(),
|
||||||
|
endTime: new Date(today.setHours(15, 0, 0, 0)).toISOString(),
|
||||||
|
location: "Conference Room A",
|
||||||
|
allDay: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
title: "Sprint Planning",
|
||||||
|
startTime: new Date(tomorrow.setHours(10, 0, 0, 0)).toISOString(),
|
||||||
|
endTime: new Date(tomorrow.setHours(12, 0, 0, 0)).toISOString(),
|
||||||
|
allDay: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}, 500);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void loadEvents();
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatTime = (dateValue: Date | string): string => {
|
const formatTime = (dateString: string): string => {
|
||||||
const date = new Date(dateValue);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleTimeString("en-US", {
|
return date.toLocaleTimeString("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
@@ -48,8 +66,8 @@ export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDay = (dateValue: Date | string): string => {
|
const formatDay = (dateString: string): string => {
|
||||||
const date = new Date(dateValue);
|
const date = new Date(dateString);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const tomorrow = new Date(today);
|
const tomorrow = new Date(today);
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|||||||
@@ -4,56 +4,68 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { CheckCircle, Circle, Clock, AlertCircle } from "lucide-react";
|
import { CheckCircle, Circle, Clock, AlertCircle } from "lucide-react";
|
||||||
import { TaskPriority, TaskStatus, type WidgetProps, type Task } from "@mosaic/shared";
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
import { fetchTasks } from "@/lib/api/tasks";
|
|
||||||
|
|
||||||
export function TasksWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
dueDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-empty-pattern
|
||||||
|
export function TasksWidget({}: WidgetProps): React.JSX.Element {
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Mock data for now - will fetch from API later
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadTasks = async (): Promise<void> => {
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
// Simulate API call
|
||||||
const data = await fetchTasks();
|
setTimeout(() => {
|
||||||
if (isMounted) {
|
setTasks([
|
||||||
setTasks(data);
|
{
|
||||||
}
|
id: "1",
|
||||||
} catch {
|
title: "Complete project documentation",
|
||||||
if (isMounted) {
|
status: "IN_PROGRESS",
|
||||||
setTasks([]);
|
priority: "HIGH",
|
||||||
}
|
dueDate: "2024-02-01",
|
||||||
} finally {
|
},
|
||||||
if (isMounted) {
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "Review pull requests",
|
||||||
|
status: "NOT_STARTED",
|
||||||
|
priority: "MEDIUM",
|
||||||
|
dueDate: "2024-02-02",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
title: "Update dependencies",
|
||||||
|
status: "COMPLETED",
|
||||||
|
priority: "LOW",
|
||||||
|
dueDate: "2024-01-30",
|
||||||
|
},
|
||||||
|
]);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}, 500);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void loadTasks();
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getPriorityIcon = (priority: TaskPriority): React.JSX.Element => {
|
const getPriorityIcon = (priority: string): React.JSX.Element => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case TaskPriority.HIGH:
|
case "HIGH":
|
||||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||||
case TaskPriority.MEDIUM:
|
case "MEDIUM":
|
||||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||||
case TaskPriority.LOW:
|
case "LOW":
|
||||||
return <Circle className="w-4 h-4 text-gray-400" />;
|
return <Circle className="w-4 h-4 text-gray-400" />;
|
||||||
default:
|
default:
|
||||||
return <Circle className="w-4 h-4 text-gray-400" />;
|
return <Circle className="w-4 h-4 text-gray-400" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusIcon = (status: TaskStatus): React.JSX.Element => {
|
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||||
return status === TaskStatus.COMPLETED ? (
|
return status === "COMPLETED" ? (
|
||||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<Circle className="w-4 h-4 text-gray-400" />
|
<Circle className="w-4 h-4 text-gray-400" />
|
||||||
@@ -62,8 +74,8 @@ export function TasksWidget({ id: _id, config: _config }: WidgetProps): React.JS
|
|||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: tasks.length,
|
total: tasks.length,
|
||||||
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
|
inProgress: tasks.filter((t) => t.status === "IN_PROGRESS").length,
|
||||||
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
|
completed: tasks.filter((t) => t.status === "COMPLETED").length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -1,58 +1,16 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import type { Event } from "@mosaic/shared";
|
|
||||||
import { CalendarWidget } from "../CalendarWidget";
|
import { CalendarWidget } from "../CalendarWidget";
|
||||||
import { fetchEvents } from "@/lib/api/events";
|
|
||||||
|
|
||||||
vi.mock("@/lib/api/events", () => ({
|
|
||||||
fetchEvents: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockEvents: Event[] = [
|
|
||||||
{
|
|
||||||
id: "event-1",
|
|
||||||
title: "API Planning",
|
|
||||||
description: null,
|
|
||||||
startTime: new Date("2026-02-01T09:00:00Z"),
|
|
||||||
endTime: new Date("2026-02-01T09:30:00Z"),
|
|
||||||
allDay: false,
|
|
||||||
location: "Zoom",
|
|
||||||
recurrence: null,
|
|
||||||
creatorId: "user-1",
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
projectId: null,
|
|
||||||
metadata: {},
|
|
||||||
createdAt: new Date("2026-01-30T09:00:00Z"),
|
|
||||||
updatedAt: new Date("2026-01-30T09:00:00Z"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "event-2",
|
|
||||||
title: "API Review",
|
|
||||||
description: null,
|
|
||||||
startTime: new Date("2026-02-02T10:00:00Z"),
|
|
||||||
endTime: new Date("2026-02-02T11:00:00Z"),
|
|
||||||
allDay: false,
|
|
||||||
location: "Room 1",
|
|
||||||
recurrence: null,
|
|
||||||
creatorId: "user-1",
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
projectId: null,
|
|
||||||
metadata: {},
|
|
||||||
createdAt: new Date("2026-01-30T09:00:00Z"),
|
|
||||||
updatedAt: new Date("2026-01-30T09:00:00Z"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function finishWidgetLoad(): Promise<void> {
|
async function finishWidgetLoad(): Promise<void> {
|
||||||
await waitFor(() => {
|
await act(async () => {
|
||||||
expect(screen.queryByText("Loading events...")).not.toBeInTheDocument();
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("CalendarWidget", (): void => {
|
describe("CalendarWidget", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.clearAllMocks();
|
vi.useFakeTimers();
|
||||||
vi.mocked(fetchEvents).mockResolvedValue(mockEvents);
|
|
||||||
vi.setSystemTime(new Date("2026-02-01T08:00:00Z"));
|
vi.setSystemTime(new Date("2026-02-01T08:00:00Z"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,15 +24,15 @@ describe("CalendarWidget", (): void => {
|
|||||||
expect(screen.getByText("Loading events...")).toBeInTheDocument();
|
expect(screen.getByText("Loading events...")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches and renders upcoming events after loading", async (): Promise<void> => {
|
it("renders upcoming events after loading", async (): Promise<void> => {
|
||||||
render(<CalendarWidget id="calendar-1" />);
|
render(<CalendarWidget id="calendar-1" />);
|
||||||
|
|
||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
expect(fetchEvents).toHaveBeenCalledTimes(1);
|
|
||||||
expect(screen.getByText("Upcoming Events")).toBeInTheDocument();
|
expect(screen.getByText("Upcoming Events")).toBeInTheDocument();
|
||||||
expect(screen.getByText("API Planning")).toBeInTheDocument();
|
expect(screen.getByText("Team Standup")).toBeInTheDocument();
|
||||||
expect(screen.getByText("API Review")).toBeInTheDocument();
|
expect(screen.getByText("Project Review")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Sprint Planning")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows relative day labels", async (): Promise<void> => {
|
it("shows relative day labels", async (): Promise<void> => {
|
||||||
@@ -92,15 +50,6 @@ describe("CalendarWidget", (): void => {
|
|||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
expect(screen.getByText("Zoom")).toBeInTheDocument();
|
expect(screen.getByText("Zoom")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Room 1")).toBeInTheDocument();
|
expect(screen.getByText("Conference Room A")).toBeInTheDocument();
|
||||||
});
|
|
||||||
|
|
||||||
it("shows empty state when no events are returned", async (): Promise<void> => {
|
|
||||||
vi.mocked(fetchEvents).mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
render(<CalendarWidget id="calendar-1" />);
|
|
||||||
await finishWidgetLoad();
|
|
||||||
|
|
||||||
expect(screen.getByText("No upcoming events")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,80 +1,20 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import { TaskStatus, TaskPriority, type Task } from "@mosaic/shared";
|
|
||||||
import { TasksWidget } from "../TasksWidget";
|
import { TasksWidget } from "../TasksWidget";
|
||||||
import { fetchTasks } from "@/lib/api/tasks";
|
|
||||||
|
|
||||||
vi.mock("@/lib/api/tasks", () => ({
|
|
||||||
fetchTasks: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockTasks: Task[] = [
|
|
||||||
{
|
|
||||||
id: "task-1",
|
|
||||||
title: "API task one",
|
|
||||||
description: null,
|
|
||||||
status: TaskStatus.IN_PROGRESS,
|
|
||||||
priority: TaskPriority.HIGH,
|
|
||||||
dueDate: new Date("2026-02-03T09:00:00Z"),
|
|
||||||
creatorId: "user-1",
|
|
||||||
assigneeId: "user-1",
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
projectId: null,
|
|
||||||
parentId: null,
|
|
||||||
sortOrder: 0,
|
|
||||||
metadata: {},
|
|
||||||
completedAt: null,
|
|
||||||
createdAt: new Date("2026-02-01T09:00:00Z"),
|
|
||||||
updatedAt: new Date("2026-02-01T09:00:00Z"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "task-2",
|
|
||||||
title: "API task two",
|
|
||||||
description: null,
|
|
||||||
status: TaskStatus.NOT_STARTED,
|
|
||||||
priority: TaskPriority.MEDIUM,
|
|
||||||
dueDate: new Date("2026-02-04T09:00:00Z"),
|
|
||||||
creatorId: "user-1",
|
|
||||||
assigneeId: "user-1",
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
projectId: null,
|
|
||||||
parentId: null,
|
|
||||||
sortOrder: 1,
|
|
||||||
metadata: {},
|
|
||||||
completedAt: null,
|
|
||||||
createdAt: new Date("2026-02-01T09:00:00Z"),
|
|
||||||
updatedAt: new Date("2026-02-01T09:00:00Z"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "task-3",
|
|
||||||
title: "API task three",
|
|
||||||
description: null,
|
|
||||||
status: TaskStatus.COMPLETED,
|
|
||||||
priority: TaskPriority.LOW,
|
|
||||||
dueDate: new Date("2026-02-05T09:00:00Z"),
|
|
||||||
creatorId: "user-1",
|
|
||||||
assigneeId: "user-1",
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
projectId: null,
|
|
||||||
parentId: null,
|
|
||||||
sortOrder: 2,
|
|
||||||
metadata: {},
|
|
||||||
completedAt: new Date("2026-02-02T09:00:00Z"),
|
|
||||||
createdAt: new Date("2026-02-01T09:00:00Z"),
|
|
||||||
updatedAt: new Date("2026-02-02T09:00:00Z"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function finishWidgetLoad(): Promise<void> {
|
async function finishWidgetLoad(): Promise<void> {
|
||||||
await waitFor(() => {
|
await act(async () => {
|
||||||
expect(screen.queryByText("Loading tasks...")).not.toBeInTheDocument();
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TasksWidget", (): void => {
|
describe("TasksWidget", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.clearAllMocks();
|
vi.useFakeTimers();
|
||||||
vi.mocked(fetchTasks).mockResolvedValue(mockTasks);
|
});
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders loading state initially", (): void => {
|
it("renders loading state initially", (): void => {
|
||||||
@@ -83,26 +23,25 @@ describe("TasksWidget", (): void => {
|
|||||||
expect(screen.getByText("Loading tasks...")).toBeInTheDocument();
|
expect(screen.getByText("Loading tasks...")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches tasks and renders summary stats", async (): Promise<void> => {
|
it("renders default summary stats", async (): Promise<void> => {
|
||||||
render(<TasksWidget id="tasks-1" />);
|
render(<TasksWidget id="tasks-1" />);
|
||||||
|
|
||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
expect(fetchTasks).toHaveBeenCalledTimes(1);
|
|
||||||
expect(screen.getByText("Total")).toBeInTheDocument();
|
expect(screen.getByText("Total")).toBeInTheDocument();
|
||||||
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Done")).toBeInTheDocument();
|
expect(screen.getByText("Done")).toBeInTheDocument();
|
||||||
expect(screen.getByText("3")).toBeInTheDocument();
|
expect(screen.getByText("3")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders task rows from API response", async (): Promise<void> => {
|
it("renders default task rows", async (): Promise<void> => {
|
||||||
render(<TasksWidget id="tasks-1" />);
|
render(<TasksWidget id="tasks-1" />);
|
||||||
|
|
||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
expect(screen.getByText("API task one")).toBeInTheDocument();
|
expect(screen.getByText("Complete project documentation")).toBeInTheDocument();
|
||||||
expect(screen.getByText("API task two")).toBeInTheDocument();
|
expect(screen.getByText("Review pull requests")).toBeInTheDocument();
|
||||||
expect(screen.getByText("API task three")).toBeInTheDocument();
|
expect(screen.getByText("Update dependencies")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows due date labels for each task", async (): Promise<void> => {
|
it("shows due date labels for each task", async (): Promise<void> => {
|
||||||
@@ -112,13 +51,4 @@ describe("TasksWidget", (): void => {
|
|||||||
|
|
||||||
expect(screen.getAllByText(/Due:/).length).toBe(3);
|
expect(screen.getAllByText(/Due:/).length).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows empty state when API returns no tasks", async (): Promise<void> => {
|
|
||||||
vi.mocked(fetchTasks).mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
render(<TasksWidget id="tasks-1" />);
|
|
||||||
await finishWidgetLoad();
|
|
||||||
|
|
||||||
expect(screen.getByText("No tasks yet")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useChat, type Message } from "./useChat";
|
|||||||
import * as chatApi from "@/lib/api/chat";
|
import * as chatApi from "@/lib/api/chat";
|
||||||
import * as ideasApi from "@/lib/api/ideas";
|
import * as ideasApi from "@/lib/api/ideas";
|
||||||
import type { Idea } from "@/lib/api/ideas";
|
import type { Idea } from "@/lib/api/ideas";
|
||||||
|
import type { ChatResponse } from "@/lib/api/chat";
|
||||||
|
|
||||||
// Mock the API modules - use importOriginal to preserve types/enums
|
// Mock the API modules - use importOriginal to preserve types/enums
|
||||||
vi.mock("@/lib/api/chat", () => ({
|
vi.mock("@/lib/api/chat", () => ({
|
||||||
@@ -36,8 +37,24 @@ const mockStreamChatMessage = chatApi.streamChatMessage as MockedFunction<
|
|||||||
const mockCreateConversation = ideasApi.createConversation as MockedFunction<
|
const mockCreateConversation = ideasApi.createConversation as MockedFunction<
|
||||||
typeof ideasApi.createConversation
|
typeof ideasApi.createConversation
|
||||||
>;
|
>;
|
||||||
|
const mockUpdateConversation = ideasApi.updateConversation as MockedFunction<
|
||||||
|
typeof ideasApi.updateConversation
|
||||||
|
>;
|
||||||
const mockGetIdea = ideasApi.getIdea as MockedFunction<typeof ideasApi.getIdea>;
|
const mockGetIdea = ideasApi.getIdea as MockedFunction<typeof ideasApi.getIdea>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock ChatResponse
|
||||||
|
*/
|
||||||
|
function createMockChatResponse(content: string, model = "llama3.2"): ChatResponse {
|
||||||
|
return {
|
||||||
|
message: { role: "assistant" as const, content },
|
||||||
|
model,
|
||||||
|
done: true,
|
||||||
|
promptEvalCount: 10,
|
||||||
|
evalCount: 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a mock Idea
|
* Creates a mock Idea
|
||||||
*/
|
*/
|
||||||
@@ -59,9 +76,9 @@ function createMockIdea(id: string, title: string, content: string): Idea {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure streamChatMessage to immediately fail,
|
* Configure streamChatMessage to immediately fail,
|
||||||
* without using a non-streaming fallback.
|
* triggering the fallback to sendChatMessage.
|
||||||
*/
|
*/
|
||||||
function makeStreamFail(error: Error = new Error("Streaming not available")): void {
|
function makeStreamFail(): void {
|
||||||
mockStreamChatMessage.mockImplementation(
|
mockStreamChatMessage.mockImplementation(
|
||||||
(
|
(
|
||||||
_request,
|
_request,
|
||||||
@@ -71,7 +88,7 @@ function makeStreamFail(error: Error = new Error("Streaming not available")): vo
|
|||||||
_signal?: AbortSignal
|
_signal?: AbortSignal
|
||||||
): void => {
|
): void => {
|
||||||
// Call synchronously so the Promise rejects immediately
|
// Call synchronously so the Promise rejects immediately
|
||||||
onError(error);
|
onError(new Error("Streaming not available"));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -138,7 +155,24 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendMessage (streaming failure path)", () => {
|
describe("sendMessage (fallback path when streaming fails)", () => {
|
||||||
|
it("should add user message and assistant response via fallback", async () => {
|
||||||
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Hello there!"));
|
||||||
|
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages).toHaveLength(3); // welcome + user + assistant
|
||||||
|
expect(result.current.messages[1]?.role).toBe("user");
|
||||||
|
expect(result.current.messages[1]?.content).toBe("Hello");
|
||||||
|
expect(result.current.messages[2]?.role).toBe("assistant");
|
||||||
|
expect(result.current.messages[2]?.content).toBe("Hello there!");
|
||||||
|
});
|
||||||
|
|
||||||
it("should not send empty messages", async () => {
|
it("should not send empty messages", async () => {
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
@@ -152,19 +186,22 @@ describe("useChat", () => {
|
|||||||
expect(result.current.messages).toHaveLength(1); // only welcome
|
expect(result.current.messages).toHaveLength(1); // only welcome
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle streaming errors gracefully", async () => {
|
it("should handle API errors gracefully", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
makeStreamFail(new Error("Streaming not available"));
|
mockSendChatMessage.mockRejectedValueOnce(new Error("API Error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const onError = vi.fn();
|
||||||
|
const { result } = renderHook(() => useChat({ onError }));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Streaming fails, no fallback, placeholder is removed
|
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
||||||
expect(result.current.error).toContain("Chat error:");
|
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
||||||
expect(result.current.messages).toHaveLength(2); // welcome + user (no assistant)
|
expect(result.current.messages).toHaveLength(3);
|
||||||
|
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -551,8 +588,9 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
describe("clearError", () => {
|
describe("clearError", () => {
|
||||||
it("should clear error state", async () => {
|
it("should clear error state", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
makeStreamFail(new Error("Test error"));
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Test error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
@@ -560,7 +598,7 @@ describe("useChat", () => {
|
|||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.error).toContain("Chat error:");
|
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.clearError();
|
result.current.clearError();
|
||||||
@@ -570,14 +608,87 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: "error context logging" tests removed - the detailed logging with LLM_ERROR type
|
describe("error context logging", () => {
|
||||||
// was removed in commit 44da50d when guest fallback mode was removed.
|
it("should log comprehensive error context when sendMessage fails", async () => {
|
||||||
// The implementation now uses simple console.warn for streaming failures.
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
mockSendChatMessage.mockRejectedValueOnce(new Error("LLM timeout"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat({ model: "llama3.2" }));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to send chat message",
|
||||||
|
expect.objectContaining({
|
||||||
|
errorType: "LLM_ERROR",
|
||||||
|
messageLength: 11,
|
||||||
|
messagePreview: "Hello world",
|
||||||
|
model: "llama3.2",
|
||||||
|
timestamp: expect.any(String) as string,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should truncate long message previews to 50 characters", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Failed"));
|
||||||
|
|
||||||
|
const longMessage = "A".repeat(100);
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage(longMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to send chat message",
|
||||||
|
expect.objectContaining({
|
||||||
|
messagePreview: "A".repeat(50),
|
||||||
|
messageLength: 100,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include message count in error context", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
// First successful message via streaming
|
||||||
|
makeStreamSucceed(["OK"]);
|
||||||
|
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("First");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second message: streaming fails, fallback fails
|
||||||
|
makeStreamFail();
|
||||||
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Fail"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Second");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to send chat message",
|
||||||
|
expect.objectContaining({
|
||||||
|
messageCount: expect.any(Number) as number,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("LLM vs persistence error separation", () => {
|
describe("LLM vs persistence error separation", () => {
|
||||||
it("should show streaming error when stream fails", async () => {
|
it("should show LLM error and add error message to chat when API fails", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
makeStreamFail(new Error("Streaming not available"));
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
@@ -585,9 +696,9 @@ describe("useChat", () => {
|
|||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Streaming fails, placeholder is removed, error is set
|
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
||||||
expect(result.current.error).toContain("Chat error:");
|
expect(result.current.messages).toHaveLength(3);
|
||||||
expect(result.current.messages).toHaveLength(2); // welcome + user (no assistant)
|
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep assistant message visible when save fails (streaming path)", async () => {
|
it("should keep assistant message visible when save fails (streaming path)", async () => {
|
||||||
@@ -606,10 +717,27 @@ describe("useChat", () => {
|
|||||||
expect(result.current.error).toContain("Message sent but failed to save");
|
expect(result.current.error).toContain("Message sent but failed to save");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should keep assistant message visible when save fails (fallback path)", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Great answer!"));
|
||||||
|
mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages).toHaveLength(3);
|
||||||
|
expect(result.current.messages[2]?.content).toBe("Great answer!");
|
||||||
|
expect(result.current.error).toContain("Message sent but failed to save");
|
||||||
|
});
|
||||||
|
|
||||||
it("should log with PERSISTENCE_ERROR type when save fails", async () => {
|
it("should log with PERSISTENCE_ERROR type when save fails", async () => {
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
makeStreamSucceed(["Response"]);
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
|
||||||
mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
|
mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
@@ -637,6 +765,53 @@ describe("useChat", () => {
|
|||||||
expect(llmErrorCalls).toHaveLength(0);
|
expect(llmErrorCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should use different user-facing messages for LLM vs save errors", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
// LLM error path (streaming fails + fallback fails)
|
||||||
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Timeout"));
|
||||||
|
const { result: result1 } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result1.current.sendMessage("Test");
|
||||||
|
});
|
||||||
|
|
||||||
|
const llmError = result1.current.error;
|
||||||
|
|
||||||
|
// Save error path (streaming succeeds, save fails)
|
||||||
|
makeStreamSucceed(["OK"]);
|
||||||
|
mockCreateConversation.mockRejectedValueOnce(new Error("DB down"));
|
||||||
|
const { result: result2 } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result2.current.sendMessage("Test");
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveError = result2.current.error;
|
||||||
|
|
||||||
|
expect(llmError).toBe("Unable to send message. Please try again.");
|
||||||
|
expect(saveError).toContain("Message sent but failed to save");
|
||||||
|
expect(llmError).not.toEqual(saveError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle non-Error throws from LLM API", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
mockSendChatMessage.mockRejectedValueOnce("string error");
|
||||||
|
|
||||||
|
const onError = vi.fn();
|
||||||
|
const { result } = renderHook(() => useChat({ onError }));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
||||||
|
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
||||||
|
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
||||||
|
});
|
||||||
|
|
||||||
it("should handle non-Error throws from persistence layer", async () => {
|
it("should handle non-Error throws from persistence layer", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
@@ -654,5 +829,37 @@ describe("useChat", () => {
|
|||||||
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
||||||
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should handle updateConversation failure for existing conversations", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
// First message via fallback
|
||||||
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("First response"));
|
||||||
|
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("First");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.conversationId).toBe("conv-1");
|
||||||
|
|
||||||
|
// Second message via fallback, updateConversation fails
|
||||||
|
makeStreamFail();
|
||||||
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Second response"));
|
||||||
|
mockUpdateConversation.mockRejectedValueOnce(new Error("Connection reset"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Second");
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantMessages = result.current.messages.filter(
|
||||||
|
(m) => m.role === "assistant" && m.id !== "welcome"
|
||||||
|
);
|
||||||
|
expect(assistantMessages[assistantMessages.length - 1]?.content).toBe("Second response");
|
||||||
|
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
import { streamChatMessage, type ChatMessage as ApiChatMessage } from "@/lib/api/chat";
|
import {
|
||||||
|
sendChatMessage,
|
||||||
|
streamChatMessage,
|
||||||
|
type ChatMessage as ApiChatMessage,
|
||||||
|
} from "@/lib/api/chat";
|
||||||
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
|
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
|
||||||
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
|
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
|
||||||
|
|
||||||
@@ -27,7 +31,6 @@ export interface UseChatOptions {
|
|||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
agent?: string;
|
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +67,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
maxTokens,
|
maxTokens,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
projectId,
|
projectId,
|
||||||
agent,
|
|
||||||
onError,
|
onError,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@@ -79,10 +81,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
const projectIdRef = useRef<string | null>(projectId ?? null);
|
const projectIdRef = useRef<string | null>(projectId ?? null);
|
||||||
projectIdRef.current = projectId ?? null;
|
projectIdRef.current = projectId ?? null;
|
||||||
|
|
||||||
// Track agent in ref to prevent stale closures
|
|
||||||
const agentRef = useRef<string | undefined>(agent);
|
|
||||||
agentRef.current = agent;
|
|
||||||
|
|
||||||
// Track messages in ref to prevent stale closures during rapid sends
|
// Track messages in ref to prevent stale closures during rapid sends
|
||||||
const messagesRef = useRef<Message[]>(messages);
|
const messagesRef = useRef<Message[]>(messages);
|
||||||
messagesRef.current = messages;
|
messagesRef.current = messages;
|
||||||
@@ -215,12 +213,13 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
...(temperature !== undefined && { temperature }),
|
...(temperature !== undefined && { temperature }),
|
||||||
...(maxTokens !== undefined && { maxTokens }),
|
...(maxTokens !== undefined && { maxTokens }),
|
||||||
...(systemPrompt !== undefined && { systemPrompt }),
|
...(systemPrompt !== undefined && { systemPrompt }),
|
||||||
...(agentRef.current && { agent: agentRef.current }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortControllerRef.current = controller;
|
abortControllerRef.current = controller;
|
||||||
|
|
||||||
|
let streamingSucceeded = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
let hasReceivedData = false;
|
let hasReceivedData = false;
|
||||||
@@ -248,6 +247,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
|
streamingSucceeded = true;
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
resolve();
|
resolve();
|
||||||
@@ -278,8 +278,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming failed — show error (no guest fallback, auth required)
|
// Streaming failed — fall back to non-streaming
|
||||||
console.warn("Streaming failed", {
|
console.warn("Streaming failed, falling back to non-streaming", {
|
||||||
error: err instanceof Error ? err : new Error(String(err)),
|
error: err instanceof Error ? err : new Error(String(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,15 +289,66 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
return withoutPlaceholder;
|
return withoutPlaceholder;
|
||||||
});
|
});
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
const errorMsg = err instanceof Error ? err.message : "Chat unavailable";
|
try {
|
||||||
setError(`Chat error: ${errorMsg}`);
|
const response = await sendChatMessage(request);
|
||||||
|
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: `assistant-${Date.now().toString()}`,
|
||||||
|
role: "assistant",
|
||||||
|
content: response.message.content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
model: response.model,
|
||||||
|
promptTokens: response.promptEvalCount ?? 0,
|
||||||
|
completionTokens: response.evalCount ?? 0,
|
||||||
|
totalTokens: (response.promptEvalCount ?? 0) + (response.evalCount ?? 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev, assistantMessage];
|
||||||
|
messagesRef.current = updated;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
streamingSucceeded = true;
|
||||||
|
} catch (fallbackErr: unknown) {
|
||||||
|
const errorMsg =
|
||||||
|
fallbackErr instanceof Error ? fallbackErr.message : "Failed to send message";
|
||||||
|
setError("Unable to send message. Please try again.");
|
||||||
|
onError?.(fallbackErr instanceof Error ? fallbackErr : new Error(errorMsg));
|
||||||
|
console.error("Failed to send chat message", {
|
||||||
|
error: fallbackErr,
|
||||||
|
errorType: "LLM_ERROR",
|
||||||
|
conversationId: conversationIdRef.current,
|
||||||
|
messageLength: content.length,
|
||||||
|
messagePreview: content.substring(0, 50),
|
||||||
|
model,
|
||||||
|
messageCount: messagesRef.current.length,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMessage: Message = {
|
||||||
|
id: `error-${String(Date.now())}`,
|
||||||
|
role: "assistant",
|
||||||
|
content: "Something went wrong. Please try again.",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev, errorMessage];
|
||||||
|
messagesRef.current = updated;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
|
if (!streamingSucceeded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const finalMessages = messagesRef.current;
|
const finalMessages = messagesRef.current;
|
||||||
|
|
||||||
const isFirstMessage =
|
const isFirstMessage =
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
/**
|
|
||||||
* Activity API Client
|
|
||||||
* Handles activity-log-related API requests
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiGet, type ApiResponse } from "./client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Activity action enum (matches backend ActivityAction)
|
|
||||||
*/
|
|
||||||
export enum ActivityAction {
|
|
||||||
CREATED = "CREATED",
|
|
||||||
UPDATED = "UPDATED",
|
|
||||||
DELETED = "DELETED",
|
|
||||||
COMPLETED = "COMPLETED",
|
|
||||||
ASSIGNED = "ASSIGNED",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity type enum (matches backend EntityType)
|
|
||||||
*/
|
|
||||||
export enum EntityType {
|
|
||||||
TASK = "TASK",
|
|
||||||
EVENT = "EVENT",
|
|
||||||
PROJECT = "PROJECT",
|
|
||||||
WORKSPACE = "WORKSPACE",
|
|
||||||
USER = "USER",
|
|
||||||
DOMAIN = "DOMAIN",
|
|
||||||
IDEA = "IDEA",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Activity log response interface (matches Prisma ActivityLog model)
|
|
||||||
*/
|
|
||||||
export interface ActivityLog {
|
|
||||||
id: string;
|
|
||||||
workspaceId: string;
|
|
||||||
userId: string;
|
|
||||||
action: ActivityAction;
|
|
||||||
entityType: EntityType;
|
|
||||||
entityId: string;
|
|
||||||
details: Record<string, unknown> | null;
|
|
||||||
ipAddress: string | null;
|
|
||||||
userAgent: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
user?: {
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters for querying activity logs
|
|
||||||
*/
|
|
||||||
export interface ActivityLogFilters {
|
|
||||||
workspaceId?: string;
|
|
||||||
userId?: string;
|
|
||||||
action?: ActivityAction;
|
|
||||||
entityType?: EntityType;
|
|
||||||
entityId?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paginated activity logs response
|
|
||||||
*/
|
|
||||||
export interface PaginatedActivityLogs {
|
|
||||||
data: ActivityLog[];
|
|
||||||
meta: {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
totalPages: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch activity logs with optional filters
|
|
||||||
*/
|
|
||||||
export async function fetchActivityLogs(filters?: ActivityLogFilters): Promise<ActivityLog[]> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
if (filters?.userId) {
|
|
||||||
params.append("userId", filters.userId);
|
|
||||||
}
|
|
||||||
if (filters?.action) {
|
|
||||||
params.append("action", filters.action);
|
|
||||||
}
|
|
||||||
if (filters?.entityType) {
|
|
||||||
params.append("entityType", filters.entityType);
|
|
||||||
}
|
|
||||||
if (filters?.entityId) {
|
|
||||||
params.append("entityId", filters.entityId);
|
|
||||||
}
|
|
||||||
if (filters?.startDate) {
|
|
||||||
params.append("startDate", filters.startDate);
|
|
||||||
}
|
|
||||||
if (filters?.endDate) {
|
|
||||||
params.append("endDate", filters.endDate);
|
|
||||||
}
|
|
||||||
if (filters?.page !== undefined) {
|
|
||||||
params.append("page", String(filters.page));
|
|
||||||
}
|
|
||||||
if (filters?.limit !== undefined) {
|
|
||||||
params.append("limit", String(filters.limit));
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryString = params.toString();
|
|
||||||
const endpoint = queryString ? `/api/activity?${queryString}` : "/api/activity";
|
|
||||||
|
|
||||||
const response = await apiGet<PaginatedActivityLogs>(endpoint, filters?.workspaceId);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a single activity log by ID
|
|
||||||
*/
|
|
||||||
export async function fetchActivityLog(id: string, workspaceId?: string): Promise<ActivityLog> {
|
|
||||||
return apiGet<ActivityLog>(`/api/activity/${id}`, workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch audit trail for a specific entity
|
|
||||||
*/
|
|
||||||
export async function fetchAuditTrail(
|
|
||||||
entityType: EntityType,
|
|
||||||
entityId: string,
|
|
||||||
workspaceId?: string
|
|
||||||
): Promise<ActivityLog[]> {
|
|
||||||
const response = await apiGet<ApiResponse<ActivityLog[]>>(
|
|
||||||
`/api/activity/audit/${entityType}/${entityId}`,
|
|
||||||
workspaceId
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
/**
|
|
||||||
* Agent API client
|
|
||||||
* Handles agent-related API interactions
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "./client";
|
|
||||||
|
|
||||||
export interface AgentStatus {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
role: string;
|
|
||||||
isActive: boolean;
|
|
||||||
containerStatus?: "running" | "stopped" | "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserAgent {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
templateId: string | null;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
role: string;
|
|
||||||
personality: string;
|
|
||||||
primaryModel: string | null;
|
|
||||||
fallbackModels: string[];
|
|
||||||
toolPermissions: string[];
|
|
||||||
discordChannel: string | null;
|
|
||||||
isActive: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateUserAgentRequest {
|
|
||||||
templateId?: string;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
role: string;
|
|
||||||
personality: string;
|
|
||||||
primaryModel?: string;
|
|
||||||
fallbackModels?: string[];
|
|
||||||
toolPermissions?: string[];
|
|
||||||
discordChannel?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateUserAgentRequest {
|
|
||||||
name?: string;
|
|
||||||
displayName?: string;
|
|
||||||
role?: string;
|
|
||||||
personality?: string;
|
|
||||||
primaryModel?: string;
|
|
||||||
fallbackModels?: string[];
|
|
||||||
toolPermissions?: string[];
|
|
||||||
discordChannel?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateUserAgentRequest {
|
|
||||||
name?: string;
|
|
||||||
displayName?: string;
|
|
||||||
role?: string;
|
|
||||||
personality?: string;
|
|
||||||
primaryModel?: string;
|
|
||||||
fallbackModels?: string[];
|
|
||||||
toolPermissions?: string[];
|
|
||||||
discordChannel?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all user's agents
|
|
||||||
*/
|
|
||||||
export async function getAgents(): Promise<UserAgent[]> {
|
|
||||||
return apiGet<UserAgent[]>("/api/agents");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all agent statuses
|
|
||||||
*/
|
|
||||||
export async function getAgentStatuses(): Promise<AgentStatus[]> {
|
|
||||||
return apiGet<AgentStatus[]>("/api/agents/status");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single agent by ID
|
|
||||||
*/
|
|
||||||
export async function getAgent(id: string): Promise<UserAgent> {
|
|
||||||
return apiGet<UserAgent>(`/api/agents/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single agent's status
|
|
||||||
*/
|
|
||||||
export async function getAgentStatus(id: string): Promise<AgentStatus> {
|
|
||||||
return apiGet<AgentStatus>(`/api/agents/${id}/status`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new custom agent
|
|
||||||
*/
|
|
||||||
export async function createAgent(data: CreateUserAgentRequest): Promise<UserAgent> {
|
|
||||||
return apiPost<UserAgent>("/api/agents", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an agent from a template
|
|
||||||
*/
|
|
||||||
export async function createAgentFromTemplate(templateId: string): Promise<UserAgent> {
|
|
||||||
return apiPost<UserAgent>(`/api/agents/from-template/${templateId}`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an agent
|
|
||||||
*/
|
|
||||||
export async function updateAgent(id: string, data: UpdateUserAgentRequest): Promise<UserAgent> {
|
|
||||||
return apiPatch<UserAgent>(`/api/agents/${id}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an agent
|
|
||||||
*/
|
|
||||||
export async function deleteAgent(id: string): Promise<void> {
|
|
||||||
await apiDelete(`/api/agents/${id}`);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Chat API client
|
* Chat API client
|
||||||
* Handles LLM chat interactions via /api/chat/stream (streaming) and /api/llm/chat (fallback)
|
* Handles LLM chat interactions via /api/llm/chat
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiPost, fetchCsrfToken, getCsrfToken } from "./client";
|
import { apiPost, fetchCsrfToken, getCsrfToken } from "./client";
|
||||||
@@ -18,7 +18,6 @@ export interface ChatRequest {
|
|||||||
temperature?: number;
|
temperature?: number;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
agent?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatResponse {
|
export interface ChatResponse {
|
||||||
@@ -34,28 +33,9 @@ export interface ChatResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parsed SSE data chunk from OpenAI-compatible stream
|
* Parsed SSE data chunk from the LLM stream
|
||||||
*/
|
*/
|
||||||
interface OpenAiSseChunk {
|
interface SseChunk {
|
||||||
id?: string;
|
|
||||||
object?: string;
|
|
||||||
created?: number;
|
|
||||||
model?: string;
|
|
||||||
choices?: {
|
|
||||||
index: number;
|
|
||||||
delta?: {
|
|
||||||
role?: string;
|
|
||||||
content?: string;
|
|
||||||
};
|
|
||||||
finish_reason?: string | null;
|
|
||||||
}[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parsed SSE data chunk from legacy /api/llm/chat stream
|
|
||||||
*/
|
|
||||||
interface LegacySseChunk {
|
|
||||||
error?: string;
|
error?: string;
|
||||||
message?: {
|
message?: {
|
||||||
role: string;
|
role: string;
|
||||||
@@ -66,17 +46,7 @@ interface LegacySseChunk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parsed SSE data chunk with simple token format
|
* Send a chat message to the LLM
|
||||||
*/
|
|
||||||
interface SimpleTokenChunk {
|
|
||||||
token?: string;
|
|
||||||
done?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a chat message to the LLM (non-streaming fallback)
|
|
||||||
* Uses /api/llm/chat endpoint which supports both streaming and non-streaming
|
|
||||||
*/
|
*/
|
||||||
export async function sendChatMessage(request: ChatRequest): Promise<ChatResponse> {
|
export async function sendChatMessage(request: ChatRequest): Promise<ChatResponse> {
|
||||||
return apiPost<ChatResponse>("/api/llm/chat", request);
|
return apiPost<ChatResponse>("/api/llm/chat", request);
|
||||||
@@ -93,162 +63,14 @@ async function ensureCsrfTokenForStream(): Promise<string> {
|
|||||||
return fetchCsrfToken();
|
return fetchCsrfToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stream a guest chat message (no authentication required).
|
|
||||||
* Uses /api/chat/guest endpoint with shared LLM configuration.
|
|
||||||
*
|
|
||||||
* @param request - Chat request
|
|
||||||
* @param onChunk - Called with each token string as it arrives
|
|
||||||
* @param onComplete - Called when the stream finishes successfully
|
|
||||||
* @param onError - Called if the stream encounters an error
|
|
||||||
* @param signal - Optional AbortSignal for cancellation
|
|
||||||
*/
|
|
||||||
export function streamGuestChat(
|
|
||||||
request: ChatRequest,
|
|
||||||
onChunk: (chunk: string) => void,
|
|
||||||
onComplete: () => void,
|
|
||||||
onError: (error: Error) => void,
|
|
||||||
signal?: AbortSignal
|
|
||||||
): void {
|
|
||||||
void (async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/api/chat/guest`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({
|
|
||||||
messages: request.messages,
|
|
||||||
stream: true,
|
|
||||||
...(request.agent && { agent: request.agent }),
|
|
||||||
}),
|
|
||||||
signal: signal ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text().catch(() => response.statusText);
|
|
||||||
throw new Error(`Guest chat failed: ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error("Response body is not readable");
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder("utf-8");
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
let readerDone = false;
|
|
||||||
while (!readerDone) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
readerDone = done;
|
|
||||||
if (done) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
|
|
||||||
// SSE messages are separated by double newlines
|
|
||||||
const parts = buffer.split("\n\n");
|
|
||||||
buffer = parts.pop() ?? "";
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
const trimmed = part.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
|
|
||||||
// Handle event: error format
|
|
||||||
const eventMatch = /^event:\s*(\S+)\n/i.exec(trimmed);
|
|
||||||
const dataMatch = /^data:\s*(.+)$/im.exec(trimmed);
|
|
||||||
|
|
||||||
if (eventMatch?.[1] === "error" && dataMatch?.[1]) {
|
|
||||||
try {
|
|
||||||
const errorData = JSON.parse(dataMatch[1].trim()) as {
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
throw new Error(errorData.error ?? "Stream error occurred");
|
|
||||||
} catch (parseErr) {
|
|
||||||
if (parseErr instanceof SyntaxError) {
|
|
||||||
throw new Error("Stream error occurred");
|
|
||||||
}
|
|
||||||
throw parseErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard SSE format: data: {...}
|
|
||||||
for (const line of trimmed.split("\n")) {
|
|
||||||
if (!line.startsWith("data: ")) continue;
|
|
||||||
|
|
||||||
const data = line.slice("data: ".length).trim();
|
|
||||||
|
|
||||||
if (data === "[DONE]") {
|
|
||||||
onComplete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(data);
|
|
||||||
|
|
||||||
// Handle OpenAI format
|
|
||||||
const openAiChunk = parsed as OpenAiSseChunk;
|
|
||||||
if (openAiChunk.choices?.[0]?.delta?.content) {
|
|
||||||
onChunk(openAiChunk.choices[0].delta.content);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle simple token format
|
|
||||||
const simpleChunk = parsed as SimpleTokenChunk;
|
|
||||||
if (simpleChunk.token) {
|
|
||||||
onChunk(simpleChunk.token);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (simpleChunk.done === true) {
|
|
||||||
onComplete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = openAiChunk.error ?? simpleChunk.error;
|
|
||||||
if (error) {
|
|
||||||
throw new Error(error);
|
|
||||||
}
|
|
||||||
} catch (parseErr) {
|
|
||||||
if (parseErr instanceof SyntaxError) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw parseErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onComplete();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (err instanceof DOMException && err.name === "AbortError") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onError(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream a chat message from the LLM using SSE over fetch.
|
* Stream a chat message from the LLM using SSE over fetch.
|
||||||
*
|
*
|
||||||
* Uses /api/chat/stream endpoint which proxies to OpenClaw.
|
* The backend accepts stream: true in the request body and responds with
|
||||||
* The backend responds with Server-Sent Events in one of these formats:
|
* Server-Sent Events:
|
||||||
*
|
* data: {"message":{"content":"token"},...}\n\n for each token
|
||||||
* OpenAI-compatible format:
|
* data: [DONE]\n\n when the stream is complete
|
||||||
* data: {"choices":[{"delta":{"content":"token"}}],...}\n\n
|
* data: {"error":"message"}\n\n on error
|
||||||
* data: [DONE]\n\n
|
|
||||||
*
|
|
||||||
* Legacy format (from /api/llm/chat):
|
|
||||||
* data: {"message":{"content":"token"},...}\n\n
|
|
||||||
* data: [DONE]\n\n
|
|
||||||
*
|
|
||||||
* Simple token format:
|
|
||||||
* data: {"token":"..."}\n\n
|
|
||||||
* data: {"done":true}\n\n
|
|
||||||
*
|
*
|
||||||
* @param request - Chat request (stream field will be forced to true)
|
* @param request - Chat request (stream field will be forced to true)
|
||||||
* @param onChunk - Called with each token string as it arrives
|
* @param onChunk - Called with each token string as it arrives
|
||||||
@@ -267,18 +89,14 @@ export function streamChatMessage(
|
|||||||
try {
|
try {
|
||||||
const csrfToken = await ensureCsrfTokenForStream();
|
const csrfToken = await ensureCsrfTokenForStream();
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/api/chat/stream`, {
|
const response = await fetch(`${API_BASE_URL}/api/llm/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-CSRF-Token": csrfToken,
|
"X-CSRF-Token": csrfToken,
|
||||||
},
|
},
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ ...request, stream: true }),
|
||||||
messages: request.messages,
|
|
||||||
stream: true,
|
|
||||||
...(request.agent && { agent: request.agent }),
|
|
||||||
}),
|
|
||||||
signal: signal ?? null,
|
signal: signal ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -314,25 +132,6 @@ export function streamChatMessage(
|
|||||||
const trimmed = part.trim();
|
const trimmed = part.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
|
|
||||||
// Handle event: error format
|
|
||||||
const eventMatch = /^event:\s*(\S+)\n/i.exec(trimmed);
|
|
||||||
const dataMatch = /^data:\s*(.+)$/im.exec(trimmed);
|
|
||||||
|
|
||||||
if (eventMatch?.[1] === "error" && dataMatch?.[1]) {
|
|
||||||
try {
|
|
||||||
const errorData = JSON.parse(dataMatch[1].trim()) as {
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
throw new Error(errorData.error ?? "Stream error occurred");
|
|
||||||
} catch (parseErr) {
|
|
||||||
if (parseErr instanceof SyntaxError) {
|
|
||||||
throw new Error("Stream error occurred");
|
|
||||||
}
|
|
||||||
throw parseErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard SSE format: data: {...}
|
|
||||||
for (const line of trimmed.split("\n")) {
|
for (const line of trimmed.split("\n")) {
|
||||||
if (!line.startsWith("data: ")) continue;
|
if (!line.startsWith("data: ")) continue;
|
||||||
|
|
||||||
@@ -344,39 +143,14 @@ export function streamChatMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(data);
|
const parsed = JSON.parse(data) as SseChunk;
|
||||||
|
|
||||||
// Handle OpenAI format (from /api/chat/stream via OpenClaw)
|
if (parsed.error) {
|
||||||
const openAiChunk = parsed as OpenAiSseChunk;
|
throw new Error(parsed.error);
|
||||||
if (openAiChunk.choices?.[0]?.delta?.content) {
|
|
||||||
onChunk(openAiChunk.choices[0].delta.content);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle legacy format (from /api/llm/chat)
|
if (parsed.message?.content) {
|
||||||
const legacyChunk = parsed as LegacySseChunk;
|
onChunk(parsed.message.content);
|
||||||
if (legacyChunk.message?.content) {
|
|
||||||
onChunk(legacyChunk.message.content);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle simple token format
|
|
||||||
const simpleChunk = parsed as SimpleTokenChunk;
|
|
||||||
if (simpleChunk.token) {
|
|
||||||
onChunk(simpleChunk.token);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle done flag in simple format
|
|
||||||
if (simpleChunk.done === true) {
|
|
||||||
onComplete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle error in any format
|
|
||||||
const error = openAiChunk.error ?? legacyChunk.error ?? simpleChunk.error;
|
|
||||||
if (error) {
|
|
||||||
throw new Error(error);
|
|
||||||
}
|
}
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
if (parseErr instanceof SyntaxError) {
|
if (parseErr instanceof SyntaxError) {
|
||||||
@@ -388,7 +162,7 @@ export function streamChatMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Natural end of stream without [DONE] or done flag
|
// Natural end of stream without [DONE]
|
||||||
onComplete();
|
onComplete();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof DOMException && err.name === "AbortError") {
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
|
|||||||
@@ -37,24 +37,14 @@ describe("createFleetProvider", (): void => {
|
|||||||
name: "openai-main",
|
name: "openai-main",
|
||||||
displayName: "OpenAI Main",
|
displayName: "OpenAI Main",
|
||||||
type: "openai",
|
type: "openai",
|
||||||
baseUrl: "https://api.openai.com/v1",
|
|
||||||
apiKey: "sk-test",
|
apiKey: "sk-test",
|
||||||
models: [
|
|
||||||
{ id: "gpt-4.1-mini", name: "gpt-4.1-mini" },
|
|
||||||
{ id: "gpt-4o-mini", name: "gpt-4o-mini" },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(client.apiPost).toHaveBeenCalledWith("/api/fleet-settings/providers", {
|
expect(client.apiPost).toHaveBeenCalledWith("/api/fleet-settings/providers", {
|
||||||
name: "openai-main",
|
name: "openai-main",
|
||||||
displayName: "OpenAI Main",
|
displayName: "OpenAI Main",
|
||||||
type: "openai",
|
type: "openai",
|
||||||
baseUrl: "https://api.openai.com/v1",
|
|
||||||
apiKey: "sk-test",
|
apiKey: "sk-test",
|
||||||
models: [
|
|
||||||
{ id: "gpt-4.1-mini", name: "gpt-4.1-mini" },
|
|
||||||
{ id: "gpt-4o-mini", name: "gpt-4o-mini" },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,4 +18,3 @@ export * from "./projects";
|
|||||||
export * from "./workspaces";
|
export * from "./workspaces";
|
||||||
export * from "./admin";
|
export * from "./admin";
|
||||||
export * from "./fleet-settings";
|
export * from "./fleet-settings";
|
||||||
export * from "./activity";
|
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ export interface Project {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
priority?: string | null;
|
|
||||||
startDate: string | null;
|
startDate: string | null;
|
||||||
dueDate?: string | null;
|
|
||||||
endDate: string | null;
|
endDate: string | null;
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
domainId: string | null;
|
domainId: string | null;
|
||||||
@@ -37,54 +35,6 @@ export interface Project {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal creator details included on project detail response
|
|
||||||
*/
|
|
||||||
export interface ProjectCreator {
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Task row included on project detail response
|
|
||||||
*/
|
|
||||||
export interface ProjectTaskSummary {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
priority: string;
|
|
||||||
dueDate: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event row included on project detail response
|
|
||||||
*/
|
|
||||||
export interface ProjectEventSummary {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Counts included on project detail response
|
|
||||||
*/
|
|
||||||
export interface ProjectDetailCounts {
|
|
||||||
tasks: number;
|
|
||||||
events: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single-project response with related details
|
|
||||||
*/
|
|
||||||
export interface ProjectDetail extends Project {
|
|
||||||
creator: ProjectCreator;
|
|
||||||
tasks: ProjectTaskSummary[];
|
|
||||||
events: ProjectEventSummary[];
|
|
||||||
_count: ProjectDetailCounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO for creating a new project
|
* DTO for creating a new project
|
||||||
*/
|
*/
|
||||||
@@ -95,7 +45,6 @@ export interface CreateProjectDto {
|
|||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
domainId?: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +58,6 @@ export interface UpdateProjectDto {
|
|||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
endDate?: string | null;
|
endDate?: string | null;
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
domainId?: string | null;
|
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +72,8 @@ export async function fetchProjects(workspaceId?: string): Promise<Project[]> {
|
|||||||
/**
|
/**
|
||||||
* Fetch a single project by ID
|
* Fetch a single project by ID
|
||||||
*/
|
*/
|
||||||
export async function fetchProject(id: string, workspaceId?: string): Promise<ProjectDetail> {
|
export async function fetchProject(id: string, workspaceId?: string): Promise<Project> {
|
||||||
return apiGet<ProjectDetail>(`/api/projects/${id}`, workspaceId);
|
return apiGet<Project>(`/api/projects/${id}`, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -46,21 +46,3 @@ export async function updateTask(
|
|||||||
const res = await apiPatch<ApiResponse<Task>>(`/api/tasks/${id}`, data, workspaceId);
|
const res = await apiPatch<ApiResponse<Task>>(`/api/tasks/${id}`, data, workspaceId);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTaskInput {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
status?: TaskStatus;
|
|
||||||
priority?: TaskPriority;
|
|
||||||
dueDate?: string;
|
|
||||||
projectId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new task
|
|
||||||
*/
|
|
||||||
export async function createTask(data: CreateTaskInput, workspaceId?: string): Promise<Task> {
|
|
||||||
const { apiPost } = await import("./client");
|
|
||||||
const res = await apiPost<ApiResponse<Task>>("/api/tasks", data, workspaceId);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import { fetchUsageSummary } from "./telemetry";
|
|
||||||
|
|
||||||
vi.mock("./client", () => ({
|
|
||||||
apiGet: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { apiGet } = await import("./client");
|
|
||||||
|
|
||||||
describe("Telemetry API Client", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date("2026-03-02T12:00:00Z"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach((): void => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fetches usage summary from llm usage analytics endpoint", async (): Promise<void> => {
|
|
||||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
|
||||||
data: {
|
|
||||||
totalCalls: 47,
|
|
||||||
totalPromptTokens: 120000,
|
|
||||||
totalCompletionTokens: 125800,
|
|
||||||
totalTokens: 245800,
|
|
||||||
totalCostCents: 342,
|
|
||||||
averageDurationMs: 3200,
|
|
||||||
byProvider: [],
|
|
||||||
byModel: [],
|
|
||||||
byTaskType: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await fetchUsageSummary("30d");
|
|
||||||
|
|
||||||
const calledEndpoint = vi.mocked(apiGet).mock.calls[0]?.[0];
|
|
||||||
expect(calledEndpoint).toMatch(/^\/api\/llm-usage\/analytics\?/);
|
|
||||||
|
|
||||||
const queryString = calledEndpoint?.split("?")[1] ?? "";
|
|
||||||
const params = new URLSearchParams(queryString);
|
|
||||||
expect(params.get("startDate")).toBeTruthy();
|
|
||||||
expect(params.get("endDate")).toBeTruthy();
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
totalTokens: 245800,
|
|
||||||
totalCost: 3.42,
|
|
||||||
taskCount: 47,
|
|
||||||
avgQualityGatePassRate: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Telemetry API Client
|
* Telemetry API Client
|
||||||
* Handles telemetry data fetching for the usage dashboard.
|
* Handles telemetry data fetching for the usage dashboard.
|
||||||
|
*
|
||||||
|
* NOTE: Currently returns mock/placeholder data since the telemetry API
|
||||||
|
* aggregation endpoints don't exist yet. The important thing is the UI structure.
|
||||||
|
* When the backend endpoints are ready, replace mock calls with real apiGet() calls.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiGet, type ApiResponse } from "./client";
|
import { apiGet, type ApiResponse } from "./client";
|
||||||
@@ -56,84 +60,65 @@ export interface EstimateResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProviderUsageAnalyticsItem {
|
// ─── Mock Data Generators ────────────────────────────────────────────
|
||||||
provider: string;
|
|
||||||
calls: number;
|
|
||||||
promptTokens: number;
|
|
||||||
completionTokens: number;
|
|
||||||
totalTokens: number;
|
|
||||||
costCents: number;
|
|
||||||
averageDurationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelUsageAnalyticsItem {
|
function generateDateRange(range: TimeRange): string[] {
|
||||||
model: string;
|
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||||
calls: number;
|
const dates: string[] = [];
|
||||||
promptTokens: number;
|
const now = new Date();
|
||||||
completionTokens: number;
|
|
||||||
totalTokens: number;
|
|
||||||
costCents: number;
|
|
||||||
averageDurationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TaskTypeUsageAnalyticsItem {
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
taskType: string;
|
const d = new Date(now);
|
||||||
calls: number;
|
d.setDate(d.getDate() - i);
|
||||||
promptTokens: number;
|
dates.push(d.toISOString().split("T")[0] ?? "");
|
||||||
completionTokens: number;
|
|
||||||
totalTokens: number;
|
|
||||||
costCents: number;
|
|
||||||
averageDurationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsageAnalyticsResponse {
|
|
||||||
totalCalls: number;
|
|
||||||
totalPromptTokens: number;
|
|
||||||
totalCompletionTokens: number;
|
|
||||||
totalTokens: number;
|
|
||||||
totalCostCents: number;
|
|
||||||
averageDurationMs: number;
|
|
||||||
byProvider: ProviderUsageAnalyticsItem[];
|
|
||||||
byModel: ModelUsageAnalyticsItem[];
|
|
||||||
byTaskType: TaskTypeUsageAnalyticsItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const TASK_OUTCOME_COLORS = ["#6EBF8B", "#F5C862", "#94A3B8", "#C4A5DE", "#7AA2F7"];
|
|
||||||
const DAYS_BY_RANGE: Record<TimeRange, number> = {
|
|
||||||
"7d": 7,
|
|
||||||
"30d": 30,
|
|
||||||
"90d": 90,
|
|
||||||
};
|
|
||||||
const analyticsRequestCache = new Map<TimeRange, Promise<UsageAnalyticsResponse>>();
|
|
||||||
|
|
||||||
function buildAnalyticsEndpoint(timeRange: TimeRange): string {
|
|
||||||
const endDate = new Date();
|
|
||||||
const startDate = new Date(endDate);
|
|
||||||
startDate.setDate(startDate.getDate() - (DAYS_BY_RANGE[timeRange] - 1));
|
|
||||||
startDate.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const query = new URLSearchParams({
|
|
||||||
startDate: startDate.toISOString(),
|
|
||||||
endDate: endDate.toISOString(),
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
return `/api/llm-usage/analytics?${query}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchUsageAnalytics(timeRange: TimeRange): Promise<UsageAnalyticsResponse> {
|
|
||||||
const cachedRequest = analyticsRequestCache.get(timeRange);
|
|
||||||
if (cachedRequest) {
|
|
||||||
return cachedRequest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = apiGet<ApiResponse<UsageAnalyticsResponse>>(buildAnalyticsEndpoint(timeRange))
|
return dates;
|
||||||
.then((response) => response.data)
|
}
|
||||||
.finally(() => {
|
|
||||||
analyticsRequestCache.delete(timeRange);
|
|
||||||
});
|
|
||||||
|
|
||||||
analyticsRequestCache.set(timeRange, request);
|
function generateMockTokenUsage(range: TimeRange): TokenUsagePoint[] {
|
||||||
return request;
|
const dates = generateDateRange(range);
|
||||||
|
|
||||||
|
return dates.map((date) => {
|
||||||
|
const baseInput = 8000 + Math.floor(Math.random() * 12000);
|
||||||
|
const baseOutput = 3000 + Math.floor(Math.random() * 7000);
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
inputTokens: baseInput,
|
||||||
|
outputTokens: baseOutput,
|
||||||
|
totalTokens: baseInput + baseOutput,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMockSummary(range: TimeRange): UsageSummary {
|
||||||
|
const multiplier = range === "7d" ? 1 : range === "30d" ? 4 : 12;
|
||||||
|
return {
|
||||||
|
totalTokens: 245_800 * multiplier,
|
||||||
|
totalCost: 3.42 * multiplier,
|
||||||
|
taskCount: 47 * multiplier,
|
||||||
|
avgQualityGatePassRate: 0.87,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMockCostBreakdown(): CostBreakdownItem[] {
|
||||||
|
return [
|
||||||
|
{ model: "claude-sonnet-4-5", provider: "anthropic", cost: 18.5, taskCount: 124 },
|
||||||
|
{ model: "gpt-4o", provider: "openai", cost: 12.3, taskCount: 89 },
|
||||||
|
{ model: "claude-haiku-3.5", provider: "anthropic", cost: 4.2, taskCount: 156 },
|
||||||
|
{ model: "llama-3.3-70b", provider: "ollama", cost: 0, taskCount: 67 },
|
||||||
|
{ model: "gemini-2.0-flash", provider: "google", cost: 2.8, taskCount: 42 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDA-friendly colors: calm, no aggressive reds
|
||||||
|
function generateMockTaskOutcomes(): TaskOutcomeItem[] {
|
||||||
|
return [
|
||||||
|
{ outcome: "Success", count: 312, color: "#6EBF8B" },
|
||||||
|
{ outcome: "Partial", count: 48, color: "#F5C862" },
|
||||||
|
{ outcome: "Timeout", count: 18, color: "#94A3B8" },
|
||||||
|
{ outcome: "Incomplete", count: 22, color: "#C4A5DE" },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── API Functions ───────────────────────────────────────────────────
|
// ─── API Functions ───────────────────────────────────────────────────
|
||||||
@@ -142,54 +127,47 @@ async function fetchUsageAnalytics(timeRange: TimeRange): Promise<UsageAnalytics
|
|||||||
* Fetch usage summary data (total tokens, cost, task count, quality rate)
|
* Fetch usage summary data (total tokens, cost, task count, quality rate)
|
||||||
*/
|
*/
|
||||||
export async function fetchUsageSummary(timeRange: TimeRange): Promise<UsageSummary> {
|
export async function fetchUsageSummary(timeRange: TimeRange): Promise<UsageSummary> {
|
||||||
const analytics = await fetchUsageAnalytics(timeRange);
|
// TODO: Replace with real API call when backend aggregation endpoints are ready
|
||||||
|
// const response = await apiGet<ApiResponse<UsageSummary>>(`/api/telemetry/summary?range=${timeRange}`);
|
||||||
return {
|
// return response.data;
|
||||||
totalTokens: analytics.totalTokens,
|
void apiGet; // suppress unused import warning in the meantime
|
||||||
totalCost: analytics.totalCostCents / 100,
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
taskCount: analytics.totalCalls,
|
return generateMockSummary(timeRange);
|
||||||
avgQualityGatePassRate: 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch token usage time series for charts
|
* Fetch token usage time series for charts
|
||||||
*/
|
*/
|
||||||
export function fetchTokenUsage(timeRange: TimeRange): Promise<TokenUsagePoint[]> {
|
export async function fetchTokenUsage(timeRange: TimeRange): Promise<TokenUsagePoint[]> {
|
||||||
void timeRange;
|
// TODO: Replace with real API call
|
||||||
return Promise.resolve([]);
|
// const response = await apiGet<ApiResponse<TokenUsagePoint[]>>(`/api/telemetry/tokens?range=${timeRange}`);
|
||||||
|
// return response.data;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
return generateMockTokenUsage(timeRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch cost breakdown by model
|
* Fetch cost breakdown by model
|
||||||
*/
|
*/
|
||||||
export async function fetchCostBreakdown(timeRange: TimeRange): Promise<CostBreakdownItem[]> {
|
export async function fetchCostBreakdown(timeRange: TimeRange): Promise<CostBreakdownItem[]> {
|
||||||
const analytics = await fetchUsageAnalytics(timeRange);
|
// TODO: Replace with real API call
|
||||||
|
// const response = await apiGet<ApiResponse<CostBreakdownItem[]>>(`/api/telemetry/costs?range=${timeRange}`);
|
||||||
return analytics.byModel
|
// return response.data;
|
||||||
.filter((item) => item.calls > 0)
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
.sort((a, b) => b.costCents - a.costCents)
|
void timeRange;
|
||||||
.map((item) => ({
|
return generateMockCostBreakdown();
|
||||||
model: item.model,
|
|
||||||
provider: "unknown",
|
|
||||||
cost: item.costCents / 100,
|
|
||||||
taskCount: item.calls,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch task outcome distribution
|
* Fetch task outcome distribution
|
||||||
*/
|
*/
|
||||||
export async function fetchTaskOutcomes(timeRange: TimeRange): Promise<TaskOutcomeItem[]> {
|
export async function fetchTaskOutcomes(timeRange: TimeRange): Promise<TaskOutcomeItem[]> {
|
||||||
const analytics = await fetchUsageAnalytics(timeRange);
|
// TODO: Replace with real API call
|
||||||
|
// const response = await apiGet<ApiResponse<TaskOutcomeItem[]>>(`/api/telemetry/outcomes?range=${timeRange}`);
|
||||||
return analytics.byTaskType
|
// return response.data;
|
||||||
.filter((item) => item.calls > 0)
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
.map((item, index) => ({
|
void timeRange;
|
||||||
outcome: item.taskType,
|
return generateMockTaskOutcomes();
|
||||||
count: item.calls,
|
|
||||||
color: TASK_OUTCOME_COLORS[index % TASK_OUTCOME_COLORS.length] ?? "#94A3B8",
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
# - OpenBao: Standalone container (see docker-compose.openbao.yml)
|
# - OpenBao: Standalone container (see docker-compose.openbao.yml)
|
||||||
# - Authentik: External OIDC provider
|
# - Authentik: External OIDC provider
|
||||||
# - Ollama: External AI inference
|
# - Ollama: External AI inference
|
||||||
# - PostgreSQL: Provided by the openbrain stack (openbrain_brain-db)
|
|
||||||
# Deploy openbrain stack before this stack.
|
|
||||||
#
|
#
|
||||||
# Usage (Portainer):
|
# Usage (Portainer):
|
||||||
# 1. Stacks -> Add Stack -> Upload or paste
|
# 1. Stacks -> Add Stack -> Upload or paste
|
||||||
@@ -38,75 +36,37 @@
|
|||||||
# Required vars use plain ${VAR} — the app validates at startup.
|
# Required vars use plain ${VAR} — the app validates at startup.
|
||||||
#
|
#
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# DATABASE (openbrain_brain-db — external)
|
|
||||||
# ==============================================
|
|
||||||
#
|
|
||||||
# This stack uses the PostgreSQL instance from the openbrain stack.
|
|
||||||
# The openbrain stack must be deployed first and its brain-internal
|
|
||||||
# overlay network must exist.
|
|
||||||
#
|
|
||||||
# Required env vars for DB access:
|
|
||||||
# BRAIN_DB_ADMIN_USER — openbrain superuser (default: openbrain)
|
|
||||||
# BRAIN_DB_ADMIN_PASSWORD — openbrain superuser password
|
|
||||||
# (must match openbrain stack POSTGRES_PASSWORD)
|
|
||||||
# POSTGRES_USER — mosaic application DB user (created by mosaic-db-init)
|
|
||||||
# POSTGRES_PASSWORD — mosaic application DB password
|
|
||||||
# POSTGRES_DB — mosaic application database name (default: mosaic)
|
|
||||||
#
|
|
||||||
# ==============================================
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ============================================
|
# ============================================
|
||||||
# DATABASE INIT
|
# CORE INFRASTRUCTURE
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# Mosaic Database Init
|
# PostgreSQL Database
|
||||||
# ======================
|
# ======================
|
||||||
# Creates the mosaic application user and database in the shared
|
postgres:
|
||||||
# openbrain PostgreSQL instance (openbrain_brain-db).
|
image: git.mosaicstack.dev/mosaic/stack-postgres:${IMAGE_TAG:-latest}
|
||||||
# Runs once and exits. Idempotent — safe to run on every deploy.
|
|
||||||
mosaic-db-init:
|
|
||||||
image: postgres:17-alpine
|
|
||||||
environment:
|
environment:
|
||||||
PGHOST: openbrain_brain-db
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
PGPORT: 5432
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
PGUSER: ${BRAIN_DB_ADMIN_USER:-openbrain}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
PGPASSWORD: ${BRAIN_DB_ADMIN_PASSWORD}
|
POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-256MB}
|
||||||
MOSAIC_USER: ${POSTGRES_USER}
|
POSTGRES_EFFECTIVE_CACHE_SIZE: ${POSTGRES_EFFECTIVE_CACHE_SIZE:-1GB}
|
||||||
MOSAIC_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-100}
|
||||||
MOSAIC_DB: ${POSTGRES_DB:-mosaic}
|
volumes:
|
||||||
entrypoint: ["sh", "-c"]
|
- postgres_data:/var/lib/postgresql/data
|
||||||
command:
|
healthcheck:
|
||||||
- |
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
until pg_isready -h openbrain_brain-db -p 5432 -U $${PGUSER}; do
|
interval: 10s
|
||||||
echo "Waiting for openbrain_brain-db..."
|
timeout: 5s
|
||||||
sleep 2
|
retries: 5
|
||||||
done
|
start_period: 30s
|
||||||
echo "Database ready. Creating mosaic user and database..."
|
|
||||||
|
|
||||||
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_roles WHERE rolname='$${MOSAIC_USER}'" | grep -q 1 || \
|
|
||||||
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE USER $${MOSAIC_USER} WITH PASSWORD '$${MOSAIC_PASSWORD}';"
|
|
||||||
|
|
||||||
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_database WHERE datname='$${MOSAIC_DB}'" | grep -q 1 || \
|
|
||||||
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE DATABASE $${MOSAIC_DB} OWNER $${MOSAIC_USER} ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;"
|
|
||||||
|
|
||||||
echo "Enabling required extensions in $${MOSAIC_DB}..."
|
|
||||||
psql -h openbrain_brain-db -U $${PGUSER} -d $${MOSAIC_DB} -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
|
||||||
psql -h openbrain_brain-db -U $${PGUSER} -d $${MOSAIC_DB} -c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";"
|
|
||||||
|
|
||||||
echo "Mosaic database ready: $${MOSAIC_DB}"
|
|
||||||
networks:
|
networks:
|
||||||
- openbrain-brain-internal
|
- internal
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
delay: 5s
|
|
||||||
max_attempts: 5
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# CORE INFRASTRUCTURE
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# Valkey Cache
|
# Valkey Cache
|
||||||
@@ -145,7 +105,7 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: ${API_PORT:-3001}
|
PORT: ${API_PORT:-3001}
|
||||||
API_HOST: ${API_HOST:-0.0.0.0}
|
API_HOST: ${API_HOST:-0.0.0.0}
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@openbrain_brain-db:5432/${POSTGRES_DB:-mosaic}
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||||
VALKEY_URL: redis://valkey:6379
|
VALKEY_URL: redis://valkey:6379
|
||||||
# Auth (external Authentik)
|
# Auth (external Authentik)
|
||||||
OIDC_ENABLED: ${OIDC_ENABLED:-false}
|
OIDC_ENABLED: ${OIDC_ENABLED:-false}
|
||||||
@@ -168,8 +128,6 @@ services:
|
|||||||
# Matrix bridge (optional — configure after Synapse is running)
|
# Matrix bridge (optional — configure after Synapse is running)
|
||||||
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL:-http://synapse:8008}
|
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL:-http://synapse:8008}
|
||||||
MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-}
|
MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-}
|
||||||
# System admin IDs (comma-separated user UUIDs) for auth settings access
|
|
||||||
SYSTEM_ADMIN_IDS: ${SYSTEM_ADMIN_IDS:-}
|
|
||||||
MATRIX_BOT_USER_ID: ${MATRIX_BOT_USER_ID:-}
|
MATRIX_BOT_USER_ID: ${MATRIX_BOT_USER_ID:-}
|
||||||
MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID:-}
|
MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID:-}
|
||||||
MATRIX_WORKSPACE_ID: ${MATRIX_WORKSPACE_ID:-}
|
MATRIX_WORKSPACE_ID: ${MATRIX_WORKSPACE_ID:-}
|
||||||
@@ -203,7 +161,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
- traefik-public
|
- traefik-public
|
||||||
- openbrain-brain-internal
|
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
@@ -348,36 +305,36 @@ services:
|
|||||||
# ======================
|
# ======================
|
||||||
# Synapse Database Init
|
# Synapse Database Init
|
||||||
# ======================
|
# ======================
|
||||||
# Creates the 'synapse' database in the shared openbrain PostgreSQL instance.
|
# Creates the 'synapse' database in the shared PostgreSQL instance.
|
||||||
# Runs once and exits. Idempotent — safe to run on every deploy.
|
# Runs once and exits. Idempotent — safe to run on every deploy.
|
||||||
synapse-db-init:
|
synapse-db-init:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
environment:
|
environment:
|
||||||
PGHOST: openbrain_brain-db
|
PGHOST: postgres
|
||||||
PGPORT: 5432
|
PGPORT: 5432
|
||||||
PGUSER: ${BRAIN_DB_ADMIN_USER:-openbrain}
|
PGUSER: ${POSTGRES_USER}
|
||||||
PGPASSWORD: ${BRAIN_DB_ADMIN_PASSWORD}
|
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||||
SYNAPSE_DB: ${SYNAPSE_POSTGRES_DB}
|
SYNAPSE_DB: ${SYNAPSE_POSTGRES_DB}
|
||||||
SYNAPSE_USER: ${SYNAPSE_POSTGRES_USER}
|
SYNAPSE_USER: ${SYNAPSE_POSTGRES_USER}
|
||||||
SYNAPSE_PASSWORD: ${SYNAPSE_POSTGRES_PASSWORD}
|
SYNAPSE_PASSWORD: ${SYNAPSE_POSTGRES_PASSWORD}
|
||||||
entrypoint: ["sh", "-c"]
|
entrypoint: ["sh", "-c"]
|
||||||
command:
|
command:
|
||||||
- |
|
- |
|
||||||
until pg_isready -h openbrain_brain-db -p 5432 -U $${PGUSER}; do
|
until pg_isready -h postgres -p 5432 -U $${PGUSER}; do
|
||||||
echo "Waiting for openbrain_brain-db..."
|
echo "Waiting for PostgreSQL..."
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
echo "Database ready. Creating Synapse user and database..."
|
echo "PostgreSQL is ready. Creating Synapse database and user..."
|
||||||
|
|
||||||
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_roles WHERE rolname='$${SYNAPSE_USER}'" | grep -q 1 || \
|
psql -h postgres -U $${PGUSER} -tc "SELECT 1 FROM pg_roles WHERE rolname='$${SYNAPSE_USER}'" | grep -q 1 || \
|
||||||
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE USER $${SYNAPSE_USER} WITH PASSWORD '$${SYNAPSE_PASSWORD}';"
|
psql -h postgres -U $${PGUSER} -c "CREATE USER $${SYNAPSE_USER} WITH PASSWORD '$${SYNAPSE_PASSWORD}';"
|
||||||
|
|
||||||
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_database WHERE datname='$${SYNAPSE_DB}'" | grep -q 1 || \
|
psql -h postgres -U $${PGUSER} -tc "SELECT 1 FROM pg_database WHERE datname='$${SYNAPSE_DB}'" | grep -q 1 || \
|
||||||
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE DATABASE $${SYNAPSE_DB} OWNER $${SYNAPSE_USER} ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;"
|
psql -h postgres -U $${PGUSER} -c "CREATE DATABASE $${SYNAPSE_DB} OWNER $${SYNAPSE_USER} ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;"
|
||||||
|
|
||||||
echo "Synapse database ready: $${SYNAPSE_DB}"
|
echo "Synapse database ready: $${SYNAPSE_DB}"
|
||||||
networks:
|
networks:
|
||||||
- openbrain-brain-internal
|
- internal
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
@@ -492,6 +449,7 @@ services:
|
|||||||
# Volumes
|
# Volumes
|
||||||
# ======================
|
# ======================
|
||||||
volumes:
|
volumes:
|
||||||
|
postgres_data:
|
||||||
valkey_data:
|
valkey_data:
|
||||||
orchestrator_workspace:
|
orchestrator_workspace:
|
||||||
speaches_models:
|
speaches_models:
|
||||||
@@ -504,6 +462,3 @@ networks:
|
|||||||
driver: overlay
|
driver: overlay
|
||||||
traefik-public:
|
traefik-public:
|
||||||
external: true
|
external: true
|
||||||
openbrain-brain-internal:
|
|
||||||
external: true
|
|
||||||
name: openbrain_brain-internal
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user