Compare commits

...

41 Commits

Author SHA1 Message Date
11fa1734bd Merge pull request 'chore(tasks): mark MS24 tasks done' (#752) from chore/ms24-tasks-done into main 2026-03-08 23:25:43 +00:00
46815707a9 chore(tasks): mark MS24 tasks done 2026-03-08 18:25:28 -05:00
621df6ee70 chore(tasks): add MS24 queue integration tasks 2026-03-08 18:25:20 -05:00
ac406f19bc Merge pull request 'feat(web): MS24 queue notification feed component' (#751) from feat/ms24-queue-ui into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-08 23:21:23 +00:00
72d295edd6 feat(web): add queue notification feed
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-08 18:18:07 -05:00
6e9def3c5a Merge pull request 'feat(api): MS24 queue notifications module' (#750) from feat/ms24-queue-api into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-08 23:04:16 +00:00
456d53fc7f fix(api): add SkipCsrf to queue notification ack endpoint
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-08 17:54:35 -05:00
8014930b70 Merge pull request 'fix(api): proxy mission-control routes to orchestrator' (#748) from fix/mission-control-proxy into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-08 22:51:08 +00:00
06f2cc4be3 feat(api): add queue notifications module
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-08 17:27:41 -05:00
04bbdf3308 fix(api): proxy mission-control routes to orchestrator
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-08 14:04:12 -05:00
a6f1438f40 fix(web): route Mission Control API calls through orchestrator proxy (#747)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-08 17:00:27 +00:00
523662656e Merge pull request 'fix(deploy): use consistent network alias for openbrain-brain-internal' (#746) from fix/compose-network-alias-consistency into main 2026-03-08 16:35:32 +00:00
27120ac3f2 fix(deploy): use consistent network alias for openbrain-brain-internal
All checks were successful
ci/woodpecker/push/ci Infra-only: compose YAML fix, no app code
Service definitions were using 'openbrain_brain-internal' (underscore) but the
networks block defines the alias as 'openbrain-brain-internal' (hyphen), with
name: openbrain_brain-internal pointing to the actual Docker network.

This caused 'undefined network' errors on every Portainer deploy for
orchestrator and synapse services.

Fixed: all service network references now use 'openbrain-brain-internal'.
2026-03-08 11:34:38 -05:00
ad9921107c fix(deploy): add DATABASE_URL and openbrain network to orchestrator + synapse (#745)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-08 15:51:22 +00:00
3c288f9849 fix(web): add ReactQueryProvider to root layout for Mission Control (#744)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-08 15:50:40 +00:00
51d6302401 Merge pull request 'style(web): fix prettier formatting in AuditLogDrawer' (#743) from fix/audit-drawer-format into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-08 01:56:03 +00:00
cf490510bf style(web): fix prettier formatting in AuditLogDrawer
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 19:54:08 -06:00
3d91334df7 Merge pull request 'fix(mission-control): increase rate limit for events/recent, add error handling' (#742) from fix/mission-control-ratelimit into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2026-03-08 00:44:31 +00:00
e80b624ca6 fix(mission-control): increase rate limit for events/recent, add error handling
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2026-03-07 18:42:50 -06:00
65536fcb75 Merge pull request 'fix(orchestrator): add missing module import for OrchestratorApiKeyGuard' (#741) from fix/orchestrator-guard-import into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 23:36:55 +00:00
53915dc621 fix(orchestrator): add missing module import for OrchestratorApiKeyGuard
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 17:35:57 -06:00
398ee06920 Merge pull request 'chore: release v0.0.23 — Mission Control Dashboard' (#740) from chore/ms23-v0.0.23-release into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 23:01:19 +00:00
2182717f59 chore: release v0.0.23 — Mission Control Dashboard
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 17:00:15 -06:00
fe55363f38 Merge pull request 'chore: MS23-P4-001 QA gate — lint/typecheck/test all green' (#739) from chore/ms23-p4-qa into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 22:57:06 +00:00
d60165572a fix(orchestrator): encrypt OpenClaw provider tokens at rest
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 16:55:51 -06:00
ff73fbd391 Merge pull request 'test(orchestrator): MS23-P3-004 OpenClaw provider E2E — Phase 3 gate' (#738) from test/ms23-p3 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 22:46:21 +00:00
95ec63a868 test(orchestrator): MS23-P3-004 OpenClaw provider E2E — Phase 3 gate
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 16:44:57 -06:00
2ab736b68b Merge pull request 'feat(web): MS23-P3-003 OpenClaw provider config UI' (#736) from feat/ms23-p3-config-ui into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 22:33:22 +00:00
30e0168983 Merge pull request 'feat(orchestrator): MS23-P3-002 OpenClaw SSE bridge' (#737) from feat/ms23-p3-openclaw-bridge into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 22:33:13 +00:00
495d78115e feat(orchestrator): add OpenClaw SSE bridge streaming
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 16:20:27 -06:00
54ee5cf945 feat(web): MS23-P3-003 OpenClaw provider config UI
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 16:18:21 -06:00
563d59ad5d Merge pull request 'feat(orchestrator): MS23-P3-001 OpenClawProvider' (#735) from feat/ms23-p3-openclaw-provider into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 22:06:31 +00:00
da6e055113 feat(orchestrator): MS23-P3-001 OpenClawProvider
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 15:58:56 -06:00
0441d44f42 Merge pull request 'test(web): MS23-P2-009 Mission Control frontend tests' (#733) from test/ms23-p2 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 21:38:36 +00:00
7147dc3503 test(web): MS23-P2-009 Mission Control frontend tests
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 15:27:36 -06:00
f0aa3b5a75 test(web): add Mission Control frontend tests for phase 2 gate
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 15:25:39 -06:00
11d64341b1 Merge pull request 'fix(web): MS23-P2 restore main branch CI' (#732) from fix/ms23-p2-main into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 21:08:54 +00:00
90d2fa7563 fix(web): MS23-P2 fix broken lint/typecheck on main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 14:55:04 -06:00
31af6c26ec Merge pull request 'feat(web): MS23-P2-008 Panel grid responsive layout' (#731) from feat/ms23-p2-grid into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 20:48:36 +00:00
e4f942dde7 feat(web): MS23-P2-008 panel grid responsive layout
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 14:47:39 -06:00
4ea31c5749 Merge pull request 'feat: MS23-P2-007 AuditLogDrawer + audit log endpoint' (#730) from feat/ms23-p2-audit into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 20:40:21 +00:00
65 changed files with 6620 additions and 90 deletions

View File

@@ -56,6 +56,7 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-auth": "^1.4.17", "better-auth": "^1.4.17",
"bullmq": "^5.67.2", "bullmq": "^5.67.2",
"chokidar": "^4.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",

View File

@@ -60,7 +60,9 @@ import { ContainerReaperModule } from "./container-reaper/container-reaper.modul
import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module"; import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module";
import { OnboardingModule } from "./onboarding/onboarding.module"; import { OnboardingModule } from "./onboarding/onboarding.module";
import { ChatProxyModule } from "./chat-proxy/chat-proxy.module"; import { ChatProxyModule } from "./chat-proxy/chat-proxy.module";
import { MissionControlProxyModule } from "./mission-control-proxy/mission-control-proxy.module";
import { OrchestratorModule } from "./orchestrator/orchestrator.module"; import { OrchestratorModule } from "./orchestrator/orchestrator.module";
import { QueueNotificationsModule } from "./queue-notifications/queue-notifications.module";
@Module({ @Module({
imports: [ imports: [
@@ -142,7 +144,9 @@ import { OrchestratorModule } from "./orchestrator/orchestrator.module";
FleetSettingsModule, FleetSettingsModule,
OnboardingModule, OnboardingModule,
ChatProxyModule, ChatProxyModule,
MissionControlProxyModule,
OrchestratorModule, OrchestratorModule,
QueueNotificationsModule,
], ],
controllers: [AppController, CsrfController], controllers: [AppController, CsrfController],
providers: [ providers: [

View File

@@ -0,0 +1,286 @@
import {
Body,
Controller,
Get,
Logger,
Param,
Post,
Req,
Res,
ServiceUnavailableException,
UseGuards,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { AuthGuard } from "../auth/guards/auth.guard";
const ORCHESTRATOR_URL_KEY = "ORCHESTRATOR_URL";
const ORCHESTRATOR_API_KEY = "ORCHESTRATOR_API_KEY";
@Controller("mission-control")
@UseGuards(AuthGuard)
export class MissionControlProxyController {
private readonly logger = new Logger(MissionControlProxyController.name);
private readonly orchestratorUrl: string;
private readonly orchestratorApiKey: string;
constructor() {
this.orchestratorUrl = this.requireEnv(ORCHESTRATOR_URL_KEY);
this.orchestratorApiKey = this.requireEnv(ORCHESTRATOR_API_KEY);
}
@Get("sessions")
proxySessions(@Req() req: Request, @Res() res: Response): Promise<void> {
return this.proxyRequest("GET", "sessions", req, res);
}
@Get("sessions/:sessionId")
proxySession(
@Param("sessionId") sessionId: string,
@Req() req: Request,
@Res() res: Response
): Promise<void> {
return this.proxyRequest("GET", `sessions/${sessionId}`, req, res);
}
@Get("sessions/:sessionId/messages")
proxyMessages(
@Param("sessionId") sessionId: string,
@Req() req: Request,
@Res() res: Response
): Promise<void> {
return this.proxyRequest("GET", `sessions/${sessionId}/messages`, req, res);
}
@Get("audit-log")
proxyAuditLog(@Req() req: Request, @Res() res: Response): Promise<void> {
return this.proxyRequest("GET", "audit-log", req, res);
}
@Post("sessions/:sessionId/inject")
proxyInject(
@Param("sessionId") sessionId: string,
@Body() body: unknown,
@Req() req: Request,
@Res() res: Response
): Promise<void> {
return this.proxyRequest("POST", `sessions/${sessionId}/inject`, req, res, body);
}
@Post("sessions/:sessionId/pause")
proxyPause(
@Param("sessionId") sessionId: string,
@Req() req: Request,
@Res() res: Response
): Promise<void> {
return this.proxyRequest("POST", `sessions/${sessionId}/pause`, req, res);
}
@Post("sessions/:sessionId/resume")
proxyResume(
@Param("sessionId") sessionId: string,
@Req() req: Request,
@Res() res: Response
): Promise<void> {
return this.proxyRequest("POST", `sessions/${sessionId}/resume`, req, res);
}
@Post("sessions/:sessionId/kill")
proxyKill(
@Param("sessionId") sessionId: string,
@Body() body: unknown,
@Req() req: Request,
@Res() res: Response
): Promise<void> {
return this.proxyRequest("POST", `sessions/${sessionId}/kill`, req, res, body);
}
@Get("sessions/:sessionId/stream")
async proxySessionStream(
@Param("sessionId") sessionId: string,
@Req() req: Request,
@Res() res: Response
): Promise<void> {
const abortController = new AbortController();
req.once("close", () => {
abortController.abort();
});
try {
const upstream = await this.fetchUpstream(
"GET",
`sessions/${sessionId}/stream`,
req.query,
undefined,
abortController.signal
);
if (!upstream.ok || !upstream.body) {
await this.sendStandardResponse(upstream, res);
return;
}
res.status(upstream.status);
this.copyHeaderIfPresent(upstream, res, "content-type");
this.copyHeaderIfPresent(upstream, res, "cache-control");
this.copyHeaderIfPresent(upstream, res, "connection");
res.setHeader("X-Accel-Buffering", "no");
if (typeof res.flushHeaders === "function") {
res.flushHeaders();
}
for await (const chunk of upstream.body as unknown as AsyncIterable<Uint8Array>) {
if (res.writableEnded || res.destroyed) {
break;
}
res.write(Buffer.from(chunk));
}
if (!res.writableEnded && !res.destroyed) {
res.end();
}
} catch (error: unknown) {
if (this.isAbortError(error)) {
return;
}
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Mission Control stream proxy request failed: ${message}`);
if (!res.headersSent) {
res.status(503).json({ message: "Failed to proxy Mission Control request" });
} else if (!res.writableEnded && !res.destroyed) {
res.end();
}
}
}
private async proxyRequest(
method: "GET" | "POST",
path: string,
req: Request,
res: Response,
body?: unknown
): Promise<void> {
const abortController = new AbortController();
req.once("close", () => {
abortController.abort();
});
try {
const upstream = await this.fetchUpstream(
method,
path,
req.query,
body,
abortController.signal
);
await this.sendStandardResponse(upstream, res);
} catch (error: unknown) {
if (this.isAbortError(error)) {
return;
}
this.handleProxyError(error);
}
}
private async fetchUpstream(
method: "GET" | "POST",
path: string,
query: Request["query"],
body: unknown,
signal: AbortSignal
): Promise<globalThis.Response> {
const url = new URL(`/api/mission-control/${path}`, this.orchestratorUrl);
this.appendQueryParams(url.searchParams, query);
const headers: Record<string, string> = {
"X-API-Key": this.orchestratorApiKey,
};
const requestInit: RequestInit = {
method,
headers,
signal,
};
if (method === "POST" && body !== undefined) {
headers["Content-Type"] = "application/json";
requestInit.body = JSON.stringify(body);
}
return fetch(url.toString(), requestInit);
}
private appendQueryParams(searchParams: URLSearchParams, query: Request["query"]): void {
for (const [key, value] of Object.entries(query)) {
this.appendQueryValue(searchParams, key, value);
}
}
private appendQueryValue(searchParams: URLSearchParams, key: string, value: unknown): void {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
for (const item of value) {
this.appendQueryValue(searchParams, key, item);
}
return;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
searchParams.append(key, String(value));
}
}
private async sendStandardResponse(upstream: globalThis.Response, res: Response): Promise<void> {
res.status(upstream.status);
this.copyHeaderIfPresent(upstream, res, "content-type");
this.copyHeaderIfPresent(upstream, res, "cache-control");
this.copyHeaderIfPresent(upstream, res, "location");
const responseText = await upstream.text();
if (responseText.length === 0) {
res.end();
return;
}
res.send(responseText);
}
private copyHeaderIfPresent(
upstream: globalThis.Response,
res: Response,
headerName: string
): void {
const value = upstream.headers.get(headerName);
if (value) {
res.setHeader(headerName, value);
}
}
private handleProxyError(error: unknown): never {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Mission Control proxy request failed: ${message}`);
throw new ServiceUnavailableException("Failed to proxy Mission Control request");
}
private isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
private requireEnv(key: string): string {
const value = process.env[key];
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`@mosaic/api: ${key} is required. Set it in your config or via ${key}.`);
}
return value;
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { MissionControlProxyController } from "./mission-control-proxy.controller";
@Module({
imports: [AuthModule],
controllers: [MissionControlProxyController],
})
export class MissionControlProxyModule {}

View File

@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NotFoundException } from "@nestjs/common";
import type { Response } from "express";
import { ConfigService } from "@nestjs/config";
import { Test, type TestingModule } from "@nestjs/testing";
import { QueueNotificationsController } from "./queue-notifications.controller";
import { QueueNotificationsService } from "./queue-notifications.service";
import { ApiKeyGuard } from "../common/guards/api-key.guard";
describe("QueueNotificationsController", () => {
let controller: QueueNotificationsController;
const mockService = {
listNotifications: vi.fn(),
streamNotifications: vi.fn(),
ackNotification: vi.fn(),
listTasks: vi.fn(),
};
const mockConfigService = {
get: vi.fn().mockReturnValue("coordinator-api-key"),
};
beforeEach(async () => {
vi.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
controllers: [QueueNotificationsController],
providers: [
{ provide: QueueNotificationsService, useValue: mockService },
{ provide: ConfigService, useValue: mockConfigService },
],
})
.overrideGuard(ApiKeyGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<QueueNotificationsController>(QueueNotificationsController);
});
it("returns notification objects", async () => {
mockService.listNotifications.mockResolvedValue([
{
id: "notif-1",
agent: "mosaic",
filename: "notif-1.json",
payload: { type: "task.ready" },
createdAt: new Date("2026-03-08T22:00:00.000Z"),
},
]);
await expect(controller.getNotifications()).resolves.toEqual([
expect.objectContaining({
id: "notif-1",
agent: "mosaic",
filename: "notif-1.json",
payload: { type: "task.ready" },
}),
]);
});
it("streams notifications through the response object", async () => {
const res = {
setHeader: vi.fn(),
flushHeaders: vi.fn(),
write: vi.fn(),
on: vi.fn(),
end: vi.fn(),
} as unknown as Response;
mockService.streamNotifications.mockResolvedValue(undefined);
await controller.streamNotifications(res);
expect(mockService.streamNotifications).toHaveBeenCalledWith(res);
});
it("acks a notification by id", async () => {
mockService.ackNotification.mockResolvedValue({ success: true, id: "notif-2" });
await expect(controller.ackNotification("notif-2")).resolves.toEqual({
success: true,
id: "notif-2",
});
});
it("surfaces ack errors", async () => {
mockService.ackNotification.mockRejectedValue(new NotFoundException("missing"));
await expect(controller.ackNotification("missing")).rejects.toThrow(NotFoundException);
});
it("returns parsed queue tasks", async () => {
mockService.listTasks.mockResolvedValue([
{
id: "task-1",
project: "mosaic-stack",
taskId: "MS24-API-001",
status: "pending",
description: "Build queue notifications module",
},
]);
await expect(controller.getTasks()).resolves.toEqual([
{
id: "task-1",
project: "mosaic-stack",
taskId: "MS24-API-001",
status: "pending",
description: "Build queue notifications module",
},
]);
});
it("uses ApiKeyGuard at the controller level", () => {
const guards = Reflect.getMetadata("__guards__", QueueNotificationsController) as unknown[];
expect(guards).toContain(ApiKeyGuard);
});
});

View File

@@ -0,0 +1,36 @@
import { Controller, Get, Param, Post, Res, UseGuards } from "@nestjs/common";
import type { Response } from "express";
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
import { ApiKeyGuard } from "../common/guards/api-key.guard";
import {
QueueNotificationsService,
type QueueNotification,
type QueueTask,
} from "./queue-notifications.service";
@Controller("queue")
@UseGuards(ApiKeyGuard)
export class QueueNotificationsController {
constructor(private readonly queueNotificationsService: QueueNotificationsService) {}
@Get("notifications")
async getNotifications(): Promise<QueueNotification[]> {
return this.queueNotificationsService.listNotifications();
}
@Get("notifications/stream")
async streamNotifications(@Res() res: Response): Promise<void> {
await this.queueNotificationsService.streamNotifications(res);
}
@SkipCsrf()
@Post("notifications/:id/ack")
async ackNotification(@Param("id") id: string): Promise<{ success: true; id: string }> {
return this.queueNotificationsService.ackNotification(id);
}
@Get("tasks")
async getTasks(): Promise<QueueTask[]> {
return this.queueNotificationsService.listTasks();
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AuthModule } from "../auth/auth.module";
import { ApiKeyGuard } from "../common/guards/api-key.guard";
import { QueueNotificationsController } from "./queue-notifications.controller";
import { QueueNotificationsService } from "./queue-notifications.service";
@Module({
imports: [ConfigModule, AuthModule],
controllers: [QueueNotificationsController],
providers: [QueueNotificationsService, ApiKeyGuard],
exports: [QueueNotificationsService],
})
export class QueueNotificationsModule {}

View File

@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConfigService } from "@nestjs/config";
import { Logger, NotFoundException } from "@nestjs/common";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFile } from "node:child_process";
import { QueueNotificationsService } from "./queue-notifications.service";
vi.mock("node:child_process", () => ({
execFile: vi.fn(),
}));
describe("QueueNotificationsService", () => {
let service: QueueNotificationsService;
let inboxDir: string;
let configService: ConfigService;
beforeEach(async () => {
vi.clearAllMocks();
inboxDir = await mkdtemp(join(tmpdir(), "queue-notifications-"));
configService = {
get: vi.fn((key: string) => {
if (key === "MOSAIC_QUEUE_INBOX_DIR") {
return inboxDir;
}
if (key === "MOSAIC_QUEUE_CLI") {
return "/tmp/mosaic-queue-cli.js";
}
return undefined;
}),
} as unknown as ConfigService;
service = new QueueNotificationsService(configService);
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(inboxDir, { recursive: true, force: true });
});
describe("onModuleInit", () => {
it("logs a warning when the inbox directory does not exist", async () => {
await rm(inboxDir, { recursive: true, force: true });
const warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined);
await service.onModuleInit();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Queue notifications inbox directory does not exist")
);
});
});
describe("listNotifications", () => {
it("returns parsed notifications from agent inbox directories", async () => {
await mkdir(join(inboxDir, "mosaic"), { recursive: true });
await mkdir(join(inboxDir, "mosaic", "_acked"), { recursive: true });
await mkdir(join(inboxDir, "sage"), { recursive: true });
await writeFile(
join(inboxDir, "mosaic", "notif-1.json"),
JSON.stringify({ type: "task.ready", taskId: "MS24-API-001" })
);
await writeFile(
join(inboxDir, "mosaic", "_acked", "notif-ignored.json"),
JSON.stringify({ ignored: true })
);
await writeFile(join(inboxDir, "sage", "notif-2.json"), JSON.stringify({ type: "done" }));
const notifications = await service.listNotifications();
expect(notifications).toHaveLength(2);
expect(notifications).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "notif-1",
agent: "mosaic",
filename: "notif-1.json",
payload: { type: "task.ready", taskId: "MS24-API-001" },
}),
expect.objectContaining({
id: "notif-2",
agent: "sage",
filename: "notif-2.json",
payload: { type: "done" },
}),
])
);
});
it("returns an empty array when the inbox directory is missing", async () => {
await rm(inboxDir, { recursive: true, force: true });
await expect(service.listNotifications()).resolves.toEqual([]);
});
});
describe("ackNotification", () => {
it("executes the queue CLI with node and ack args", async () => {
await mkdir(join(inboxDir, "mosaic"), { recursive: true });
await writeFile(join(inboxDir, "mosaic", "notif-3.json"), JSON.stringify({ ok: true }));
vi.mocked(execFile).mockImplementation(
(
_command: string,
_args: readonly string[],
callback: (error: Error | null, stdout: string, stderr: string) => void
) => callback(null, "acked", "")
);
await expect(service.ackNotification("notif-3")).resolves.toEqual({
success: true,
id: "notif-3",
});
expect(execFile).toHaveBeenCalledWith(
"node",
["/tmp/mosaic-queue-cli.js", "ack", "notif-3"],
expect.any(Function)
);
});
it("throws NotFoundException when the notification does not exist", async () => {
await expect(service.ackNotification("missing")).rejects.toThrow(NotFoundException);
expect(execFile).not.toHaveBeenCalled();
});
});
describe("listTasks", () => {
it("parses tab-separated CLI output", async () => {
vi.mocked(execFile).mockImplementation(
(
_command: string,
_args: readonly string[],
callback: (error: Error | null, stdout: string, stderr: string) => void
) =>
callback(
null,
[
"task-1\tmosaic-stack/MS24-API-001\t[pending]\tBuild queue notifications module",
"task-2\tmosaic-stack/MS24-API-002\t[done]\tWrite tests",
].join("\n"),
""
)
);
await expect(service.listTasks()).resolves.toEqual([
{
id: "task-1",
project: "mosaic-stack",
taskId: "MS24-API-001",
status: "pending",
description: "Build queue notifications module",
},
{
id: "task-2",
project: "mosaic-stack",
taskId: "MS24-API-002",
status: "done",
description: "Write tests",
},
]);
expect(execFile).toHaveBeenCalledWith(
"node",
["/tmp/mosaic-queue-cli.js", "list", "mosaic-stack"],
expect.any(Function)
);
});
});
});

View File

@@ -0,0 +1,231 @@
import {
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
OnModuleInit,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { execFile } from "node:child_process";
import { access, readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, join } from "node:path";
import type { Response } from "express";
import chokidar from "chokidar";
export interface QueueNotification {
id: string;
agent: string;
filename: string;
payload: unknown;
createdAt: Date;
}
export interface QueueTask {
id: string;
project: string;
taskId: string;
status: string;
description: string;
}
@Injectable()
export class QueueNotificationsService implements OnModuleInit {
private readonly logger = new Logger(QueueNotificationsService.name);
constructor(private readonly configService: ConfigService) {}
async onModuleInit(): Promise<void> {
if (!(await this.inboxDirExists())) {
this.logger.warn(`Queue notifications inbox directory does not exist: ${this.getInboxDir()}`);
}
}
async listNotifications(): Promise<QueueNotification[]> {
const inboxDir = this.getInboxDir();
if (!(await this.inboxDirExists())) {
return [];
}
// Paths come from controlled config plus directory entries under the inbox root.
// eslint-disable-next-line security/detect-non-literal-fs-filename
const agentEntries = await readdir(inboxDir, { withFileTypes: true });
const notifications: QueueNotification[] = [];
for (const agentEntry of agentEntries) {
if (!agentEntry.isDirectory() || this.isIgnoredDirectory(agentEntry.name)) {
continue;
}
const agentDir = join(inboxDir, agentEntry.name);
// eslint-disable-next-line security/detect-non-literal-fs-filename
const files = await readdir(agentDir, { withFileTypes: true });
for (const fileEntry of files) {
if (!fileEntry.isFile() || !fileEntry.name.endsWith(".json")) {
continue;
}
const filePath = join(agentDir, fileEntry.name);
const [rawPayload, fileStats] = await Promise.all([
// eslint-disable-next-line security/detect-non-literal-fs-filename
readFile(filePath, "utf8"),
// eslint-disable-next-line security/detect-non-literal-fs-filename
stat(filePath),
]);
notifications.push({
id: basename(fileEntry.name, ".json"),
agent: agentEntry.name,
filename: fileEntry.name,
payload: JSON.parse(rawPayload) as unknown,
createdAt: fileStats.birthtime,
});
}
}
return notifications.sort(
(left, right) => right.createdAt.getTime() - left.createdAt.getTime()
);
}
async streamNotifications(res: Response): Promise<void> {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
if (typeof res.flushHeaders === "function") {
res.flushHeaders();
}
const emitNotifications = async (): Promise<void> => {
try {
const notifications = await this.listNotifications();
res.write(`data: ${JSON.stringify(notifications)}\n\n`);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
res.write(`event: error\n`);
res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
}
};
await emitNotifications();
const watcher = chokidar.watch(this.getInboxDir(), {
ignoreInitial: true,
persistent: true,
ignored: (watchedPath: string) => {
return watchedPath.includes("/_acked/") || watchedPath.includes("/_dead-letter/");
},
});
watcher.on("add", () => {
void emitNotifications();
});
watcher.on("unlink", () => {
void emitNotifications();
});
res.on("close", () => {
void watcher.close();
res.end();
});
}
async ackNotification(id: string): Promise<{ success: true; id: string }> {
const notification = (await this.listNotifications()).find((entry) => entry.id === id);
if (!notification) {
throw new NotFoundException(`Queue notification ${id} not found`);
}
await this.execQueueCli(["ack", notification.id]);
return {
success: true,
id: notification.id,
};
}
async listTasks(): Promise<QueueTask[]> {
const stdout = await this.execQueueCli(["list", "mosaic-stack"]);
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => {
const [rawId = "", projectTaskId = "", rawStatus = "", description = ""] = line.split("\t");
const [project = "", taskId = ""] = projectTaskId.split("/");
return {
id: rawId,
project,
taskId,
status: rawStatus.replace(/^\[/, "").replace(/\]$/, ""),
description,
};
});
}
private async execQueueCli(args: string[]): Promise<string> {
const cliPath = this.getQueueCliPath();
return new Promise<string>((resolve, reject) => {
execFile("node", [cliPath, ...args], (error, stdout, stderr) => {
if (error) {
this.logger.error(
`Queue CLI command failed: node ${cliPath} ${args.join(" ")} | ${stderr || error.message}`
);
reject(
new InternalServerErrorException(`Queue CLI command failed: ${stderr || error.message}`)
);
return;
}
resolve(stdout);
});
});
}
private getInboxDir(): string {
return this.expandHomePath(
this.configService.get<string>("MOSAIC_QUEUE_INBOX_DIR") ??
"~/.openclaw/workspace/agent-inbox"
);
}
private getQueueCliPath(): string {
return this.expandHomePath(
this.configService.get<string>("MOSAIC_QUEUE_CLI") ?? "~/src/mosaic-queue/dist/cli.js"
);
}
private expandHomePath(value: string): string {
if (value === "~") {
return homedir();
}
if (value.startsWith("~/")) {
return join(homedir(), value.slice(2));
}
return value;
}
private async inboxDirExists(): Promise<boolean> {
try {
await access(this.getInboxDir());
return true;
} catch {
return false;
}
}
private isIgnoredDirectory(name: string): boolean {
return name === "_acked" || name === "_dead-letter";
}
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/orchestrator", "name": "@mosaic/orchestrator",
"version": "0.0.20", "version": "0.0.23",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
@@ -22,6 +22,7 @@
"@anthropic-ai/sdk": "^0.72.1", "@anthropic-ai/sdk": "^0.72.1",
"@mosaic/config": "workspace:*", "@mosaic/config": "workspace:*",
"@mosaic/shared": "workspace:*", "@mosaic/shared": "workspace:*",
"@nestjs/axios": "^4.0.1",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.12", "@nestjs/common": "^11.1.12",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",

View File

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

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { NotFoundException } from "@nestjs/common"; import { NotFoundException } from "@nestjs/common";
import { EncryptionService } from "../../security/encryption.service";
import { AgentProvidersService } from "./agent-providers.service"; import { AgentProvidersService } from "./agent-providers.service";
import { PrismaService } from "../../prisma/prisma.service"; import { PrismaService } from "../../prisma/prisma.service";
@@ -14,6 +15,9 @@ describe("AgentProvidersService", () => {
delete: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn>;
}; };
}; };
let encryptionService: {
encryptIfNeeded: ReturnType<typeof vi.fn>;
};
beforeEach(() => { beforeEach(() => {
prisma = { prisma = {
@@ -26,7 +30,14 @@ describe("AgentProvidersService", () => {
}, },
}; };
service = new AgentProvidersService(prisma as unknown as PrismaService); encryptionService = {
encryptIfNeeded: vi.fn((value: string) => `enc:${value}`),
};
service = new AgentProvidersService(
prisma as unknown as PrismaService,
encryptionService as unknown as EncryptionService
);
}); });
it("lists all provider configs", async () => { it("lists all provider configs", async () => {
@@ -111,6 +122,42 @@ describe("AgentProvidersService", () => {
credentials: {}, credentials: {},
}, },
}); });
expect(encryptionService.encryptIfNeeded).not.toHaveBeenCalled();
expect(result).toEqual(created);
});
it("encrypts openclaw token credentials when creating provider config", async () => {
const created = {
id: "cfg-openclaw",
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
name: "OpenClaw",
provider: "openclaw",
gatewayUrl: "https://openclaw.example.com",
credentials: { apiToken: "enc:top-secret" },
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: "OpenClaw",
provider: "openclaw",
gatewayUrl: "https://openclaw.example.com",
credentials: { apiToken: "top-secret" },
});
expect(encryptionService.encryptIfNeeded).toHaveBeenCalledWith("top-secret");
expect(prisma.agentProviderConfig.create).toHaveBeenCalledWith({
data: {
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
name: "OpenClaw",
provider: "openclaw",
gatewayUrl: "https://openclaw.example.com",
credentials: { apiToken: "enc:top-secret" },
},
});
expect(result).toEqual(created); expect(result).toEqual(created);
}); });
@@ -156,6 +203,47 @@ describe("AgentProvidersService", () => {
isActive: false, isActive: false,
}, },
}); });
expect(encryptionService.encryptIfNeeded).not.toHaveBeenCalled();
expect(result).toEqual(updated);
});
it("encrypts openclaw token credentials when updating provider config", async () => {
prisma.agentProviderConfig.findUnique.mockResolvedValue({
id: "cfg-openclaw",
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
name: "OpenClaw",
provider: "openclaw",
gatewayUrl: "https://openclaw.example.com",
credentials: { apiToken: "enc:existing" },
isActive: true,
createdAt: new Date("2026-03-07T18:00:00.000Z"),
updatedAt: new Date("2026-03-07T18:00:00.000Z"),
});
const updated = {
id: "cfg-openclaw",
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
name: "OpenClaw",
provider: "openclaw",
gatewayUrl: "https://openclaw.example.com",
credentials: { apiToken: "enc:rotated-token" },
isActive: true,
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-openclaw", {
credentials: { apiToken: "rotated-token" },
});
expect(encryptionService.encryptIfNeeded).toHaveBeenCalledWith("rotated-token");
expect(prisma.agentProviderConfig.update).toHaveBeenCalledWith({
where: { id: "cfg-openclaw" },
data: {
credentials: { apiToken: "enc:rotated-token" },
},
});
expect(result).toEqual(updated); expect(result).toEqual(updated);
}); });

View File

@@ -1,12 +1,19 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { Injectable, NotFoundException } from "@nestjs/common";
import type { AgentProviderConfig, Prisma } from "@prisma/client"; import type { AgentProviderConfig, Prisma } from "@prisma/client";
import { EncryptionService } from "../../security/encryption.service";
import { PrismaService } from "../../prisma/prisma.service"; import { PrismaService } from "../../prisma/prisma.service";
import { CreateAgentProviderDto } from "./dto/create-agent-provider.dto"; import { CreateAgentProviderDto } from "./dto/create-agent-provider.dto";
import { UpdateAgentProviderDto } from "./dto/update-agent-provider.dto"; import { UpdateAgentProviderDto } from "./dto/update-agent-provider.dto";
const OPENCLAW_PROVIDER_TYPE = "openclaw";
const OPENCLAW_TOKEN_KEYS = ["apiToken", "token", "bearerToken"] as const;
@Injectable() @Injectable()
export class AgentProvidersService { export class AgentProvidersService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly encryptionService: EncryptionService
) {}
async list(): Promise<AgentProviderConfig[]> { async list(): Promise<AgentProviderConfig[]> {
return this.prisma.agentProviderConfig.findMany({ return this.prisma.agentProviderConfig.findMany({
@@ -27,20 +34,23 @@ export class AgentProvidersService {
} }
async create(dto: CreateAgentProviderDto): Promise<AgentProviderConfig> { async create(dto: CreateAgentProviderDto): Promise<AgentProviderConfig> {
const credentials = this.sanitizeCredentials(dto.provider, dto.credentials ?? {});
return this.prisma.agentProviderConfig.create({ return this.prisma.agentProviderConfig.create({
data: { data: {
workspaceId: dto.workspaceId, workspaceId: dto.workspaceId,
name: dto.name, name: dto.name,
provider: dto.provider, provider: dto.provider,
gatewayUrl: dto.gatewayUrl, gatewayUrl: dto.gatewayUrl,
credentials: this.toJsonValue(dto.credentials ?? {}), credentials: this.toJsonValue(credentials),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
}, },
}); });
} }
async update(id: string, dto: UpdateAgentProviderDto): Promise<AgentProviderConfig> { async update(id: string, dto: UpdateAgentProviderDto): Promise<AgentProviderConfig> {
await this.getById(id); const existingConfig = await this.getById(id);
const provider = dto.provider ?? existingConfig.provider;
const data: Prisma.AgentProviderConfigUpdateInput = { const data: Prisma.AgentProviderConfigUpdateInput = {
...(dto.workspaceId !== undefined ? { workspaceId: dto.workspaceId } : {}), ...(dto.workspaceId !== undefined ? { workspaceId: dto.workspaceId } : {}),
@@ -48,7 +58,9 @@ export class AgentProvidersService {
...(dto.provider !== undefined ? { provider: dto.provider } : {}), ...(dto.provider !== undefined ? { provider: dto.provider } : {}),
...(dto.gatewayUrl !== undefined ? { gatewayUrl: dto.gatewayUrl } : {}), ...(dto.gatewayUrl !== undefined ? { gatewayUrl: dto.gatewayUrl } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...(dto.credentials !== undefined ? { credentials: this.toJsonValue(dto.credentials) } : {}), ...(dto.credentials !== undefined
? { credentials: this.toJsonValue(this.sanitizeCredentials(provider, dto.credentials)) }
: {}),
}; };
return this.prisma.agentProviderConfig.update({ return this.prisma.agentProviderConfig.update({
@@ -65,6 +77,25 @@ export class AgentProvidersService {
}); });
} }
private sanitizeCredentials(
provider: string,
credentials: Record<string, unknown>
): Record<string, unknown> {
if (provider.toLowerCase() !== OPENCLAW_PROVIDER_TYPE) {
return credentials;
}
const nextCredentials: Record<string, unknown> = { ...credentials };
for (const key of OPENCLAW_TOKEN_KEYS) {
const tokenValue = nextCredentials[key];
if (typeof tokenValue === "string" && tokenValue.length > 0) {
nextCredentials[key] = this.encryptionService.encryptIfNeeded(tokenValue);
}
}
return nextCredentials;
}
private toJsonValue(value: Record<string, unknown>): Prisma.InputJsonValue { private toJsonValue(value: Record<string, unknown>): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue; return value as Prisma.InputJsonValue;
} }

View File

@@ -146,7 +146,7 @@ export class AgentsController {
* Return recent orchestrator events for non-streaming consumers. * Return recent orchestrator events for non-streaming consumers.
*/ */
@Get("events/recent") @Get("events/recent")
@Throttle({ status: { limit: 200, ttl: 60000 } }) @Throttle({ default: { limit: 1000, ttl: 60000 } })
getRecentEvents(@Query("limit") limit?: string): { getRecentEvents(@Query("limit") limit?: string): {
events: ReturnType<AgentEventsService["getRecentEvents"]>; events: ReturnType<AgentEventsService["getRecentEvents"]>;
} { } {

View File

@@ -0,0 +1,145 @@
import type { HttpService } from "@nestjs/axios";
import type { AgentMessage } from "@mosaic/shared";
import { Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OpenClawSseBridge } from "./openclaw-sse.bridge";
describe("OpenClawSseBridge", () => {
let bridge: OpenClawSseBridge;
let httpService: {
axiosRef: {
get: ReturnType<typeof vi.fn>;
};
};
beforeEach(() => {
httpService = {
axiosRef: {
get: vi.fn(),
},
};
bridge = new OpenClawSseBridge(httpService as unknown as HttpService);
});
afterEach(() => {
vi.useRealTimers();
});
it("maps message and status events, and skips heartbeats", async () => {
httpService.axiosRef.get.mockResolvedValue({
data: Readable.from([
'event: message\ndata: {"id":"msg-1","role":"assistant","content":"hello","timestamp":"2026-03-07T16:00:00.000Z"}\n\n',
"event: heartbeat\ndata: {}\n\n",
'event: status\ndata: {"status":"paused","timestamp":"2026-03-07T16:00:01.000Z"}\n\n',
"data: [DONE]\n\n",
]),
});
const messages = await collectMessages(
bridge.streamSession("https://gateway.example.com/", "session-1", {
Authorization: "Bearer test-token",
})
);
expect(httpService.axiosRef.get).toHaveBeenCalledWith(
"https://gateway.example.com/api/sessions/session-1/stream",
{
headers: {
Authorization: "Bearer test-token",
Accept: "text/event-stream",
},
responseType: "stream",
}
);
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual({
id: "msg-1",
sessionId: "session-1",
role: "assistant",
content: "hello",
timestamp: new Date("2026-03-07T16:00:00.000Z"),
});
expect(messages[1]).toEqual({
id: expect.any(String),
sessionId: "session-1",
role: "system",
content: "Session status changed to paused",
timestamp: new Date("2026-03-07T16:00:01.000Z"),
metadata: {
status: "paused",
timestamp: "2026-03-07T16:00:01.000Z",
},
});
});
it("retries after disconnect and resumes streaming", async () => {
vi.useFakeTimers();
httpService.axiosRef.get
.mockResolvedValueOnce({
data: Readable.from([
'event: message\ndata: {"id":"msg-1","content":"first","timestamp":"2026-03-07T16:10:00.000Z"}\n\n',
]),
})
.mockResolvedValueOnce({
data: Readable.from(["data: [DONE]\n\n"]),
});
const consumePromise = collectMessages(
bridge.streamSession("https://gateway.example.com", "session-1", {
Authorization: "Bearer test-token",
})
);
await vi.advanceTimersByTimeAsync(2000);
const messages = await consumePromise;
expect(httpService.axiosRef.get).toHaveBeenCalledTimes(2);
expect(messages).toEqual([
{
id: "msg-1",
sessionId: "session-1",
role: "user",
content: "first",
timestamp: new Date("2026-03-07T16:10:00.000Z"),
},
]);
});
it("throws after exhausting reconnect retries", async () => {
vi.useFakeTimers();
httpService.axiosRef.get.mockRejectedValue(new Error("socket closed"));
const consumePromise = collectMessages(
bridge.streamSession("https://gateway.example.com", "session-1", {
Authorization: "Bearer test-token",
})
);
const rejection = expect(consumePromise).rejects.toThrow(
"Failed to reconnect OpenClaw stream for session session-1 after 5 retries: socket closed"
);
for (let attempt = 0; attempt < 5; attempt += 1) {
await vi.advanceTimersByTimeAsync(2000);
}
await rejection;
expect(httpService.axiosRef.get).toHaveBeenCalledTimes(6);
});
});
async function collectMessages(stream: AsyncIterable<AgentMessage>): Promise<AgentMessage[]> {
const messages: AgentMessage[] = [];
for await (const message of stream) {
messages.push(message);
}
return messages;
}

View File

@@ -0,0 +1,420 @@
import { HttpService } from "@nestjs/axios";
import { Injectable } from "@nestjs/common";
import type { AgentMessage, AgentMessageRole } from "@mosaic/shared";
import { randomUUID } from "node:crypto";
const STREAM_RETRY_DELAY_MS = 2000;
const STREAM_MAX_RETRIES = 5;
type JsonRecord = Record<string, unknown>;
type AsyncChunkStream = AsyncIterable<string | Uint8Array | Buffer>;
type ParsedStreamEvent =
| {
type: "message";
message: AgentMessage;
}
| {
type: "done";
};
@Injectable()
export class OpenClawSseBridge {
constructor(private readonly httpService: HttpService) {}
async *streamSession(
baseUrl: string,
sessionId: string,
headers: Record<string, string>
): AsyncIterable<AgentMessage> {
let retryCount = 0;
let lastError: unknown = new Error("OpenClaw stream disconnected");
while (retryCount <= STREAM_MAX_RETRIES) {
try {
const response = await this.httpService.axiosRef.get(
this.buildStreamUrl(baseUrl, sessionId),
{
headers: {
...headers,
Accept: "text/event-stream",
},
responseType: "stream",
}
);
const stream = this.asAsyncChunkStream(response.data);
if (stream === null) {
throw new Error("OpenClaw stream response is not readable");
}
retryCount = 0;
let streamCompleted = false;
for await (const event of this.parseStream(stream, sessionId)) {
if (event.type === "done") {
streamCompleted = true;
break;
}
yield event.message;
}
if (streamCompleted) {
return;
}
lastError = new Error("OpenClaw stream disconnected");
} catch (error) {
lastError = error;
}
if (retryCount >= STREAM_MAX_RETRIES) {
throw new Error(
`Failed to reconnect OpenClaw stream for session ${sessionId} after ${String(STREAM_MAX_RETRIES)} retries: ${this.toErrorMessage(lastError)}`
);
}
retryCount += 1;
await this.delay(STREAM_RETRY_DELAY_MS);
}
}
private async *parseStream(
stream: AsyncChunkStream,
sessionId: string
): AsyncGenerator<ParsedStreamEvent> {
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of stream) {
const textChunk = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
buffer += textChunk.replace(/\r\n/gu, "\n");
const rawEvents = buffer.split("\n\n");
buffer = rawEvents.pop() ?? "";
for (const rawEvent of rawEvents) {
const parsedEvent = this.parseRawEvent(rawEvent);
if (parsedEvent === null) {
continue;
}
if (parsedEvent.data === "[DONE]") {
yield {
type: "done",
};
return;
}
const payload = this.tryParseJson(parsedEvent.data) ?? parsedEvent.data;
const message = this.mapEventToMessage(parsedEvent.type, payload, sessionId);
if (message !== null) {
yield {
type: "message",
message,
};
}
}
}
buffer += decoder.decode();
const trailingEvent = this.parseRawEvent(buffer.trim());
if (trailingEvent === null) {
return;
}
if (trailingEvent.data === "[DONE]") {
yield {
type: "done",
};
return;
}
const payload = this.tryParseJson(trailingEvent.data) ?? trailingEvent.data;
const message = this.mapEventToMessage(trailingEvent.type, payload, sessionId);
if (message !== null) {
yield {
type: "message",
message,
};
}
}
private parseRawEvent(rawEvent: string): { type: string; data: string } | null {
if (rawEvent.trim().length === 0) {
return null;
}
let type = "message";
const dataLines: string[] = [];
for (const line of rawEvent.split("\n")) {
const trimmedLine = line.trimEnd();
if (trimmedLine.length === 0 || trimmedLine.startsWith(":")) {
continue;
}
if (trimmedLine.startsWith("event:")) {
type = trimmedLine.slice(6).trim().toLowerCase();
continue;
}
if (trimmedLine.startsWith("data:")) {
dataLines.push(trimmedLine.slice(5).trimStart());
}
}
if (dataLines.length > 0) {
return {
type,
data: dataLines.join("\n").trim(),
};
}
const trimmedEvent = rawEvent.trim();
if (trimmedEvent.startsWith("{") || trimmedEvent.startsWith("[")) {
return {
type,
data: trimmedEvent,
};
}
return null;
}
private mapEventToMessage(
eventType: string,
payload: unknown,
fallbackSessionId: string
): AgentMessage | null {
switch (eventType) {
case "heartbeat":
return null;
case "status":
return this.toStatusMessage(payload, fallbackSessionId);
case "message":
default:
return this.toAgentMessage(payload, fallbackSessionId);
}
}
private toStatusMessage(value: unknown, sessionId: string): AgentMessage | null {
if (typeof value === "string") {
const status = value.trim();
if (status.length === 0) {
return null;
}
return {
id: randomUUID(),
sessionId,
role: "system",
content: `Session status changed to ${status}`,
timestamp: new Date(),
metadata: {
status,
},
};
}
if (!this.isRecord(value)) {
return null;
}
const status = this.readString(value.status);
if (!status) {
return null;
}
return {
id: randomUUID(),
sessionId,
role: "system",
content: `Session status changed to ${status}`,
timestamp: this.parseDate(value.timestamp ?? value.updatedAt),
metadata: value,
};
}
private toAgentMessage(value: unknown, fallbackSessionId: string): AgentMessage | null {
if (typeof value === "string") {
const content = value.trim();
if (content.length === 0) {
return null;
}
return {
id: randomUUID(),
sessionId: fallbackSessionId,
role: "assistant",
content,
timestamp: new Date(),
};
}
let candidate: JsonRecord | null = null;
if (this.isRecord(value) && this.isRecord(value.message)) {
candidate = value.message;
} else if (this.isRecord(value)) {
candidate = value;
}
if (candidate === null) {
return null;
}
const sessionId = this.readString(candidate.sessionId) ?? fallbackSessionId;
if (!sessionId) {
return null;
}
const content = this.extractMessageContent(
candidate.content ?? candidate.text ?? candidate.message
);
if (content.length === 0) {
return null;
}
const metadata = this.toMetadata(candidate.metadata);
return {
id: this.readString(candidate.id) ?? this.readString(candidate.messageId) ?? randomUUID(),
sessionId,
role: this.toMessageRole(this.readString(candidate.role) ?? this.readString(candidate.type)),
content,
timestamp: this.parseDate(candidate.timestamp ?? candidate.createdAt),
...(metadata !== undefined ? { metadata } : {}),
};
}
private extractMessageContent(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
const parts: string[] = [];
for (const part of content) {
if (typeof part === "string") {
const trimmed = part.trim();
if (trimmed.length > 0) {
parts.push(trimmed);
}
continue;
}
if (!this.isRecord(part)) {
continue;
}
const text = this.readString(part.text) ?? this.readString(part.content);
if (text !== undefined && text.trim().length > 0) {
parts.push(text.trim());
}
}
return parts.join("\n\n").trim();
}
if (this.isRecord(content)) {
const text = this.readString(content.text) ?? this.readString(content.content);
return text?.trim() ?? "";
}
return "";
}
private toMessageRole(role?: string): AgentMessageRole {
switch (role?.toLowerCase()) {
case "assistant":
case "agent":
return "assistant";
case "system":
return "system";
case "tool":
return "tool";
case "operator":
case "user":
default:
return "user";
}
}
private parseDate(value: unknown, fallback = new Date()): Date {
if (value instanceof Date) {
return value;
}
if (typeof value === "string" || typeof value === "number") {
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
}
return fallback;
}
private toMetadata(value: unknown): Record<string, unknown> | undefined {
if (this.isRecord(value)) {
return value;
}
return undefined;
}
private buildStreamUrl(baseUrl: string, sessionId: string): string {
const normalizedBaseUrl = baseUrl.replace(/\/$/u, "");
return new URL(
`/api/sessions/${encodeURIComponent(sessionId)}/stream`,
`${normalizedBaseUrl}/`
).toString();
}
private tryParseJson(value: string): unknown {
try {
return JSON.parse(value) as unknown;
} catch {
return null;
}
}
private asAsyncChunkStream(value: unknown): AsyncChunkStream | null {
if (value !== null && typeof value === "object" && Symbol.asyncIterator in value) {
return value as AsyncChunkStream;
}
return null;
}
private isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
private readString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
private async delay(ms: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
}

View File

@@ -0,0 +1,24 @@
import { HttpService } from "@nestjs/axios";
import { Injectable } from "@nestjs/common";
import type { AgentProviderConfig } from "@prisma/client";
import { EncryptionService } from "../../../security/encryption.service";
import { OpenClawSseBridge } from "./openclaw-sse.bridge";
import { OpenClawProvider } from "./openclaw.provider";
@Injectable()
export class OpenClawProviderFactory {
constructor(
private readonly encryptionService: EncryptionService,
private readonly httpService: HttpService,
private readonly openClawSseBridge: OpenClawSseBridge
) {}
createProvider(config: AgentProviderConfig): OpenClawProvider {
return new OpenClawProvider(
config,
this.encryptionService,
this.httpService,
this.openClawSseBridge
);
}
}

View File

@@ -0,0 +1,183 @@
import type { HttpService } from "@nestjs/axios";
import { ServiceUnavailableException } from "@nestjs/common";
import type { AgentMessage } from "@mosaic/shared";
import type { AgentProviderConfig } from "@prisma/client";
import { Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EncryptionService } from "../../../security/encryption.service";
import { OpenClawSseBridge } from "./openclaw-sse.bridge";
import { OpenClawProvider } from "./openclaw.provider";
describe("Phase 3 gate: OpenClaw provider config registered in DB → provider loaded on boot → sessions returned from /api/mission-control/sessions → inject/pause/kill proxied to gateway", () => {
let provider: OpenClawProvider;
let httpService: {
axiosRef: {
get: ReturnType<typeof vi.fn>;
post: ReturnType<typeof vi.fn>;
};
};
let encryptionService: {
decryptIfNeeded: ReturnType<typeof vi.fn>;
};
const config: AgentProviderConfig = {
id: "cfg-openclaw-1",
workspaceId: "workspace-1",
name: "openclaw-home",
provider: "openclaw",
gatewayUrl: "https://gateway.example.com",
credentials: {
apiToken: "enc:token",
},
isActive: true,
createdAt: new Date("2026-03-07T15:00:00.000Z"),
updatedAt: new Date("2026-03-07T15:00:00.000Z"),
};
beforeEach(() => {
httpService = {
axiosRef: {
get: vi.fn(),
post: vi.fn(),
},
};
encryptionService = {
decryptIfNeeded: vi.fn().mockReturnValue("plain-token"),
};
provider = new OpenClawProvider(
config,
encryptionService as unknown as EncryptionService,
httpService as unknown as HttpService,
new OpenClawSseBridge(httpService as unknown as HttpService)
);
});
afterEach(() => {
vi.useRealTimers();
});
it("maps listSessions from mocked OpenClaw gateway HTTP responses", async () => {
httpService.axiosRef.get.mockResolvedValue({
data: {
sessions: [
{
id: "session-1",
status: "running",
createdAt: "2026-03-07T15:01:00.000Z",
updatedAt: "2026-03-07T15:02:00.000Z",
},
],
total: 1,
},
});
await expect(provider.listSessions()).resolves.toEqual({
sessions: [
{
id: "session-1",
providerId: "openclaw-home",
providerType: "openclaw",
status: "active",
createdAt: new Date("2026-03-07T15:01:00.000Z"),
updatedAt: new Date("2026-03-07T15:02:00.000Z"),
},
],
total: 1,
});
expect(httpService.axiosRef.get).toHaveBeenCalledWith(
"https://gateway.example.com/api/sessions",
{
headers: {
Authorization: "Bearer plain-token",
},
params: {
limit: 50,
},
}
);
});
it("maps streamMessages from mock SSE events into AgentMessage output", async () => {
httpService.axiosRef.get.mockResolvedValue({
data: Readable.from([
'event: message\ndata: {"id":"msg-1","role":"assistant","content":"hello from stream","timestamp":"2026-03-07T15:03:00.000Z"}\n\n',
'event: status\ndata: {"status":"paused","timestamp":"2026-03-07T15:04:00.000Z"}\n\n',
"data: [DONE]\n\n",
]),
});
const messages = await collectMessages(provider.streamMessages("session-1"));
expect(messages).toEqual([
{
id: "msg-1",
sessionId: "session-1",
role: "assistant",
content: "hello from stream",
timestamp: new Date("2026-03-07T15:03:00.000Z"),
},
{
id: expect.any(String),
sessionId: "session-1",
role: "system",
content: "Session status changed to paused",
timestamp: new Date("2026-03-07T15:04:00.000Z"),
metadata: {
status: "paused",
timestamp: "2026-03-07T15:04:00.000Z",
},
},
]);
});
it("handles unavailable gateway errors", async () => {
httpService.axiosRef.get.mockRejectedValue(new Error("gateway unavailable"));
await expect(provider.listSessions()).rejects.toBeInstanceOf(ServiceUnavailableException);
await expect(provider.listSessions()).rejects.toThrow("gateway unavailable");
});
it("handles bad token decryption errors", async () => {
encryptionService.decryptIfNeeded.mockImplementation(() => {
throw new Error("bad token");
});
await expect(provider.listSessions()).rejects.toBeInstanceOf(ServiceUnavailableException);
await expect(provider.listSessions()).rejects.toThrow("Failed to decrypt API token");
});
it("handles malformed SSE stream responses", async () => {
vi.useFakeTimers();
httpService.axiosRef.get.mockResolvedValue({
data: {
malformed: true,
},
});
const streamPromise = collectMessages(provider.streamMessages("session-malformed"));
const rejection = expect(streamPromise).rejects.toThrow(
"OpenClaw provider openclaw-home failed to stream messages for session session-malformed"
);
for (let attempt = 0; attempt < 5; attempt += 1) {
await vi.advanceTimersByTimeAsync(2000);
}
await rejection;
expect(httpService.axiosRef.get).toHaveBeenCalledTimes(6);
});
});
async function collectMessages(stream: AsyncIterable<AgentMessage>): Promise<AgentMessage[]> {
const messages: AgentMessage[] = [];
for await (const message of stream) {
messages.push(message);
}
return messages;
}

View File

@@ -0,0 +1,271 @@
import type { HttpService } from "@nestjs/axios";
import { ServiceUnavailableException } from "@nestjs/common";
import type { AgentProviderConfig } from "@prisma/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EncryptionService } from "../../../security/encryption.service";
import { OpenClawSseBridge } from "./openclaw-sse.bridge";
import { OpenClawProvider } from "./openclaw.provider";
describe("OpenClawProvider", () => {
let provider: OpenClawProvider;
let httpService: {
axiosRef: {
get: ReturnType<typeof vi.fn>;
post: ReturnType<typeof vi.fn>;
};
};
let encryptionService: {
decryptIfNeeded: ReturnType<typeof vi.fn>;
};
let sseBridge: {
streamSession: ReturnType<typeof vi.fn>;
};
const config: AgentProviderConfig = {
id: "cfg-openclaw-1",
workspaceId: "workspace-1",
name: "openclaw-home",
provider: "openclaw",
gatewayUrl: "https://gateway.example.com/",
credentials: {
apiToken: "enc:token-value",
displayName: "Home OpenClaw",
},
isActive: true,
createdAt: new Date("2026-03-07T15:00:00.000Z"),
updatedAt: new Date("2026-03-07T15:00:00.000Z"),
};
beforeEach(() => {
httpService = {
axiosRef: {
get: vi.fn(),
post: vi.fn(),
},
};
encryptionService = {
decryptIfNeeded: vi.fn().mockReturnValue("plain-token"),
};
sseBridge = {
streamSession: vi.fn(),
};
provider = new OpenClawProvider(
config,
encryptionService as unknown as EncryptionService,
httpService as unknown as HttpService,
sseBridge as unknown as OpenClawSseBridge
);
});
it("maps listSessions from OpenClaw API", async () => {
httpService.axiosRef.get.mockResolvedValue({
data: {
sessions: [
{
id: "session-1",
status: "running",
createdAt: "2026-03-07T15:01:00.000Z",
updatedAt: "2026-03-07T15:02:00.000Z",
},
],
total: 1,
cursor: "next-cursor",
},
});
const result = await provider.listSessions("cursor-1", 25);
expect(httpService.axiosRef.get).toHaveBeenCalledWith(
"https://gateway.example.com/api/sessions",
{
headers: {
Authorization: "Bearer plain-token",
},
params: {
cursor: "cursor-1",
limit: 25,
},
}
);
expect(result).toEqual({
sessions: [
{
id: "session-1",
providerId: "openclaw-home",
providerType: "openclaw",
status: "active",
createdAt: new Date("2026-03-07T15:01:00.000Z"),
updatedAt: new Date("2026-03-07T15:02:00.000Z"),
},
],
total: 1,
cursor: "next-cursor",
});
expect(encryptionService.decryptIfNeeded).toHaveBeenCalledWith("enc:token-value");
});
it("returns null from getSession when OpenClaw returns 404", async () => {
httpService.axiosRef.get.mockRejectedValue({
response: {
status: 404,
},
});
await expect(provider.getSession("missing-session")).resolves.toBeNull();
});
it("maps getMessages response", async () => {
httpService.axiosRef.get.mockResolvedValue({
data: {
messages: [
{
id: "message-1",
sessionId: "session-1",
role: "agent",
content: "hello",
timestamp: "2026-03-07T15:03:00.000Z",
metadata: {
tokens: 128,
},
},
],
},
});
const result = await provider.getMessages("session-1", 20, "before-cursor");
expect(httpService.axiosRef.get).toHaveBeenCalledWith(
"https://gateway.example.com/api/messages",
{
headers: {
Authorization: "Bearer plain-token",
},
params: {
sessionId: "session-1",
limit: 20,
before: "before-cursor",
},
}
);
expect(result).toEqual([
{
id: "message-1",
sessionId: "session-1",
role: "assistant",
content: "hello",
timestamp: new Date("2026-03-07T15:03:00.000Z"),
metadata: {
tokens: 128,
},
},
]);
});
it("maps inject and control endpoints", async () => {
httpService.axiosRef.post
.mockResolvedValueOnce({
data: {
accepted: true,
messageId: "message-2",
},
})
.mockResolvedValueOnce({ data: {} })
.mockResolvedValueOnce({ data: {} })
.mockResolvedValueOnce({ data: {} });
await expect(provider.injectMessage("session-1", "barge in")).resolves.toEqual({
accepted: true,
messageId: "message-2",
});
await provider.pauseSession("session-1");
await provider.resumeSession("session-1");
await provider.killSession("session-1", false);
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
1,
"https://gateway.example.com/api/sessions/session-1/inject",
{ content: "barge in" },
{
headers: {
Authorization: "Bearer plain-token",
},
}
);
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
2,
"https://gateway.example.com/api/sessions/session-1/pause",
{},
{
headers: {
Authorization: "Bearer plain-token",
},
}
);
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
3,
"https://gateway.example.com/api/sessions/session-1/resume",
{},
{
headers: {
Authorization: "Bearer plain-token",
},
}
);
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
4,
"https://gateway.example.com/api/sessions/session-1/kill",
{ force: false },
{
headers: {
Authorization: "Bearer plain-token",
},
}
);
});
it("delegates streaming to OpenClawSseBridge", async () => {
const streamedMessage = {
id: "message-stream",
sessionId: "session-stream",
role: "assistant",
content: "stream hello",
timestamp: new Date("2026-03-07T16:00:00.000Z"),
};
sseBridge.streamSession.mockReturnValue(
(async function* () {
yield streamedMessage;
})()
);
const messages: Array<unknown> = [];
for await (const message of provider.streamMessages("session-stream")) {
messages.push(message);
}
expect(sseBridge.streamSession).toHaveBeenCalledWith(
"https://gateway.example.com",
"session-stream",
{
Authorization: "Bearer plain-token",
}
);
expect(messages).toEqual([streamedMessage]);
});
it("throws ServiceUnavailableException for request failures", async () => {
httpService.axiosRef.get.mockRejectedValue(new Error("gateway unreachable"));
await expect(provider.listSessions()).rejects.toBeInstanceOf(ServiceUnavailableException);
});
it("returns false from isAvailable when gateway check fails", async () => {
httpService.axiosRef.get.mockRejectedValue(new Error("gateway unreachable"));
await expect(provider.isAvailable()).resolves.toBe(false);
});
});

View File

@@ -0,0 +1,613 @@
import { HttpService } from "@nestjs/axios";
import { Injectable, ServiceUnavailableException } from "@nestjs/common";
import type {
AgentMessage,
AgentMessageRole,
AgentSession,
AgentSessionList,
AgentSessionStatus,
IAgentProvider,
InjectResult,
} from "@mosaic/shared";
import type { AgentProviderConfig } from "@prisma/client";
import { randomUUID } from "node:crypto";
import { EncryptionService } from "../../../security/encryption.service";
import { OpenClawSseBridge } from "./openclaw-sse.bridge";
const DEFAULT_SESSION_LIMIT = 50;
const DEFAULT_MESSAGE_LIMIT = 50;
const MAX_MESSAGE_LIMIT = 200;
const OPENCLAW_PROVIDER_TYPE = "openclaw";
const API_TOKEN_KEYS = ["apiToken", "token", "bearerToken"] as const;
const DISPLAY_NAME_KEYS = ["displayName", "label"] as const;
type JsonRecord = Record<string, unknown>;
interface HttpErrorWithResponse {
response?: {
status?: number;
};
}
@Injectable()
export class OpenClawProvider implements IAgentProvider {
readonly providerId: string;
readonly providerType = OPENCLAW_PROVIDER_TYPE;
readonly displayName: string;
constructor(
private readonly config: AgentProviderConfig,
private readonly encryptionService: EncryptionService,
private readonly httpService: HttpService,
private readonly sseBridge: OpenClawSseBridge
) {
this.providerId = this.config.name;
this.displayName = this.resolveDisplayName();
}
validateBaseUrl(): void {
void this.resolveBaseUrl();
}
validateToken(): void {
void this.resolveApiToken();
}
async listSessions(cursor?: string, limit = DEFAULT_SESSION_LIMIT): Promise<AgentSessionList> {
const safeLimit = this.normalizeLimit(limit, DEFAULT_SESSION_LIMIT);
const params: Record<string, number | string> = { limit: safeLimit };
if (typeof cursor === "string" && cursor.length > 0) {
params.cursor = cursor;
}
try {
const response = await this.httpService.axiosRef.get(this.buildUrl("/api/sessions"), {
headers: this.authHeaders(),
params,
});
const page = this.extractSessionPage(response.data);
const sessions = page.records
.map((record) => this.toAgentSession(record))
.filter((session): session is AgentSession => session !== null);
return {
sessions,
total: page.total ?? sessions.length,
...(page.cursor !== undefined ? { cursor: page.cursor } : {}),
};
} catch (error) {
throw this.toServiceUnavailable("list sessions", error);
}
}
async getSession(sessionId: string): Promise<AgentSession | null> {
try {
const response = await this.httpService.axiosRef.get(
this.buildUrl(`/api/sessions/${encodeURIComponent(sessionId)}`),
{
headers: this.authHeaders(),
}
);
const payload = this.unwrapContainer(response.data, ["session", "data"]);
return this.toAgentSession(payload);
} catch (error) {
if (this.getHttpStatus(error) === 404) {
return null;
}
throw this.toServiceUnavailable(`get session ${sessionId}`, error);
}
}
async getMessages(
sessionId: string,
limit = DEFAULT_MESSAGE_LIMIT,
before?: string
): Promise<AgentMessage[]> {
const safeLimit = this.normalizeLimit(limit, DEFAULT_MESSAGE_LIMIT);
const params: Record<string, number | string> = {
sessionId,
limit: safeLimit,
};
if (typeof before === "string" && before.length > 0) {
params.before = before;
}
try {
const response = await this.httpService.axiosRef.get(this.buildUrl("/api/messages"), {
headers: this.authHeaders(),
params,
});
return this.extractMessageRecords(response.data)
.map((record) => this.toAgentMessage(record, sessionId))
.filter((message): message is AgentMessage => message !== null);
} catch (error) {
throw this.toServiceUnavailable(`get messages for session ${sessionId}`, error);
}
}
async injectMessage(sessionId: string, content: string): Promise<InjectResult> {
try {
const response = await this.httpService.axiosRef.post(
this.buildUrl(`/api/sessions/${encodeURIComponent(sessionId)}/inject`),
{ content },
{
headers: this.authHeaders(),
}
);
const payload = this.isRecord(response.data) ? response.data : {};
return {
accepted: typeof payload.accepted === "boolean" ? payload.accepted : true,
...(this.readString(payload.messageId) !== undefined
? { messageId: this.readString(payload.messageId) }
: {}),
};
} catch (error) {
throw this.toServiceUnavailable(`inject message into session ${sessionId}`, error);
}
}
async pauseSession(sessionId: string): Promise<void> {
try {
await this.httpService.axiosRef.post(
this.buildUrl(`/api/sessions/${encodeURIComponent(sessionId)}/pause`),
{},
{
headers: this.authHeaders(),
}
);
} catch (error) {
throw this.toServiceUnavailable(`pause session ${sessionId}`, error);
}
}
async resumeSession(sessionId: string): Promise<void> {
try {
await this.httpService.axiosRef.post(
this.buildUrl(`/api/sessions/${encodeURIComponent(sessionId)}/resume`),
{},
{
headers: this.authHeaders(),
}
);
} catch (error) {
throw this.toServiceUnavailable(`resume session ${sessionId}`, error);
}
}
async killSession(sessionId: string, force = true): Promise<void> {
try {
await this.httpService.axiosRef.post(
this.buildUrl(`/api/sessions/${encodeURIComponent(sessionId)}/kill`),
{ force },
{
headers: this.authHeaders(),
}
);
} catch (error) {
throw this.toServiceUnavailable(`kill session ${sessionId}`, error);
}
}
async *streamMessages(sessionId: string): AsyncIterable<AgentMessage> {
try {
yield* this.sseBridge.streamSession(this.resolveBaseUrl(), sessionId, this.authHeaders());
} catch (error) {
throw this.toServiceUnavailable(`stream messages for session ${sessionId}`, error);
}
}
async isAvailable(): Promise<boolean> {
try {
this.validateBaseUrl();
this.validateToken();
await this.httpService.axiosRef.get(this.buildUrl("/api/sessions"), {
headers: this.authHeaders(),
params: { limit: 1 },
});
return true;
} catch {
return false;
}
}
private extractSessionPage(payload: unknown): {
records: unknown[];
total?: number;
cursor?: string;
} {
if (Array.isArray(payload)) {
return {
records: payload,
total: payload.length,
};
}
if (!this.isRecord(payload)) {
return {
records: [],
};
}
let records: unknown[] = [];
if (Array.isArray(payload.sessions)) {
records = payload.sessions;
} else if (Array.isArray(payload.items)) {
records = payload.items;
} else if (Array.isArray(payload.data)) {
records = payload.data;
}
const total = typeof payload.total === "number" ? payload.total : undefined;
const cursor = this.readString(payload.cursor) ?? this.readString(payload.nextCursor);
return {
records,
total,
...(cursor !== undefined ? { cursor } : {}),
};
}
private extractMessageRecords(payload: unknown): unknown[] {
if (Array.isArray(payload)) {
return payload;
}
if (!this.isRecord(payload)) {
return [];
}
if (Array.isArray(payload.messages)) {
return payload.messages;
}
if (Array.isArray(payload.items)) {
return payload.items;
}
if (Array.isArray(payload.data)) {
return payload.data;
}
return [];
}
private unwrapContainer(payload: unknown, keys: string[]): unknown {
if (!this.isRecord(payload)) {
return payload;
}
for (const key of keys) {
if (key in payload) {
return payload[key];
}
}
return payload;
}
private toAgentSession(record: unknown): AgentSession | null {
if (!this.isRecord(record)) {
return null;
}
const id =
this.readString(record.id) ??
this.readString(record.sessionId) ??
this.readString(record.key);
if (!id) {
return null;
}
const createdAt = this.parseDate(record.createdAt ?? record.spawnedAt ?? record.startedAt);
const updatedAt = this.parseDate(
record.updatedAt ?? record.completedAt ?? record.lastActivityAt ?? record.endedAt,
createdAt
);
const label =
this.readString(record.label) ??
this.readString(record.title) ??
this.readString(record.name) ??
undefined;
const parentSessionId = this.readString(record.parentSessionId) ?? undefined;
const metadata = this.toMetadata(record.metadata);
return {
id,
providerId: this.providerId,
providerType: this.providerType,
...(label !== undefined ? { label } : {}),
status: this.toSessionStatus(this.readString(record.status)),
...(parentSessionId !== undefined ? { parentSessionId } : {}),
createdAt,
updatedAt,
...(metadata !== undefined ? { metadata } : {}),
};
}
private toAgentMessage(value: unknown, fallbackSessionId?: string): AgentMessage | null {
if (typeof value === "string") {
const content = value.trim();
if (content.length === 0 || fallbackSessionId === undefined) {
return null;
}
return {
id: randomUUID(),
sessionId: fallbackSessionId,
role: "assistant",
content,
timestamp: new Date(),
};
}
let candidate: JsonRecord | null = null;
if (this.isRecord(value) && this.isRecord(value.message)) {
candidate = value.message;
} else if (this.isRecord(value)) {
candidate = value;
}
if (candidate === null) {
return null;
}
const sessionId = this.readString(candidate.sessionId) ?? fallbackSessionId;
if (!sessionId) {
return null;
}
const content = this.extractMessageContent(
candidate.content ?? candidate.text ?? candidate.message
);
if (content.length === 0) {
return null;
}
const metadata = this.toMetadata(candidate.metadata);
return {
id: this.readString(candidate.id) ?? this.readString(candidate.messageId) ?? randomUUID(),
sessionId,
role: this.toMessageRole(this.readString(candidate.role) ?? this.readString(candidate.type)),
content,
timestamp: this.parseDate(candidate.timestamp ?? candidate.createdAt),
...(metadata !== undefined ? { metadata } : {}),
};
}
private extractMessageContent(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
const parts: string[] = [];
for (const part of content) {
if (typeof part === "string") {
const trimmed = part.trim();
if (trimmed.length > 0) {
parts.push(trimmed);
}
continue;
}
if (!this.isRecord(part)) {
continue;
}
const text = this.readString(part.text) ?? this.readString(part.content);
if (text !== undefined && text.trim().length > 0) {
parts.push(text.trim());
}
}
return parts.join("\n\n").trim();
}
if (this.isRecord(content)) {
const text = this.readString(content.text) ?? this.readString(content.content);
return text?.trim() ?? "";
}
return "";
}
private toSessionStatus(status?: string): AgentSessionStatus {
switch (status?.toLowerCase()) {
case "active":
case "running":
return "active";
case "paused":
return "paused";
case "completed":
case "done":
case "succeeded":
return "completed";
case "failed":
case "error":
case "killed":
case "terminated":
case "cancelled":
return "failed";
case "idle":
case "pending":
case "queued":
default:
return "idle";
}
}
private toMessageRole(role?: string): AgentMessageRole {
switch (role?.toLowerCase()) {
case "assistant":
case "agent":
return "assistant";
case "system":
return "system";
case "tool":
return "tool";
case "operator":
case "user":
default:
return "user";
}
}
private normalizeLimit(value: number, fallback: number): number {
const normalized = Number.isFinite(value) ? Math.trunc(value) : fallback;
if (normalized < 1) {
return 1;
}
return Math.min(normalized, MAX_MESSAGE_LIMIT);
}
private parseDate(value: unknown, fallback = new Date()): Date {
if (value instanceof Date) {
return value;
}
if (typeof value === "string" || typeof value === "number") {
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
}
return fallback;
}
private toMetadata(value: unknown): Record<string, unknown> | undefined {
if (this.isRecord(value)) {
return value;
}
return undefined;
}
private resolveDisplayName(): string {
const credentials = this.readCredentials();
for (const key of DISPLAY_NAME_KEYS) {
const value = this.readString(credentials[key]);
if (value !== undefined) {
return value;
}
}
return this.config.name;
}
private resolveBaseUrl(): string {
const configRecord = this.config as unknown as JsonRecord;
const rawBaseUrl =
this.readString(this.config.gatewayUrl) ?? this.readString(configRecord.baseUrl);
if (rawBaseUrl === undefined) {
throw new Error(`OpenClaw provider ${this.providerId} is missing gateway URL`);
}
try {
const parsed = new URL(rawBaseUrl);
return parsed.toString().replace(/\/$/u, "");
} catch {
throw new Error(`OpenClaw provider ${this.providerId} has invalid gateway URL`);
}
}
private resolveApiToken(): string {
const configRecord = this.config as unknown as JsonRecord;
const credentials = this.readCredentials();
const rawToken =
this.readString(configRecord.apiToken) ??
this.readString(configRecord.token) ??
this.readString(configRecord.bearerToken) ??
this.findFirstString(credentials, API_TOKEN_KEYS);
if (rawToken === undefined) {
throw new Error(`OpenClaw provider ${this.providerId} is missing apiToken credentials`);
}
try {
return this.encryptionService.decryptIfNeeded(rawToken);
} catch (error) {
throw new Error(`Failed to decrypt API token: ${this.toErrorMessage(error)}`);
}
}
private readCredentials(): JsonRecord {
return this.isRecord(this.config.credentials) ? this.config.credentials : {};
}
private findFirstString(record: JsonRecord, keys: readonly string[]): string | undefined {
for (const key of keys) {
const value = this.readString(record[key]);
if (value !== undefined) {
return value;
}
}
return undefined;
}
private authHeaders(extraHeaders: Record<string, string> = {}): Record<string, string> {
return {
Authorization: `Bearer ${this.resolveApiToken()}`,
...extraHeaders,
};
}
private buildUrl(path: string): string {
return new URL(path, `${this.resolveBaseUrl()}/`).toString();
}
private isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
private readString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
private getHttpStatus(error: unknown): number | undefined {
if (typeof error !== "object" || error === null || !("response" in error)) {
return undefined;
}
const response = (error as HttpErrorWithResponse).response;
return typeof response?.status === "number" ? response.status : undefined;
}
private toServiceUnavailable(operation: string, error: unknown): ServiceUnavailableException {
return new ServiceUnavailableException(
`OpenClaw provider ${this.providerId} failed to ${operation}: ${this.toErrorMessage(error)}`
);
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
}

View File

@@ -0,0 +1,131 @@
import { Logger } from "@nestjs/common";
import type { AgentProviderConfig } from "@prisma/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PrismaService } from "../../prisma/prisma.service";
import { AgentProviderRegistry } from "../agents/agent-provider.registry";
import { OpenClawProviderFactory } from "./openclaw/openclaw.provider-factory";
import { ProvidersModule } from "./providers.module";
type MockOpenClawProvider = {
providerId: string;
validateBaseUrl: ReturnType<typeof vi.fn>;
validateToken: ReturnType<typeof vi.fn>;
isAvailable: ReturnType<typeof vi.fn>;
};
describe("ProvidersModule", () => {
let moduleRef: ProvidersModule;
let prisma: {
agentProviderConfig: {
findMany: ReturnType<typeof vi.fn>;
};
};
let registry: {
registerProvider: ReturnType<typeof vi.fn>;
};
let factory: {
createProvider: ReturnType<typeof vi.fn>;
};
const config: AgentProviderConfig = {
id: "cfg-openclaw-1",
workspaceId: "workspace-1",
name: "openclaw-home",
provider: "openclaw",
gatewayUrl: "https://gateway.example.com",
credentials: { apiToken: "enc:token-value" },
isActive: true,
createdAt: new Date("2026-03-07T15:00:00.000Z"),
updatedAt: new Date("2026-03-07T15:00:00.000Z"),
};
beforeEach(() => {
prisma = {
agentProviderConfig: {
findMany: vi.fn(),
},
};
registry = {
registerProvider: vi.fn(),
};
factory = {
createProvider: vi.fn(),
};
moduleRef = new ProvidersModule(
prisma as unknown as PrismaService,
registry as unknown as AgentProviderRegistry,
factory as unknown as OpenClawProviderFactory
);
});
it("registers reachable OpenClaw providers", async () => {
const provider: MockOpenClawProvider = {
providerId: "openclaw-home",
validateBaseUrl: vi.fn(),
validateToken: vi.fn(),
isAvailable: vi.fn().mockResolvedValue(true),
};
prisma.agentProviderConfig.findMany.mockResolvedValue([config]);
factory.createProvider.mockReturnValue(provider);
await moduleRef.onModuleInit();
expect(prisma.agentProviderConfig.findMany).toHaveBeenCalledWith({
where: {
provider: "openclaw",
isActive: true,
},
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
});
expect(factory.createProvider).toHaveBeenCalledWith(config);
expect(provider.validateBaseUrl).toHaveBeenCalledTimes(1);
expect(provider.validateToken).toHaveBeenCalledTimes(1);
expect(provider.isAvailable).toHaveBeenCalledTimes(1);
expect(registry.registerProvider).toHaveBeenCalledWith(provider);
});
it("skips provider registration when gateway is unreachable", async () => {
const warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined);
const provider: MockOpenClawProvider = {
providerId: "openclaw-home",
validateBaseUrl: vi.fn(),
validateToken: vi.fn(),
isAvailable: vi.fn().mockResolvedValue(false),
};
prisma.agentProviderConfig.findMany.mockResolvedValue([config]);
factory.createProvider.mockReturnValue(provider);
await moduleRef.onModuleInit();
expect(registry.registerProvider).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Skipping OpenClaw provider openclaw-home")
);
});
it("skips provider registration when token decryption fails", async () => {
const errorSpy = vi.spyOn(Logger.prototype, "error").mockImplementation(() => undefined);
const provider: MockOpenClawProvider = {
providerId: "openclaw-home",
validateBaseUrl: vi.fn(),
validateToken: vi.fn().mockImplementation(() => {
throw new Error("Failed to decrypt API token");
}),
isAvailable: vi.fn().mockResolvedValue(true),
};
prisma.agentProviderConfig.findMany.mockResolvedValue([config]);
factory.createProvider.mockReturnValue(provider);
await moduleRef.onModuleInit();
expect(registry.registerProvider).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("token decryption failed"));
expect(provider.isAvailable).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,95 @@
import { HttpModule } from "@nestjs/axios";
import { Logger, Module, OnModuleInit } from "@nestjs/common";
import type { AgentProviderConfig } from "@prisma/client";
import { PrismaModule } from "../../prisma/prisma.module";
import { PrismaService } from "../../prisma/prisma.service";
import { EncryptionService } from "../../security/encryption.service";
import { AgentProviderRegistry } from "../agents/agent-provider.registry";
import { AgentsModule } from "../agents/agents.module";
import { OpenClawProviderFactory } from "./openclaw/openclaw.provider-factory";
import { OpenClawSseBridge } from "./openclaw/openclaw-sse.bridge";
const OPENCLAW_PROVIDER_TYPE = "openclaw";
@Module({
imports: [
AgentsModule,
PrismaModule,
HttpModule.register({
timeout: 10000,
maxRedirects: 5,
}),
],
providers: [EncryptionService, OpenClawSseBridge, OpenClawProviderFactory],
})
export class ProvidersModule implements OnModuleInit {
private readonly logger = new Logger(ProvidersModule.name);
constructor(
private readonly prisma: PrismaService,
private readonly registry: AgentProviderRegistry,
private readonly openClawProviderFactory: OpenClawProviderFactory
) {}
async onModuleInit(): Promise<void> {
const configs = await this.prisma.agentProviderConfig.findMany({
where: {
provider: OPENCLAW_PROVIDER_TYPE,
isActive: true,
},
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
});
for (const config of configs) {
await this.registerProvider(config);
}
}
private async registerProvider(config: AgentProviderConfig): Promise<void> {
const provider = this.openClawProviderFactory.createProvider(config);
try {
provider.validateBaseUrl();
} catch (error) {
this.logger.warn(
`Skipping OpenClaw provider ${config.name}: invalid configuration (${this.toErrorMessage(error)})`
);
return;
}
try {
provider.validateToken();
} catch (error) {
this.logger.error(
`Skipping OpenClaw provider ${config.name}: token decryption failed (${this.toErrorMessage(error)})`
);
return;
}
try {
const available = await provider.isAvailable();
if (!available) {
this.logger.warn(
`Skipping OpenClaw provider ${config.name}: gateway ${config.gatewayUrl} is unreachable`
);
return;
}
} catch (error) {
this.logger.warn(
`Skipping OpenClaw provider ${config.name}: gateway ${config.gatewayUrl} is unreachable (${this.toErrorMessage(error)})`
);
return;
}
this.registry.registerProvider(provider);
this.logger.log(`Registered OpenClaw provider ${provider.providerId}`);
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
}

View File

@@ -7,6 +7,7 @@ import { AgentsModule } from "./api/agents/agents.module";
import { MissionControlModule } from "./api/mission-control/mission-control.module"; import { MissionControlModule } from "./api/mission-control/mission-control.module";
import { QueueApiModule } from "./api/queue/queue-api.module"; import { QueueApiModule } from "./api/queue/queue-api.module";
import { AgentProvidersModule } from "./api/agent-providers/agent-providers.module"; import { AgentProvidersModule } from "./api/agent-providers/agent-providers.module";
import { ProvidersModule } from "./api/providers/providers.module";
import { CoordinatorModule } from "./coordinator/coordinator.module"; import { CoordinatorModule } from "./coordinator/coordinator.module";
import { BudgetModule } from "./budget/budget.module"; import { BudgetModule } from "./budget/budget.module";
import { CIModule } from "./ci"; import { CIModule } from "./ci";
@@ -54,6 +55,7 @@ import { orchestratorConfig } from "./config/orchestrator.config";
HealthModule, HealthModule,
AgentsModule, AgentsModule,
AgentProvidersModule, AgentProvidersModule,
ProvidersModule,
MissionControlModule, MissionControlModule,
QueueApiModule, QueueApiModule,
CoordinatorModule, CoordinatorModule,

View File

@@ -4,6 +4,6 @@ import { AuthGuard } from "./guards/auth.guard";
@Module({ @Module({
providers: [OrchestratorApiKeyGuard, AuthGuard], providers: [OrchestratorApiKeyGuard, AuthGuard],
exports: [AuthGuard], exports: [OrchestratorApiKeyGuard, AuthGuard],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -0,0 +1,106 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const ENCRYPTED_PREFIX = "enc:";
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const DERIVED_KEY_LENGTH = 32;
const HKDF_SALT = "mosaic.crypto.v1";
const HKDF_INFO = "mosaic-db-secret-encryption";
@Injectable()
export class EncryptionService {
private key: Buffer | null = null;
constructor(private readonly configService: ConfigService) {}
encryptIfNeeded(value: string): string {
if (this.isEncrypted(value)) {
return value;
}
return this.encrypt(value);
}
encrypt(plaintext: string): string {
try {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, this.getOrCreateKey(), iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
const payload = Buffer.concat([iv, ciphertext, authTag]);
return `${ENCRYPTED_PREFIX}${payload.toString("base64")}`;
} catch {
throw new Error("Failed to encrypt value");
}
}
decryptIfNeeded(value: string): string {
if (!this.isEncrypted(value)) {
return value;
}
return this.decrypt(value);
}
decrypt(encrypted: string): string {
if (!this.isEncrypted(encrypted)) {
throw new Error("Value is not encrypted");
}
const payloadBase64 = encrypted.slice(ENCRYPTED_PREFIX.length);
try {
const payload = Buffer.from(payloadBase64, "base64");
if (payload.length < IV_LENGTH + AUTH_TAG_LENGTH) {
throw new Error("Encrypted payload is too short");
}
const iv = payload.subarray(0, IV_LENGTH);
const authTag = payload.subarray(payload.length - AUTH_TAG_LENGTH);
const ciphertext = payload.subarray(IV_LENGTH, payload.length - AUTH_TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, this.getOrCreateKey(), iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
} catch {
throw new Error("Failed to decrypt value");
}
}
isEncrypted(value: string): boolean {
return value.startsWith(ENCRYPTED_PREFIX);
}
private getOrCreateKey(): Buffer {
if (this.key !== null) {
return this.key;
}
const secret = this.configService.get<string>("MOSAIC_SECRET_KEY");
if (!secret) {
throw new Error(
"orchestrator: MOSAIC_SECRET_KEY is required. Set it in your config or via MOSAIC_SECRET_KEY."
);
}
if (secret.length < 32) {
throw new Error("MOSAIC_SECRET_KEY must be at least 32 characters");
}
this.key = Buffer.from(
hkdfSync(
"sha256",
Buffer.from(secret, "utf8"),
Buffer.from(HKDF_SALT, "utf8"),
Buffer.from(HKDF_INFO, "utf8"),
DERIVED_KEY_LENGTH
)
);
return this.key;
}
}

View File

@@ -0,0 +1,315 @@
import type { HttpService } from "@nestjs/axios";
import type {
AgentMessage,
AgentSession,
AgentSessionList,
IAgentProvider,
InjectResult,
} from "@mosaic/shared";
import type { AgentProviderConfig } from "@prisma/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { InternalAgentProvider } from "../../src/api/agents/internal-agent.provider";
import { AgentProviderRegistry } from "../../src/api/agents/agent-provider.registry";
import { MissionControlController } from "../../src/api/mission-control/mission-control.controller";
import { MissionControlService } from "../../src/api/mission-control/mission-control.service";
import { OpenClawProviderFactory } from "../../src/api/providers/openclaw/openclaw.provider-factory";
import { OpenClawSseBridge } from "../../src/api/providers/openclaw/openclaw-sse.bridge";
import { ProvidersModule } from "../../src/api/providers/providers.module";
import type { PrismaService } from "../../src/prisma/prisma.service";
import type { EncryptionService } from "../../src/security/encryption.service";
type MockProvider = IAgentProvider & {
listSessions: ReturnType<typeof vi.fn>;
getSession: ReturnType<typeof vi.fn>;
injectMessage: ReturnType<typeof vi.fn>;
pauseSession: ReturnType<typeof vi.fn>;
killSession: ReturnType<typeof vi.fn>;
};
type MockPrisma = {
agentProviderConfig: {
create: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
};
operatorAuditLog: {
create: ReturnType<typeof vi.fn>;
};
};
const emptyMessageStream = async function* (): AsyncIterable<AgentMessage> {
return;
};
describe("MS23-P3-004 API integration", () => {
let controller: MissionControlController;
let providersModule: ProvidersModule;
let registry: AgentProviderRegistry;
let prisma: MockPrisma;
let httpService: {
axiosRef: {
get: ReturnType<typeof vi.fn>;
post: ReturnType<typeof vi.fn>;
};
};
const gatewayUrl = "https://openclaw-gateway.example.com";
const internalSession: AgentSession = {
id: "session-internal-1",
providerId: "internal",
providerType: "internal",
status: "active",
createdAt: new Date("2026-03-07T16:00:00.000Z"),
updatedAt: new Date("2026-03-07T16:02:00.000Z"),
};
const openClawGatewaySession = {
id: "session-openclaw-1",
status: "running",
createdAt: "2026-03-07T16:01:00.000Z",
updatedAt: "2026-03-07T16:03:00.000Z",
};
const createInternalProvider = (session: AgentSession): MockProvider => ({
providerId: "internal",
providerType: "internal",
displayName: "Internal",
listSessions: vi.fn().mockResolvedValue({ sessions: [session], total: 1 } as AgentSessionList),
getSession: vi.fn().mockImplementation(async (sessionId: string) => {
return sessionId === session.id ? session : 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),
});
beforeEach(() => {
const providerConfigs: AgentProviderConfig[] = [];
prisma = {
agentProviderConfig: {
create: vi.fn().mockImplementation(async (args: { data: Record<string, unknown> }) => {
const now = new Date("2026-03-07T15:00:00.000Z");
const record: AgentProviderConfig = {
id: `cfg-${String(providerConfigs.length + 1)}`,
workspaceId: String(args.data.workspaceId),
name: String(args.data.name),
provider: String(args.data.provider),
gatewayUrl: String(args.data.gatewayUrl),
credentials: (args.data.credentials ?? {}) as AgentProviderConfig["credentials"],
isActive: args.data.isActive !== false,
createdAt: now,
updatedAt: now,
};
providerConfigs.push(record);
return record;
}),
findMany: vi
.fn()
.mockImplementation(
async (args: { where?: { provider?: string; isActive?: boolean } }) => {
const where = args.where ?? {};
return providerConfigs.filter((config) => {
if (where.provider !== undefined && config.provider !== where.provider) {
return false;
}
if (where.isActive !== undefined && config.isActive !== where.isActive) {
return false;
}
return true;
});
}
),
},
operatorAuditLog: {
create: vi.fn().mockResolvedValue(undefined),
},
};
httpService = {
axiosRef: {
get: vi.fn().mockImplementation(async (url: string) => {
if (url === `${gatewayUrl}/api/sessions`) {
return {
data: {
sessions: [openClawGatewaySession],
total: 1,
},
};
}
if (url === `${gatewayUrl}/api/sessions/${openClawGatewaySession.id}`) {
return {
data: {
session: openClawGatewaySession,
},
};
}
throw new Error(`Unexpected GET ${url}`);
}),
post: vi.fn().mockImplementation(async (url: string) => {
if (url.endsWith("/inject")) {
return { data: { accepted: true, messageId: "msg-inject-1" } };
}
if (url.endsWith("/pause") || url.endsWith("/kill")) {
return { data: {} };
}
throw new Error(`Unexpected POST ${url}`);
}),
},
};
const internalProvider = createInternalProvider(internalSession);
registry = new AgentProviderRegistry(internalProvider as unknown as InternalAgentProvider);
registry.onModuleInit();
const encryptionService = {
decryptIfNeeded: vi.fn().mockReturnValue("plain-openclaw-token"),
};
const sseBridge = new OpenClawSseBridge(httpService as unknown as HttpService);
const openClawProviderFactory = new OpenClawProviderFactory(
encryptionService as unknown as EncryptionService,
httpService as unknown as HttpService,
sseBridge
);
providersModule = new ProvidersModule(
prisma as unknown as PrismaService,
registry,
openClawProviderFactory
);
const missionControlService = new MissionControlService(
registry,
prisma as unknown as PrismaService
);
controller = new MissionControlController(missionControlService);
});
it("Phase 3 gate: OpenClaw provider config registered in DB → provider loaded on boot → sessions returned from /api/mission-control/sessions → inject/pause/kill proxied to gateway", async () => {
await prisma.agentProviderConfig.create({
data: {
workspaceId: "workspace-ms23",
name: "openclaw-home",
provider: "openclaw",
gatewayUrl,
credentials: {
apiToken: "enc:test-openclaw-token",
},
isActive: true,
},
});
await providersModule.onModuleInit();
// Equivalent to GET /api/mission-control/sessions
const sessionsResponse = await controller.listSessions();
expect(sessionsResponse.sessions.map((session) => session.id)).toEqual([
"session-openclaw-1",
"session-internal-1",
]);
expect(sessionsResponse.sessions).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "session-internal-1",
providerId: "internal",
}),
expect.objectContaining({
id: "session-openclaw-1",
providerId: "openclaw-home",
providerType: "openclaw",
}),
])
);
const operatorRequest = {
user: {
id: "operator-ms23",
},
};
await expect(
controller.injectMessage(
"session-openclaw-1",
{
message: "Ship it",
},
operatorRequest
)
).resolves.toEqual({ accepted: true, messageId: "msg-inject-1" });
await expect(controller.pauseSession("session-openclaw-1", operatorRequest)).resolves.toEqual({
message: "Session session-openclaw-1 paused",
});
await expect(
controller.killSession(
"session-openclaw-1",
{
force: false,
},
operatorRequest
)
).resolves.toEqual({ message: "Session session-openclaw-1 killed" });
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
1,
`${gatewayUrl}/api/sessions/session-openclaw-1/inject`,
{ content: "Ship it" },
{
headers: {
Authorization: "Bearer plain-openclaw-token",
},
}
);
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
2,
`${gatewayUrl}/api/sessions/session-openclaw-1/pause`,
{},
{
headers: {
Authorization: "Bearer plain-openclaw-token",
},
}
);
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
3,
`${gatewayUrl}/api/sessions/session-openclaw-1/kill`,
{ force: false },
{
headers: {
Authorization: "Bearer plain-openclaw-token",
},
}
);
expect(prisma.operatorAuditLog.create).toHaveBeenNthCalledWith(1, {
data: {
sessionId: "session-openclaw-1",
userId: "operator-ms23",
provider: "openclaw-home",
action: "inject",
content: "Ship it",
metadata: {
payload: {
message: "Ship it",
},
},
},
});
});
});

View File

@@ -4,7 +4,7 @@ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: "node", environment: "node",
include: ["**/*.e2e-spec.ts"], include: ["tests/integration/**/*.e2e-spec.ts", "tests/integration/**/*.spec.ts"],
testTimeout: 30000, testTimeout: 30000,
}, },
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/web", "name": "@mosaic/web",
"version": "0.0.20", "version": "0.0.23",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "next build", "build": "next build",

View File

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

View File

@@ -0,0 +1,528 @@
"use client";
import {
useCallback,
useEffect,
useState,
type ChangeEvent,
type ReactElement,
type SyntheticEvent,
} from "react";
import { Pencil, Trash2 } from "lucide-react";
import { FleetSettingsNav } from "@/components/settings/FleetSettingsNav";
import {
createAgentProvider,
deleteAgentProvider,
fetchAgentProviders,
updateAgentProvider,
type AgentProviderConfig,
type CreateAgentProviderRequest,
type UpdateAgentProviderRequest,
} from "@/lib/api/agent-providers";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
interface ProviderFormData {
name: string;
provider: "openclaw";
gatewayUrl: string;
apiToken: string;
isActive: boolean;
}
const NAME_PATTERN = /^[a-zA-Z0-9-]+$/;
const INITIAL_FORM: ProviderFormData = {
name: "",
provider: "openclaw",
gatewayUrl: "",
apiToken: "",
isActive: true,
};
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return fallback;
}
function isValidHttpsUrl(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === "https:";
} catch {
return false;
}
}
function formatCreatedDate(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return "Unknown";
}
return new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}).format(parsed);
}
function validateForm(form: ProviderFormData, isEditing: boolean): string | null {
const name = form.name.trim();
if (name.length === 0) {
return "Name is required.";
}
if (!NAME_PATTERN.test(name)) {
return "Name must contain only letters, numbers, and hyphens.";
}
const gatewayUrl = form.gatewayUrl.trim();
if (gatewayUrl.length === 0) {
return "Gateway URL is required.";
}
if (!isValidHttpsUrl(gatewayUrl)) {
return "Gateway URL must be a valid https:// URL.";
}
if (!isEditing && form.apiToken.trim().length === 0) {
return "API token is required when creating a provider.";
}
return null;
}
export default function AgentProvidersSettingsPage(): ReactElement {
const [providers, setProviders] = useState<AgentProviderConfig[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const [editingProvider, setEditingProvider] = useState<AgentProviderConfig | null>(null);
const [form, setForm] = useState<ProviderFormData>(INITIAL_FORM);
const [formError, setFormError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [deleteTarget, setDeleteTarget] = useState<AgentProviderConfig | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const loadProviders = useCallback(async (showLoadingState: boolean): Promise<void> => {
if (showLoadingState) {
setIsLoading(true);
} else {
setIsRefreshing(true);
}
try {
const data = await fetchAgentProviders();
setProviders(data);
setError(null);
} catch (loadError: unknown) {
setError(getErrorMessage(loadError, "Failed to load agent providers."));
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
useEffect(() => {
void loadProviders(true);
}, [loadProviders]);
function openCreateDialog(): void {
setEditingProvider(null);
setForm(INITIAL_FORM);
setFormError(null);
setIsDialogOpen(true);
}
function openEditDialog(provider: AgentProviderConfig): void {
setEditingProvider(provider);
setForm({
name: provider.name,
provider: "openclaw",
gatewayUrl: provider.gatewayUrl,
apiToken: "",
isActive: provider.isActive,
});
setFormError(null);
setIsDialogOpen(true);
}
function closeDialog(): void {
if (isSaving) {
return;
}
setIsDialogOpen(false);
setEditingProvider(null);
setForm(INITIAL_FORM);
setFormError(null);
}
async function handleSubmit(event: SyntheticEvent): Promise<void> {
event.preventDefault();
setFormError(null);
setSuccessMessage(null);
const validationError = validateForm(form, editingProvider !== null);
if (validationError !== null) {
setFormError(validationError);
return;
}
const name = form.name.trim();
const gatewayUrl = form.gatewayUrl.trim();
const apiToken = form.apiToken.trim();
try {
setIsSaving(true);
if (editingProvider) {
const updatePayload: UpdateAgentProviderRequest = {
name,
provider: form.provider,
gatewayUrl,
isActive: form.isActive,
};
if (apiToken.length > 0) {
updatePayload.credentials = { apiToken };
}
await updateAgentProvider(editingProvider.id, updatePayload);
setSuccessMessage(`Updated provider "${name}".`);
} else {
const createPayload: CreateAgentProviderRequest = {
name,
provider: form.provider,
gatewayUrl,
credentials: { apiToken },
isActive: form.isActive,
};
await createAgentProvider(createPayload);
setSuccessMessage(`Added provider "${name}".`);
}
setIsDialogOpen(false);
setEditingProvider(null);
setForm(INITIAL_FORM);
await loadProviders(false);
} catch (saveError: unknown) {
setFormError(getErrorMessage(saveError, "Unable to save agent provider."));
} finally {
setIsSaving(false);
}
}
async function handleDeleteProvider(): Promise<void> {
if (!deleteTarget) {
return;
}
try {
setIsDeleting(true);
await deleteAgentProvider(deleteTarget.id);
setSuccessMessage(`Deleted provider "${deleteTarget.name}".`);
setDeleteTarget(null);
await loadProviders(false);
} catch (deleteError: unknown) {
setError(getErrorMessage(deleteError, "Failed to delete agent provider."));
} finally {
setIsDeleting(false);
}
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="space-y-4">
<div>
<h1 className="text-3xl font-bold">Agent Providers</h1>
<p className="text-muted-foreground mt-1">
Register OpenClaw gateways and API tokens used for external agent sessions.
</p>
</div>
<FleetSettingsNav />
</div>
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>OpenClaw Gateways</CardTitle>
<CardDescription>
Add one or more OpenClaw gateway endpoints and control which ones are active.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => {
void loadProviders(false);
}}
disabled={isLoading || isRefreshing}
>
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
<Button onClick={openCreateDialog}>Add Provider</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
{successMessage ? <p className="text-sm text-emerald-600">{successMessage}</p> : null}
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading agent providers...</p>
) : providers.length === 0 ? (
<p className="text-sm text-muted-foreground">
No agent providers configured yet. Add one to register an OpenClaw gateway.
</p>
) : (
providers.map((provider) => (
<div
key={provider.id}
className="rounded-lg border p-4 flex flex-col gap-4 md:flex-row md:items-start md:justify-between"
>
<div className="space-y-2 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-semibold truncate">{provider.name}</p>
<Badge variant={provider.isActive ? "default" : "secondary"}>
{provider.isActive ? "Active" : "Inactive"}
</Badge>
<Badge variant="outline">{provider.provider}</Badge>
</div>
<p className="text-sm text-muted-foreground break-all">
Gateway URL: {provider.gatewayUrl}
</p>
<p className="text-sm text-muted-foreground">
Created: {formatCreatedDate(provider.createdAt)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
openEditDialog(provider);
}}
>
<Pencil className="h-4 w-4 mr-2" />
Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
setDeleteTarget(provider);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
))
)}
</CardContent>
</Card>
<Dialog
open={isDialogOpen}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
closeDialog();
return;
}
setIsDialogOpen(true);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingProvider ? "Edit Agent Provider" : "Add Agent Provider"}
</DialogTitle>
<DialogDescription>
Configure an OpenClaw gateway URL and API token for agent provider registration.
</DialogDescription>
</DialogHeader>
<form onSubmit={(event) => void handleSubmit(event)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="agent-provider-name">Name</Label>
<Input
id="agent-provider-name"
value={form.name}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setForm((previous) => ({ ...previous, name: event.target.value }));
}}
placeholder="openclaw-primary"
maxLength={100}
disabled={isSaving}
required
/>
<p className="text-xs text-muted-foreground">
Use letters, numbers, and hyphens only.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="agent-provider-type">Provider Type</Label>
<Select
value={form.provider}
onValueChange={(value) => {
if (value === "openclaw") {
setForm((previous) => ({ ...previous, provider: value }));
}
}}
disabled={isSaving}
>
<SelectTrigger id="agent-provider-type">
<SelectValue placeholder="Select provider type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="openclaw">openclaw</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="agent-provider-gateway-url">Gateway URL</Label>
<Input
id="agent-provider-gateway-url"
value={form.gatewayUrl}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setForm((previous) => ({ ...previous, gatewayUrl: event.target.value }));
}}
placeholder="https://my-openclaw.example.com"
disabled={isSaving}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-provider-api-token">API Token</Label>
<Input
id="agent-provider-api-token"
type="password"
value={form.apiToken}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setForm((previous) => ({ ...previous, apiToken: event.target.value }));
}}
placeholder={
editingProvider ? "Leave blank to keep existing token" : "Enter API token"
}
autoComplete="new-password"
disabled={isSaving}
/>
<p className="text-xs text-muted-foreground">
{editingProvider
? "Leave blank to keep the currently stored token."
: "Required when creating a provider."}
</p>
</div>
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<div>
<Label htmlFor="agent-provider-active">Provider Status</Label>
<p className="text-xs text-muted-foreground">
Inactive providers remain saved but are excluded from routing.
</p>
</div>
<Switch
id="agent-provider-active"
checked={form.isActive}
onCheckedChange={(checked) => {
setForm((previous) => ({ ...previous, isActive: checked }));
}}
disabled={isSaving}
/>
</div>
{formError ? (
<p className="text-sm text-destructive" role="alert">
{formError}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={closeDialog} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? "Saving..." : editingProvider ? "Save Changes" : "Create Provider"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open && !isDeleting) {
setDeleteTarget(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Agent Provider</AlertDialogTitle>
<AlertDialogDescription>
Delete provider "{deleteTarget?.name}"? This permanently removes its gateway and token
configuration.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteProvider} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete Provider"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -227,6 +227,33 @@ const categories: CategoryConfig[] = [
</svg> </svg>
), ),
}, },
{
title: "Agent Providers",
description:
"Register OpenClaw gateway URLs and API tokens for external agent provider routing.",
href: "/settings/agent-providers",
accent: "var(--ms-blue-400)",
iconBg: "rgba(47, 128, 255, 0.12)",
icon: (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 6.5h12" />
<path d="M6.5 10h7" />
<path d="M4 13.5h12" />
<circle cx="5.5" cy="10" r="1.5" />
<circle cx="14.5" cy="10" r="1.5" />
</svg>
),
},
{ {
title: "Agent Config", title: "Agent Config",
description: "Choose primary and fallback models, plus optional personality/SOUL instructions.", description: "Choose primary and fallback models, plus optional personality/SOUL instructions.",

View File

@@ -0,0 +1,93 @@
import { type NextRequest, NextResponse } from "next/server";
const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001";
function getOrchestratorUrl(): string {
return (
process.env.ORCHESTRATOR_URL ??
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ??
process.env.NEXT_PUBLIC_API_URL ??
DEFAULT_ORCHESTRATOR_URL
);
}
/**
* Generic catch-all proxy for orchestrator API routes.
*
* Forwards any request to /api/orchestrator/<path> → ORCHESTRATOR_URL/<path>
* with the ORCHESTRATOR_API_KEY injected server-side so it never reaches the browser.
*
* Supports GET, POST, PATCH, DELETE, PUT.
*
* Example:
* GET /api/orchestrator/mission-control/sessions
* → GET ORCHESTRATOR_URL/api/mission-control/sessions
* POST /api/orchestrator/mission-control/sessions/abc/kill
* → POST ORCHESTRATOR_URL/api/mission-control/sessions/abc/kill
*/
async function proxyToOrchestrator(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
): Promise<NextResponse> {
const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY;
if (!orchestratorApiKey) {
return NextResponse.json(
{ error: "ORCHESTRATOR_API_KEY is not configured on the web server." },
{ status: 503 }
);
}
const { path } = await context.params;
const upstreamPath = `/${path.join("/")}`;
const search = request.nextUrl.search;
const upstreamUrl = `${getOrchestratorUrl()}${upstreamPath}${search}`;
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 30_000);
try {
const headers: Record<string, string> = {
"X-API-Key": orchestratorApiKey,
};
const contentType = request.headers.get("Content-Type");
if (contentType) {
headers["Content-Type"] = contentType;
}
const hasBody = request.method !== "GET" && request.method !== "HEAD";
const body = hasBody ? await request.text() : null;
const upstream = await fetch(upstreamUrl, {
method: request.method,
headers,
...(body !== null ? { body } : {}),
cache: "no-store",
signal: controller.signal,
});
const responseText = await upstream.text();
return new NextResponse(responseText, {
status: upstream.status,
headers: {
"Content-Type": upstream.headers.get("Content-Type") ?? "application/json",
},
});
} catch (error) {
const message =
error instanceof Error && error.name === "AbortError"
? "Orchestrator request timed out."
: "Unable to reach orchestrator.";
return NextResponse.json({ error: message }, { status: 502 });
} finally {
clearTimeout(timeout);
}
}
export const GET = proxyToOrchestrator;
export const POST = proxyToOrchestrator;
export const PATCH = proxyToOrchestrator;
export const PUT = proxyToOrchestrator;
export const DELETE = proxyToOrchestrator;

View File

@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001";
function getOrchestratorUrl(): string {
return (
process.env.ORCHESTRATOR_URL ??
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ??
process.env.NEXT_PUBLIC_API_URL ??
DEFAULT_ORCHESTRATOR_URL
);
}
export const dynamic = "force-dynamic";
export async function GET(): Promise<NextResponse> {
const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY;
if (!orchestratorApiKey) {
return NextResponse.json(
{ error: "ORCHESTRATOR_API_KEY is not configured on the web server." },
{ status: 503 }
);
}
try {
const upstream = await fetch(`${getOrchestratorUrl()}/api/queue/notifications/stream`, {
method: "GET",
headers: {
"X-API-Key": orchestratorApiKey,
Accept: "text/event-stream",
},
cache: "no-store",
});
if (!upstream.ok || upstream.body === null) {
const message = await upstream.text();
return new NextResponse(message || "Failed to connect to queue notifications stream", {
status: upstream.status || 502,
headers: {
"Content-Type": upstream.headers.get("Content-Type") ?? "text/plain; charset=utf-8",
},
});
}
return new NextResponse(upstream.body, {
status: upstream.status,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
} catch {
return NextResponse.json({ error: "Unable to reach orchestrator." }, { status: 502 });
}
}

View File

@@ -4,6 +4,7 @@ import { Outfit, Fira_Code } from "next/font/google";
import { AuthProvider } from "@/lib/auth/auth-context"; import { AuthProvider } from "@/lib/auth/auth-context";
import { ErrorBoundary } from "@/components/error-boundary"; import { ErrorBoundary } from "@/components/error-boundary";
import { ThemeProvider } from "@/providers/ThemeProvider"; import { ThemeProvider } from "@/providers/ThemeProvider";
import { ReactQueryProvider } from "@/providers/ReactQueryProvider";
import "./globals.css"; import "./globals.css";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -56,9 +57,11 @@ export default function RootLayout({ children }: { children: ReactNode }): React
</head> </head>
<body> <body>
<ThemeProvider> <ThemeProvider>
<ReactQueryProvider>
<ErrorBoundary> <ErrorBoundary>
<AuthProvider>{children}</AuthProvider> <AuthProvider>{children}</AuthProvider>
</ErrorBoundary> </ErrorBoundary>
</ReactQueryProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
@@ -29,6 +29,10 @@ interface NavGroup {
items: NavItemConfig[]; items: NavItemConfig[];
} }
interface QueueNotification {
id: string;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SVG Icons (16x16 viewBox, stroke="currentColor", strokeWidth="1.5") // SVG Icons (16x16 viewBox, stroke="currentColor", strokeWidth="1.5")
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -685,6 +689,72 @@ function CollapseToggle({ collapsed, onToggle }: CollapseToggleProps): React.JSX
export function AppSidebar(): React.JSX.Element { export function AppSidebar(): React.JSX.Element {
const pathname = usePathname(); const pathname = usePathname();
const { collapsed, toggleCollapsed, mobileOpen, setMobileOpen, isMobile } = useSidebar(); const { collapsed, toggleCollapsed, mobileOpen, setMobileOpen, isMobile } = useSidebar();
const [missionControlBadgeCount, setMissionControlBadgeCount] = useState<number>(0);
useEffect(() => {
let isActive = true;
const loadNotificationCount = async (): Promise<void> => {
try {
const response = await fetch("/api/orchestrator/api/queue/notifications", {
method: "GET",
});
if (!response.ok) {
return;
}
const payload = (await response.json()) as QueueNotification[];
if (isActive) {
setMissionControlBadgeCount(Array.isArray(payload) ? payload.length : 0);
}
} catch {
// Ignore badge failures in the nav.
}
};
void loadNotificationCount();
if (typeof EventSource === "undefined") {
return (): void => {
isActive = false;
};
}
const source = new EventSource("/api/orchestrator/queue/notifications/stream");
source.onmessage = (event: MessageEvent<string>): void => {
try {
const payload = JSON.parse(event.data) as QueueNotification[];
if (isActive) {
setMissionControlBadgeCount(Array.isArray(payload) ? payload.length : 0);
}
} catch {
// Ignore malformed badge updates.
}
};
return (): void => {
isActive = false;
source.close();
};
}, []);
const navGroups = useMemo(
() =>
NAV_GROUPS.map((group) => ({
...group,
items: group.items.map((item) =>
item.href === "/mission-control" && missionControlBadgeCount > 0
? {
...item,
badge: { label: String(missionControlBadgeCount) },
}
: item
),
})),
[missionControlBadgeCount]
);
return ( return (
<> <>
@@ -722,7 +792,7 @@ export function AppSidebar(): React.JSX.Element {
}} }}
aria-label="Main navigation" aria-label="Main navigation"
> >
{NAV_GROUPS.map((group) => ( {navGroups.map((group) => (
<div key={group.label} style={{ marginBottom: "18px" }}> <div key={group.label} style={{ marginBottom: "18px" }}>
{/* Group label — hidden when collapsed */} {/* Group label — hidden when collapsed */}
{!collapsed && ( {!collapsed && (

View File

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

@@ -44,6 +44,25 @@ interface AuditLogResponse {
total: number; total: number;
page: number; page: number;
pages: number; pages: number;
notice?: string;
}
function createEmptyAuditLogResponse(page: number, notice?: string): AuditLogResponse {
return {
items: [],
total: 0,
page,
pages: 0,
...(notice !== undefined ? { notice } : {}),
};
}
function isRateLimitError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return /429|rate limit|too many requests/i.test(error.message);
} }
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -138,7 +157,17 @@ async function fetchAuditLog(
params.set("sessionId", normalizedSessionId); params.set("sessionId", normalizedSessionId);
} }
return apiGet<AuditLogResponse>(`/api/mission-control/audit-log?${params.toString()}`); try {
return await apiGet<AuditLogResponse>(
`/api/orchestrator/api/mission-control/audit-log?${params.toString()}`
);
} catch (error) {
if (isRateLimitError(error)) {
return createEmptyAuditLogResponse(page, "Rate limited - retrying...");
}
return createEmptyAuditLogResponse(page);
}
} }
export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): React.JSX.Element { export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): React.JSX.Element {
@@ -180,11 +209,10 @@ export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): Rea
const totalItems = auditLogQuery.data?.total ?? 0; const totalItems = auditLogQuery.data?.total ?? 0;
const totalPages = auditLogQuery.data?.pages ?? 0; const totalPages = auditLogQuery.data?.pages ?? 0;
const items = auditLogQuery.data?.items ?? []; const items = auditLogQuery.data?.items ?? [];
const notice = auditLogQuery.data?.notice;
const canGoPrevious = page > 1; const canGoPrevious = page > 1;
const canGoNext = totalPages > 0 && page < totalPages; const canGoNext = totalPages > 0 && page < totalPages;
const errorMessage =
auditLogQuery.error instanceof Error ? auditLogQuery.error.message : "Failed to load audit log";
return ( return (
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
@@ -237,10 +265,13 @@ export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): Rea
Loading audit log... Loading audit log...
</td> </td>
</tr> </tr>
) : auditLogQuery.error ? ( ) : notice ? (
<tr> <tr>
<td colSpan={5} className="px-3 py-6 text-center text-sm text-red-500"> <td
{errorMessage} colSpan={5}
className="px-3 py-6 text-center text-sm text-muted-foreground"
>
{notice}
</td> </td>
</tr> </tr>
) : items.length === 0 ? ( ) : items.length === 0 ? (

View File

@@ -0,0 +1,167 @@
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/orchestrator/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/orchestrator/api/mission-control/sessions/session-2/pause",
undefined,
]);
expect(calls[1]).toEqual([
"/api/orchestrator/api/mission-control/sessions/session-2/inject",
{ content: "hello world" },
]);
expect(calls[2]).toEqual([
"/api/orchestrator/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/orchestrator/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

@@ -39,7 +39,7 @@ export function BargeInInput({ sessionId, onSent }: BargeInInputProps): React.JS
} }
const encodedSessionId = encodeURIComponent(sessionId); const encodedSessionId = encodeURIComponent(sessionId);
const baseEndpoint = `/api/mission-control/sessions/${encodedSessionId}`; const baseEndpoint = `/api/orchestrator/api/mission-control/sessions/${encodedSessionId}`;
let didPause = false; let didPause = false;
let didInject = false; let didInject = false;

View File

@@ -0,0 +1,206 @@
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/orchestrator/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

@@ -83,7 +83,7 @@ function groupByProvider(sessions: MissionControlSession[]): ProviderSessionGrou
async function fetchSessions(): Promise<MissionControlSession[]> { async function fetchSessions(): Promise<MissionControlSession[]> {
const payload = await apiGet<MissionControlSession[] | SessionsPayload>( const payload = await apiGet<MissionControlSession[] | SessionsPayload>(
"/api/mission-control/sessions" "/api/orchestrator/api/mission-control/sessions"
); );
return Array.isArray(payload) ? payload : payload.sessions; return Array.isArray(payload) ? payload : payload.sessions;
} }
@@ -118,9 +118,12 @@ export function GlobalAgentRoster({
const killMutation = useMutation({ const killMutation = useMutation({
mutationFn: async (sessionId: string): Promise<string> => { mutationFn: async (sessionId: string): Promise<string> => {
await apiPost<{ message: string }>(`/api/mission-control/sessions/${sessionId}/kill`, { await apiPost<{ message: string }>(
`/api/orchestrator/api/mission-control/sessions/${sessionId}/kill`,
{
force: false, force: false,
}); }
);
return sessionId; return sessionId;
}, },
onSuccess: (): void => { onSuccess: (): void => {

View File

@@ -0,0 +1,182 @@
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/orchestrator/api/mission-control/sessions/internal-1/kill",
{
force: true,
}
);
});
expect(mockApiPost).not.toHaveBeenCalledWith(
"/api/orchestrator/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/orchestrator/api/mission-control/sessions/internal-2/kill",
{
force: true,
}
);
expect(mockApiPost).toHaveBeenCalledWith(
"/api/orchestrator/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

@@ -96,9 +96,12 @@ export function KillAllDialog({ sessions, onComplete }: KillAllDialogProps): Rea
const killRequests = targetSessions.map(async (session) => { const killRequests = targetSessions.map(async (session) => {
try { try {
await apiPost<{ message: string }>(`/api/mission-control/sessions/${session.id}/kill`, { await apiPost<{ message: string }>(
`/api/orchestrator/api/mission-control/sessions/${session.id}/kill`,
{
force: true, force: true,
}); }
);
return true; return true;
} catch { } catch {
return false; return false;

View File

@@ -0,0 +1,79 @@
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();
const mockQueueNotificationFeed = 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/mission-control/QueueNotificationFeed", () => ({
QueueNotificationFeed: (props: unknown): React.JSX.Element => {
mockQueueNotificationFeed(props);
return <div data-testid="queue-notification-feed" />;
},
}));
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();
expect(screen.getByTestId("queue-notification-feed")).toBeInTheDocument();
});
});

View File

@@ -1,21 +1,87 @@
"use client"; "use client";
import { useState } from "react"; import { useCallback, useState } from "react";
import { AuditLogDrawer } from "@/components/mission-control/AuditLogDrawer"; import { AuditLogDrawer } from "@/components/mission-control/AuditLogDrawer";
import { GlobalAgentRoster } from "@/components/mission-control/GlobalAgentRoster"; import { GlobalAgentRoster } from "@/components/mission-control/GlobalAgentRoster";
import { MissionControlPanel } from "@/components/mission-control/MissionControlPanel"; import {
MAX_PANEL_COUNT,
MIN_PANEL_COUNT,
MissionControlPanel,
type PanelConfig,
} from "@/components/mission-control/MissionControlPanel";
import { QueueNotificationFeed } from "@/components/mission-control/QueueNotificationFeed";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useSessions } from "@/hooks/useMissionControl";
const DEFAULT_PANEL_SLOTS = ["panel-1", "panel-2", "panel-3", "panel-4"] as const; const INITIAL_PANELS: PanelConfig[] = [{}];
export function MissionControlLayout(): React.JSX.Element { export function MissionControlLayout(): React.JSX.Element {
const { sessions } = useSessions(); const [panels, setPanels] = useState<PanelConfig[]>(INITIAL_PANELS);
const [selectedSessionId, setSelectedSessionId] = useState<string>(); const [selectedSessionId, setSelectedSessionId] = useState<string>();
// First panel: selected session (from roster click) or first available session const handleSelectSession = useCallback((sessionId: string): void => {
const firstPanelSessionId = selectedSessionId ?? sessions[0]?.id; setSelectedSessionId(sessionId);
const panelSessionIds = [firstPanelSessionId, undefined, undefined, undefined] as const;
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 ( return (
<section className="flex h-full min-h-0 flex-col overflow-hidden" aria-label="Mission Control"> <section className="flex h-full min-h-0 flex-col overflow-hidden" aria-label="Mission Control">
@@ -29,16 +95,24 @@ export function MissionControlLayout(): React.JSX.Element {
/> />
</header> </header>
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[280px_minmax(0,1fr)]"> <div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[280px_minmax(0,1fr)_320px]">
<aside className="h-full min-h-0"> <aside className="h-full min-h-0">
<GlobalAgentRoster <GlobalAgentRoster
onSelectSession={setSelectedSessionId} onSelectSession={handleSelectSession}
{...(selectedSessionId !== undefined ? { selectedSessionId } : {})} {...(selectedSessionId !== undefined ? { selectedSessionId } : {})}
/> />
</aside> </aside>
<main className="h-full min-h-0 overflow-hidden"> <main className="h-full min-h-0 overflow-hidden">
<MissionControlPanel panels={DEFAULT_PANEL_SLOTS} panelSessionIds={panelSessionIds} /> <MissionControlPanel
panels={panels}
onAddPanel={handleAddPanel}
onRemovePanel={handleRemovePanel}
onExpandPanel={handleExpandPanel}
/>
</main> </main>
<aside className="h-full min-h-0">
<QueueNotificationFeed />
</aside>
</div> </div>
</section> </section>
); );

View File

@@ -0,0 +1,153 @@
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,27 +1,107 @@
"use client"; "use client";
import { useEffect } from "react";
import { OrchestratorPanel } from "@/components/mission-control/OrchestratorPanel"; import { OrchestratorPanel } from "@/components/mission-control/OrchestratorPanel";
import { Button } from "@/components/ui/button";
export interface PanelConfig {
sessionId?: string;
expanded?: boolean;
}
interface MissionControlPanelProps { interface MissionControlPanelProps {
panels: readonly string[]; panels: PanelConfig[];
panelSessionIds?: readonly (string | undefined)[]; 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({ export function MissionControlPanel({
panels, panels,
panelSessionIds, onAddPanel,
onRemovePanel,
onExpandPanel,
}: MissionControlPanelProps): React.JSX.Element { }: MissionControlPanelProps): React.JSX.Element {
return ( const expandedPanelIndex = panels.findIndex((panel) => panel.expanded);
<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"> const expandedPanel = expandedPanelIndex >= 0 ? panels[expandedPanelIndex] : undefined;
{panels.map((panelId, index) => { const canAddPanel = panels.length < MAX_PANEL_COUNT;
const sessionId = panelSessionIds?.[index]; const canRemovePanel = panels.length > MIN_PANEL_COUNT;
if (sessionId === undefined) { useEffect(() => {
return <OrchestratorPanel key={panelId} />; if (expandedPanelIndex < 0) {
return;
} }
return <OrchestratorPanel key={panelId} sessionId={sessionId} />; 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> </div>
); );
} }

View File

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

@@ -5,6 +5,7 @@ import { formatDistanceToNow } from "date-fns";
import { BargeInInput } from "@/components/mission-control/BargeInInput"; import { BargeInInput } from "@/components/mission-control/BargeInInput";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import type { BadgeVariant } 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PanelControls } from "@/components/mission-control/PanelControls"; import { PanelControls } from "@/components/mission-control/PanelControls";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@@ -36,6 +37,64 @@ const CONNECTION_TEXT: Record<MissionControlConnectionStatus, string> = {
export interface OrchestratorPanelProps { export interface OrchestratorPanelProps {
sessionId?: string; 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 { function formatRelativeTimestamp(timestamp: string): string {
@@ -47,7 +106,13 @@ function formatRelativeTimestamp(timestamp: string): string {
return formatDistanceToNow(parsedDate, { addSuffix: true }); return formatDistanceToNow(parsedDate, { addSuffix: true });
} }
export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.JSX.Element { export function OrchestratorPanel({
sessionId,
onClose,
closeDisabled,
onExpand,
expanded,
}: OrchestratorPanelProps): React.JSX.Element {
const { messages, status, error } = useSessionStream(sessionId ?? ""); const { messages, status, error } = useSessionStream(sessionId ?? "");
const { sessions } = useSessions(); const { sessions } = useSessions();
const bottomAnchorRef = useRef<HTMLDivElement | null>(null); const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
@@ -55,6 +120,12 @@ export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.
const selectedSessionStatus = sessions.find((session) => session.id === sessionId)?.status; const selectedSessionStatus = sessions.find((session) => session.id === sessionId)?.status;
const controlsStatus = optimisticStatus ?? selectedSessionStatus ?? "unknown"; const controlsStatus = optimisticStatus ?? selectedSessionStatus ?? "unknown";
const panelHeaderActionProps = {
...(onClose !== undefined ? { onClose } : {}),
...(closeDisabled !== undefined ? { closeDisabled } : {}),
...(onExpand !== undefined ? { onExpand } : {}),
...(expanded !== undefined ? { expanded } : {}),
};
useEffect(() => { useEffect(() => {
bottomAnchorRef.current?.scrollIntoView({ block: "end" }); bottomAnchorRef.current?.scrollIntoView({ block: "end" });
@@ -68,7 +139,10 @@ export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.
return ( return (
<Card className="flex h-full min-h-[220px] flex-col"> <Card className="flex h-full min-h-[220px] flex-col">
<CardHeader> <CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base">Orchestrator Panel</CardTitle> <CardTitle className="text-base">Orchestrator Panel</CardTitle>
<PanelHeaderActions {...panelHeaderActionProps} />
</div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-1 items-center justify-center text-sm text-muted-foreground"> <CardContent className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select an agent to view its stream Select an agent to view its stream
@@ -80,9 +154,11 @@ export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.
return ( return (
<Card className="flex h-full min-h-[220px] flex-col"> <Card className="flex h-full min-h-[220px] flex-col">
<CardHeader className="space-y-2"> <CardHeader className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"> <div className="flex items-start justify-between gap-2">
<CardTitle className="text-base">Orchestrator Panel</CardTitle> <CardTitle className="text-base">Orchestrator Panel</CardTitle>
<div className="flex flex-col items-start gap-2 sm:items-end"> <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"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<span <span
className={`h-2.5 w-2.5 rounded-full ${CONNECTION_DOT_CLASS[status]} ${ className={`h-2.5 w-2.5 rounded-full ${CONNECTION_DOT_CLASS[status]} ${
@@ -98,7 +174,6 @@ export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.
onStatusChange={setOptimisticStatus} onStatusChange={setOptimisticStatus}
/> />
</div> </div>
</div>
<p className="truncate text-xs text-muted-foreground">Session: {sessionId}</p> <p className="truncate text-xs text-muted-foreground">Session: {sessionId}</p>
</CardHeader> </CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col p-0"> <CardContent className="flex min-h-0 flex-1 flex-col p-0">

View File

@@ -0,0 +1,167 @@
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/orchestrator/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/orchestrator/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/orchestrator/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

@@ -50,23 +50,23 @@ export function PanelControls({
switch (action) { switch (action) {
case "pause": case "pause":
await apiPost<{ message: string }>( await apiPost<{ message: string }>(
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause` `/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause`
); );
return { nextStatus: "paused" }; return { nextStatus: "paused" };
case "resume": case "resume":
await apiPost<{ message: string }>( await apiPost<{ message: string }>(
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume` `/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume`
); );
return { nextStatus: "active" }; return { nextStatus: "active" };
case "graceful-kill": case "graceful-kill":
await apiPost<{ message: string }>( await apiPost<{ message: string }>(
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, `/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
{ force: false } { force: false }
); );
return { nextStatus: "killed" }; return { nextStatus: "killed" };
case "force-kill": case "force-kill":
await apiPost<{ message: string }>( await apiPost<{ message: string }>(
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, `/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
{ force: true } { force: true }
); );
return { nextStatus: "killed" }; return { nextStatus: "killed" };

View File

@@ -0,0 +1,225 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
vi.mock("date-fns", () => ({
formatDistanceToNow: (): string => "5 minutes ago",
}));
interface MockButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
}
interface MockContainerProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
}
interface MockEventSourceInstance {
url: string;
onerror: ((event: Event) => void) | null;
onmessage: ((event: MessageEvent<string>) => void) | null;
close: ReturnType<typeof vi.fn>;
emitMessage: (payload: unknown) => void;
}
interface QueueNotification {
id: string;
agent: string;
filename: string;
payload: unknown;
createdAt: string;
}
const mockApiGet = vi.fn<(endpoint: string) => Promise<QueueNotification[]>>();
const mockApiPost = vi.fn<(endpoint: string) => Promise<{ success: true; id: string }>>();
const mockShowToast = vi.fn<(message: string, variant?: string) => void>();
let mockEventSourceInstances: MockEventSourceInstance[] = [];
const MockEventSource = vi.fn(function (this: MockEventSourceInstance, url: string): void {
this.url = url;
this.onerror = null;
this.onmessage = null;
this.close = vi.fn();
this.emitMessage = (payload: unknown): void => {
this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(payload) }));
};
mockEventSourceInstances.push(this);
});
vi.mock("@/lib/api/client", () => ({
apiGet: (endpoint: string): Promise<QueueNotification[]> => mockApiGet(endpoint),
apiPost: (endpoint: string): Promise<{ success: true; id: string }> => mockApiPost(endpoint),
}));
vi.mock("@mosaic/ui", () => ({
useToast: (): { showToast: typeof mockShowToast; removeToast: ReturnType<typeof vi.fn> } => ({
showToast: mockShowToast,
removeToast: vi.fn(),
}),
}));
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/collapsible", () => ({
Collapsible: ({ children }: MockContainerProps): React.JSX.Element => <div>{children}</div>,
}));
vi.mock("@/components/ui/scroll-area", () => ({
ScrollArea: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
<div {...props}>{children}</div>
),
}));
vi.mock("@/components/ui/skeleton", () => ({
Skeleton: (props: HTMLAttributes<HTMLDivElement>): React.JSX.Element => <div {...props} />,
}));
import { QueueNotificationFeed } from "./QueueNotificationFeed";
function latestEventSource(): MockEventSourceInstance {
const instance = mockEventSourceInstances[mockEventSourceInstances.length - 1];
if (!instance) {
throw new Error("Expected an EventSource instance");
}
return instance;
}
function makeNotification(overrides: Partial<QueueNotification>): QueueNotification {
return {
id: "notif-1",
agent: "mosaic",
filename: "notif-1.json",
payload: {
taskId: "MS24-WEB-001",
eventType: "task.ready",
},
createdAt: "2026-03-08T23:00:00.000Z",
...overrides,
};
}
describe("QueueNotificationFeed", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
vi.stubGlobal("EventSource", MockEventSource);
vi.stubGlobal("fetch", vi.fn());
mockEventSourceInstances = [];
mockApiGet.mockResolvedValue([]);
mockApiPost.mockResolvedValue({ success: true, id: "notif-1" });
});
afterEach((): void => {
vi.unstubAllGlobals();
});
it("loads and renders notifications grouped by agent", async (): Promise<void> => {
mockApiGet.mockResolvedValue([
makeNotification({ id: "notif-1", agent: "mosaic" }),
makeNotification({
id: "notif-2",
agent: "dyor",
payload: { taskId: "MS24-WEB-002", eventType: "task.blocked" },
}),
]);
render(<QueueNotificationFeed />);
await waitFor((): void => {
expect(mockApiGet).toHaveBeenCalledWith("/api/orchestrator/api/queue/notifications");
});
expect(screen.getByText("mosaic")).toBeInTheDocument();
expect(screen.getByText("dyor")).toBeInTheDocument();
expect(screen.getByText("MS24-WEB-001")).toBeInTheDocument();
expect(screen.getByText("task.ready")).toBeInTheDocument();
expect(screen.getAllByText("5 minutes ago")).toHaveLength(2);
expect(MockEventSource).toHaveBeenCalledWith("/api/orchestrator/queue/notifications/stream");
});
it("acknowledges a notification and removes it from the list", async (): Promise<void> => {
const user = userEvent.setup();
mockApiGet.mockResolvedValue([makeNotification({ id: "notif-ack" })]);
render(<QueueNotificationFeed />);
await waitFor((): void => {
expect(screen.getByText("MS24-WEB-001")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "ACK notification MS24-WEB-001" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith(
"/api/orchestrator/api/queue/notifications/notif-ack/ack"
);
});
await waitFor((): void => {
expect(screen.getByText("No pending notifications")).toBeInTheDocument();
});
});
it("renders the empty state when there are no notifications", async (): Promise<void> => {
mockApiGet.mockResolvedValue([]);
render(<QueueNotificationFeed />);
await waitFor((): void => {
expect(screen.getByText("No pending notifications")).toBeInTheDocument();
});
});
it("refreshes the list when an SSE message arrives", async (): Promise<void> => {
mockApiGet.mockResolvedValue([makeNotification({ id: "notif-before" })]);
render(<QueueNotificationFeed />);
await waitFor((): void => {
expect(screen.getByText("MS24-WEB-001")).toBeInTheDocument();
});
act(() => {
latestEventSource().emitMessage([
makeNotification({
id: "notif-after",
agent: "sage",
payload: { taskId: "MS24-WEB-003", eventType: "task.failed" },
}),
]);
});
await waitFor((): void => {
expect(screen.getByText("sage")).toBeInTheDocument();
expect(screen.getByText("MS24-WEB-003")).toBeInTheDocument();
expect(screen.getByText("task.failed")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,332 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { BellOff, ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
import { useToast } from "@mosaic/ui";
import { Badge } 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";
export interface QueueNotificationFeedProps {
className?: string;
}
interface QueueNotification {
id: string;
agent: string;
filename: string;
payload: unknown;
createdAt: string;
}
interface QueuePayloadRecord {
taskId?: unknown;
type?: unknown;
eventType?: unknown;
event?: unknown;
}
interface NotificationGroup {
agent: string;
notifications: QueueNotification[];
}
const NOTIFICATIONS_ENDPOINT = "/api/orchestrator/api/queue/notifications";
const NOTIFICATIONS_STREAM_ENDPOINT = "/api/orchestrator/queue/notifications/stream";
function joinClasses(...classes: (string | undefined)[]): string {
return classes.filter((value) => typeof value === "string" && value.length > 0).join(" ");
}
function asPayloadRecord(payload: unknown): QueuePayloadRecord | null {
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
return payload as QueuePayloadRecord;
}
function getNotificationTaskId(notification: QueueNotification): string {
const payload = asPayloadRecord(notification.payload);
return typeof payload?.taskId === "string" && payload.taskId.trim().length > 0
? payload.taskId
: notification.id;
}
function getNotificationEventType(notification: QueueNotification): string {
const payload = asPayloadRecord(notification.payload);
const candidates = [payload?.eventType, payload?.type, payload?.event];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim().length > 0) {
return candidate;
}
}
return "notification";
}
function formatNotificationAge(createdAt: string): string {
const parsedDate = new Date(createdAt);
if (Number.isNaN(parsedDate.getTime())) {
return "just now";
}
return formatDistanceToNow(parsedDate, { addSuffix: true });
}
function groupNotificationsByAgent(notifications: QueueNotification[]): NotificationGroup[] {
const grouped = new Map<string, QueueNotification[]>();
for (const notification of notifications) {
const current = grouped.get(notification.agent) ?? [];
current.push(notification);
grouped.set(notification.agent, current);
}
return Array.from(grouped.entries())
.sort(([leftAgent], [rightAgent]) => leftAgent.localeCompare(rightAgent))
.map(([agent, items]) => ({
agent,
notifications: [...items].sort(
(left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()
),
}));
}
export function QueueNotificationFeed({
className,
}: QueueNotificationFeedProps): React.JSX.Element {
const { showToast } = useToast();
const [notifications, setNotifications] = useState<QueueNotification[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [acknowledgingIds, setAcknowledgingIds] = useState<Record<string, boolean>>({});
const [isCollapsed, setIsCollapsed] = useState(false);
const [now, setNow] = useState(Date.now());
const refreshNotifications = useCallback(async (): Promise<void> => {
try {
const payload = await apiGet<QueueNotification[]>(NOTIFICATIONS_ENDPOINT);
setNotifications(payload);
setErrorMessage(null);
} catch (error) {
const message =
error instanceof Error && error.message.trim().length > 0
? error.message
: "Failed to load queue notifications.";
setErrorMessage(message);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void refreshNotifications();
}, [refreshNotifications]);
useEffect(() => {
if (typeof EventSource === "undefined") {
return undefined;
}
const source = new EventSource(NOTIFICATIONS_STREAM_ENDPOINT);
source.onmessage = (event: MessageEvent<string>): void => {
try {
const payload = JSON.parse(event.data) as QueueNotification[];
setNotifications(payload);
setErrorMessage(null);
setIsLoading(false);
} catch {
setErrorMessage("Received an invalid notification stream payload.");
}
};
source.onerror = (): void => {
setErrorMessage((current) => current ?? "Queue notification stream disconnected.");
};
return (): void => {
source.close();
};
}, []);
useEffect(() => {
const intervalId = window.setInterval(() => {
setNow(Date.now());
}, 60_000);
return (): void => {
window.clearInterval(intervalId);
};
}, []);
const groupedNotifications = useMemo(
() => groupNotificationsByAgent(notifications),
[notifications]
);
const pendingCount = notifications.length;
const handleAck = useCallback(
async (notificationId: string): Promise<void> => {
setAcknowledgingIds((current) => ({
...current,
[notificationId]: true,
}));
try {
await apiPost<{ success: true; id: string }>(
`/api/orchestrator/api/queue/notifications/${encodeURIComponent(notificationId)}/ack`
);
setNotifications((current) => current.filter((item) => item.id !== notificationId));
} catch (error) {
const message =
error instanceof Error && error.message.trim().length > 0
? error.message
: "Failed to acknowledge notification.";
showToast(message, "error");
} finally {
setAcknowledgingIds((current) => {
const { [notificationId]: _omitted, ...remaining } = current;
return remaining;
});
}
},
[showToast]
);
return (
<Card className={joinClasses("flex h-full min-h-0 flex-col", className)}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<CardTitle className="text-base">Queue Notifications</CardTitle>
<Badge variant={pendingCount > 0 ? "status-info" : "secondary"}>{pendingCount}</Badge>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setIsCollapsed((current) => !current);
}}
aria-label={isCollapsed ? "Expand queue notifications" : "Collapse queue notifications"}
title={isCollapsed ? "Expand queue notifications" : "Collapse queue notifications"}
>
{isCollapsed ? (
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
) : (
<ChevronRight className="h-4 w-4" aria-hidden="true" />
)}
</Button>
</div>
</CardHeader>
<Collapsible open={!isCollapsed} className="min-h-0 flex-1">
{!isCollapsed ? (
<CardContent className="min-h-0 flex-1 px-3 pb-3">
{isLoading ? (
<ScrollArea className="h-full">
<div className="space-y-2 pr-1">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={`queue-notification-skeleton-${String(index)}`}
className="h-14 w-full"
/>
))}
</div>
</ScrollArea>
) : errorMessage && notifications.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-red-500">
{errorMessage}
</div>
) : groupedNotifications.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
<BellOff className="h-5 w-5" aria-hidden="true" />
<span>No pending notifications</span>
</div>
) : (
<ScrollArea className="h-full">
<div className="space-y-4 pr-1">
{groupedNotifications.map((group) => (
<section key={group.agent} className="space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">{group.agent}</h3>
<span className="text-xs text-muted-foreground">
{group.notifications.length}
</span>
</div>
<div className="space-y-2">
{group.notifications.map((notification) => {
const taskId = getNotificationTaskId(notification);
const eventType = getNotificationEventType(notification);
const isAcknowledging = acknowledgingIds[notification.id] ?? false;
return (
<article
key={notification.id}
className="rounded-lg border border-border/70 bg-card px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-foreground">
{taskId}
</span>
<Badge variant="secondary">{eventType}</Badge>
</div>
<time
className="block text-xs text-muted-foreground"
dateTime={notification.createdAt}
>
{formatNotificationAge(notification.createdAt)}
</time>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={isAcknowledging}
onClick={() => {
void handleAck(notification.id);
}}
aria-label={`ACK notification ${taskId}`}
>
{isAcknowledging ? (
<span className="flex items-center gap-2">
<Loader2
className="h-4 w-4 animate-spin"
aria-hidden="true"
/>
ACK
</span>
) : (
"ACK"
)}
</Button>
</div>
</article>
);
})}
</div>
</section>
))}
</div>
</ScrollArea>
)}
</CardContent>
) : null}
</Collapsible>
<span className="sr-only" aria-live="polite">
{pendingCount} pending notifications, refreshed at {new Date(now).toISOString()}
</span>
</Card>
);
}

View File

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

View File

@@ -11,6 +11,7 @@ interface FleetSettingsLink {
const FLEET_SETTINGS_LINKS: FleetSettingsLink[] = [ const FLEET_SETTINGS_LINKS: FleetSettingsLink[] = [
{ href: "/settings/providers", label: "Providers" }, { href: "/settings/providers", label: "Providers" },
{ href: "/settings/agent-providers", label: "Agent Providers" },
{ href: "/settings/agent-config", label: "Agent Config" }, { href: "/settings/agent-config", label: "Agent Config" },
{ href: "/settings/auth", label: "Authentication" }, { href: "/settings/auth", label: "Authentication" },
]; ];

View File

@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as client from "./client";
import {
createAgentProvider,
deleteAgentProvider,
fetchAgentProviders,
updateAgentProvider,
} from "./agent-providers";
vi.mock("./client");
beforeEach((): void => {
vi.clearAllMocks();
});
describe("fetchAgentProviders", (): void => {
it("calls provider list endpoint", async (): Promise<void> => {
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
await fetchAgentProviders();
expect(client.apiGet).toHaveBeenCalledWith("/api/agent-providers");
});
});
describe("createAgentProvider", (): void => {
it("posts create payload", async (): Promise<void> => {
vi.mocked(client.apiPost).mockResolvedValueOnce({ id: "provider-1" } as never);
await createAgentProvider({
name: "openclaw-primary",
provider: "openclaw",
gatewayUrl: "https://openclaw.example.com",
credentials: {
apiToken: "top-secret",
},
isActive: true,
});
expect(client.apiPost).toHaveBeenCalledWith("/api/agent-providers", {
name: "openclaw-primary",
provider: "openclaw",
gatewayUrl: "https://openclaw.example.com",
credentials: {
apiToken: "top-secret",
},
isActive: true,
});
});
});
describe("updateAgentProvider", (): void => {
it("sends PUT request with update payload", async (): Promise<void> => {
vi.mocked(client.apiRequest).mockResolvedValueOnce({ id: "provider-1" } as never);
await updateAgentProvider("provider-1", {
gatewayUrl: "https://new-openclaw.example.com",
isActive: false,
});
expect(client.apiRequest).toHaveBeenCalledWith("/api/agent-providers/provider-1", {
method: "PUT",
body: JSON.stringify({
gatewayUrl: "https://new-openclaw.example.com",
isActive: false,
}),
});
});
});
describe("deleteAgentProvider", (): void => {
it("calls delete endpoint", async (): Promise<void> => {
vi.mocked(client.apiDelete).mockResolvedValueOnce(undefined as never);
await deleteAgentProvider("provider-1");
expect(client.apiDelete).toHaveBeenCalledWith("/api/agent-providers/provider-1");
});
});

View File

@@ -0,0 +1,61 @@
import { apiDelete, apiGet, apiPost, apiRequest } from "./client";
export type AgentProviderType = "openclaw";
export interface AgentProviderCredentials {
apiToken?: string;
}
export interface AgentProviderConfig {
id: string;
workspaceId: string;
name: string;
provider: AgentProviderType;
gatewayUrl: string;
credentials: AgentProviderCredentials | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateAgentProviderRequest {
name: string;
provider: AgentProviderType;
gatewayUrl: string;
credentials: {
apiToken: string;
};
isActive: boolean;
}
export interface UpdateAgentProviderRequest {
name?: string;
provider?: AgentProviderType;
gatewayUrl?: string;
credentials?: AgentProviderCredentials;
isActive?: boolean;
}
export async function fetchAgentProviders(): Promise<AgentProviderConfig[]> {
return apiGet<AgentProviderConfig[]>("/api/agent-providers");
}
export async function createAgentProvider(
data: CreateAgentProviderRequest
): Promise<AgentProviderConfig> {
return apiPost<AgentProviderConfig>("/api/agent-providers", data);
}
export async function updateAgentProvider(
providerId: string,
data: UpdateAgentProviderRequest
): Promise<AgentProviderConfig> {
return apiRequest<AgentProviderConfig>(`/api/agent-providers/${providerId}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
export async function deleteAgentProvider(providerId: string): Promise<void> {
await apiDelete<unknown>(`/api/agent-providers/${providerId}`);
}

View File

@@ -18,4 +18,5 @@ export * from "./projects";
export * from "./workspaces"; export * from "./workspaces";
export * from "./admin"; export * from "./admin";
export * from "./fleet-settings"; export * from "./fleet-settings";
export * from "./agent-providers";
export * from "./activity"; export * from "./activity";

View File

@@ -0,0 +1,28 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
interface ReactQueryProviderProps {
children: ReactNode;
}
export function ReactQueryProvider({ children }: ReactQueryProviderProps): React.JSX.Element {
// Create a stable QueryClient per component mount (one per app session)
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// Don't refetch on window focus in a dashboard context
refetchOnWindowFocus: false,
// Stale time of 30s — short enough for live data, avoids hammering
staleTime: 30_000,
retry: 1,
},
},
})
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@@ -316,6 +316,8 @@ services:
SANDBOX_ENABLED: "true" SANDBOX_ENABLED: "true"
# API key for authenticating requests from the web proxy # API key for authenticating requests from the web proxy
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY} ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
# Prisma database connection (uses the shared openbrain postgres)
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@openbrain_brain-db:5432/${POSTGRES_DB:-mosaic}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- orchestrator_workspace:/workspace - orchestrator_workspace:/workspace
@@ -331,6 +333,7 @@ services:
start_period: 40s start_period: 40s
networks: networks:
- internal - internal
- openbrain-brain-internal
cap_drop: cap_drop:
- ALL - ALL
cap_add: cap_add:
@@ -403,6 +406,7 @@ services:
networks: networks:
- internal - internal
- traefik-public - traefik-public
- openbrain-brain-internal
deploy: deploy:
restart_policy: restart_policy:
condition: on-failure condition: on-failure

View File

@@ -62,6 +62,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Updated Makefile with Traefik deployment shortcuts - Updated Makefile with Traefik deployment shortcuts
- Enhanced docker-compose.override.yml.example with Traefik examples - Enhanced docker-compose.override.yml.example with Traefik examples
## [0.0.23] - 2026-03-07
### Added
- **Mission Control Dashboard** — real-time agent orchestration UI at `/mission-control`
- Live SSE message streams per agent (`OrchestratorPanel`)
- Barge-in input with optional pause-before-send
- Pause / Resume / Graceful Kill / Force Kill controls per agent panel
- Global agent roster sidebar with tree view and per-agent kill
- KillAllDialog with scope selector (requires typing `KILL ALL` to confirm)
- AuditLogDrawer with paginated operator action history
- Responsive panel grid: up to 6 panels, add/remove, full-screen expand
- **Agent Provider Interface** — extensible `IAgentProvider` plugin system
- `InternalAgentProvider` wrapping existing orchestrator services
- `AgentProviderRegistry` aggregating sessions across providers
- `AgentProviderConfig` CRUD API (`/api/agent-providers`)
- Mission Control proxy API (`/api/mission-control/*`) with SSE proxying and audit log
- **OpenClaw Provider Adapter** — connect external OpenClaw instances
- `OpenClawProvider` implementing `IAgentProvider` against OpenClaw REST API
- Dedicated `OpenClawSseBridge` with retry logic (5 retries, 2s backoff)
- Provider config UI in Settings for registering OpenClaw gateways
- Tokens encrypted at rest via `EncryptionService` (AES-256-GCM)
- **OperatorAuditLog** — every inject/pause/resume/kill persisted to DB
### Changed
- Orchestrator app: extended with `AgentsModule` exports for provider registry
- Settings navigation: added "Agent Providers" section
### Fixed
- Flaky web tests: async query timing in Kanban and OnboardingWizard tests
## [0.0.1] - 2026-01-28 ## [0.0.1] - 2026-01-28
### Added ### Added
@@ -79,5 +112,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Documentation structure (Bookstack-compatible hierarchy) - Documentation structure (Bookstack-compatible hierarchy)
- Development workflow and coding standards - Development workflow and coding standards
[Unreleased]: https://git.mosaicstack.dev/mosaic/stack/compare/v0.0.1...HEAD [Unreleased]: https://git.mosaicstack.dev/mosaic/stack/compare/v0.0.23...HEAD
[0.0.23]: https://git.mosaicstack.dev/mosaic/stack/releases/tag/v0.0.23
[0.0.1]: https://git.mosaicstack.dev/mosaic/stack/releases/tag/v0.0.1 [0.0.1]: https://git.mosaicstack.dev/mosaic/stack/releases/tag/v0.0.1

View File

@@ -1,6 +1,6 @@
# Mosaic Stack Roadmap # Mosaic Stack Roadmap
**Last Updated:** 2026-01-29 **Last Updated:** 2026-03-07
**Authoritative Source:** [Issues & Milestones](https://git.mosaicstack.dev/mosaic/stack/issues) **Authoritative Source:** [Issues & Milestones](https://git.mosaicstack.dev/mosaic/stack/issues)
## Versioning Policy ## Versioning Policy
@@ -12,6 +12,20 @@
| `0.x.y` | Pre-stable iteration, API may change with notice | | `0.x.y` | Pre-stable iteration, API may change with notice |
| `1.0.0` | Stable release, public API contract | | `1.0.0` | Stable release, public API contract |
## Release Track (Current)
### ✅ v0.0.23 — Mission Control Dashboard (Complete)
- Mission Control dashboard shipped at `/mission-control`
- Agent provider plugin system and Mission Control proxy API shipped
- OpenClaw provider adapter shipped with encrypted token storage
- Operator audit logging persisted for inject/pause/resume/kill actions
### 📋 v0.0.24 — Placeholder
- Scope TBD (to be defined after v0.0.23 production deployment)
- Initial release notes and roadmap breakdown pending
--- ---
## Milestone Overview ## Milestone Overview

View File

@@ -120,7 +120,7 @@ Target version: `v0.0.23`
### Phase 0 — Backend Core (Foundation) ### Phase 0 — Backend Core (Foundation)
| id | status | milestone | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes | | 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-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-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-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 | — | |
@@ -183,3 +183,28 @@ Target version: `v0.0.23`
| **Total** | **29** | **~478K** | | **Total** | **29** | **~478K** |
Recommended dispatch: Codex for Phase 2 UI + routine API tasks; Sonnet for complex streaming logic (P0-003, P1-005, P3-002). Recommended dispatch: Codex for Phase 2 UI + routine API tasks; Sonnet for complex streaming logic (P0-003, P1-005, P3-002).
---
## MS24 — Queue Integration in Mission Control
PRD: Issue #749
Milestone: `0.0.24`
Target version: `v0.0.24`
> Single-writer: orchestrator (Jarvis/OpenClaw) only. Workers read but never modify.
| id | status | milestone | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
| ------------ | ------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----- | ------------------- | ------------ | ------------ | ----- | ---------- | ------------ | -------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MS24-API-001 | done | p0-api | QueueNotificationsModule: GET /notifications, GET /notifications/stream (SSE), POST /notifications/:id/ack, GET /tasks. Auth: ApiKeyGuard. Unit tests included. | #749 | api | feat/ms24-queue-api | — | MS24-WEB-001 | — | — | — | 25K | — | Guard: ApiKeyGuard from src/common/guards/api-key.guard.ts (COORDINATOR_API_KEY). SSE via raw express Response + chokidar. Fail-soft if inbox dir missing. |
| MS24-WEB-001 | done | p0-ui | QueueNotificationFeed component + MissionControlLayout wiring (right sidebar panel, badge count) | #749 | web | feat/ms24-queue-ui | MS24-API-001 | MS24-VER-001 | — | — | — | 20K | — | SSE to /api/queue/notifications/stream. ACK button. Collapsible panel in MissionControlLayout.tsx. |
| MS24-VER-001 | done | p0-verify | CI green, no regressions, deploy to prod, tag v0.0.24 | #749 | stack | — | MS24-WEB-001 | — | — | — | — | 5K | — | |
### MS24 Budget Summary
| Phase | Tasks | Estimate |
| --------- | ----- | -------- |
| API | 1 | ~25K |
| UI | 1 | ~20K |
| Verify | 1 | ~5K |
| **Total** | **3** | **~50K** |

View File

@@ -1,6 +1,6 @@
{ {
"name": "mosaic-stack", "name": "mosaic-stack",
"version": "0.0.20", "version": "0.0.23",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "pnpm@10.19.0", "packageManager": "pnpm@10.19.0",

8
pnpm-lock.yaml generated
View File

@@ -162,6 +162,9 @@ importers:
bullmq: bullmq:
specifier: ^5.67.2 specifier: ^5.67.2
version: 5.67.2 version: 5.67.2
chokidar:
specifier: ^4.0.3
version: 4.0.3
class-transformer: class-transformer:
specifier: ^0.5.1 specifier: ^0.5.1
version: 0.5.1 version: 0.5.1
@@ -319,6 +322,9 @@ importers:
'@mosaic/shared': '@mosaic/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
'@nestjs/axios':
specifier: ^4.0.1
version: 4.0.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.5)(rxjs@7.8.2)
'@nestjs/bullmq': '@nestjs/bullmq':
specifier: ^11.0.4 specifier: ^11.0.4
version: 11.0.4(@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)(bullmq@5.67.2) version: 11.0.4(@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)(bullmq@5.67.2)
@@ -1622,7 +1628,6 @@ packages:
'@mosaicstack/telemetry-client@0.1.1': '@mosaicstack/telemetry-client@0.1.1':
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz} resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
engines: {node: '>=18'}
'@mrleebo/prisma-ast@0.13.1': '@mrleebo/prisma-ast@0.13.1':
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
@@ -5173,6 +5178,7 @@ packages:
glob@10.5.0: glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true hasBin: true
glob@13.0.0: glob@13.0.0: