[codex] Add resource membership controls (#6677)
Release / publish_stable (push) Has been skipped
Release / verify_stable (push) Has been skipped
Release / preview_stable (push) Has been skipped
Refresh Lockfile / refresh (push) Successful in 48s
Docker / build-and-push (push) Failing after 2m20s
Release / verify_canary (push) Failing after 6m5s
Release / publish_canary (push) Has been skipped

## Thinking Path

> - Paperclip orchestrates AI-agent companies through company-scoped
issues, projects, agents, and board-visible workflows.
> - The board sidebar and project list are the daily navigation surface
for that control plane.
> - Users need to keep all projects and agents accessible while hiding
resources they have intentionally left from their own sidebar.
> - That requires user-scoped resource membership state backed by
company-scoped API and database contracts.
> - The branch also needed to preserve HTTP worktree login sessions and
keep the project list easier to scan after membership grouping.
> - This pull request adds resource membership controls, sidebar leave
actions, grouped/sortable project listings, and focused tests.
> - The benefit is a cleaner personal workspace view without weakening
company-scoped access to the underlying project or agent detail pages.

## What Changed

- Added `project_memberships` and `agent_memberships` tables with
API/shared/server contracts for current-user join/leave state.
- Renumbered the membership migration to `0090_resource_memberships`
after rebasing onto current `master`, and made it idempotent for anyone
who had applied the old branch-local `0087` migration.
- Added project and agent sidebar leave actions, plus list filtering
that waits for membership state before hiding resources.
- Added grouped project listing, project sorting controls, and reserved
row subtitle height for cleaner scanning.
- Fixed HTTP auth cookie security handling so HTTP worktree sessions can
persist.
- Updated focused server and UI tests for the new membership, sidebar,
project list, and auth behavior.

## Verification

- `pnpm exec vitest run server/src/__tests__/better-auth.test.ts
server/src/__tests__/resource-memberships-routes.test.ts
ui/src/pages/Projects.test.tsx
ui/src/components/SidebarProjects.test.tsx
ui/src/components/SidebarAgents.test.tsx
ui/src/components/MembershipAction.test.tsx
ui/src/components/EntityRow.test.tsx`
- Confirmed the branch is rebased on current `origin/master`.
- Confirmed the PR diff does not include `pnpm-lock.yaml` or
`.github/workflows` changes.

## Risks

- Migration safety: low to medium. The migration now uses `IF NOT
EXISTS` / guarded constraints and is numbered after current master
migrations, but it should still get CI coverage against fresh databases.
- UI behavior: low. Left resources are hidden from sidebar only after
membership state loads; direct detail access remains available.
- Auth behavior: low. Cookie security is relaxed only for HTTP/private
local-style origins where secure cookies would prevent login
persistence.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex coding agent, tool-enabled shell/git workflow,
context window not exposed by runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Screenshot note: no browser screenshots were captured in this heartbeat;
the UI changes are covered by focused component tests above.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-25 13:12:41 -05:00
committed by GitHub
parent 60efa38f86
commit 9aea3e3d35
42 changed files with 20241 additions and 201 deletions
+87 -2
View File
@@ -1,10 +1,10 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import type { Agent, ResourceMemberships } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarAgents } from "./SidebarAgents";
@@ -22,6 +22,11 @@ const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForCompany: vi.fn(),
}));
const mockResourceMembershipsApi = vi.hoisted(() => ({
listMine: vi.fn(),
updateAgent: vi.fn(),
}));
const mockOpenNewAgent = vi.hoisted(() => vi.fn());
const mockPushToast = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
@@ -91,6 +96,10 @@ vi.mock("../api/heartbeats", () => ({
heartbeatsApi: mockHeartbeatsApi,
}));
vi.mock("../api/resourceMemberships", () => ({
resourceMembershipsApi: mockResourceMembershipsApi,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -99,6 +108,14 @@ if (!globalThis.PointerEvent) {
(globalThis as any).PointerEvent = MouseEvent;
}
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
function makeAgent(overrides: Partial<Agent>): Agent {
return {
id: "agent-1",
@@ -177,6 +194,7 @@ describe("SidebarAgents", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
let queryClient: QueryClient;
let memberships: ResourceMemberships;
beforeEach(() => {
container = document.createElement("div");
@@ -193,6 +211,27 @@ describe("SidebarAgents", () => {
user: { id: "user-1" },
});
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
memberships = {
projectMemberships: {},
agentMemberships: {},
updatedAt: null,
};
mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships));
mockResourceMembershipsApi.updateAgent.mockImplementation((_companyId, agentId, data) => {
memberships = {
...memberships,
agentMemberships: {
...memberships.agentMemberships,
[agentId]: data.state,
},
updatedAt: new Date(),
};
return Promise.resolve({
resourceType: "agent",
resourceId: agentId,
state: data.state,
});
});
localStorage.clear();
});
@@ -311,6 +350,31 @@ describe("SidebarAgents", () => {
expect(agentLinkLabels(container)).toEqual(["Bravo", "Charlie", "Alpha"]);
});
it("filters left agents only after membership state loads", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({ id: "agent-1", name: "Alpha", urlKey: "alpha" }),
makeAgent({ id: "agent-2", name: "Beta", urlKey: "beta" }),
]);
let resolveMemberships!: (value: unknown) => void;
mockResourceMembershipsApi.listMine.mockReturnValue(new Promise((resolve) => {
resolveMemberships = resolve;
}));
await renderSidebarAgents();
expect(agentLinkLabels(container)).toEqual(["Alpha", "Beta"]);
await act(async () => {
resolveMemberships({
projectMemberships: {},
agentMemberships: { "agent-1": "left" },
updatedAt: null,
});
});
await flushReact();
expect(agentLinkLabels(container)).toEqual(["Beta"]);
});
it("shows edit and pause actions for an active sidebar agent", async () => {
await renderSidebarAgents();
await openAgentMenu();
@@ -333,6 +397,27 @@ describe("SidebarAgents", () => {
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Agent paused" }));
});
it("offers leave agent from each sidebar agent menu", async () => {
await renderSidebarAgents();
await openAgentMenu();
const leaveItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Leave agent"));
expect(leaveItem).toBeTruthy();
await act(async () => {
leaveItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockResourceMembershipsApi.updateAgent).toHaveBeenCalledWith(
"company-1",
"agent-1",
{ state: "left" },
);
expect(agentLinkLabels(container)).toEqual([]);
});
it("shows resume for paused sidebar agents", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({ status: "paused", pauseReason: "manual", pausedAt: new Date("2026-01-02T00:00:00Z") }),