Compare commits
1 Commits
docs/ms22-
...
chore/ms21
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a742aa634 |
@@ -1,19 +1,17 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
import { WorkspaceMemberRole } from "@mosaic/shared";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
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 AdminUser,
|
||||
deactivateUser,
|
||||
fetchAdminUsers,
|
||||
inviteUser,
|
||||
updateUser,
|
||||
type AdminUsersResponse,
|
||||
} from "@/lib/api/admin";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
|
||||
import UsersSettingsPage from "./page";
|
||||
|
||||
@@ -41,80 +39,48 @@ vi.mock("@/lib/api/workspaces", () => ({
|
||||
updateWorkspaceMemberRole: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
const fetchAdminUsersMock = vi.mocked(fetchAdminUsers);
|
||||
const inviteUserMock = vi.mocked(inviteUser);
|
||||
const updateUserMock = vi.mocked(updateUser);
|
||||
const deactivateUserMock = vi.mocked(deactivateUser);
|
||||
const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
|
||||
const updateWorkspaceMemberRoleMock = vi.mocked(updateWorkspaceMemberRole);
|
||||
const useAuthMock = vi.mocked(useAuth);
|
||||
|
||||
function makeAdminUser(overrides?: Partial<AdminUser>): AdminUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
deactivatedAt: null,
|
||||
isLocalAuth: false,
|
||||
invitedAt: null,
|
||||
invitedBy: null,
|
||||
workspaceMemberships: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
workspaceName: "Personal Workspace",
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
joinedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAdminUsersResponse(options?: {
|
||||
data?: AdminUser[];
|
||||
page?: number;
|
||||
totalPages?: number;
|
||||
total?: number;
|
||||
limit?: number;
|
||||
}): AdminUsersResponse {
|
||||
const data = options?.data ?? [makeAdminUser()];
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total: options?.total ?? data.length,
|
||||
page: options?.page ?? 1,
|
||||
limit: options?.limit ?? 50,
|
||||
totalPages: options?.totalPages ?? 1,
|
||||
const adminUsersResponse: AdminUsersResponse = {
|
||||
data: [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
deactivatedAt: null,
|
||||
isLocalAuth: false,
|
||||
invitedAt: null,
|
||||
invitedBy: null,
|
||||
workspaceMemberships: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
workspaceName: "Personal Workspace",
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
joinedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeAuthState(userId: string): ReturnType<typeof useAuth> {
|
||||
return {
|
||||
user: { id: userId, email: `${userId}@example.com`, name: "Current User" },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
authError: null,
|
||||
sessionExpiring: false,
|
||||
sessionMinutesRemaining: 0,
|
||||
signOut: vi.fn(() => Promise.resolve()),
|
||||
refreshSession: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
}
|
||||
],
|
||||
meta: {
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
describe("UsersSettingsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const adminUsersResponse = makeAdminUsersResponse();
|
||||
|
||||
fetchAdminUsersMock.mockResolvedValue(adminUsersResponse);
|
||||
fetchUserWorkspacesMock.mockResolvedValue([
|
||||
{
|
||||
@@ -131,7 +97,10 @@ describe("UsersSettingsPage", () => {
|
||||
email: "new@example.com",
|
||||
invitedAt: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
const firstUser = adminUsersResponse.data[0] ?? makeAdminUser();
|
||||
const firstUser = adminUsersResponse.data[0];
|
||||
if (!firstUser) {
|
||||
throw new Error("Expected at least one admin user in test fixtures");
|
||||
}
|
||||
|
||||
updateUserMock.mockResolvedValue(firstUser);
|
||||
deactivateUserMock.mockResolvedValue(firstUser);
|
||||
@@ -147,8 +116,6 @@ describe("UsersSettingsPage", () => {
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
|
||||
useAuthMock.mockReturnValue(makeAuthState("user-current"));
|
||||
});
|
||||
|
||||
it("shows access denied to non-admin users", async () => {
|
||||
@@ -207,146 +174,4 @@ describe("UsersSettingsPage", () => {
|
||||
|
||||
expect(updateWorkspaceMemberRoleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps pagination to the last valid page after deactivation shrinks the dataset", async () => {
|
||||
const user = userEvent.setup();
|
||||
const pageOneUser = makeAdminUser({
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
});
|
||||
const pageTwoUser = makeAdminUser({
|
||||
id: "user-2",
|
||||
name: "Bob",
|
||||
email: "bob@example.com",
|
||||
});
|
||||
|
||||
fetchAdminUsersMock.mockReset();
|
||||
const responses = [
|
||||
{
|
||||
expectedPage: 1,
|
||||
response: makeAdminUsersResponse({
|
||||
data: [pageOneUser],
|
||||
page: 1,
|
||||
totalPages: 2,
|
||||
total: 2,
|
||||
}),
|
||||
},
|
||||
{
|
||||
expectedPage: 2,
|
||||
response: makeAdminUsersResponse({
|
||||
data: [pageTwoUser],
|
||||
page: 2,
|
||||
totalPages: 2,
|
||||
total: 2,
|
||||
}),
|
||||
},
|
||||
{
|
||||
expectedPage: 2,
|
||||
response: makeAdminUsersResponse({
|
||||
data: [],
|
||||
page: 2,
|
||||
totalPages: 1,
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
{
|
||||
expectedPage: 1,
|
||||
response: makeAdminUsersResponse({
|
||||
data: [pageOneUser],
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
fetchAdminUsersMock.mockImplementation((page = 1) => {
|
||||
const next = responses.shift();
|
||||
if (!next) {
|
||||
throw new Error("Unexpected fetchAdminUsers call in pagination-cap test");
|
||||
}
|
||||
|
||||
expect(page).toBe(next.expectedPage);
|
||||
return Promise.resolve(next.response);
|
||||
});
|
||||
|
||||
render(<UsersSettingsPage />);
|
||||
|
||||
expect(await screen.findByText("alice@example.com")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(await screen.findByText("bob@example.com")).toBeInTheDocument();
|
||||
|
||||
const pageTwoRow = screen.getByText("bob@example.com").closest('[role="button"]');
|
||||
if (!(pageTwoRow instanceof HTMLElement)) {
|
||||
throw new Error("Expected Bob's row to exist");
|
||||
}
|
||||
|
||||
await user.click(within(pageTwoRow).getByRole("button", { name: "Deactivate" }));
|
||||
const deactivateButtons = await screen.findAllByRole("button", { name: "Deactivate" });
|
||||
const confirmDeactivateButton = deactivateButtons[deactivateButtons.length - 1];
|
||||
if (!confirmDeactivateButton) {
|
||||
throw new Error("Expected confirmation deactivate button to be rendered");
|
||||
}
|
||||
await user.click(confirmDeactivateButton);
|
||||
|
||||
expect(await screen.findByText("alice@example.com")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No Users Yet")).not.toBeInTheDocument();
|
||||
expect(deactivateUserMock).toHaveBeenCalledWith("user-2");
|
||||
const requestedPages = fetchAdminUsersMock.mock.calls.map(([requestedPage]) => requestedPage);
|
||||
expect(requestedPages.slice(-2)).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it("shows the API error state without rendering the empty-state message", async () => {
|
||||
fetchAdminUsersMock.mockRejectedValueOnce(new Error("Unable to load users"));
|
||||
|
||||
render(<UsersSettingsPage />);
|
||||
|
||||
expect(await screen.findByText("Unable to load users")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No Users Yet")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Invite the first user to get started.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prevents the current user from deactivating their own account", async () => {
|
||||
useAuthMock.mockReturnValue(makeAuthState("user-1"));
|
||||
|
||||
const selfUser = makeAdminUser({
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
});
|
||||
const otherUser = makeAdminUser({
|
||||
id: "user-2",
|
||||
name: "Bob",
|
||||
email: "bob@example.com",
|
||||
});
|
||||
|
||||
fetchAdminUsersMock.mockResolvedValueOnce(
|
||||
makeAdminUsersResponse({
|
||||
data: [selfUser, otherUser],
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
total: 2,
|
||||
})
|
||||
);
|
||||
|
||||
render(<UsersSettingsPage />);
|
||||
|
||||
expect(await screen.findByText("alice@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("bob@example.com")).toBeInTheDocument();
|
||||
|
||||
const selfRow = screen.getByText("alice@example.com").closest('[role="button"]');
|
||||
if (!(selfRow instanceof HTMLElement)) {
|
||||
throw new Error("Expected current-user row to exist");
|
||||
}
|
||||
expect(within(selfRow).queryByRole("button", { name: "Deactivate" })).not.toBeInTheDocument();
|
||||
|
||||
const otherRow = screen.getByText("bob@example.com").closest('[role="button"]');
|
||||
if (!(otherRow instanceof HTMLElement)) {
|
||||
throw new Error("Expected other-user row to exist");
|
||||
}
|
||||
expect(within(otherRow).getByRole("button", { name: "Deactivate" })).toBeInTheDocument();
|
||||
expect(deactivateUserMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
type InviteUserDto,
|
||||
type UpdateUserDto,
|
||||
} from "@/lib/api/admin";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
|
||||
import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied";
|
||||
|
||||
@@ -78,7 +77,6 @@ const INITIAL_DETAIL_FORM = {
|
||||
workspaceId: null as string | null,
|
||||
workspaceName: null as string | null,
|
||||
};
|
||||
const USERS_PAGE_SIZE = 50;
|
||||
|
||||
interface DetailInitialState {
|
||||
name: string;
|
||||
@@ -106,11 +104,8 @@ function getPrimaryMembership(user: AdminUser): AdminWorkspaceMembership | null
|
||||
}
|
||||
|
||||
export default function UsersSettingsPage(): ReactElement {
|
||||
const { user: authUser } = useAuth();
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [meta, setMeta] = useState<AdminUsersResponse["meta"] | null>(null);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -132,35 +127,25 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null);
|
||||
const [isDeactivating, setIsDeactivating] = useState<boolean>(false);
|
||||
|
||||
const loadUsers = useCallback(
|
||||
async (showLoadingState: boolean): Promise<void> => {
|
||||
try {
|
||||
if (showLoadingState) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
setIsRefreshing(true);
|
||||
}
|
||||
|
||||
const response = await fetchAdminUsers(page, USERS_PAGE_SIZE);
|
||||
const lastValidPage = Math.max(1, response.meta.totalPages);
|
||||
|
||||
if (page > lastValidPage) {
|
||||
setPage(lastValidPage);
|
||||
return;
|
||||
}
|
||||
|
||||
setUsers(response.data);
|
||||
setMeta(response.meta);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load admin users");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => {
|
||||
try {
|
||||
if (showLoadingState) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
setIsRefreshing(true);
|
||||
}
|
||||
},
|
||||
[page]
|
||||
);
|
||||
|
||||
const response = await fetchAdminUsers(1, 50);
|
||||
setUsers(response.data);
|
||||
setMeta(response.meta);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load admin users");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserWorkspaces()
|
||||
@@ -185,7 +170,7 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
}
|
||||
|
||||
void loadUsers(true);
|
||||
}, [isAdmin, loadUsers, page]);
|
||||
}, [isAdmin, loadUsers]);
|
||||
|
||||
function resetInviteForm(): void {
|
||||
setInviteForm(INITIAL_INVITE_FORM);
|
||||
@@ -339,12 +324,6 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (authUser?.id === deactivateTarget.id) {
|
||||
setDeactivateTarget(null);
|
||||
setError("You cannot deactivate your own account.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeactivating(true);
|
||||
await deactivateUser(deactivateTarget.id);
|
||||
@@ -502,13 +481,7 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
Loading users...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : error ? (
|
||||
{error ? (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
@@ -516,6 +489,14 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
Loading users...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : users.length === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -533,7 +514,6 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
{users.map((user) => {
|
||||
const primaryMembership = getPrimaryMembership(user);
|
||||
const isActive = user.deactivatedAt === null;
|
||||
const isCurrentUser = authUser?.id === user.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -549,14 +529,7 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="font-semibold truncate">
|
||||
{user.name || "Unnamed User"}
|
||||
{isCurrentUser ? (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
(You)
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="font-semibold truncate">{user.name || "Unnamed User"}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
|
||||
</div>
|
||||
|
||||
@@ -567,7 +540,7 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
<Badge variant={isActive ? "secondary" : "destructive"}>
|
||||
{isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
{isActive && !isCurrentUser ? (
|
||||
{isActive ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@@ -584,36 +557,6 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{meta && meta.totalPages > 1 ? (
|
||||
<div className="flex items-center justify-between pt-3 mt-1 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {page} of {meta.totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => {
|
||||
setPage((previousPage) => Math.max(1, previousPage - 1));
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= meta.totalPages}
|
||||
onClick={() => {
|
||||
setPage((previousPage) => Math.min(meta.totalPages, previousPage + 1));
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
90
docker/OPENCLAW-FLEET.md
Normal file
90
docker/OPENCLAW-FLEET.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# OpenClaw Agent Fleet
|
||||
|
||||
OpenClaw multi-agent deployment for Mosaic Stack using Docker Swarm and Portainer.
|
||||
|
||||
## Agent Roster
|
||||
|
||||
| Agent | Service | Primary Model | Role |
|
||||
| ----------------- | ------------------- | --------------- | ---------------------------------- |
|
||||
| jarvis-main | `jarvis-main` | `zai/glm-5` | Orchestrator / user-facing gateway |
|
||||
| jarvis-projects | `jarvis-projects` | `zai/glm-5` | Development and coding tasks |
|
||||
| jarvis-research | `jarvis-research` | `zai/glm-5` | Research and web search |
|
||||
| jarvis-operations | `jarvis-operations` | `ollama/cogito` | Monitoring, health checks, alerts |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Docker Swarm initialized on the target host
|
||||
2. Existing Docker network `mosaic-stack_internal` (external swarm network)
|
||||
3. Z.ai API access key (`ZAI_API_KEY`)
|
||||
4. Ollama reachable at `10.1.1.42:11434` for the `cogito` model
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Configure each agent env file
|
||||
|
||||
Set values in:
|
||||
|
||||
- `docker/openclaw-instances/jarvis-main.env`
|
||||
- `docker/openclaw-instances/jarvis-projects.env`
|
||||
- `docker/openclaw-instances/jarvis-research.env`
|
||||
- `docker/openclaw-instances/jarvis-operations.env`
|
||||
|
||||
Required variables:
|
||||
|
||||
- `OPENCLAW_CONFIG_PATH=/config/openclaw.json`
|
||||
- `ZAI_API_KEY=<your-zai-api-key>`
|
||||
- `OPENCLAW_GATEWAY_TOKEN=<unique-token-per-agent>`
|
||||
|
||||
### 2. Generate unique gateway tokens
|
||||
|
||||
Generate one token per service:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
### 3. Deploy the fleet
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
docker stack deploy -c docker/openclaw-compose.yml jarvis
|
||||
```
|
||||
|
||||
### 4. Verify service status
|
||||
|
||||
```bash
|
||||
docker stack services jarvis
|
||||
docker service logs jarvis-jarvis-main --tail 100
|
||||
docker service logs jarvis-jarvis-projects --tail 100
|
||||
docker service logs jarvis-jarvis-research --tail 100
|
||||
docker service logs jarvis-jarvis-operations --tail 100
|
||||
```
|
||||
|
||||
### 5. First-time auth (if required)
|
||||
|
||||
Exec into a container and run OpenClaw auth device flow:
|
||||
|
||||
```bash
|
||||
docker exec -it $(docker ps -q -f name=jarvis-jarvis-main) sh
|
||||
openclaw auth
|
||||
```
|
||||
|
||||
You can also complete this in the Mosaic WebUI terminal (xterm.js).
|
||||
|
||||
## Management Commands
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------------------------------------- | ---------------------- |
|
||||
| `docker stack deploy -c docker/openclaw-compose.yml jarvis` | Deploy/update fleet |
|
||||
| `docker stack services jarvis` | List services in stack |
|
||||
| `docker service logs jarvis-<service>` | View service logs |
|
||||
| `docker service update --force jarvis-<service>` | Restart rolling update |
|
||||
| `docker service scale jarvis-<service>=N` | Scale a service |
|
||||
| `docker stack rm jarvis` | Remove fleet |
|
||||
|
||||
## Notes
|
||||
|
||||
- Each service stores persistent local OpenClaw state in `/home/node/.openclaw`.
|
||||
- Each service mounts a read-only per-agent JSON config at `/config/openclaw.json`.
|
||||
- `chatCompletions` endpoint is enabled in each agent config for Mosaic API usage.
|
||||
166
docker/openclaw-compose.yml
Normal file
166
docker/openclaw-compose.yml
Normal file
@@ -0,0 +1,166 @@
|
||||
services:
|
||||
jarvis-main:
|
||||
image: alpine/openclaw:latest
|
||||
command: ["gateway", "run", "--bind", "lan", "--auth", "token"]
|
||||
env_file:
|
||||
- ./openclaw-instances/jarvis-main.env
|
||||
environment:
|
||||
OPENCLAW_CONFIG_PATH: /config/openclaw.json
|
||||
volumes:
|
||||
- jarvis-main-config:/config/openclaw.json:ro
|
||||
- jarvis-main-state:/home/node/.openclaw
|
||||
networks:
|
||||
- mosaic-stack_internal
|
||||
healthcheck:
|
||||
test: ["CMD", "openclaw", "gateway", "health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
reservations:
|
||||
memory: 512M
|
||||
labels:
|
||||
- com.mosaic.agent=jarvis-main
|
||||
- com.mosaic.role=orchestrator
|
||||
|
||||
jarvis-projects:
|
||||
image: alpine/openclaw:latest
|
||||
command: ["gateway", "run", "--bind", "lan", "--auth", "token"]
|
||||
env_file:
|
||||
- ./openclaw-instances/jarvis-projects.env
|
||||
environment:
|
||||
OPENCLAW_CONFIG_PATH: /config/openclaw.json
|
||||
volumes:
|
||||
- jarvis-projects-config:/config/openclaw.json:ro
|
||||
- jarvis-projects-state:/home/node/.openclaw
|
||||
networks:
|
||||
- mosaic-stack_internal
|
||||
healthcheck:
|
||||
test: ["CMD", "openclaw", "gateway", "health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
reservations:
|
||||
memory: 1G
|
||||
labels:
|
||||
- com.mosaic.agent=jarvis-projects
|
||||
- com.mosaic.role=development
|
||||
|
||||
jarvis-research:
|
||||
image: alpine/openclaw:latest
|
||||
command: ["gateway", "run", "--bind", "lan", "--auth", "token"]
|
||||
env_file:
|
||||
- ./openclaw-instances/jarvis-research.env
|
||||
environment:
|
||||
OPENCLAW_CONFIG_PATH: /config/openclaw.json
|
||||
volumes:
|
||||
- jarvis-research-config:/config/openclaw.json:ro
|
||||
- jarvis-research-state:/home/node/.openclaw
|
||||
networks:
|
||||
- mosaic-stack_internal
|
||||
healthcheck:
|
||||
test: ["CMD", "openclaw", "gateway", "health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
reservations:
|
||||
memory: 256M
|
||||
labels:
|
||||
- com.mosaic.agent=jarvis-research
|
||||
- com.mosaic.role=research
|
||||
|
||||
jarvis-operations:
|
||||
image: alpine/openclaw:latest
|
||||
command: ["gateway", "run", "--bind", "lan", "--auth", "token"]
|
||||
env_file:
|
||||
- ./openclaw-instances/jarvis-operations.env
|
||||
environment:
|
||||
OPENCLAW_CONFIG_PATH: /config/openclaw.json
|
||||
volumes:
|
||||
- jarvis-operations-config:/config/openclaw.json:ro
|
||||
- jarvis-operations-state:/home/node/.openclaw
|
||||
networks:
|
||||
- mosaic-stack_internal
|
||||
healthcheck:
|
||||
test: ["CMD", "openclaw", "gateway", "health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
reservations:
|
||||
memory: 256M
|
||||
labels:
|
||||
- com.mosaic.agent=jarvis-operations
|
||||
- com.mosaic.role=operations
|
||||
|
||||
networks:
|
||||
mosaic-stack_internal:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
jarvis-main-config:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ${PWD}/docker/openclaw-instances/jarvis-main.json
|
||||
jarvis-projects-config:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ${PWD}/docker/openclaw-instances/jarvis-projects.json
|
||||
jarvis-research-config:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ${PWD}/docker/openclaw-instances/jarvis-research.json
|
||||
jarvis-operations-config:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ${PWD}/docker/openclaw-instances/jarvis-operations.json
|
||||
jarvis-main-state:
|
||||
jarvis-projects-state:
|
||||
jarvis-research-state:
|
||||
jarvis-operations-state:
|
||||
47
docker/openclaw-instances/README.md
Normal file
47
docker/openclaw-instances/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# OpenClaw Agent Instance Setup
|
||||
|
||||
Each service in the OpenClaw fleet reads:
|
||||
|
||||
- A per-agent environment file: `docker/openclaw-instances/<agent>.env`
|
||||
- A per-agent JSON5 config: `docker/openclaw-instances/<agent>.json`
|
||||
|
||||
## 1. Fill in API keys in `.env` files
|
||||
|
||||
Set `ZAI_API_KEY` in each instance env file:
|
||||
|
||||
- `jarvis-main.env`
|
||||
- `jarvis-projects.env`
|
||||
- `jarvis-research.env`
|
||||
- `jarvis-operations.env`
|
||||
|
||||
## 2. Generate unique gateway tokens per agent
|
||||
|
||||
Generate one token per instance:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Set a different `OPENCLAW_GATEWAY_TOKEN` in each `.env` file.
|
||||
|
||||
## 3. Deploy the Docker Swarm stack
|
||||
|
||||
From repository root:
|
||||
|
||||
```bash
|
||||
docker stack deploy -c docker/openclaw-compose.yml jarvis
|
||||
```
|
||||
|
||||
## 4. First-time auth (if needed)
|
||||
|
||||
If an instance requires first-time login, exec into the running container and run:
|
||||
|
||||
```bash
|
||||
openclaw auth
|
||||
```
|
||||
|
||||
This uses OpenClaw's headless OAuth device-code flow.
|
||||
|
||||
## 5. Use Mosaic WebUI terminal for auth
|
||||
|
||||
You can complete the device-code auth flow from the Mosaic WebUI terminal (xterm.js) attached to the service container.
|
||||
3
docker/openclaw-instances/jarvis-main.env
Normal file
3
docker/openclaw-instances/jarvis-main.env
Normal file
@@ -0,0 +1,3 @@
|
||||
OPENCLAW_CONFIG_PATH=/config/openclaw.json
|
||||
ZAI_API_KEY=REPLACE_WITH_ZAI_API_KEY
|
||||
OPENCLAW_GATEWAY_TOKEN=REPLACE_WITH_UNIQUE_GATEWAY_TOKEN
|
||||
41
docker/openclaw-instances/jarvis-main.json
Normal file
41
docker/openclaw-instances/jarvis-main.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"port": 18789,
|
||||
"bind": "lan",
|
||||
"auth": { "mode": "token" },
|
||||
"http": {
|
||||
"endpoints": {
|
||||
"chatCompletions": { "enabled": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/home/node/workspace",
|
||||
"model": { "primary": "zai/glm-5" }
|
||||
}
|
||||
},
|
||||
// Z.ai is built in and uses ZAI_API_KEY.
|
||||
// Ollama is configured for optional local reasoning fallback.
|
||||
"models": {
|
||||
"mode": "merge",
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"baseUrl": "http://10.1.1.42:11434/v1",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "cogito",
|
||||
"name": "Cogito (Local Reasoning)",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
|
||||
"contextWindow": 128000,
|
||||
"maxTokens": 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
docker/openclaw-instances/jarvis-operations.env
Normal file
3
docker/openclaw-instances/jarvis-operations.env
Normal file
@@ -0,0 +1,3 @@
|
||||
OPENCLAW_CONFIG_PATH=/config/openclaw.json
|
||||
ZAI_API_KEY=REPLACE_WITH_ZAI_API_KEY
|
||||
OPENCLAW_GATEWAY_TOKEN=REPLACE_WITH_UNIQUE_GATEWAY_TOKEN
|
||||
40
docker/openclaw-instances/jarvis-operations.json
Normal file
40
docker/openclaw-instances/jarvis-operations.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"port": 18789,
|
||||
"bind": "lan",
|
||||
"auth": { "mode": "token" },
|
||||
"http": {
|
||||
"endpoints": {
|
||||
"chatCompletions": { "enabled": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/home/node/workspace",
|
||||
"model": { "primary": "ollama/cogito" }
|
||||
}
|
||||
},
|
||||
// Operations uses local Ollama Cogito as the primary model.
|
||||
"models": {
|
||||
"mode": "merge",
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"baseUrl": "http://10.1.1.42:11434/v1",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "cogito",
|
||||
"name": "Cogito (Local Reasoning)",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
|
||||
"contextWindow": 128000,
|
||||
"maxTokens": 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
docker/openclaw-instances/jarvis-projects.env
Normal file
3
docker/openclaw-instances/jarvis-projects.env
Normal file
@@ -0,0 +1,3 @@
|
||||
OPENCLAW_CONFIG_PATH=/config/openclaw.json
|
||||
ZAI_API_KEY=REPLACE_WITH_ZAI_API_KEY
|
||||
OPENCLAW_GATEWAY_TOKEN=REPLACE_WITH_UNIQUE_GATEWAY_TOKEN
|
||||
39
docker/openclaw-instances/jarvis-projects.json
Normal file
39
docker/openclaw-instances/jarvis-projects.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"port": 18789,
|
||||
"bind": "lan",
|
||||
"auth": { "mode": "token" },
|
||||
"http": {
|
||||
"endpoints": {
|
||||
"chatCompletions": { "enabled": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/home/node/workspace",
|
||||
"model": { "primary": "zai/glm-5" }
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"mode": "merge",
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"baseUrl": "http://10.1.1.42:11434/v1",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "cogito",
|
||||
"name": "Cogito (Local Reasoning)",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
|
||||
"contextWindow": 128000,
|
||||
"maxTokens": 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
docker/openclaw-instances/jarvis-research.env
Normal file
3
docker/openclaw-instances/jarvis-research.env
Normal file
@@ -0,0 +1,3 @@
|
||||
OPENCLAW_CONFIG_PATH=/config/openclaw.json
|
||||
ZAI_API_KEY=REPLACE_WITH_ZAI_API_KEY
|
||||
OPENCLAW_GATEWAY_TOKEN=REPLACE_WITH_UNIQUE_GATEWAY_TOKEN
|
||||
39
docker/openclaw-instances/jarvis-research.json
Normal file
39
docker/openclaw-instances/jarvis-research.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"port": 18789,
|
||||
"bind": "lan",
|
||||
"auth": { "mode": "token" },
|
||||
"http": {
|
||||
"endpoints": {
|
||||
"chatCompletions": { "enabled": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/home/node/workspace",
|
||||
"model": { "primary": "zai/glm-5" }
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"mode": "merge",
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"baseUrl": "http://10.1.1.42:11434/v1",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "cogito",
|
||||
"name": "Cogito (Local Reasoning)",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
|
||||
"contextWindow": 128000,
|
||||
"maxTokens": 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,12 @@
|
||||
| MS21-MIG-003 | not-started | phase-3 | Run migration on production database | #568 | api | — | MS21-MIG-001,MS21-TEST-003 | MS21-VER-001 | — | — | — | 5K | — | Needs deploy coordination; not automatable |
|
||||
| MS21-MIG-004 | done | phase-3 | Import API endpoints (6/6 tests) | #568 | api | feat/ms21-import-api | MS21-DB-001 | — | codex | 2026-02-28 | 2026-02-28 | 20K | 24K | PR #567 merged, CI green. Review: 0 blockers, 4 should-fix, 1 medium sec (no audit log). |
|
||||
| MS21-UI-001 | done | phase-4 | Settings/users page | #569 | web | feat/ms21-ui-users | MS21-API-001,MS21-API-002 | — | codex | 2026-02-28 | 2026-02-28 | 20K | ~30K | PR #573 merged. Review: 0 blockers, 4 should-fix → MS21-UI-001-QA |
|
||||
| MS21-UI-001-QA | done | phase-4 | QA: fix 4 review findings (pagination, error state, self-deactivate guard, tests) | #569 | web | fix/ms21-ui-001-qa | MS21-UI-001 | — | — | — | — | 15K | — | 0 blockers; merged per framework. Should-fix: pagination cap, error/empty collision, self-deactivate guard, no tests. |
|
||||
| MS21-UI-001-QA | in-progress | phase-4 | QA: fix 4 review findings (pagination, error state, self-deactivate guard, tests) | #569 | web | fix/ms21-ui-001-qa | MS21-UI-001 | — | — | — | — | 15K | — | 0 blockers; merged per framework. Should-fix: pagination cap, error/empty collision, self-deactivate guard, no tests. |
|
||||
| MS21-UI-002 | done | phase-4 | User detail/edit and invite dialogs | #569 | web | feat/ms21-ui-users | MS21-UI-001 | — | — | — | — | 15K | — | |
|
||||
| MS21-UI-003 | done | phase-4 | Settings/workspaces page (wire to real API) | #569 | web | feat/ms21-ui-workspaces | MS21-API-003 | — | codex | 2026-02-28 | 2026-02-28 | 15K | ~25K | PR #574 merged. Review: 0 critical, 1 low (raw errors in UI) |
|
||||
| MS21-UI-004 | done | phase-4 | Workspace member management UI | #569 | web | feat/ms21-ui-workspaces | MS21-UI-003,MS21-API-003 | — | — | — | — | 15K | — | Components exist |
|
||||
| MS21-UI-005 | done | phase-4 | Settings/teams page | #569 | web | feat/ms21-ui-teams | MS21-API-004 | — | — | — | — | 15K | — | |
|
||||
| MS21-TEST-004 | done | phase-4 | Frontend component tests | #569 | web | test/ms21-ui | MS21-UI-001,MS21-UI-002,MS21-UI-003,MS21-UI-004,MS21-UI-005 | — | — | — | — | 20K | — | |
|
||||
| MS21-TEST-004 | in-progress | phase-4 | Frontend component tests | #569 | web | test/ms21-ui | MS21-UI-001,MS21-UI-002,MS21-UI-003,MS21-UI-004,MS21-UI-005 | — | — | — | — | 20K | — | |
|
||||
| MS21-RBAC-001 | done | phase-5 | Sidebar navigation role gating | #570 | web | feat/ms21-rbac | MS21-UI-001 | — | — | — | — | 10K | — | |
|
||||
| MS21-RBAC-002 | done | phase-5 | Settings page access restriction | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
|
||||
| MS21-RBAC-003 | done | phase-5 | Action button permission gating | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
|
||||
@@ -55,19 +55,19 @@ Remaining estimate: ~143K tokens (Codex budget).
|
||||
|
||||
## MS22 — Fleet Evolution (Phase 0: Knowledge Layer)
|
||||
|
||||
| id | status | milestone | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||
| --------------- | ------ | ------------ | ------------------------------------------------------------ | -------- | ----- | ------------------------------ | --------------------------------------------------------- | ------------- | ------------ | ---------- | ------------ | -------- | ---- | --------------------------------------------- |
|
||||
| MS22-PLAN-001 | done | p0-knowledge | PRD + mission bootstrap + TASKS.md | TASKS:P0 | stack | feat/ms22-knowledge-schema | — | MS22-DB-001 | orchestrator | 2026-02-28 | 2026-02-28 | 10K | 8K | PRD-MS22.md, mission fleet-evolution-20260228 |
|
||||
| MS22-DB-001 | done | p0-knowledge | Findings module (pgvector, CRUD, similarity search) | TASKS:P0 | api | feat/ms22-findings | MS22-PLAN-001 | — | codex | 2026-02-28 | 2026-02-28 | 20K | ~22K | PR #585 merged, CI green |
|
||||
| MS22-API-001 | done | p0-knowledge | Findings API endpoints | TASKS:P0 | api | feat/ms22-findings | MS22-DB-001 | — | codex | 2026-02-28 | 2026-02-28 | — | — | Combined with DB-001 |
|
||||
| MS22-DB-002 | done | p0-knowledge | AgentMemory module (key/value store, upsert) | TASKS:P0 | api | feat/ms22-agent-memory | MS22-DB-001 | — | codex | 2026-02-28 | 2026-02-28 | 15K | ~16K | PR #586 merged, CI green |
|
||||
| MS22-API-002 | done | p0-knowledge | AgentMemory API endpoints | TASKS:P0 | api | feat/ms22-agent-memory | MS22-DB-002 | — | codex | 2026-02-28 | 2026-02-28 | — | — | Combined with DB-002 |
|
||||
| MS22-DB-004 | done | p0-knowledge | ConversationArchive module (pgvector, ingest, search) | TASKS:P0 | api | feat/ms22-conversation-archive | MS22-DB-001 | — | codex | 2026-02-28 | 2026-02-28 | 20K | ~18K | PR #587 merged, CI green |
|
||||
| MS22-API-004 | done | p0-knowledge | ConversationArchive API endpoints | TASKS:P0 | api | feat/ms22-conversation-archive | MS22-DB-004 | — | codex | 2026-02-28 | 2026-02-28 | — | — | Combined with DB-004 |
|
||||
| MS22-API-005 | done | p0-knowledge | EmbeddingService (reuse existing KnowledgeModule) | TASKS:P0 | api | — | — | — | orchestrator | 2026-02-28 | 2026-02-28 | 0 | 0 | Already existed; no work needed |
|
||||
| MS22-DB-003 | done | p0-knowledge | Task model: add assigned_agent field + migration | TASKS:P0 | api | feat/ms22-task-agent | MS22-DB-001 | MS22-API-003 | — | — | — | 8K | — | Small schema + migration only |
|
||||
| MS22-API-003 | done | p0-knowledge | Task API: expose assigned_agent in CRUD | TASKS:P0 | api | feat/ms22-task-agent | MS22-DB-003 | MS22-TEST-001 | — | — | — | 8K | — | Extend existing TaskModule |
|
||||
| MS22-TEST-001 | done | p0-knowledge | Integration tests: Findings + AgentMemory + ConvArchive | TASKS:P0 | api | test/ms22-integration | MS22-API-001,MS22-API-002,MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | E2E with live postgres |
|
||||
| MS22-SKILL-001 | done | p0-knowledge | OpenClaw mosaic skill (agents read/write findings/memory) | TASKS:P0 | stack | feat/ms22-openclaw-skill | MS22-API-001,MS22-API-002 | MS22-VER-P0 | — | — | — | 15K | — | Skill in ~/.agents/skills/mosaic/ |
|
||||
| MS22-INGEST-001 | done | p0-knowledge | Session log ingestion pipeline (OpenClaw logs → ConvArchive) | TASKS:P0 | stack | feat/ms22-ingest | MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | Script to batch-ingest existing logs |
|
||||
| MS22-VER-P0 | done | p0-knowledge | Phase 0 verification: all modules deployed + smoke tested | TASKS:P0 | stack | — | MS22-TEST-001,MS22-SKILL-001,MS22-INGEST-001,MS22-API-003 | — | — | — | — | 5K | — | |
|
||||
| id | status | milestone | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||
| --------------- | ----------- | ------------ | ------------------------------------------------------------ | -------- | ----- | ------------------------------ | --------------------------------------------------------- | ------------- | ------------ | ---------- | ------------ | -------- | ---- | --------------------------------------------- |
|
||||
| MS22-PLAN-001 | done | p0-knowledge | PRD + mission bootstrap + TASKS.md | TASKS:P0 | stack | feat/ms22-knowledge-schema | — | MS22-DB-001 | orchestrator | 2026-02-28 | 2026-02-28 | 10K | 8K | PRD-MS22.md, mission fleet-evolution-20260228 |
|
||||
| MS22-DB-001 | done | p0-knowledge | Findings module (pgvector, CRUD, similarity search) | TASKS:P0 | api | feat/ms22-findings | MS22-PLAN-001 | — | codex | 2026-02-28 | 2026-02-28 | 20K | ~22K | PR #585 merged, CI green |
|
||||
| MS22-API-001 | done | p0-knowledge | Findings API endpoints | TASKS:P0 | api | feat/ms22-findings | MS22-DB-001 | — | codex | 2026-02-28 | 2026-02-28 | — | — | Combined with DB-001 |
|
||||
| MS22-DB-002 | done | p0-knowledge | AgentMemory module (key/value store, upsert) | TASKS:P0 | api | feat/ms22-agent-memory | MS22-DB-001 | — | codex | 2026-02-28 | 2026-02-28 | 15K | ~16K | PR #586 merged, CI green |
|
||||
| MS22-API-002 | done | p0-knowledge | AgentMemory API endpoints | TASKS:P0 | api | feat/ms22-agent-memory | MS22-DB-002 | — | codex | 2026-02-28 | 2026-02-28 | — | — | Combined with DB-002 |
|
||||
| MS22-DB-004 | done | p0-knowledge | ConversationArchive module (pgvector, ingest, search) | TASKS:P0 | api | feat/ms22-conversation-archive | MS22-DB-001 | — | codex | 2026-02-28 | 2026-02-28 | 20K | ~18K | PR #587 merged, CI green |
|
||||
| MS22-API-004 | done | p0-knowledge | ConversationArchive API endpoints | TASKS:P0 | api | feat/ms22-conversation-archive | MS22-DB-004 | — | codex | 2026-02-28 | 2026-02-28 | — | — | Combined with DB-004 |
|
||||
| MS22-API-005 | done | p0-knowledge | EmbeddingService (reuse existing KnowledgeModule) | TASKS:P0 | api | — | — | — | orchestrator | 2026-02-28 | 2026-02-28 | 0 | 0 | Already existed; no work needed |
|
||||
| MS22-DB-003 | not-started | p0-knowledge | Task model: add assigned_agent field + migration | TASKS:P0 | api | feat/ms22-task-agent | MS22-DB-001 | MS22-API-003 | — | — | — | 8K | — | Small schema + migration only |
|
||||
| MS22-API-003 | not-started | p0-knowledge | Task API: expose assigned_agent in CRUD | TASKS:P0 | api | feat/ms22-task-agent | MS22-DB-003 | MS22-TEST-001 | — | — | — | 8K | — | Extend existing TaskModule |
|
||||
| MS22-TEST-001 | not-started | p0-knowledge | Integration tests: Findings + AgentMemory + ConvArchive | TASKS:P0 | api | test/ms22-integration | MS22-API-001,MS22-API-002,MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | E2E with live postgres |
|
||||
| MS22-SKILL-001 | not-started | p0-knowledge | OpenClaw mosaic skill (agents read/write findings/memory) | TASKS:P0 | stack | feat/ms22-openclaw-skill | MS22-API-001,MS22-API-002 | MS22-VER-P0 | — | — | — | 15K | — | Skill in ~/.agents/skills/mosaic/ |
|
||||
| MS22-INGEST-001 | done | p0-knowledge | Session log ingestion pipeline (OpenClaw logs → ConvArchive) | TASKS:P0 | stack | feat/ms22-ingest | MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | Script to batch-ingest existing logs |
|
||||
| MS22-VER-P0 | not-started | p0-knowledge | Phase 0 verification: all modules deployed + smoke tested | TASKS:P0 | stack | — | MS22-TEST-001,MS22-SKILL-001,MS22-INGEST-001,MS22-API-003 | — | — | — | — | 5K | — | |
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
# MS22 Phase 1: DB-Centric Multi-User Agent Architecture
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **2 env vars to bootstrap** — `DATABASE_URL` + `MOSAIC_SECRET_KEY`
|
||||
2. **DB-centric config** — All runtime config in Postgres, managed via WebUI
|
||||
3. **Mosaic is the gatekeeper** — Users authenticate to Mosaic, never to OpenClaw directly
|
||||
4. **Per-user agent isolation** — Each user gets their own OpenClaw container(s) with their own credentials
|
||||
5. **Onboarding-first** — Breakglass user + wizard on first boot
|
||||
6. **Generic product** — No hardcoded names, models, providers, or endpoints
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ MOSAIC WEBUI │
|
||||
│ (Auth: breakglass local + OIDC via settings) │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ MOSAIC API │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌────────────────┐ ┌─────────┐ │
|
||||
│ │ Onboarding │ │ Container │ │ Config │ │
|
||||
│ │ Wizard │ │ Lifecycle Mgr │ │ Store │ │
|
||||
│ └──────────────┘ └───────┬────────┘ └─────────┘ │
|
||||
│ │ │
|
||||
└────────────────────────────┼────────────────────────┘
|
||||
│ Docker API
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ OpenClaw │ │ OpenClaw │ │ OpenClaw │
|
||||
│ User A │ │ User B │ │ System │
|
||||
│ │ │ │ │ (admin) │
|
||||
│ Claude Max │ │ Z.ai key │ │ Shared key │
|
||||
│ own memory │ │ own memory │ │ monitoring │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
Scale to zero Scale to zero Always on
|
||||
after idle after idle
|
||||
```
|
||||
|
||||
## Container Lifecycle
|
||||
|
||||
### User containers (on-demand)
|
||||
|
||||
1. User logs in → Mosaic checks `UserContainer` table
|
||||
2. No running container → Mosaic calls Docker API to create one
|
||||
3. Injects user's encrypted API keys via config endpoint
|
||||
4. Routes chat requests to user's container
|
||||
5. Idle timeout (configurable, default 30min) → scale to zero
|
||||
6. State volume persists (sessions, memory, auth tokens)
|
||||
7. Next request → container restarts, picks up state from volume
|
||||
|
||||
### System containers (always-on, optional)
|
||||
|
||||
- Admin-provisioned for system tasks (monitoring, scheduled jobs)
|
||||
- Use admin-configured shared API keys
|
||||
- Not tied to any user
|
||||
|
||||
## Auth Layers
|
||||
|
||||
| Flow | Method |
|
||||
| ------------------------------- | ---------------------------------------------------------------------- |
|
||||
| User → Mosaic WebUI | Breakglass (local) or OIDC (configured in settings) |
|
||||
| Mosaic API → OpenClaw container | Bearer token (auto-generated per container, stored encrypted in DB) |
|
||||
| OpenClaw → LLM providers | User's own API keys (delivered via config endpoint, decrypted from DB) |
|
||||
| Admin → System settings | RBAC (admin role required) |
|
||||
| Internal config endpoint | Bearer token (container authenticates to fetch its config) |
|
||||
|
||||
## Database Schema
|
||||
|
||||
### System Tables
|
||||
|
||||
```prisma
|
||||
model SystemConfig {
|
||||
id String @id @default(cuid())
|
||||
key String @unique // "oidc.issuerUrl", "oidc.clientId", "onboarding.completed"
|
||||
value String // plaintext or encrypted (prefix: "enc:")
|
||||
encrypted Boolean @default(false)
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model BreakglassUser {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
passwordHash String // bcrypt
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
### Provider Tables (per-user)
|
||||
|
||||
```prisma
|
||||
model LlmProvider {
|
||||
id String @id @default(cuid())
|
||||
userId String // owner — each user manages their own providers
|
||||
name String // "my-zai", "work-openai", "local-ollama"
|
||||
displayName String // "Z.ai", "OpenAI (Work)", "Local Ollama"
|
||||
type String // "zai" | "openai" | "anthropic" | "ollama" | "custom"
|
||||
baseUrl String? // null for built-in, URL for custom/ollama
|
||||
apiKey String? // encrypted
|
||||
apiType String @default("openai-completions")
|
||||
models Json @default("[]") // [{id, name, contextWindow, maxTokens}]
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, name])
|
||||
}
|
||||
```
|
||||
|
||||
### Container Tables
|
||||
|
||||
```prisma
|
||||
model UserContainer {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
containerId String? // Docker container ID (null = not running)
|
||||
containerName String // "mosaic-user-{userId}"
|
||||
gatewayPort Int? // assigned port (null = not running)
|
||||
gatewayToken String // encrypted — auto-generated
|
||||
status String @default("stopped") // "running" | "stopped" | "starting" | "error"
|
||||
lastActiveAt DateTime?
|
||||
idleTimeoutMin Int @default(30)
|
||||
config Json @default("{}") // cached openclaw.json for this user
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model SystemContainer {
|
||||
id String @id @default(cuid())
|
||||
name String @unique // "mosaic-system-ops", "mosaic-system-monitor"
|
||||
role String // "operations" | "monitor" | "scheduler"
|
||||
containerId String?
|
||||
gatewayPort Int?
|
||||
gatewayToken String // encrypted
|
||||
status String @default("stopped")
|
||||
providerId String? // references admin-level LlmProvider
|
||||
primaryModel String // "zai/glm-5", etc.
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
### User Agent Preferences
|
||||
|
||||
```prisma
|
||||
model UserAgentConfig {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
primaryModel String? // user's preferred model
|
||||
fallbackModels Json @default("[]")
|
||||
personality String? // custom SOUL.md content
|
||||
providerId String? // default provider for this user
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Config Endpoint
|
||||
|
||||
`GET /api/internal/agent-config/:containerType/:id`
|
||||
|
||||
- Auth: Bearer token (container's own gateway token)
|
||||
- Returns: Complete `openclaw.json` generated from DB
|
||||
- For user containers: includes user's providers, model prefs, personality
|
||||
- For system containers: includes admin provider config
|
||||
|
||||
Response assembles openclaw.json dynamically:
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": { "mode": "local", "port": 18789, "bind": "lan", "auth": { "mode": "token" } ... },
|
||||
"agents": { "defaults": { "model": { "primary": "<from UserAgentConfig>" } } },
|
||||
"models": { "providers": { "<from LlmProvider rows>": { ... } } }
|
||||
}
|
||||
```
|
||||
|
||||
## Container Lifecycle Manager
|
||||
|
||||
NestJS service that manages Docker containers:
|
||||
|
||||
```typescript
|
||||
class ContainerLifecycleService {
|
||||
// Create and start a user's OpenClaw container
|
||||
async ensureRunning(userId: string): Promise<{ url: string; token: string }>;
|
||||
|
||||
// Stop idle containers (called by cron/scheduler)
|
||||
async reapIdle(): Promise<number>;
|
||||
|
||||
// Stop a specific user's container
|
||||
async stop(userId: string): Promise<void>;
|
||||
|
||||
// Health check all running containers
|
||||
async healthCheckAll(): Promise<HealthStatus[]>;
|
||||
|
||||
// Restart container with updated config
|
||||
async restart(userId: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
Uses Docker Engine API (`/var/run/docker.sock` or TCP) via `dockerode` npm package.
|
||||
|
||||
## Onboarding Wizard
|
||||
|
||||
### First-Boot Detection
|
||||
|
||||
- API checks: `SystemConfig.get("onboarding.completed")` → null = first boot
|
||||
- WebUI redirects to `/onboarding` if not completed
|
||||
|
||||
### Steps
|
||||
|
||||
**Step 1: Create Breakglass Admin**
|
||||
|
||||
- Username + password → bcrypt → `BreakglassUser` table
|
||||
- This user always works, even if OIDC is misconfigured
|
||||
|
||||
**Step 2: Configure Authentication (optional)**
|
||||
|
||||
- OIDC: provider URL, client ID, client secret → encrypted in `SystemConfig`
|
||||
- Skip = breakglass-only auth (can add OIDC later in settings)
|
||||
|
||||
**Step 3: Add Your First LLM Provider**
|
||||
|
||||
- Pick type → enter API key/endpoint → test connection → save to `LlmProvider`
|
||||
- This becomes the admin's default provider
|
||||
|
||||
**Step 4: System Agents (optional)**
|
||||
|
||||
- Configure always-on system agents for monitoring/ops
|
||||
- Or skip — users can just use their own personal agents
|
||||
|
||||
**Step 5: Complete**
|
||||
|
||||
- Sets `SystemConfig("onboarding.completed") = true`
|
||||
- Redirects to dashboard
|
||||
|
||||
### Post-Onboarding: User Self-Service
|
||||
|
||||
- Each user adds their own LLM providers in profile settings
|
||||
- Each user configures their preferred model, personality
|
||||
- First chat request triggers container creation
|
||||
|
||||
## Docker Compose (final)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mosaic-api:
|
||||
image: mosaic/api:latest
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
MOSAIC_SECRET_KEY: ${MOSAIC_SECRET_KEY}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock # Docker API access
|
||||
networks:
|
||||
- internal
|
||||
|
||||
mosaic-web:
|
||||
image: mosaic/web:latest
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: http://mosaic-api:4000
|
||||
networks:
|
||||
- internal
|
||||
|
||||
postgres:
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_DB: mosaic
|
||||
POSTGRES_USER: mosaic
|
||||
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- internal
|
||||
|
||||
# System agent (optional, admin-provisioned)
|
||||
# mosaic-system:
|
||||
# image: alpine/openclaw:latest
|
||||
# ... (managed by ContainerLifecycleService)
|
||||
|
||||
# User containers are NOT in this file —
|
||||
# they are dynamically created by ContainerLifecycleService
|
||||
# via the Docker API at runtime.
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: overlay
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
```
|
||||
|
||||
Note: User OpenClaw containers are **not** defined in docker-compose. They are
|
||||
created dynamically by the `ContainerLifecycleService` when users start chatting.
|
||||
|
||||
## Entrypoint (for dynamically created containers)
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
: "${MOSAIC_API_URL:?required}"
|
||||
: "${AGENT_TOKEN:?required}"
|
||||
: "${AGENT_ID:?required}"
|
||||
|
||||
# Fetch config from Mosaic API
|
||||
curl -sf "${MOSAIC_API_URL}/api/internal/agent-config/${AGENT_ID}" \
|
||||
-H "Authorization: Bearer ${AGENT_TOKEN}" \
|
||||
-o /tmp/openclaw.json
|
||||
|
||||
export OPENCLAW_CONFIG_PATH=/tmp/openclaw.json
|
||||
exec openclaw gateway run --bind lan --auth token
|
||||
```
|
||||
|
||||
Container env vars (injected by ContainerLifecycleService):
|
||||
|
||||
- `MOSAIC_API_URL` — internal API URL
|
||||
- `AGENT_TOKEN` — this container's bearer token (from DB)
|
||||
- `AGENT_ID` — container ID for config lookup
|
||||
|
||||
## Config Update Strategy
|
||||
|
||||
When a user changes settings (model, provider, personality):
|
||||
|
||||
1. Mosaic API updates DB
|
||||
2. API calls `ContainerLifecycleService.restart(userId)`
|
||||
3. Container restarts, fetches fresh config from API
|
||||
4. OpenClaw gateway starts with new config
|
||||
5. State volume preserves sessions/memory across restarts
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
| Task | Phase | Scope | Dependencies |
|
||||
| -------- | -------------- | --------------------------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| MS22-P1a | Schema | Prisma models: SystemConfig, BreakglassUser, LlmProvider, UserContainer, SystemContainer, UserAgentConfig. Migration. | — |
|
||||
| MS22-P1b | Crypto | Encryption service for API keys/tokens (AES-256-GCM using MOSAIC_SECRET_KEY) | P1a |
|
||||
| MS22-P1c | Config API | Internal config endpoint: assembles openclaw.json from DB | P1a, P1b |
|
||||
| MS22-P1d | Container Mgr | ContainerLifecycleService: Docker API integration (dockerode), start/stop/health/reap | P1a |
|
||||
| MS22-P1e | Onboarding API | Onboarding endpoints: breakglass, OIDC, provider, complete | P1a, P1b |
|
||||
| MS22-P1f | Onboarding UI | Multi-step wizard in WebUI | P1e |
|
||||
| MS22-P1g | Settings API | CRUD: providers, agent config, OIDC, breakglass | P1a, P1b |
|
||||
| MS22-P1h | Settings UI | Settings pages: Providers, Agent Config, Auth | P1g |
|
||||
| MS22-P1i | Chat Proxy | Route WebUI chat → user's OpenClaw container (SSE) | P1c, P1d |
|
||||
| MS22-P1j | Docker | Entrypoint script, health checks, compose for core services | P1c |
|
||||
| MS22-P1k | Idle Reaper | Cron service to stop idle user containers | P1d |
|
||||
|
||||
## Open Questions (Resolved)
|
||||
|
||||
1. ~~Config updates → restart?~~ **Yes.** Mosaic restarts the container, fresh config on boot.
|
||||
2. ~~CLI alternative for breakglass?~~ **Yes.** Both WebUI wizard and CLI (`mosaic admin create-breakglass`).
|
||||
3. ~~Config cache TTL?~~ **Yes.** Config fetched once at startup, changes trigger restart.
|
||||
|
||||
## Security Isolation Model
|
||||
|
||||
### Core Principle: ZERO cross-user access
|
||||
|
||||
Every user is fully sandboxed. No exceptions.
|
||||
|
||||
### Container Isolation
|
||||
|
||||
- Each user gets their **own** OpenClaw container (separate process, PID namespace)
|
||||
- Each container has its **own** Docker volume (sessions, memory, workspace)
|
||||
- Containers run on an **internal-only** Docker network — no external exposure
|
||||
- Users NEVER talk to OpenClaw directly — Mosaic proxies all requests
|
||||
- Container gateway tokens are unique per-user and single-purpose
|
||||
|
||||
### Data Isolation (enforced at API + DB level)
|
||||
|
||||
| Data | Isolation | Enforcement |
|
||||
| ---------------- | ------------------------- | --------------------------------------------------------------------------------- |
|
||||
| LLM API keys | Per-user, encrypted | `LlmProvider.userId` — all queries scoped by authenticated user |
|
||||
| Chat history | Per-user container volume | Separate Docker volume per user, not shared |
|
||||
| Agent memory | Per-user container volume | Separate Docker volume per user |
|
||||
| Agent config | Per-user | `UserAgentConfig.userId` — scoped queries |
|
||||
| Container access | Per-user | `UserContainer.userId` — Mosaic validates user owns the container before proxying |
|
||||
|
||||
### API Enforcement
|
||||
|
||||
- **All user-facing endpoints** include `WHERE userId = authenticatedUser.id`
|
||||
- **No admin endpoint** exposes another user's API keys (even to admins)
|
||||
- **Chat proxy** validates: authenticated user → owns target container → forwards request
|
||||
- **Config endpoint** validates: container token matches the container requesting config
|
||||
- **Provider CRUD** is fully user-scoped — User A cannot list, read, or modify User B's providers
|
||||
|
||||
### What admins CAN see
|
||||
|
||||
- Container status (running/stopped) — not contents
|
||||
- User list and roles
|
||||
- System-level config (OIDC, system agents)
|
||||
- Aggregate usage metrics (not individual conversations)
|
||||
|
||||
### What admins CANNOT see
|
||||
|
||||
- Other users' API keys (encrypted, no decrypt endpoint)
|
||||
- Other users' chat history (in container volumes, not in Mosaic DB)
|
||||
- Other users' agent memory/workspace contents
|
||||
|
||||
### Future: Team Workspaces (NOT in scope)
|
||||
|
||||
Team/shared workspaces are a potential future feature where users opt-in to
|
||||
shared agent contexts. This requires explicit consent, shared-key management,
|
||||
and a different isolation model. **Not designed here. Not built now.**
|
||||
|
||||
### Attack Surface Notes
|
||||
|
||||
- Docker socket access (`/var/run/docker.sock`) is required by Mosaic API for container management. This is a privileged operation — the Mosaic API container must be trusted.
|
||||
- `MOSAIC_SECRET_KEY` is the root of trust for encryption. Rotation requires re-encrypting all secrets in DB.
|
||||
- Container-to-container communication is blocked by default (no shared network between user containers unless explicitly configured).
|
||||
Reference in New Issue
Block a user