Compare commits
1 Commits
v0.0.23
...
test/ms23-
| Author | SHA1 | Date | |
|---|---|---|---|
| 762277585d |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/orchestrator",
|
"name": "@mosaic/orchestrator",
|
||||||
"version": "0.0.23",
|
"version": "0.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
@@ -22,7 +22,6 @@
|
|||||||
"@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",
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
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, EncryptionService, AgentProvidersService],
|
providers: [OrchestratorApiKeyGuard, AgentProvidersService],
|
||||||
})
|
})
|
||||||
export class AgentProvidersModule {}
|
export class AgentProvidersModule {}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -15,9 +14,6 @@ describe("AgentProvidersService", () => {
|
|||||||
delete: ReturnType<typeof vi.fn>;
|
delete: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
let encryptionService: {
|
|
||||||
encryptIfNeeded: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma = {
|
prisma = {
|
||||||
@@ -30,14 +26,7 @@ describe("AgentProvidersService", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
encryptionService = {
|
service = new AgentProvidersService(prisma as unknown as PrismaService);
|
||||||
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 () => {
|
||||||
@@ -122,42 +111,6 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,47 +156,6 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
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(
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
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({
|
||||||
@@ -34,23 +27,20 @@ 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(credentials),
|
credentials: this.toJsonValue(dto.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> {
|
||||||
const existingConfig = await this.getById(id);
|
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 } : {}),
|
||||||
@@ -58,9 +48,7 @@ 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
|
...(dto.credentials !== undefined ? { credentials: this.toJsonValue(dto.credentials) } : {}),
|
||||||
? { credentials: this.toJsonValue(this.sanitizeCredentials(provider, dto.credentials)) }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.prisma.agentProviderConfig.update({
|
return this.prisma.agentProviderConfig.update({
|
||||||
@@ -77,25 +65,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({ default: { limit: 1000, ttl: 60000 } })
|
@Throttle({ status: { limit: 200, ttl: 60000 } })
|
||||||
getRecentEvents(@Query("limit") limit?: string): {
|
getRecentEvents(@Query("limit") limit?: string): {
|
||||||
events: ReturnType<AgentEventsService["getRecentEvents"]>;
|
events: ReturnType<AgentEventsService["getRecentEvents"]>;
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,613 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ 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";
|
||||||
@@ -55,7 +54,6 @@ import { orchestratorConfig } from "./config/orchestrator.config";
|
|||||||
HealthModule,
|
HealthModule,
|
||||||
AgentsModule,
|
AgentsModule,
|
||||||
AgentProvidersModule,
|
AgentProvidersModule,
|
||||||
ProvidersModule,
|
|
||||||
MissionControlModule,
|
MissionControlModule,
|
||||||
QueueApiModule,
|
QueueApiModule,
|
||||||
CoordinatorModule,
|
CoordinatorModule,
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ import { AuthGuard } from "./guards/auth.guard";
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [OrchestratorApiKeyGuard, AuthGuard],
|
providers: [OrchestratorApiKeyGuard, AuthGuard],
|
||||||
exports: [OrchestratorApiKeyGuard, AuthGuard],
|
exports: [AuthGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,7 +4,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["tests/integration/**/*.e2e-spec.ts", "tests/integration/**/*.spec.ts"],
|
include: ["**/*.e2e-spec.ts"],
|
||||||
testTimeout: 30000,
|
testTimeout: 30000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/web",
|
"name": "@mosaic/web",
|
||||||
"version": "0.0.23",
|
"version": "0.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|||||||
@@ -1,528 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -227,33 +227,6 @@ 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.",
|
||||||
|
|||||||
@@ -44,25 +44,6 @@ 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> {
|
||||||
@@ -157,15 +138,7 @@ async function fetchAuditLog(
|
|||||||
params.set("sessionId", normalizedSessionId);
|
params.set("sessionId", normalizedSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return apiGet<AuditLogResponse>(`/api/mission-control/audit-log?${params.toString()}`);
|
||||||
return await apiGet<AuditLogResponse>(`/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 {
|
||||||
@@ -207,10 +180,11 @@ 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}>
|
||||||
@@ -263,13 +237,10 @@ export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): Rea
|
|||||||
Loading audit log...
|
Loading audit log...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : notice ? (
|
) : auditLogQuery.error ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td colSpan={5} className="px-3 py-6 text-center text-sm text-red-500">
|
||||||
colSpan={5}
|
{errorMessage}
|
||||||
className="px-3 py-6 text-center text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{notice}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { GlobalAgentRoster } from "../GlobalAgentRoster";
|
||||||
|
|
||||||
|
const { mockApiGet, mockApiPost } = vi.hoisted(() => ({
|
||||||
|
mockApiGet: vi.fn(),
|
||||||
|
mockApiPost: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/client", () => ({
|
||||||
|
apiGet: mockApiGet,
|
||||||
|
apiPost: mockApiPost,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderWithQueryClient(ui: ReactElement): void {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false, gcTime: 0 },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GlobalAgentRoster (__tests__)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
mockApiGet.mockReset();
|
||||||
|
mockApiPost.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty state when no sessions", async () => {
|
||||||
|
mockApiGet.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
renderWithQueryClient(<GlobalAgentRoster />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("No active agents")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders session rows grouped by provider", async () => {
|
||||||
|
mockApiGet.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: "sess-int-123456",
|
||||||
|
providerId: "internal",
|
||||||
|
providerType: "internal",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-03-07T19:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-07T19:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sess-rem-654321",
|
||||||
|
providerId: "remote-a",
|
||||||
|
providerType: "remote",
|
||||||
|
status: "paused",
|
||||||
|
createdAt: "2026-03-07T19:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-07T19:00:00.000Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderWithQueryClient(<GlobalAgentRoster />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("internal")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("remote-a (remote)")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("sess-int")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("sess-rem")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kill button per row calls the API", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
mockApiGet.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: "killme123456",
|
||||||
|
providerId: "internal",
|
||||||
|
providerType: "internal",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-03-07T19:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-07T19:00:00.000Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockApiPost.mockResolvedValue({ message: "ok" });
|
||||||
|
|
||||||
|
renderWithQueryClient(<GlobalAgentRoster />);
|
||||||
|
|
||||||
|
const killButton = await screen.findByRole("button", { name: "Kill session killme12" });
|
||||||
|
await user.click(killButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/killme123456/kill", {
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onSelectSession callback fires on row click", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelectSession = vi.fn();
|
||||||
|
|
||||||
|
mockApiGet.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: "selectme123456",
|
||||||
|
providerId: "internal",
|
||||||
|
providerType: "internal",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-03-07T19:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-07T19:00:00.000Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderWithQueryClient(<GlobalAgentRoster onSelectSession={onSelectSession} />);
|
||||||
|
|
||||||
|
const sessionLabel = await screen.findByText("selectme");
|
||||||
|
const row = sessionLabel.closest('[role="button"]');
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new Error("Expected session row for selectme123456");
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.click(row);
|
||||||
|
|
||||||
|
expect(onSelectSession).toHaveBeenCalledWith("selectme123456");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AgentSession } from "@mosaic/shared";
|
||||||
|
import { KillAllDialog } from "../KillAllDialog";
|
||||||
|
import * as apiClient from "@/lib/api/client";
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/client", () => ({
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockApiPost = vi.mocked(apiClient.apiPost);
|
||||||
|
const baseDate = new Date("2026-03-07T14:00:00.000Z");
|
||||||
|
|
||||||
|
const sessions: AgentSession[] = [
|
||||||
|
{
|
||||||
|
id: "session-internal-1",
|
||||||
|
providerId: "provider-internal-1",
|
||||||
|
providerType: "internal",
|
||||||
|
status: "active",
|
||||||
|
createdAt: baseDate,
|
||||||
|
updatedAt: baseDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session-internal-2",
|
||||||
|
providerId: "provider-internal-2",
|
||||||
|
providerType: "internal",
|
||||||
|
status: "paused",
|
||||||
|
createdAt: baseDate,
|
||||||
|
updatedAt: baseDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session-external-1",
|
||||||
|
providerId: "provider-openclaw-1",
|
||||||
|
providerType: "openclaw",
|
||||||
|
status: "active",
|
||||||
|
createdAt: baseDate,
|
||||||
|
updatedAt: baseDate,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("KillAllDialog (__tests__)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
mockApiPost.mockResolvedValue({ message: "killed" } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Confirm button disabled until "KILL ALL" typed exactly', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<KillAllDialog sessions={sessions} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Kill All" }));
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Type KILL ALL to confirm");
|
||||||
|
const confirmButton = screen.getByRole("button", { name: "Kill All Agents" });
|
||||||
|
|
||||||
|
expect(confirmButton).toBeDisabled();
|
||||||
|
|
||||||
|
await user.type(input, "kill all");
|
||||||
|
expect(confirmButton).toBeDisabled();
|
||||||
|
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "KILL ALL");
|
||||||
|
expect(confirmButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires kill API for each session on confirm", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<KillAllDialog sessions={sessions} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Kill All" }));
|
||||||
|
await user.click(screen.getByLabelText("All providers (3)"));
|
||||||
|
await user.type(screen.getByLabelText("Type KILL ALL to confirm"), "KILL ALL");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Kill All Agents" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiPost).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockApiPost).toHaveBeenCalledWith(
|
||||||
|
"/api/mission-control/sessions/session-internal-1/kill",
|
||||||
|
{ force: true }
|
||||||
|
);
|
||||||
|
expect(mockApiPost).toHaveBeenCalledWith(
|
||||||
|
"/api/mission-control/sessions/session-internal-2/kill",
|
||||||
|
{ force: true }
|
||||||
|
);
|
||||||
|
expect(mockApiPost).toHaveBeenCalledWith(
|
||||||
|
"/api/mission-control/sessions/session-external-1/kill",
|
||||||
|
{ force: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { OrchestratorPanel } from "../OrchestratorPanel";
|
||||||
|
import * as missionControlHooks from "@/hooks/useMissionControl";
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useMissionControl", () => ({
|
||||||
|
useSessionStream: vi.fn(),
|
||||||
|
useSessions: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/mission-control/PanelControls", () => ({
|
||||||
|
PanelControls: (): React.JSX.Element => <div data-testid="panel-controls" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/mission-control/BargeInInput", () => ({
|
||||||
|
BargeInInput: ({ sessionId }: { sessionId: string }): React.JSX.Element => (
|
||||||
|
<div data-testid="barge-in-input">barge-in:{sessionId}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("date-fns", () => ({
|
||||||
|
formatDistanceToNow: (): string => "moments ago",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseSessionStream = vi.mocked(missionControlHooks.useSessionStream);
|
||||||
|
const mockUseSessions = vi.mocked(missionControlHooks.useSessions);
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window.HTMLElement.prototype, "scrollIntoView", {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OrchestratorPanel (__tests__)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockUseSessionStream.mockReturnValue({
|
||||||
|
messages: [],
|
||||||
|
status: "connected",
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseSessions.mockReturnValue({
|
||||||
|
sessions: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty state when no sessionId", () => {
|
||||||
|
render(<OrchestratorPanel />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Select an agent to view its stream")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders connection indicator", () => {
|
||||||
|
const { container } = render(<OrchestratorPanel sessionId="session-1" />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Connected")).toBeInTheDocument();
|
||||||
|
expect(container.querySelector(".bg-emerald-500")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders message list when messages are present", () => {
|
||||||
|
mockUseSessionStream.mockReturnValue({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "msg-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Mission update one",
|
||||||
|
timestamp: "2026-03-07T21:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "msg-2",
|
||||||
|
sessionId: "session-1",
|
||||||
|
role: "tool",
|
||||||
|
content: "Mission update two",
|
||||||
|
timestamp: "2026-03-07T21:00:01.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: "connected",
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OrchestratorPanel sessionId="session-1" />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Mission update one")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Mission update two")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Waiting for messages...")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PanelControls } from "../PanelControls";
|
||||||
|
import * as apiClient from "@/lib/api/client";
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/client", () => ({
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockApiPost = vi.mocked(apiClient.apiPost);
|
||||||
|
|
||||||
|
function renderPanelControls(status: string): void {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<PanelControls sessionId="session-1" status={status} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PanelControls (__tests__)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
mockApiPost.mockResolvedValue({ message: "ok" } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Pause button disabled when status=paused", () => {
|
||||||
|
renderPanelControls("paused");
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Pause session" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Resume button disabled when status=active", () => {
|
||||||
|
renderPanelControls("active");
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Resume session" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Kill buttons disabled when status=killed", () => {
|
||||||
|
renderPanelControls("killed");
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Gracefully kill session" })).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "Force kill session" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking pause calls the API", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
renderPanelControls("active");
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Pause session" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-1/pause");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { MissionControlLayout } from "../MissionControlLayout";
|
||||||
|
|
||||||
|
vi.mock("@/components/mission-control/AuditLogDrawer", () => ({
|
||||||
|
AuditLogDrawer: ({ trigger }: { trigger: ReactNode }): React.JSX.Element => (
|
||||||
|
<div data-testid="audit-log-drawer">{trigger}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/mission-control/GlobalAgentRoster", () => ({
|
||||||
|
GlobalAgentRoster: (): React.JSX.Element => <div data-testid="global-agent-roster" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/mission-control/MissionControlPanel", () => ({
|
||||||
|
MissionControlPanel: (): React.JSX.Element => <div data-testid="mission-control-panel" />,
|
||||||
|
MIN_PANEL_COUNT: 1,
|
||||||
|
MAX_PANEL_COUNT: 6,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Mission Control Phase 2 Gate", () => {
|
||||||
|
it("Phase 2 gate: MissionControlLayout renders with all components present", () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation((..._args) => undefined);
|
||||||
|
|
||||||
|
render(<MissionControlLayout />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("global-agent-roster")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("mission-control-panel")).toBeInTheDocument();
|
||||||
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,6 @@ 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" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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}`);
|
|
||||||
}
|
|
||||||
@@ -18,5 +18,4 @@ 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";
|
||||||
|
|||||||
@@ -62,39 +62,6 @@ 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
|
||||||
@@ -112,6 +79,5 @@ 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.23...HEAD
|
[Unreleased]: https://git.mosaicstack.dev/mosaic/stack/compare/v0.0.1...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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Mosaic Stack Roadmap
|
# Mosaic Stack Roadmap
|
||||||
|
|
||||||
**Last Updated:** 2026-03-07
|
**Last Updated:** 2026-01-29
|
||||||
**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,20 +12,6 @@
|
|||||||
| `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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mosaic-stack",
|
"name": "mosaic-stack",
|
||||||
"version": "0.0.23",
|
"version": "0.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.19.0",
|
"packageManager": "pnpm@10.19.0",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -319,9 +319,6 @@ 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user