Compare commits
15 Commits
chore/ms23
...
fix/missio
| Author | SHA1 | Date | |
|---|---|---|---|
| 811a32eb61 | |||
| 23036cb1dd | |||
| 523662656e | |||
| 27120ac3f2 | |||
| ad9921107c | |||
| 3c288f9849 | |||
| 51d6302401 | |||
| cf490510bf | |||
| 3d91334df7 | |||
| e80b624ca6 | |||
| 65536fcb75 | |||
| 53915dc621 | |||
| 398ee06920 | |||
| 2182717f59 | |||
| fe55363f38 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/orchestrator",
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.23",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
|
||||
@@ -146,7 +146,7 @@ export class AgentsController {
|
||||
* Return recent orchestrator events for non-streaming consumers.
|
||||
*/
|
||||
@Get("events/recent")
|
||||
@Throttle({ status: { limit: 200, ttl: 60000 } })
|
||||
@Throttle({ default: { limit: 1000, ttl: 60000 } })
|
||||
getRecentEvents(@Query("limit") limit?: string): {
|
||||
events: ReturnType<AgentEventsService["getRecentEvents"]>;
|
||||
} {
|
||||
|
||||
@@ -4,6 +4,6 @@ import { AuthGuard } from "./guards/auth.guard";
|
||||
|
||||
@Module({
|
||||
providers: [OrchestratorApiKeyGuard, AuthGuard],
|
||||
exports: [AuthGuard],
|
||||
exports: [OrchestratorApiKeyGuard, AuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/web",
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.23",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
|
||||
93
apps/web/src/app/api/orchestrator/[...path]/route.ts
Normal file
93
apps/web/src/app/api/orchestrator/[...path]/route.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001";
|
||||
|
||||
function getOrchestratorUrl(): string {
|
||||
return (
|
||||
process.env.ORCHESTRATOR_URL ??
|
||||
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ??
|
||||
process.env.NEXT_PUBLIC_API_URL ??
|
||||
DEFAULT_ORCHESTRATOR_URL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic catch-all proxy for orchestrator API routes.
|
||||
*
|
||||
* Forwards any request to /api/orchestrator/<path> → ORCHESTRATOR_URL/<path>
|
||||
* with the ORCHESTRATOR_API_KEY injected server-side so it never reaches the browser.
|
||||
*
|
||||
* Supports GET, POST, PATCH, DELETE, PUT.
|
||||
*
|
||||
* Example:
|
||||
* GET /api/orchestrator/mission-control/sessions
|
||||
* → GET ORCHESTRATOR_URL/api/mission-control/sessions
|
||||
* POST /api/orchestrator/mission-control/sessions/abc/kill
|
||||
* → POST ORCHESTRATOR_URL/api/mission-control/sessions/abc/kill
|
||||
*/
|
||||
async function proxyToOrchestrator(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> }
|
||||
): Promise<NextResponse> {
|
||||
const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY;
|
||||
if (!orchestratorApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "ORCHESTRATOR_API_KEY is not configured on the web server." },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const { path } = await context.params;
|
||||
const upstreamPath = `/${path.join("/")}`;
|
||||
const search = request.nextUrl.search;
|
||||
const upstreamUrl = `${getOrchestratorUrl()}${upstreamPath}${search}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 30_000);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"X-API-Key": orchestratorApiKey,
|
||||
};
|
||||
|
||||
const contentType = request.headers.get("Content-Type");
|
||||
if (contentType) {
|
||||
headers["Content-Type"] = contentType;
|
||||
}
|
||||
|
||||
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
||||
const body = hasBody ? await request.text() : null;
|
||||
|
||||
const upstream = await fetch(upstreamUrl, {
|
||||
method: request.method,
|
||||
headers,
|
||||
...(body !== null ? { body } : {}),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseText = await upstream.text();
|
||||
return new NextResponse(responseText, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"Content-Type": upstream.headers.get("Content-Type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.name === "AbortError"
|
||||
? "Orchestrator request timed out."
|
||||
: "Unable to reach orchestrator.";
|
||||
return NextResponse.json({ error: message }, { status: 502 });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = proxyToOrchestrator;
|
||||
export const POST = proxyToOrchestrator;
|
||||
export const PATCH = proxyToOrchestrator;
|
||||
export const PUT = proxyToOrchestrator;
|
||||
export const DELETE = proxyToOrchestrator;
|
||||
@@ -4,6 +4,7 @@ import { Outfit, Fira_Code } from "next/font/google";
|
||||
import { AuthProvider } from "@/lib/auth/auth-context";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
import { ThemeProvider } from "@/providers/ThemeProvider";
|
||||
import { ReactQueryProvider } from "@/providers/ReactQueryProvider";
|
||||
import "./globals.css";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -56,9 +57,11 @@ export default function RootLayout({ children }: { children: ReactNode }): React
|
||||
</head>
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<ReactQueryProvider>
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
</ReactQueryProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -44,6 +44,25 @@ interface AuditLogResponse {
|
||||
total: number;
|
||||
page: 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> {
|
||||
@@ -138,7 +157,17 @@ async function fetchAuditLog(
|
||||
params.set("sessionId", normalizedSessionId);
|
||||
}
|
||||
|
||||
return apiGet<AuditLogResponse>(`/api/mission-control/audit-log?${params.toString()}`);
|
||||
try {
|
||||
return await apiGet<AuditLogResponse>(
|
||||
`/api/orchestrator/api/mission-control/audit-log?${params.toString()}`
|
||||
);
|
||||
} catch (error) {
|
||||
if (isRateLimitError(error)) {
|
||||
return createEmptyAuditLogResponse(page, "Rate limited - retrying...");
|
||||
}
|
||||
|
||||
return createEmptyAuditLogResponse(page);
|
||||
}
|
||||
}
|
||||
|
||||
export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): React.JSX.Element {
|
||||
@@ -180,11 +209,10 @@ export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): Rea
|
||||
const totalItems = auditLogQuery.data?.total ?? 0;
|
||||
const totalPages = auditLogQuery.data?.pages ?? 0;
|
||||
const items = auditLogQuery.data?.items ?? [];
|
||||
const notice = auditLogQuery.data?.notice;
|
||||
|
||||
const canGoPrevious = page > 1;
|
||||
const canGoNext = totalPages > 0 && page < totalPages;
|
||||
const errorMessage =
|
||||
auditLogQuery.error instanceof Error ? auditLogQuery.error.message : "Failed to load audit log";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
@@ -237,10 +265,13 @@ export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): Rea
|
||||
Loading audit log...
|
||||
</td>
|
||||
</tr>
|
||||
) : auditLogQuery.error ? (
|
||||
) : notice ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-sm text-red-500">
|
||||
{errorMessage}
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-3 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{notice}
|
||||
</td>
|
||||
</tr>
|
||||
) : items.length === 0 ? (
|
||||
|
||||
@@ -59,9 +59,12 @@ describe("BargeInInput", (): void => {
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-1/inject", {
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/api/orchestrator/api/mission-control/sessions/session-1/inject",
|
||||
{
|
||||
content: "execute plan",
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSent).toHaveBeenCalledTimes(1);
|
||||
@@ -83,12 +86,18 @@ describe("BargeInInput", (): void => {
|
||||
|
||||
const calls = mockApiPost.mock.calls as [string, unknown?][];
|
||||
|
||||
expect(calls[0]).toEqual(["/api/mission-control/sessions/session-2/pause", undefined]);
|
||||
expect(calls[0]).toEqual([
|
||||
"/api/orchestrator/api/mission-control/sessions/session-2/pause",
|
||||
undefined,
|
||||
]);
|
||||
expect(calls[1]).toEqual([
|
||||
"/api/mission-control/sessions/session-2/inject",
|
||||
"/api/orchestrator/api/mission-control/sessions/session-2/inject",
|
||||
{ content: "hello world" },
|
||||
]);
|
||||
expect(calls[2]).toEqual(["/api/mission-control/sessions/session-2/resume", undefined]);
|
||||
expect(calls[2]).toEqual([
|
||||
"/api/orchestrator/api/mission-control/sessions/session-2/resume",
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
|
||||
it("submits with Enter and does not submit on Shift+Enter", async (): Promise<void> => {
|
||||
@@ -105,9 +114,12 @@ describe("BargeInInput", (): void => {
|
||||
fireEvent.keyDown(textarea, { key: "Enter", code: "Enter", shiftKey: false });
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-3/inject", {
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/api/orchestrator/api/mission-control/sessions/session-3/inject",
|
||||
{
|
||||
content: "first",
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export function BargeInInput({ sessionId, onSent }: BargeInInputProps): React.JS
|
||||
}
|
||||
|
||||
const encodedSessionId = encodeURIComponent(sessionId);
|
||||
const baseEndpoint = `/api/mission-control/sessions/${encodedSessionId}`;
|
||||
const baseEndpoint = `/api/orchestrator/api/mission-control/sessions/${encodedSessionId}`;
|
||||
let didPause = false;
|
||||
let didInject = false;
|
||||
|
||||
|
||||
@@ -177,9 +177,12 @@ describe("GlobalAgentRoster", (): void => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Kill session killme12" }));
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/killme123456/kill", {
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/api/orchestrator/api/mission-control/sessions/killme123456/kill",
|
||||
{
|
||||
force: false,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ function groupByProvider(sessions: MissionControlSession[]): ProviderSessionGrou
|
||||
|
||||
async function fetchSessions(): Promise<MissionControlSession[]> {
|
||||
const payload = await apiGet<MissionControlSession[] | SessionsPayload>(
|
||||
"/api/mission-control/sessions"
|
||||
"/api/orchestrator/api/mission-control/sessions"
|
||||
);
|
||||
return Array.isArray(payload) ? payload : payload.sessions;
|
||||
}
|
||||
@@ -118,9 +118,12 @@ export function GlobalAgentRoster({
|
||||
|
||||
const killMutation = useMutation({
|
||||
mutationFn: async (sessionId: string): Promise<string> => {
|
||||
await apiPost<{ message: string }>(`/api/mission-control/sessions/${sessionId}/kill`, {
|
||||
await apiPost<{ message: string }>(
|
||||
`/api/orchestrator/api/mission-control/sessions/${sessionId}/kill`,
|
||||
{
|
||||
force: false,
|
||||
});
|
||||
}
|
||||
);
|
||||
return sessionId;
|
||||
},
|
||||
onSuccess: (): void => {
|
||||
|
||||
@@ -112,14 +112,20 @@ describe("KillAllDialog", (): void => {
|
||||
await user.click(screen.getByRole("button", { name: "Kill All Agents" }));
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-1/kill", {
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/api/orchestrator/api/mission-control/sessions/internal-1/kill",
|
||||
{
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockApiPost).not.toHaveBeenCalledWith("/api/mission-control/sessions/external-1/kill", {
|
||||
expect(mockApiPost).not.toHaveBeenCalledWith(
|
||||
"/api/orchestrator/api/mission-control/sessions/external-1/kill",
|
||||
{
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -141,12 +147,18 @@ describe("KillAllDialog", (): void => {
|
||||
await user.click(screen.getByRole("button", { name: "Kill All Agents" }));
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-2/kill", {
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/api/orchestrator/api/mission-control/sessions/internal-2/kill",
|
||||
{
|
||||
force: true,
|
||||
});
|
||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/external-2/kill", {
|
||||
}
|
||||
);
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/api/orchestrator/api/mission-control/sessions/external-2/kill",
|
||||
{
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -96,9 +96,12 @@ export function KillAllDialog({ sessions, onComplete }: KillAllDialogProps): Rea
|
||||
|
||||
const killRequests = targetSessions.map(async (session) => {
|
||||
try {
|
||||
await apiPost<{ message: string }>(`/api/mission-control/sessions/${session.id}/kill`, {
|
||||
await apiPost<{ message: string }>(
|
||||
`/api/orchestrator/api/mission-control/sessions/${session.id}/kill`,
|
||||
{
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -89,7 +89,7 @@ describe("PanelControls", (): void => {
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/api/mission-control/sessions/session%20with%20space/pause",
|
||||
"/api/orchestrator/api/mission-control/sessions/session%20with%20space/pause",
|
||||
undefined
|
||||
);
|
||||
});
|
||||
@@ -114,9 +114,12 @@ describe("PanelControls", (): void => {
|
||||
await user.click(screen.getByRole("button", { name: "Confirm" }));
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-4/kill", {
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/api/orchestrator/api/mission-control/sessions/session-4/kill",
|
||||
{
|
||||
force: false,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
expect(onStatusChange).toHaveBeenCalledWith("killed");
|
||||
@@ -137,9 +140,12 @@ describe("PanelControls", (): void => {
|
||||
await user.click(screen.getByRole("button", { name: "Confirm" }));
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-5/kill", {
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/api/orchestrator/api/mission-control/sessions/session-5/kill",
|
||||
{
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
expect(onStatusChange).toHaveBeenCalledWith("killed");
|
||||
|
||||
@@ -50,23 +50,23 @@ export function PanelControls({
|
||||
switch (action) {
|
||||
case "pause":
|
||||
await apiPost<{ message: string }>(
|
||||
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause`
|
||||
`/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause`
|
||||
);
|
||||
return { nextStatus: "paused" };
|
||||
case "resume":
|
||||
await apiPost<{ message: string }>(
|
||||
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume`
|
||||
`/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume`
|
||||
);
|
||||
return { nextStatus: "active" };
|
||||
case "graceful-kill":
|
||||
await apiPost<{ message: string }>(
|
||||
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
|
||||
`/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
|
||||
{ force: false }
|
||||
);
|
||||
return { nextStatus: "killed" };
|
||||
case "force-kill":
|
||||
await apiPost<{ message: string }>(
|
||||
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
|
||||
`/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
|
||||
{ force: true }
|
||||
);
|
||||
return { nextStatus: "killed" };
|
||||
|
||||
28
apps/web/src/providers/ReactQueryProvider.tsx
Normal file
28
apps/web/src/providers/ReactQueryProvider.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
interface ReactQueryProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ReactQueryProvider({ children }: ReactQueryProviderProps): React.JSX.Element {
|
||||
// Create a stable QueryClient per component mount (one per app session)
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Don't refetch on window focus in a dashboard context
|
||||
refetchOnWindowFocus: false,
|
||||
// Stale time of 30s — short enough for live data, avoids hammering
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
@@ -316,6 +316,8 @@ services:
|
||||
SANDBOX_ENABLED: "true"
|
||||
# API key for authenticating requests from the web proxy
|
||||
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
|
||||
# Prisma database connection (uses the shared openbrain postgres)
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@openbrain_brain-db:5432/${POSTGRES_DB:-mosaic}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- orchestrator_workspace:/workspace
|
||||
@@ -331,6 +333,7 @@ services:
|
||||
start_period: 40s
|
||||
networks:
|
||||
- internal
|
||||
- openbrain-brain-internal
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
@@ -403,6 +406,7 @@ services:
|
||||
networks:
|
||||
- internal
|
||||
- traefik-public
|
||||
- openbrain-brain-internal
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -62,6 +62,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Updated Makefile with Traefik deployment shortcuts
|
||||
- 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
|
||||
|
||||
### Added
|
||||
@@ -79,5 +112,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Documentation structure (Bookstack-compatible hierarchy)
|
||||
- Development workflow and coding standards
|
||||
|
||||
[Unreleased]: https://git.mosaicstack.dev/mosaic/stack/compare/v0.0.1...HEAD
|
||||
[Unreleased]: https://git.mosaicstack.dev/mosaic/stack/compare/v0.0.23...HEAD
|
||||
[0.0.23]: https://git.mosaicstack.dev/mosaic/stack/releases/tag/v0.0.23
|
||||
[0.0.1]: https://git.mosaicstack.dev/mosaic/stack/releases/tag/v0.0.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Mosaic Stack Roadmap
|
||||
|
||||
**Last Updated:** 2026-01-29
|
||||
**Last Updated:** 2026-03-07
|
||||
**Authoritative Source:** [Issues & Milestones](https://git.mosaicstack.dev/mosaic/stack/issues)
|
||||
|
||||
## Versioning Policy
|
||||
@@ -12,6 +12,20 @@
|
||||
| `0.x.y` | Pre-stable iteration, API may change with notice |
|
||||
| `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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mosaic-stack",
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.19.0",
|
||||
|
||||
Reference in New Issue
Block a user