Compare commits

..

2 Commits

Author SHA1 Message Date
b63d94f8ef fix(web): handle correct agent status values in AgentStatusWidget
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Use isWorking/isIdle/isErrored helpers to match both old
  status names (running/failed) and new enum values (WORKING/ERROR)
- Fix stats computation and card styling
2026-03-02 10:41:08 -06:00
a1a37c77f6 fix(api): add missing /orchestrator/health and /orchestrator/events/recent endpoints
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Added GET /orchestrator/health for widget health checks
- Added GET /orchestrator/events/recent for recent agent events
- Widgets were calling endpoints that returned 404
2026-03-02 09:33:31 -06:00
15 changed files with 135 additions and 652 deletions

View File

@@ -80,8 +80,8 @@
"session_id": "sess-002",
"runtime": "unknown",
"started_at": "2026-02-28T20:30:13Z",
"ended_at": "2026-03-04T13:45:06Z",
"ended_reason": "completed",
"ended_at": "",
"ended_reason": "",
"milestone_at_end": "",
"tasks_completed": [],
"last_task_id": ""

View File

@@ -0,0 +1,8 @@
{
"session_id": "sess-002",
"runtime": "unknown",
"pid": 3178395,
"started_at": "2026-02-28T20:30:13Z",
"project_path": "/tmp/ms21-ui-001",
"milestone_id": ""
}

View File

@@ -337,46 +337,3 @@ steps:
- security-trivy-api
- security-trivy-orchestrator
- security-trivy-web
# ─── Deploy to Docker Swarm via Portainer API (main only) ─────────────────────
deploy-swarm:
image: alpine:3
environment:
PORTAINER_URL:
from_secret: portainer_url
PORTAINER_API_KEY:
from_secret: portainer_api_key
PORTAINER_STACK_ID: "121"
commands:
- apk add --no-cache curl
- |
set -e
echo "🚀 Deploying to Docker Swarm via Portainer API..."
# Use Portainer API to update the stack (forces pull of new images)
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "X-API-Key: $PORTAINER_API_KEY" \
-H "Content-Type: application/json" \
"$PORTAINER_URL/api/stacks/$PORTAINER_STACK_ID/git/redeploy")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -n -1)
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "202" ]; then
echo "✅ Stack update triggered successfully"
else
echo "❌ Stack update failed (HTTP $HTTP_CODE)"
echo "$BODY"
exit 1
fi
# Wait for services to converge
echo "⏳ Waiting for services to converge..."
sleep 30
echo "✅ Deploy complete"
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- link-packages

View File

@@ -1,13 +0,0 @@
-- MS21: Add admin, local auth, and invitation fields to users table
-- These columns were added to schema.prisma but never captured in a migration.
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "deactivated_at" TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS "is_local_auth" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "password_hash" TEXT,
ADD COLUMN IF NOT EXISTS "invited_by" UUID,
ADD COLUMN IF NOT EXISTS "invitation_token" TEXT,
ADD COLUMN IF NOT EXISTS "invited_at" TIMESTAMPTZ;
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "users_invitation_token_key" ON "users"("invitation_token");

View File

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

View File

@@ -1,5 +1,4 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AuthModule } from "../auth/auth.module";
import { AgentConfigModule } from "../agent-config/agent-config.module";
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
@@ -8,7 +7,7 @@ import { ChatProxyController } from "./chat-proxy.controller";
import { ChatProxyService } from "./chat-proxy.service";
@Module({
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule, ConfigModule],
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule],
controllers: [ChatProxyController],
providers: [ChatProxyService],
exports: [ChatProxyService],

View File

@@ -4,14 +4,11 @@ import {
Logger,
ServiceUnavailableException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
import { PrismaService } from "../prisma/prisma.service";
import type { ChatMessage } from "./chat-proxy.dto";
const DEFAULT_OPENCLAW_MODEL = "openclaw:default";
const DEFAULT_GUEST_LLM_URL = "http://10.1.1.42:11434/v1";
const DEFAULT_GUEST_LLM_MODEL = "llama3.2";
interface ContainerConnection {
url: string;
@@ -24,8 +21,7 @@ export class ChatProxyService {
constructor(
private readonly prisma: PrismaService,
private readonly containerLifecycle: ContainerLifecycleService,
private readonly config: ConfigService
private readonly containerLifecycle: ContainerLifecycleService
) {}
// Get the user's OpenClaw container URL and mark it active.
@@ -83,65 +79,6 @@ export class ChatProxyService {
}
}
/**
* Proxy guest chat request to configured LLM endpoint.
* Uses environment variables for configuration:
* - GUEST_LLM_URL: OpenAI-compatible endpoint URL
* - GUEST_LLM_API_KEY: API key (optional, for cloud providers)
* - GUEST_LLM_MODEL: Model name to use
*/
async proxyGuestChat(messages: ChatMessage[], signal?: AbortSignal): Promise<Response> {
const llmUrl = this.config.get<string>("GUEST_LLM_URL") ?? DEFAULT_GUEST_LLM_URL;
const llmApiKey = this.config.get<string>("GUEST_LLM_API_KEY");
const llmModel = this.config.get<string>("GUEST_LLM_MODEL") ?? DEFAULT_GUEST_LLM_MODEL;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (llmApiKey) {
headers.Authorization = `Bearer ${llmApiKey}`;
}
const requestInit: RequestInit = {
method: "POST",
headers,
body: JSON.stringify({
messages,
model: llmModel,
stream: true,
}),
};
if (signal) {
requestInit.signal = signal;
}
try {
this.logger.debug(`Guest chat proxying to ${llmUrl} with model ${llmModel}`);
const response = await fetch(`${llmUrl}/chat/completions`, requestInit);
if (!response.ok) {
const detail = await this.readResponseText(response);
const status = `${String(response.status)} ${response.statusText}`.trim();
this.logger.warn(
detail ? `Guest LLM returned ${status}: ${detail}` : `Guest LLM returned ${status}`
);
throw new BadGatewayException(`Guest LLM returned ${status}`);
}
return response;
} catch (error: unknown) {
if (error instanceof BadGatewayException) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to proxy guest chat request: ${message}`);
throw new ServiceUnavailableException("Failed to proxy guest chat to LLM");
}
}
private async getContainerConnection(userId: string): Promise<ContainerConnection> {
const connection = await this.containerLifecycle.ensureRunning(userId);
await this.containerLifecycle.touch(userId);

View File

@@ -601,21 +601,9 @@ class TestCoordinatorIntegration:
coordinator = Coordinator(queue_manager=queue_manager, poll_interval=0.02)
task = asyncio.create_task(coordinator.start())
# Poll for completion with timeout instead of fixed sleep
deadline = asyncio.get_event_loop().time() + 5.0 # 5 second timeout
while asyncio.get_event_loop().time() < deadline:
all_completed = True
for i in range(157, 162):
item = queue_manager.get_item(i)
if item is None or item.status != QueueItemStatus.COMPLETED:
all_completed = False
break
if all_completed:
break
await asyncio.sleep(0.05)
await asyncio.sleep(0.5) # Allow time for processing
await coordinator.stop()
task.cancel()
try:
await task

View File

@@ -342,31 +342,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
)}
{/* 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
className="sticky bottom-0 border-t"
style={{

View File

@@ -55,8 +55,8 @@ export function ChatOverlay(): React.JSX.Element {
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"
style={{
backgroundColor: "var(--accent-primary, #10b981)",
color: "var(--text-on-accent, #ffffff)",
backgroundColor: "rgb(var(--accent-primary))",
color: "rgb(var(--text-on-accent))",
}}
aria-label="Open chat"
title="Open Jarvis chat (Cmd+Shift+J)"
@@ -78,18 +78,18 @@ export function ChatOverlay(): React.JSX.Element {
if (isMinimized) {
return (
<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={{
backgroundColor: "var(--surface-0, #ffffff)",
borderColor: "var(--border-default, #e5e7eb)",
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
}}
>
<button
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"
style={{
borderColor: "var(--border-default, #e5e7eb)",
backgroundColor: "var(--surface-0, #ffffff)",
borderColor: "rgb(var(--border-default))",
backgroundColor: "rgb(var(--surface-0))",
}}
aria-label="Expand chat"
>
@@ -135,10 +135,10 @@ export function ChatOverlay(): React.JSX.Element {
{/* Chat Panel */}
<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={{
backgroundColor: "var(--surface-0, #ffffff)",
borderColor: "var(--border-default, #e5e7eb)",
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
}}
>
{/* Header */}

View File

@@ -4,7 +4,11 @@
*/
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 { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
@@ -214,6 +218,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
const controller = new AbortController();
abortControllerRef.current = controller;
let streamingSucceeded = false;
try {
await new Promise<void>((resolve, reject) => {
let hasReceivedData = false;
@@ -241,6 +247,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
});
},
() => {
streamingSucceeded = true;
setIsStreaming(false);
abortControllerRef.current = null;
resolve();
@@ -271,8 +278,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
return;
}
// Streaming failed — show error (no guest fallback, auth required)
console.warn("Streaming failed", {
// Streaming failed — fall back to non-streaming
console.warn("Streaming failed, falling back to non-streaming", {
error: err instanceof Error ? err : new Error(String(err)),
});
@@ -282,15 +289,66 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
return withoutPlaceholder;
});
setIsStreaming(false);
setIsLoading(false);
const errorMsg = err instanceof Error ? err.message : "Chat unavailable";
setError(`Chat error: ${errorMsg}`);
try {
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;
}
}
setIsLoading(false);
if (!streamingSucceeded) {
return;
}
const finalMessages = messagesRef.current;
const isFirstMessage =

View File

@@ -92,141 +92,6 @@ async function ensureCsrfTokenForStream(): Promise<string> {
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 }),
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.
*

View File

@@ -9,8 +9,6 @@
# - OpenBao: Standalone container (see docker-compose.openbao.yml)
# - Authentik: External OIDC provider
# - Ollama: External AI inference
# - PostgreSQL: Provided by the openbrain stack (openbrain_brain-db)
# Deploy openbrain stack before this stack.
#
# Usage (Portainer):
# 1. Stacks -> Add Stack -> Upload or paste
@@ -38,75 +36,37 @@
# 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:
# ============================================
# DATABASE INIT
# CORE INFRASTRUCTURE
# ============================================
# ======================
# Mosaic Database Init
# PostgreSQL Database
# ======================
# Creates the mosaic application user and database in the shared
# openbrain PostgreSQL instance (openbrain_brain-db).
# Runs once and exits. Idempotent — safe to run on every deploy.
mosaic-db-init:
image: postgres:17-alpine
postgres:
image: git.mosaicstack.dev/mosaic/stack-postgres:${IMAGE_TAG:-latest}
environment:
PGHOST: openbrain_brain-db
PGPORT: 5432
PGUSER: ${BRAIN_DB_ADMIN_USER:-openbrain}
PGPASSWORD: ${BRAIN_DB_ADMIN_PASSWORD}
MOSAIC_USER: ${POSTGRES_USER}
MOSAIC_PASSWORD: ${POSTGRES_PASSWORD}
MOSAIC_DB: ${POSTGRES_DB:-mosaic}
entrypoint: ["sh", "-c"]
command:
- |
until pg_isready -h openbrain_brain-db -p 5432 -U $${PGUSER}; do
echo "Waiting for openbrain_brain-db..."
sleep 2
done
echo "Database ready. Creating mosaic user and database..."
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_roles WHERE rolname='$${MOSAIC_USER}'" | grep -q 1 || \
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE USER $${MOSAIC_USER} WITH PASSWORD '$${MOSAIC_PASSWORD}';"
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_database WHERE datname='$${MOSAIC_DB}'" | grep -q 1 || \
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE DATABASE $${MOSAIC_DB} OWNER $${MOSAIC_USER} ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;"
echo "Enabling required extensions in $${MOSAIC_DB}..."
psql -h openbrain_brain-db -U $${PGUSER} -d $${MOSAIC_DB} -c "CREATE EXTENSION IF NOT EXISTS vector;"
psql -h openbrain_brain-db -U $${PGUSER} -d $${MOSAIC_DB} -c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";"
echo "Mosaic database ready: $${MOSAIC_DB}"
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-256MB}
POSTGRES_EFFECTIVE_CACHE_SIZE: ${POSTGRES_EFFECTIVE_CACHE_SIZE:-1GB}
POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-100}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- openbrain-brain-internal
- internal
deploy:
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 5
# ============================================
# CORE INFRASTRUCTURE
# ============================================
# ======================
# Valkey Cache
@@ -145,7 +105,7 @@ services:
NODE_ENV: production
PORT: ${API_PORT:-3001}
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
# Auth (external Authentik)
OIDC_ENABLED: ${OIDC_ENABLED:-false}
@@ -203,7 +163,6 @@ services:
networks:
- internal
- traefik-public
- openbrain-brain-internal
deploy:
restart_policy:
condition: on-failure
@@ -348,36 +307,36 @@ services:
# ======================
# 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.
synapse-db-init:
image: postgres:17-alpine
environment:
PGHOST: openbrain_brain-db
PGHOST: postgres
PGPORT: 5432
PGUSER: ${BRAIN_DB_ADMIN_USER:-openbrain}
PGPASSWORD: ${BRAIN_DB_ADMIN_PASSWORD}
PGUSER: ${POSTGRES_USER}
PGPASSWORD: ${POSTGRES_PASSWORD}
SYNAPSE_DB: ${SYNAPSE_POSTGRES_DB}
SYNAPSE_USER: ${SYNAPSE_POSTGRES_USER}
SYNAPSE_PASSWORD: ${SYNAPSE_POSTGRES_PASSWORD}
entrypoint: ["sh", "-c"]
command:
- |
until pg_isready -h openbrain_brain-db -p 5432 -U $${PGUSER}; do
echo "Waiting for openbrain_brain-db..."
until pg_isready -h postgres -p 5432 -U $${PGUSER}; do
echo "Waiting for PostgreSQL..."
sleep 2
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 openbrain_brain-db -U $${PGUSER} -c "CREATE USER $${SYNAPSE_USER} WITH PASSWORD '$${SYNAPSE_PASSWORD}';"
psql -h postgres -U $${PGUSER} -tc "SELECT 1 FROM pg_roles WHERE rolname='$${SYNAPSE_USER}'" | grep -q 1 || \
psql -h postgres -U $${PGUSER} -c "CREATE USER $${SYNAPSE_USER} WITH PASSWORD '$${SYNAPSE_PASSWORD}';"
psql -h openbrain_brain-db -U $${PGUSER} -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} -tc "SELECT 1 FROM pg_database WHERE datname='$${SYNAPSE_DB}'" | grep -q 1 || \
psql -h postgres -U $${PGUSER} -c "CREATE DATABASE $${SYNAPSE_DB} OWNER $${SYNAPSE_USER} ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;"
echo "Synapse database ready: $${SYNAPSE_DB}"
networks:
- openbrain-brain-internal
- internal
deploy:
restart_policy:
condition: on-failure
@@ -492,6 +451,7 @@ services:
# Volumes
# ======================
volumes:
postgres_data:
valkey_data:
orchestrator_workspace:
speaches_models:
@@ -504,6 +464,3 @@ networks:
driver: overlay
traefik-public:
external: true
openbrain-brain-internal:
external: true
name: openbrain_brain-internal

View File

@@ -1,182 +0,0 @@
# PRD: MS22 Phase 2 — Named Agent Fleet
## Metadata
- **Owner:** Jason Woltje
- **Date:** 2026-03-04
- **Status:** draft
- **Design Doc:** `~/src/jarvis-brain/docs/planning/FLEET-EVOLUTION-PLAN.md`
- **Depends On:** MS22 Phase 1 (DB-Centric Architecture) — COMPLETE
## Problem Statement
Mosaic Stack has the infrastructure for per-user containers and knowledge layer, but no predefined agent personalities. Users start with a blank slate. For Jason's personal use case, we need named agents with distinct roles, personalities, and tool access that can collaborate through the shared knowledge layer.
## Objectives
1. **Named agents** — jarvis (orchestrator), builder (coding), medic (monitoring)
2. **Per-agent model assignment** — Opus for jarvis, Codex for builder, Haiku for medic
3. **Tool permissions** — Restrict dangerous tools to appropriate agents
4. **Discord bindings** — Route agents to specific channels
5. **Mosaic skill** — All agents can read/write findings and memory
## Scope
### In Scope
- Agent personality definitions (SOUL.md for each)
- Agent registry in Mosaic DB
- Per-agent model configuration
- Per-agent tool permission sets
- Discord channel routing
- Default agent templates for new users
### Out of Scope
- Matrix observation rooms (nice-to-have)
- WebUI chat improvements (separate phase)
- Cross-agent quality gates (future)
- Team workspaces (future)
## Agent Definitions
### Jarvis — Orchestrator
| Property | Value |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| **Role** | Main orchestrator, user-facing assistant |
| **Model** | Opus (primary), Sonnet (fallback) |
| **Tools** | All tools — full access |
| **Discord** | #jarvis |
| **Personality** | Capable, direct, proactive. Gets stuff done without hand-holding. Thinks before acting, speaks up when seeing a better way. NOT a yes-man. |
### Builder — Coding Agent
| Property | Value |
| --------------- | --------------------------------------------------------------------------------------- |
| **Role** | Code implementation, PRs, refactoring |
| **Model** | Codex (primary, uses OpenAI credits), Sonnet (fallback) |
| **Tools** | exec, read, write, edit, github, browser |
| **Discord** | #builder |
| **Personality** | Focused, thorough. Writes clean code. Tests before declaring done. Documents decisions. |
### Medic — Health Monitoring
| Property | Value |
| --------------- | ------------------------------------------------------------------------------- |
| **Role** | System health checks, alerts, monitoring |
| **Model** | Haiku (primary), MiniMax (fallback) |
| **Tools** | exec (SSH), nodes, cron, message (alerts only) |
| **Discord** | #medic-alerts |
| **Personality** | Vigilant, concise. Alerts on anomalies. Proactive health checks. Minimal noise. |
## Database Schema
```prisma
model AgentTemplate {
id String @id @default(cuid())
name String @unique // "jarvis", "builder", "medic"
displayName String // "Jarvis", "Builder", "Medic"
role String // "orchestrator" | "coding" | "monitoring"
personality String // SOUL.md content
primaryModel String // "opus", "codex", "haiku"
fallbackModels Json @default("[]") // ["sonnet", "haiku"]
toolPermissions Json @default("[]") // ["exec", "read", "write", ...]
discordChannel String? // "jarvis", "builder", "medic-alerts"
isActive Boolean @default(true)
isDefault Boolean @default(false) // Include in new user provisioning
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model UserAgent {
id String @id @default(cuid())
userId String
templateId String? // null = custom agent
name String // "jarvis", "builder", "medic" or custom
displayName String
role String
personality String // User can customize
primaryModel String?
fallbackModels Json @default("[]")
toolPermissions Json @default("[]")
discordChannel String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, name])
}
```
## API Endpoints
### Agent Templates (Admin)
```
GET /api/admin/agent-templates — List all templates
POST /api/admin/agent-templates — Create template
GET /api/admin/agent-templates/:id — Get template
PATCH /api/admin/agent-templates/:id — Update template
DELETE /api/admin/agent-templates/:id — Delete template
```
### User Agents
```
GET /api/agents — List user's agents
POST /api/agents — Create custom agent (or from template)
GET /api/agents/:id — Get agent details
PATCH /api/agents/:id — Update agent (personality, model)
DELETE /api/agents/:id — Delete custom agent
POST /api/agents/:id/chat — Chat with agent (proxy to container)
```
### Agent Status
```
GET /api/agents/status — All agents status for user
GET /api/agents/:id/status — Single agent status
```
## Task Breakdown
| Task ID | Phase | Description | Scope | Dependencies | Estimate |
| -------------- | ------- | ---------------------------------------------- | ----- | ------------ | -------- |
| P2-DB-001 | schema | Prisma models: AgentTemplate, UserAgent | api | P1a | 10K |
| P2-SEED-001 | seed | Seed default agents (jarvis, builder, medic) | api | P2-DB-001 | 5K |
| P2-API-001 | api | Agent template CRUD endpoints | api | P2-DB-001 | 15K |
| P2-API-002 | api | User agent CRUD endpoints | api | P2-DB-001 | 15K |
| P2-API-003 | api | Agent status endpoints | api | P2-DB-001 | 10K |
| P2-PROXY-001 | api | Agent chat routing (select agent by name) | api | P2-API-002 | 15K |
| P2-DISCORD-001 | discord | Route Discord messages to correct agent | api | P2-PROXY-001 | 15K |
| P2-UI-001 | web | Agent list/selector in WebUI | web | P2-API-002 | 15K |
| P2-UI-002 | web | Agent detail/edit page | web | P2-UI-001 | 15K |
| P2-TEST-001 | test | Unit tests for agent services | api | P2-API-002 | 15K |
| P2-VER-001 | verify | End-to-end: Discord → correct agent → response | stack | all | 10K |
**Total Estimate:** ~140K tokens
## Success Criteria
1. ✅ User can list available agents in WebUI
2. ✅ User can select agent and chat with it
3. ✅ Discord messages in #jarvis go to jarvis agent
4. ✅ Discord messages in #builder go to builder agent
5. ✅ Each agent uses its assigned model
6. ✅ Each agent has correct tool permissions
7. ✅ Agents can read/write findings via mosaic skill
## Risks
| Risk | Mitigation |
| --------------------------- | ------------------------------------------------ |
| Agent routing complexity | Keep it simple: map Discord channel → agent name |
| Tool permission enforcement | OpenClaw config generation respects permissions |
| Model fallback failures | Log and alert, don't block user |
## Next Steps
1. Review this PRD with Jason
2. Create Mission MS22-P2 in TASKS.md
3. Begin with P2-DB-001 (schema)

View File

@@ -89,20 +89,3 @@ Design doc: `docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md`
| MS22-P1i | done | phase-1i | Chat proxy: route WebUI chat to user's OpenClaw container (SSE) | — | api+web | feat/ms22-p1i-chat-proxy | P1c,P1d | — | — | — | — | 20K | — | |
| MS22-P1j | done | phase-1j | Docker entrypoint + health checks + core compose | — | docker | feat/ms22-p1j-docker | P1c | — | — | — | — | 10K | — | |
| MS22-P1k | done | phase-1k | Idle reaper cron: stop inactive user containers | — | api | feat/ms22-p1k-idle-reaper | P1d | — | — | — | — | 10K | — | |
## MS22 Phase 2: Named Agent Fleet
PRD: `docs/PRD-MS22-P2-AGENT-FLEET.md`
| Task ID | Status | Phase | Description | Issue | Scope | Branch | Depends On | Blocks | Assigned Worker | Started | Completed | Est Tokens | Act Tokens | Notes |
| ----------- | ----------- | -------- | -------------------------------------------- | -------- | ----- | --------------------------- | ------------- | ------------- | --------------- | ------- | --------- | ---------- | ---------- | ----- |
| MS22-P2-001 | not-started | p2-fleet | Prisma schema: AgentTemplate, UserAgent | TASKS:P2 | api | feat/ms22-p2-agent-schema | MS22-P1a | P2-002,P2-003 | — | — | — | 10K | — | |
| MS22-P2-002 | not-started | p2-fleet | Seed default agents (jarvis, builder, medic) | TASKS:P2 | api | feat/ms22-p2-agent-seed | P2-001 | P2-004 | — | — | — | 5K | — | |
| MS22-P2-003 | not-started | p2-fleet | Agent template CRUD endpoints (admin) | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-001 | P2-005 | — | — | — | 15K | — | |
| MS22-P2-004 | not-started | p2-fleet | User agent CRUD endpoints | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-002,P2-003 | P2-006 | — | — | — | 15K | — | |
| MS22-P2-005 | not-started | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-003 | P2-008 | — | — | — | 10K | — | |
| MS22-P2-006 | not-started | p2-fleet | Agent chat routing (select agent by name) | TASKS:P2 | api | feat/ms22-p2-agent-routing | P2-004 | P2-007 | — | — | — | 15K | — | |
| MS22-P2-007 | not-started | p2-fleet | Discord channel → agent routing | TASKS:P2 | api | feat/ms22-p2-discord-router | P2-006 | P2-009 | — | — | — | 15K | — | |
| MS22-P2-008 | not-started | p2-fleet | Agent list/selector UI in WebUI | TASKS:P2 | web | feat/ms22-p2-agent-ui | P2-005 | — | — | — | — | 15K | — | |
| MS22-P2-009 | not-started | p2-fleet | Unit tests for agent services | TASKS:P2 | api | test/ms22-p2-agent-tests | P2-007 | P2-010 | — | — | — | 15K | — | |
| MS22-P2-010 | not-started | p2-fleet | E2E verification: Discord → agent → response | TASKS:P2 | stack | — | P2-009 | — | — | — | — | 10K | — | |