Compare commits

..

17 Commits

Author SHA1 Message Date
8d7a1be7f5 fix(docker): strip hardcoded model/provider assumptions from fleet doc
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
Model choices and provider prereqs belong in onboarding/settings,
not static documentation.
2026-03-01 08:06:15 -06:00
89767e26ef fix(docker): generic naming (mosaic-*), env-var-only config, no hardcoded values
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
- Renamed all jarvis-* to mosaic-* (generic for any deployment)
- Config files are .json.template with ${VAR} placeholders
- entrypoint.sh renders templates via envsubst at startup
- Ollama is optional: set OLLAMA_BASE_URL to auto-inject provider
- Model is configurable via OPENCLAW_MODEL env var
- No hardcoded IPs, keys, model names, or user preferences
- Updated README with full env var reference
2026-03-01 08:02:31 -06:00
50f0dc6018 Revert "fix(docker): use envsubst template pattern — no hardcoded URLs or keys (MS22-P1a)"
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
This reverts commit 11136e2f23.
2026-03-01 07:55:32 -06:00
11136e2f23 fix(docker): use envsubst template pattern — no hardcoded URLs or keys (MS22-P1a)
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
2026-03-01 07:54:28 -06:00
256171cc62 feat(docker): OpenClaw agent fleet compose + real configs (MS22-P1a)
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
2026-03-01 07:54:03 -06:00
512a29a240 fix(web): QA fixes on users settings page (MS21-UI-001-QA) (#599)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
fix(web): QA fixes on users settings page (MS21-UI-001-QA)

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 13:52:15 +00:00
8ea3c3ee67 Merge pull request 'chore(orchestrator): sync TASKS.md — mark MS21 completed tasks as done' (#597) from chore/ms21-tasks-sync into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Reviewed-on: #597
2026-03-01 13:41:45 +00:00
c4a6be5b6b Merge pull request 'chore(orchestrator): mark MS22 Phase 0 complete' (#596) from chore/ms22-phase0-complete into main
Reviewed-on: #596
2026-03-01 13:41:29 +00:00
f4c1c9d816 chore(orchestrator): sync TASKS.md — mark UI-002,004,005,RBAC-001,002 done; UI-001-QA+TEST-004 in-progress 2026-03-01 07:38:51 -06:00
ac67697fe4 chore(orchestrator): mark MS22 Phase 0 complete — all tasks done 2026-02-28 22:55:18 -06:00
6521f655a8 feat(web): add teams page and RBAC navigation/route gating (MS21-UI-005, RBAC-001, RBAC-002) (#595)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 04:54:25 +00:00
0e74b03d9c test(api): integration tests for MS22 knowledge layer modules (MS22-TEST-001) (#594)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 04:54:23 +00:00
a925f91062 feat: add OpenClaw session log ingestion script (MS22-INGEST-001) (#593)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 03:54:36 +00:00
7106512fa9 feat(web): add user edit/invite dialogs and workspace member management (MS21-UI-002, MS21-UI-004) (#592)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 03:54:32 +00:00
1df20f0e13 feat(api): add assigned_agent to Task model (MS22-DB-003, MS22-API-003) (#591)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 03:54:28 +00:00
8dab20c022 chore(orchestrator): add MS22 Phase 0 tasks to TASKS.md (#590)
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 03:14:55 +00:00
7073057e8d fix: bump openbao 2.5.0→2.5.1 (CVE-2026-24051 otel/sdk PATH hijack) (#589)
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 03:14:49 +00:00
35 changed files with 3447 additions and 572 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "assigned_agent" TEXT;

View File

@@ -379,6 +379,7 @@ model Task {
creatorId String @map("creator_id") @db.Uuid
projectId String? @map("project_id") @db.Uuid
parentId String? @map("parent_id") @db.Uuid
assignedAgent String? @map("assigned_agent")
domainId String? @map("domain_id") @db.Uuid
sortOrder Int @default(0) @map("sort_order")
metadata Json @default("{}")

View File

@@ -0,0 +1,198 @@
import { beforeAll, beforeEach, describe, expect, it, afterAll } from "vitest";
import { randomUUID as uuid } from "crypto";
import { Test, TestingModule } from "@nestjs/testing";
import { NotFoundException } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
import { AgentMemoryService } from "./agent-memory.service";
import { PrismaService } from "../prisma/prisma.service";
const shouldRunDbIntegrationTests =
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
async function createWorkspace(
prisma: PrismaClient,
label: string
): Promise<{ workspaceId: string; ownerId: string }> {
const workspace = await prisma.workspace.create({
data: {
name: `${label} ${Date.now()}`,
owner: {
create: {
email: `${label.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}@example.com`,
name: `${label} Owner`,
},
},
},
});
return {
workspaceId: workspace.id,
ownerId: workspace.ownerId,
};
}
describeFn("AgentMemoryService Integration", () => {
let moduleRef: TestingModule;
let prisma: PrismaClient;
let service: AgentMemoryService;
let setupComplete = false;
let workspaceAId: string;
let workspaceAOwnerId: string;
let workspaceBId: string;
let workspaceBOwnerId: string;
beforeAll(async () => {
prisma = new PrismaClient();
await prisma.$connect();
const workspaceA = await createWorkspace(prisma, "Agent Memory Integration A");
workspaceAId = workspaceA.workspaceId;
workspaceAOwnerId = workspaceA.ownerId;
const workspaceB = await createWorkspace(prisma, "Agent Memory Integration B");
workspaceBId = workspaceB.workspaceId;
workspaceBOwnerId = workspaceB.ownerId;
moduleRef = await Test.createTestingModule({
providers: [
AgentMemoryService,
{
provide: PrismaService,
useValue: prisma,
},
],
}).compile();
service = moduleRef.get<AgentMemoryService>(AgentMemoryService);
setupComplete = true;
});
beforeEach(async () => {
if (!setupComplete) {
return;
}
await prisma.agentMemory.deleteMany({
where: {
workspaceId: {
in: [workspaceAId, workspaceBId],
},
},
});
});
afterAll(async () => {
if (!prisma) {
return;
}
const workspaceIds = [workspaceAId, workspaceBId].filter(
(id): id is string => typeof id === "string"
);
const ownerIds = [workspaceAOwnerId, workspaceBOwnerId].filter(
(id): id is string => typeof id === "string"
);
if (workspaceIds.length > 0) {
await prisma.agentMemory.deleteMany({
where: {
workspaceId: {
in: workspaceIds,
},
},
});
await prisma.workspace.deleteMany({ where: { id: { in: workspaceIds } } });
}
if (ownerIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: ownerIds } } });
}
if (moduleRef) {
await moduleRef.close();
}
await prisma.$disconnect();
});
it("upserts and lists memory entries", async () => {
if (!setupComplete) {
return;
}
const agentId = `agent-${uuid()}`;
const entry = await service.upsert(workspaceAId, agentId, "session-context", {
value: { intent: "create-tests", depth: "integration" },
});
expect(entry.workspaceId).toBe(workspaceAId);
expect(entry.agentId).toBe(agentId);
expect(entry.key).toBe("session-context");
const listed = await service.findAll(workspaceAId, agentId);
expect(listed).toHaveLength(1);
expect(listed[0]?.id).toBe(entry.id);
expect(listed[0]?.value).toMatchObject({ intent: "create-tests" });
});
it("updates existing key via upsert without creating duplicates", async () => {
if (!setupComplete) {
return;
}
const agentId = `agent-${uuid()}`;
const first = await service.upsert(workspaceAId, agentId, "preferences", {
value: { model: "fast" },
});
const second = await service.upsert(workspaceAId, agentId, "preferences", {
value: { model: "accurate" },
});
expect(second.id).toBe(first.id);
expect(second.value).toMatchObject({ model: "accurate" });
const rowCount = await prisma.agentMemory.count({
where: {
workspaceId: workspaceAId,
agentId,
key: "preferences",
},
});
expect(rowCount).toBe(1);
});
it("lists keys in sorted order and isolates by workspace", async () => {
if (!setupComplete) {
return;
}
const agentId = `agent-${uuid()}`;
await service.upsert(workspaceAId, agentId, "beta", { value: { v: 2 } });
await service.upsert(workspaceAId, agentId, "alpha", { value: { v: 1 } });
await service.upsert(workspaceBId, agentId, "alpha", { value: { v: 99 } });
const workspaceAEntries = await service.findAll(workspaceAId, agentId);
const workspaceBEntries = await service.findAll(workspaceBId, agentId);
expect(workspaceAEntries.map((row) => row.key)).toEqual(["alpha", "beta"]);
expect(workspaceBEntries).toHaveLength(1);
expect(workspaceBEntries[0]?.value).toMatchObject({ v: 99 });
});
it("throws NotFoundException when requesting unknown key", async () => {
if (!setupComplete) {
return;
}
await expect(service.findOne(workspaceAId, `agent-${uuid()}`, "missing")).rejects.toThrow(
NotFoundException
);
});
});

View File

@@ -0,0 +1,239 @@
import { beforeAll, beforeEach, describe, expect, it, afterAll, vi } from "vitest";
import { randomUUID as uuid } from "crypto";
import { Test, TestingModule } from "@nestjs/testing";
import { ConflictException } from "@nestjs/common";
import { PrismaClient, Prisma } from "@prisma/client";
import { EMBEDDING_DIMENSION } from "@mosaic/shared";
import { ConversationArchiveService } from "./conversation-archive.service";
import { PrismaService } from "../prisma/prisma.service";
import { EmbeddingService } from "../knowledge/services/embedding.service";
const shouldRunDbIntegrationTests =
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
function vector(value: number): number[] {
return Array.from({ length: EMBEDDING_DIMENSION }, () => value);
}
function toVectorLiteral(input: number[]): string {
return `[${input.join(",")}]`;
}
describeFn("ConversationArchiveService Integration", () => {
let moduleRef: TestingModule;
let prisma: PrismaClient;
let service: ConversationArchiveService;
let workspaceId: string;
let ownerId: string;
let setupComplete = false;
const embeddingServiceMock = {
isConfigured: vi.fn(),
generateEmbedding: vi.fn(),
};
beforeAll(async () => {
prisma = new PrismaClient();
await prisma.$connect();
const workspace = await prisma.workspace.create({
data: {
name: `Conversation Archive Integration ${Date.now()}`,
owner: {
create: {
email: `conversation-archive-integration-${Date.now()}@example.com`,
name: "Conversation Archive Integration Owner",
},
},
},
});
workspaceId = workspace.id;
ownerId = workspace.ownerId;
moduleRef = await Test.createTestingModule({
providers: [
ConversationArchiveService,
{
provide: PrismaService,
useValue: prisma,
},
{
provide: EmbeddingService,
useValue: embeddingServiceMock,
},
],
}).compile();
service = moduleRef.get<ConversationArchiveService>(ConversationArchiveService);
setupComplete = true;
});
beforeEach(async () => {
vi.clearAllMocks();
embeddingServiceMock.isConfigured.mockReturnValue(false);
if (!setupComplete) {
return;
}
await prisma.conversationArchive.deleteMany({ where: { workspaceId } });
});
afterAll(async () => {
if (!prisma) {
return;
}
if (workspaceId) {
await prisma.conversationArchive.deleteMany({ where: { workspaceId } });
await prisma.workspace.deleteMany({ where: { id: workspaceId } });
}
if (ownerId) {
await prisma.user.deleteMany({ where: { id: ownerId } });
}
if (moduleRef) {
await moduleRef.close();
}
await prisma.$disconnect();
});
it("ingests a conversation log", async () => {
if (!setupComplete) {
return;
}
const sessionId = `session-${uuid()}`;
const result = await service.ingest(workspaceId, {
sessionId,
agentId: "agent-conversation-ingest",
messages: [
{ role: "user", content: "Can you summarize deployment issues?" },
{ role: "assistant", content: "Yes, three retries timed out in staging." },
],
summary: "Deployment retry failures discussed",
startedAt: "2026-02-28T21:00:00.000Z",
endedAt: "2026-02-28T21:05:00.000Z",
metadata: { source: "integration-test" },
});
expect(result.id).toBeDefined();
const stored = await prisma.conversationArchive.findUnique({
where: {
id: result.id,
},
});
expect(stored).toBeTruthy();
expect(stored?.workspaceId).toBe(workspaceId);
expect(stored?.sessionId).toBe(sessionId);
expect(stored?.messageCount).toBe(2);
expect(stored?.summary).toBe("Deployment retry failures discussed");
});
it("rejects duplicate session ingest per workspace", async () => {
if (!setupComplete) {
return;
}
const sessionId = `session-${uuid()}`;
const dto = {
sessionId,
agentId: "agent-conversation-duplicate",
messages: [{ role: "user", content: "hello" }],
summary: "simple conversation",
startedAt: "2026-02-28T22:00:00.000Z",
};
await service.ingest(workspaceId, dto);
await expect(service.ingest(workspaceId, dto)).rejects.toThrow(ConflictException);
});
it("rejects semantic search when embeddings are disabled", async () => {
if (!setupComplete) {
return;
}
embeddingServiceMock.isConfigured.mockReturnValue(false);
await expect(
service.search(workspaceId, {
query: "deployment retries",
})
).rejects.toThrow(ConflictException);
});
it("searches archived conversations by vector similarity", async () => {
if (!setupComplete) {
return;
}
const near = vector(0.02);
const far = vector(0.85);
const matching = await prisma.conversationArchive.create({
data: {
workspaceId,
sessionId: `session-search-${uuid()}`,
agentId: "agent-conversation-search-a",
messages: [
{ role: "user", content: "What caused deployment retries?" },
{ role: "assistant", content: "A connection pool timeout." },
] as unknown as Prisma.InputJsonValue,
messageCount: 2,
summary: "Deployment retries caused by connection pool timeout",
startedAt: new Date("2026-02-28T23:00:00.000Z"),
metadata: { channel: "cli" } as Prisma.InputJsonValue,
},
});
const nonMatching = await prisma.conversationArchive.create({
data: {
workspaceId,
sessionId: `session-search-${uuid()}`,
agentId: "agent-conversation-search-b",
messages: [
{ role: "user", content: "How is billing configured?" },
] as unknown as Prisma.InputJsonValue,
messageCount: 1,
summary: "Billing and quotas conversation",
startedAt: new Date("2026-02-28T23:10:00.000Z"),
metadata: { channel: "cli" } as Prisma.InputJsonValue,
},
});
await prisma.$executeRaw`
UPDATE conversation_archives
SET embedding = ${toVectorLiteral(near)}::vector(${EMBEDDING_DIMENSION})
WHERE id = ${matching.id}::uuid
`;
await prisma.$executeRaw`
UPDATE conversation_archives
SET embedding = ${toVectorLiteral(far)}::vector(${EMBEDDING_DIMENSION})
WHERE id = ${nonMatching.id}::uuid
`;
embeddingServiceMock.isConfigured.mockReturnValue(true);
embeddingServiceMock.generateEmbedding.mockResolvedValue(near);
const result = await service.search(workspaceId, {
query: "deployment retries timeout",
agentId: "agent-conversation-search-a",
similarityThreshold: 0,
limit: 10,
});
const rows = result.data as Array<{ id: string; agent_id: string; similarity: number }>;
expect(result.pagination.total).toBe(1);
expect(rows).toHaveLength(1);
expect(rows[0]?.id).toBe(matching.id);
expect(rows[0]?.agent_id).toBe("agent-conversation-search-a");
expect(rows[0]?.similarity).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,226 @@
import { beforeAll, beforeEach, describe, expect, it, afterAll, vi } from "vitest";
import { randomUUID as uuid } from "crypto";
import { Test, TestingModule } from "@nestjs/testing";
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { PrismaClient, Prisma } from "@prisma/client";
import { FindingsService } from "./findings.service";
import { PrismaService } from "../prisma/prisma.service";
import { EmbeddingService } from "../knowledge/services/embedding.service";
const shouldRunDbIntegrationTests =
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
const EMBEDDING_DIMENSION = 1536;
function vector(value: number): number[] {
return Array.from({ length: EMBEDDING_DIMENSION }, () => value);
}
function toVectorLiteral(input: number[]): string {
return `[${input.join(",")}]`;
}
describeFn("FindingsService Integration", () => {
let moduleRef: TestingModule;
let prisma: PrismaClient;
let service: FindingsService;
let workspaceId: string;
let ownerId: string;
let setupComplete = false;
const embeddingServiceMock = {
isConfigured: vi.fn(),
generateEmbedding: vi.fn(),
};
beforeAll(async () => {
prisma = new PrismaClient();
await prisma.$connect();
const workspace = await prisma.workspace.create({
data: {
name: `Findings Integration ${Date.now()}`,
owner: {
create: {
email: `findings-integration-${Date.now()}@example.com`,
name: "Findings Integration Owner",
},
},
},
});
workspaceId = workspace.id;
ownerId = workspace.ownerId;
moduleRef = await Test.createTestingModule({
providers: [
FindingsService,
{
provide: PrismaService,
useValue: prisma,
},
{
provide: EmbeddingService,
useValue: embeddingServiceMock,
},
],
}).compile();
service = moduleRef.get<FindingsService>(FindingsService);
setupComplete = true;
});
beforeEach(() => {
vi.clearAllMocks();
embeddingServiceMock.isConfigured.mockReturnValue(false);
});
afterAll(async () => {
if (!prisma) {
return;
}
if (workspaceId) {
await prisma.finding.deleteMany({ where: { workspaceId } });
await prisma.workspace.deleteMany({ where: { id: workspaceId } });
}
if (ownerId) {
await prisma.user.deleteMany({ where: { id: ownerId } });
}
if (moduleRef) {
await moduleRef.close();
}
await prisma.$disconnect();
});
it("creates, lists, fetches, and deletes findings", async () => {
if (!setupComplete) {
return;
}
const created = await service.create(workspaceId, {
agentId: "agent-findings-crud",
type: "security",
title: "Unescaped SQL fragment",
data: { severity: "high" },
summary: "Potential injection risk in dynamic query path.",
});
expect(created.id).toBeDefined();
expect(created.workspaceId).toBe(workspaceId);
expect(created.taskId).toBeNull();
const listed = await service.findAll(workspaceId, {
page: 1,
limit: 10,
agentId: "agent-findings-crud",
});
expect(listed.meta.total).toBeGreaterThanOrEqual(1);
expect(listed.data.some((row) => row.id === created.id)).toBe(true);
const found = await service.findOne(created.id, workspaceId);
expect(found.id).toBe(created.id);
expect(found.title).toBe("Unescaped SQL fragment");
await expect(service.findOne(created.id, uuid())).rejects.toThrow(NotFoundException);
await expect(service.remove(created.id, workspaceId)).resolves.toEqual({
message: "Finding deleted successfully",
});
await expect(service.findOne(created.id, workspaceId)).rejects.toThrow(NotFoundException);
});
it("rejects create when taskId does not exist in workspace", async () => {
if (!setupComplete) {
return;
}
await expect(
service.create(workspaceId, {
taskId: uuid(),
agentId: "agent-findings-missing-task",
type: "bug",
title: "Invalid task id",
data: { source: "integration-test" },
summary: "Should fail when task relation is not found.",
})
).rejects.toThrow(NotFoundException);
});
it("rejects vector search when embeddings are disabled", async () => {
if (!setupComplete) {
return;
}
embeddingServiceMock.isConfigured.mockReturnValue(false);
await expect(
service.search(workspaceId, {
query: "security issue",
})
).rejects.toThrow(BadRequestException);
});
it("searches findings by vector similarity with filters", async () => {
if (!setupComplete) {
return;
}
const near = vector(0.01);
const far = vector(0.9);
const matchedFinding = await prisma.finding.create({
data: {
workspaceId,
agentId: "agent-findings-search-a",
type: "incident",
title: "Authentication bypass",
data: { score: 0.9 } as Prisma.InputJsonValue,
summary: "Bypass risk found in login checks.",
},
});
const otherFinding = await prisma.finding.create({
data: {
workspaceId,
agentId: "agent-findings-search-b",
type: "incident",
title: "Retry timeout",
data: { score: 0.2 } as Prisma.InputJsonValue,
summary: "Timeout issue in downstream retries.",
},
});
await prisma.$executeRaw`
UPDATE findings
SET embedding = ${toVectorLiteral(near)}::vector(1536)
WHERE id = ${matchedFinding.id}::uuid
`;
await prisma.$executeRaw`
UPDATE findings
SET embedding = ${toVectorLiteral(far)}::vector(1536)
WHERE id = ${otherFinding.id}::uuid
`;
embeddingServiceMock.isConfigured.mockReturnValue(true);
embeddingServiceMock.generateEmbedding.mockResolvedValue(near);
const result = await service.search(workspaceId, {
query: "authentication bypass risk",
agentId: "agent-findings-search-a",
limit: 10,
similarityThreshold: 0,
});
expect(result.query).toBe("authentication bypass risk");
expect(result.meta.total).toBe(1);
expect(result.data).toHaveLength(1);
expect(result.data[0]?.id).toBe(matchedFinding.id);
expect(result.data[0]?.agentId).toBe("agent-findings-search-a");
expect(result.data.find((row) => row.id === otherFinding.id)).toBeUndefined();
});
});

View File

@@ -50,6 +50,12 @@ export class CreateTaskDto {
@IsUUID("4", { message: "parentId must be a valid UUID" })
parentId?: string;
@IsOptional()
@IsString({ message: "assignedAgent must be a string" })
@MinLength(1, { message: "assignedAgent must not be empty" })
@MaxLength(255, { message: "assignedAgent must not exceed 255 characters" })
assignedAgent?: string;
@IsOptional()
@IsInt({ message: "sortOrder must be an integer" })
@Min(0, { message: "sortOrder must be at least 0" })

View File

@@ -52,6 +52,12 @@ export class UpdateTaskDto {
@IsUUID("4", { message: "parentId must be a valid UUID" })
parentId?: string | null;
@IsOptional()
@IsString({ message: "assignedAgent must be a string" })
@MinLength(1, { message: "assignedAgent must not be empty" })
@MaxLength(255, { message: "assignedAgent must not exceed 255 characters" })
assignedAgent?: string | null;
@IsOptional()
@IsInt({ message: "sortOrder must be an integer" })
@Min(0, { message: "sortOrder must be at least 0" })

View File

@@ -0,0 +1,162 @@
import { beforeAll, beforeEach, describe, expect, it, afterAll, vi } from "vitest";
import { randomUUID as uuid } from "crypto";
import { Test, TestingModule } from "@nestjs/testing";
import { PrismaClient } from "@prisma/client";
import { TasksService } from "./tasks.service";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityService } from "../activity/activity.service";
const shouldRunDbIntegrationTests =
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
describeFn("TasksService assignedAgent Integration", () => {
let moduleRef: TestingModule;
let prisma: PrismaClient;
let service: TasksService;
let workspaceId: string;
let ownerId: string;
let setupComplete = false;
const activityServiceMock = {
logTaskCreated: vi.fn().mockResolvedValue(undefined),
logTaskUpdated: vi.fn().mockResolvedValue(undefined),
logTaskDeleted: vi.fn().mockResolvedValue(undefined),
logTaskCompleted: vi.fn().mockResolvedValue(undefined),
logTaskAssigned: vi.fn().mockResolvedValue(undefined),
};
beforeAll(async () => {
prisma = new PrismaClient();
await prisma.$connect();
const workspace = await prisma.workspace.create({
data: {
name: `Tasks Assigned Agent Integration ${Date.now()}`,
owner: {
create: {
email: `tasks-assigned-agent-integration-${Date.now()}@example.com`,
name: "Tasks Assigned Agent Integration Owner",
},
},
},
});
workspaceId = workspace.id;
ownerId = workspace.ownerId;
moduleRef = await Test.createTestingModule({
providers: [
TasksService,
{
provide: PrismaService,
useValue: prisma,
},
{
provide: ActivityService,
useValue: activityServiceMock,
},
],
}).compile();
service = moduleRef.get<TasksService>(TasksService);
setupComplete = true;
});
beforeEach(async () => {
vi.clearAllMocks();
if (!setupComplete) {
return;
}
await prisma.task.deleteMany({ where: { workspaceId } });
});
afterAll(async () => {
if (!prisma) {
return;
}
if (workspaceId) {
await prisma.task.deleteMany({ where: { workspaceId } });
await prisma.workspace.deleteMany({ where: { id: workspaceId } });
}
if (ownerId) {
await prisma.user.deleteMany({ where: { id: ownerId } });
}
if (moduleRef) {
await moduleRef.close();
}
await prisma.$disconnect();
});
it("persists assignedAgent on create", async () => {
if (!setupComplete) {
return;
}
const task = await service.create(workspaceId, ownerId, {
title: `Assigned agent create ${uuid()}`,
assignedAgent: "fleet-worker-1",
});
expect(task.assignedAgent).toBe("fleet-worker-1");
const stored = await prisma.task.findUnique({
where: {
id: task.id,
},
select: {
id: true,
assignedAgent: true,
},
});
expect(stored).toMatchObject({
id: task.id,
assignedAgent: "fleet-worker-1",
});
const listed = await service.findAll({ workspaceId, page: 1, limit: 10 }, ownerId);
const listedTask = listed.data.find((row) => row.id === task.id);
expect(listedTask?.assignedAgent).toBe("fleet-worker-1");
});
it("updates and clears assignedAgent", async () => {
if (!setupComplete) {
return;
}
const created = await service.create(workspaceId, ownerId, {
title: `Assigned agent update ${uuid()}`,
});
expect(created.assignedAgent).toBeNull();
const updated = await service.update(created.id, workspaceId, ownerId, {
assignedAgent: "fleet-worker-2",
});
expect(updated.assignedAgent).toBe("fleet-worker-2");
const cleared = await service.update(created.id, workspaceId, ownerId, {
assignedAgent: null,
});
expect(cleared.assignedAgent).toBeNull();
const stored = await prisma.task.findUnique({
where: {
id: created.id,
},
select: {
assignedAgent: true,
},
});
expect(stored?.assignedAgent).toBeNull();
});
});

View File

@@ -48,6 +48,7 @@ describe("TasksService", () => {
creatorId: mockUserId,
projectId: null,
parentId: null,
assignedAgent: null,
sortOrder: 0,
metadata: {},
createdAt: new Date(),
@@ -158,6 +159,28 @@ describe("TasksService", () => {
})
);
});
it("should include assignedAgent when provided", async () => {
const createDto = {
title: "Agent-owned Task",
assignedAgent: "fleet-worker-1",
};
mockPrismaService.task.create.mockResolvedValue({
...mockTask,
assignedAgent: createDto.assignedAgent,
});
await service.create(mockWorkspaceId, mockUserId, createDto);
expect(prisma.task.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
assignedAgent: createDto.assignedAgent,
}),
})
);
});
});
describe("findAll", () => {
@@ -469,6 +492,26 @@ describe("TasksService", () => {
service.update(mockTaskId, mockWorkspaceId, mockUserId, { title: "Test" })
).rejects.toThrow(NotFoundException);
});
it("should update assignedAgent when provided", async () => {
const updateDto = { assignedAgent: "fleet-worker-2" };
mockPrismaService.task.findUnique.mockResolvedValue(mockTask);
mockPrismaService.task.update.mockResolvedValue({
...mockTask,
assignedAgent: updateDto.assignedAgent,
});
await service.update(mockTaskId, mockWorkspaceId, mockUserId, updateDto);
expect(prisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
assignedAgent: updateDto.assignedAgent,
}),
})
);
});
});
describe("remove", () => {

View File

@@ -67,6 +67,9 @@ export class TasksService {
metadata: createTaskDto.metadata
? (createTaskDto.metadata as unknown as Prisma.InputJsonValue)
: {},
...(createTaskDto.assignedAgent !== undefined && {
assignedAgent: createTaskDto.assignedAgent,
}),
...(assigneeConnection && { assignee: assigneeConnection }),
...(projectConnection && { project: projectConnection }),
...(parentConnection && { parent: parentConnection }),
@@ -291,6 +294,9 @@ export class TasksService {
if (updateTaskDto.parentId !== undefined && updateTaskDto.parentId !== null) {
data.parent = { connect: { id: updateTaskDto.parentId } };
}
if (updateTaskDto.assignedAgent !== undefined) {
data.assignedAgent = updateTaskDto.assignedAgent;
}
// Handle completedAt based on status changes
if (updateTaskDto.status) {

View File

@@ -230,6 +230,7 @@ const categories: CategoryConfig[] = [
title: "Teams",
description: "Create and manage teams within your active workspace.",
href: "/settings/teams",
adminOnly: true,
accent: "var(--ms-blue-400)",
iconBg: "rgba(47, 128, 255, 0.12)",
icon: (

View File

@@ -1,9 +1,12 @@
import type { ReactElement, ReactNode } from "react";
import type { TeamRecord } from "@/lib/api/teams";
import { render, screen } from "@testing-library/react";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchTeams } from "@/lib/api/teams";
import { createTeam, deleteTeam, fetchTeams, updateTeam } from "@/lib/api/teams";
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
import TeamsSettingsPage from "./page";
@@ -22,9 +25,19 @@ vi.mock("next/link", () => ({
vi.mock("@/lib/api/teams", () => ({
fetchTeams: vi.fn(),
createTeam: vi.fn(),
updateTeam: vi.fn(),
deleteTeam: vi.fn(),
}));
vi.mock("@/lib/api/workspaces", () => ({
fetchUserWorkspaces: vi.fn(),
}));
const fetchTeamsMock = vi.mocked(fetchTeams);
const createTeamMock = vi.mocked(createTeam);
const updateTeamMock = vi.mocked(updateTeam);
const deleteTeamMock = vi.mocked(deleteTeam);
const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
const baseTeam: TeamRecord = {
id: "team-1",
@@ -42,6 +55,33 @@ const baseTeam: TeamRecord = {
describe("TeamsSettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
fetchTeamsMock.mockResolvedValue([]);
fetchUserWorkspacesMock.mockResolvedValue([
{
id: "workspace-1",
name: "Personal Workspace",
ownerId: "owner-1",
role: WorkspaceMemberRole.OWNER,
createdAt: "2026-01-01T00:00:00.000Z",
},
]);
});
it("shows access denied to non-admin users", async () => {
fetchUserWorkspacesMock.mockResolvedValueOnce([
{
id: "workspace-1",
name: "Personal Workspace",
ownerId: "owner-1",
role: WorkspaceMemberRole.MEMBER,
createdAt: "2026-01-01T00:00:00.000Z",
},
]);
render(<TeamsSettingsPage />);
expect(await screen.findByText("Access Denied")).toBeInTheDocument();
expect(fetchTeamsMock).not.toHaveBeenCalled();
});
it("loads and renders teams from the API", async () => {
@@ -49,9 +89,7 @@ describe("TeamsSettingsPage", () => {
render(<TeamsSettingsPage />);
expect(screen.getByText("Loading teams...")).toBeInTheDocument();
expect(await screen.findByText("Your Teams (1)")).toBeInTheDocument();
expect(await screen.findByText("Team Directory")).toBeInTheDocument();
expect(screen.getByText("Platform Team")).toBeInTheDocument();
expect(fetchTeamsMock).toHaveBeenCalledTimes(1);
});
@@ -61,8 +99,8 @@ describe("TeamsSettingsPage", () => {
render(<TeamsSettingsPage />);
expect(await screen.findByText("Your Teams (0)")).toBeInTheDocument();
expect(screen.getByText("No teams yet")).toBeInTheDocument();
expect(await screen.findByText("No Teams Yet")).toBeInTheDocument();
expect(screen.getByText("Create the first team to get started.")).toBeInTheDocument();
});
it("shows error state and does not show empty state", async () => {
@@ -71,6 +109,82 @@ describe("TeamsSettingsPage", () => {
render(<TeamsSettingsPage />);
expect(await screen.findByText("Unable to load teams")).toBeInTheDocument();
expect(screen.queryByText("No teams yet")).not.toBeInTheDocument();
});
it("creates a team from the create dialog", async () => {
const user = userEvent.setup();
fetchTeamsMock.mockResolvedValue([baseTeam]);
createTeamMock.mockResolvedValue({
...baseTeam,
id: "team-2",
name: "Design Team",
description: "Owns design quality",
});
render(<TeamsSettingsPage />);
expect(await screen.findByText("Platform Team")).toBeInTheDocument();
const triggerButton = screen.getByRole("button", { name: "Create Team" });
await user.click(triggerButton);
await user.type(screen.getByLabelText("Name"), "Design Team");
await user.type(screen.getByLabelText("Description"), "Owns design quality");
const submitButton = screen.getAllByRole("button", { name: "Create Team" })[1];
if (!submitButton) {
throw new Error("Expected create-team submit button to be rendered");
}
await user.click(submitButton);
await waitFor(() => {
expect(createTeamMock).toHaveBeenCalledWith({
name: "Design Team",
description: "Owns design quality",
});
});
});
it("opens team details and updates name", async () => {
const user = userEvent.setup();
fetchTeamsMock.mockResolvedValue([baseTeam]);
updateTeamMock.mockResolvedValue({
...baseTeam,
name: "Platform Engineering",
});
render(<TeamsSettingsPage />);
expect(await screen.findByText("Platform Team")).toBeInTheDocument();
await user.click(screen.getByText("Platform Team"));
const nameInput = await screen.findByLabelText("Name");
await user.clear(nameInput);
await user.type(nameInput, "Platform Engineering");
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(updateTeamMock).toHaveBeenCalledWith("team-1", {
name: "Platform Engineering",
});
});
});
it("deletes a team from the confirmation dialog", async () => {
const user = userEvent.setup();
fetchTeamsMock.mockResolvedValue([baseTeam]);
deleteTeamMock.mockResolvedValue();
render(<TeamsSettingsPage />);
expect(await screen.findByText("Platform Team")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(screen.getByRole("button", { name: "Delete Team" }));
await waitFor(() => {
expect(deleteTeamMock).toHaveBeenCalledWith("team-1");
});
});
});

View File

@@ -1,244 +1,582 @@
"use client";
import type { ReactElement, SyntheticEvent } from "react";
import { useCallback, useEffect, useState } from "react";
import {
useCallback,
useEffect,
useState,
type ChangeEvent,
type KeyboardEvent,
type ReactElement,
type SyntheticEvent,
} from "react";
import Link from "next/link";
import { createTeam, fetchTeams, type CreateTeamDto, type TeamRecord } from "@/lib/api/teams";
import { Plus, Trash2, Users } from "lucide-react";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied";
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,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
createTeam,
deleteTeam,
fetchTeams,
updateTeam,
type CreateTeamDto,
type TeamRecord,
type UpdateTeamDto,
} from "@/lib/api/teams";
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error) {
return error.message;
}
const INITIAL_CREATE_FORM = {
name: "",
description: "",
};
return fallback;
const INITIAL_DETAIL_FORM = {
name: "",
description: "",
};
interface DetailInitialState {
name: string;
description: string;
}
function toMemberLabel(count: number): string {
return `${String(count)} member${count === 1 ? "" : "s"}`;
}
export default function TeamsSettingsPage(): ReactElement {
const [teams, setTeams] = useState<TeamRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [newTeamName, setNewTeamName] = useState("");
const [newTeamDescription, setNewTeamDescription] = useState("");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState<boolean>(false);
const [createForm, setCreateForm] = useState(INITIAL_CREATE_FORM);
const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState<boolean>(false);
const loadTeams = useCallback(async (): Promise<void> => {
setIsLoading(true);
const [detailTarget, setDetailTarget] = useState<TeamRecord | null>(null);
const [detailForm, setDetailForm] = useState(INITIAL_DETAIL_FORM);
const [detailInitial, setDetailInitial] = useState<DetailInitialState | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [isSavingDetails, setIsSavingDetails] = useState<boolean>(false);
const [deleteTarget, setDeleteTarget] = useState<TeamRecord | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const loadTeams = useCallback(async (showLoadingState: boolean): Promise<void> => {
try {
if (showLoadingState) {
setIsLoading(true);
} else {
setIsRefreshing(true);
}
const data = await fetchTeams();
setTeams(data);
setLoadError(null);
} catch (error) {
setLoadError(getErrorMessage(error, "Failed to load teams"));
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load teams");
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
useEffect(() => {
void loadTeams();
}, [loadTeams]);
fetchUserWorkspaces()
.then((workspaces) => {
const adminRoles: WorkspaceMemberRole[] = [
WorkspaceMemberRole.OWNER,
WorkspaceMemberRole.ADMIN,
];
const handleCreateTeam = async (e: SyntheticEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
setIsAdmin(workspaces.some((workspace) => adminRoles.includes(workspace.role)));
})
.catch(() => {
setIsAdmin(true); // fail open
});
}, []);
const teamName = newTeamName.trim();
if (!teamName) return;
useEffect(() => {
if (isAdmin !== true) {
return;
}
setIsCreating(true);
void loadTeams(true);
}, [isAdmin, loadTeams]);
function resetCreateForm(): void {
setCreateForm(INITIAL_CREATE_FORM);
setCreateError(null);
}
function openTeamDetails(team: TeamRecord): void {
const nextDetailForm = {
name: team.name,
description: team.description ?? "",
};
setDetailTarget(team);
setDetailForm(nextDetailForm);
setDetailInitial({
name: nextDetailForm.name,
description: nextDetailForm.description,
});
setDetailError(null);
}
function resetTeamDetails(): void {
setDetailTarget(null);
setDetailForm(INITIAL_DETAIL_FORM);
setDetailInitial(null);
setDetailError(null);
}
function handleTeamRowKeyDown(event: KeyboardEvent<HTMLDivElement>, team: TeamRecord): void {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openTeamDetails(team);
}
}
async function handleCreateSubmit(event: SyntheticEvent): Promise<void> {
event.preventDefault();
setCreateError(null);
try {
const description = newTeamDescription.trim();
const dto: CreateTeamDto = { name: teamName };
if (description.length > 0) {
dto.description = description;
}
const name = createForm.name.trim();
if (!name) {
setCreateError("Name is required.");
return;
}
const description = createForm.description.trim();
const dto: CreateTeamDto = { name };
if (description) {
dto.description = description;
}
try {
setIsCreating(true);
await createTeam(dto);
setNewTeamName("");
setNewTeamDescription("");
setIsCreateDialogOpen(false);
await loadTeams();
} catch (error) {
setCreateError(getErrorMessage(error, "Failed to create team"));
setIsCreateOpen(false);
resetCreateForm();
await loadTeams(false);
} catch (err: unknown) {
setCreateError(err instanceof Error ? err.message : "Failed to create team");
} finally {
setIsCreating(false);
}
};
}
async function handleDetailSubmit(event: SyntheticEvent): Promise<void> {
event.preventDefault();
if (detailTarget === null || detailInitial === null) {
return;
}
const name = detailForm.name.trim();
if (!name) {
setDetailError("Name is required.");
return;
}
const nextDescription = detailForm.description.trim();
const normalizedNextDescription = nextDescription.length > 0 ? nextDescription : null;
const normalizedInitialDescription =
detailInitial.description.trim().length > 0 ? detailInitial.description.trim() : null;
const dto: UpdateTeamDto = {};
if (name !== detailInitial.name) {
dto.name = name;
}
if (normalizedNextDescription !== normalizedInitialDescription) {
dto.description = normalizedNextDescription;
}
if (Object.keys(dto).length === 0) {
resetTeamDetails();
return;
}
try {
setIsSavingDetails(true);
setDetailError(null);
await updateTeam(detailTarget.id, dto);
resetTeamDetails();
await loadTeams(false);
} catch (err: unknown) {
setDetailError(err instanceof Error ? err.message : "Failed to update team");
} finally {
setIsSavingDetails(false);
}
}
async function confirmDelete(): Promise<void> {
if (!deleteTarget) {
return;
}
try {
setIsDeleting(true);
await deleteTeam(deleteTarget.id);
setDeleteTarget(null);
await loadTeams(false);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to delete team");
} finally {
setIsDeleting(false);
}
}
if (isAdmin === null) {
return (
<Card className="max-w-2xl mx-auto mt-8">
<CardContent className="py-12 text-center text-muted-foreground">
Checking permissions...
</CardContent>
</Card>
);
}
if (!isAdmin) {
return <SettingsAccessDenied message="You need Admin or Owner role to manage teams." />;
}
return (
<main className="container mx-auto px-4 py-8 max-w-5xl">
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-3xl font-bold text-gray-900">Teams</h1>
<Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700">
{"<-"} Back to Settings
</Link>
</div>
<p className="text-gray-600">Manage teams in your active workspace</p>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">Create New Team</h2>
<p className="text-sm text-gray-600 mt-1">
Add a team to organize members and permissions.
</p>
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">Teams</h1>
<Badge variant="outline">{teams.length} total</Badge>
</div>
<button
type="button"
<p className="text-muted-foreground mt-1">Create and manage workspace teams</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => {
setCreateError(null);
setIsCreateDialogOpen(true);
void loadTeams(false);
}}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
disabled={isLoading || isRefreshing}
>
Create Team
</button>
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
<Dialog
open={isCreateOpen}
onOpenChange={(open) => {
if (!open && !isCreating) {
resetCreateForm();
}
setIsCreateOpen(open);
}}
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Team
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Team</DialogTitle>
<DialogDescription>
Create a team in the active workspace to organize members and permissions.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
void handleCreateSubmit(event);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="create-name">Name</Label>
<Input
id="create-name"
value={createForm.name}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setCreateForm((prev) => ({ ...prev, name: event.target.value }));
}}
placeholder="Platform Team"
maxLength={100}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-description">Description</Label>
<Textarea
id="create-description"
value={createForm.description}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
setCreateForm((prev) => ({ ...prev, description: event.target.value }));
}}
placeholder="Owns platform services and infrastructure"
maxLength={500}
rows={4}
/>
</div>
{createError ? (
<p className="text-sm text-destructive" role="alert">
{createError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
if (!isCreating) {
setIsCreateOpen(false);
resetCreateForm();
}
}}
disabled={isCreating}
>
Cancel
</Button>
<Button type="submit" disabled={isCreating}>
{isCreating ? "Creating..." : "Create Team"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div>
{isCreateDialogOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
role="dialog"
>
<div className="w-full max-w-lg rounded-lg border border-gray-200 bg-white p-6 shadow-xl">
<h3 className="text-lg font-semibold text-gray-900">Create New Team</h3>
<p className="mt-1 text-sm text-gray-600">
Enter a team name and optional description.
<div>
<Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700">
Back to Settings
</Link>
</div>
{error ? (
<Card>
<CardContent className="py-4">
<p className="text-sm text-destructive" role="alert">
{error}
</p>
</CardContent>
</Card>
) : null}
<form onSubmit={handleCreateTeam} className="mt-4 space-y-4">
<div>
<label htmlFor="team-name" className="mb-1 block text-sm font-medium text-gray-700">
Team Name
</label>
<input
id="team-name"
type="text"
value={newTeamName}
onChange={(e) => {
setNewTeamName(e.target.value);
}}
placeholder="Enter team name..."
disabled={isCreating}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
autoFocus
/>
</div>
{isLoading ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Loading teams...
</CardContent>
</Card>
) : teams.length === 0 ? (
<Card>
<CardHeader>
<CardTitle>No Teams Yet</CardTitle>
<CardDescription>Create the first team to get started.</CardDescription>
</CardHeader>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Team Directory</CardTitle>
<CardDescription>
Click a team to view details or edit name and description.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{teams.map((team) => {
const memberCount = team._count?.members ?? 0;
const description = team.description?.trim();
<div>
<label
htmlFor="team-description"
className="mb-1 block text-sm font-medium text-gray-700"
>
Description (optional)
</label>
<textarea
id="team-description"
value={newTeamDescription}
onChange={(e) => {
setNewTeamDescription(e.target.value);
}}
placeholder="Describe this team's purpose..."
disabled={isCreating}
rows={3}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
/>
</div>
{createError !== null && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{createError}
</div>
)}
<div className="flex justify-end gap-3">
<button
type="button"
return (
<div
key={team.id}
className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between cursor-pointer hover:bg-muted/30"
role="button"
tabIndex={0}
onClick={() => {
if (!isCreating) {
setIsCreateDialogOpen(false);
}
openTeamDetails(team);
}}
onKeyDown={(event) => {
handleTeamRowKeyDown(event, team);
}}
disabled={isCreating}
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
type="submit"
disabled={isCreating || !newTeamName.trim()}
className="px-5 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isCreating ? "Creating..." : "Create Team"}
</button>
</div>
</form>
</div>
</div>
<div className="space-y-1 min-w-0">
<p className="font-semibold truncate">{team.name}</p>
<p className="text-sm text-muted-foreground truncate">
{description && description.length > 0 ? description : "No description"}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap md:justify-end">
<Badge variant="outline">
<Users className="h-3.5 w-3.5 mr-1" />
{toMemberLabel(memberCount)}
</Badge>
<Badge variant="secondary">
Created {new Date(team.createdAt).toLocaleDateString()}
</Badge>
<Button
variant="destructive"
size="sm"
onClick={(event) => {
event.stopPropagation();
setDeleteTarget(team);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
);
})}
</CardContent>
</Card>
)}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900">
Your Teams ({isLoading ? "..." : teams.length})
</h2>
{loadError !== null ? (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-red-700">
{loadError}
</div>
) : isLoading ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center text-gray-600">
Loading teams...
</div>
) : teams.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5V8H2v12h5m10 0v-4a3 3 0 10-6 0v4m6 0H7"
<Dialog
open={detailTarget !== null}
onOpenChange={(open) => {
if (!open && !isSavingDetails) {
resetTeamDetails();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Team Details</DialogTitle>
<DialogDescription>
Edit team details for {detailTarget?.name ?? "selected team"}.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
void handleDetailSubmit(event);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="detail-name">Name</Label>
<Input
id="detail-name"
value={detailForm.name}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setDetailForm((prev) => ({ ...prev, name: event.target.value }));
}}
placeholder="Team name"
maxLength={100}
disabled={isSavingDetails}
required
/>
</svg>
<h3 className="text-lg font-medium text-gray-900 mb-2">No teams yet</h3>
<p className="text-gray-600">Create your first team to get started</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{teams.map((team) => (
<article
key={team.id}
className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm"
data-testid="team-card"
</div>
<div className="space-y-2">
<Label htmlFor="detail-description">Description</Label>
<Textarea
id="detail-description"
value={detailForm.description}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
setDetailForm((prev) => ({ ...prev, description: event.target.value }));
}}
placeholder="Describe this team"
maxLength={500}
rows={4}
disabled={isSavingDetails}
/>
</div>
{detailError !== null ? (
<p className="text-sm text-destructive" role="alert">
{detailError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
if (!isSavingDetails) {
resetTeamDetails();
}
}}
disabled={isSavingDetails}
>
<h3 className="text-lg font-semibold text-gray-900">{team.name}</h3>
{team.description ? (
<p className="mt-1 text-sm text-gray-600">{team.description}</p>
) : (
<p className="mt-1 text-sm text-gray-400 italic">No description</p>
)}
<div className="mt-4 flex items-center gap-3 text-xs text-gray-500">
<span>{team._count?.members ?? 0} members</span>
<span>|</span>
<span>Created {new Date(team.createdAt).toLocaleDateString()}</span>
</div>
</article>
))}
</div>
)}
</div>
</main>
Cancel
</Button>
<Button type="submit" disabled={isSavingDetails}>
{isSavingDetails ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open && !isDeleting) {
setDeleteTarget(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Team</AlertDialogTitle>
<AlertDialogDescription>
Delete {deleteTarget?.name}? Team members will be removed from this team assignment.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isDeleting}
onClick={() => {
void confirmDelete();
}}
>
{isDeleting ? "Deleting..." : "Delete Team"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,352 @@
import type { ReactElement, ReactNode } from "react";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { render, screen, waitFor, within } 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";
vi.mock("next/link", () => ({
default: function LinkMock({
children,
href,
}: {
children: ReactNode;
href: string;
}): ReactElement {
return <a href={href}>{children}</a>;
},
}));
vi.mock("@/lib/api/admin", () => ({
fetchAdminUsers: vi.fn(),
inviteUser: vi.fn(),
updateUser: vi.fn(),
deactivateUser: vi.fn(),
}));
vi.mock("@/lib/api/workspaces", () => ({
fetchUserWorkspaces: vi.fn(),
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,
},
};
}
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()),
};
}
describe("UsersSettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
const adminUsersResponse = makeAdminUsersResponse();
fetchAdminUsersMock.mockResolvedValue(adminUsersResponse);
fetchUserWorkspacesMock.mockResolvedValue([
{
id: "workspace-1",
name: "Personal Workspace",
ownerId: "owner-1",
role: WorkspaceMemberRole.OWNER,
createdAt: "2026-01-01T00:00:00.000Z",
},
]);
inviteUserMock.mockResolvedValue({
userId: "user-2",
invitationToken: "token-1",
email: "new@example.com",
invitedAt: "2026-01-02T00:00:00.000Z",
});
const firstUser = adminUsersResponse.data[0] ?? makeAdminUser();
updateUserMock.mockResolvedValue(firstUser);
deactivateUserMock.mockResolvedValue(firstUser);
updateWorkspaceMemberRoleMock.mockResolvedValue({
workspaceId: "workspace-1",
userId: "user-1",
role: WorkspaceMemberRole.ADMIN,
joinedAt: "2026-01-01T00:00:00.000Z",
user: {
id: "user-1",
email: "alice@example.com",
name: "Alice",
image: null,
},
});
useAuthMock.mockReturnValue(makeAuthState("user-current"));
});
it("shows access denied to non-admin users", async () => {
fetchUserWorkspacesMock.mockResolvedValueOnce([
{
id: "workspace-1",
name: "Personal Workspace",
ownerId: "owner-1",
role: WorkspaceMemberRole.MEMBER,
createdAt: "2026-01-01T00:00:00.000Z",
},
]);
render(<UsersSettingsPage />);
expect(await screen.findByText("Access Denied")).toBeInTheDocument();
expect(fetchAdminUsersMock).not.toHaveBeenCalled();
});
it("invites a user with email and role from the dialog", async () => {
const user = userEvent.setup();
render(<UsersSettingsPage />);
expect(await screen.findByText("User Directory")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Invite User" }));
await user.type(screen.getByLabelText("Email"), "new@example.com");
await user.click(screen.getByRole("button", { name: "Send Invite" }));
await waitFor(() => {
expect(inviteUserMock).toHaveBeenCalledWith({
email: "new@example.com",
role: WorkspaceMemberRole.MEMBER,
workspaceId: "workspace-1",
});
});
});
it("opens user detail dialog from row click and saves edited profile fields", async () => {
const user = userEvent.setup();
render(<UsersSettingsPage />);
expect(await screen.findByText("alice@example.com")).toBeInTheDocument();
await user.click(screen.getByText("Alice"));
const nameInput = await screen.findByLabelText("Name");
await user.clear(nameInput);
await user.type(nameInput, "Alice Updated");
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(updateUserMock).toHaveBeenCalledWith("user-1", { name: "Alice Updated" });
});
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();
});
});

View File

@@ -5,12 +5,14 @@ import {
useEffect,
useState,
type ChangeEvent,
type KeyboardEvent,
type ReactElement,
type SyntheticEvent,
} from "react";
import Link from "next/link";
import { Pencil, UserPlus, UserX } from "lucide-react";
import { UserPlus, UserX } from "lucide-react";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { isValidEmail } from "@/components/workspace/validation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -42,7 +44,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
import {
deactivateUser,
fetchAdminUsers,
@@ -50,9 +51,13 @@ import {
updateUser,
type AdminUser,
type AdminUsersResponse,
type AdminWorkspaceMembership,
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";
const ROLE_PRIORITY: Record<WorkspaceMemberRole, number> = {
[WorkspaceMemberRole.OWNER]: 4,
@@ -63,74 +68,99 @@ const ROLE_PRIORITY: Record<WorkspaceMemberRole, number> = {
const INITIAL_INVITE_FORM = {
email: "",
name: "",
workspaceId: "",
role: WorkspaceMemberRole.MEMBER,
};
const INITIAL_DETAIL_FORM = {
name: "",
email: "",
role: WorkspaceMemberRole.MEMBER,
workspaceId: null as string | null,
workspaceName: null as string | null,
};
const USERS_PAGE_SIZE = 50;
interface DetailInitialState {
name: string;
email: string;
role: WorkspaceMemberRole;
workspaceId: string | null;
}
function toRoleLabel(role: WorkspaceMemberRole): string {
return `${role.charAt(0)}${role.slice(1).toLowerCase()}`;
}
function getPrimaryRole(user: AdminUser): WorkspaceMemberRole | null {
function getPrimaryMembership(user: AdminUser): AdminWorkspaceMembership | null {
const [firstMembership, ...restMemberships] = user.workspaceMemberships;
if (!firstMembership) {
return null;
}
return restMemberships.reduce((highest, membership) => {
if (ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highest]) {
return membership.role;
if (ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highest.role]) {
return membership;
}
return highest;
}, firstMembership.role);
}, firstMembership);
}
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);
const [defaultWorkspaceId, setDefaultWorkspaceId] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [isInviteOpen, setIsInviteOpen] = useState<boolean>(false);
const [inviteForm, setInviteForm] = useState(INITIAL_INVITE_FORM);
const [inviteError, setInviteError] = useState<string | null>(null);
const [isInviting, setIsInviting] = useState<boolean>(false);
const [detailTarget, setDetailTarget] = useState<AdminUser | null>(null);
const [detailForm, setDetailForm] = useState(INITIAL_DETAIL_FORM);
const [detailInitial, setDetailInitial] = useState<DetailInitialState | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [isSavingDetails, setIsSavingDetails] = useState<boolean>(false);
const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null);
const [isDeactivating, setIsDeactivating] = useState<boolean>(false);
const [editTarget, setEditTarget] = useState<AdminUser | null>(null);
const [editName, setEditName] = useState<string>("");
const [editError, setEditError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState<boolean>(false);
const loadUsers = useCallback(
async (showLoadingState: boolean): Promise<void> => {
try {
if (showLoadingState) {
setIsLoading(true);
} else {
setIsRefreshing(true);
}
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const response = await fetchAdminUsers(page, USERS_PAGE_SIZE);
const lastValidPage = Math.max(1, response.meta.totalPages);
const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => {
try {
if (showLoadingState) {
setIsLoading(true);
} else {
setIsRefreshing(true);
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 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(() => {
void loadUsers(true);
}, [loadUsers]);
},
[page]
);
useEffect(() => {
fetchUserWorkspaces()
@@ -139,27 +169,67 @@ export default function UsersSettingsPage(): ReactElement {
WorkspaceMemberRole.OWNER,
WorkspaceMemberRole.ADMIN,
];
setIsAdmin(workspaces.some((ws) => adminRoles.includes(ws.role)));
setDefaultWorkspaceId(workspaces[0]?.id ?? null);
setIsAdmin(workspaces.some((workspace) => adminRoles.includes(workspace.role)));
})
.catch(() => {
setDefaultWorkspaceId(null);
setIsAdmin(true); // fail open
});
}, []);
useEffect(() => {
if (isAdmin !== true) {
return;
}
void loadUsers(true);
}, [isAdmin, loadUsers, page]);
function resetInviteForm(): void {
setInviteForm(INITIAL_INVITE_FORM);
setInviteError(null);
}
function handleInviteOpenChange(open: boolean): void {
if (!open && !isInviting) {
resetInviteForm();
}
setIsInviteOpen(open);
function openUserDetails(user: AdminUser): void {
const primaryMembership = getPrimaryMembership(user);
const nextDetailForm = {
name: user.name,
email: user.email,
role: primaryMembership?.role ?? WorkspaceMemberRole.MEMBER,
workspaceId: primaryMembership?.workspaceId ?? null,
workspaceName: primaryMembership?.workspaceName ?? null,
};
setDetailTarget(user);
setDetailForm(nextDetailForm);
setDetailInitial({
name: nextDetailForm.name,
email: nextDetailForm.email,
role: nextDetailForm.role,
workspaceId: nextDetailForm.workspaceId,
});
setDetailError(null);
}
async function handleInviteSubmit(e: SyntheticEvent): Promise<void> {
e.preventDefault();
function resetUserDetails(): void {
setDetailTarget(null);
setDetailForm(INITIAL_DETAIL_FORM);
setDetailInitial(null);
setDetailError(null);
}
function handleUserRowKeyDown(event: KeyboardEvent<HTMLDivElement>, user: AdminUser): void {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openUserDetails(user);
}
}
async function handleInviteSubmit(event: SyntheticEvent): Promise<void> {
event.preventDefault();
setInviteError(null);
const email = inviteForm.email.trim();
@@ -168,17 +238,18 @@ export default function UsersSettingsPage(): ReactElement {
return;
}
const dto: InviteUserDto = { email };
const name = inviteForm.name.trim();
if (name) {
dto.name = name;
if (!isValidEmail(email)) {
setInviteError("Please enter a valid email address.");
return;
}
const workspaceId = inviteForm.workspaceId.trim();
if (workspaceId) {
dto.workspaceId = workspaceId;
dto.role = inviteForm.role;
const dto: InviteUserDto = {
email,
role: inviteForm.role,
};
if (defaultWorkspaceId) {
dto.workspaceId = defaultWorkspaceId;
}
try {
@@ -194,11 +265,86 @@ export default function UsersSettingsPage(): ReactElement {
}
}
async function handleDetailSubmit(event: SyntheticEvent): Promise<void> {
event.preventDefault();
if (detailTarget === null || detailInitial === null) {
return;
}
const name = detailForm.name.trim();
const email = detailForm.email.trim();
if (!name) {
setDetailError("Name is required.");
return;
}
if (!email) {
setDetailError("Email is required.");
return;
}
if (!isValidEmail(email)) {
setDetailError("Please enter a valid email address.");
return;
}
const didUpdateUser = name !== detailInitial.name || email !== detailInitial.email;
const didUpdateRole =
detailForm.workspaceId !== null &&
detailForm.workspaceId === detailInitial.workspaceId &&
detailForm.role !== detailInitial.role;
if (!didUpdateUser && !didUpdateRole) {
resetUserDetails();
return;
}
try {
setIsSavingDetails(true);
setDetailError(null);
if (didUpdateUser) {
const dto: UpdateUserDto = {};
if (name !== detailInitial.name) {
dto.name = name;
}
if (email !== detailInitial.email) {
dto.email = email;
}
await updateUser(detailTarget.id, dto);
}
if (didUpdateRole && detailForm.workspaceId !== null) {
await updateWorkspaceMemberRole(detailForm.workspaceId, detailTarget.id, {
role: detailForm.role,
});
}
resetUserDetails();
await loadUsers(false);
} catch (err: unknown) {
setDetailError(err instanceof Error ? err.message : "Failed to update user");
} finally {
setIsSavingDetails(false);
}
}
async function confirmDeactivate(): Promise<void> {
if (!deactivateTarget) {
return;
}
if (authUser?.id === deactivateTarget.id) {
setDeactivateTarget(null);
setError("You cannot deactivate your own account.");
return;
}
try {
setIsDeactivating(true);
await deactivateUser(deactivateTarget.id);
@@ -212,32 +358,18 @@ export default function UsersSettingsPage(): ReactElement {
}
}
async function handleEditSubmit(): Promise<void> {
if (editTarget === null) return;
setIsEditing(true);
setEditError(null);
try {
const dto: UpdateUserDto = {};
if (editName.trim()) dto.name = editName.trim();
await updateUser(editTarget.id, dto);
setEditTarget(null);
await loadUsers(false);
} catch (err: unknown) {
setEditError(err instanceof Error ? err.message : "Failed to update user");
} finally {
setIsEditing(false);
}
if (isAdmin === null) {
return (
<Card className="max-w-2xl mx-auto mt-8">
<CardContent className="py-12 text-center text-muted-foreground">
Checking permissions...
</CardContent>
</Card>
);
}
if (isAdmin === false) {
return (
<div className="p-8 max-w-2xl">
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
<p className="text-lg font-semibold text-red-700">Access Denied</p>
<p className="mt-2 text-sm text-red-600">You need Admin or Owner role to manage users.</p>
</div>
</div>
);
if (!isAdmin) {
return <SettingsAccessDenied message="You need Admin or Owner role to manage users." />;
}
return (
@@ -262,7 +394,15 @@ export default function UsersSettingsPage(): ReactElement {
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
<Dialog open={isInviteOpen} onOpenChange={handleInviteOpenChange}>
<Dialog
open={isInviteOpen}
onOpenChange={(open) => {
if (!open && !isInviting) {
resetInviteForm();
}
setIsInviteOpen(open);
}}
>
<DialogTrigger asChild>
<Button>
<UserPlus className="h-4 w-4 mr-2" />
@@ -273,13 +413,13 @@ export default function UsersSettingsPage(): ReactElement {
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
<DialogDescription>
Create an invited account and optionally assign workspace access.
Invite a new user and assign their role for your default workspace.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
void handleInviteSubmit(e);
onSubmit={(event) => {
void handleInviteSubmit(event);
}}
className="space-y-4"
>
@@ -289,8 +429,8 @@ export default function UsersSettingsPage(): ReactElement {
id="invite-email"
type="email"
value={inviteForm.email}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, email: e.target.value }));
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, email: event.target.value }));
}}
placeholder="user@example.com"
maxLength={255}
@@ -298,33 +438,6 @@ export default function UsersSettingsPage(): ReactElement {
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-name">Name (optional)</Label>
<Input
id="invite-name"
type="text"
value={inviteForm.name}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, name: e.target.value }));
}}
placeholder="Jane Doe"
maxLength={255}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-workspace-id">Workspace ID (optional)</Label>
<Input
id="invite-workspace-id"
type="text"
value={inviteForm.workspaceId}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, workspaceId: e.target.value }));
}}
placeholder="UUID workspace id"
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-role">Role</Label>
<Select
@@ -344,9 +457,13 @@ export default function UsersSettingsPage(): ReactElement {
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Role is only applied when workspace ID is provided.
</p>
{defaultWorkspaceId ? (
<p className="text-xs text-muted-foreground">Role will be applied on invite.</p>
) : (
<p className="text-xs text-muted-foreground">
No default workspace found. User will be invited without workspace assignment.
</p>
)}
</div>
{inviteError ? (
@@ -360,7 +477,10 @@ export default function UsersSettingsPage(): ReactElement {
type="button"
variant="outline"
onClick={() => {
handleInviteOpenChange(false);
if (!isInviting) {
setIsInviteOpen(false);
resetInviteForm();
}
}}
disabled={isInviting}
>
@@ -382,7 +502,13 @@ export default function UsersSettingsPage(): ReactElement {
</Link>
</div>
{error ? (
{isLoading ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Loading users...
</CardContent>
</Card>
) : error ? (
<Card>
<CardContent className="py-4">
<p className="text-sm text-destructive" role="alert">
@@ -390,14 +516,6 @@ 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>
@@ -409,47 +527,52 @@ export default function UsersSettingsPage(): ReactElement {
<Card>
<CardHeader>
<CardTitle>User Directory</CardTitle>
<CardDescription>Name, email, role, and account status.</CardDescription>
<CardDescription>Click a user to view details or edit profile fields.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{users.map((user) => {
const primaryRole = getPrimaryRole(user);
const primaryMembership = getPrimaryMembership(user);
const isActive = user.deactivatedAt === null;
const isCurrentUser = authUser?.id === user.id;
return (
<div
key={user.id}
className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between cursor-pointer hover:bg-muted/30"
role="button"
tabIndex={0}
onClick={() => {
openUserDetails(user);
}}
onKeyDown={(event) => {
handleUserRowKeyDown(event, user);
}}
>
<div className="space-y-1 min-w-0">
<p className="font-semibold truncate">{user.name || "Unnamed User"}</p>
<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="text-sm text-muted-foreground truncate">{user.email}</p>
</div>
<div className="flex items-center gap-2 flex-wrap md:justify-end">
<Badge variant="outline">
{primaryRole ? toRoleLabel(primaryRole) : "No role"}
{primaryMembership ? toRoleLabel(primaryMembership.role) : "No role"}
</Badge>
<Badge variant={isActive ? "secondary" : "destructive"}>
{isActive ? "Active" : "Inactive"}
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditTarget(user);
setEditName(user.name);
setEditError(null);
}}
>
<Pencil className="h-4 w-4 mr-2" />
Edit Role
</Button>
{isActive ? (
{isActive && !isCurrentUser ? (
<Button
variant="destructive"
size="sm"
onClick={() => {
onClick={(event) => {
event.stopPropagation();
setDeactivateTarget(user);
}}
>
@@ -461,10 +584,151 @@ 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>
)}
<Dialog
open={detailTarget !== null}
onOpenChange={(open) => {
if (!open && !isSavingDetails) {
resetUserDetails();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>User Details</DialogTitle>
<DialogDescription>
Edit profile details for {detailTarget?.email ?? "selected user"}.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
void handleDetailSubmit(event);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="detail-name">Name</Label>
<Input
id="detail-name"
value={detailForm.name}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setDetailForm((prev) => ({ ...prev, name: event.target.value }));
}}
placeholder="Full name"
maxLength={255}
disabled={isSavingDetails}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="detail-email">Email</Label>
<Input
id="detail-email"
type="email"
value={detailForm.email}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setDetailForm((prev) => ({ ...prev, email: event.target.value }));
}}
placeholder="user@example.com"
maxLength={255}
disabled={isSavingDetails}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="detail-role">Role</Label>
<Select
value={detailForm.role}
disabled={detailForm.workspaceId === null || isSavingDetails}
onValueChange={(value) => {
setDetailForm((prev) => ({ ...prev, role: value as WorkspaceMemberRole }));
}}
>
<SelectTrigger id="detail-role">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{Object.values(WorkspaceMemberRole).map((role) => (
<SelectItem key={role} value={role}>
{toRoleLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
{detailForm.workspaceName ? (
<p className="text-xs text-muted-foreground">
Role updates apply to: {detailForm.workspaceName}
</p>
) : (
<p className="text-xs text-muted-foreground">
This user has no workspace membership. Role cannot be updated.
</p>
)}
</div>
{detailError !== null ? (
<p className="text-sm text-destructive" role="alert">
{detailError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
if (!isSavingDetails) {
resetUserDetails();
}
}}
disabled={isSavingDetails}
>
Cancel
</Button>
<Button type="submit" disabled={isSavingDetails}>
{isSavingDetails ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={deactivateTarget !== null}
onOpenChange={(open) => {
@@ -496,55 +760,4 @@ export default function UsersSettingsPage(): ReactElement {
</AlertDialog>
</div>
);
<Dialog
open={editTarget !== null}
onOpenChange={(open) => {
if (!open && !isEditing) {
setEditTarget(null);
setEditError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User Role</DialogTitle>
<DialogDescription>Change role for {editTarget?.email ?? "user"}.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{editError !== null ? <p className="text-sm text-destructive">{editError}</p> : null}
<div className="space-y-2">
<Label htmlFor="edit-name">Display Name</Label>
<Input
id="edit-name"
value={editName}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setEditName(e.target.value);
}}
placeholder="Full name"
disabled={isEditing}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setEditTarget(null);
}}
disabled={isEditing}
>
Cancel
</Button>
<Button
onClick={() => {
void handleEditSubmit();
}}
disabled={isEditing}
>
{isEditing ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
}

View File

@@ -1,12 +1,12 @@
import type { UserWorkspace } from "@/lib/api/workspaces";
import type { UserWorkspace, WorkspaceMemberEntry } from "@/lib/api/workspaces";
import type { ReactElement, ReactNode } from "react";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createWorkspace, fetchUserWorkspaces } from "@/lib/api/workspaces";
import { createWorkspace, fetchUserWorkspaces, fetchWorkspaceMembers } from "@/lib/api/workspaces";
import WorkspacesPage from "./page";
vi.mock("next/link", () => ({
@@ -21,33 +21,23 @@ vi.mock("next/link", () => ({
},
}));
vi.mock("@/components/workspace/WorkspaceCard", () => ({
WorkspaceCard: function WorkspaceCardMock({
workspace,
userRole,
memberCount,
}: {
workspace: { name: string };
userRole: WorkspaceMemberRole;
memberCount: number;
}): ReactElement {
return (
<div data-testid="workspace-card">
{workspace.name} | {userRole} | {String(memberCount)}
</div>
);
},
}));
vi.mock("@/lib/api/workspaces", () => ({
fetchUserWorkspaces: vi.fn(),
createWorkspace: vi.fn(),
fetchWorkspaceMembers: vi.fn(),
addWorkspaceMember: vi.fn(),
removeWorkspaceMember: vi.fn(),
}));
vi.mock("@/lib/api/admin", () => ({
fetchAdminUsers: vi.fn(),
}));
const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
const createWorkspaceMock = vi.mocked(createWorkspace);
const fetchWorkspaceMembersMock = vi.mocked(fetchWorkspaceMembers);
const baseWorkspace: UserWorkspace = {
const workspaceA: UserWorkspace = {
id: "workspace-1",
name: "Personal Workspace",
ownerId: "owner-1",
@@ -55,45 +45,93 @@ const baseWorkspace: UserWorkspace = {
createdAt: "2026-01-01T00:00:00.000Z",
};
const workspaceB: UserWorkspace = {
id: "workspace-2",
name: "Client Workspace",
ownerId: "owner-2",
role: WorkspaceMemberRole.ADMIN,
createdAt: "2026-01-02T00:00:00.000Z",
};
const membersA: WorkspaceMemberEntry[] = [
{
workspaceId: "workspace-1",
userId: "user-a",
role: WorkspaceMemberRole.OWNER,
joinedAt: "2026-01-03T00:00:00.000Z",
user: {
id: "user-a",
email: "alice@example.com",
name: "Alice",
image: null,
},
},
];
const membersB: WorkspaceMemberEntry[] = [
{
workspaceId: "workspace-2",
userId: "user-b",
role: WorkspaceMemberRole.MEMBER,
joinedAt: "2026-01-04T00:00:00.000Z",
user: {
id: "user-b",
email: "bob@example.com",
name: "Bob",
image: null,
},
},
];
describe("WorkspacesPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("loads and renders user workspaces from the API", async () => {
fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]);
it("loads workspaces and fetches members for the first workspace", async () => {
fetchUserWorkspacesMock.mockResolvedValue([workspaceA, workspaceB]);
fetchWorkspaceMembersMock.mockResolvedValue(membersA);
render(<WorkspacesPage />);
expect(screen.getByText("Loading workspaces...")).toBeInTheDocument();
expect(await screen.findByText("Your Workspaces (2)")).toBeInTheDocument();
expect(await screen.findByText("Personal Workspace Members")).toBeInTheDocument();
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument();
expect(screen.getByTestId("workspace-card")).toHaveTextContent("Personal Workspace");
expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(fetchWorkspaceMembersMock).toHaveBeenCalledWith("workspace-1");
});
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
});
it("shows fetch errors in the UI", async () => {
fetchUserWorkspacesMock.mockRejectedValue(new Error("Unable to load workspaces"));
it("switches selected workspace and reloads member list", async () => {
fetchUserWorkspacesMock.mockResolvedValue([workspaceA, workspaceB]);
fetchWorkspaceMembersMock.mockResolvedValueOnce(membersA).mockResolvedValueOnce(membersB);
const user = userEvent.setup();
render(<WorkspacesPage />);
expect(await screen.findByText("Unable to load workspaces")).toBeInTheDocument();
expect(await screen.findByText("Personal Workspace Members")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /client workspace/i }));
await waitFor(() => {
expect(fetchWorkspaceMembersMock).toHaveBeenLastCalledWith("workspace-2");
});
expect(await screen.findByText("Client Workspace Members")).toBeInTheDocument();
expect(screen.getByText("bob@example.com")).toBeInTheDocument();
});
it("creates a workspace and refreshes the list", async () => {
fetchUserWorkspacesMock.mockResolvedValueOnce([baseWorkspace]).mockResolvedValueOnce([
baseWorkspace,
{
...baseWorkspace,
id: "workspace-2",
name: "New Workspace",
role: WorkspaceMemberRole.MEMBER,
},
]);
fetchUserWorkspacesMock
.mockResolvedValueOnce([workspaceA])
.mockResolvedValueOnce([workspaceA, workspaceB]);
fetchWorkspaceMembersMock.mockResolvedValue(membersA);
createWorkspaceMock.mockResolvedValue({
id: "workspace-2",
name: "New Workspace",
ownerId: "owner-1",
name: "Client Workspace",
ownerId: "owner-2",
settings: {},
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
@@ -105,31 +143,17 @@ describe("WorkspacesPage", () => {
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Enter workspace name..."), "New Workspace");
await user.type(screen.getByPlaceholderText("Enter workspace name..."), "Client Workspace");
await user.click(screen.getByRole("button", { name: "Create Workspace" }));
await waitFor(() => {
expect(createWorkspaceMock).toHaveBeenCalledWith({ name: "New Workspace" });
expect(createWorkspaceMock).toHaveBeenCalledWith({ name: "Client Workspace" });
});
await waitFor(() => {
expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(2);
});
expect(await screen.findByText("Your Workspaces (2)")).toBeInTheDocument();
});
it("shows create errors in the UI", async () => {
fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]);
createWorkspaceMock.mockRejectedValue(new Error("Workspace creation failed"));
const user = userEvent.setup();
render(<WorkspacesPage />);
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Enter workspace name..."), "Bad Workspace");
await user.click(screen.getByRole("button", { name: "Create Workspace" }));
expect(await screen.findByText("Workspace creation failed")).toBeInTheDocument();
});
});

View File

@@ -2,10 +2,51 @@
import type { ReactElement, SyntheticEvent } from "react";
import { useCallback, useEffect, useState } from "react";
import { WorkspaceCard } from "@/components/workspace/WorkspaceCard";
import { createWorkspace, fetchUserWorkspaces, type UserWorkspace } from "@/lib/api/workspaces";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { UserPlus, UserX } from "lucide-react";
import { WorkspaceMemberRole } from "@mosaic/shared";
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,
DialogTrigger,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
addWorkspaceMember,
createWorkspace,
fetchUserWorkspaces,
fetchWorkspaceMembers,
removeWorkspaceMember,
type UserWorkspace,
type WorkspaceMemberEntry,
} from "@/lib/api/workspaces";
import { fetchAdminUsers, type AdminUser } from "@/lib/api/admin";
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error) {
@@ -15,18 +56,53 @@ function getErrorMessage(error: unknown, fallback: string): string {
return fallback;
}
/**
* Workspaces Page
* Fetches and creates workspaces through the real API.
*/
function toRoleLabel(role: WorkspaceMemberRole): string {
return `${role.charAt(0)}${role.slice(1).toLowerCase()}`;
}
interface RemoveMemberTarget {
userId: string;
email: string;
}
const ROLE_BADGE_CLASS: Record<WorkspaceMemberRole, string> = {
[WorkspaceMemberRole.OWNER]: "border-purple-200 bg-purple-50 text-purple-700",
[WorkspaceMemberRole.ADMIN]: "border-blue-200 bg-blue-50 text-blue-700",
[WorkspaceMemberRole.MEMBER]: "border-green-200 bg-green-50 text-green-700",
[WorkspaceMemberRole.GUEST]: "border-gray-200 bg-gray-50 text-gray-700",
};
export default function WorkspacesPage(): ReactElement {
const [workspaces, setWorkspaces] = useState<UserWorkspace[]>([]);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [newWorkspaceName, setNewWorkspaceName] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
const [members, setMembers] = useState<WorkspaceMemberEntry[]>([]);
const [isMembersLoading, setIsMembersLoading] = useState(false);
const [membersError, setMembersError] = useState<string | null>(null);
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
const [isAddingMember, setIsAddingMember] = useState(false);
const [addMemberError, setAddMemberError] = useState<string | null>(null);
const [memberUserId, setMemberUserId] = useState<string>("");
const [memberRole, setMemberRole] = useState<WorkspaceMemberRole>(WorkspaceMemberRole.MEMBER);
const [availableUsers, setAvailableUsers] = useState<AdminUser[]>([]);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [removeTarget, setRemoveTarget] = useState<RemoveMemberTarget | null>(null);
const [isRemovingMember, setIsRemovingMember] = useState(false);
const selectedWorkspace = useMemo(
() => workspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? null,
[selectedWorkspaceId, workspaces]
);
const loadWorkspaces = useCallback(async (): Promise<void> => {
setIsLoading(true);
@@ -34,31 +110,57 @@ export default function WorkspacesPage(): ReactElement {
const data = await fetchUserWorkspaces();
setWorkspaces(data);
setLoadError(null);
setSelectedWorkspaceId((current) => {
if (current && data.some((workspace) => workspace.id === current)) {
return current;
}
return data[0]?.id ?? null;
});
} catch (error) {
setLoadError(getErrorMessage(error, "Failed to load workspaces"));
setSelectedWorkspaceId(null);
} finally {
setIsLoading(false);
}
}, []);
const loadMembers = useCallback(async (workspaceId: string): Promise<void> => {
setIsMembersLoading(true);
setMembersError(null);
try {
const data = await fetchWorkspaceMembers(workspaceId);
setMembers(data);
} catch (error) {
setMembersError(getErrorMessage(error, "Failed to load workspace members"));
setMembers([]);
} finally {
setIsMembersLoading(false);
}
}, []);
useEffect(() => {
void loadWorkspaces();
}, [loadWorkspaces]);
const workspacesWithRoles = workspaces.map((workspace) => ({
...workspace,
settings: {},
createdAt: new Date(workspace.createdAt),
updatedAt: new Date(workspace.createdAt),
userRole: workspace.role,
memberCount: 1,
}));
useEffect(() => {
if (!selectedWorkspaceId) {
setMembers([]);
setMembersError(null);
return;
}
const handleCreateWorkspace = async (e: SyntheticEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
void loadMembers(selectedWorkspaceId);
}, [loadMembers, selectedWorkspaceId]);
const handleCreateWorkspace = async (event: SyntheticEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
const workspaceName = newWorkspaceName.trim();
if (!workspaceName) return;
if (!workspaceName) {
return;
}
setIsCreating(true);
setCreateError(null);
@@ -74,91 +176,394 @@ export default function WorkspacesPage(): ReactElement {
}
};
const eligibleUsers = useMemo(() => {
const memberIds = new Set(members.map((member) => member.userId));
return availableUsers.filter((user) => !memberIds.has(user.id));
}, [availableUsers, members]);
const loadAvailableUsers = useCallback(async (): Promise<void> => {
setIsLoadingUsers(true);
try {
const response = await fetchAdminUsers(1, 200);
const activeUsers = response.data.filter((user) => user.deactivatedAt === null);
setAvailableUsers(activeUsers);
if (memberUserId && activeUsers.some((user) => user.id === memberUserId)) {
return;
}
const memberIds = new Set(members.map((member) => member.userId));
const firstEligible = activeUsers.find((user) => !memberIds.has(user.id));
setMemberUserId(firstEligible?.id ?? "");
} catch (error) {
setAddMemberError(getErrorMessage(error, "Failed to load users for member assignment"));
setAvailableUsers([]);
setMemberUserId("");
} finally {
setIsLoadingUsers(false);
}
}, [memberUserId, members]);
const openAddMemberDialog = async (): Promise<void> => {
setAddMemberError(null);
setMemberRole(WorkspaceMemberRole.MEMBER);
setIsAddMemberOpen(true);
await loadAvailableUsers();
};
const handleAddMember = async (event: SyntheticEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
if (!selectedWorkspaceId) {
setAddMemberError("Select a workspace before adding members.");
return;
}
if (!memberUserId) {
setAddMemberError("Select a user to add.");
return;
}
setIsAddingMember(true);
setAddMemberError(null);
try {
await addWorkspaceMember(selectedWorkspaceId, {
userId: memberUserId,
role: memberRole,
});
setIsAddMemberOpen(false);
await loadMembers(selectedWorkspaceId);
} catch (error) {
setAddMemberError(getErrorMessage(error, "Failed to add member"));
} finally {
setIsAddingMember(false);
}
};
const handleRemoveMember = async (): Promise<void> => {
if (!selectedWorkspaceId || !removeTarget) {
return;
}
setIsRemovingMember(true);
try {
await removeWorkspaceMember(selectedWorkspaceId, removeTarget.userId);
setRemoveTarget(null);
await loadMembers(selectedWorkspaceId);
} catch (error) {
setMembersError(getErrorMessage(error, "Failed to remove member"));
} finally {
setIsRemovingMember(false);
}
};
return (
<main className="container mx-auto px-4 py-8 max-w-5xl">
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-3xl font-bold text-gray-900">Workspaces</h1>
<Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700">
Back to Settings
</Link>
<main className="max-w-6xl mx-auto p-6 space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Workspaces</h1>
<p className="text-muted-foreground mt-1">Manage workspaces and workspace members</p>
</div>
<p className="text-gray-600">Manage your workspaces and collaborate with your team</p>
<Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700">
Back to Settings
</Link>
</div>
{/* Create New Workspace */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Create New Workspace</h2>
<form onSubmit={handleCreateWorkspace} className="flex gap-3">
<input
type="text"
value={newWorkspaceName}
onChange={(e) => {
setNewWorkspaceName(e.target.value);
}}
placeholder="Enter workspace name..."
disabled={isCreating}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100"
/>
<button
type="submit"
disabled={isCreating || !newWorkspaceName.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{isCreating ? "Creating..." : "Create Workspace"}
</button>
</form>
{createError !== null && (
<div className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{createError}
</div>
)}
<Card>
<CardHeader>
<CardTitle>Create New Workspace</CardTitle>
<CardDescription>Create a workspace for a new team or project.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreateWorkspace} className="flex gap-3">
<Input
type="text"
value={newWorkspaceName}
onChange={(event) => {
setNewWorkspaceName(event.target.value);
}}
placeholder="Enter workspace name..."
disabled={isCreating}
/>
<Button type="submit" disabled={isCreating || !newWorkspaceName.trim()}>
{isCreating ? "Creating..." : "Create Workspace"}
</Button>
</form>
{createError !== null ? (
<p className="mt-3 text-sm text-destructive" role="alert">
{createError}
</p>
) : null}
</CardContent>
</Card>
{loadError !== null ? (
<Card>
<CardContent className="py-4">
<p className="text-sm text-destructive" role="alert">
{loadError}
</p>
</CardContent>
</Card>
) : null}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Your Workspaces ({isLoading ? "..." : workspaces.length})</CardTitle>
<CardDescription>Click a workspace to manage its members.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading workspaces...</p>
) : workspaces.length === 0 ? (
<p className="text-sm text-muted-foreground">
No workspaces yet. Create one to begin.
</p>
) : (
workspaces.map((workspace) => {
const isSelected = selectedWorkspaceId === workspace.id;
return (
<button
key={workspace.id}
type="button"
onClick={() => {
setSelectedWorkspaceId(workspace.id);
}}
className={`w-full rounded-lg border p-4 text-left transition-colors ${
isSelected ? "border-primary bg-muted/40" : "border-border hover:bg-muted/20"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-semibold truncate">{workspace.name}</p>
<p className="text-xs text-muted-foreground mt-1">
Created {new Date(workspace.createdAt).toLocaleDateString()}
</p>
</div>
<Badge variant="outline">{toRoleLabel(workspace.role)}</Badge>
</div>
</button>
);
})
)}
</CardContent>
</Card>
<Card className="lg:col-span-3">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>
{selectedWorkspace ? `${selectedWorkspace.name} Members` : "Workspace Members"}
</CardTitle>
<CardDescription>
{selectedWorkspace
? "Manage member roles and access for this workspace."
: "Select a workspace to view its members."}
</CardDescription>
</div>
<Dialog
open={isAddMemberOpen}
onOpenChange={(open) => {
if (!open && !isAddingMember) {
setIsAddMemberOpen(false);
setAddMemberError(null);
}
}}
>
<DialogTrigger asChild>
<Button
onClick={() => {
void openAddMemberDialog();
}}
disabled={!selectedWorkspace}
>
<UserPlus className="h-4 w-4 mr-2" />
Add Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Workspace Member</DialogTitle>
<DialogDescription>
Add an existing user to {selectedWorkspace?.name ?? "this workspace"}.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
void handleAddMember(event);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="member-user">User</Label>
<Select
value={memberUserId}
onValueChange={(value) => {
setMemberUserId(value);
}}
disabled={isLoadingUsers || eligibleUsers.length === 0 || isAddingMember}
>
<SelectTrigger id="member-user">
<SelectValue
placeholder={
isLoadingUsers
? "Loading users..."
: eligibleUsers.length === 0
? "No eligible users"
: "Select a user"
}
/>
</SelectTrigger>
<SelectContent>
{eligibleUsers.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="member-role">Role</Label>
<Select
value={memberRole}
onValueChange={(value) => {
setMemberRole(value as WorkspaceMemberRole);
}}
disabled={isAddingMember}
>
<SelectTrigger id="member-role">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{Object.values(WorkspaceMemberRole)
.filter((role) => role !== WorkspaceMemberRole.OWNER)
.map((role) => (
<SelectItem key={role} value={role}>
{toRoleLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{addMemberError !== null ? (
<p className="text-sm text-destructive" role="alert">
{addMemberError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
if (!isAddingMember) {
setIsAddMemberOpen(false);
setAddMemberError(null);
}
}}
disabled={isAddingMember}
>
Cancel
</Button>
<Button type="submit" disabled={isAddingMember || !memberUserId}>
{isAddingMember ? "Adding..." : "Add Member"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="space-y-3">
{selectedWorkspace === null ? (
<p className="text-sm text-muted-foreground">Select a workspace to view members.</p>
) : membersError !== null ? (
<p className="text-sm text-destructive" role="alert">
{membersError}
</p>
) : isMembersLoading ? (
<p className="text-sm text-muted-foreground">Loading members...</p>
) : members.length === 0 ? (
<p className="text-sm text-muted-foreground">No members found for this workspace.</p>
) : (
members.map((member) => (
<div
key={member.userId}
className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
>
<div className="space-y-1 min-w-0">
<p className="font-semibold truncate">{member.user.name ?? "Unnamed User"}</p>
<p className="text-sm text-muted-foreground truncate">{member.user.email}</p>
</div>
<div className="flex items-center gap-2 md:justify-end">
<Badge variant="outline" className={ROLE_BADGE_CLASS[member.role]}>
{toRoleLabel(member.role)}
</Badge>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
setRemoveTarget({
userId: member.userId,
email: member.user.email,
});
}}
>
<UserX className="h-4 w-4 mr-2" />
Remove
</Button>
</div>
</div>
))
)}
</CardContent>
</Card>
</div>
{/* Workspace List */}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900">
Your Workspaces ({isLoading ? "..." : workspacesWithRoles.length})
</h2>
{loadError !== null ? (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-red-700">
{loadError}
</div>
) : isLoading ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center text-gray-600">
Loading workspaces...
</div>
) : workspacesWithRoles.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<AlertDialog
open={removeTarget !== null}
onOpenChange={(open) => {
if (!open && !isRemovingMember) {
setRemoveTarget(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Workspace Member</AlertDialogTitle>
<AlertDialogDescription>
Remove {removeTarget?.email} from {selectedWorkspace?.name}? They will lose access to
this workspace.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemovingMember}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isRemovingMember}
onClick={() => {
void handleRemoveMember();
}}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<h3 className="text-lg font-medium text-gray-900 mb-2">No workspaces yet</h3>
<p className="text-gray-600">Create your first workspace to get started</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{workspacesWithRoles.map((workspace) => (
<WorkspaceCard
key={workspace.id}
workspace={workspace}
userRole={workspace.userRole}
memberCount={workspace.memberCount}
/>
))}
</div>
)}
</div>
{isRemovingMember ? "Removing..." : "Remove"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main>
);
}

View File

@@ -0,0 +1,16 @@
import type { ReactElement } from "react";
interface SettingsAccessDeniedProps {
message: string;
}
export function SettingsAccessDenied({ message }: SettingsAccessDeniedProps): ReactElement {
return (
<div className="p-8 max-w-2xl">
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
<p className="text-lg font-semibold text-red-700">Access Denied</p>
<p className="mt-2 text-sm text-red-600">{message}</p>
</div>
</div>
);
}

View File

@@ -53,6 +53,7 @@ export interface InvitationResponse {
export interface UpdateUserDto {
name?: string;
email?: string;
deactivatedAt?: string | null;
emailVerified?: boolean;
preferences?: Record<string, unknown>;

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as client from "./client";
import { fetchTeams, createTeam, fetchTeamMembers } from "./teams";
import { fetchTeams, createTeam, fetchTeamMembers, updateTeam, deleteTeam } from "./teams";
vi.mock("./client");
@@ -44,6 +44,18 @@ describe("createTeam", (): void => {
});
});
describe("updateTeam", (): void => {
it("patches team endpoint", async (): Promise<void> => {
vi.mocked(client.apiPatch).mockResolvedValueOnce({ id: "t1", name: "Platform" } as never);
await updateTeam("t1", { name: "Platform" });
expect(client.apiPatch).toHaveBeenCalledWith(
"/api/workspaces/ws-1/teams/t1",
expect.objectContaining({ name: "Platform" }),
"ws-1"
);
});
});
describe("fetchTeamMembers", (): void => {
it("calls members endpoint for team", async (): Promise<void> => {
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
@@ -51,3 +63,11 @@ describe("fetchTeamMembers", (): void => {
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces/ws-1/teams/t-1/members", "ws-1");
});
});
describe("deleteTeam", (): void => {
it("deletes team endpoint", async (): Promise<void> => {
vi.mocked(client.apiDelete).mockResolvedValueOnce(undefined as never);
await deleteTeam("t1");
expect(client.apiDelete).toHaveBeenCalledWith("/api/workspaces/ws-1/teams/t1", "ws-1");
});
});

View File

@@ -4,7 +4,7 @@
*/
import type { TeamMemberRole } from "@mosaic/shared";
import { apiDelete, apiGet, apiPost } from "./client";
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
const WORKSPACE_STORAGE_KEY = "mosaic-workspace-id";
@@ -55,6 +55,11 @@ export interface CreateTeamDto {
description?: string;
}
export interface UpdateTeamDto {
name?: string;
description?: string | null;
}
export interface AddTeamMemberDto {
userId: string;
role?: TeamMemberRole;
@@ -80,6 +85,22 @@ export async function createTeam(dto: CreateTeamDto, workspaceId?: string): Prom
);
}
/**
* Update a team in the active workspace.
*/
export async function updateTeam(
teamId: string,
dto: UpdateTeamDto,
workspaceId?: string
): Promise<TeamRecord> {
const resolvedWorkspaceId = resolveWorkspaceId(workspaceId);
return apiPatch<TeamRecord>(
`/api/workspaces/${resolvedWorkspaceId}/teams/${teamId}`,
dto,
resolvedWorkspaceId
);
}
/**
* Fetch team members for a team in the active workspace.
* The current backend route shape is workspace-scoped team membership.

49
docker/OPENCLAW-FLEET.md Normal file
View File

@@ -0,0 +1,49 @@
# Mosaic Agent Fleet
Multi-agent deployment for Mosaic Stack using OpenClaw containers on Docker Swarm.
## Architecture
Each agent runs as an isolated OpenClaw Gateway instance with its own:
- **Workspace** — persistent volume for agent files and memory
- **State** — persistent volume for auth tokens and sessions
- **Config** — template rendered at startup from environment variables
Agents communicate with the Mosaic API via the OpenAI-compatible
`/v1/chat/completions` endpoint. The Mosaic WebUI routes chat requests
to agents through the `OpenClawGatewayModule`.
## Default Agent Roles
| Agent | Role | Description |
| ----------------- | ------------ | ------------------------------------------- |
| mosaic-main | Orchestrator | User-facing gateway, routes to other agents |
| mosaic-projects | Developer | Implementation, coding, PRs |
| mosaic-research | Research | Web search, analysis, discovery |
| mosaic-operations | Operations | Monitoring, health checks, alerts |
> **Models and providers are configured per-deployment** via environment
> variables and the Mosaic Settings UI — not hardcoded in these files.
> See the [Setup Guide](openclaw-instances/README.md) for env var reference.
## Prerequisites
- Docker Swarm initialized on target host
- Mosaic Stack running (`mosaic-stack_internal` network available)
- At least one LLM provider API key (Z.ai, OpenAI, Anthropic, etc.)
## Quick Start
1. **Configure** — Fill in `docker/openclaw-instances/*.env` files
2. **Deploy**`docker stack deploy -c docker/openclaw-compose.yml mosaic-agents`
3. **Auth** — If needed, run `openclaw auth` inside a container (or via Mosaic terminal)
4. **Verify**`docker stack services mosaic-agents`
See [openclaw-instances/README.md](openclaw-instances/README.md) for detailed setup.
## Future: Onboarding Wizard
Model assignments, provider configuration, and agent customization will be
managed through the Mosaic WebUI onboarding wizard and Settings pages (MS22-P4).
Until then, use environment variables per the README.

View File

@@ -1,4 +1,4 @@
FROM quay.io/openbao/openbao:2.5.0
FROM quay.io/openbao/openbao:2.5.1
LABEL maintainer="Mosaic Stack <dev@mosaic.local>"
LABEL description="OpenBao secrets management for Mosaic Stack"

150
docker/openclaw-compose.yml Normal file
View File

@@ -0,0 +1,150 @@
# Mosaic Agent Fleet — OpenClaw Docker Swarm Stack
# Deploy: docker stack deploy -c docker/openclaw-compose.yml mosaic-agents
# All config via env vars — see openclaw-instances/*.env
services:
mosaic-main:
image: alpine/openclaw:latest
command: ["/config/entrypoint.sh"]
env_file:
- ./openclaw-instances/mosaic-main.env
environment:
OPENCLAW_CONFIG_PATH: /tmp/openclaw.json
volumes:
- mosaic-main-config:/config:ro
- mosaic-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=mosaic-main
- com.mosaic.role=orchestrator
mosaic-projects:
image: alpine/openclaw:latest
command: ["/config/entrypoint.sh"]
env_file:
- ./openclaw-instances/mosaic-projects.env
environment:
OPENCLAW_CONFIG_PATH: /tmp/openclaw.json
volumes:
- mosaic-projects-config:/config:ro
- mosaic-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: 10s
max_attempts: 3
resources:
limits:
memory: 4G
reservations:
memory: 1G
labels:
- com.mosaic.agent=mosaic-projects
- com.mosaic.role=developer
mosaic-research:
image: alpine/openclaw:latest
command: ["/config/entrypoint.sh"]
env_file:
- ./openclaw-instances/mosaic-research.env
environment:
OPENCLAW_CONFIG_PATH: /tmp/openclaw.json
volumes:
- mosaic-research-config:/config:ro
- mosaic-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: 10s
max_attempts: 3
resources:
limits:
memory: 1G
reservations:
memory: 256M
labels:
- com.mosaic.agent=mosaic-research
- com.mosaic.role=research
mosaic-operations:
image: alpine/openclaw:latest
command: ["/config/entrypoint.sh"]
env_file:
- ./openclaw-instances/mosaic-operations.env
environment:
OPENCLAW_CONFIG_PATH: /tmp/openclaw.json
volumes:
- mosaic-operations-config:/config:ro
- mosaic-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: 10s
max_attempts: 3
resources:
limits:
memory: 1G
reservations:
memory: 256M
labels:
- com.mosaic.agent=mosaic-operations
- com.mosaic.role=operations
networks:
mosaic-stack_internal:
external: true
volumes:
mosaic-main-config:
mosaic-main-state:
mosaic-projects-config:
mosaic-projects-state:
mosaic-research-config:
mosaic-research-state:
mosaic-operations-config:
mosaic-operations-state:

View File

@@ -0,0 +1,97 @@
# Mosaic Agent Fleet — Setup Guide
## Prerequisites
- Docker Swarm initialized on target host
- Mosaic Stack running (Postgres, Valkey on `mosaic-stack_internal` network)
## 1. Configure Environment Variables
Copy and fill in each agent's `.env` file:
```bash
cd docker/openclaw-instances
# Required for each agent:
# ZAI_API_KEY — Your Z.ai API key (or other LLM provider key)
# OPENCLAW_GATEWAY_TOKEN — Unique bearer token per agent
# Generate unique tokens:
for agent in main projects research operations; do
echo "OPENCLAW_GATEWAY_TOKEN=$(openssl rand -hex 32)"
done
```
### Optional: Local Ollama
If you have an Ollama instance, add to any agent's `.env`:
```bash
OLLAMA_BASE_URL=http://your-ollama-host:11434
OLLAMA_MODEL=cogito # or any model you have pulled
```
The entrypoint script will automatically inject the Ollama provider at startup.
### Optional: Override Default Model
```bash
OPENCLAW_MODEL=anthropic/claude-sonnet-4-6
```
## 2. Populate Config Volumes
Each agent needs its `.json.template` file in its config volume:
```bash
# Create config directories and copy templates
for agent in main projects research operations; do
mkdir -p /var/lib/docker/volumes/mosaic-agents_mosaic-${agent}-config/_data/
cp openclaw-instances/mosaic-${agent}.json.template \
/var/lib/docker/volumes/mosaic-agents_mosaic-${agent}-config/_data/openclaw.json.template
cp openclaw-instances/entrypoint.sh \
/var/lib/docker/volumes/mosaic-agents_mosaic-${agent}-config/_data/entrypoint.sh
done
```
## 3. Deploy
```bash
docker stack deploy -c docker/openclaw-compose.yml mosaic-agents
docker stack services mosaic-agents
```
## 4. First-Time Auth (if needed)
For providers requiring OAuth (e.g., Anthropic):
```bash
docker exec -it $(docker ps -q -f name=mosaic-main) openclaw auth
```
Follow the device-code flow in your browser. Tokens persist in the state volume.
You can also use the Mosaic WebUI terminal (xterm.js) for this.
## 5. Verify
```bash
# Check health
curl http://localhost:18789/health
# Test chat completions endpoint
curl http://localhost:18789/v1/chat/completions \
-H "Authorization: Bearer YOUR_GATEWAY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"model":"openclaw:main","messages":[{"role":"user","content":"hello"}]}'
```
## Environment Variable Reference
| Variable | Required | Description |
| ------------------------ | -------- | ------------------------------------------------- |
| `ZAI_API_KEY` | Yes\* | Z.ai API key (\*or other provider key) |
| `OPENCLAW_GATEWAY_TOKEN` | Yes | Bearer token for this agent (unique per instance) |
| `OPENCLAW_MODEL` | No | Override default model (default: `zai/glm-5`) |
| `OLLAMA_BASE_URL` | No | Ollama endpoint (e.g., `http://10.1.1.42:11434`) |
| `OLLAMA_MODEL` | No | Ollama model name (default: `cogito`) |

View File

@@ -0,0 +1,53 @@
#!/bin/sh
# Mosaic Agent Fleet — OpenClaw container entrypoint
# Renders config template from env vars, optionally adds Ollama provider, starts gateway
set -e
TEMPLATE="/config/openclaw.json.template"
CONFIG="/tmp/openclaw.json"
if [ ! -f "$TEMPLATE" ]; then
echo "ERROR: Config template not found at $TEMPLATE"
echo "Mount your config volume at /config with a .json.template file"
exit 1
fi
# Validate required env vars
: "${OPENCLAW_GATEWAY_TOKEN:?OPENCLAW_GATEWAY_TOKEN is required (generate: openssl rand -hex 32)}"
# Render template with env var substitution
envsubst < "$TEMPLATE" > "$CONFIG"
# If OLLAMA_BASE_URL is set, inject Ollama provider into config
if [ -n "$OLLAMA_BASE_URL" ]; then
# Use python3 if available, fall back to node
if command -v python3 >/dev/null 2>&1; then
python3 -c "
import json, sys
with open('$CONFIG') as f: cfg = json.load(f)
cfg.setdefault('models', {})['mode'] = 'merge'
cfg['models'].setdefault('providers', {})['ollama'] = {
'baseUrl': '$OLLAMA_BASE_URL/v1',
'api': 'openai-completions',
'models': [{'id': '${OLLAMA_MODEL:-cogito}', 'name': '${OLLAMA_MODEL:-cogito} (Local)', 'reasoning': False, 'input': ['text'], 'cost': {'input':0,'output':0,'cacheRead':0,'cacheWrite':0}, 'contextWindow': 128000, 'maxTokens': 8192}]
}
with open('$CONFIG','w') as f: json.dump(cfg, f, indent=2)
"
echo "Ollama provider added: $OLLAMA_BASE_URL (model: ${OLLAMA_MODEL:-cogito})"
elif command -v node >/dev/null 2>&1; then
node -e "
const fs = require('fs');
const cfg = JSON.parse(fs.readFileSync('$CONFIG','utf8'));
cfg.models = cfg.models || {}; cfg.models.mode = 'merge';
cfg.models.providers = cfg.models.providers || {};
cfg.models.providers.ollama = {baseUrl:'$OLLAMA_BASE_URL/v1',api:'openai-completions',models:[{id:'${OLLAMA_MODEL:-cogito}',name:'${OLLAMA_MODEL:-cogito} (Local)',reasoning:false,input:['text'],cost:{input:0,output:0,cacheRead:0,cacheWrite:0},contextWindow:128000,maxTokens:8192}]};
fs.writeFileSync('$CONFIG', JSON.stringify(cfg, null, 2));
"
echo "Ollama provider added: $OLLAMA_BASE_URL (model: ${OLLAMA_MODEL:-cogito})"
else
echo "WARNING: OLLAMA_BASE_URL set but no python3/node available to inject provider"
fi
fi
export OPENCLAW_CONFIG_PATH="$CONFIG"
exec openclaw gateway run --bind lan --auth token "$@"

View File

@@ -0,0 +1,14 @@
# Mosaic Agent: main
# Fill in all values before deploying.
# Required: LLM provider API key (Z.ai, OpenAI, etc.)
ZAI_API_KEY=
# Required: unique bearer token for this agent instance (generate: openssl rand -hex 32)
OPENCLAW_GATEWAY_TOKEN=
# Optional: override default model (default: zai/glm-5)
# OPENCLAW_MODEL=zai/glm-5
# Optional: Ollama endpoint for local inference (uncomment to enable)
# OLLAMA_BASE_URL=

View File

@@ -0,0 +1,19 @@
{
"gateway": {
"mode": "local",
"port": 18789,
"bind": "lan",
"auth": { "mode": "token" },
"http": {
"endpoints": {
"chatCompletions": { "enabled": true }
}
}
},
"agents": {
"defaults": {
"workspace": "/home/node/workspace",
"model": { "primary": "${OPENCLAW_MODEL:-zai/glm-5}" }
}
}
}

View File

@@ -0,0 +1,14 @@
# Mosaic Agent: operations
# Fill in all values before deploying.
# Required: LLM provider API key (Z.ai, OpenAI, etc.)
ZAI_API_KEY=
# Required: unique bearer token for this agent instance (generate: openssl rand -hex 32)
OPENCLAW_GATEWAY_TOKEN=
# Optional: override default model (default: zai/glm-5)
# OPENCLAW_MODEL=zai/glm-5
# Optional: Ollama endpoint for local inference (uncomment to enable)
# OLLAMA_BASE_URL=

View File

@@ -0,0 +1,19 @@
{
"gateway": {
"mode": "local",
"port": 18789,
"bind": "lan",
"auth": { "mode": "token" },
"http": {
"endpoints": {
"chatCompletions": { "enabled": true }
}
}
},
"agents": {
"defaults": {
"workspace": "/home/node/workspace",
"model": { "primary": "${OPENCLAW_MODEL:-zai/glm-5}" }
}
}
}

View File

@@ -0,0 +1,14 @@
# Mosaic Agent: projects
# Fill in all values before deploying.
# Required: LLM provider API key (Z.ai, OpenAI, etc.)
ZAI_API_KEY=
# Required: unique bearer token for this agent instance (generate: openssl rand -hex 32)
OPENCLAW_GATEWAY_TOKEN=
# Optional: override default model (default: zai/glm-5)
# OPENCLAW_MODEL=zai/glm-5
# Optional: Ollama endpoint for local inference (uncomment to enable)
# OLLAMA_BASE_URL=

View File

@@ -0,0 +1,19 @@
{
"gateway": {
"mode": "local",
"port": 18789,
"bind": "lan",
"auth": { "mode": "token" },
"http": {
"endpoints": {
"chatCompletions": { "enabled": true }
}
}
},
"agents": {
"defaults": {
"workspace": "/home/node/workspace",
"model": { "primary": "${OPENCLAW_MODEL:-zai/glm-5}" }
}
}
}

View File

@@ -0,0 +1,14 @@
# Mosaic Agent: research
# Fill in all values before deploying.
# Required: LLM provider API key (Z.ai, OpenAI, etc.)
ZAI_API_KEY=
# Required: unique bearer token for this agent instance (generate: openssl rand -hex 32)
OPENCLAW_GATEWAY_TOKEN=
# Optional: override default model (default: zai/glm-5)
# OPENCLAW_MODEL=zai/glm-5
# Optional: Ollama endpoint for local inference (uncomment to enable)
# OLLAMA_BASE_URL=

View File

@@ -0,0 +1,19 @@
{
"gateway": {
"mode": "local",
"port": 18789,
"bind": "lan",
"auth": { "mode": "token" },
"http": {
"endpoints": {
"chatCompletions": { "enabled": true }
}
}
},
"agents": {
"defaults": {
"workspace": "/home/node/workspace",
"model": { "primary": "${OPENCLAW_MODEL:-zai/glm-5}" }
}
}
}

View File

@@ -25,14 +25,14 @@
| 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 | not-started | 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 | not-started | phase-4 | User detail/edit and invite dialogs | #569 | web | feat/ms21-ui-users | MS21-UI-001 | — | — | — | — | 15K | — | |
| 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 | not-started | phase-4 | Workspace member management UI | #569 | web | feat/ms21-ui-workspaces | MS21-UI-003,MS21-API-003 | — | — | — | — | 15K | — | Components exist |
| MS21-UI-005 | not-started | phase-4 | Settings/teams page | #569 | web | feat/ms21-ui-teams | MS21-API-004 | — | — | — | — | 15K | — | |
| MS21-TEST-004 | not-started | 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 | not-started | phase-5 | Sidebar navigation role gating | #570 | web | feat/ms21-rbac | MS21-UI-001 | — | — | — | — | 10K | — | |
| MS21-RBAC-002 | not-started | phase-5 | Settings page access restriction | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
| 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 | 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 | — | |
| MS21-RBAC-004 | done | phase-5 | User profile role display | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 5K | — | |
| MS21-VER-001 | done | phase-6 | Full quality gate pass | #571 | stack | — | MS21-TEST-004,MS21-RBAC-004,MS21-MIG-003 | MS21-VER-002 | — | — | — | 5K | — | |
@@ -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 | 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 | not-started | 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 | — | |
| 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 | — | |