Compare commits

..

5 Commits

Author SHA1 Message Date
e657a6d199 chore(orchestrator): MS21 complete — UI-001-QA (#599) and TEST-004 done 2026-03-01 07:54:58 -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
ac67697fe4 chore(orchestrator): mark MS22 Phase 0 complete — all tasks done 2026-02-28 22:55:18 -06:00
3 changed files with 314 additions and 82 deletions

View File

@@ -1,17 +1,19 @@
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { WorkspaceMemberRole } from "@mosaic/shared"; import { WorkspaceMemberRole } from "@mosaic/shared";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
type AdminUser,
deactivateUser, deactivateUser,
fetchAdminUsers, fetchAdminUsers,
inviteUser, inviteUser,
updateUser, updateUser,
type AdminUsersResponse, type AdminUsersResponse,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces"; import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
import UsersSettingsPage from "./page"; import UsersSettingsPage from "./page";
@@ -39,48 +41,80 @@ vi.mock("@/lib/api/workspaces", () => ({
updateWorkspaceMemberRole: vi.fn(), updateWorkspaceMemberRole: vi.fn(),
})); }));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: vi.fn(),
}));
const fetchAdminUsersMock = vi.mocked(fetchAdminUsers); const fetchAdminUsersMock = vi.mocked(fetchAdminUsers);
const inviteUserMock = vi.mocked(inviteUser); const inviteUserMock = vi.mocked(inviteUser);
const updateUserMock = vi.mocked(updateUser); const updateUserMock = vi.mocked(updateUser);
const deactivateUserMock = vi.mocked(deactivateUser); const deactivateUserMock = vi.mocked(deactivateUser);
const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces); const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
const updateWorkspaceMemberRoleMock = vi.mocked(updateWorkspaceMemberRole); const updateWorkspaceMemberRoleMock = vi.mocked(updateWorkspaceMemberRole);
const useAuthMock = vi.mocked(useAuth);
const adminUsersResponse: AdminUsersResponse = { function makeAdminUser(overrides?: Partial<AdminUser>): AdminUser {
data: [ return {
{ id: "user-1",
id: "user-1", name: "Alice",
name: "Alice", email: "alice@example.com",
email: "alice@example.com", emailVerified: true,
emailVerified: true, image: null,
image: null, createdAt: "2026-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z", deactivatedAt: null,
deactivatedAt: null, isLocalAuth: false,
isLocalAuth: false, invitedAt: null,
invitedAt: null, invitedBy: null,
invitedBy: null, workspaceMemberships: [
workspaceMemberships: [ {
{ workspaceId: "workspace-1",
workspaceId: "workspace-1", workspaceName: "Personal Workspace",
workspaceName: "Personal Workspace", role: WorkspaceMemberRole.ADMIN,
role: WorkspaceMemberRole.ADMIN, joinedAt: "2026-01-01T00:00:00.000Z",
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,
}, },
], };
meta: { }
total: 1,
page: 1, function makeAuthState(userId: string): ReturnType<typeof useAuth> {
limit: 50, return {
totalPages: 1, 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", () => { describe("UsersSettingsPage", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
const adminUsersResponse = makeAdminUsersResponse();
fetchAdminUsersMock.mockResolvedValue(adminUsersResponse); fetchAdminUsersMock.mockResolvedValue(adminUsersResponse);
fetchUserWorkspacesMock.mockResolvedValue([ fetchUserWorkspacesMock.mockResolvedValue([
{ {
@@ -97,10 +131,7 @@ describe("UsersSettingsPage", () => {
email: "new@example.com", email: "new@example.com",
invitedAt: "2026-01-02T00:00:00.000Z", invitedAt: "2026-01-02T00:00:00.000Z",
}); });
const firstUser = adminUsersResponse.data[0]; const firstUser = adminUsersResponse.data[0] ?? makeAdminUser();
if (!firstUser) {
throw new Error("Expected at least one admin user in test fixtures");
}
updateUserMock.mockResolvedValue(firstUser); updateUserMock.mockResolvedValue(firstUser);
deactivateUserMock.mockResolvedValue(firstUser); deactivateUserMock.mockResolvedValue(firstUser);
@@ -116,6 +147,8 @@ describe("UsersSettingsPage", () => {
image: null, image: null,
}, },
}); });
useAuthMock.mockReturnValue(makeAuthState("user-current"));
}); });
it("shows access denied to non-admin users", async () => { it("shows access denied to non-admin users", async () => {
@@ -174,4 +207,146 @@ describe("UsersSettingsPage", () => {
expect(updateWorkspaceMemberRoleMock).not.toHaveBeenCalled(); 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

@@ -55,6 +55,7 @@ import {
type InviteUserDto, type InviteUserDto,
type UpdateUserDto, type UpdateUserDto,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces"; import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied"; import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied";
@@ -77,6 +78,7 @@ const INITIAL_DETAIL_FORM = {
workspaceId: null as string | null, workspaceId: null as string | null,
workspaceName: null as string | null, workspaceName: null as string | null,
}; };
const USERS_PAGE_SIZE = 50;
interface DetailInitialState { interface DetailInitialState {
name: string; name: string;
@@ -104,8 +106,11 @@ function getPrimaryMembership(user: AdminUser): AdminWorkspaceMembership | null
} }
export default function UsersSettingsPage(): ReactElement { export default function UsersSettingsPage(): ReactElement {
const { user: authUser } = useAuth();
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [meta, setMeta] = useState<AdminUsersResponse["meta"] | null>(null); const [meta, setMeta] = useState<AdminUsersResponse["meta"] | null>(null);
const [page, setPage] = useState<number>(1);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false); const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -127,25 +132,35 @@ export default function UsersSettingsPage(): ReactElement {
const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null); const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null);
const [isDeactivating, setIsDeactivating] = useState<boolean>(false); const [isDeactivating, setIsDeactivating] = useState<boolean>(false);
const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => { const loadUsers = useCallback(
try { async (showLoadingState: boolean): Promise<void> => {
if (showLoadingState) { try {
setIsLoading(true); if (showLoadingState) {
} else { setIsLoading(true);
setIsRefreshing(true); } else {
} setIsRefreshing(true);
}
const response = await fetchAdminUsers(1, 50); const response = await fetchAdminUsers(page, USERS_PAGE_SIZE);
setUsers(response.data); const lastValidPage = Math.max(1, response.meta.totalPages);
setMeta(response.meta);
setError(null); if (page > lastValidPage) {
} catch (err: unknown) { setPage(lastValidPage);
setError(err instanceof Error ? err.message : "Failed to load admin users"); return;
} finally { }
setIsLoading(false);
setIsRefreshing(false); 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);
}
},
[page]
);
useEffect(() => { useEffect(() => {
fetchUserWorkspaces() fetchUserWorkspaces()
@@ -170,7 +185,7 @@ export default function UsersSettingsPage(): ReactElement {
} }
void loadUsers(true); void loadUsers(true);
}, [isAdmin, loadUsers]); }, [isAdmin, loadUsers, page]);
function resetInviteForm(): void { function resetInviteForm(): void {
setInviteForm(INITIAL_INVITE_FORM); setInviteForm(INITIAL_INVITE_FORM);
@@ -324,6 +339,12 @@ export default function UsersSettingsPage(): ReactElement {
return; return;
} }
if (authUser?.id === deactivateTarget.id) {
setDeactivateTarget(null);
setError("You cannot deactivate your own account.");
return;
}
try { try {
setIsDeactivating(true); setIsDeactivating(true);
await deactivateUser(deactivateTarget.id); await deactivateUser(deactivateTarget.id);
@@ -481,7 +502,13 @@ export default function UsersSettingsPage(): ReactElement {
</Link> </Link>
</div> </div>
{error ? ( {isLoading ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Loading users...
</CardContent>
</Card>
) : error ? (
<Card> <Card>
<CardContent className="py-4"> <CardContent className="py-4">
<p className="text-sm text-destructive" role="alert"> <p className="text-sm text-destructive" role="alert">
@@ -489,14 +516,6 @@ export default function UsersSettingsPage(): ReactElement {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
) : null}
{isLoading ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Loading users...
</CardContent>
</Card>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -514,6 +533,7 @@ export default function UsersSettingsPage(): ReactElement {
{users.map((user) => { {users.map((user) => {
const primaryMembership = getPrimaryMembership(user); const primaryMembership = getPrimaryMembership(user);
const isActive = user.deactivatedAt === null; const isActive = user.deactivatedAt === null;
const isCurrentUser = authUser?.id === user.id;
return ( return (
<div <div
@@ -529,7 +549,14 @@ export default function UsersSettingsPage(): ReactElement {
}} }}
> >
<div className="space-y-1 min-w-0"> <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> <p className="text-sm text-muted-foreground truncate">{user.email}</p>
</div> </div>
@@ -540,7 +567,7 @@ export default function UsersSettingsPage(): ReactElement {
<Badge variant={isActive ? "secondary" : "destructive"}> <Badge variant={isActive ? "secondary" : "destructive"}>
{isActive ? "Active" : "Inactive"} {isActive ? "Active" : "Inactive"}
</Badge> </Badge>
{isActive ? ( {isActive && !isCurrentUser ? (
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
@@ -557,6 +584,36 @@ export default function UsersSettingsPage(): ReactElement {
</div> </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> </CardContent>
</Card> </Card>
)} )}

View File

@@ -25,12 +25,12 @@
| MS21-MIG-003 | not-started | phase-3 | Run migration on production database | #568 | api | — | MS21-MIG-001,MS21-TEST-003 | MS21-VER-001 | — | — | — | 5K | — | Needs deploy coordination; not automatable | | MS21-MIG-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-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 | 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 | 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-001-QA | done | phase-4 | QA: fix 4 review findings (pagination, error state, self-deactivate guard, tests) | #569 | web | fix/ms21-ui-001-qa | MS21-UI-001 | — | — | — | — | 15K | — | 0 blockers; merged per framework. Should-fix: pagination cap, error/empty collision, self-deactivate guard, no tests. |
| MS21-UI-002 | done | phase-4 | User detail/edit and invite dialogs | #569 | web | feat/ms21-ui-users | MS21-UI-001 | — | — | — | — | 15K | — | | | 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-003 | done | phase-4 | Settings/workspaces page (wire to real API) | #569 | web | feat/ms21-ui-workspaces | MS21-API-003 | — | codex | 2026-02-28 | 2026-02-28 | 15K | ~25K | PR #574 merged. Review: 0 critical, 1 low (raw errors in UI) |
| MS21-UI-004 | done | phase-4 | Workspace member management UI | #569 | web | feat/ms21-ui-workspaces | MS21-UI-003,MS21-API-003 | — | — | — | — | 15K | — | Components exist | | MS21-UI-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-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-TEST-004 | done | phase-4 | Frontend component tests | #569 | web | test/ms21-ui | MS21-UI-001,MS21-UI-002,MS21-UI-003,MS21-UI-004,MS21-UI-005 | — | — | — | — | 20K | — | |
| MS21-RBAC-001 | done | phase-5 | Sidebar navigation role gating | #570 | web | feat/ms21-rbac | MS21-UI-001 | — | — | — | — | 10K | — | | | 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-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-003 | done | phase-5 | Action button permission gating | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
@@ -55,19 +55,19 @@ Remaining estimate: ~143K tokens (Codex budget).
## MS22 — Fleet Evolution (Phase 0: Knowledge Layer) ## 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 | | 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-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-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-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-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-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-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-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-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-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 | 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-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 | 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-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 | 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-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-INGEST-001 | done | p0-knowledge | Session log ingestion pipeline (OpenClaw logs → ConvArchive) | TASKS:P0 | stack | feat/ms22-ingest | MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | Script to batch-ingest existing logs |
| MS22-VER-P0 | not-started | p0-knowledge | Phase 0 verification: all modules deployed + smoke tested | TASKS:P0 | stack | — | MS22-TEST-001,MS22-SKILL-001,MS22-INGEST-001,MS22-API-003 | — | — | — | — | 5K | — | | | 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 | — | |