Compare commits

..

1 Commits

Author SHA1 Message Date
156ea1438b test(ms22-p2): add unit tests for agent services
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Add UserAgentService tests (14 tests covering CRUD, status, templates)
- Add ChatProxyService agent routing tests (7 tests covering agent config, fallbacks, errors)

Task: MS22-P2-009

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:38:00 -06:00
96 changed files with 89 additions and 8657 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,6 @@ FROM base AS deps
COPY packages/shared/package.json ./packages/shared/
COPY packages/config/package.json ./packages/config/
COPY apps/orchestrator/package.json ./apps/orchestrator/
# API schema is available via apps/orchestrator/prisma/schema.prisma symlink
# Copy npm configuration for native binary architecture hints
COPY .npmrc ./
@@ -47,15 +46,6 @@ COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_module
COPY --from=deps /app/packages/config/node_modules ./packages/config/node_modules
COPY --from=deps /app/apps/orchestrator/node_modules ./apps/orchestrator/node_modules
# The repo has apps/orchestrator/prisma/schema.prisma as a symlink for CI use.
# Kaniko resolves destination symlinks on COPY, which fails because the symlink
# target (../../api/prisma/schema.prisma) doesn't exist in the container.
# Fix: remove the dangling symlink first, then copy the real schema file there.
RUN rm -f apps/orchestrator/prisma/schema.prisma
COPY apps/api/prisma/schema.prisma ./apps/orchestrator/prisma/schema.prisma
# pnpm turbo build runs prisma:generate (--schema=./prisma/schema.prisma) from the
# orchestrator package context — no cross-package project-root issues.
# Build the orchestrator app using TurboRepo
RUN pnpm turbo build --filter=@mosaic/orchestrator

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,37 +0,0 @@
import { plainToInstance } from "class-transformer";
import { validate } from "class-validator";
import { describe, expect, it } from "vitest";
import { GetMessagesQueryDto } from "./get-messages-query.dto";
describe("GetMessagesQueryDto", () => {
it("should use defaults when empty", async () => {
const dto = plainToInstance(GetMessagesQueryDto, {});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.limit).toBe(50);
expect(dto.skip).toBe(0);
});
it("should reject limit greater than 200", async () => {
const dto = plainToInstance(GetMessagesQueryDto, {
limit: 201,
skip: 0,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some((error) => error.property === "limit")).toBe(true);
});
it("should reject negative skip", async () => {
const dto = plainToInstance(GetMessagesQueryDto, {
limit: 50,
skip: -1,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some((error) => error.property === "skip")).toBe(true);
});
});

View File

@@ -1,17 +0,0 @@
import { Type } from "class-transformer";
import { IsInt, IsOptional, Max, Min } from "class-validator";
export class GetMessagesQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
limit = 50;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
skip = 0;
}

View File

@@ -1,7 +0,0 @@
import { IsNotEmpty, IsString } from "class-validator";
export class InjectAgentDto {
@IsString()
@IsNotEmpty()
message!: string;
}

View File

@@ -116,10 +116,6 @@ export class SpawnAgentDto {
@IsOptional()
@IsIn(["strict", "standard", "minimal", "custom"])
gateProfile?: GateProfileType;
@IsOptional()
@IsString()
parentAgentId?: string;
}
/**

View File

@@ -1,216 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentConversationMessage, AgentSessionTree } from "@prisma/client";
import { AgentControlService } from "./agent-control.service";
import { AgentMessagesService } from "./agent-messages.service";
import { AgentTreeService } from "./agent-tree.service";
import { InternalAgentProvider } from "./internal-agent.provider";
describe("InternalAgentProvider", () => {
let provider: InternalAgentProvider;
let messagesService: {
getMessages: ReturnType<typeof vi.fn>;
getReplayMessages: ReturnType<typeof vi.fn>;
getMessagesAfter: ReturnType<typeof vi.fn>;
};
let controlService: {
injectMessage: ReturnType<typeof vi.fn>;
pauseAgent: ReturnType<typeof vi.fn>;
resumeAgent: ReturnType<typeof vi.fn>;
killAgent: ReturnType<typeof vi.fn>;
};
let treeService: {
listSessions: ReturnType<typeof vi.fn>;
getSession: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
messagesService = {
getMessages: vi.fn(),
getReplayMessages: vi.fn(),
getMessagesAfter: vi.fn(),
};
controlService = {
injectMessage: vi.fn().mockResolvedValue(undefined),
pauseAgent: vi.fn().mockResolvedValue(undefined),
resumeAgent: vi.fn().mockResolvedValue(undefined),
killAgent: vi.fn().mockResolvedValue(undefined),
};
treeService = {
listSessions: vi.fn(),
getSession: vi.fn(),
};
provider = new InternalAgentProvider(
messagesService as unknown as AgentMessagesService,
controlService as unknown as AgentControlService,
treeService as unknown as AgentTreeService
);
});
it("maps paginated sessions", async () => {
const sessionEntry: AgentSessionTree = {
id: "tree-1",
sessionId: "session-1",
parentSessionId: "parent-1",
provider: "internal",
missionId: null,
taskId: "task-123",
taskSource: "queue",
agentType: "worker",
status: "running",
spawnedAt: new Date("2026-03-07T10:00:00.000Z"),
completedAt: null,
metadata: { branch: "feat/test" },
};
treeService.listSessions.mockResolvedValue({
sessions: [sessionEntry],
total: 1,
cursor: "next-cursor",
});
const result = await provider.listSessions("cursor-1", 25);
expect(treeService.listSessions).toHaveBeenCalledWith("cursor-1", 25);
expect(result).toEqual({
sessions: [
{
id: "session-1",
providerId: "internal",
providerType: "internal",
label: "task-123",
status: "active",
parentSessionId: "parent-1",
createdAt: new Date("2026-03-07T10:00:00.000Z"),
updatedAt: new Date("2026-03-07T10:00:00.000Z"),
metadata: { branch: "feat/test" },
},
],
total: 1,
cursor: "next-cursor",
});
});
it("returns null for missing session", async () => {
treeService.getSession.mockResolvedValue(null);
const result = await provider.getSession("missing-session");
expect(treeService.getSession).toHaveBeenCalledWith("missing-session");
expect(result).toBeNull();
});
it("maps message history and parses skip cursor", async () => {
const message: AgentConversationMessage = {
id: "msg-1",
sessionId: "session-1",
provider: "internal",
role: "agent",
content: "hello",
timestamp: new Date("2026-03-07T10:05:00.000Z"),
metadata: { tokens: 42 },
};
messagesService.getMessages.mockResolvedValue({
messages: [message],
total: 10,
});
const result = await provider.getMessages("session-1", 30, "2");
expect(messagesService.getMessages).toHaveBeenCalledWith("session-1", 30, 2);
expect(result).toEqual([
{
id: "msg-1",
sessionId: "session-1",
role: "assistant",
content: "hello",
timestamp: new Date("2026-03-07T10:05:00.000Z"),
metadata: { tokens: 42 },
},
]);
});
it("routes control operations through AgentControlService", async () => {
const injectResult = await provider.injectMessage("session-1", "new instruction");
await provider.pauseSession("session-1");
await provider.resumeSession("session-1");
await provider.killSession("session-1", false);
expect(controlService.injectMessage).toHaveBeenCalledWith(
"session-1",
"internal-provider",
"new instruction"
);
expect(injectResult).toEqual({ accepted: true });
expect(controlService.pauseAgent).toHaveBeenCalledWith("session-1", "internal-provider");
expect(controlService.resumeAgent).toHaveBeenCalledWith("session-1", "internal-provider");
expect(controlService.killAgent).toHaveBeenCalledWith("session-1", "internal-provider", false);
});
it("streams replay and incremental messages", async () => {
const replayMessage: AgentConversationMessage = {
id: "msg-replay",
sessionId: "session-1",
provider: "internal",
role: "agent",
content: "replay",
timestamp: new Date("2026-03-07T10:00:00.000Z"),
metadata: {},
};
const incrementalMessage: AgentConversationMessage = {
id: "msg-live",
sessionId: "session-1",
provider: "internal",
role: "operator",
content: "live",
timestamp: new Date("2026-03-07T10:00:01.000Z"),
metadata: {},
};
messagesService.getReplayMessages.mockResolvedValue([replayMessage]);
messagesService.getMessagesAfter
.mockResolvedValueOnce([incrementalMessage])
.mockResolvedValueOnce([]);
const iterator = provider.streamMessages("session-1")[Symbol.asyncIterator]();
const first = await iterator.next();
const second = await iterator.next();
expect(first.done).toBe(false);
expect(first.value).toEqual({
id: "msg-replay",
sessionId: "session-1",
role: "assistant",
content: "replay",
timestamp: new Date("2026-03-07T10:00:00.000Z"),
metadata: {},
});
expect(second.done).toBe(false);
expect(second.value).toEqual({
id: "msg-live",
sessionId: "session-1",
role: "user",
content: "live",
timestamp: new Date("2026-03-07T10:00:01.000Z"),
metadata: {},
});
await iterator.return?.();
expect(messagesService.getReplayMessages).toHaveBeenCalledWith("session-1", 50);
expect(messagesService.getMessagesAfter).toHaveBeenCalledWith(
"session-1",
new Date("2026-03-07T10:00:00.000Z"),
"msg-replay"
);
});
it("reports provider availability", async () => {
await expect(provider.isAvailable()).resolves.toBe(true);
});
});

View File

@@ -1,218 +0,0 @@
import { Injectable } from "@nestjs/common";
import type {
AgentMessage,
AgentMessageRole,
AgentSession,
AgentSessionList,
AgentSessionStatus,
IAgentProvider,
InjectResult,
} from "@mosaic/shared";
import type { AgentConversationMessage, AgentSessionTree } from "@prisma/client";
import { AgentControlService } from "./agent-control.service";
import { AgentMessagesService } from "./agent-messages.service";
import { AgentTreeService } from "./agent-tree.service";
const DEFAULT_SESSION_LIMIT = 50;
const DEFAULT_MESSAGE_LIMIT = 50;
const MAX_MESSAGE_LIMIT = 200;
const STREAM_POLL_INTERVAL_MS = 1000;
const INTERNAL_OPERATOR_ID = "internal-provider";
@Injectable()
export class InternalAgentProvider implements IAgentProvider {
readonly providerId = "internal";
readonly providerType = "internal";
readonly displayName = "Internal Orchestrator";
constructor(
private readonly messagesService: AgentMessagesService,
private readonly controlService: AgentControlService,
private readonly treeService: AgentTreeService
) {}
async listSessions(cursor?: string, limit = DEFAULT_SESSION_LIMIT): Promise<AgentSessionList> {
const {
sessions,
total,
cursor: nextCursor,
} = await this.treeService.listSessions(cursor, limit);
return {
sessions: sessions.map((session) => this.toAgentSession(session)),
total,
...(nextCursor !== undefined ? { cursor: nextCursor } : {}),
};
}
async getSession(sessionId: string): Promise<AgentSession | null> {
const session = await this.treeService.getSession(sessionId);
return session ? this.toAgentSession(session) : null;
}
async getMessages(
sessionId: string,
limit = DEFAULT_MESSAGE_LIMIT,
before?: string
): Promise<AgentMessage[]> {
const safeLimit = this.normalizeMessageLimit(limit);
const skip = this.parseSkip(before);
const result = await this.messagesService.getMessages(sessionId, safeLimit, skip);
return result.messages.map((message) => this.toAgentMessage(message));
}
async injectMessage(sessionId: string, content: string): Promise<InjectResult> {
await this.controlService.injectMessage(sessionId, INTERNAL_OPERATOR_ID, content);
return {
accepted: true,
};
}
async pauseSession(sessionId: string): Promise<void> {
await this.controlService.pauseAgent(sessionId, INTERNAL_OPERATOR_ID);
}
async resumeSession(sessionId: string): Promise<void> {
await this.controlService.resumeAgent(sessionId, INTERNAL_OPERATOR_ID);
}
async killSession(sessionId: string, force = true): Promise<void> {
await this.controlService.killAgent(sessionId, INTERNAL_OPERATOR_ID, force);
}
async *streamMessages(sessionId: string): AsyncIterable<AgentMessage> {
const replayMessages = await this.messagesService.getReplayMessages(
sessionId,
DEFAULT_MESSAGE_LIMIT
);
let lastSeenTimestamp = new Date();
let lastSeenMessageId: string | null = null;
for (const message of replayMessages) {
yield this.toAgentMessage(message);
lastSeenTimestamp = message.timestamp;
lastSeenMessageId = message.id;
}
for (;;) {
const newMessages = await this.messagesService.getMessagesAfter(
sessionId,
lastSeenTimestamp,
lastSeenMessageId
);
for (const message of newMessages) {
yield this.toAgentMessage(message);
lastSeenTimestamp = message.timestamp;
lastSeenMessageId = message.id;
}
await this.delay(STREAM_POLL_INTERVAL_MS);
}
}
isAvailable(): Promise<boolean> {
return Promise.resolve(true);
}
private toAgentSession(session: AgentSessionTree): AgentSession {
const metadata = this.toMetadata(session.metadata);
return {
id: session.sessionId,
providerId: this.providerId,
providerType: this.providerType,
...(session.taskId !== null ? { label: session.taskId } : {}),
status: this.toSessionStatus(session.status),
...(session.parentSessionId !== null ? { parentSessionId: session.parentSessionId } : {}),
createdAt: session.spawnedAt,
updatedAt: session.completedAt ?? session.spawnedAt,
...(metadata !== undefined ? { metadata } : {}),
};
}
private toAgentMessage(message: AgentConversationMessage): AgentMessage {
const metadata = this.toMetadata(message.metadata);
return {
id: message.id,
sessionId: message.sessionId,
role: this.toMessageRole(message.role),
content: message.content,
timestamp: message.timestamp,
...(metadata !== undefined ? { metadata } : {}),
};
}
private toSessionStatus(status: string): AgentSessionStatus {
switch (status) {
case "running":
return "active";
case "paused":
return "paused";
case "completed":
return "completed";
case "failed":
case "killed":
return "failed";
case "spawning":
default:
return "idle";
}
}
private toMessageRole(role: string): AgentMessageRole {
switch (role) {
case "agent":
case "assistant":
return "assistant";
case "system":
return "system";
case "tool":
return "tool";
case "operator":
case "user":
default:
return "user";
}
}
private normalizeMessageLimit(limit: number): number {
const normalized = Number.isFinite(limit) ? Math.trunc(limit) : DEFAULT_MESSAGE_LIMIT;
if (normalized < 1) {
return 1;
}
return Math.min(normalized, MAX_MESSAGE_LIMIT);
}
private parseSkip(before?: string): number {
if (!before) {
return 0;
}
const parsed = Number.parseInt(before, 10);
if (Number.isNaN(parsed) || parsed < 0) {
return 0;
}
return parsed;
}
private toMetadata(value: unknown): Record<string, unknown> | undefined {
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return undefined;
}
private async delay(ms: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
}

View File

@@ -1,21 +0,0 @@
import { Type } from "class-transformer";
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
export class GetMissionControlAuditLogQueryDto {
@IsOptional()
@IsString()
sessionId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
limit = 50;
}

View File

@@ -1,15 +0,0 @@
import { Type } from "class-transformer";
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
export class GetMissionControlMessagesQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
limit?: number;
@IsOptional()
@IsString()
before?: string;
}

View File

@@ -1,7 +0,0 @@
import { IsBoolean, IsOptional } from "class-validator";
export class KillSessionDto {
@IsOptional()
@IsBoolean()
force?: boolean;
}

View File

@@ -1,67 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentSession } from "@mosaic/shared";
import type { PrismaService } from "../../prisma/prisma.service";
import { AgentProviderRegistry } from "../agents/agent-provider.registry";
import { MissionControlController } from "./mission-control.controller";
import { MissionControlService } from "./mission-control.service";
describe("MissionControlController", () => {
let controller: MissionControlController;
let registry: {
listAllSessions: ReturnType<typeof vi.fn>;
getProviderForSession: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
registry = {
listAllSessions: vi.fn(),
getProviderForSession: vi.fn(),
};
const prisma = {
operatorAuditLog: {
create: vi.fn().mockResolvedValue(undefined),
},
};
const service = new MissionControlService(
registry as unknown as AgentProviderRegistry,
prisma as unknown as PrismaService
);
controller = new MissionControlController(service);
});
it("Phase 1 gate: unified sessions endpoint returns internal provider sessions", async () => {
const internalSession: AgentSession = {
id: "session-internal-1",
providerId: "internal",
providerType: "internal",
status: "active",
createdAt: new Date("2026-03-07T20:00:00.000Z"),
updatedAt: new Date("2026-03-07T20:01:00.000Z"),
};
const externalSession: AgentSession = {
id: "session-openclaw-1",
providerId: "openclaw",
providerType: "external",
status: "active",
createdAt: new Date("2026-03-07T20:02:00.000Z"),
updatedAt: new Date("2026-03-07T20:03:00.000Z"),
};
registry.listAllSessions.mockResolvedValue([internalSession, externalSession]);
const response = await controller.listSessions();
expect(registry.listAllSessions).toHaveBeenCalledTimes(1);
expect(response.sessions).toEqual([internalSession, externalSession]);
expect(response.sessions).toContainEqual(
expect.objectContaining({
id: "session-internal-1",
providerId: "internal",
})
);
});
});

View File

@@ -1,192 +0,0 @@
import {
Body,
Controller,
Get,
Header,
HttpCode,
MessageEvent,
Param,
Post,
Query,
Request,
Sse,
UseGuards,
UsePipes,
ValidationPipe,
} from "@nestjs/common";
import type { AgentMessage, AgentSession, InjectResult } from "@mosaic/shared";
import { Observable } from "rxjs";
import { AuthGuard } from "../../auth/guards/auth.guard";
import { InjectAgentDto } from "../agents/dto/inject-agent.dto";
import { GetMissionControlAuditLogQueryDto } from "./dto/get-mission-control-audit-log-query.dto";
import { GetMissionControlMessagesQueryDto } from "./dto/get-mission-control-messages-query.dto";
import { KillSessionDto } from "./dto/kill-session.dto";
import { MissionControlService, type MissionControlAuditLogPage } from "./mission-control.service";
const DEFAULT_OPERATOR_ID = "mission-control";
interface MissionControlRequest {
user?: {
id?: string;
};
}
@Controller("api/mission-control")
@UseGuards(AuthGuard)
export class MissionControlController {
constructor(private readonly missionControlService: MissionControlService) {}
@Get("sessions")
async listSessions(): Promise<{ sessions: AgentSession[] }> {
const sessions = await this.missionControlService.listSessions();
return { sessions };
}
@Get("sessions/:sessionId")
getSession(@Param("sessionId") sessionId: string): Promise<AgentSession> {
return this.missionControlService.getSession(sessionId);
}
@Get("sessions/:sessionId/messages")
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getMessages(
@Param("sessionId") sessionId: string,
@Query() query: GetMissionControlMessagesQueryDto
): Promise<{ messages: AgentMessage[] }> {
const messages = await this.missionControlService.getMessages(
sessionId,
query.limit,
query.before
);
return { messages };
}
@Get("audit-log")
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
getAuditLog(
@Query() query: GetMissionControlAuditLogQueryDto
): Promise<MissionControlAuditLogPage> {
return this.missionControlService.getAuditLog(query.sessionId, query.page, query.limit);
}
@Post("sessions/:sessionId/inject")
@HttpCode(200)
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
injectMessage(
@Param("sessionId") sessionId: string,
@Body() dto: InjectAgentDto,
@Request() req: MissionControlRequest
): Promise<InjectResult> {
return this.missionControlService.injectMessage(
sessionId,
dto.message,
this.resolveOperatorId(req)
);
}
@Post("sessions/:sessionId/pause")
@HttpCode(200)
async pauseSession(
@Param("sessionId") sessionId: string,
@Request() req: MissionControlRequest
): Promise<{ message: string }> {
await this.missionControlService.pauseSession(sessionId, this.resolveOperatorId(req));
return { message: `Session ${sessionId} paused` };
}
@Post("sessions/:sessionId/resume")
@HttpCode(200)
async resumeSession(
@Param("sessionId") sessionId: string,
@Request() req: MissionControlRequest
): Promise<{ message: string }> {
await this.missionControlService.resumeSession(sessionId, this.resolveOperatorId(req));
return { message: `Session ${sessionId} resumed` };
}
@Post("sessions/:sessionId/kill")
@HttpCode(200)
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async killSession(
@Param("sessionId") sessionId: string,
@Body() dto: KillSessionDto,
@Request() req: MissionControlRequest
): Promise<{ message: string }> {
await this.missionControlService.killSession(
sessionId,
dto.force ?? true,
this.resolveOperatorId(req)
);
return { message: `Session ${sessionId} killed` };
}
@Sse("sessions/:sessionId/stream")
@Header("Content-Type", "text/event-stream")
@Header("Cache-Control", "no-cache")
streamSessionMessages(@Param("sessionId") sessionId: string): Observable<MessageEvent> {
return new Observable<MessageEvent>((subscriber) => {
let isClosed = false;
let iterator: AsyncIterator<AgentMessage> | null = null;
void this.missionControlService
.streamMessages(sessionId)
.then(async (stream) => {
iterator = stream[Symbol.asyncIterator]();
for (;;) {
if (isClosed) {
break;
}
const next = (await iterator.next()) as { done: boolean; value: AgentMessage };
if (next.done) {
break;
}
subscriber.next({
data: this.toStreamPayload(next.value),
});
}
subscriber.complete();
})
.catch((error: unknown) => {
subscriber.error(error);
});
return () => {
isClosed = true;
void iterator?.return?.();
};
});
}
private resolveOperatorId(req: MissionControlRequest): string {
const operatorId = req.user?.id;
return typeof operatorId === "string" && operatorId.length > 0
? operatorId
: DEFAULT_OPERATOR_ID;
}
private toStreamPayload(message: AgentMessage): {
id: string;
sessionId: string;
role: string;
content: string;
timestamp: string;
metadata?: Record<string, unknown>;
} {
return {
id: message.id,
sessionId: message.sessionId,
role: message.role,
content: message.content,
timestamp: message.timestamp.toISOString(),
...(message.metadata !== undefined ? { metadata: message.metadata } : {}),
};
}
}

View File

@@ -1,13 +0,0 @@
import { Module } from "@nestjs/common";
import { AgentsModule } from "../agents/agents.module";
import { AuthModule } from "../../auth/auth.module";
import { PrismaModule } from "../../prisma/prisma.module";
import { MissionControlController } from "./mission-control.controller";
import { MissionControlService } from "./mission-control.service";
@Module({
imports: [AgentsModule, AuthModule, PrismaModule],
controllers: [MissionControlController],
providers: [MissionControlService],
})
export class MissionControlModule {}

View File

@@ -1,213 +0,0 @@
import { NotFoundException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentMessage, AgentSession, IAgentProvider, InjectResult } from "@mosaic/shared";
import type { PrismaService } from "../../prisma/prisma.service";
import { AgentProviderRegistry } from "../agents/agent-provider.registry";
import { MissionControlService } from "./mission-control.service";
type MockProvider = IAgentProvider & {
listSessions: ReturnType<typeof vi.fn>;
getSession: ReturnType<typeof vi.fn>;
getMessages: ReturnType<typeof vi.fn>;
injectMessage: ReturnType<typeof vi.fn>;
pauseSession: ReturnType<typeof vi.fn>;
resumeSession: ReturnType<typeof vi.fn>;
killSession: ReturnType<typeof vi.fn>;
streamMessages: ReturnType<typeof vi.fn>;
};
const emptyMessageStream = async function* (): AsyncIterable<AgentMessage> {
return;
};
const createProvider = (providerId = "internal"): MockProvider => ({
providerId,
providerType: providerId,
displayName: providerId,
listSessions: vi.fn().mockResolvedValue({ sessions: [], total: 0 }),
getSession: vi.fn().mockResolvedValue(null),
getMessages: vi.fn().mockResolvedValue([]),
injectMessage: vi.fn().mockResolvedValue({ accepted: true } as InjectResult),
pauseSession: vi.fn().mockResolvedValue(undefined),
resumeSession: vi.fn().mockResolvedValue(undefined),
killSession: vi.fn().mockResolvedValue(undefined),
streamMessages: vi.fn().mockReturnValue(emptyMessageStream()),
isAvailable: vi.fn().mockResolvedValue(true),
});
describe("MissionControlService", () => {
let service: MissionControlService;
let registry: {
listAllSessions: ReturnType<typeof vi.fn>;
getProviderForSession: ReturnType<typeof vi.fn>;
};
let prisma: {
operatorAuditLog: {
create: ReturnType<typeof vi.fn>;
};
};
const session: AgentSession = {
id: "session-1",
providerId: "internal",
providerType: "internal",
status: "active",
createdAt: new Date("2026-03-07T14:00:00.000Z"),
updatedAt: new Date("2026-03-07T14:01:00.000Z"),
};
beforeEach(() => {
registry = {
listAllSessions: vi.fn().mockResolvedValue([session]),
getProviderForSession: vi.fn().mockResolvedValue(null),
};
prisma = {
operatorAuditLog: {
create: vi.fn().mockResolvedValue(undefined),
},
};
service = new MissionControlService(
registry as unknown as AgentProviderRegistry,
prisma as unknown as PrismaService
);
});
it("lists sessions from the registry", async () => {
await expect(service.listSessions()).resolves.toEqual([session]);
expect(registry.listAllSessions).toHaveBeenCalledTimes(1);
});
it("returns a session when it is found", async () => {
const provider = createProvider("internal");
registry.getProviderForSession.mockResolvedValue({ provider, session });
await expect(service.getSession(session.id)).resolves.toEqual(session);
});
it("throws NotFoundException when session lookup fails", async () => {
await expect(service.getSession("missing-session")).rejects.toBeInstanceOf(NotFoundException);
});
it("gets messages from the resolved provider", async () => {
const provider = createProvider("openclaw");
const messages: AgentMessage[] = [
{
id: "message-1",
sessionId: session.id,
role: "assistant",
content: "hello",
timestamp: new Date("2026-03-07T14:01:00.000Z"),
},
];
provider.getMessages.mockResolvedValue(messages);
registry.getProviderForSession.mockResolvedValue({ provider, session });
await expect(service.getMessages(session.id, 25, "10")).resolves.toEqual(messages);
expect(provider.getMessages).toHaveBeenCalledWith(session.id, 25, "10");
});
it("injects a message and writes an audit log", async () => {
const provider = createProvider("internal");
const injectResult: InjectResult = { accepted: true, messageId: "msg-1" };
provider.injectMessage.mockResolvedValue(injectResult);
registry.getProviderForSession.mockResolvedValue({ provider, session });
await expect(service.injectMessage(session.id, "ship it", "operator-1")).resolves.toEqual(
injectResult
);
expect(provider.injectMessage).toHaveBeenCalledWith(session.id, "ship it");
expect(prisma.operatorAuditLog.create).toHaveBeenCalledWith({
data: {
sessionId: session.id,
userId: "operator-1",
provider: "internal",
action: "inject",
content: "ship it",
metadata: {
payload: { message: "ship it" },
},
},
});
});
it("pauses and resumes using default operator id", async () => {
const provider = createProvider("openclaw");
registry.getProviderForSession.mockResolvedValue({ provider, session });
await service.pauseSession(session.id);
await service.resumeSession(session.id);
expect(provider.pauseSession).toHaveBeenCalledWith(session.id);
expect(provider.resumeSession).toHaveBeenCalledWith(session.id);
expect(prisma.operatorAuditLog.create).toHaveBeenNthCalledWith(1, {
data: {
sessionId: session.id,
userId: "mission-control",
provider: "openclaw",
action: "pause",
metadata: {
payload: {},
},
},
});
expect(prisma.operatorAuditLog.create).toHaveBeenNthCalledWith(2, {
data: {
sessionId: session.id,
userId: "mission-control",
provider: "openclaw",
action: "resume",
metadata: {
payload: {},
},
},
});
});
it("kills with provided force value and writes audit log", async () => {
const provider = createProvider("openclaw");
registry.getProviderForSession.mockResolvedValue({ provider, session });
await service.killSession(session.id, false, "operator-2");
expect(provider.killSession).toHaveBeenCalledWith(session.id, false);
expect(prisma.operatorAuditLog.create).toHaveBeenCalledWith({
data: {
sessionId: session.id,
userId: "operator-2",
provider: "openclaw",
action: "kill",
metadata: {
payload: { force: false },
},
},
});
});
it("resolves provider message stream", async () => {
const provider = createProvider("internal");
const messageStream = (async function* (): AsyncIterable<AgentMessage> {
yield {
id: "message-1",
sessionId: session.id,
role: "assistant",
content: "stream",
timestamp: new Date("2026-03-07T14:03:00.000Z"),
};
})();
provider.streamMessages.mockReturnValue(messageStream);
registry.getProviderForSession.mockResolvedValue({ provider, session });
await expect(service.streamMessages(session.id)).resolves.toBe(messageStream);
expect(provider.streamMessages).toHaveBeenCalledWith(session.id);
});
it("does not write audit log when session cannot be resolved", async () => {
await expect(service.pauseSession("missing-session")).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.operatorAuditLog.create).not.toHaveBeenCalled();
});
});

View File

@@ -1,186 +0,0 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import type { AgentMessage, AgentSession, IAgentProvider, InjectResult } from "@mosaic/shared";
import type { Prisma } from "@prisma/client";
import { PrismaService } from "../../prisma/prisma.service";
import { AgentProviderRegistry } from "../agents/agent-provider.registry";
type MissionControlAction = "inject" | "pause" | "resume" | "kill";
const DEFAULT_OPERATOR_ID = "mission-control";
export interface AuditLogEntry {
id: string;
userId: string;
sessionId: string;
provider: string;
action: string;
content: string | null;
metadata: Prisma.JsonValue;
createdAt: Date;
}
export interface MissionControlAuditLogPage {
items: AuditLogEntry[];
total: number;
page: number;
pages: number;
}
@Injectable()
export class MissionControlService {
constructor(
private readonly registry: AgentProviderRegistry,
private readonly prisma: PrismaService
) {}
listSessions(): Promise<AgentSession[]> {
return this.registry.listAllSessions();
}
async getSession(sessionId: string): Promise<AgentSession> {
const resolved = await this.registry.getProviderForSession(sessionId);
if (!resolved) {
throw new NotFoundException(`Session ${sessionId} not found`);
}
return resolved.session;
}
async getMessages(sessionId: string, limit?: number, before?: string): Promise<AgentMessage[]> {
const { provider } = await this.getProviderForSessionOrThrow(sessionId);
return provider.getMessages(sessionId, limit, before);
}
async getAuditLog(
sessionId: string | undefined,
page: number,
limit: number
): Promise<MissionControlAuditLogPage> {
const normalizedSessionId = sessionId?.trim();
const where: Prisma.OperatorAuditLogWhereInput =
normalizedSessionId && normalizedSessionId.length > 0
? { sessionId: normalizedSessionId }
: {};
const [total, items] = await this.prisma.$transaction([
this.prisma.operatorAuditLog.count({ where }),
this.prisma.operatorAuditLog.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
take: limit,
}),
]);
return {
items,
total,
page,
pages: total === 0 ? 0 : Math.ceil(total / limit),
};
}
async injectMessage(
sessionId: string,
message: string,
operatorId = DEFAULT_OPERATOR_ID
): Promise<InjectResult> {
const { provider } = await this.getProviderForSessionOrThrow(sessionId);
const result = await provider.injectMessage(sessionId, message);
await this.writeOperatorAuditLog({
sessionId,
providerId: provider.providerId,
operatorId,
action: "inject",
content: message,
payload: { message },
});
return result;
}
async pauseSession(sessionId: string, operatorId = DEFAULT_OPERATOR_ID): Promise<void> {
const { provider } = await this.getProviderForSessionOrThrow(sessionId);
await provider.pauseSession(sessionId);
await this.writeOperatorAuditLog({
sessionId,
providerId: provider.providerId,
operatorId,
action: "pause",
payload: {},
});
}
async resumeSession(sessionId: string, operatorId = DEFAULT_OPERATOR_ID): Promise<void> {
const { provider } = await this.getProviderForSessionOrThrow(sessionId);
await provider.resumeSession(sessionId);
await this.writeOperatorAuditLog({
sessionId,
providerId: provider.providerId,
operatorId,
action: "resume",
payload: {},
});
}
async killSession(
sessionId: string,
force = true,
operatorId = DEFAULT_OPERATOR_ID
): Promise<void> {
const { provider } = await this.getProviderForSessionOrThrow(sessionId);
await provider.killSession(sessionId, force);
await this.writeOperatorAuditLog({
sessionId,
providerId: provider.providerId,
operatorId,
action: "kill",
payload: { force },
});
}
async streamMessages(sessionId: string): Promise<AsyncIterable<AgentMessage>> {
const { provider } = await this.getProviderForSessionOrThrow(sessionId);
return provider.streamMessages(sessionId);
}
private async getProviderForSessionOrThrow(
sessionId: string
): Promise<{ provider: IAgentProvider; session: AgentSession }> {
const resolved = await this.registry.getProviderForSession(sessionId);
if (!resolved) {
throw new NotFoundException(`Session ${sessionId} not found`);
}
return resolved;
}
private toJsonValue(value: Record<string, unknown>): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue;
}
private async writeOperatorAuditLog(params: {
sessionId: string;
providerId: string;
operatorId: string;
action: MissionControlAction;
content?: string;
payload: Record<string, unknown>;
}): Promise<void> {
await this.prisma.operatorAuditLog.create({
data: {
sessionId: params.sessionId,
userId: params.operatorId,
provider: params.providerId,
action: params.action,
...(params.content !== undefined ? { content: params.content } : {}),
metadata: this.toJsonValue({ payload: params.payload }),
},
});
}
}

View File

@@ -4,9 +4,7 @@ import { BullModule } from "@nestjs/bullmq";
import { ThrottlerModule } from "@nestjs/throttler";
import { HealthModule } from "./api/health/health.module";
import { AgentsModule } from "./api/agents/agents.module";
import { MissionControlModule } from "./api/mission-control/mission-control.module";
import { QueueApiModule } from "./api/queue/queue-api.module";
import { AgentProvidersModule } from "./api/agent-providers/agent-providers.module";
import { CoordinatorModule } from "./coordinator/coordinator.module";
import { BudgetModule } from "./budget/budget.module";
import { CIModule } from "./ci";
@@ -53,8 +51,6 @@ import { orchestratorConfig } from "./config/orchestrator.config";
]),
HealthModule,
AgentsModule,
AgentProvidersModule,
MissionControlModule,
QueueApiModule,
CoordinatorModule,
BudgetModule,

View File

@@ -1,9 +0,0 @@
import { Module } from "@nestjs/common";
import { OrchestratorApiKeyGuard } from "../common/guards/api-key.guard";
import { AuthGuard } from "./guards/auth.guard";
@Module({
providers: [OrchestratorApiKeyGuard, AuthGuard],
exports: [AuthGuard],
})
export class AuthModule {}

View File

@@ -1,11 +0,0 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly apiKeyGuard: OrchestratorApiKeyGuard) {}
canActivate(context: ExecutionContext): boolean | Promise<boolean> {
return this.apiKeyGuard.canActivate(context);
}
}

View File

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

View File

@@ -1,26 +0,0 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
/**
* Lightweight Prisma service for orchestrator ingestion persistence.
*/
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
});
}
async onModuleInit(): Promise<void> {
await this.$connect();
this.logger.log("Database connection established");
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
this.logger.log("Database connection closed");
}
}

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger, Inject, Optional, forwardRef } from "@nestjs/common";
import { Injectable, Logger, Inject, forwardRef } from "@nestjs/common";
import { ValkeyService } from "../valkey/valkey.service";
import { AgentSpawnerService } from "./agent-spawner.service";
import { AgentIngestionService } from "../agent-ingestion/agent-ingestion.service";
import type { AgentState, AgentStatus, AgentEvent } from "../valkey/types";
import { isValidAgentTransition } from "../valkey/types/state.types";
@@ -33,8 +32,7 @@ export class AgentLifecycleService {
constructor(
private readonly valkeyService: ValkeyService,
@Inject(forwardRef(() => AgentSpawnerService))
private readonly spawnerService: AgentSpawnerService,
@Optional() private readonly agentIngestionService?: AgentIngestionService
private readonly spawnerService: AgentSpawnerService
) {
this.logger.log("AgentLifecycleService initialized");
}
@@ -57,25 +55,6 @@ export class AgentLifecycleService {
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.
* Uses promise chaining: each caller chains onto the previous lock,
@@ -139,10 +118,6 @@ export class AgentLifecycleService {
// Emit event
await this.publishStateChangeEvent("agent.running", updatedState);
await this.recordLifecycleIngestion(agentId, "started", (ingestionService) =>
ingestionService.recordAgentStarted(agentId)
);
this.logger.log(`Agent ${agentId} transitioned to running`);
return updatedState;
});
@@ -180,10 +155,6 @@ export class AgentLifecycleService {
// Emit event
await this.publishStateChangeEvent("agent.completed", updatedState);
await this.recordLifecycleIngestion(agentId, "completed", (ingestionService) =>
ingestionService.recordAgentCompleted(agentId)
);
// Schedule session cleanup
this.spawnerService.scheduleSessionCleanup(agentId);
@@ -221,10 +192,6 @@ export class AgentLifecycleService {
// Emit event
await this.publishStateChangeEvent("agent.failed", updatedState, error);
await this.recordLifecycleIngestion(agentId, "failed", (ingestionService) =>
ingestionService.recordAgentFailed(agentId, error)
);
// Schedule session cleanup
this.spawnerService.scheduleSessionCleanup(agentId);
@@ -261,10 +228,6 @@ export class AgentLifecycleService {
// Emit event
await this.publishStateChangeEvent("agent.killed", updatedState);
await this.recordLifecycleIngestion(agentId, "killed", (ingestionService) =>
ingestionService.recordAgentKilled(agentId)
);
// Schedule session cleanup
this.spawnerService.scheduleSessionCleanup(agentId);

View File

@@ -1,11 +1,4 @@
import {
Injectable,
Logger,
HttpException,
HttpStatus,
OnModuleDestroy,
Optional,
} from "@nestjs/common";
import { Injectable, Logger, HttpException, HttpStatus, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Anthropic from "@anthropic-ai/sdk";
import { randomUUID } from "crypto";
@@ -15,7 +8,6 @@ import {
AgentSession,
AgentType,
} from "./types/agent-spawner.types";
import { AgentIngestionService } from "../agent-ingestion/agent-ingestion.service";
/**
* Default delay in milliseconds before cleaning up sessions after terminal states
@@ -38,10 +30,7 @@ export class AgentSpawnerService implements OnModuleDestroy {
private readonly sessionCleanupDelayMs: number;
private readonly cleanupTimers = new Map<string, NodeJS.Timeout>();
constructor(
private readonly configService: ConfigService,
@Optional() private readonly agentIngestionService?: AgentIngestionService
) {
constructor(private readonly configService: ConfigService) {
const configuredProvider = this.configService.get<string>("orchestrator.aiProvider");
this.aiProvider = this.normalizeAiProvider(configuredProvider);
@@ -109,25 +98,6 @@ export class AgentSpawnerService implements OnModuleDestroy {
this.cleanupTimers.clear();
}
private recordSpawnedAgentIngestion(agentId: string, request: SpawnAgentRequest): void {
if (!this.agentIngestionService) {
return;
}
void this.agentIngestionService
.recordAgentSpawned(
agentId,
request.parentAgentId,
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
* @param request Agent spawn request
@@ -160,8 +130,6 @@ export class AgentSpawnerService implements OnModuleDestroy {
// Store session
this.sessions.set(agentId, session);
this.recordSpawnedAgentIngestion(agentId, request);
this.logger.log(`Agent spawned successfully: ${agentId} (type: ${request.agentType})`);
// NOTE: Actual Claude SDK integration will be implemented in next iteration (see issue #TBD)

View File

@@ -3,10 +3,9 @@ import { AgentSpawnerService } from "./agent-spawner.service";
import { AgentLifecycleService } from "./agent-lifecycle.service";
import { DockerSandboxService } from "./docker-sandbox.service";
import { ValkeyModule } from "../valkey/valkey.module";
import { AgentIngestionModule } from "../agent-ingestion/agent-ingestion.module";
@Module({
imports: [ValkeyModule, AgentIngestionModule],
imports: [ValkeyModule],
providers: [AgentSpawnerService, AgentLifecycleService, DockerSandboxService],
exports: [AgentSpawnerService, AgentLifecycleService, DockerSandboxService],
})

View File

@@ -40,8 +40,6 @@ export interface SpawnAgentOptions {
export interface SpawnAgentRequest {
/** Unique task identifier */
taskId: string;
/** Optional parent session identifier for subagent lineage */
parentAgentId?: string;
/** Type of agent to spawn */
agentType: AgentType;
/** Context for task execution */

View File

@@ -4,7 +4,6 @@ export default defineConfig({
test: {
globals: true,
environment: "node",
setupFiles: ["reflect-metadata"],
exclude: ["**/node_modules/**", "**/dist/**", "**/tests/integration/**"],
include: ["src/**/*.spec.ts", "src/**/*.test.ts"],
coverage: {

View File

@@ -132,7 +132,7 @@ describe("KanbanPage add task flow", (): void => {
});
// Click the "+ Add task" button in the To Do column
const addTaskButtons = await screen.findAllByRole("button", { name: /\+ Add task/i });
const addTaskButtons = screen.getAllByRole("button", { name: /\+ Add task/i });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await user.click(addTaskButtons[0]!); // First column is "To Do"
@@ -165,7 +165,7 @@ describe("KanbanPage add task flow", (): void => {
});
// Click the "+ Add task" button
const addTaskButtons = await screen.findAllByRole("button", { name: /\+ Add task/i });
const addTaskButtons = screen.getAllByRole("button", { name: /\+ Add task/i });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await user.click(addTaskButtons[0]!);

View File

@@ -1,5 +0,0 @@
import { MissionControlLayout } from "@/components/mission-control/MissionControlLayout";
export default function MissionControlPage(): React.JSX.Element {
return <MissionControlLayout />;
}

View File

@@ -156,26 +156,6 @@ function IconTerminal(): React.JSX.Element {
);
}
function IconMissionControl(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<circle cx="8" cy="8" r="1.5" />
<path d="M11 5a4.25 4.25 0 0 1 0 6" />
<path d="M5 5a4.25 4.25 0 0 0 0 6" />
<path d="M13.5 2.5a7.75 7.75 0 0 1 0 11" />
<path d="M2.5 2.5a7.75 7.75 0 0 0 0 11" />
</svg>
);
}
function IconSettings(): React.JSX.Element {
return (
<svg
@@ -280,11 +260,6 @@ const NAV_GROUPS: NavGroup[] = [
label: "Terminal",
icon: <IconTerminal />,
},
{
href: "/mission-control",
label: "Mission Control",
icon: <IconMissionControl />,
},
],
},
{

View File

@@ -1,205 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
interface MockButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
}
interface MockBadgeProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
}
interface AuditLogEntry {
id: string;
userId: string;
sessionId: string;
provider: string;
action: string;
content: string | null;
metadata: unknown;
createdAt: string;
}
interface AuditLogResponse {
items: AuditLogEntry[];
total: number;
page: number;
pages: number;
}
const mockApiGet = vi.fn<(endpoint: string) => Promise<AuditLogResponse>>();
vi.mock("@/lib/api/client", () => ({
apiGet: (endpoint: string): Promise<AuditLogResponse> => mockApiGet(endpoint),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => (
<button {...props}>{children}</button>
),
}));
vi.mock("@/components/ui/badge", () => ({
Badge: ({ children, ...props }: MockBadgeProps): React.JSX.Element => (
<span {...props}>{children}</span>
),
}));
import { AuditLogDrawer } from "./AuditLogDrawer";
function renderWithQueryClient(ui: React.JSX.Element): ReturnType<typeof render> {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
function responseWith(items: AuditLogEntry[], page: number, pages: number): AuditLogResponse {
return {
items,
total: items.length,
page,
pages,
};
}
describe("AuditLogDrawer", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
mockApiGet.mockResolvedValue(responseWith([], 1, 0));
});
it("opens from trigger text and renders empty state", async (): Promise<void> => {
const user = userEvent.setup();
renderWithQueryClient(<AuditLogDrawer trigger="Audit" />);
await user.click(screen.getByRole("button", { name: "Audit" }));
await waitFor((): void => {
expect(screen.getByText("Audit Log")).toBeInTheDocument();
expect(screen.getByText("No audit entries found.")).toBeInTheDocument();
});
});
it("renders audit entries with action, session id, and payload", async (): Promise<void> => {
const user = userEvent.setup();
mockApiGet.mockResolvedValue(
responseWith(
[
{
id: "entry-1",
userId: "operator-1",
sessionId: "1234567890abcdef",
provider: "internal",
action: "inject",
content: "Run diagnostics",
metadata: { payload: { ignored: true } },
createdAt: "2026-03-07T19:00:00.000Z",
},
],
1,
1
)
);
renderWithQueryClient(<AuditLogDrawer trigger="Audit" />);
await user.click(screen.getByRole("button", { name: "Audit" }));
await waitFor((): void => {
expect(screen.getByText("inject")).toBeInTheDocument();
expect(screen.getByText("12345678")).toBeInTheDocument();
expect(screen.getByText("Run diagnostics")).toBeInTheDocument();
});
});
it("supports pagination and metadata payload summary", async (): Promise<void> => {
const user = userEvent.setup();
mockApiGet.mockImplementation((endpoint: string): Promise<AuditLogResponse> => {
const query = endpoint.split("?")[1] ?? "";
const params = new URLSearchParams(query);
const page = Number(params.get("page") ?? "1");
if (page === 1) {
return Promise.resolve({
items: [
{
id: "entry-page-1",
userId: "operator-2",
sessionId: "abcdefgh12345678",
provider: "internal",
action: "pause",
content: "",
metadata: { payload: { reason: "hold" } },
createdAt: "2026-03-07T19:01:00.000Z",
},
],
total: 2,
page: 1,
pages: 2,
});
}
return Promise.resolve({
items: [
{
id: "entry-page-2",
userId: "operator-3",
sessionId: "zzzz111122223333",
provider: "internal",
action: "kill",
content: null,
metadata: { payload: { force: true } },
createdAt: "2026-03-07T19:02:00.000Z",
},
],
total: 2,
page: 2,
pages: 2,
});
});
renderWithQueryClient(<AuditLogDrawer trigger="Audit" />);
await user.click(screen.getByRole("button", { name: "Audit" }));
await waitFor((): void => {
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
expect(screen.getByText("reason=hold")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Next" }));
await waitFor((): void => {
expect(screen.getByText("Page 2 of 2")).toBeInTheDocument();
expect(screen.getByText("force=true")).toBeInTheDocument();
});
});
it("includes sessionId filter in query string", async (): Promise<void> => {
const user = userEvent.setup();
renderWithQueryClient(<AuditLogDrawer trigger="Audit" sessionId="session 7" />);
await user.click(screen.getByRole("button", { name: "Audit" }));
await waitFor((): void => {
expect(mockApiGet).toHaveBeenCalled();
});
const firstCall = mockApiGet.mock.calls[0];
const endpoint = firstCall?.[0] ?? "";
expect(endpoint).toContain("sessionId=session+7");
});
});

View File

@@ -1,322 +0,0 @@
"use client";
import { isValidElement, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { format } from "date-fns";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { BadgeVariant } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { apiGet } from "@/lib/api/client";
const AUDIT_LOG_REFRESH_INTERVAL_MS = 10_000;
const AUDIT_LOG_PAGE_SIZE = 50;
const SUMMARY_MAX_LENGTH = 120;
interface AuditLogDrawerProps {
sessionId?: string;
trigger: ReactNode;
}
interface AuditLogEntry {
id: string;
userId: string;
sessionId: string;
provider: string;
action: string;
content: string | null;
metadata: unknown;
createdAt: string;
}
interface AuditLogResponse {
items: AuditLogEntry[];
total: number;
page: number;
pages: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function truncateText(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 1)}`;
}
function truncateSessionId(sessionId: string): string {
return sessionId.slice(0, 8);
}
function formatTimestamp(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return "Unknown";
}
return format(parsed, "yyyy-MM-dd HH:mm:ss");
}
function stringifyPayloadValue(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return "[unserializable]";
}
}
function getPayloadSummary(entry: AuditLogEntry): string {
const metadata = isRecord(entry.metadata) ? entry.metadata : undefined;
const payload = metadata && isRecord(metadata.payload) ? metadata.payload : undefined;
if (typeof entry.content === "string" && entry.content.trim().length > 0) {
return truncateText(entry.content.trim(), SUMMARY_MAX_LENGTH);
}
if (payload) {
const summary = Object.entries(payload)
.map(([key, value]) => `${key}=${stringifyPayloadValue(value)}`)
.join(", ");
if (summary.length > 0) {
return truncateText(summary, SUMMARY_MAX_LENGTH);
}
}
return "—";
}
function getActionVariant(action: string): BadgeVariant {
switch (action) {
case "inject":
return "badge-blue";
case "pause":
return "status-warning";
case "resume":
return "status-success";
case "kill":
return "status-error";
default:
return "status-neutral";
}
}
async function fetchAuditLog(
sessionId: string | undefined,
page: number
): Promise<AuditLogResponse> {
const params = new URLSearchParams({
page: String(page),
limit: String(AUDIT_LOG_PAGE_SIZE),
});
const normalizedSessionId = sessionId?.trim();
if (normalizedSessionId && normalizedSessionId.length > 0) {
params.set("sessionId", normalizedSessionId);
}
return apiGet<AuditLogResponse>(`/api/mission-control/audit-log?${params.toString()}`);
}
export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): React.JSX.Element {
const [open, setOpen] = useState(false);
const [page, setPage] = useState(1);
const triggerElement = useMemo(
() =>
isValidElement(trigger) ? (
trigger
) : (
<Button variant="outline" size="sm">
{trigger}
</Button>
),
[trigger]
);
const auditLogQuery = useQuery<AuditLogResponse>({
queryKey: ["mission-control", "audit-log", sessionId ?? "all", page],
queryFn: async (): Promise<AuditLogResponse> => fetchAuditLog(sessionId, page),
enabled: open,
refetchInterval: open ? AUDIT_LOG_REFRESH_INTERVAL_MS : false,
});
useEffect(() => {
if (open) {
setPage(1);
}
}, [open, sessionId]);
useEffect(() => {
const pages = auditLogQuery.data?.pages;
if (pages !== undefined && pages > 0 && page > pages) {
setPage(pages);
}
}, [auditLogQuery.data?.pages, page]);
const totalItems = auditLogQuery.data?.total ?? 0;
const totalPages = auditLogQuery.data?.pages ?? 0;
const items = auditLogQuery.data?.items ?? [];
const canGoPrevious = page > 1;
const canGoNext = totalPages > 0 && page < totalPages;
const errorMessage =
auditLogQuery.error instanceof Error ? auditLogQuery.error.message : "Failed to load audit log";
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>{triggerElement}</SheetTrigger>
<SheetContent className="sm:max-w-[920px]">
<SheetHeader>
<div className="flex items-center justify-between gap-3">
<SheetTitle>Audit Log</SheetTitle>
{auditLogQuery.isFetching ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" aria-hidden="true" />
) : null}
</div>
<SheetDescription>
{sessionId
? `Showing actions for session ${sessionId}.`
: "Showing operator actions across all mission control sessions."}
</SheetDescription>
</SheetHeader>
<div className="mt-4 flex min-h-0 flex-1 flex-col gap-3">
<div className="rounded-md border border-border/70">
<ScrollArea className="h-[64vh]">
<table className="w-full min-w-[760px] border-collapse text-sm">
<thead className="sticky top-0 z-10 bg-muted/90">
<tr>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
Timestamp
</th>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
Action
</th>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
Session
</th>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
Operator
</th>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
Payload
</th>
</tr>
</thead>
<tbody>
{auditLogQuery.isLoading ? (
<tr>
<td
colSpan={5}
className="px-3 py-6 text-center text-sm text-muted-foreground"
>
Loading audit log...
</td>
</tr>
) : auditLogQuery.error ? (
<tr>
<td colSpan={5} className="px-3 py-6 text-center text-sm text-red-500">
{errorMessage}
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-3 py-6 text-center text-sm text-muted-foreground"
>
No audit entries found.
</td>
</tr>
) : (
items.map((entry) => {
const payloadSummary = getPayloadSummary(entry);
return (
<tr key={entry.id} className="border-t border-border/60 align-top">
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
{formatTimestamp(entry.createdAt)}
</td>
<td className="px-3 py-2">
<Badge variant={getActionVariant(entry.action)} className="capitalize">
{entry.action}
</Badge>
</td>
<td className="px-3 py-2 font-mono text-xs" title={entry.sessionId}>
{truncateSessionId(entry.sessionId)}
</td>
<td
className="px-3 py-2 text-xs text-muted-foreground"
title={entry.userId}
>
{entry.userId}
</td>
<td className="px-3 py-2 text-xs" title={payloadSummary}>
{payloadSummary}
</td>
</tr>
);
})
)}
</tbody>
</table>
</ScrollArea>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{totalItems} total entries</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!canGoPrevious || auditLogQuery.isFetching}
onClick={() => {
setPage((currentPage) => Math.max(1, currentPage - 1));
}}
>
Previous
</Button>
<span className="text-xs text-muted-foreground">
Page {page} of {Math.max(totalPages, 1)}
</span>
<Button
variant="outline"
size="sm"
disabled={!canGoNext || auditLogQuery.isFetching}
onClick={() => {
setPage((currentPage) => currentPage + 1);
}}
>
Next
</Button>
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -1,155 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as MosaicUi from "@mosaic/ui";
import type { ButtonHTMLAttributes, ReactNode } from "react";
interface MockButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
}
const mockApiPost = vi.fn<(endpoint: string, body?: unknown) => Promise<{ message?: string }>>();
const mockShowToast = vi.fn<(message: string, variant?: string) => void>();
const useToastSpy = vi.spyOn(MosaicUi, "useToast");
vi.mock("@/lib/api/client", () => ({
apiPost: (endpoint: string, body?: unknown): Promise<{ message?: string }> =>
mockApiPost(endpoint, body),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => (
<button {...props}>{children}</button>
),
}));
import { BargeInInput } from "./BargeInInput";
describe("BargeInInput", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn());
mockApiPost.mockResolvedValue({ message: "ok" });
useToastSpy.mockReturnValue({
showToast: mockShowToast,
removeToast: vi.fn(),
} as ReturnType<typeof MosaicUi.useToast>);
});
afterEach((): void => {
vi.unstubAllGlobals();
});
it("renders input controls and keeps send disabled for empty content", (): void => {
render(<BargeInInput sessionId="session-1" />);
expect(screen.getByLabelText("Inject message")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Pause before send" })).not.toBeChecked();
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
});
it("sends a trimmed message and clears the textarea", async (): Promise<void> => {
const onSent = vi.fn<() => void>();
const user = userEvent.setup();
render(<BargeInInput sessionId="session-1" onSent={onSent} />);
const textarea = screen.getByLabelText("Inject message");
await user.type(textarea, " execute plan ");
await user.click(screen.getByRole("button", { name: "Send" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-1/inject", {
content: "execute plan",
});
});
expect(onSent).toHaveBeenCalledTimes(1);
expect(textarea).toHaveValue("");
});
it("pauses and resumes the session around injection when checkbox is enabled", async (): Promise<void> => {
const user = userEvent.setup();
render(<BargeInInput sessionId="session-2" />);
await user.click(screen.getByRole("checkbox", { name: "Pause before send" }));
await user.type(screen.getByLabelText("Inject message"), "hello world");
await user.click(screen.getByRole("button", { name: "Send" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledTimes(3);
});
const calls = mockApiPost.mock.calls as [string, unknown?][];
expect(calls[0]).toEqual(["/api/mission-control/sessions/session-2/pause", undefined]);
expect(calls[1]).toEqual([
"/api/mission-control/sessions/session-2/inject",
{ content: "hello world" },
]);
expect(calls[2]).toEqual(["/api/mission-control/sessions/session-2/resume", undefined]);
});
it("submits with Enter and does not submit on Shift+Enter", async (): Promise<void> => {
const user = userEvent.setup();
render(<BargeInInput sessionId="session-3" />);
const textarea = screen.getByLabelText("Inject message");
await user.type(textarea, "first");
fireEvent.keyDown(textarea, { key: "Enter", code: "Enter", shiftKey: true });
expect(mockApiPost).not.toHaveBeenCalled();
fireEvent.keyDown(textarea, { key: "Enter", code: "Enter", shiftKey: false });
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-3/inject", {
content: "first",
});
});
});
it("shows an inline error and toast when injection fails", async (): Promise<void> => {
const user = userEvent.setup();
mockApiPost.mockRejectedValueOnce(new Error("Injection failed"));
render(<BargeInInput sessionId="session-4" />);
await user.type(screen.getByLabelText("Inject message"), "help");
await user.click(screen.getByRole("button", { name: "Send" }));
await waitFor((): void => {
expect(screen.getByRole("alert")).toHaveTextContent("Injection failed");
});
expect(mockShowToast).toHaveBeenCalledWith("Injection failed", "error");
});
it("reports resume failures after a successful send", async (): Promise<void> => {
const user = userEvent.setup();
mockApiPost
.mockResolvedValueOnce({ message: "paused" })
.mockResolvedValueOnce({ message: "sent" })
.mockRejectedValueOnce(new Error("resume failed"));
render(<BargeInInput sessionId="session-5" />);
await user.click(screen.getByRole("checkbox", { name: "Pause before send" }));
await user.type(screen.getByLabelText("Inject message"), "deploy now");
await user.click(screen.getByRole("button", { name: "Send" }));
await waitFor((): void => {
expect(screen.getByRole("alert")).toHaveTextContent(
"Message sent, but failed to resume session: resume failed"
);
});
expect(mockShowToast).toHaveBeenCalledWith(
"Message sent, but failed to resume session: resume failed",
"error"
);
});
});

View File

@@ -1,147 +0,0 @@
"use client";
import { useCallback, useState, type KeyboardEvent } from "react";
import { Loader2 } from "lucide-react";
import { useToast } from "@mosaic/ui";
import { Button } from "@/components/ui/button";
import { apiPost } from "@/lib/api/client";
const MAX_ROWS = 4;
const TEXTAREA_MAX_HEIGHT_REM = 6.5;
interface BargeInMutationResponse {
message?: string;
}
export interface BargeInInputProps {
sessionId: string;
onSent?: () => void;
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return "Failed to send message to the session.";
}
export function BargeInInput({ sessionId, onSent }: BargeInInputProps): React.JSX.Element {
const { showToast } = useToast();
const [content, setContent] = useState("");
const [pauseBeforeSend, setPauseBeforeSend] = useState(false);
const [isSending, setIsSending] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleSend = useCallback(async (): Promise<void> => {
const trimmedContent = content.trim();
if (!trimmedContent || isSending) {
return;
}
const encodedSessionId = encodeURIComponent(sessionId);
const baseEndpoint = `/api/mission-control/sessions/${encodedSessionId}`;
let didPause = false;
let didInject = false;
setIsSending(true);
setErrorMessage(null);
try {
if (pauseBeforeSend) {
await apiPost<BargeInMutationResponse>(`${baseEndpoint}/pause`);
didPause = true;
}
await apiPost<BargeInMutationResponse>(`${baseEndpoint}/inject`, { content: trimmedContent });
didInject = true;
setContent("");
onSent?.();
} catch (error) {
const message = getErrorMessage(error);
setErrorMessage(message);
showToast(message, "error");
} finally {
if (didPause) {
try {
await apiPost<BargeInMutationResponse>(`${baseEndpoint}/resume`);
} catch (resumeError) {
const resumeMessage = getErrorMessage(resumeError);
const message = didInject
? `Message sent, but failed to resume session: ${resumeMessage}`
: `Failed to resume session: ${resumeMessage}`;
setErrorMessage(message);
showToast(message, "error");
}
}
setIsSending(false);
}
}, [content, isSending, onSent, pauseBeforeSend, sessionId, showToast]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>): void => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
},
[handleSend]
);
const isSendDisabled = isSending || content.trim().length === 0;
return (
<div className="space-y-2">
<textarea
value={content}
onChange={(event) => {
setContent(event.target.value);
}}
onKeyDown={handleKeyDown}
disabled={isSending}
rows={MAX_ROWS}
placeholder="Inject a message into this session..."
className="block w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm leading-5 text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60"
style={{ maxHeight: `${String(TEXTAREA_MAX_HEIGHT_REM)}rem` }}
aria-label="Inject message"
/>
<div className="flex items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={pauseBeforeSend}
onChange={(event) => {
setPauseBeforeSend(event.target.checked);
}}
disabled={isSending}
className="h-4 w-4 rounded border-border"
/>
<span>Pause before send</span>
</label>
<Button
type="button"
variant="primary"
size="sm"
disabled={isSendDisabled}
onClick={() => {
void handleSend();
}}
>
{isSending ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Sending...
</span>
) : (
"Send"
)}
</Button>
</div>
{errorMessage ? (
<p role="alert" className="text-sm text-red-500">
{errorMessage}
</p>
) : null}
</div>
);
}

View File

@@ -1,203 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
interface MockButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
}
interface MockContainerProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
}
interface MockSession {
id: string;
providerId: string;
providerType: string;
status: "active" | "paused" | "killed";
createdAt: string;
updatedAt: string;
metadata?: Record<string, unknown>;
}
const mockApiGet = vi.fn<(endpoint: string) => Promise<MockSession[]>>();
const mockApiPost = vi.fn<(endpoint: string, body?: unknown) => Promise<{ message: string }>>();
const mockKillAllDialog = vi.fn<() => React.JSX.Element>();
vi.mock("@/lib/api/client", () => ({
apiGet: (endpoint: string): Promise<MockSession[]> => mockApiGet(endpoint),
apiPost: (endpoint: string, body?: unknown): Promise<{ message: string }> =>
mockApiPost(endpoint, body),
}));
vi.mock("@/components/mission-control/KillAllDialog", () => ({
KillAllDialog: (): React.JSX.Element => mockKillAllDialog(),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => (
<button {...props}>{children}</button>
),
}));
vi.mock("@/components/ui/badge", () => ({
Badge: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
<span {...props}>{children}</span>
),
}));
vi.mock("@/components/ui/card", () => ({
Card: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
<section {...props}>{children}</section>
),
CardHeader: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
<header {...props}>{children}</header>
),
CardContent: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
<div {...props}>{children}</div>
),
CardTitle: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
<h2 {...props}>{children}</h2>
),
}));
import { GlobalAgentRoster } from "./GlobalAgentRoster";
function renderWithQueryClient(ui: React.JSX.Element): ReturnType<typeof render> {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
function makeSession(overrides: Partial<MockSession>): MockSession {
return {
id: "session-12345678",
providerId: "internal",
providerType: "internal",
status: "active",
createdAt: "2026-03-07T10:00:00.000Z",
updatedAt: "2026-03-07T10:01:00.000Z",
...overrides,
};
}
function getRowForSessionLabel(label: string): HTMLElement {
const sessionLabel = screen.getByText(label);
const row = sessionLabel.closest('[role="button"]');
if (!(row instanceof HTMLElement)) {
throw new Error(`Expected a row element for session label ${label}`);
}
return row;
}
describe("GlobalAgentRoster", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn());
mockApiGet.mockResolvedValue([]);
mockApiPost.mockResolvedValue({ message: "ok" });
mockKillAllDialog.mockImplementation(
(): React.JSX.Element => <div data-testid="kill-all-dialog">kill-all-dialog</div>
);
});
afterEach((): void => {
vi.unstubAllGlobals();
});
it("renders the empty state when no active sessions are returned", async (): Promise<void> => {
renderWithQueryClient(<GlobalAgentRoster />);
await waitFor((): void => {
expect(screen.getByText("No active agents")).toBeInTheDocument();
});
expect(screen.queryByTestId("kill-all-dialog")).not.toBeInTheDocument();
});
it("groups sessions by provider and shows kill-all control when sessions exist", async (): Promise<void> => {
mockApiGet.mockResolvedValue([
makeSession({ id: "alpha123456", providerId: "internal", providerType: "internal" }),
makeSession({ id: "bravo123456", providerId: "codex", providerType: "openai" }),
]);
renderWithQueryClient(<GlobalAgentRoster />);
await waitFor((): void => {
expect(screen.getByText("internal")).toBeInTheDocument();
expect(screen.getByText("codex (openai)")).toBeInTheDocument();
});
expect(screen.getByText("alpha123")).toBeInTheDocument();
expect(screen.getByText("bravo123")).toBeInTheDocument();
expect(screen.getByTestId("kill-all-dialog")).toBeInTheDocument();
});
it("calls onSelectSession on row click and keyboard activation", async (): Promise<void> => {
const onSelectSession = vi.fn<(sessionId: string) => void>();
mockApiGet.mockResolvedValue([makeSession({ id: "target123456" })]);
renderWithQueryClient(<GlobalAgentRoster onSelectSession={onSelectSession} />);
await waitFor((): void => {
expect(screen.getByText("target12")).toBeInTheDocument();
});
const row = getRowForSessionLabel("target12");
fireEvent.click(row);
fireEvent.keyDown(row, { key: "Enter" });
expect(onSelectSession).toHaveBeenCalledTimes(2);
expect(onSelectSession).toHaveBeenNthCalledWith(1, "target123456");
expect(onSelectSession).toHaveBeenNthCalledWith(2, "target123456");
});
it("kills a session from the roster", async (): Promise<void> => {
mockApiGet.mockResolvedValue([makeSession({ id: "killme123456" })]);
renderWithQueryClient(<GlobalAgentRoster />);
await waitFor((): void => {
expect(screen.getByRole("button", { name: "Kill session killme12" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Kill session killme12" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/killme123456/kill", {
force: false,
});
});
});
it("collapses and reopens provider groups", async (): Promise<void> => {
mockApiGet.mockResolvedValue([makeSession({ id: "grouped12345" })]);
renderWithQueryClient(<GlobalAgentRoster />);
await waitFor((): void => {
expect(screen.getByText("grouped1")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /internal/i }));
expect(screen.queryByText("grouped1")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /internal/i }));
expect(screen.getByText("grouped1")).toBeInTheDocument();
});
});

View File

@@ -1,292 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AgentSession } from "@mosaic/shared";
import { ChevronRight, Loader2, X } from "lucide-react";
import { KillAllDialog } from "@/components/mission-control/KillAllDialog";
import { Badge } from "@/components/ui/badge";
import type { BadgeVariant } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet, apiPost } from "@/lib/api/client";
const SESSIONS_QUERY_KEY = ["mission-control", "sessions"] as const;
const SESSIONS_POLL_INTERVAL_MS = 5_000;
type MissionControlSessionStatus = AgentSession["status"] | "killed";
interface MissionControlSession extends Omit<AgentSession, "status" | "createdAt" | "updatedAt"> {
status: MissionControlSessionStatus;
createdAt: string | Date;
updatedAt: string | Date;
}
interface SessionsPayload {
sessions: MissionControlSession[];
}
interface ProviderSessionGroup {
providerId: string;
providerType: string;
sessions: MissionControlSession[];
}
export interface GlobalAgentRosterProps {
onSelectSession?: (sessionId: string) => void;
selectedSessionId?: string | undefined;
}
function getStatusVariant(status: MissionControlSessionStatus): BadgeVariant {
switch (status) {
case "active":
return "status-success";
case "paused":
return "status-warning";
case "killed":
return "status-error";
default:
return "status-neutral";
}
}
function truncateSessionId(sessionId: string): string {
return sessionId.slice(0, 8);
}
function resolveProviderName(providerId: string, providerType: string): string {
return providerId === providerType ? providerId : `${providerId} (${providerType})`;
}
function groupByProvider(sessions: MissionControlSession[]): ProviderSessionGroup[] {
const grouped = new Map<string, ProviderSessionGroup>();
for (const session of sessions) {
const existing = grouped.get(session.providerId);
if (existing) {
existing.sessions.push(session);
continue;
}
grouped.set(session.providerId, {
providerId: session.providerId,
providerType: session.providerType,
sessions: [session],
});
}
return Array.from(grouped.values()).sort((a, b) => a.providerId.localeCompare(b.providerId));
}
async function fetchSessions(): Promise<MissionControlSession[]> {
const payload = await apiGet<MissionControlSession[] | SessionsPayload>(
"/api/mission-control/sessions"
);
return Array.isArray(payload) ? payload : payload.sessions;
}
function toKillAllSessions(sessions: MissionControlSession[]): AgentSession[] {
return sessions
.filter(
(session): session is MissionControlSession & { status: AgentSession["status"] } =>
session.status !== "killed"
)
.map((session) => ({
...session,
createdAt:
session.createdAt instanceof Date ? session.createdAt : new Date(session.createdAt),
updatedAt:
session.updatedAt instanceof Date ? session.updatedAt : new Date(session.updatedAt),
}));
}
export function GlobalAgentRoster({
onSelectSession,
selectedSessionId,
}: GlobalAgentRosterProps): React.JSX.Element {
const queryClient = useQueryClient();
const [openProviders, setOpenProviders] = useState<Record<string, boolean>>({});
const sessionsQuery = useQuery<MissionControlSession[]>({
queryKey: SESSIONS_QUERY_KEY,
queryFn: fetchSessions,
refetchInterval: SESSIONS_POLL_INTERVAL_MS,
});
const killMutation = useMutation({
mutationFn: async (sessionId: string): Promise<string> => {
await apiPost<{ message: string }>(`/api/mission-control/sessions/${sessionId}/kill`, {
force: false,
});
return sessionId;
},
onSuccess: (): void => {
void queryClient.invalidateQueries({ queryKey: SESSIONS_QUERY_KEY });
},
});
const groupedSessions = useMemo(
() => groupByProvider(sessionsQuery.data ?? []),
[sessionsQuery.data]
);
const killAllSessions = useMemo(
() => toKillAllSessions(sessionsQuery.data ?? []),
[sessionsQuery.data]
);
const totalSessionCount = sessionsQuery.data?.length ?? 0;
const pendingKillSessionId = killMutation.isPending ? killMutation.variables : undefined;
const toggleProvider = (providerId: string): void => {
setOpenProviders((prev) => ({
...prev,
[providerId]: !(prev[providerId] ?? true),
}));
};
const isProviderOpen = (providerId: string): boolean => openProviders[providerId] ?? true;
const handleKillAllComplete = (): void => {
void queryClient.invalidateQueries({ queryKey: SESSIONS_QUERY_KEY });
};
return (
<Card className="flex h-full min-h-0 flex-col">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between gap-2 text-base">
<span>Agent Roster</span>
<div className="flex items-center gap-2">
{totalSessionCount > 0 ? (
<KillAllDialog sessions={killAllSessions} onComplete={handleKillAllComplete} />
) : null}
{sessionsQuery.isFetching && !sessionsQuery.isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" aria-hidden="true" />
) : null}
</div>
</CardTitle>
</CardHeader>
<CardContent className="min-h-0 flex-1 px-3 pb-3">
{sessionsQuery.isLoading ? (
<ScrollArea className="h-full">
<div className="space-y-2 pr-1">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={`roster-skeleton-${String(index)}`} className="h-10 w-full" />
))}
</div>
</ScrollArea>
) : sessionsQuery.error ? (
<div className="flex h-full items-center justify-center text-center text-sm text-red-500">
Failed to load agents: {sessionsQuery.error.message}
</div>
) : groupedSessions.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No active agents
</div>
) : (
<ScrollArea className="h-full">
<div className="space-y-3 pr-1">
{groupedSessions.map((group) => {
const providerOpen = isProviderOpen(group.providerId);
return (
<Collapsible key={group.providerId} open={providerOpen} className="space-y-1">
<button
type="button"
onClick={() => {
toggleProvider(group.providerId);
}}
className="flex w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm hover:bg-muted/40"
aria-expanded={providerOpen}
>
<ChevronRight
className={`h-4 w-4 text-muted-foreground transition-transform ${providerOpen ? "rotate-90" : ""}`}
aria-hidden="true"
/>
<span className="font-medium">
{resolveProviderName(group.providerId, group.providerType)}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{group.sessions.length}
</span>
</button>
{providerOpen ? (
<div className="space-y-1 pl-2">
{group.sessions.map((session) => {
const isSelected = selectedSessionId === session.id;
const isKilling = pendingKillSessionId === session.id;
return (
<div
key={session.id}
role="button"
tabIndex={0}
onClick={() => {
onSelectSession?.(session.id);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelectSession?.(session.id);
}
}}
className="flex items-center justify-between gap-2 rounded-md border border-transparent px-2 py-1.5 transition-colors hover:bg-muted/40"
style={
isSelected
? {
borderColor: "rgba(47, 128, 255, 0.35)",
backgroundColor: "rgba(47, 128, 255, 0.08)",
}
: undefined
}
>
<div className="flex min-w-0 items-center gap-2">
<span className="font-mono text-xs" title={session.id}>
{truncateSessionId(session.id)}
</span>
<Badge
variant={getStatusVariant(session.status)}
className="capitalize"
>
{session.status}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 min-h-7 w-7 min-w-7 p-0"
disabled={isKilling}
onClick={(event) => {
event.stopPropagation();
killMutation.mutate(session.id);
}}
aria-label={`Kill session ${truncateSessionId(session.id)}`}
>
{isKilling ? (
<Loader2
className="h-3.5 w-3.5 animate-spin"
aria-hidden="true"
/>
) : (
<X className="h-3.5 w-3.5" aria-hidden="true" />
)}
</Button>
</div>
);
})}
</div>
) : null}
</Collapsible>
);
})}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,170 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
LabelHTMLAttributes,
ReactNode,
} from "react";
interface MockButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
}
interface MockInputProps extends InputHTMLAttributes<HTMLInputElement> {
children?: ReactNode;
}
interface MockLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
children: ReactNode;
}
interface MockSession {
id: string;
providerId: string;
providerType: string;
status: "active" | "paused";
createdAt: Date;
updatedAt: Date;
}
const mockApiPost = vi.fn<(endpoint: string, body?: unknown) => Promise<{ message: string }>>();
vi.mock("@/lib/api/client", () => ({
apiPost: (endpoint: string, body?: unknown): Promise<{ message: string }> =>
mockApiPost(endpoint, body),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => (
<button {...props}>{children}</button>
),
}));
vi.mock("@/components/ui/input", () => ({
Input: ({ ...props }: MockInputProps): React.JSX.Element => <input {...props} />,
}));
vi.mock("@/components/ui/label", () => ({
Label: ({ children, ...props }: MockLabelProps): React.JSX.Element => (
<label {...props}>{children}</label>
),
}));
import { KillAllDialog } from "./KillAllDialog";
function makeSession(overrides: Partial<MockSession>): MockSession {
return {
id: "session-1",
providerId: "internal",
providerType: "internal",
status: "active",
createdAt: new Date("2026-03-07T10:00:00.000Z"),
updatedAt: new Date("2026-03-07T10:01:00.000Z"),
...overrides,
};
}
describe("KillAllDialog", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
mockApiPost.mockResolvedValue({ message: "killed" });
});
it("renders trigger button and requires exact confirmation text", async (): Promise<void> => {
const user = userEvent.setup();
render(<KillAllDialog sessions={[makeSession({})]} />);
await user.click(screen.getByRole("button", { name: "Kill All" }));
const confirmInput = screen.getByLabelText("Type KILL ALL to confirm");
const confirmButton = screen.getByRole("button", { name: "Kill All Agents" });
expect(confirmButton).toBeDisabled();
await user.type(confirmInput, "kill all");
expect(confirmButton).toBeDisabled();
await user.clear(confirmInput);
await user.type(confirmInput, "KILL ALL");
expect(confirmButton).toBeEnabled();
});
it("kills only internal sessions by default and invokes completion callback", async (): Promise<void> => {
const onComplete = vi.fn<() => void>();
const user = userEvent.setup();
render(
<KillAllDialog
sessions={[
makeSession({ id: "internal-1", providerType: "internal" }),
makeSession({ id: "external-1", providerType: "external" }),
]}
onComplete={onComplete}
/>
);
await user.click(screen.getByRole("button", { name: "Kill All" }));
await user.type(screen.getByLabelText("Type KILL ALL to confirm"), "KILL ALL");
await user.click(screen.getByRole("button", { name: "Kill All Agents" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-1/kill", {
force: true,
});
});
expect(mockApiPost).not.toHaveBeenCalledWith("/api/mission-control/sessions/external-1/kill", {
force: true,
});
expect(onComplete).toHaveBeenCalledTimes(1);
});
it("kills all providers when all scope is selected", async (): Promise<void> => {
const user = userEvent.setup();
render(
<KillAllDialog
sessions={[
makeSession({ id: "internal-2", providerType: "internal" }),
makeSession({ id: "external-2", providerType: "external" }),
]}
/>
);
await user.click(screen.getByRole("button", { name: "Kill All" }));
await user.click(screen.getByRole("radio", { name: /All providers \(2\)/ }));
await user.type(screen.getByLabelText("Type KILL ALL to confirm"), "KILL ALL");
await user.click(screen.getByRole("button", { name: "Kill All Agents" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-2/kill", {
force: true,
});
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/external-2/kill", {
force: true,
});
});
});
it("shows empty-scope warning when internal sessions are unavailable", async (): Promise<void> => {
const user = userEvent.setup();
render(
<KillAllDialog
sessions={[
makeSession({ id: "external-only", providerId: "ext", providerType: "external" }),
]}
/>
);
await user.click(screen.getByRole("button", { name: "Kill All" }));
await user.type(screen.getByLabelText("Type KILL ALL to confirm"), "KILL ALL");
expect(screen.getByText("No sessions in the selected scope.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Kill All Agents" })).toBeDisabled();
});
});

View File

@@ -1,224 +0,0 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { AgentSession } from "@mosaic/shared";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiPost } from "@/lib/api/client";
const CONFIRM_TEXT = "KILL ALL";
const AUTO_CLOSE_DELAY_MS = 2_000;
type KillScope = "internal" | "all";
export interface KillAllDialogProps {
sessions: AgentSession[];
onComplete?: () => void;
}
export function KillAllDialog({ sessions, onComplete }: KillAllDialogProps): React.JSX.Element {
const [open, setOpen] = useState(false);
const [scope, setScope] = useState<KillScope>("internal");
const [confirmationInput, setConfirmationInput] = useState("");
const [isKilling, setIsKilling] = useState(false);
const [completedCount, setCompletedCount] = useState(0);
const [targetCount, setTargetCount] = useState(0);
const [successCount, setSuccessCount] = useState<number | null>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const internalSessions = useMemo(
() => sessions.filter((session) => session.providerType.toLowerCase() === "internal"),
[sessions]
);
const scopedSessions = useMemo(
() => (scope === "all" ? sessions : internalSessions),
[scope, sessions, internalSessions]
);
const hasConfirmation = confirmationInput === CONFIRM_TEXT;
const isConfirmDisabled =
isKilling || successCount !== null || !hasConfirmation || scopedSessions.length === 0;
useEffect((): (() => void) => {
return (): void => {
if (closeTimeoutRef.current !== null) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);
const resetState = (): void => {
setScope("internal");
setConfirmationInput("");
setIsKilling(false);
setCompletedCount(0);
setTargetCount(0);
setSuccessCount(null);
};
const handleOpenChange = (nextOpen: boolean): void => {
if (!nextOpen && isKilling) {
return;
}
if (!nextOpen) {
if (closeTimeoutRef.current !== null) {
clearTimeout(closeTimeoutRef.current);
}
resetState();
}
setOpen(nextOpen);
};
const handleKillAll = async (): Promise<void> => {
if (isConfirmDisabled) {
return;
}
const targetSessions = [...scopedSessions];
setIsKilling(true);
setCompletedCount(0);
setTargetCount(targetSessions.length);
setSuccessCount(null);
const killRequests = targetSessions.map(async (session) => {
try {
await apiPost<{ message: string }>(`/api/mission-control/sessions/${session.id}/kill`, {
force: true,
});
return true;
} catch {
return false;
} finally {
setCompletedCount((currentCount) => currentCount + 1);
}
});
const results = await Promise.all(killRequests);
const successfulKills = results.filter(Boolean).length;
setIsKilling(false);
setSuccessCount(successfulKills);
onComplete?.();
closeTimeoutRef.current = setTimeout(() => {
setOpen(false);
resetState();
}, AUTO_CLOSE_DELAY_MS);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="danger" size="sm">
Kill All
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Kill All Agents</DialogTitle>
<DialogDescription>
This force-kills every selected agent session. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Scope</legend>
<label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
<input
type="radio"
name="kill-all-scope"
checked={scope === "internal"}
disabled={isKilling}
onChange={() => {
setScope("internal");
}}
/>
<span>Internal provider only ({internalSessions.length})</span>
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
<input
type="radio"
name="kill-all-scope"
checked={scope === "all"}
disabled={isKilling}
onChange={() => {
setScope("all");
}}
/>
<span>All providers ({sessions.length})</span>
</label>
</fieldset>
<div className="space-y-2">
<Label htmlFor="kill-all-confirmation-input">Type KILL ALL to confirm</Label>
<Input
id="kill-all-confirmation-input"
value={confirmationInput}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setConfirmationInput(event.target.value);
}}
placeholder={CONFIRM_TEXT}
autoComplete="off"
disabled={isKilling}
/>
</div>
{scopedSessions.length === 0 ? (
<p className="text-sm text-red-500">No sessions in the selected scope.</p>
) : null}
{isKilling ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
<span>
Killing {completedCount} of {targetCount} agents...
</span>
</div>
) : successCount !== null ? (
<p className="text-sm text-muted-foreground">
Killed {successCount} of {targetCount} agents. Closing...
</p>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isKilling}
onClick={() => {
setOpen(false);
}}
>
Cancel
</Button>
<Button
type="button"
variant="danger"
disabled={isConfirmDisabled}
onClick={() => {
void handleKillAll();
}}
>
Kill All Agents
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,70 +0,0 @@
import { render, screen } from "@testing-library/react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
interface MockButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
}
const mockGlobalAgentRoster = vi.fn();
const mockMissionControlPanel = vi.fn();
vi.mock("@/components/mission-control/AuditLogDrawer", () => ({
AuditLogDrawer: ({ trigger }: { trigger: ReactNode }): React.JSX.Element => (
<div data-testid="audit-log-drawer">{trigger}</div>
),
}));
vi.mock("@/components/mission-control/GlobalAgentRoster", () => ({
GlobalAgentRoster: (props: unknown): React.JSX.Element => {
mockGlobalAgentRoster(props);
return <div data-testid="global-agent-roster" />;
},
}));
vi.mock("@/components/mission-control/MissionControlPanel", () => ({
MissionControlPanel: (props: unknown): React.JSX.Element => {
mockMissionControlPanel(props);
return <div data-testid="mission-control-panel" />;
},
MAX_PANEL_COUNT: 6,
MIN_PANEL_COUNT: 1,
}));
vi.mock("@/components/ui/button", () => ({
Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => (
<button {...props}>{children}</button>
),
}));
import { MissionControlLayout } from "./MissionControlLayout";
describe("MissionControlLayout", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn());
});
afterEach((): void => {
vi.unstubAllGlobals();
});
it("renders without crashing", (): void => {
render(<MissionControlLayout />);
expect(screen.getByRole("region", { name: "Mission Control" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Audit Log" })).toBeInTheDocument();
});
it("renders sidebar and panel grid container", (): void => {
render(<MissionControlLayout />);
const region = screen.getByRole("region", { name: "Mission Control" });
expect(region.querySelector(".grid")).toBeInTheDocument();
expect(region.querySelector("aside")).toBeInTheDocument();
expect(region.querySelector("main")).toBeInTheDocument();
expect(screen.getByTestId("global-agent-roster")).toBeInTheDocument();
expect(screen.getByTestId("mission-control-panel")).toBeInTheDocument();
});
});

View File

@@ -1,115 +0,0 @@
"use client";
import { useCallback, useState } from "react";
import { AuditLogDrawer } from "@/components/mission-control/AuditLogDrawer";
import { GlobalAgentRoster } from "@/components/mission-control/GlobalAgentRoster";
import {
MAX_PANEL_COUNT,
MIN_PANEL_COUNT,
MissionControlPanel,
type PanelConfig,
} from "@/components/mission-control/MissionControlPanel";
import { Button } from "@/components/ui/button";
const INITIAL_PANELS: PanelConfig[] = [{}];
export function MissionControlLayout(): React.JSX.Element {
const [panels, setPanels] = useState<PanelConfig[]>(INITIAL_PANELS);
const [selectedSessionId, setSelectedSessionId] = useState<string>();
const handleSelectSession = useCallback((sessionId: string): void => {
setSelectedSessionId(sessionId);
setPanels((currentPanels) => {
if (currentPanels.some((panel) => panel.sessionId === sessionId)) {
return currentPanels;
}
const firstEmptyPanelIndex = currentPanels.findIndex(
(panel) => panel.sessionId === undefined
);
if (firstEmptyPanelIndex >= 0) {
return currentPanels.map((panel, index) =>
index === firstEmptyPanelIndex ? { ...panel, sessionId } : panel
);
}
if (currentPanels.length >= MAX_PANEL_COUNT) {
return currentPanels;
}
return [...currentPanels, { sessionId }];
});
}, []);
const handleAddPanel = useCallback((): void => {
setPanels((currentPanels) => {
if (currentPanels.length >= MAX_PANEL_COUNT) {
return currentPanels;
}
return [...currentPanels, {}];
});
}, []);
const handleRemovePanel = useCallback((panelIndex: number): void => {
setPanels((currentPanels) => {
if (panelIndex < 0 || panelIndex >= currentPanels.length) {
return currentPanels;
}
if (currentPanels.length <= MIN_PANEL_COUNT) {
return currentPanels;
}
const nextPanels = currentPanels.filter((_, index) => index !== panelIndex);
return nextPanels.length === 0 ? INITIAL_PANELS : nextPanels;
});
}, []);
const handleExpandPanel = useCallback((panelIndex: number): void => {
setPanels((currentPanels) => {
if (panelIndex < 0 || panelIndex >= currentPanels.length) {
return currentPanels;
}
const shouldExpand = !currentPanels[panelIndex]?.expanded;
return currentPanels.map((panel, index) => ({
...panel,
expanded: shouldExpand && index === panelIndex,
}));
});
}, []);
return (
<section className="flex h-full min-h-0 flex-col overflow-hidden" aria-label="Mission Control">
<header className="mb-3 flex items-center justify-end">
<AuditLogDrawer
trigger={
<Button variant="outline" size="sm">
Audit Log
</Button>
}
/>
</header>
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
<aside className="h-full min-h-0">
<GlobalAgentRoster
onSelectSession={handleSelectSession}
{...(selectedSessionId !== undefined ? { selectedSessionId } : {})}
/>
</aside>
<main className="h-full min-h-0 overflow-hidden">
<MissionControlPanel
panels={panels}
onAddPanel={handleAddPanel}
onRemovePanel={handleRemovePanel}
onExpandPanel={handleExpandPanel}
/>
</main>
</div>
</section>
);
}

View File

@@ -1,153 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { MAX_PANEL_COUNT, MissionControlPanel, type PanelConfig } from "./MissionControlPanel";
interface MockButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
}
interface MockOrchestratorPanelProps {
sessionId?: string;
onClose?: () => void;
closeDisabled?: boolean;
onExpand?: () => void;
expanded?: boolean;
}
const mockOrchestratorPanel = vi.fn<(props: MockOrchestratorPanelProps) => React.JSX.Element>();
vi.mock("@/components/mission-control/OrchestratorPanel", () => ({
OrchestratorPanel: (props: MockOrchestratorPanelProps): React.JSX.Element =>
mockOrchestratorPanel(props),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => (
<button {...props}>{children}</button>
),
}));
function buildPanels(count: number): PanelConfig[] {
return Array.from({ length: count }, (_, index) => ({
sessionId: `session-${String(index + 1)}`,
}));
}
describe("MissionControlPanel", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
mockOrchestratorPanel.mockImplementation(
({ sessionId, closeDisabled, expanded }: MockOrchestratorPanelProps): React.JSX.Element => (
<div
data-testid="orchestrator-panel"
data-session-id={sessionId ?? ""}
data-close-disabled={String(closeDisabled ?? false)}
data-expanded={String(expanded ?? false)}
/>
)
);
});
it("renders the panel grid and default heading", (): void => {
render(
<MissionControlPanel
panels={[{}]}
onAddPanel={vi.fn<() => void>()}
onRemovePanel={vi.fn<(index: number) => void>()}
onExpandPanel={vi.fn<(index: number) => void>()}
/>
);
expect(screen.getByRole("heading", { name: "Panels" })).toBeInTheDocument();
expect(screen.getAllByTestId("orchestrator-panel")).toHaveLength(1);
});
it("calls onAddPanel when the add button is clicked", (): void => {
const onAddPanel = vi.fn<() => void>();
render(
<MissionControlPanel
panels={[{}]}
onAddPanel={onAddPanel}
onRemovePanel={vi.fn<(index: number) => void>()}
onExpandPanel={vi.fn<(index: number) => void>()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Add panel" }));
expect(onAddPanel).toHaveBeenCalledTimes(1);
});
it("disables add panel at the configured maximum", (): void => {
render(
<MissionControlPanel
panels={buildPanels(MAX_PANEL_COUNT)}
onAddPanel={vi.fn<() => void>()}
onRemovePanel={vi.fn<(index: number) => void>()}
onExpandPanel={vi.fn<(index: number) => void>()}
/>
);
const addButton = screen.getByRole("button", { name: "Add panel" });
expect(addButton).toBeDisabled();
expect(addButton).toHaveAttribute("title", "Maximum of 6 panels");
});
it("passes closeDisabled=false when more than one panel exists", (): void => {
render(
<MissionControlPanel
panels={buildPanels(2)}
onAddPanel={vi.fn<() => void>()}
onRemovePanel={vi.fn<(index: number) => void>()}
onExpandPanel={vi.fn<(index: number) => void>()}
/>
);
const renderedPanels = screen.getAllByTestId("orchestrator-panel");
expect(renderedPanels).toHaveLength(2);
for (const panel of renderedPanels) {
expect(panel).toHaveAttribute("data-close-disabled", "false");
}
});
it("renders only the expanded panel in focused mode", (): void => {
render(
<MissionControlPanel
panels={[{ sessionId: "session-1" }, { sessionId: "session-2", expanded: true }]}
onAddPanel={vi.fn<() => void>()}
onRemovePanel={vi.fn<(index: number) => void>()}
onExpandPanel={vi.fn<(index: number) => void>()}
/>
);
const renderedPanels = screen.getAllByTestId("orchestrator-panel");
expect(renderedPanels).toHaveLength(1);
expect(renderedPanels[0]).toHaveAttribute("data-session-id", "session-2");
expect(renderedPanels[0]).toHaveAttribute("data-expanded", "true");
});
it("handles Escape key by toggling expanded panel", async (): Promise<void> => {
const onExpandPanel = vi.fn<(index: number) => void>();
render(
<MissionControlPanel
panels={[{ sessionId: "session-1", expanded: true }, { sessionId: "session-2" }]}
onAddPanel={vi.fn<() => void>()}
onRemovePanel={vi.fn<(index: number) => void>()}
onExpandPanel={onExpandPanel}
/>
);
fireEvent.keyDown(window, { key: "Escape" });
await waitFor((): void => {
expect(onExpandPanel).toHaveBeenCalledWith(0);
});
});
});

View File

@@ -1,107 +0,0 @@
"use client";
import { useEffect } from "react";
import { OrchestratorPanel } from "@/components/mission-control/OrchestratorPanel";
import { Button } from "@/components/ui/button";
export interface PanelConfig {
sessionId?: string;
expanded?: boolean;
}
interface MissionControlPanelProps {
panels: PanelConfig[];
onAddPanel: () => void;
onRemovePanel: (index: number) => void;
onExpandPanel: (index: number) => void;
}
export const MIN_PANEL_COUNT = 1;
export const MAX_PANEL_COUNT = 6;
export function MissionControlPanel({
panels,
onAddPanel,
onRemovePanel,
onExpandPanel,
}: MissionControlPanelProps): React.JSX.Element {
const expandedPanelIndex = panels.findIndex((panel) => panel.expanded);
const expandedPanel = expandedPanelIndex >= 0 ? panels[expandedPanelIndex] : undefined;
const canAddPanel = panels.length < MAX_PANEL_COUNT;
const canRemovePanel = panels.length > MIN_PANEL_COUNT;
useEffect(() => {
if (expandedPanelIndex < 0) {
return;
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
onExpandPanel(expandedPanelIndex);
}
};
window.addEventListener("keydown", handleKeyDown);
return (): void => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [expandedPanelIndex, onExpandPanel]);
return (
<div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-muted-foreground">Panels</h2>
<Button
type="button"
variant="outline"
size="icon"
onClick={onAddPanel}
disabled={!canAddPanel}
aria-label="Add panel"
title={canAddPanel ? "Add panel" : "Maximum of 6 panels"}
>
<span aria-hidden="true" className="text-lg leading-none">
+
</span>
</Button>
</div>
<div className="min-h-0 flex-1">
{expandedPanelIndex >= 0 && expandedPanel ? (
<div className="h-full min-h-0">
<OrchestratorPanel
{...(expandedPanel.sessionId !== undefined
? { sessionId: expandedPanel.sessionId }
: {})}
onClose={() => {
onRemovePanel(expandedPanelIndex);
}}
closeDisabled={!canRemovePanel}
onExpand={() => {
onExpandPanel(expandedPanelIndex);
}}
expanded
/>
</div>
) : (
<div className="grid h-full min-h-0 auto-rows-fr grid-cols-1 gap-4 overflow-y-auto pr-1 md:grid-cols-2 xl:grid-cols-3">
{panels.map((panel, index) => (
<OrchestratorPanel
key={`panel-${String(index)}`}
{...(panel.sessionId !== undefined ? { sessionId: panel.sessionId } : {})}
onClose={() => {
onRemovePanel(index);
}}
closeDisabled={!canRemovePanel}
onExpand={() => {
onExpandPanel(index);
}}
expanded={panel.expanded ?? false}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,218 +0,0 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
interface MockButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
}
interface MockContainerProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
}
type MockConnectionStatus = "connected" | "connecting" | "error";
type MockRole = "user" | "assistant" | "tool" | "system";
interface MockMessage {
id: string;
role: MockRole;
content: string;
timestamp: string;
}
interface MockSession {
id: string;
status: string;
}
interface MockSessionStreamResult {
messages: MockMessage[];
status: MockConnectionStatus;
error: string | null;
}
interface MockSessionsResult {
sessions: MockSession[];
loading: boolean;
error: Error | null;
}
interface MockPanelControlsProps {
sessionId: string;
status: string;
onStatusChange?: (nextStatus: string) => void;
}
interface MockBargeInInputProps {
sessionId: string;
}
const mockUseSessionStream = vi.fn<(sessionId: string) => MockSessionStreamResult>();
const mockUseSessions = vi.fn<() => MockSessionsResult>();
const mockPanelControls = vi.fn<(props: MockPanelControlsProps) => React.JSX.Element>();
const mockBargeInInput = vi.fn<(props: MockBargeInInputProps) => React.JSX.Element>();
vi.mock("date-fns", () => ({
formatDistanceToNow: (): string => "moments ago",
}));
vi.mock("@/hooks/useMissionControl", () => ({
useSessionStream: (sessionId: string): MockSessionStreamResult => mockUseSessionStream(sessionId),
useSessions: (): MockSessionsResult => mockUseSessions(),
}));
vi.mock("@/components/mission-control/PanelControls", () => ({
PanelControls: (props: MockPanelControlsProps): React.JSX.Element => mockPanelControls(props),
}));
vi.mock("@/components/mission-control/BargeInInput", () => ({
BargeInInput: (props: MockBargeInInputProps): React.JSX.Element => mockBargeInInput(props),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => (
<button {...props}>{children}</button>
),
}));
vi.mock("@/components/ui/badge", () => ({
Badge: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
<span {...props}>{children}</span>
),
}));
vi.mock("@/components/ui/card", () => ({
Card: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
<section {...props}>{children}</section>
),
CardHeader: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
<header {...props}>{children}</header>
),
CardContent: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
<div {...props}>{children}</div>
),
CardTitle: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
<h2 {...props}>{children}</h2>
),
}));
vi.mock("@/components/ui/scroll-area", () => ({
ScrollArea: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
<div {...props}>{children}</div>
),
}));
import { OrchestratorPanel } from "./OrchestratorPanel";
beforeAll((): void => {
Object.defineProperty(window.HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
describe("OrchestratorPanel", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn());
mockUseSessionStream.mockReturnValue({
messages: [],
status: "connecting",
error: null,
});
mockUseSessions.mockReturnValue({
sessions: [],
loading: false,
error: null,
});
mockPanelControls.mockImplementation(
({ status }: MockPanelControlsProps): React.JSX.Element => (
<div data-testid="panel-controls">status:{status}</div>
)
);
mockBargeInInput.mockImplementation(
({ sessionId }: MockBargeInInputProps): React.JSX.Element => (
<textarea aria-label="barge-input" data-session-id={sessionId} />
)
);
});
afterEach((): void => {
vi.unstubAllGlobals();
});
it("renders a selectable empty state when no session is provided", (): void => {
render(<OrchestratorPanel />);
expect(screen.getByText("Select an agent to view its stream")).toBeInTheDocument();
expect(screen.queryByText("Session: session-1")).not.toBeInTheDocument();
});
it("renders connection indicator and panel controls for an active session", (): void => {
mockUseSessionStream.mockReturnValue({
messages: [],
status: "connected",
error: null,
});
mockUseSessions.mockReturnValue({
sessions: [{ id: "session-1", status: "paused" }],
loading: false,
error: null,
});
render(<OrchestratorPanel sessionId="session-1" />);
expect(screen.getByText("Connected")).toBeInTheDocument();
expect(screen.getByText("Session: session-1")).toBeInTheDocument();
expect(screen.getByText("Waiting for messages...")).toBeInTheDocument();
expect(screen.getByTestId("panel-controls")).toHaveTextContent("status:paused");
});
it("renders stream messages with role and content", (): void => {
mockUseSessionStream.mockReturnValue({
status: "connected",
error: null,
messages: [
{
id: "msg-1",
role: "assistant",
content: "Mission accepted.",
timestamp: "2026-03-07T18:42:00.000Z",
},
],
});
render(<OrchestratorPanel sessionId="session-2" />);
expect(screen.getByText("assistant")).toBeInTheDocument();
expect(screen.getByText("Mission accepted.")).toBeInTheDocument();
expect(screen.getByText("moments ago")).toBeInTheDocument();
expect(screen.getByLabelText("barge-input")).toHaveAttribute("data-session-id", "session-2");
});
it("renders stream error text when the session has no messages", (): void => {
mockUseSessionStream.mockReturnValue({
messages: [],
status: "error",
error: "Mission Control stream disconnected.",
});
render(<OrchestratorPanel sessionId="session-3" />);
expect(screen.getByText("Error")).toBeInTheDocument();
expect(screen.getByText("Mission Control stream disconnected.")).toBeInTheDocument();
});
it("respects close button disabled state in panel actions", (): void => {
const onClose = vi.fn<() => void>();
render(<OrchestratorPanel onClose={onClose} closeDisabled />);
expect(screen.getByRole("button", { name: "Remove panel" })).toBeDisabled();
});
});

View File

@@ -1,217 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { BargeInInput } from "@/components/mission-control/BargeInInput";
import { Badge } from "@/components/ui/badge";
import type { BadgeVariant } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PanelControls } from "@/components/mission-control/PanelControls";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
useSessionStream,
useSessions,
type MissionControlConnectionStatus,
type MissionControlMessageRole,
} from "@/hooks/useMissionControl";
const ROLE_BADGE_VARIANT: Record<MissionControlMessageRole, BadgeVariant> = {
user: "badge-blue",
assistant: "status-success",
tool: "badge-amber",
system: "badge-muted",
};
const CONNECTION_DOT_CLASS: Record<MissionControlConnectionStatus, string> = {
connected: "bg-emerald-500",
connecting: "bg-amber-500",
error: "bg-red-500",
};
const CONNECTION_TEXT: Record<MissionControlConnectionStatus, string> = {
connected: "Connected",
connecting: "Connecting",
error: "Error",
};
export interface OrchestratorPanelProps {
sessionId?: string;
onClose?: () => void;
closeDisabled?: boolean;
onExpand?: () => void;
expanded?: boolean;
}
interface PanelHeaderActionsProps {
onClose?: () => void;
closeDisabled?: boolean;
onExpand?: () => void;
expanded?: boolean;
}
function PanelHeaderActions({
onClose,
closeDisabled = false,
onExpand,
expanded = false,
}: PanelHeaderActionsProps): React.JSX.Element | null {
if (!onClose && !onExpand) {
return null;
}
return (
<div className="flex items-center gap-1">
{onExpand ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onExpand}
aria-label={expanded ? "Collapse panel" : "Expand panel"}
title={expanded ? "Collapse panel" : "Expand panel"}
>
<span aria-hidden="true" className="text-base leading-none">
{expanded ? "↙" : "↗"}
</span>
</Button>
) : null}
{onClose ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onClose}
disabled={closeDisabled}
aria-label="Remove panel"
title="Remove panel"
>
<span aria-hidden="true" className="text-base leading-none">
×
</span>
</Button>
) : null}
</div>
);
}
function formatRelativeTimestamp(timestamp: string): string {
const parsedDate = new Date(timestamp);
if (Number.isNaN(parsedDate.getTime())) {
return "just now";
}
return formatDistanceToNow(parsedDate, { addSuffix: true });
}
export function OrchestratorPanel({
sessionId,
onClose,
closeDisabled,
onExpand,
expanded,
}: OrchestratorPanelProps): React.JSX.Element {
const { messages, status, error } = useSessionStream(sessionId ?? "");
const { sessions } = useSessions();
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
const [optimisticStatus, setOptimisticStatus] = useState<string | null>(null);
const selectedSessionStatus = sessions.find((session) => session.id === sessionId)?.status;
const controlsStatus = optimisticStatus ?? selectedSessionStatus ?? "unknown";
const panelHeaderActionProps = {
...(onClose !== undefined ? { onClose } : {}),
...(closeDisabled !== undefined ? { closeDisabled } : {}),
...(onExpand !== undefined ? { onExpand } : {}),
...(expanded !== undefined ? { expanded } : {}),
};
useEffect(() => {
bottomAnchorRef.current?.scrollIntoView({ block: "end" });
}, [messages.length]);
useEffect(() => {
setOptimisticStatus(null);
}, [sessionId, selectedSessionStatus]);
if (!sessionId) {
return (
<Card className="flex h-full min-h-[220px] flex-col">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base">Orchestrator Panel</CardTitle>
<PanelHeaderActions {...panelHeaderActionProps} />
</div>
</CardHeader>
<CardContent className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select an agent to view its stream
</CardContent>
</Card>
);
}
return (
<Card className="flex h-full min-h-[220px] flex-col">
<CardHeader className="space-y-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base">Orchestrator Panel</CardTitle>
<PanelHeaderActions {...panelHeaderActionProps} />
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span
className={`h-2.5 w-2.5 rounded-full ${CONNECTION_DOT_CLASS[status]} ${
status === "connecting" ? "animate-pulse" : ""
}`}
aria-hidden="true"
/>
<span>{CONNECTION_TEXT[status]}</span>
</div>
<PanelControls
sessionId={sessionId}
status={controlsStatus}
onStatusChange={setOptimisticStatus}
/>
</div>
<p className="truncate text-xs text-muted-foreground">Session: {sessionId}</p>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col p-0">
<div className="min-h-0 flex-1">
<ScrollArea className="h-full w-full">
<div className="flex min-h-full flex-col gap-3 p-4">
{messages.length === 0 ? (
<p className="mt-6 text-center text-sm text-muted-foreground">
{error ?? "Waiting for messages..."}
</p>
) : (
messages.map((message) => (
<article
key={message.id}
className="rounded-lg border border-border/70 bg-card px-3 py-2"
>
<div className="mb-2 flex items-center justify-between gap-2">
<Badge variant={ROLE_BADGE_VARIANT[message.role]} className="uppercase">
{message.role}
</Badge>
<time className="text-xs text-muted-foreground">
{formatRelativeTimestamp(message.timestamp)}
</time>
</div>
<p className="whitespace-pre-wrap break-words text-sm text-foreground">
{message.content}
</p>
</article>
))
)}
<div ref={bottomAnchorRef} />
</div>
</ScrollArea>
</div>
<div className="border-t border-border/70 p-3">
<BargeInInput sessionId={sessionId} />
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,161 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
interface MockButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
}
interface MockBadgeProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
}
const mockApiPost = vi.fn<(endpoint: string, body?: unknown) => Promise<{ message: string }>>();
vi.mock("@/lib/api/client", () => ({
apiPost: (endpoint: string, body?: unknown): Promise<{ message: string }> =>
mockApiPost(endpoint, body),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => (
<button {...props}>{children}</button>
),
}));
vi.mock("@/components/ui/badge", () => ({
Badge: ({ children, ...props }: MockBadgeProps): React.JSX.Element => (
<span {...props}>{children}</span>
),
}));
import { PanelControls } from "./PanelControls";
function renderWithQueryClient(ui: React.JSX.Element): ReturnType<typeof render> {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
describe("PanelControls", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn());
mockApiPost.mockResolvedValue({ message: "ok" });
});
afterEach((): void => {
vi.unstubAllGlobals();
});
it("renders action buttons with correct disabled state for active sessions", (): void => {
renderWithQueryClient(<PanelControls sessionId="session-1" status="active" />);
expect(screen.getByRole("button", { name: "Pause session" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Resume session" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Gracefully kill session" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Force kill session" })).toBeEnabled();
});
it("disables all action buttons when session is already killed", (): void => {
renderWithQueryClient(<PanelControls sessionId="session-2" status="killed" />);
expect(screen.getByRole("button", { name: "Pause session" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Resume session" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Gracefully kill session" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Force kill session" })).toBeDisabled();
});
it("pauses a running session and reports the next status", async (): Promise<void> => {
const onStatusChange = vi.fn<(status: string) => void>();
const user = userEvent.setup();
renderWithQueryClient(
<PanelControls
sessionId="session with space"
status="active"
onStatusChange={onStatusChange}
/>
);
await user.click(screen.getByRole("button", { name: "Pause session" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith(
"/api/mission-control/sessions/session%20with%20space/pause",
undefined
);
});
expect(onStatusChange).toHaveBeenCalledWith("paused");
});
it("asks for graceful kill confirmation before submitting", async (): Promise<void> => {
const onStatusChange = vi.fn<(status: string) => void>();
const user = userEvent.setup();
renderWithQueryClient(
<PanelControls sessionId="session-4" status="active" onStatusChange={onStatusChange} />
);
await user.click(screen.getByRole("button", { name: "Gracefully kill session" }));
expect(
screen.getByText("Gracefully stop this agent after it finishes the current step?")
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-4/kill", {
force: false,
});
});
expect(onStatusChange).toHaveBeenCalledWith("killed");
});
it("sends force kill after confirmation", async (): Promise<void> => {
const onStatusChange = vi.fn<(status: string) => void>();
const user = userEvent.setup();
renderWithQueryClient(
<PanelControls sessionId="session-5" status="paused" onStatusChange={onStatusChange} />
);
await user.click(screen.getByRole("button", { name: "Force kill session" }));
expect(screen.getByText("This will hard-kill the agent immediately.")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-5/kill", {
force: true,
});
});
expect(onStatusChange).toHaveBeenCalledWith("killed");
});
it("shows an error badge when an action fails", async (): Promise<void> => {
const user = userEvent.setup();
mockApiPost.mockRejectedValueOnce(new Error("unable to pause"));
renderWithQueryClient(<PanelControls sessionId="session-6" status="active" />);
await user.click(screen.getByRole("button", { name: "Pause session" }));
await waitFor((): void => {
expect(screen.getByText("unable to pause")).toBeInTheDocument();
});
});
});

View File

@@ -1,259 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { apiPost } from "@/lib/api/client";
const SESSIONS_QUERY_KEY = ["mission-control", "sessions"] as const;
type PanelAction = "pause" | "resume" | "graceful-kill" | "force-kill";
type KillConfirmationState = "graceful" | "force" | null;
interface PanelActionResult {
nextStatus: string;
}
export interface PanelControlsProps {
sessionId: string;
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
status: "active" | "paused" | "killed" | string;
onStatusChange?: (newStatus: string) => void;
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return "Failed to update agent session.";
}
export function PanelControls({
sessionId,
status,
onStatusChange,
}: PanelControlsProps): React.JSX.Element {
const queryClient = useQueryClient();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [confirmingKill, setConfirmingKill] = useState<KillConfirmationState>(null);
useEffect(() => {
setErrorMessage(null);
setConfirmingKill(null);
}, [sessionId]);
const controlMutation = useMutation({
mutationFn: async (action: PanelAction): Promise<PanelActionResult> => {
switch (action) {
case "pause":
await apiPost<{ message: string }>(
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause`
);
return { nextStatus: "paused" };
case "resume":
await apiPost<{ message: string }>(
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume`
);
return { nextStatus: "active" };
case "graceful-kill":
await apiPost<{ message: string }>(
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
{ force: false }
);
return { nextStatus: "killed" };
case "force-kill":
await apiPost<{ message: string }>(
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
{ force: true }
);
return { nextStatus: "killed" };
}
},
onSuccess: ({ nextStatus }): void => {
setErrorMessage(null);
setConfirmingKill(null);
onStatusChange?.(nextStatus);
void queryClient.invalidateQueries({ queryKey: SESSIONS_QUERY_KEY });
},
onError: (error: unknown): void => {
setConfirmingKill(null);
setErrorMessage(getErrorMessage(error));
},
});
const normalizedStatus = status.toLowerCase();
const isKilled = normalizedStatus === "killed";
const isBusy = controlMutation.isPending;
const pendingAction = isBusy ? controlMutation.variables : undefined;
const submitAction = (action: PanelAction): void => {
setErrorMessage(null);
controlMutation.mutate(action);
};
const pauseDisabled = isBusy || normalizedStatus === "paused" || isKilled;
const resumeDisabled = isBusy || normalizedStatus === "active" || isKilled;
const gracefulKillDisabled = isBusy || isKilled;
const forceKillDisabled = isBusy || isKilled;
return (
<div className="flex flex-col items-end gap-2">
<div className="flex flex-wrap items-center justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
submitAction("pause");
}}
disabled={pauseDisabled}
aria-label="Pause session"
>
{pendingAction === "pause" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : (
<span aria-hidden="true"></span>
)}
<span>Pause</span>
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
submitAction("resume");
}}
disabled={resumeDisabled}
aria-label="Resume session"
>
{pendingAction === "resume" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : (
<span aria-hidden="true"></span>
)}
<span>Resume</span>
</Button>
<div className="relative">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
setErrorMessage(null);
setConfirmingKill((current) => (current === "graceful" ? null : "graceful"));
}}
disabled={gracefulKillDisabled}
aria-label="Gracefully kill session"
>
{pendingAction === "graceful-kill" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : (
<span aria-hidden="true"></span>
)}
<span>Graceful Kill</span>
</Button>
{confirmingKill === "graceful" ? (
<div className="absolute right-0 top-[calc(100%+0.375rem)] z-20 w-72 rounded-md border border-border bg-card p-2 shadow-lg">
<p className="text-xs text-muted-foreground">
Gracefully stop this agent after it finishes the current step?
</p>
<div className="mt-2 flex justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setConfirmingKill(null);
}}
disabled={isBusy}
>
Cancel
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
submitAction("graceful-kill");
}}
disabled={isBusy}
>
{pendingAction === "graceful-kill" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : null}
<span>Confirm</span>
</Button>
</div>
</div>
) : null}
</div>
<div className="relative">
<Button
type="button"
size="sm"
variant="danger"
onClick={() => {
setErrorMessage(null);
setConfirmingKill((current) => (current === "force" ? null : "force"));
}}
disabled={forceKillDisabled}
aria-label="Force kill session"
>
{pendingAction === "force-kill" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : (
<span aria-hidden="true">💀</span>
)}
<span>Force Kill</span>
</Button>
{confirmingKill === "force" ? (
<div className="absolute right-0 top-[calc(100%+0.375rem)] z-20 w-72 rounded-md border border-border bg-card p-2 shadow-lg">
<p className="text-xs text-muted-foreground">
This will hard-kill the agent immediately.
</p>
<div className="mt-2 flex justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setConfirmingKill(null);
}}
disabled={isBusy}
>
Cancel
</Button>
<Button
type="button"
size="sm"
variant="danger"
onClick={() => {
submitAction("force-kill");
}}
disabled={isBusy}
>
{pendingAction === "force-kill" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : null}
<span>Confirm</span>
</Button>
</div>
</div>
) : null}
</div>
</div>
{errorMessage ? (
<Badge variant="status-error" className="max-w-[32rem] whitespace-normal text-xs">
{errorMessage}
</Badge>
) : null}
</div>
);
}

View File

@@ -1,133 +0,0 @@
import type { ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GlobalAgentRoster } from "../GlobalAgentRoster";
const { mockApiGet, mockApiPost } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
mockApiPost: vi.fn(),
}));
vi.mock("@/lib/api/client", () => ({
apiGet: mockApiGet,
apiPost: mockApiPost,
}));
function renderWithQueryClient(ui: ReactElement): void {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
describe("GlobalAgentRoster (__tests__)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn());
mockApiGet.mockReset();
mockApiPost.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("renders empty state when no sessions", async () => {
mockApiGet.mockResolvedValueOnce([]);
renderWithQueryClient(<GlobalAgentRoster />);
expect(await screen.findByText("No active agents")).toBeInTheDocument();
});
it("renders session rows grouped by provider", async () => {
mockApiGet.mockResolvedValueOnce([
{
id: "sess-int-123456",
providerId: "internal",
providerType: "internal",
status: "active",
createdAt: "2026-03-07T19:00:00.000Z",
updatedAt: "2026-03-07T19:00:00.000Z",
},
{
id: "sess-rem-654321",
providerId: "remote-a",
providerType: "remote",
status: "paused",
createdAt: "2026-03-07T19:00:00.000Z",
updatedAt: "2026-03-07T19:00:00.000Z",
},
]);
renderWithQueryClient(<GlobalAgentRoster />);
expect(await screen.findByText("internal")).toBeInTheDocument();
expect(screen.getByText("remote-a (remote)")).toBeInTheDocument();
expect(screen.getByText("sess-int")).toBeInTheDocument();
expect(screen.getByText("sess-rem")).toBeInTheDocument();
});
it("kill button per row calls the API", async () => {
const user = userEvent.setup();
mockApiGet.mockResolvedValueOnce([
{
id: "killme123456",
providerId: "internal",
providerType: "internal",
status: "active",
createdAt: "2026-03-07T19:00:00.000Z",
updatedAt: "2026-03-07T19:00:00.000Z",
},
]);
mockApiPost.mockResolvedValue({ message: "ok" });
renderWithQueryClient(<GlobalAgentRoster />);
const killButton = await screen.findByRole("button", { name: "Kill session killme12" });
await user.click(killButton);
await waitFor(() => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/killme123456/kill", {
force: false,
});
});
});
it("onSelectSession callback fires on row click", async () => {
const user = userEvent.setup();
const onSelectSession = vi.fn();
mockApiGet.mockResolvedValueOnce([
{
id: "selectme123456",
providerId: "internal",
providerType: "internal",
status: "active",
createdAt: "2026-03-07T19:00:00.000Z",
updatedAt: "2026-03-07T19:00:00.000Z",
},
]);
renderWithQueryClient(<GlobalAgentRoster onSelectSession={onSelectSession} />);
const sessionLabel = await screen.findByText("selectme");
const row = sessionLabel.closest('[role="button"]');
if (!row) {
throw new Error("Expected session row for selectme123456");
}
await user.click(row);
expect(onSelectSession).toHaveBeenCalledWith("selectme123456");
});
});

View File

@@ -1,96 +0,0 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentSession } from "@mosaic/shared";
import { KillAllDialog } from "../KillAllDialog";
import * as apiClient from "@/lib/api/client";
vi.mock("@/lib/api/client", () => ({
apiPost: vi.fn(),
}));
const mockApiPost = vi.mocked(apiClient.apiPost);
const baseDate = new Date("2026-03-07T14:00:00.000Z");
const sessions: AgentSession[] = [
{
id: "session-internal-1",
providerId: "provider-internal-1",
providerType: "internal",
status: "active",
createdAt: baseDate,
updatedAt: baseDate,
},
{
id: "session-internal-2",
providerId: "provider-internal-2",
providerType: "internal",
status: "paused",
createdAt: baseDate,
updatedAt: baseDate,
},
{
id: "session-external-1",
providerId: "provider-openclaw-1",
providerType: "openclaw",
status: "active",
createdAt: baseDate,
updatedAt: baseDate,
},
];
describe("KillAllDialog (__tests__)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn());
mockApiPost.mockResolvedValue({ message: "killed" } as never);
});
it('Confirm button disabled until "KILL ALL" typed exactly', async () => {
const user = userEvent.setup();
render(<KillAllDialog sessions={sessions} />);
await user.click(screen.getByRole("button", { name: "Kill All" }));
const input = screen.getByLabelText("Type KILL ALL to confirm");
const confirmButton = screen.getByRole("button", { name: "Kill All Agents" });
expect(confirmButton).toBeDisabled();
await user.type(input, "kill all");
expect(confirmButton).toBeDisabled();
await user.clear(input);
await user.type(input, "KILL ALL");
expect(confirmButton).toBeEnabled();
});
it("fires kill API for each session on confirm", async () => {
const user = userEvent.setup();
render(<KillAllDialog sessions={sessions} />);
await user.click(screen.getByRole("button", { name: "Kill All" }));
await user.click(screen.getByLabelText("All providers (3)"));
await user.type(screen.getByLabelText("Type KILL ALL to confirm"), "KILL ALL");
await user.click(screen.getByRole("button", { name: "Kill All Agents" }));
await waitFor(() => {
expect(mockApiPost).toHaveBeenCalledTimes(3);
});
expect(mockApiPost).toHaveBeenCalledWith(
"/api/mission-control/sessions/session-internal-1/kill",
{ force: true }
);
expect(mockApiPost).toHaveBeenCalledWith(
"/api/mission-control/sessions/session-internal-2/kill",
{ force: true }
);
expect(mockApiPost).toHaveBeenCalledWith(
"/api/mission-control/sessions/session-external-1/kill",
{ force: true }
);
});
});

View File

@@ -1,93 +0,0 @@
import { render, screen } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { OrchestratorPanel } from "../OrchestratorPanel";
import * as missionControlHooks from "@/hooks/useMissionControl";
vi.mock("@/hooks/useMissionControl", () => ({
useSessionStream: vi.fn(),
useSessions: vi.fn(),
}));
vi.mock("@/components/mission-control/PanelControls", () => ({
PanelControls: (): React.JSX.Element => <div data-testid="panel-controls" />,
}));
vi.mock("@/components/mission-control/BargeInInput", () => ({
BargeInInput: ({ sessionId }: { sessionId: string }): React.JSX.Element => (
<div data-testid="barge-in-input">barge-in:{sessionId}</div>
),
}));
vi.mock("date-fns", () => ({
formatDistanceToNow: (): string => "moments ago",
}));
const mockUseSessionStream = vi.mocked(missionControlHooks.useSessionStream);
const mockUseSessions = vi.mocked(missionControlHooks.useSessions);
beforeAll(() => {
Object.defineProperty(window.HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
describe("OrchestratorPanel (__tests__)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseSessionStream.mockReturnValue({
messages: [],
status: "connected",
error: null,
});
mockUseSessions.mockReturnValue({
sessions: [],
loading: false,
error: null,
});
});
it("renders empty state when no sessionId", () => {
render(<OrchestratorPanel />);
expect(screen.getByText("Select an agent to view its stream")).toBeInTheDocument();
});
it("renders connection indicator", () => {
const { container } = render(<OrchestratorPanel sessionId="session-1" />);
expect(screen.getByText("Connected")).toBeInTheDocument();
expect(container.querySelector(".bg-emerald-500")).toBeInTheDocument();
});
it("renders message list when messages are present", () => {
mockUseSessionStream.mockReturnValue({
messages: [
{
id: "msg-1",
sessionId: "session-1",
role: "assistant",
content: "Mission update one",
timestamp: "2026-03-07T21:00:00.000Z",
},
{
id: "msg-2",
sessionId: "session-1",
role: "tool",
content: "Mission update two",
timestamp: "2026-03-07T21:00:01.000Z",
},
],
status: "connected",
error: null,
});
render(<OrchestratorPanel sessionId="session-1" />);
expect(screen.getByText("Mission update one")).toBeInTheDocument();
expect(screen.getByText("Mission update two")).toBeInTheDocument();
expect(screen.queryByText("Waiting for messages...")).not.toBeInTheDocument();
});
});

View File

@@ -1,70 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PanelControls } from "../PanelControls";
import * as apiClient from "@/lib/api/client";
vi.mock("@/lib/api/client", () => ({
apiPost: vi.fn(),
}));
const mockApiPost = vi.mocked(apiClient.apiPost);
function renderPanelControls(status: string): void {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
render(
<QueryClientProvider client={queryClient}>
<PanelControls sessionId="session-1" status={status} />
</QueryClientProvider>
);
}
describe("PanelControls (__tests__)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn());
mockApiPost.mockResolvedValue({ message: "ok" } as never);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("Pause button disabled when status=paused", () => {
renderPanelControls("paused");
expect(screen.getByRole("button", { name: "Pause session" })).toBeDisabled();
});
it("Resume button disabled when status=active", () => {
renderPanelControls("active");
expect(screen.getByRole("button", { name: "Resume session" })).toBeDisabled();
});
it("Kill buttons disabled when status=killed", () => {
renderPanelControls("killed");
expect(screen.getByRole("button", { name: "Gracefully kill session" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Force kill session" })).toBeDisabled();
});
it("clicking pause calls the API", async () => {
const user = userEvent.setup();
renderPanelControls("active");
await user.click(screen.getByRole("button", { name: "Pause session" }));
await waitFor(() => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-1/pause");
});
});
});

View File

@@ -1,34 +0,0 @@
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MissionControlLayout } from "../MissionControlLayout";
vi.mock("@/components/mission-control/AuditLogDrawer", () => ({
AuditLogDrawer: ({ trigger }: { trigger: ReactNode }): React.JSX.Element => (
<div data-testid="audit-log-drawer">{trigger}</div>
),
}));
vi.mock("@/components/mission-control/GlobalAgentRoster", () => ({
GlobalAgentRoster: (): React.JSX.Element => <div data-testid="global-agent-roster" />,
}));
vi.mock("@/components/mission-control/MissionControlPanel", () => ({
MissionControlPanel: (): React.JSX.Element => <div data-testid="mission-control-panel" />,
MIN_PANEL_COUNT: 1,
MAX_PANEL_COUNT: 6,
}));
describe("Mission Control Phase 2 Gate", () => {
it("Phase 2 gate: MissionControlLayout renders with all components present", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation((..._args) => undefined);
render(<MissionControlLayout />);
expect(screen.getByTestId("global-agent-roster")).toBeInTheDocument();
expect(screen.getByTestId("mission-control-panel")).toBeInTheDocument();
expect(consoleErrorSpy).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});

View File

@@ -102,5 +102,5 @@ describe("OnboardingWizard", () => {
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/");
});
}, 10_000);
});
});

View File

@@ -1,13 +0,0 @@
import * as React from "react";
export interface CollapsibleProps extends React.HTMLAttributes<HTMLDivElement> {
open?: boolean;
}
export function Collapsible({
open = true,
className = "",
...props
}: CollapsibleProps): React.JSX.Element {
return <div data-state={open ? "open" : "closed"} className={className} {...props} />;
}

View File

@@ -1,15 +0,0 @@
import * as React from "react";
export type ScrollAreaProps = React.HTMLAttributes<HTMLDivElement>;
export const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
({ className = "", children, ...props }, ref) => {
return (
<div ref={ref} className={`relative overflow-hidden ${className}`} {...props}>
<div className="h-full w-full overflow-auto">{children}</div>
</div>
);
}
);
ScrollArea.displayName = "ScrollArea";

View File

@@ -1,137 +0,0 @@
import * as React from "react";
import { X } from "lucide-react";
export interface SheetProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
}
export interface SheetTriggerProps {
children?: React.ReactNode;
asChild?: boolean;
}
export interface SheetContentProps {
children?: React.ReactNode;
className?: string;
}
export interface SheetHeaderProps {
children?: React.ReactNode;
className?: string;
}
export interface SheetTitleProps {
children?: React.ReactNode;
className?: string;
}
export interface SheetDescriptionProps {
children?: React.ReactNode;
className?: string;
}
const SheetContext = React.createContext<{
open?: boolean;
onOpenChange?: (open: boolean) => void;
}>({});
export function Sheet({ open, onOpenChange, children }: SheetProps): React.JSX.Element {
const contextValue: { open?: boolean; onOpenChange?: (open: boolean) => void } = {};
if (open !== undefined) {
contextValue.open = open;
}
if (onOpenChange !== undefined) {
contextValue.onOpenChange = onOpenChange;
}
return <SheetContext.Provider value={contextValue}>{children}</SheetContext.Provider>;
}
export function SheetTrigger({ children, asChild }: SheetTriggerProps): React.JSX.Element {
const { onOpenChange } = React.useContext(SheetContext);
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
onClick: () => onOpenChange?.(true),
} as React.HTMLAttributes<HTMLElement>);
}
return (
<button type="button" onClick={() => onOpenChange?.(true)}>
{children}
</button>
);
}
export function SheetContent({
children,
className = "",
}: SheetContentProps): React.JSX.Element | null {
const { open, onOpenChange } = React.useContext(SheetContext);
React.useEffect(() => {
if (!open) {
return;
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
onOpenChange?.(false);
}
};
window.addEventListener("keydown", onKeyDown);
return (): void => {
window.removeEventListener("keydown", onKeyDown);
};
}, [onOpenChange, open]);
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-50">
<button
type="button"
aria-label="Close sheet"
className="absolute inset-0 h-full w-full bg-black/50"
onClick={() => onOpenChange?.(false)}
/>
<div
className={`absolute inset-y-0 right-0 z-10 flex h-full w-full max-w-3xl flex-col border-l border-border bg-background p-6 shadow-xl ${className}`}
>
<button
type="button"
onClick={() => onOpenChange?.(false)}
className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
{children}
</div>
</div>
);
}
export function SheetHeader({ children, className = "" }: SheetHeaderProps): React.JSX.Element {
return <div className={`space-y-1 pr-8 ${className}`}>{children}</div>;
}
export function SheetTitle({ children, className = "" }: SheetTitleProps): React.JSX.Element {
return <h2 className={`text-lg font-semibold ${className}`}>{children}</h2>;
}
export function SheetDescription({
children,
className = "",
}: SheetDescriptionProps): React.JSX.Element {
return <p className={`text-sm text-muted-foreground ${className}`}>{children}</p>;
}

View File

@@ -1,15 +0,0 @@
import * as React from "react";
export type SkeletonProps = React.HTMLAttributes<HTMLDivElement>;
export const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`animate-pulse rounded-md bg-[rgb(var(--surface-2))] ${className}`}
{...props}
/>
)
);
Skeleton.displayName = "Skeleton";

View File

@@ -1,189 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { AgentMessageRole, AgentSessionStatus } from "@mosaic/shared";
import { apiGet } from "@/lib/api/client";
const MISSION_CONTROL_SESSIONS_QUERY_KEY = ["mission-control", "sessions"] as const;
const SESSIONS_REFRESH_INTERVAL_MS = 15_000;
export type MissionControlMessageRole = AgentMessageRole;
export interface MissionControlSession {
id: string;
providerId: string;
providerType: string;
label?: string;
status: AgentSessionStatus;
parentSessionId?: string;
createdAt: string;
updatedAt: string;
metadata?: Record<string, unknown>;
}
interface MissionControlSessionsResponse {
sessions: MissionControlSession[];
}
export interface MissionControlStreamMessage {
id: string;
sessionId: string;
role: MissionControlMessageRole;
content: string;
timestamp: string;
metadata?: Record<string, unknown>;
}
export type MissionControlConnectionStatus = "connecting" | "connected" | "error";
export interface UseSessionsResult {
sessions: MissionControlSession[];
loading: boolean;
error: Error | null;
}
export interface UseSessionStreamResult {
messages: MissionControlStreamMessage[];
status: MissionControlConnectionStatus;
error: string | null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isMessageRole(value: unknown): value is MissionControlMessageRole {
return value === "assistant" || value === "system" || value === "tool" || value === "user";
}
function isMissionControlStreamMessage(value: unknown): value is MissionControlStreamMessage {
if (!isRecord(value)) {
return false;
}
const { id, sessionId, role, content, timestamp, metadata } = value;
if (
typeof id !== "string" ||
typeof sessionId !== "string" ||
!isMessageRole(role) ||
typeof content !== "string" ||
typeof timestamp !== "string"
) {
return false;
}
if (metadata !== undefined && !isRecord(metadata)) {
return false;
}
return true;
}
/**
* Fetches Mission Control sessions.
*/
export function useSessions(): UseSessionsResult {
const query = useQuery<MissionControlSessionsResponse>({
queryKey: MISSION_CONTROL_SESSIONS_QUERY_KEY,
queryFn: async (): Promise<MissionControlSessionsResponse> => {
return apiGet<MissionControlSessionsResponse>("/api/mission-control/sessions");
},
refetchInterval: SESSIONS_REFRESH_INTERVAL_MS,
});
return {
sessions: query.data?.sessions ?? [],
loading: query.isLoading,
error: query.error ?? null,
};
}
/**
* Backward-compatible alias for early Mission Control integration.
*/
export function useMissionControl(): UseSessionsResult {
return useSessions();
}
/**
* Streams Mission Control session messages over SSE.
*/
export function useSessionStream(sessionId: string): UseSessionStreamResult {
const [messages, setMessages] = useState<MissionControlStreamMessage[]>([]);
const [status, setStatus] = useState<MissionControlConnectionStatus>("connecting");
const [error, setError] = useState<string | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (eventSourceRef.current !== null) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setMessages([]);
setError(null);
if (!sessionId) {
setStatus("connecting");
return;
}
if (typeof EventSource === "undefined") {
setStatus("error");
setError("Mission Control stream is not supported by this browser.");
return;
}
setStatus("connecting");
const source = new EventSource(
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/stream`
);
eventSourceRef.current = source;
source.onopen = (): void => {
setStatus("connected");
setError(null);
};
source.onmessage = (event: MessageEvent<string>): void => {
try {
const parsed = JSON.parse(event.data) as unknown;
if (!isMissionControlStreamMessage(parsed)) {
return;
}
setMessages((previousMessages) => [...previousMessages, parsed]);
} catch {
// Ignore malformed events from the stream.
}
};
source.onerror = (): void => {
if (source.readyState === EventSource.CONNECTING) {
setStatus("connecting");
setError(null);
return;
}
setStatus("error");
setError("Mission Control stream disconnected.");
};
return (): void => {
source.close();
if (eventSourceRef.current === source) {
eventSourceRef.current = null;
}
};
}, [sessionId]);
return {
messages,
status,
error,
};
}

View File

@@ -8,64 +8,62 @@
**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.
**PRD:** `docs/PRD-MS22-P2-AGENT-FLEET.md`
**Phase:** Completion
**Status:** completed
**Phase:** Execution
**Status:** in-progress
**Last Updated:** 2026-03-05
## Success Criteria
1. AgentTemplate and UserAgent tables exist and are seeded with jarvis/builder/medic
2. Admin CRUD endpoints at `/admin/agent-templates` work and are guarded
3. User agent CRUD endpoints allow per-user agent customization
4. Chat proxy routes messages to correct agent by name
5. Discord channel → agent routing maps #jarvis/#builder/#medic-alerts
6. WebUI shows agent selector and connects to correct agent
7. All CI gates green
1. AgentTemplate and UserAgent tables exist and are seeded with jarvis/builder/medic
2. Admin CRUD endpoints at `/admin/agent-templates` work and are guarded
3. User agent CRUD endpoints allow per-user agent customization
4. Chat proxy routes messages to correct agent by name
5. Discord channel → agent routing maps #jarvis/#builder/#medic-alerts
6. WebUI shows agent selector and connects to correct agent
7. All CI gates green
## Milestones
| # | ID | Name | Status | Tasks | Notes |
| --- | ------------- | ------------- | ------- | ---------------------- | --------------------------- |
| --- | ------------- | ------------- | ---------- | -------------- | --------------------- |
| 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 |
| 3 | user-crud | User CRUD | ✅ done | P2-004 | PR #682 merged |
| 4 | agent-routing | Agent Routing | ✅ done | P2-005, P2-006 | PR #684 merged |
| 5 | discord-ui | Discord+UI | ✅ done | P2-007, P2-008, P2-009 | PRs #685, #687, #688 merged |
| 6 | verification | Verification | ✅ done | P2-010 | All CI gates green |
| 5 | discord-ui | Discord+UI | 🔄 partial | P2-007, P2-008 | P2-008 done (PR #685) |
| 6 | verification | Verification | ⬜ pending | P2-009, P2-010 | Final gate |
## Task Summary
See `docs/TASKS.md` — MS22 Phase 2 section for full task details.
| Task | Status | PR | Notes |
| ----------------------- | ------- | ---- | ------------------------------ |
| ----------------------- | -------------- | ---- | ------------------------------ |
| P2-001 Schema | ✅ done | #675 | AgentTemplate + UserAgent |
| P2-002 Seed | ✅ done | #677 | jarvis/builder/medic templates |
| P2-003 Admin CRUD | ✅ done | #678 | /admin/agent-templates |
| P2-004 User CRUD | ✅ done | #682 | /api/agents |
| P2-005 Status endpoints | ✅ done | #684 | Agent status API |
| P2-006 Chat routing | ✅ done | #684 | Agent routing in chat proxy |
| P2-007 Discord routing | ✅ done | #688 | Channel → agent routing |
| P2-007 Discord routing | ⬜ not-started | — | |
| P2-008 WebUI selector | ✅ done | #685 | AgentSelector component |
| P2-009 Unit tests | ✅ done | #687 | Agent services tests |
| P2-010 E2E verification | ✅ done | — | 3547 tests pass, CI green |
| P2-009 Unit tests | ⬜ not-started | — | |
| P2-010 E2E verification | ⬜ not-started | — | |
## Token Budget
| Phase | Est | Used |
| ----------------- | -------- | -------- |
| ----------------- | -------- | ------------------ |
| Schema+Seed+CRUD | 30K | ~15K |
| User CRUD+Routing | 40K | ~25K |
| Discord+UI | 30K | ~24K |
| Verification | 10K | ~5K |
| **Total** | **110K** | **~69K** |
| Discord+UI | 30K | ~15K (P2-008 done) |
| Verification | 10K | |
| **Total** | **110K** | **~55K** |
## Session Log
| Date | Work Done |
| ---------- | ----------------------------------------------------------------------------------------------------------------- |
| 2026-03-05 | Session 5: Completed P2-010 E2E verification. All 10 tasks done. Mission complete. |
| 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

@@ -1,555 +0,0 @@
# 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

@@ -95,91 +95,14 @@ Design doc: `docs/design/MS22-DB-CENTRIC-ARCHITECTURE.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 |
| ----------- | ------ | -------- | -------------------------------------------- | -------- | ----- | --------------------------- | ------------- | ------------- | --------------- | ---------- | ---------- | ---------- | ---------- | --------------- |
| ----------- | ----------- | -------- | -------------------------------------------- | -------- | ----- | --------------------------- | ------------- | ------------- | --------------- | ---------- | ---------- | ---------- | ---------- | -------------- |
| MS22-P2-001 | done | p2-fleet | Prisma schema: AgentTemplate, UserAgent | TASKS:P2 | api | feat/ms22-p2-agent-schema | MS22-P1a | P2-002,P2-003 | orchestrator | 2026-03-04 | 2026-03-04 | 10K | 3K | PR #675 merged |
| MS22-P2-002 | done | p2-fleet | Seed default agents (jarvis, builder, medic) | TASKS:P2 | api | feat/ms22-p2-agent-seed | P2-001 | P2-004 | orchestrator | 2026-03-04 | 2026-03-04 | 5K | 2K | PR #677 merged |
| MS22-P2-003 | done | p2-fleet | Agent template CRUD endpoints (admin) | TASKS:P2 | api | feat/ms22-p2-agent-crud | P2-001 | P2-005 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 5K | PR #678 merged |
| MS22-P2-004 | done | p2-fleet | User agent CRUD endpoints | TASKS:P2 | api | feat/ms22-p2-user-agents | P2-002,P2-003 | P2-006 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 8K | PR #682 merged |
| MS22-P2-005 | done | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-routing | P2-003 | P2-008 | orchestrator | 2026-03-04 | 2026-03-04 | 10K | 5K | PR #684 merged |
| MS22-P2-006 | done | p2-fleet | Agent chat routing (select agent by name) | TASKS:P2 | api | feat/ms22-p2-agent-routing | P2-004 | P2-007 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 5K | PR #684 merged |
| MS22-P2-007 | done | p2-fleet | Discord channel → agent routing | TASKS:P2 | api | feat/ms22-p2-discord-router | P2-006 | P2-010 | orchestrator | 2026-03-05 | 2026-03-05 | 15K | 8K | PR #688 |
| MS22-P2-007 | not-started | p2-fleet | Discord channel → agent routing | TASKS:P2 | api | feat/ms22-p2-discord-router | P2-006 | P2-009 | — | — | — | 15K | | |
| MS22-P2-008 | done | p2-fleet | Agent list/selector UI in WebUI | TASKS:P2 | web | feat/ms22-p2-agent-ui | P2-005 | — | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 8K | PR #685 merged |
| MS22-P2-009 | done | p2-fleet | Unit tests for agent services | TASKS:P2 | api | test/ms22-p2-agent-tests | P2-006 | P2-010 | orchestrator | 2026-03-04 | 2026-03-05 | 15K | 8K | PR #687 merged |
| MS22-P2-010 | done | p2-fleet | E2E verification: Discord → agent → response | TASKS:P2 | stack | — | P2-009 | — | orchestrator | 2026-03-05 | 2026-03-05 | 10K | 5K | All gates green |
---
## 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 | done | 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 | 2026-03-07 | 20K | — | |
| MS23-P0-003 | done | p0-foundation | Orchestrator API: GET /agents/:id/messages + SSE stream endpoint | #693 | orchestrator | feat/ms23-p0-stream | MS23-P0-001 | MS23-P0-006 | codex | 2026-03-06 | 2026-03-07 | 20K | — | |
| MS23-P0-004 | done | p0-foundation | Orchestrator API: POST /agents/:id/inject + pause/resume endpoints | #693 | orchestrator | feat/ms23-p0-controls | MS23-P0-001 | MS23-P0-006 | codex | 2026-03-07 | 2026-03-07 | 15K | — | |
| MS23-P0-005 | done | 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 | done | 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 | codex | 2026-03-07 | 2026-03-07 | 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).
| MS22-P2-009 | not-started | p2-fleet | Unit tests for agent services | TASKS:P2 | api | test/ms22-p2-agent-tests | P2-007 | P2-010 | — | — | — | 15K | | |
| MS22-P2-010 | not-started | p2-fleet | E2E verification: Discord → agent → response | TASKS:P2 | stack | — | P2-009 | — | — | — | — | 10K | | |

View File

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

View File

@@ -1,78 +0,0 @@
// Agent message roles
export type AgentMessageRole = "user" | "assistant" | "system" | "tool";
// A single message in an agent conversation
export interface AgentMessage {
id: string;
sessionId: string;
role: AgentMessageRole;
content: string;
timestamp: Date;
metadata?: Record<string, unknown>;
}
// Session lifecycle status
export type AgentSessionStatus = "active" | "paused" | "completed" | "failed" | "idle";
// An agent session (conversation thread)
export interface AgentSession {
id: string;
providerId: string; // which provider owns this session
providerType: string; // "internal" | "openclaw" | etc.
label?: string;
status: AgentSessionStatus;
parentSessionId?: string; // for subagent trees
createdAt: Date;
updatedAt: Date;
metadata?: Record<string, unknown>;
}
// Result of listing sessions
export interface AgentSessionList {
sessions: AgentSession[];
total: number;
cursor?: string;
}
// Result of injecting a message
export interface InjectResult {
accepted: boolean;
messageId?: string;
}
// The IAgentProvider interface — every provider (internal, OpenClaw, future) implements this
export interface IAgentProvider {
readonly providerId: string;
readonly providerType: string;
readonly displayName: string;
// Session management
listSessions(cursor?: string, limit?: number): Promise<AgentSessionList>;
getSession(sessionId: string): Promise<AgentSession | null>;
getMessages(sessionId: string, limit?: number, before?: string): Promise<AgentMessage[]>;
// Control operations
injectMessage(sessionId: string, content: string): Promise<InjectResult>;
pauseSession(sessionId: string): Promise<void>;
resumeSession(sessionId: string): Promise<void>;
killSession(sessionId: string, force?: boolean): Promise<void>;
// SSE streaming — returns an AsyncIterable of AgentMessage events
streamMessages(sessionId: string): AsyncIterable<AgentMessage>;
// Health
isAvailable(): Promise<boolean>;
}
// Provider configuration stored in DB (AgentProviderConfig model from P0 schema)
export interface AgentProviderConfig {
id: string;
providerId: string;
providerType: string;
displayName: string;
baseUrl?: string;
apiToken?: string;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -134,6 +134,3 @@ export * from "./widget.types";
// Export WebSocket types
export * from "./websocket.types";
// Export agent provider types
export * from "./agent-provider.types";

24
pnpm-lock.yaml generated
View File

@@ -337,9 +337,6 @@ importers:
'@nestjs/throttler':
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)
'@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:
specifier: ^5.67.2
version: 5.67.2
@@ -389,9 +386,6 @@ importers:
'@vitest/coverage-v8':
specifier: ^4.0.18
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
prisma:
specifier: ^6.19.2
version: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@swc/core@1.15.11)(@types/node@22.19.7)(typescript@5.9.3)
@@ -8011,7 +8005,7 @@ snapshots:
chalk: 5.6.2
commander: 12.1.0
dotenv: 17.2.4
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
open: 10.2.0
pg: 8.17.2
prettier: 3.8.1
@@ -11351,7 +11345,7 @@ snapshots:
optionalDependencies:
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
better-sqlite3: 12.6.2
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -11376,7 +11370,7 @@ snapshots:
optionalDependencies:
'@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
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -12200,6 +12194,17 @@ snapshots:
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)):
optionalDependencies:
'@opentelemetry/api': 1.9.0
@@ -12210,6 +12215,7 @@ snapshots:
pg: 8.17.2
postgres: 3.4.8
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
optional: true
dunder-proto@1.0.1:
dependencies: