Compare commits

...

17 Commits

Author SHA1 Message Date
e0e269d8cb fix(orchestrator): add prisma generate to Dockerfile and reflect-metadata to vitest setup
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Two CI fixes:
1. Orchestrator Dockerfile now copies apps/api/prisma schema and runs
   prisma generate before build. Fixes TS2305/TS2339 errors (PrismaClient
   and model properties not found) in docker-build-orchestrator step.
2. Add reflect-metadata to vitest setupFiles. Fixes class-transformer
   decorator error (Reflect.getMetadata is not a function) in DTO unit tests.
2026-03-06 22:45:00 -06:00
de6faf659e feat(orchestrator): MS23 agent lifecycle ingestion service (#701)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-07 04:21:26 +00:00
49fa958444 chore(orchestrator): MS23 P0-001 done, P0-002 in-progress (#700)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-07 02:10:59 +00:00
8d6abd72bb feat(api): MS23 mission control Prisma schema (#699)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-07 01:18:06 +00:00
1bed5b3573 chore(orchestrator): Bootstrap MS23 mission-control TASKS.md + PRD (#698)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-07 00:27:24 +00:00
c644d1044b Merge pull request 'fix(api): add AuthModule import to UserAgentModule' (#691) from fix/user-agent-auth-module into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-05 22:22:18 +00:00
bf5779fb73 fix(api): add AuthModule import to UserAgentModule
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-05 16:21:38 -06:00
08d7a6b708 chore: trigger CI rebuild for hotfix 2026-03-05 16:15:32 -06:00
570edef4e5 Merge pull request 'fix(api): add AuthModule import to AgentTemplateModule' (#690) from fix/agent-template-auth-module into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-05 22:10:36 +00:00
d220be6b58 fix(api): add AuthModule import to AgentTemplateModule
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
The AgentTemplateController uses AuthGuard which requires AuthService,
but AgentTemplateModule was not importing AuthModule. This caused the
API to crash during dependency resolution.
2026-03-05 09:46:22 -06:00
ade9e968ca chore(ms22-p2): mission complete — all 10 tasks done (#689)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-05 15:41:43 +00:00
413ecdb63b feat(ms22-p2): add Discord channel → agent routing (#688)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-05 14:04:06 +00:00
e85fb11f03 test(ms22-p2): add unit tests for agent services (#687)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-05 03:40:35 +00:00
0869a3dcb6 chore(ms22-p2): update mission docs — P2-008 complete (#686)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-05 03:32:36 +00:00
a70f149886 feat(ms22-p2): add agent selector UI in WebUI (#685)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-05 03:29:02 +00:00
2f1ee53c8d feat(ms22-p2): add agent status endpoints and chat routing (#684)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-05 02:56:36 +00:00
b52c4e7ff9 chore(ms22-p2): update docs after P2-004 completion (#683)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-05 02:46:24 +00:00
37 changed files with 2414 additions and 106 deletions

View File

@@ -343,6 +343,11 @@ RATE_LIMIT_STORAGE=redis
# DISCORD_CONTROL_CHANNEL_ID=channel-id-for-commands # DISCORD_CONTROL_CHANNEL_ID=channel-id-for-commands
# DISCORD_WORKSPACE_ID=your-workspace-uuid # DISCORD_WORKSPACE_ID=your-workspace-uuid
# #
# Agent channel routing: Maps Discord channels to specific agents.
# Format: <channelId>:<agentName>,<channelId>:<agentName>
# Example: 123456789:jarvis,987654321:builder
# DISCORD_AGENT_CHANNELS=
#
# SECURITY: DISCORD_WORKSPACE_ID must be a valid workspace UUID from your database. # SECURITY: DISCORD_WORKSPACE_ID must be a valid workspace UUID from your database.
# All Discord commands will execute within this workspace context for proper # All Discord commands will execute within this workspace context for proper
# multi-tenant isolation. Each Discord bot instance should be configured for # multi-tenant isolation. Each Discord bot instance should be configured for

View File

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

View File

@@ -1739,3 +1739,66 @@ model UserAgent {
@@unique([userId, name]) @@unique([userId, name])
@@index([userId]) @@index([userId])
} }
// MS23: Agent conversation messages for Mission Control streaming
model AgentConversationMessage {
id String @id @default(cuid())
sessionId String
provider String @default("internal")
role String
content String
timestamp DateTime @default(now())
metadata Json @default("{}")
@@index([sessionId, timestamp])
}
// MS23: Agent session tree for parent/child relationships
model AgentSessionTree {
id String @id @default(cuid())
sessionId String @unique
parentSessionId String?
provider String @default("internal")
missionId String?
taskId String?
taskSource String? @default("internal")
agentType String?
status String @default("spawning")
spawnedAt DateTime @default(now())
completedAt DateTime?
metadata Json @default("{}")
@@index([parentSessionId])
@@index([missionId])
}
// MS23: External agent provider configuration per workspace
model AgentProviderConfig {
id String @id @default(cuid())
workspaceId String
name String
provider String
gatewayUrl String
credentials Json @default("{}")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([workspaceId, name])
}
// MS23: Audit log for operator interventions
model OperatorAuditLog {
id String @id @default(cuid())
userId String
sessionId String
provider String
action String
content String?
metadata Json @default("{}")
createdAt DateTime @default(now())
@@index([sessionId])
@@index([userId])
@@index([createdAt])
}

View File

@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
import { AgentTemplateService } from "./agent-template.service"; import { AgentTemplateService } from "./agent-template.service";
import { AgentTemplateController } from "./agent-template.controller"; import { AgentTemplateController } from "./agent-template.controller";
import { PrismaModule } from "../prisma/prisma.module"; import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule, AuthModule],
controllers: [AgentTemplateController], controllers: [AgentTemplateController],
providers: [AgentTemplateService], providers: [AgentTemplateService],
exports: [AgentTemplateService], exports: [AgentTemplateService],

View File

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

View File

@@ -5,6 +5,8 @@ import { MatrixRoomService } from "./matrix/matrix-room.service";
import { MatrixStreamingService } from "./matrix/matrix-streaming.service"; import { MatrixStreamingService } from "./matrix/matrix-streaming.service";
import { CommandParserService } from "./parser/command-parser.service"; import { CommandParserService } from "./parser/command-parser.service";
import { StitcherModule } from "../stitcher/stitcher.module"; import { StitcherModule } from "../stitcher/stitcher.module";
import { ChatProxyModule } from "../chat-proxy/chat-proxy.module";
import { PrismaModule } from "../prisma/prisma.module";
import { CHAT_PROVIDERS } from "./bridge.constants"; import { CHAT_PROVIDERS } from "./bridge.constants";
import type { IChatProvider } from "./interfaces"; import type { IChatProvider } from "./interfaces";
@@ -28,7 +30,7 @@ const logger = new Logger("BridgeModule");
* MatrixRoomService handles workspace-to-Matrix-room mapping. * MatrixRoomService handles workspace-to-Matrix-room mapping.
*/ */
@Module({ @Module({
imports: [StitcherModule], imports: [StitcherModule, ChatProxyModule, PrismaModule],
providers: [ providers: [
CommandParserService, CommandParserService,
MatrixRoomService, MatrixRoomService,

View File

@@ -1,6 +1,8 @@
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { DiscordService } from "./discord.service"; import { DiscordService } from "./discord.service";
import { StitcherService } from "../../stitcher/stitcher.service"; import { StitcherService } from "../../stitcher/stitcher.service";
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
import { PrismaService } from "../../prisma/prisma.service";
import { Client, Events, GatewayIntentBits, Message } from "discord.js"; import { Client, Events, GatewayIntentBits, Message } from "discord.js";
import { vi, describe, it, expect, beforeEach } from "vitest"; import { vi, describe, it, expect, beforeEach } from "vitest";
import type { ChatMessage, ChatCommand } from "../interfaces"; import type { ChatMessage, ChatCommand } from "../interfaces";
@@ -61,6 +63,8 @@ vi.mock("discord.js", () => {
describe("DiscordService", () => { describe("DiscordService", () => {
let service: DiscordService; let service: DiscordService;
let stitcherService: StitcherService; let stitcherService: StitcherService;
let chatProxyService: ChatProxyService;
let prismaService: PrismaService;
const mockStitcherService = { const mockStitcherService = {
dispatchJob: vi.fn().mockResolvedValue({ dispatchJob: vi.fn().mockResolvedValue({
@@ -71,12 +75,29 @@ describe("DiscordService", () => {
trackJobEvent: vi.fn().mockResolvedValue(undefined), trackJobEvent: vi.fn().mockResolvedValue(undefined),
}; };
const mockChatProxyService = {
proxyChat: vi.fn().mockResolvedValue(
new Response('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\ndata: [DONE]\n\n', {
headers: { "Content-Type": "text/event-stream" },
})
),
};
const mockPrismaService = {
workspace: {
findUnique: vi.fn().mockResolvedValue({
ownerId: "owner-user-id",
}),
},
};
beforeEach(async () => { beforeEach(async () => {
// Set environment variables for testing // Set environment variables for testing
process.env.DISCORD_BOT_TOKEN = "test-token"; process.env.DISCORD_BOT_TOKEN = "test-token";
process.env.DISCORD_GUILD_ID = "test-guild-id"; process.env.DISCORD_GUILD_ID = "test-guild-id";
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id"; process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
process.env.DISCORD_WORKSPACE_ID = "test-workspace-id"; process.env.DISCORD_WORKSPACE_ID = "test-workspace-id";
process.env.DISCORD_AGENT_CHANNELS = "jarvis-channel:jarvis,builder-channel:builder";
// Clear callbacks // Clear callbacks
mockReadyCallbacks.length = 0; mockReadyCallbacks.length = 0;
@@ -89,11 +110,21 @@ describe("DiscordService", () => {
provide: StitcherService, provide: StitcherService,
useValue: mockStitcherService, useValue: mockStitcherService,
}, },
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
], ],
}).compile(); }).compile();
service = module.get<DiscordService>(DiscordService); service = module.get<DiscordService>(DiscordService);
stitcherService = module.get<StitcherService>(StitcherService); stitcherService = module.get<StitcherService>(StitcherService);
chatProxyService = module.get<ChatProxyService>(ChatProxyService);
prismaService = module.get<PrismaService>(PrismaService);
// Clear all mocks // Clear all mocks
vi.clearAllMocks(); vi.clearAllMocks();
@@ -449,6 +480,14 @@ describe("DiscordService", () => {
provide: StitcherService, provide: StitcherService,
useValue: mockStitcherService, useValue: mockStitcherService,
}, },
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
], ],
}).compile(); }).compile();
@@ -470,6 +509,14 @@ describe("DiscordService", () => {
provide: StitcherService, provide: StitcherService,
useValue: mockStitcherService, useValue: mockStitcherService,
}, },
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
], ],
}).compile(); }).compile();
@@ -492,6 +539,14 @@ describe("DiscordService", () => {
provide: StitcherService, provide: StitcherService,
useValue: mockStitcherService, useValue: mockStitcherService,
}, },
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
], ],
}).compile(); }).compile();
@@ -654,4 +709,150 @@ describe("DiscordService", () => {
expect(loggedError.statusCode).toBe(408); expect(loggedError.statusCode).toBe(408);
}); });
}); });
describe("Agent Channel Routing", () => {
it("should load agent channel mappings from environment", () => {
// The service should have loaded the agent channels from DISCORD_AGENT_CHANNELS
expect((service as any).agentChannels.size).toBe(2);
expect((service as any).agentChannels.get("jarvis-channel")).toBe("jarvis");
expect((service as any).agentChannels.get("builder-channel")).toBe("builder");
});
it("should handle empty agent channels config", async () => {
delete process.env.DISCORD_AGENT_CHANNELS;
const module: TestingModule = await Test.createTestingModule({
providers: [
DiscordService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
const newService = module.get<DiscordService>(DiscordService);
expect((newService as any).agentChannels.size).toBe(0);
// Restore for other tests
process.env.DISCORD_AGENT_CHANNELS = "jarvis-channel:jarvis,builder-channel:builder";
});
it("should route messages in agent channels to ChatProxyService", async () => {
const mockChannel = {
send: vi.fn().mockResolvedValue({}),
isTextBased: () => true,
sendTyping: vi.fn(),
};
(mockClient.channels.fetch as any).mockResolvedValue(mockChannel);
// Create a mock streaming response
const mockStreamResponse = new Response(
'data: {"choices":[{"delta":{"content":"Test response"}}]}\n\ndata: [DONE]\n\n',
{ headers: { "Content-Type": "text/event-stream" } }
);
mockChatProxyService.proxyChat.mockResolvedValue(mockStreamResponse);
await service.connect();
// Simulate a message in the jarvis channel
const message: ChatMessage = {
id: "msg-agent-1",
channelId: "jarvis-channel",
authorId: "user-1",
authorName: "TestUser",
content: "Hello Jarvis!",
timestamp: new Date(),
};
// Call handleAgentChat directly
await (service as any).handleAgentChat(message, "jarvis");
// Verify ChatProxyService was called with workspace owner's ID and agent name
expect(mockChatProxyService.proxyChat).toHaveBeenCalledWith(
"owner-user-id",
[{ role: "user", content: "Hello Jarvis!" }],
undefined,
"jarvis"
);
// Verify response was sent to channel
expect(mockChannel.send).toHaveBeenCalled();
});
it("should not route empty messages", async () => {
const message: ChatMessage = {
id: "msg-empty",
channelId: "jarvis-channel",
authorId: "user-1",
authorName: "TestUser",
content: " ",
timestamp: new Date(),
};
await (service as any).handleAgentChat(message, "jarvis");
expect(mockChatProxyService.proxyChat).not.toHaveBeenCalled();
});
it("should handle ChatProxyService errors gracefully", async () => {
const mockChannel = {
send: vi.fn().mockResolvedValue({}),
isTextBased: () => true,
sendTyping: vi.fn(),
};
(mockClient.channels.fetch as any).mockResolvedValue(mockChannel);
mockChatProxyService.proxyChat.mockRejectedValue(new Error("Agent not found"));
await service.connect();
const message: ChatMessage = {
id: "msg-error",
channelId: "jarvis-channel",
authorId: "user-1",
authorName: "TestUser",
content: "Hello",
timestamp: new Date(),
};
await (service as any).handleAgentChat(message, "jarvis");
// Should send error message to channel
expect(mockChannel.send).toHaveBeenCalledWith(
expect.stringContaining("Failed to get response from jarvis")
);
});
it("should split long messages for Discord", () => {
const longContent = "A".repeat(5000);
const chunks = (service as any).splitMessageForDiscord(longContent);
// Should split into chunks of 2000 or less
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(2000);
}
// Reassembled content should match original
expect(chunks.join("")).toBe(longContent.trim());
});
it("should prefer paragraph breaks when splitting messages", () => {
const content = "A".repeat(1500) + "\n\n" + "B".repeat(1500);
const chunks = (service as any).splitMessageForDiscord(content);
expect(chunks.length).toBe(2);
expect(chunks[0]).toContain("A");
expect(chunks[1]).toContain("B");
});
});
}); });

View File

@@ -1,6 +1,8 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { Client, Events, GatewayIntentBits, TextChannel, ThreadChannel } from "discord.js"; import { Client, Events, GatewayIntentBits, TextChannel, ThreadChannel } from "discord.js";
import { StitcherService } from "../../stitcher/stitcher.service"; import { StitcherService } from "../../stitcher/stitcher.service";
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
import { PrismaService } from "../../prisma/prisma.service";
import { sanitizeForLogging } from "../../common/utils"; import { sanitizeForLogging } from "../../common/utils";
import type { import type {
IChatProvider, IChatProvider,
@@ -17,6 +19,7 @@ import type {
* - Connect to Discord via bot token * - Connect to Discord via bot token
* - Listen for commands in designated channels * - Listen for commands in designated channels
* - Forward commands to stitcher * - Forward commands to stitcher
* - Route messages in agent channels to specific agents via ChatProxyService
* - Receive status updates from herald * - Receive status updates from herald
* - Post updates to threads * - Post updates to threads
*/ */
@@ -28,12 +31,21 @@ export class DiscordService implements IChatProvider {
private readonly botToken: string; private readonly botToken: string;
private readonly controlChannelId: string; private readonly controlChannelId: string;
private readonly workspaceId: string; private readonly workspaceId: string;
private readonly agentChannels = new Map<string, string>();
private workspaceOwnerId: string | null = null;
constructor(private readonly stitcherService: StitcherService) { constructor(
private readonly stitcherService: StitcherService,
private readonly chatProxyService: ChatProxyService,
private readonly prisma: PrismaService
) {
this.botToken = process.env.DISCORD_BOT_TOKEN ?? ""; this.botToken = process.env.DISCORD_BOT_TOKEN ?? "";
this.controlChannelId = process.env.DISCORD_CONTROL_CHANNEL_ID ?? ""; this.controlChannelId = process.env.DISCORD_CONTROL_CHANNEL_ID ?? "";
this.workspaceId = process.env.DISCORD_WORKSPACE_ID ?? ""; this.workspaceId = process.env.DISCORD_WORKSPACE_ID ?? "";
// Load agent channel mappings from environment
this.loadAgentChannels();
// Initialize Discord client with required intents // Initialize Discord client with required intents
this.client = new Client({ this.client = new Client({
intents: [ intents: [
@@ -46,6 +58,51 @@ export class DiscordService implements IChatProvider {
this.setupEventHandlers(); this.setupEventHandlers();
} }
/**
* Load agent channel mappings from environment variables.
* Format: DISCORD_AGENT_CHANNELS=<channelId>:<agentName>,<channelId>:<agentName>
* Example: DISCORD_AGENT_CHANNELS=123456:jarvis,789012:builder
*/
private loadAgentChannels(): void {
const channelsConfig = process.env.DISCORD_AGENT_CHANNELS ?? "";
if (!channelsConfig) {
this.logger.debug("No agent channels configured (DISCORD_AGENT_CHANNELS not set)");
return;
}
const channels = channelsConfig.split(",").map((pair) => pair.trim());
for (const channel of channels) {
const [channelId, agentName] = channel.split(":");
if (channelId && agentName) {
this.agentChannels.set(channelId.trim(), agentName.trim());
this.logger.log(`Agent channel mapped: ${channelId.trim()}${agentName.trim()}`);
}
}
}
/**
* Get the workspace owner's user ID for chat proxy routing.
* Caches the result after first lookup.
*/
private async getWorkspaceOwnerId(): Promise<string> {
if (this.workspaceOwnerId) {
return this.workspaceOwnerId;
}
const workspace = await this.prisma.workspace.findUnique({
where: { id: this.workspaceId },
select: { ownerId: true },
});
if (!workspace) {
throw new Error(`Workspace not found: ${this.workspaceId}`);
}
this.workspaceOwnerId = workspace.ownerId;
this.logger.debug(`Workspace owner resolved: ${workspace.ownerId}`);
return workspace.ownerId;
}
/** /**
* Setup event handlers for Discord client * Setup event handlers for Discord client
*/ */
@@ -60,9 +117,6 @@ export class DiscordService implements IChatProvider {
// Ignore bot messages // Ignore bot messages
if (message.author.bot) return; if (message.author.bot) return;
// Check if message is in control channel
if (message.channelId !== this.controlChannelId) return;
// Parse message into ChatMessage format // Parse message into ChatMessage format
const chatMessage: ChatMessage = { const chatMessage: ChatMessage = {
id: message.id, id: message.id,
@@ -74,6 +128,16 @@ export class DiscordService implements IChatProvider {
...(message.channel.isThread() && { threadId: message.channelId }), ...(message.channel.isThread() && { threadId: message.channelId }),
}; };
// Check if message is in an agent channel
const agentName = this.agentChannels.get(message.channelId);
if (agentName) {
void this.handleAgentChat(chatMessage, agentName);
return;
}
// Check if message is in control channel for commands
if (message.channelId !== this.controlChannelId) return;
// Parse command // Parse command
const command = this.parseCommand(chatMessage); const command = this.parseCommand(chatMessage);
if (command) { if (command) {
@@ -394,4 +458,150 @@ export class DiscordService implements IChatProvider {
await this.sendMessage(message.channelId, helpMessage); await this.sendMessage(message.channelId, helpMessage);
} }
/**
* Handle agent chat - Route message to specific agent via ChatProxyService
* Messages in agent channels are sent directly to the agent without requiring @mosaic prefix.
*/
private async handleAgentChat(message: ChatMessage, agentName: string): Promise<void> {
this.logger.log(
`Routing message from ${message.authorName} to agent "${agentName}" in channel ${message.channelId}`
);
// Ignore empty messages
if (!message.content.trim()) {
return;
}
try {
// Get workspace owner ID for routing
const userId = await this.getWorkspaceOwnerId();
// Build message history (just the user's message for now)
const messages = [{ role: "user" as const, content: message.content }];
// Send typing indicator while waiting for response
const channel = await this.client.channels.fetch(message.channelId);
if (channel?.isTextBased()) {
void (channel as TextChannel).sendTyping();
}
// Proxy to agent
const response = await this.chatProxyService.proxyChat(
userId,
messages,
undefined,
agentName
);
// Stream the response to channel
await this.streamResponseToChannel(message.channelId, response);
this.logger.debug(`Agent "${agentName}" response sent to channel ${message.channelId}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to route message to agent "${agentName}": ${errorMessage}`);
await this.sendMessage(
message.channelId,
`Failed to get response from ${agentName}. Please try again later.`
);
}
}
/**
* Stream SSE response from chat proxy and send to Discord channel.
* Collects the full response and sends as a single message for reliability.
*/
private async streamResponseToChannel(channelId: string, response: Response): Promise<string> {
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder();
let fullContent = "";
let buffer = "";
try {
let readResult = await reader.read();
while (!readResult.done) {
const { value } = readResult;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data) as {
choices?: { delta?: { content?: string } }[];
};
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
fullContent += content;
}
} catch {
// Skip invalid JSON
}
}
}
readResult = await reader.read();
}
// Send the full response to Discord
if (fullContent.trim()) {
// Discord has a 2000 character limit, split if needed
const chunks = this.splitMessageForDiscord(fullContent);
for (const chunk of chunks) {
await this.sendMessage(channelId, chunk);
}
}
return fullContent;
} finally {
reader.releaseLock();
}
}
/**
* Split a message into chunks that fit within Discord's 2000 character limit.
* Tries to split on paragraph or sentence boundaries when possible.
*/
private splitMessageForDiscord(content: string, maxLength = 2000): string[] {
if (content.length <= maxLength) {
return [content];
}
const chunks: string[] = [];
let remaining = content;
while (remaining.length > maxLength) {
// Try to find a good break point
let breakPoint = remaining.lastIndexOf("\n\n", maxLength);
if (breakPoint < maxLength * 0.5) {
breakPoint = remaining.lastIndexOf("\n", maxLength);
}
if (breakPoint < maxLength * 0.5) {
breakPoint = remaining.lastIndexOf(". ", maxLength);
}
if (breakPoint < maxLength * 0.5) {
breakPoint = remaining.lastIndexOf(" ", maxLength);
}
if (breakPoint < maxLength * 0.5) {
breakPoint = maxLength - 1;
}
chunks.push(remaining.slice(0, breakPoint + 1).trim());
remaining = remaining.slice(breakPoint + 1).trim();
}
if (remaining.length > 0) {
chunks.push(remaining);
}
return chunks;
}
} }

View File

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

View File

@@ -99,7 +99,8 @@ export class ChatProxyController {
const upstreamResponse = await this.chatProxyService.proxyChat( const upstreamResponse = await this.chatProxyService.proxyChat(
userId, userId,
body.messages, body.messages,
abortController.signal abortController.signal,
body.agent
); );
const upstreamContentType = upstreamResponse.headers.get("content-type"); const upstreamContentType = upstreamResponse.headers.get("content-type");

View File

@@ -1,5 +1,12 @@
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { ArrayMinSize, IsArray, IsNotEmpty, IsString, ValidateNested } from "class-validator"; import {
ArrayMinSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
export interface ChatMessage { export interface ChatMessage {
role: string; role: string;
@@ -22,4 +29,8 @@ export class ChatStreamDto {
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => ChatMessageDto) @Type(() => ChatMessageDto)
messages!: ChatMessageDto[]; messages!: ChatMessageDto[];
@IsString({ message: "agent must be a string" })
@IsOptional()
agent?: string;
} }

View File

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

View File

@@ -2,6 +2,7 @@ import {
BadGatewayException, BadGatewayException,
Injectable, Injectable,
Logger, Logger,
NotFoundException,
ServiceUnavailableException, ServiceUnavailableException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
@@ -18,6 +19,13 @@ interface ContainerConnection {
token: string; token: string;
} }
interface AgentConfig {
name: string;
displayName: string;
personality: string;
primaryModel: string | null;
}
@Injectable() @Injectable()
export class ChatProxyService { export class ChatProxyService {
private readonly logger = new Logger(ChatProxyService.name); private readonly logger = new Logger(ChatProxyService.name);
@@ -38,21 +46,38 @@ export class ChatProxyService {
async proxyChat( async proxyChat(
userId: string, userId: string,
messages: ChatMessage[], messages: ChatMessage[],
signal?: AbortSignal signal?: AbortSignal,
agentName?: string
): Promise<Response> { ): Promise<Response> {
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId); const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId);
const model = await this.getPreferredModel(userId);
// Get agent config if specified
let agentConfig: AgentConfig | null = null;
if (agentName) {
agentConfig = await this.getAgentConfig(userId, agentName);
}
const model = agentConfig?.primaryModel ?? (await this.getPreferredModel(userId));
const requestBody: Record<string, unknown> = {
messages,
model,
stream: true,
};
// Add agent config if available
if (agentConfig) {
requestBody.agent = agentConfig.name;
requestBody.agent_personality = agentConfig.personality;
}
const requestInit: RequestInit = { const requestInit: RequestInit = {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${gatewayToken}`, Authorization: `Bearer ${gatewayToken}`,
}, },
body: JSON.stringify({ body: JSON.stringify(requestBody),
messages,
model,
stream: true,
}),
}; };
if (signal) { if (signal) {
@@ -170,4 +195,32 @@ export class ChatProxyService {
return null; return null;
} }
} }
private async getAgentConfig(userId: string, agentName: string): Promise<AgentConfig> {
const agent = await this.prisma.userAgent.findUnique({
where: { userId_name: { userId, name: agentName } },
select: {
name: true,
displayName: true,
personality: true,
primaryModel: true,
isActive: true,
},
});
if (!agent) {
throw new NotFoundException(`Agent "${agentName}" not found for user`);
}
if (!agent.isActive) {
throw new NotFoundException(`Agent "${agentName}" is not active`);
}
return {
name: agent.name,
displayName: agent.displayName,
personality: agent.personality,
primaryModel: agent.primaryModel,
};
}
} }

View File

@@ -26,11 +26,21 @@ export class UserAgentController {
return this.userAgentService.findAll(user.id); return this.userAgentService.findAll(user.id);
} }
@Get("status")
getAllStatuses(@CurrentUser() user: AuthUser) {
return this.userAgentService.getAllStatuses(user.id);
}
@Get(":id") @Get(":id")
findOne(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) { findOne(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) {
return this.userAgentService.findOne(user.id, id); 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() @Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateUserAgentDto) { create(@CurrentUser() user: AuthUser, @Body() dto: CreateUserAgentDto) {
return this.userAgentService.create(user.id, dto); return this.userAgentService.create(user.id, dto);

View File

@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
import { UserAgentService } from "./user-agent.service"; import { UserAgentService } from "./user-agent.service";
import { UserAgentController } from "./user-agent.controller"; import { UserAgentController } from "./user-agent.controller";
import { PrismaModule } from "../prisma/prisma.module"; import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule, AuthModule],
controllers: [UserAgentController], controllers: [UserAgentController],
providers: [UserAgentService], providers: [UserAgentService],
exports: [UserAgentService], exports: [UserAgentService],

View File

@@ -0,0 +1,300 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { UserAgentService } from "./user-agent.service";
import { PrismaService } from "../prisma/prisma.service";
import { NotFoundException, ConflictException, ForbiddenException } from "@nestjs/common";
describe("UserAgentService", () => {
let service: UserAgentService;
let prisma: PrismaService;
const mockPrismaService = {
userAgent: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
agentTemplate: {
findUnique: vi.fn(),
},
};
const mockUserId = "550e8400-e29b-41d4-a716-446655440001";
const mockAgentId = "550e8400-e29b-41d4-a716-446655440002";
const mockTemplateId = "550e8400-e29b-41d4-a716-446655440003";
const mockAgent = {
id: mockAgentId,
userId: mockUserId,
templateId: null,
name: "jarvis",
displayName: "Jarvis",
role: "orchestrator",
personality: "Capable, direct, proactive.",
primaryModel: "opus",
fallbackModels: ["sonnet"],
toolPermissions: ["all"],
discordChannel: "jarvis",
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockTemplate = {
id: mockTemplateId,
name: "builder",
displayName: "Builder",
role: "coding",
personality: "Focused, thorough.",
primaryModel: "codex",
fallbackModels: ["sonnet"],
toolPermissions: ["exec", "read", "write"],
discordChannel: "builder",
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserAgentService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<UserAgentService>(UserAgentService);
prisma = module.get<PrismaService>(PrismaService);
vi.clearAllMocks();
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("findAll", () => {
it("should return all agents for a user", async () => {
mockPrismaService.userAgent.findMany.mockResolvedValue([mockAgent]);
const result = await service.findAll(mockUserId);
expect(result).toEqual([mockAgent]);
expect(mockPrismaService.userAgent.findMany).toHaveBeenCalledWith({
where: { userId: mockUserId },
orderBy: { createdAt: "asc" },
});
});
it("should return empty array if no agents", async () => {
mockPrismaService.userAgent.findMany.mockResolvedValue([]);
const result = await service.findAll(mockUserId);
expect(result).toEqual([]);
});
});
describe("findOne", () => {
it("should return an agent by id", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
const result = await service.findOne(mockUserId, mockAgentId);
expect(result).toEqual(mockAgent);
});
it("should throw NotFoundException if agent not found", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
await expect(service.findOne(mockUserId, mockAgentId)).rejects.toThrow(NotFoundException);
});
it("should throw ForbiddenException if agent belongs to different user", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue({
...mockAgent,
userId: "different-user-id",
});
await expect(service.findOne(mockUserId, mockAgentId)).rejects.toThrow(ForbiddenException);
});
});
describe("findByName", () => {
it("should return an agent by name", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
const result = await service.findByName(mockUserId, "jarvis");
expect(result).toEqual(mockAgent);
expect(mockPrismaService.userAgent.findUnique).toHaveBeenCalledWith({
where: { userId_name: { userId: mockUserId, name: "jarvis" } },
});
});
it("should throw NotFoundException if agent not found", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
await expect(service.findByName(mockUserId, "nonexistent")).rejects.toThrow(
NotFoundException
);
});
});
describe("create", () => {
it("should create a new agent", async () => {
const createDto = {
name: "jarvis",
displayName: "Jarvis",
role: "orchestrator",
personality: "Capable, direct, proactive.",
};
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
mockPrismaService.userAgent.create.mockResolvedValue(mockAgent);
const result = await service.create(mockUserId, createDto);
expect(result).toEqual(mockAgent);
});
it("should throw ConflictException if agent name already exists", async () => {
const createDto = {
name: "jarvis",
displayName: "Jarvis",
role: "orchestrator",
personality: "Capable, direct, proactive.",
};
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
await expect(service.create(mockUserId, createDto)).rejects.toThrow(ConflictException);
});
it("should throw NotFoundException if templateId is invalid", async () => {
const createDto = {
name: "custom",
displayName: "Custom",
role: "custom",
personality: "Custom agent",
templateId: "nonexistent-template",
};
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
mockPrismaService.agentTemplate.findUnique.mockResolvedValue(null);
await expect(service.create(mockUserId, createDto)).rejects.toThrow(NotFoundException);
});
});
describe("createFromTemplate", () => {
it("should create an agent from a template", async () => {
mockPrismaService.agentTemplate.findUnique.mockResolvedValue(mockTemplate);
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
mockPrismaService.userAgent.create.mockResolvedValue({
...mockAgent,
templateId: mockTemplateId,
name: mockTemplate.name,
displayName: mockTemplate.displayName,
role: mockTemplate.role,
});
const result = await service.createFromTemplate(mockUserId, mockTemplateId);
expect(result.name).toBe(mockTemplate.name);
expect(result.displayName).toBe(mockTemplate.displayName);
});
it("should throw NotFoundException if template not found", async () => {
mockPrismaService.agentTemplate.findUnique.mockResolvedValue(null);
await expect(service.createFromTemplate(mockUserId, mockTemplateId)).rejects.toThrow(
NotFoundException
);
});
it("should throw ConflictException if agent name already exists", async () => {
mockPrismaService.agentTemplate.findUnique.mockResolvedValue(mockTemplate);
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
await expect(service.createFromTemplate(mockUserId, mockTemplateId)).rejects.toThrow(
ConflictException
);
});
});
describe("update", () => {
it("should update an agent", async () => {
const updateDto = { displayName: "Updated Jarvis" };
const updatedAgent = { ...mockAgent, ...updateDto };
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
mockPrismaService.userAgent.update.mockResolvedValue(updatedAgent);
const result = await service.update(mockUserId, mockAgentId, updateDto);
expect(result.displayName).toBe("Updated Jarvis");
});
it("should throw ConflictException if new name already exists", async () => {
const updateDto = { name: "existing-name" };
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
// Second call checks for existing name
mockPrismaService.userAgent.findUnique.mockResolvedValue({ ...mockAgent, id: "other-id" });
await expect(service.update(mockUserId, mockAgentId, updateDto)).rejects.toThrow(
ConflictException
);
});
});
describe("remove", () => {
it("should delete an agent", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
mockPrismaService.userAgent.delete.mockResolvedValue(mockAgent);
const result = await service.remove(mockUserId, mockAgentId);
expect(result).toEqual(mockAgent);
});
});
describe("getStatus", () => {
it("should return agent status", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
const result = await service.getStatus(mockUserId, mockAgentId);
expect(result).toEqual({
id: mockAgentId,
name: "jarvis",
displayName: "Jarvis",
role: "orchestrator",
isActive: true,
});
});
});
describe("getAllStatuses", () => {
it("should return all agent statuses", async () => {
mockPrismaService.userAgent.findMany.mockResolvedValue([mockAgent]);
const result = await service.getAllStatuses(mockUserId);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
id: mockAgentId,
name: "jarvis",
displayName: "Jarvis",
role: "orchestrator",
isActive: true,
});
});
});
});

View File

@@ -8,6 +8,15 @@ import { PrismaService } from "../prisma/prisma.service";
import { CreateUserAgentDto } from "./dto/create-user-agent.dto"; import { CreateUserAgentDto } from "./dto/create-user-agent.dto";
import { UpdateUserAgentDto } from "./dto/update-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() @Injectable()
export class UserAgentService { export class UserAgentService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -119,4 +128,26 @@ export class UserAgentService {
await this.findOne(userId, id); await this.findOne(userId, id);
return this.prisma.userAgent.delete({ where: { id } }); return this.prisma.userAgent.delete({ where: { id } });
} }
async getStatus(userId: string, id: string): Promise<AgentStatusResponse> {
const agent = await this.findOne(userId, id);
return {
id: agent.id,
name: agent.name,
displayName: agent.displayName,
role: agent.role,
isActive: agent.isActive,
};
}
async getAllStatuses(userId: string): Promise<AgentStatusResponse[]> {
const agents = await this.findAll(userId);
return agents.map((agent) => ({
id: agent.id,
name: agent.name,
displayName: agent.displayName,
role: agent.role,
isActive: agent.isActive,
}));
}
} }

View File

@@ -21,6 +21,8 @@ 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 npm configuration for native binary architecture hints
COPY .npmrc ./ COPY .npmrc ./
@@ -46,6 +48,10 @@ 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

View File

@@ -3,19 +3,20 @@
"version": "0.0.20", "version": "0.0.20",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "nest start --watch",
"build": "nest build", "build": "nest build",
"dev": "nest start --watch",
"lint": "eslint src/",
"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:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:dev": "nest start --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",
"typecheck": "tsc --noEmit", "test:watch": "vitest watch",
"lint": "eslint src/", "typecheck": "tsc --noEmit"
"lint:fix": "eslint src/ --fix"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.72.1", "@anthropic-ai/sdk": "^0.72.1",
@@ -27,6 +28,7 @@
"@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",

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

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

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger, Inject, forwardRef } from "@nestjs/common"; import { Injectable, Logger, Inject, Optional, 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";
@@ -32,7 +33,8 @@ 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");
} }
@@ -55,6 +57,25 @@ 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,
@@ -118,6 +139,10 @@ 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;
}); });
@@ -155,6 +180,10 @@ 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);
@@ -192,6 +221,10 @@ 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);
@@ -228,6 +261,10 @@ 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);

View File

@@ -1,4 +1,11 @@
import { Injectable, Logger, HttpException, HttpStatus, OnModuleDestroy } from "@nestjs/common"; import {
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";
@@ -8,6 +15,7 @@ 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
@@ -30,7 +38,10 @@ 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(private readonly configService: ConfigService) { constructor(
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);
@@ -98,6 +109,19 @@ 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
@@ -130,6 +154,8 @@ 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)

View File

@@ -3,9 +3,10 @@ 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], imports: [ValkeyModule, AgentIngestionModule],
providers: [AgentSpawnerService, AgentLifecycleService, DockerSandboxService], providers: [AgentSpawnerService, AgentLifecycleService, DockerSandboxService],
exports: [AgentSpawnerService, AgentLifecycleService, DockerSandboxService], exports: [AgentSpawnerService, AgentLifecycleService, DockerSandboxService],
}) })

View File

@@ -4,6 +4,7 @@ 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: {

View File

@@ -0,0 +1,128 @@
"use client";
import React from "react";
interface AgentSelectorProps {
selectedAgent?: string | null;
onChange?: (agent: string | null) => void;
disabled?: boolean;
}
const AGENT_CONFIG = {
jarvis: {
displayName: "Jarvis",
role: "Orchestrator",
color: "#3498db",
},
builder: {
displayName: "Builder",
role: "Coding Agent",
color: "#3b82f6",
},
medic: {
displayName: "Medic",
role: "Health Monitor",
color: "#10b981",
},
} as const;
function JarvisIcon({ className }: { className?: string }): React.ReactElement {
return (
<svg
className={`w-3 h-3 ${className ?? ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 22v-4" />
<path d="M2 12h4M22 12h-4" />
</svg>
);
}
function BuilderIcon({ className }: { className?: string }): React.ReactElement {
return (
<svg
className={`w-3 h-3 ${className ?? ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
);
}
function MedicIcon({ className }: { className?: string }): React.ReactElement {
return (
<svg
className={`w-3 h-3 ${className ?? ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
);
}
const AGENT_ICONS: Record<string, React.FC<{ className?: string }>> = {
jarvis: JarvisIcon,
builder: BuilderIcon,
medic: MedicIcon,
};
export function AgentSelector({
selectedAgent,
onChange,
disabled,
}: AgentSelectorProps): React.ReactElement {
return (
<div className="flex items-center gap-2">
<span className="text-xs font-medium" style={{ color: "rgb(var(--text-muted))" }}>
Agent
</span>
<div className="flex flex-wrap gap-1">
{Object.entries(AGENT_CONFIG).map(([name, config]) => {
const Icon = AGENT_ICONS[name];
const isSelected = selectedAgent === name;
return (
<button
key={name}
type="button"
onClick={() => onChange?.(isSelected ? null : name)}
disabled={disabled}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg border transition-all text-xs ${
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
style={{
borderColor: isSelected
? "rgb(var(--accent-primary))"
: "rgb(var(--border-default))",
color: isSelected ? "rgb(var(--accent-primary))" : "rgb(var(--text-primary))",
}}
title={`${config.displayName}${config.role}`}
>
<span
className="rounded-full"
style={{
backgroundColor: config.color,
width: "8px",
height: "8px",
}}
/>
{Icon && <Icon />}
<span className="font-medium">{config.displayName}</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { useWorkspaceId } from "@/lib/hooks";
import { MessageList } from "./MessageList"; import { MessageList } from "./MessageList";
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput"; import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
import { ChatEmptyState } from "./ChatEmptyState"; import { ChatEmptyState } from "./ChatEmptyState";
import { AgentSelector } from "./AgentSelector";
import type { Message } from "@/hooks/useChat"; import type { Message } from "@/hooks/useChat";
export interface ChatRef { export interface ChatRef {
@@ -66,6 +67,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2"); const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2");
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE); const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS); const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
// Suggestion fill value: controls ChatInput's textarea content // Suggestion fill value: controls ChatInput's textarea content
const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined); const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined);
@@ -88,6 +90,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
temperature, temperature,
maxTokens, maxTokens,
...(initialProjectId !== undefined && { projectId: initialProjectId }), ...(initialProjectId !== undefined && { projectId: initialProjectId }),
...(selectedAgent !== null && { agent: selectedAgent }),
}); });
// Read workspace ID from localStorage (set by auth-context after session check). // Read workspace ID from localStorage (set by auth-context after session check).
@@ -375,6 +378,13 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
}} }}
> >
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8"> <div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
<div className="mb-3">
<AgentSelector
selectedAgent={selectedAgent}
onChange={setSelectedAgent}
disabled={isChatLoading || isStreaming || !user}
/>
</div>
<ChatInput <ChatInput
onSend={handleSendMessage} onSend={handleSendMessage}
disabled={isChatLoading || !user} disabled={isChatLoading || !user}

View File

@@ -27,6 +27,7 @@ export interface UseChatOptions {
maxTokens?: number; maxTokens?: number;
systemPrompt?: string; systemPrompt?: string;
projectId?: string | null; projectId?: string | null;
agent?: string;
onError?: (error: Error) => void; onError?: (error: Error) => void;
} }
@@ -63,6 +64,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
maxTokens, maxTokens,
systemPrompt, systemPrompt,
projectId, projectId,
agent,
onError, onError,
} = options; } = options;
@@ -77,6 +79,10 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
const projectIdRef = useRef<string | null>(projectId ?? null); const projectIdRef = useRef<string | null>(projectId ?? null);
projectIdRef.current = projectId ?? null; projectIdRef.current = projectId ?? null;
// Track agent in ref to prevent stale closures
const agentRef = useRef<string | undefined>(agent);
agentRef.current = agent;
// Track messages in ref to prevent stale closures during rapid sends // Track messages in ref to prevent stale closures during rapid sends
const messagesRef = useRef<Message[]>(messages); const messagesRef = useRef<Message[]>(messages);
messagesRef.current = messages; messagesRef.current = messages;
@@ -209,6 +215,7 @@ 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();

View File

@@ -0,0 +1,125 @@
/**
* Agent API client
* Handles agent-related API interactions
*/
import { apiGet, apiPost, apiPatch, apiDelete } from "./client";
export interface AgentStatus {
id: string;
name: string;
displayName: string;
role: string;
isActive: boolean;
containerStatus?: "running" | "stopped" | "unknown";
}
export interface UserAgent {
id: string;
userId: string;
templateId: string | null;
name: string;
displayName: string;
role: string;
personality: string;
primaryModel: string | null;
fallbackModels: string[];
toolPermissions: string[];
discordChannel: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateUserAgentRequest {
templateId?: string;
name: string;
displayName: string;
role: string;
personality: string;
primaryModel?: string;
fallbackModels?: string[];
toolPermissions?: string[];
discordChannel?: string;
isActive?: boolean;
}
export interface UpdateUserAgentRequest {
name?: string;
displayName?: string;
role?: string;
personality?: string;
primaryModel?: string;
fallbackModels?: string[];
toolPermissions?: string[];
discordChannel?: string;
isActive?: boolean;
}
export interface UpdateUserAgentRequest {
name?: string;
displayName?: string;
role?: string;
personality?: string;
primaryModel?: string;
fallbackModels?: string[];
toolPermissions?: string[];
discordChannel?: string;
isActive?: boolean;
}
/**
* Get all user's agents
*/
export async function getAgents(): Promise<UserAgent[]> {
return apiGet<UserAgent[]>("/api/agents");
}
/**
* Get all agent statuses
*/
export async function getAgentStatuses(): Promise<AgentStatus[]> {
return apiGet<AgentStatus[]>("/api/agents/status");
}
/**
* Get a single agent by ID
*/
export async function getAgent(id: string): Promise<UserAgent> {
return apiGet<UserAgent>(`/api/agents/${id}`);
}
/**
* Get a single agent's status
*/
export async function getAgentStatus(id: string): Promise<AgentStatus> {
return apiGet<AgentStatus>(`/api/agents/${id}/status`);
}
/**
* Create a new custom agent
*/
export async function createAgent(data: CreateUserAgentRequest): Promise<UserAgent> {
return apiPost<UserAgent>("/api/agents", data);
}
/**
* Create an agent from a template
*/
export async function createAgentFromTemplate(templateId: string): Promise<UserAgent> {
return apiPost<UserAgent>(`/api/agents/from-template/${templateId}`, {});
}
/**
* Update an agent
*/
export async function updateAgent(id: string, data: UpdateUserAgentRequest): Promise<UserAgent> {
return apiPatch<UserAgent>(`/api/agents/${id}`, data);
}
/**
* Delete an agent
*/
export async function deleteAgent(id: string): Promise<void> {
await apiDelete(`/api/agents/${id}`);
}

View File

@@ -18,6 +18,7 @@ export interface ChatRequest {
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
systemPrompt?: string; systemPrompt?: string;
agent?: string;
} }
export interface ChatResponse { export interface ChatResponse {
@@ -117,7 +118,11 @@ export function streamGuestChat(
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: "include", credentials: "include",
body: JSON.stringify({ messages: request.messages, stream: true }), body: JSON.stringify({
messages: request.messages,
stream: true,
...(request.agent && { agent: request.agent }),
}),
signal: signal ?? null, signal: signal ?? null,
}); });
@@ -269,7 +274,11 @@ export function streamChatMessage(
"X-CSRF-Token": csrfToken, "X-CSRF-Token": csrfToken,
}, },
credentials: "include", credentials: "include",
body: JSON.stringify({ messages: request.messages, stream: true }), body: JSON.stringify({
messages: request.messages,
stream: true,
...(request.agent && { agent: request.agent }),
}),
signal: signal ?? null, signal: signal ?? null,
}); });

View File

@@ -8,61 +8,64 @@
**ID:** ms22-p2-named-agent-fleet-20260304 **ID:** ms22-p2-named-agent-fleet-20260304
**Statement:** Implement named agent fleet (jarvis, builder, medic) with per-agent personalities, model assignments, Discord channel routing, and WebUI selector. **Statement:** Implement named agent fleet (jarvis, builder, medic) with per-agent personalities, model assignments, Discord channel routing, and WebUI selector.
**PRD:** `docs/PRD-MS22-P2-AGENT-FLEET.md` **PRD:** `docs/PRD-MS22-P2-AGENT-FLEET.md`
**Phase:** Execution **Phase:** Completion
**Status:** in-progress **Status:** completed
**Last Updated:** 2026-03-04 **Last Updated:** 2026-03-05
## Success Criteria ## Success Criteria
1. AgentTemplate and UserAgent tables exist and are seeded with jarvis/builder/medic 1. AgentTemplate and UserAgent tables exist and are seeded with jarvis/builder/medic
2. Admin CRUD endpoints at `/admin/agent-templates` work and are guarded 2. Admin CRUD endpoints at `/admin/agent-templates` work and are guarded
3. User agent CRUD endpoints allow per-user agent customization 3. User agent CRUD endpoints allow per-user agent customization
4. Chat proxy routes messages to correct agent by name 4. Chat proxy routes messages to correct agent by name
5. Discord channel → agent routing maps #jarvis/#builder/#medic-alerts 5. Discord channel → agent routing maps #jarvis/#builder/#medic-alerts
6. WebUI shows agent selector and connects to correct agent 6. WebUI shows agent selector and connects to correct agent
7. All CI gates green 7. All CI gates green
## Milestones ## Milestones
| # | ID | Name | Status | Tasks | Notes | | # | ID | Name | Status | Tasks | Notes |
| --- | ------------- | ------------- | -------------- | -------------- | --------------------- | | --- | ------------- | ------------- | ------- | ---------------------- | --------------------------- |
| 1 | schema-seed | Schema+Seed | ✅ done | P2-001, P2-002 | PRs #675, #677 merged | | 1 | schema-seed | Schema+Seed | ✅ done | P2-001, P2-002 | PRs #675, #677 merged |
| 2 | admin-crud | Admin CRUD | ✅ done | P2-003 | PR #678 merged | | 2 | admin-crud | Admin CRUD | ✅ done | P2-003 | PR #678 merged |
| 3 | user-crud | User CRUD | 🔄 in-progress | P2-004 | Depends on M2 | | 3 | user-crud | User CRUD | ✅ done | P2-004 | PR #682 merged |
| 4 | agent-routing | Agent Routing | ⬜ pending | P2-005, P2-006 | Depends on M3 | | 4 | agent-routing | Agent Routing | ✅ done | P2-005, P2-006 | PR #684 merged |
| 5 | discord-ui | Discord+UI | ⬜ pending | P2-007, P2-008 | Depends on M4 | | 5 | discord-ui | Discord+UI | ✅ done | P2-007, P2-008, P2-009 | PRs #685, #687, #688 merged |
| 6 | verification | Verification | ⬜ pending | P2-009, P2-010 | Final gate | | 6 | verification | Verification | ✅ done | P2-010 | All CI gates green |
## Task Summary ## Task Summary
See `docs/TASKS.md` — MS22 Phase 2 section for full task details. See `docs/TASKS.md` — MS22 Phase 2 section for full task details.
| Task | Status | PR | Notes | | Task | Status | PR | Notes |
| ----------------------- | -------------- | ---- | ------------------------------ | | ----------------------- | ------- | ---- | ------------------------------ |
| P2-001 Schema | ✅ done | #675 | AgentTemplate + UserAgent | | P2-001 Schema | ✅ done | #675 | AgentTemplate + UserAgent |
| P2-002 Seed | ✅ done | #677 | jarvis/builder/medic templates | | P2-002 Seed | ✅ done | #677 | jarvis/builder/medic templates |
| P2-003 Admin CRUD | ✅ done | #678 | /admin/agent-templates | | P2-003 Admin CRUD | ✅ done | #678 | /admin/agent-templates |
| P2-004 User CRUD | 🔄 in-progress | — | | | P2-004 User CRUD | ✅ done | #682 | /api/agents |
| P2-005 Status endpoints | ⬜ not-started | — | | | P2-005 Status endpoints | ✅ done | #684 | Agent status API |
| P2-006 Chat routing | ⬜ not-started | — | | | P2-006 Chat routing | ✅ done | #684 | Agent routing in chat proxy |
| P2-007 Discord routing | ⬜ not-started | — | | | P2-007 Discord routing | ✅ done | #688 | Channel → agent routing |
| P2-008 WebUI selector | ⬜ not-started | — | | | P2-008 WebUI selector | ✅ done | #685 | AgentSelector component |
| P2-009 Unit tests | ⬜ not-started | — | | | P2-009 Unit tests | ✅ done | #687 | Agent services tests |
| P2-010 E2E verification | ⬜ not-started | — | | | P2-010 E2E verification | ✅ done | — | 3547 tests pass, CI green |
## Token Budget ## Token Budget
| Phase | Est | Used | | Phase | Est | Used |
| ----------------- | -------- | -------------------- | | ----------------- | -------- | -------- |
| Schema+Seed+CRUD | 30K | ~15K (done directly) | | Schema+Seed+CRUD | 30K | ~15K |
| User CRUD+Routing | 40K | | | User CRUD+Routing | 40K | ~25K |
| Discord+UI | 30K | | | Discord+UI | 30K | ~24K |
| Verification | 10K | | | Verification | 10K | ~5K |
| **Total** | **110K** | **~15K** | | **Total** | **110K** | **~69K** |
## Session Log ## Session Log
| Date | Work Done | | Date | Work Done |
| ---------- | ----------------------------------------------------------------------------------------------- | | ---------- | ----------------------------------------------------------------------------------------------------------------- |
| 2026-03-04 | Session 2: Fixed CI security audit (multer override), merged PR #678, starting P2-004 User CRUD | | 2026-03-05 | Session 5: Completed P2-010 E2E verification. All 10 tasks done. Mission complete. |
| 2026-03-04 | P2-001..003 shipped; CI fix; postgres rebuilt; mission initialized | | 2026-03-05 | Session 4: Completed P2-007 (Discord routing) PR #688. Milestone 5 complete. 9/10 tasks done, only E2E remains. |
| 2026-03-05 | Session 3: Completed P2-008 (WebUI agent selector) PR #685. Milestones 1-4 + P2-008 complete (3 tasks remaining). |
| 2026-03-04 | Session 2: Fixed CI security audit, merged PRs #681, #678, #682. Milestones 1-3 complete (4/6 remaining). |
| 2026-03-04 | P2-001..003 shipped; CI fix; postgres rebuilt; mission initialized |

View File

@@ -0,0 +1,555 @@
# PRD: MS23 — Mission Control Dashboard & Agent Provider Interface
## Metadata
- **Owner:** Jason Woltje
- **Date:** 2026-03-06
- **Status:** draft
- **Mission ID:** ms23-mission-control-20260306
- **Target Version:** 0.0.23
- **Roadmap Milestone:** M6 — Orchestration (0.0.6 trajectory)
- **Depends On:** MS22 Phase 2 (Named Agent Fleet) — COMPLETE
- **Related Docs:**
- `~/src/jarvis-brain/docs/planning/MISSION-CONTROL-UI-PRD.md` (concept origin)
- `~/src/jarvis-brain/docs/planning/FLEET-EVOLUTION-PLAN.md`
- `docs/PRD-MS22-P2-AGENT-FLEET.md`
---
## Problem Statement
The Mosaic orchestration backend is fully operational: agents spawn, execute tasks, publish lifecycle events via Valkey pub/sub, and can be killed via API. The frontend exposes rudimentary widgets (AgentStatusWidget, OrchestratorEventsWidget) that show aggregate status.
What's missing is **operational visibility and control at the session level**. There is no way to:
1. See what an individual agent is actually saying and doing (conversation stream per agent)
2. Inject a message into a running agent session without terminating it (barge-in)
3. Understand the parent/child relationship between orchestrators and their subagents
4. Connect Mosaic's orchestration layer to external agent runtimes (OpenClaw sessions, Codex ACP, raw PTY agents) through a consistent, extensible interface
Jason operates multiple projects in parallel — multiple orchestrating agents running simultaneously across missions. Today this requires context-switching between terminals, Discord channels, and status widgets. Mission Control solves this.
**Mosaic is designed to be an enterprise-grade, multi-user AI operations platform.** Not every user will use OpenClaw. Not every team will use Codex. Mosaic must provide a plugin adapter interface that allows any agent runtime to integrate with the same orchestration harness, control plane, and UI.
---
## Objectives
1. **Mission Control Dashboard** — Single-pane-of-glass view: N orchestrator panels in a responsive grid, each showing a live agent chat stream with full operator controls
2. **Per-Agent Conversation Streaming** — Stream individual agent message logs (not just lifecycle events) to the frontend via SSE
3. **Barge-In / Message Injection** — Operator can inject messages directly into any running agent session with audit trail
4. **Subagent Tree Tracking** — Agents report parent/child relationships; UI renders the full agent roster as a tree
5. **Agent Provider Interface (API)** — Formal plugin adapter interface that any agent runtime can implement to integrate with Mosaic's orchestration layer
6. **OpenClaw Provider Adapter** — Reference implementation of the Agent Provider Interface for OpenClaw ACP sessions
7. **Operator Controls** — Pause, resume, graceful terminate, hard kill per agent; kill-all panic button
8. **Audit Trail** — All operator interventions (barge-in, kill, pause) logged with timestamp, user, target, and content
---
## Scope
### In Scope
- Agent conversation log storage and streaming API (per-agent SSE stream of messages)
- Barge-in endpoint: inject operator message into running agent session
- Pause / resume agent execution
- Subagent tree: parent-agent relationship on spawn registration
- Agent Provider Interface: TypeScript interface + NestJS plugin module
- OpenClaw adapter: implements Agent Provider Interface for OpenClaw sessions
- Mission Control page (`/mission-control`) with grid of orchestrator panels
- OrchestratorPanel component: live chat stream + barge-in input + operator controls
- Global Agent Roster: tree view sidebar showing all agents + subagents with kill buttons
- Audit log: UI and API for operator action history
- Role: `operator` (full control) and `observer` (read-only) applied to all new endpoints
### Out of Scope
- Mobile layout (desktop-first, responsive grid min-width 1200px)
- Multi-user concurrent barge-in coordination (single operator per session)
- Historical session replay / time-travel debugging (future milestone)
- Codex ACP adapter (follow-on after OpenClaw adapter validates interface)
- Raw PTY adapter (follow-on)
- Agent-to-agent communication graph visualization (future)
- Agent marketplace / plugin registry UI (future)
---
## Current State Assessment
### What Exists (Do Not Rebuild)
| Component | Location | Status |
| ------------------------ | --------------------------------------- | -------------------------------- |
| AgentSpawnerService | `apps/orchestrator/src/spawner/` | ✅ Production |
| AgentLifecycleService | `apps/orchestrator/src/spawner/` | ✅ Production |
| KillswitchService | `apps/orchestrator/src/killswitch/` | ✅ Production |
| AgentEventsService | `apps/orchestrator/src/api/agents/` | ✅ SSE lifecycle events |
| `GET /agents` | Orchestrator API | ✅ Lists all agents |
| `POST /agents/:id/kill` | Orchestrator API | ✅ Kills agent |
| `POST /agents/kill-all` | Orchestrator API | ✅ Kills all |
| `GET /agents/events` | Orchestrator API | ✅ SSE lifecycle stream |
| AgentStatusWidget | `apps/web/src/components/widgets/` | ✅ Polls agent list |
| OrchestratorEventsWidget | `apps/web/src/components/widgets/` | ✅ SSE lifecycle events |
| HUD widget grid | `apps/web/src/components/hud/` | ✅ Drag/resize/add/remove |
| Chat component | `apps/web/src/components/chat/` | ✅ Chat UI exists |
| Socket.io | `apps/api/` (speech.gateway.ts) | ✅ WebSocket pattern established |
| CoordinatorIntegration | `apps/api/src/coordinator-integration/` | ✅ API ↔ Orchestrator bridge |
### What's Missing (Build This)
| Gap | Priority |
| ------------------------------------------------ | -------- |
| Per-agent conversation message log (DB + API) | P0 |
| Per-agent SSE message stream | P0 |
| Barge-in endpoint (`POST /agents/:id/inject`) | P0 |
| Pause / resume endpoints | P1 |
| Subagent tree (parentAgentId on registration) | P0 |
| Agent Provider Interface (plugin API) | P0 |
| OpenClaw adapter (implements provider interface) | P1 |
| Mission Control page (`/mission-control`) | P0 |
| OrchestratorPanel component | P0 |
| Global Agent Roster (tree view) | P0 |
| Audit log (DB + API + UI) | P1 |
---
## Architecture
### Agent Provider Interface
Mosaic defines a standard contract. Any agent runtime that implements this interface integrates natively with Mission Control.
```typescript
// packages/shared/src/agent-provider.interface.ts
export interface AgentSession {
sessionId: string;
parentSessionId?: string; // For subagent tree
provider: string; // "internal" | "openclaw" | "codex" | ...
status: AgentSessionStatus;
taskId?: string;
missionId?: string;
agentType?: string;
spawnedAt: string;
startedAt?: string;
completedAt?: string;
error?: string;
metadata?: Record<string, unknown>;
}
export type AgentSessionStatus =
| "spawning"
| "running"
| "waiting"
| "paused"
| "completed"
| "failed"
| "killed";
export interface AgentMessage {
messageId: string;
sessionId: string;
role: "agent" | "user" | "system" | "operator";
content: string;
timestamp: string;
metadata?: Record<string, unknown>;
}
export interface IAgentProvider {
readonly providerName: string;
/** List all currently active sessions */
listSessions(): Promise<AgentSession[]>;
/** Get a single session's current state */
getSession(sessionId: string): Promise<AgentSession | null>;
/** Get recent messages for a session */
getMessages(sessionId: string, limit?: number): Promise<AgentMessage[]>;
/** Subscribe to a session's message stream. Returns unsubscribe fn. */
subscribeToMessages(sessionId: string, handler: (message: AgentMessage) => void): () => void;
/** Inject an operator message into a running session (barge-in) */
injectMessage(sessionId: string, content: string, operatorId: string): Promise<void>;
/** Pause a running agent session */
pause(sessionId: string): Promise<void>;
/** Resume a paused agent session */
resume(sessionId: string): Promise<void>;
/** Graceful terminate — allow agent to finish current step */
terminate(sessionId: string): Promise<void>;
/** Hard kill — immediate termination */
kill(sessionId: string): Promise<void>;
}
```
### Internal Provider
The existing orchestrator's Docker-based agents implement `IAgentProvider` as the "internal" provider. No behavior change — just wraps existing services behind the interface.
### OpenClaw Provider
Connects to an OpenClaw gateway via its REST API:
- `GET /sessions``listSessions()`
- `GET /sessions/:key/history``getMessages()`
- `POST /sessions/:key/send``injectMessage()`
- OpenClaw SSE or polling → `subscribeToMessages()`
Config per workspace in DB (`AgentProvider` table): gateway URL, API token.
### Provider Registry
```typescript
// apps/api/src/agent-providers/provider-registry.service.ts
@Injectable()
export class AgentProviderRegistry {
register(provider: IAgentProvider): void;
getProvider(name: string): IAgentProvider;
getAllProviders(): IAgentProvider[];
listAllSessions(): Promise<AgentSession[]>; // Aggregates across all providers
}
```
---
## Database Schema
### New Tables
```prisma
// AgentConversationMessage — stores all agent messages for streaming + history
model AgentConversationMessage {
id String @id @default(cuid())
sessionId String // matches agentId in orchestrator
provider String @default("internal") // "internal" | "openclaw" | ...
role String // "agent" | "user" | "system" | "operator"
content String
timestamp DateTime @default(now())
metadata Json @default("{}")
@@index([sessionId, timestamp])
}
// AgentSessionTree — tracks parent/child relationships
model AgentSessionTree {
id String @id @default(cuid())
sessionId String @unique
parentSessionId String?
provider String @default("internal")
missionId String?
taskId String?
agentType String?
status String @default("spawning")
spawnedAt DateTime @default(now())
completedAt DateTime?
metadata Json @default("{}")
@@index([parentSessionId])
@@index([missionId])
}
// AgentProviderConfig — external provider registration per workspace
model AgentProviderConfig {
id String @id @default(cuid())
workspaceId String
name String // "openclaw-prod", "codex-team", ...
provider String // "openclaw" | "codex" | ...
gatewayUrl String
credentials Json @default("{}") // Encrypted via CryptoService
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([workspaceId, name])
}
// OperatorAuditLog — all operator interventions
model OperatorAuditLog {
id String @id @default(cuid())
userId String
sessionId String
provider String
action String // "barge-in" | "kill" | "pause" | "resume" | "kill-all"
content String? // For barge-in: message injected
metadata Json @default("{}")
createdAt DateTime @default(now())
@@index([sessionId])
@@index([userId])
@@index([createdAt])
}
```
---
## API Endpoints
### Orchestrator API — New Endpoints
```
POST /agents/:agentId/inject — Barge-in: inject operator message
POST /agents/:agentId/pause — Pause agent execution
POST /agents/:agentId/resume — Resume paused agent
GET /agents/:agentId/messages — Get message history (paginated)
GET /agents/:agentId/messages/stream — SSE: live message stream for this agent
GET /agents/tree — Full subagent tree (all agents with parent/child)
```
### Main API — New Endpoints
```
# Agent Provider Management
GET /api/agent-providers — List configured providers
POST /api/agent-providers — Register external provider
PATCH /api/agent-providers/:id — Update provider config
DELETE /api/agent-providers/:id — Remove provider
# Unified Session View (aggregates all providers)
GET /api/mission-control/sessions — All active sessions (all providers)
GET /api/mission-control/sessions/:id — Single session details
GET /api/mission-control/sessions/:id/messages — Message history
GET /api/mission-control/sessions/:id/stream — SSE message stream (proxied)
POST /api/mission-control/sessions/:id/inject — Barge-in (proxied to provider)
POST /api/mission-control/sessions/:id/pause — Pause (proxied)
POST /api/mission-control/sessions/:id/resume — Resume (proxied)
POST /api/mission-control/sessions/:id/kill — Kill (proxied)
GET /api/mission-control/tree — Full agent tree (all providers)
GET /api/mission-control/audit — Operator audit log (paginated)
```
### Authorization
All Mission Control endpoints require auth + workspace context.
- `operator` role: full access (read + inject + kill + pause)
- `observer` role: read-only (no inject, no kill, no pause)
- `admin` role: full access + provider config management
---
## Frontend — Mission Control Page
### Route
`/mission-control` — new top-level page in the web app, linked in sidebar under "Orchestration"
### Layout
```
┌─────────────────────────────────────────────────────────────────┐
│ ⚙ MISSION CONTROL [+ Add Panel] [🔴 KILL ALL] │
├──────────────────────────────────────┬──────────────────────────┤
│ │ ACTIVE AGENTS │
│ ┌──────────────┬──────────────┐ │ ▼ ms22 [internal] 🟢 │
│ │ [Panel: ms22]│ [Panel: SAGE]│ │ ├ codex-1 task-api 🟢 │
│ │ 🟢 3 agents │ 🟡 1 agent │ │ ├ codex-2 task-ui 🟢 │
│ │ │ │ │ └ glm-1 task-db 🟡 │
│ │ [chat stream]│ [chat stream]│ │ ▼ SAGE [openclaw] 🟢 │
│ │ │ │ │ └ codex-1 task-prd 🟢 │
│ │ [input ▶] │ [input ▶] │ │ │
│ │ [⚡][⏸][💀] │ [⚡][⏸][💀] │ │ [⏸ pause] [💀 kill] per │
│ └──────────────┴──────────────┘ │ agent │
│ │ │
│ [+ Add Orchestrator Panel] │ [📋 Audit Log] │
└──────────────────────────────────────┴──────────────────────────┘
```
### Components
**MissionControlPage** (`/app/mission-control/page.tsx`)
- Fetches active sessions from `/api/mission-control/sessions`
- Renders N `OrchestratorPanel` in a responsive CSS grid
- Sidebar: `GlobalAgentRoster`
- Header: session count, Kill All button (confirm dialog)
**OrchestratorPanel** (`components/mission-control/OrchestratorPanel.tsx`)
- Props: `sessionId`, `provider`, `title`
- Subscribes to `/api/mission-control/sessions/:id/stream` (SSE)
- Renders scrollable message list (role-tagged, styled by role)
- Input box + Send button (barge-in → `POST /inject`)
- Header: status badge, agent count, elapsed time, ⚡ Barge-In toggle, ⏸ Pause, 💀 Kill
- Expandable to full-screen (modal overlay)
- Color-coded border by status (green/yellow/red/gray)
**GlobalAgentRoster** (`components/mission-control/GlobalAgentRoster.tsx`)
- Fetches `/api/mission-control/tree`
- Renders tree: orch session → indented subagents
- Per-row: provider badge, status dot, task label, elapsed, Kill button
- Real-time updates via polling or SSE events
**BargeInInput** (`components/mission-control/BargeInInput.tsx`)
- Elevated textarea that renders inside a panel
- "Pause before send" checkbox
- Sends to `POST /inject`, shows confirmation
**AuditLogDrawer** (`components/mission-control/AuditLogDrawer.tsx`)
- Slide-in drawer from right
- Paginated table: timestamp, user, action, session, content preview
- Triggered from sidebar "Audit Log" button
**KillAllDialog** (`components/mission-control/KillAllDialog.tsx`)
- Confirmation modal with provider scope selector
- "Kill all internal agents" / "Kill all (all providers)"
- Requires typing "KILL ALL" to confirm
---
## Implementation Phases
### Phase 0 — Foundation (Backend Core)
Backend infrastructure required before any UI work.
| Task | Description | Scope | Est |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------ | --- |
| MS23-P0-001 | Prisma schema: AgentConversationMessage, AgentSessionTree, AgentProviderConfig, OperatorAuditLog — see mosaic-queue note below | api | 15K |
| MS23-P0-002 | Agent message ingestion: wire spawner/lifecycle to write messages to DB | orchestrator | 20K |
| MS23-P0-003 | Orchestrator API: `GET /agents/:id/messages` + SSE stream endpoint | orchestrator | 20K |
| MS23-P0-004 | Orchestrator API: `POST /agents/:id/inject` + pause/resume | orchestrator | 15K |
| MS23-P0-005 | Subagent tree: `parentAgentId` on spawn registration + `GET /agents/tree` | orchestrator | 15K |
| MS23-P0-006 | Unit + integration tests for all P0 orchestrator endpoints | orchestrator | 20K |
**Phase 0 gate:** All orchestrator endpoints tested and green. Per-agent message stream verified via curl/SSE client.
> **mosaic-queue Integration Note**
>
> `mosaic-queue` (`~/src/mosaic-queue`) is a standalone Valkey-backed task registry (CLI + MCP server) that agents use to claim and complete tasks in a pull model. It is complementary to — not a replacement for — the orchestrator's internal `QueueService` (which is push-based agent dispatch).
>
> **Schema impact on MS23-P0-001:**
>
> - `AgentSessionTree.taskId` should be `String?` and optionally reference a mosaic-queue task key
> - Add `AgentSessionTree.taskSource String? @default("internal")` — values: `"internal"` | `"mosaic-queue"` | `"external"`
> - This allows Mission Control's agent roster to resolve task metadata (title, priority, status) from the correct source
>
> **Future integration point:**
> mosaic-queue Phase 3 ("coordinator integration") will wire the coordinator to claim tasks from mosaic-queue and spawn orchestrator agents against them. When that ships, Mission Control will inherit rich task context (title, lane, priority, retry count) from the queue automatically — no rework needed in MS23's data model if `taskSource` is present from the start.
>
> **No blocking dependency:** mosaic-queue Phase 3 is not required for MS23. The `taskSource` field is additive and can be `null` initially.
### Phase 1 — Provider Interface (Plugin Architecture)
| Task | Description | Scope | Est |
| ----------- | ---------------------------------------------------------------------------------------------------------------- | ------ | --- |
| MS23-P1-001 | `IAgentProvider` interface + shared types in `packages/shared` | shared | 10K |
| MS23-P1-002 | `InternalAgentProvider`: wrap existing orchestrator services behind interface | api | 20K |
| MS23-P1-003 | `AgentProviderRegistry`: register/retrieve providers, aggregate listSessions | api | 15K |
| MS23-P1-004 | `AgentProviderConfig` CRUD API (`/api/agent-providers`) | api | 15K |
| MS23-P1-005 | Mission Control proxy API (`/api/mission-control/*`): routes to registry, handles SSE proxying, writes audit log | api | 30K |
| MS23-P1-006 | Unit tests for registry, proxy service, internal provider | api | 20K |
**Phase 1 gate:** Unified `/api/mission-control/sessions` returns sessions from internal provider. Proxy routes correctly to internal provider for kill/pause/inject. Audit log persisted.
### Phase 2 — Mission Control UI
| Task | Description | Scope | Est |
| ----------- | ----------------------------------------------------------------------- | ----- | --- |
| MS23-P2-001 | `/mission-control` page route + layout shell | web | 10K |
| MS23-P2-002 | `OrchestratorPanel` component: SSE message stream, chat display | web | 25K |
| MS23-P2-003 | `BargeInInput` component: inject message, pause-before-send | web | 15K |
| MS23-P2-004 | Panel operator controls: pause, resume, graceful kill, hard kill | web | 15K |
| MS23-P2-005 | `GlobalAgentRoster` sidebar: tree view, per-agent kill | web | 20K |
| MS23-P2-006 | `KillAllDialog`: confirmation modal with scope selector | web | 10K |
| MS23-P2-007 | `AuditLogDrawer`: paginated audit history | web | 15K |
| MS23-P2-008 | Panel grid: responsive layout, add/remove panels, expand to full-screen | web | 20K |
| MS23-P2-009 | Frontend tests (vitest + Playwright E2E for mission control page) | web | 25K |
**Phase 2 gate:** Mission Control page renders with live panels. Barge-in sends and displays. Kill triggers confirmation and removes agent from roster. Audit log shows entries. All tests green.
### Phase 3 — OpenClaw Provider Adapter
| Task | Description | Scope | Est |
| ----------- | ---------------------------------------------------------------------------------- | ------- | --- |
| MS23-P3-001 | `OpenClawProvider`: implement `IAgentProvider` against OpenClaw REST API | api | 25K |
| MS23-P3-002 | OpenClaw session polling / SSE bridge: translate OpenClaw events to `AgentMessage` | api | 20K |
| MS23-P3-003 | Provider config UI: register OpenClaw gateway (URL + API token) in Settings | web | 15K |
| MS23-P3-004 | E2E test: OpenClaw provider registered → sessions appear in Mission Control | api+web | 20K |
**Phase 3 gate:** OpenClaw sessions visible in Mission Control alongside internal agents. Barge-in to OpenClaw session injects message and shows in panel stream.
### Phase 4 — Verification & Release
| Task | Description | Scope | Est |
| ----------- | --------------------------------------------------------------------------------------- | ----- | --- |
| MS23-P4-001 | Full QA: all gates (lint, typecheck, unit, E2E) | stack | 10K |
| MS23-P4-002 | Security review: auth on all new endpoints, audit log integrity, barge-in rate limiting | api | 10K |
| MS23-P4-003 | Deploy to production (mosaic.woltje.com), smoke test with live agents | stack | 5K |
| MS23-P4-004 | Update ROADMAP.md + CHANGELOG.md, tag v0.0.23 | stack | 3K |
---
## Completion Gates (Mandatory)
Per Mosaic E2E delivery framework — a task is NOT done until:
- [ ] Code review (independent review of every changed file)
- [ ] Security review (auth, input validation, error leakage)
- [ ] QA / tests green (`pnpm turbo lint typecheck test`)
- [ ] CI pipeline green after merge
- [ ] Gitea issue closed
- [ ] Docs updated for any API or schema changes
---
## Token Budget Estimate
| Phase | Tasks | Estimate |
| ---------------------------- | ------ | --------- |
| Phase 0 — Backend Core | 6 | ~105K |
| Phase 1 — Provider Interface | 6 | ~110K |
| Phase 2 — Mission Control UI | 9 | ~155K |
| Phase 3 — OpenClaw Adapter | 4 | ~80K |
| Phase 4 — Verification | 4 | ~28K |
| **Total** | **29** | **~478K** |
Recommended split: Codex for UI (Phase 2) and routine API work. Sonnet for provider interface design and complex streaming logic.
---
## Security Considerations
- All Mission Control endpoints require authenticated session + workspace membership
- Barge-in rate-limited: 10 requests/minute per operator per session
- Kill All requires explicit confirmation (UI + double-confirm pattern)
- External provider credentials stored encrypted (AES-256-GCM via CryptoService)
- Audit log is append-only; no delete endpoint
- SSE streams authenticated via session cookie (no unauthenticated streams)
- Operator actions tagged with userId for full traceability
- `observer` role enforced at middleware level — cannot be bypassed by frontend
---
## Open Questions
1. **Panel persistence:** Should the grid layout (which sessions are pinned as panels) be stored in DB per user or in localStorage? Recommend DB for cross-device consistency.
2. **Message retention:** How long to keep `AgentConversationMessage` records? Suggest 30-day default with configurable workspace policy.
3. **OpenClaw barge-in protocol:** Does OpenClaw's `sessions_send` API support injection mid-run, or does it queue behind the current turn? Needs verification against OpenClaw API before MS23-P3-001.
4. **Subagent reporting:** Internal agents currently don't self-report a `parentAgentId` at spawn time. The orchestrator spawner needs to accept this field. Straightforward add to `SpawnAgentDto`.
5. **SSE vs WebSocket for message streaming:** Current orchestrator uses SSE (one-way push). For barge-in confirmation/ack, SSE is sufficient (inject is a separate REST call). No need to upgrade to bidirectional WebSocket for Phase 0-2.
6. **mosaic-queue Phase 3 timing:** mosaic-queue's coordinator integration phase is not yet scheduled. If it ships during MS23 development, the `taskSource` field in `AgentSessionTree` is the integration point — no schema migration required. The Mission Control roster can conditionally render task details from mosaic-queue when `taskSource === "mosaic-queue"` and the queue MCP/API is reachable.
---
## Success Criteria
1. Operator can open Mission Control and see all running orchestrator sessions as live panels
2. Each panel shows the agent's actual conversation messages in real time
3. Operator can type into any panel and inject a message; it appears in the stream tagged `[OPERATOR]`
4. Operator can pause, resume, gracefully terminate, or hard-kill any agent from the panel or roster
5. Global Agent Roster shows the full parent → subagent tree across all providers
6. Kill All button with confirmation terminates all active agents
7. All operator actions appear in the Audit Log with full attribution
8. OpenClaw sessions registered as an external provider appear in Mission Control alongside internal agents
9. `observer` role users can see everything but cannot inject, pause, or kill
10. All CI gates green, deployed to production

View File

@@ -94,15 +94,92 @@ Design doc: `docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md`
PRD: `docs/PRD-MS22-P2-AGENT-FLEET.md` 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 | | Task ID | Status | Phase | Description | Issue | Scope | Branch | Depends On | Blocks | Assigned Worker | Started | Completed | Est Tokens | Act Tokens | Notes |
| ----------- | ----------- | -------- | -------------------------------------------- | -------- | ----- | --------------------------- | ------------- | ------------- | --------------- | ---------- | ---------- | ---------- | ---------- | -------------- | | ----------- | ------ | -------- | -------------------------------------------- | -------- | ----- | --------------------------- | ------------- | ------------- | --------------- | ---------- | ---------- | ---------- | ---------- | --------------- |
| MS22-P2-001 | done | p2-fleet | Prisma schema: AgentTemplate, UserAgent | TASKS:P2 | api | feat/ms22-p2-agent-schema | MS22-P1a | P2-002,P2-003 | orchestrator | 2026-03-04 | 2026-03-04 | 10K | 3K | PR #675 merged | | MS22-P2-001 | done | p2-fleet | Prisma schema: AgentTemplate, UserAgent | TASKS:P2 | api | feat/ms22-p2-agent-schema | MS22-P1a | P2-002,P2-003 | orchestrator | 2026-03-04 | 2026-03-04 | 10K | 3K | PR #675 merged |
| MS22-P2-002 | done | p2-fleet | Seed default agents (jarvis, builder, medic) | TASKS:P2 | api | feat/ms22-p2-agent-seed | P2-001 | P2-004 | orchestrator | 2026-03-04 | 2026-03-04 | 5K | 2K | PR #677 merged | | MS22-P2-002 | done | p2-fleet | Seed default agents (jarvis, builder, medic) | TASKS:P2 | api | feat/ms22-p2-agent-seed | P2-001 | P2-004 | orchestrator | 2026-03-04 | 2026-03-04 | 5K | 2K | PR #677 merged |
| MS22-P2-003 | done | p2-fleet | Agent template CRUD endpoints (admin) | TASKS:P2 | api | feat/ms22-p2-agent-crud | P2-001 | P2-005 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 5K | PR #678 merged | | MS22-P2-003 | done | p2-fleet | Agent template CRUD endpoints (admin) | TASKS:P2 | api | feat/ms22-p2-agent-crud | P2-001 | P2-005 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 5K | PR #678 merged |
| MS22-P2-004 | in-progress | p2-fleet | User agent CRUD endpoints | TASKS:P2 | api | feat/ms22-p2-user-agents | P2-002,P2-003 | P2-006 | orchestrator | 2026-03-04 | | 15K | | | | MS22-P2-004 | done | p2-fleet | User agent CRUD endpoints | TASKS:P2 | api | feat/ms22-p2-user-agents | P2-002,P2-003 | P2-006 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 8K | PR #682 merged |
| MS22-P2-005 | not-started | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-003 | P2-008 | — | — | — | 10K | | | | MS22-P2-005 | done | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-routing | P2-003 | P2-008 | orchestrator | 2026-03-04 | 2026-03-04 | 10K | 5K | PR #684 merged |
| MS22-P2-006 | 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-006 | done | p2-fleet | Agent chat routing (select agent by name) | TASKS:P2 | api | feat/ms22-p2-agent-routing | P2-004 | P2-007 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 5K | PR #684 merged |
| MS22-P2-007 | not-started | p2-fleet | Discord channel → agent routing | TASKS:P2 | api | feat/ms22-p2-discord-router | P2-006 | P2-009 | — | — | — | 15K | | | | MS22-P2-007 | done | p2-fleet | Discord channel → agent routing | TASKS:P2 | api | feat/ms22-p2-discord-router | P2-006 | P2-010 | orchestrator | 2026-03-05 | 2026-03-05 | 15K | 8K | PR #688 |
| MS22-P2-008 | not-started | p2-fleet | Agent list/selector UI in WebUI | TASKS:P2 | web | feat/ms22-p2-agent-ui | P2-005 | — | — | — | — | 15K | | | | MS22-P2-008 | done | p2-fleet | Agent list/selector UI in WebUI | TASKS:P2 | web | feat/ms22-p2-agent-ui | P2-005 | — | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 8K | PR #685 merged |
| MS22-P2-009 | not-started | p2-fleet | Unit tests for agent services | TASKS:P2 | api | test/ms22-p2-agent-tests | P2-007 | P2-010 | — | — | — | 15K | | | | MS22-P2-009 | done | p2-fleet | Unit tests for agent services | TASKS:P2 | api | test/ms22-p2-agent-tests | P2-006 | P2-010 | orchestrator | 2026-03-04 | 2026-03-05 | 15K | 8K | PR #687 merged |
| MS22-P2-010 | not-started | p2-fleet | E2E verification: Discord → agent → response | TASKS:P2 | stack | — | P2-009 | — | — | — | — | 10K | | | | MS22-P2-010 | done | p2-fleet | E2E verification: Discord → agent → response | TASKS:P2 | stack | — | P2-009 | — | orchestrator | 2026-03-05 | 2026-03-05 | 10K | 5K | All gates green |
---
## MS23 — Mission Control Dashboard & Agent Provider Interface
PRD: `docs/PRD-MS23-mission-control.md`
Milestone: `0.0.23`
Target version: `v0.0.23`
> Single-writer: orchestrator (Jarvis/OpenClaw) only. Workers read but never modify.
### Phase 0 — Backend Core (Foundation)
| id | status | milestone | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
| ----------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------ | ----- | ------------ | ---------------------- | ----------------------------------------------- | ----------------------------------------------------------- | ----- | ---------- | ------------ | -------- | ---- | --------------------------------------------- |
| MS23-P0-001 | done | p0-foundation | Prisma schema: AgentConversationMessage, AgentSessionTree, AgentProviderConfig, OperatorAuditLog | #693 | api | feat/ms23-p0-schema | — | MS23-P0-002,MS23-P0-003,MS23-P0-004,MS23-P0-005,MS23-P1-001 | codex | 2026-03-06 | 2026-03-06 | 15K | — | taskSource field per mosaic-queue note in PRD |
| MS23-P0-002 | in-progress | p0-foundation | Agent message ingestion: wire spawner/lifecycle to write messages to DB | #693 | orchestrator | feat/ms23-p0-ingestion | MS23-P0-001 | MS23-P0-006 | codex | 2026-03-06 | — | 20K | — | |
| MS23-P0-003 | not-started | p0-foundation | Orchestrator API: GET /agents/:id/messages + SSE stream endpoint | #693 | orchestrator | feat/ms23-p0-stream | MS23-P0-001 | MS23-P0-006 | — | — | — | 20K | — | |
| MS23-P0-004 | not-started | p0-foundation | Orchestrator API: POST /agents/:id/inject + pause/resume endpoints | #693 | orchestrator | feat/ms23-p0-controls | MS23-P0-001 | MS23-P0-006 | — | — | — | 15K | — | |
| MS23-P0-005 | not-started | p0-foundation | Subagent tree: parentAgentId on spawn registration + GET /agents/tree | #693 | orchestrator | feat/ms23-p0-tree | MS23-P0-001 | MS23-P0-006 | — | — | — | 15K | — | |
| MS23-P0-006 | not-started | p0-foundation | Unit + integration tests for all P0 orchestrator endpoints | #693 | orchestrator | test/ms23-p0 | MS23-P0-002,MS23-P0-003,MS23-P0-004,MS23-P0-005 | MS23-P1-001 | — | — | — | 20K | — | Phase 0 gate: SSE stream verified via curl |
### Phase 1 — Provider Interface (Plugin Architecture)
| id | status | milestone | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
| ----------- | ----------- | ----------- | ----------------------------------------------------------------------------------- | ----- | ------ | ------------------------------ | ----------------------------------- | ----------------------- | ----- | ---------- | ------------ | -------- | ---- | ------------------------------------------------------------------ |
| MS23-P1-001 | not-started | p1-provider | IAgentProvider interface + AgentSession/AgentMessage types in packages/shared | #694 | shared | feat/ms23-p1-interface | MS23-P0-001,MS23-P0-006 | MS23-P1-002,MS23-P1-003 | — | — | — | 10K | — | |
| MS23-P1-002 | not-started | p1-provider | InternalAgentProvider: wrap existing orchestrator services behind IAgentProvider | #694 | api | feat/ms23-p1-internal-provider | MS23-P1-001 | MS23-P1-003,MS23-P1-006 | — | — | — | 20K | — | |
| MS23-P1-003 | not-started | p1-provider | AgentProviderRegistry: register/retrieve providers, aggregate listSessions | #694 | api | feat/ms23-p1-registry | MS23-P1-001,MS23-P1-002 | MS23-P1-004,MS23-P1-005 | — | — | — | 15K | — | |
| MS23-P1-004 | not-started | p1-provider | AgentProviderConfig CRUD API (/api/agent-providers) | #694 | api | feat/ms23-p1-provider-api | MS23-P1-003 | MS23-P1-006 | — | — | — | 15K | — | |
| MS23-P1-005 | not-started | p1-provider | Mission Control proxy API (/api/mission-control/\*): SSE proxying, audit log writes | #694 | api | feat/ms23-p1-proxy | MS23-P1-003 | MS23-P1-006,MS23-P2-001 | — | — | — | 30K | — | Aggregates all providers; most complex P1 task |
| MS23-P1-006 | not-started | p1-provider | Unit tests: registry, proxy service, internal provider | #694 | api | test/ms23-p1 | MS23-P1-002,MS23-P1-004,MS23-P1-005 | MS23-P2-001 | — | — | — | 20K | — | Phase 1 gate: unified /sessions returns internal provider sessions |
### Phase 2 — Mission Control UI
| id | status | milestone | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
| ----------- | ----------- | --------- | ----------------------------------------------------------------------------------- | ----- | ---- | --------------------- | ----------------------------------------------------------------------- | ----------------------- | ----- | ---------- | ------------ | -------- | ---- | ------------------------------------- |
| MS23-P2-001 | not-started | p2-ui | /mission-control page route + layout shell (sidebar + panel grid) | #695 | web | feat/ms23-p2-page | MS23-P1-005,MS23-P1-006 | MS23-P2-002,MS23-P2-005 | — | — | — | 10K | — | |
| MS23-P2-002 | not-started | p2-ui | OrchestratorPanel component: SSE message stream, chat display, role-tagged messages | #695 | web | feat/ms23-p2-panel | MS23-P2-001 | MS23-P2-003,MS23-P2-004 | — | — | — | 25K | — | |
| MS23-P2-003 | not-started | p2-ui | BargeInInput component: inject message, pause-before-send checkbox | #695 | web | feat/ms23-p2-barge | MS23-P2-002 | MS23-P2-009 | — | — | — | 15K | — | |
| MS23-P2-004 | not-started | p2-ui | Panel operator controls: pause, resume, graceful kill, hard kill buttons | #695 | web | feat/ms23-p2-controls | MS23-P2-002 | MS23-P2-009 | — | — | — | 15K | — | |
| MS23-P2-005 | not-started | p2-ui | GlobalAgentRoster sidebar: tree view, per-agent kill buttons | #695 | web | feat/ms23-p2-roster | MS23-P2-001 | MS23-P2-006,MS23-P2-009 | — | — | — | 20K | — | |
| MS23-P2-006 | not-started | p2-ui | KillAllDialog: confirmation modal with scope selector (internal / all providers) | #695 | web | feat/ms23-p2-killall | MS23-P2-005 | MS23-P2-009 | — | — | — | 10K | — | Requires typing "KILL ALL" to confirm |
| MS23-P2-007 | not-started | p2-ui | AuditLogDrawer: paginated audit history slide-in drawer | #695 | web | feat/ms23-p2-audit | MS23-P2-001 | MS23-P2-009 | — | — | — | 15K | — | |
| MS23-P2-008 | not-started | p2-ui | Panel grid: responsive layout, add/remove panels, full-screen expand | #695 | web | feat/ms23-p2-grid | MS23-P2-002 | MS23-P2-009 | — | — | — | 20K | — | |
| MS23-P2-009 | not-started | p2-ui | Frontend tests: vitest unit + Playwright E2E for mission control page | #695 | web | test/ms23-p2 | MS23-P2-003,MS23-P2-004,MS23-P2-005,MS23-P2-006,MS23-P2-007,MS23-P2-008 | MS23-P3-001 | — | — | — | 25K | — | Phase 2 gate |
### Phase 3 — OpenClaw Provider Adapter
| id | status | milestone | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
| ----------- | ----------- | ----------- | -------------------------------------------------------------------------------- | ----- | ------- | ------------------------------ | ----------- | ----------- | ----- | ---------- | ------------ | -------- | ---- | --------------------------------------------------- |
| MS23-P3-001 | not-started | p3-openclaw | OpenClawProvider: implement IAgentProvider against OpenClaw REST API | #696 | api | feat/ms23-p3-openclaw-provider | MS23-P2-009 | MS23-P3-002 | — | — | — | 25K | — | Verify barge-in protocol (see PRD open question #3) |
| MS23-P3-002 | not-started | p3-openclaw | OpenClaw session polling / SSE bridge: translate OpenClaw events to AgentMessage | #696 | api | feat/ms23-p3-openclaw-bridge | MS23-P3-001 | MS23-P3-003 | — | — | — | 20K | — | |
| MS23-P3-003 | not-started | p3-openclaw | Provider config UI: register OpenClaw gateway (URL + API token) in Settings | #696 | web | feat/ms23-p3-config-ui | MS23-P3-002 | MS23-P3-004 | — | — | — | 15K | — | |
| MS23-P3-004 | not-started | p3-openclaw | E2E test: OpenClaw provider registered → sessions appear in Mission Control | #696 | api+web | test/ms23-p3 | MS23-P3-003 | MS23-P4-001 | — | — | — | 20K | — | Phase 3 gate |
### Phase 4 — Verification & Release
| id | status | milestone | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
| ----------- | ----------- | ---------- | --------------------------------------------------------------------------------------- | ----- | ----- | ------ | ----------- | ----------- | ----- | ---------- | ------------ | -------- | ---- | ----- |
| MS23-P4-001 | not-started | p4-release | Full QA: pnpm turbo lint typecheck test — all green | #697 | stack | — | MS23-P3-004 | MS23-P4-002 | — | — | — | 10K | — | |
| MS23-P4-002 | not-started | p4-release | Security review: auth on all new endpoints, audit log integrity, barge-in rate limiting | #697 | api | — | MS23-P4-001 | MS23-P4-003 | — | — | — | 10K | — | |
| MS23-P4-003 | not-started | p4-release | Deploy to production (mosaic.woltje.com), smoke test with live agents | #697 | stack | — | MS23-P4-002 | MS23-P4-004 | — | — | — | 5K | — | |
| MS23-P4-004 | not-started | p4-release | Update ROADMAP.md + CHANGELOG.md, tag v0.0.23 | #697 | stack | — | MS23-P4-003 | — | — | — | — | 3K | — | |
### MS23 Budget Summary
| Phase | Tasks | Estimate |
| ---------------------------- | ------ | --------- |
| Phase 0 — Backend Core | 6 | ~105K |
| Phase 1 — Provider Interface | 6 | ~110K |
| Phase 2 — Mission Control UI | 9 | ~155K |
| Phase 3 — OpenClaw Adapter | 4 | ~80K |
| Phase 4 — Verification | 4 | ~28K |
| **Total** | **29** | **~478K** |
Recommended dispatch: Codex for Phase 2 UI + routine API tasks; Sonnet for complex streaming logic (P0-003, P1-005, P3-002).

View File

@@ -13,10 +13,25 @@
## Session Log ## Session Log
| Session | Date | Milestone | Tasks Done | Outcome | | Session | Date | Milestone | Tasks Done | Outcome |
| ------- | ---------- | ----------- | ---------------------- | ---------------------------------------------------------------------------------------- | | ------- | ---------- | --------- | ---------------------- | --------------------------------------------------------------------------------------------- |
| 2 | 2026-03-04 | M3-UserCRUD | P2-004 in-progress | Fixed CI security audit (multer>=2.1.1), merged PR #678 (Admin CRUD), starting User CRUD | | 5 | 2026-03-05 | M6 | P2-010 done | E2E verification: 3547 tests pass, all CI gates green. Mission complete. |
| 1 | 2026-03-04 | M1+M2 | P2-001, P2-002, P2-003 | Schema, seed, and Admin CRUD complete | | 4 | 2026-03-05 | M5+M6 | P2-007 done | Discord channel→agent routing. Fixed lint/type errors. PR #688 merged. 9/10 tasks done. |
| 3 | 2026-03-05 | M4+M5 | P2-008 done | Fixed corrupted AgentSelector.tsx, integrated into Chat.tsx. PR #685 merged. 8/10 tasks done. |
| 2 | 2026-03-04 | M1+M2+M3 | P2-004 done | Fixed CI security audit, merged PRs #681, #678, #682. Milestones 1-3 complete. |
| 1 | 2026-03-04 | M1+M2 | P2-001, P2-002, P2-003 | Schema, seed, and Admin CRUD complete |
## Mission Complete
All 10 tasks completed. Success criteria verified:
1. ✅ AgentTemplate and UserAgent tables in Prisma schema
2. ✅ Admin CRUD at /admin/agent-templates
3. ✅ User CRUD at /api/agents
4. ✅ Chat proxy routes by agent name
5. ✅ Discord channel routing via DISCORD_AGENT_CHANNELS
6. ✅ WebUI AgentSelector component
7. ✅ 3547 tests passing, CI green
## Open Questions ## Open Questions

21
pnpm-lock.yaml generated
View File

@@ -337,6 +337,9 @@ importers:
'@nestjs/throttler': '@nestjs/throttler':
specifier: ^6.5.0 specifier: ^6.5.0
version: 6.5.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(reflect-metadata@0.2.2) version: 6.5.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(reflect-metadata@0.2.2)
'@prisma/client':
specifier: ^6.19.2
version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
bullmq: bullmq:
specifier: ^5.67.2 specifier: ^5.67.2
version: 5.67.2 version: 5.67.2
@@ -8005,7 +8008,7 @@ snapshots:
chalk: 5.6.2 chalk: 5.6.2
commander: 12.1.0 commander: 12.1.0
dotenv: 17.2.4 dotenv: 17.2.4
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
open: 10.2.0 open: 10.2.0
pg: 8.17.2 pg: 8.17.2
prettier: 3.8.1 prettier: 3.8.1
@@ -11345,7 +11348,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) '@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
better-sqlite3: 12.6.2 better-sqlite3: 12.6.2
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pg: 8.17.2 pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -11370,7 +11373,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) '@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
better-sqlite3: 12.6.2 better-sqlite3: 12.6.2
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pg: 8.17.2 pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -12194,17 +12197,6 @@ snapshots:
dotenv@17.2.4: {} dotenv@17.2.4: {}
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
'@types/pg': 8.16.0
better-sqlite3: 12.6.2
kysely: 0.28.10
pg: 8.17.2
postgres: 3.4.8
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)): drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
@@ -12215,7 +12207,6 @@ snapshots:
pg: 8.17.2 pg: 8.17.2
postgres: 3.4.8 postgres: 3.4.8
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
optional: true
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies: