[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
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:
@@ -0,0 +1,55 @@
|
||||
CREATE TABLE IF NOT EXISTS "agent_memberships" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"state" text DEFAULT 'joined' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "project_memberships" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"project_id" uuid NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"state" text DEFAULT 'joined' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_memberships_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "agent_memberships" ADD CONSTRAINT "agent_memberships_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_memberships_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "agent_memberships" ADD CONSTRAINT "agent_memberships_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'project_memberships_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "project_memberships" ADD CONSTRAINT "project_memberships_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'project_memberships_project_id_projects_id_fk') THEN
|
||||
ALTER TABLE "project_memberships" ADD CONSTRAINT "project_memberships_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "agent_memberships_company_user_idx" ON "agent_memberships" USING btree ("company_id","user_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "agent_memberships_agent_idx" ON "agent_memberships" USING btree ("agent_id");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "agent_memberships_company_user_agent_uq" ON "agent_memberships" USING btree ("company_id","user_id","agent_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "project_memberships_company_user_idx" ON "project_memberships" USING btree ("company_id","user_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "project_memberships_project_idx" ON "project_memberships" USING btree ("project_id");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "project_memberships_company_user_project_uq" ON "project_memberships" USING btree ("company_id","user_id","project_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -631,6 +631,13 @@
|
||||
"when": 1779129600000,
|
||||
"tag": "0089_cloud_upstreams",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 90,
|
||||
"version": "7",
|
||||
"when": 1779573019125,
|
||||
"tag": "0090_resource_memberships",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { pgTable, uuid, text, timestamp, uniqueIndex, index } from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
export const agentMemberships = pgTable(
|
||||
"agent_memberships",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").notNull(),
|
||||
state: text("state").notNull().default("joined"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyUserIdx: index("agent_memberships_company_user_idx").on(table.companyId, table.userId),
|
||||
agentIdx: index("agent_memberships_agent_idx").on(table.agentId),
|
||||
companyUserAgentUq: uniqueIndex("agent_memberships_company_user_agent_uq").on(
|
||||
table.companyId,
|
||||
table.userId,
|
||||
table.agentId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -6,6 +6,7 @@ export { cloudUpstreamConnections, cloudUpstreamRuns } from "./cloud_upstreams.j
|
||||
export { instanceUserRoles } from "./instance_user_roles.js";
|
||||
export { userSidebarPreferences } from "./user_sidebar_preferences.js";
|
||||
export { agents } from "./agents.js";
|
||||
export { agentMemberships } from "./agent_memberships.js";
|
||||
export { boardApiKeys } from "./board_api_keys.js";
|
||||
export { cliAuthChallenges } from "./cli_auth_challenges.js";
|
||||
export { companyMemberships } from "./company_memberships.js";
|
||||
@@ -21,6 +22,7 @@ export { agentRuntimeState } from "./agent_runtime_state.js";
|
||||
export { agentTaskSessions } from "./agent_task_sessions.js";
|
||||
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
|
||||
export { projects } from "./projects.js";
|
||||
export { projectMemberships } from "./project_memberships.js";
|
||||
export { projectWorkspaces } from "./project_workspaces.js";
|
||||
export { executionWorkspaces } from "./execution_workspaces.js";
|
||||
export { environments } from "./environments.js";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { pgTable, uuid, text, timestamp, uniqueIndex, index } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
export const projectMemberships = pgTable(
|
||||
"project_memberships",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").notNull(),
|
||||
state: text("state").notNull().default("joined"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyUserIdx: index("project_memberships_company_user_idx").on(table.companyId, table.userId),
|
||||
projectIdx: index("project_memberships_project_idx").on(table.projectId),
|
||||
companyUserProjectUq: uniqueIndex("project_memberships_company_user_project_uq").on(
|
||||
table.companyId,
|
||||
table.userId,
|
||||
table.projectId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -18,6 +18,7 @@ export const API = {
|
||||
dashboard: `${API_PREFIX}/dashboard`,
|
||||
sidebarBadges: `${API_PREFIX}/sidebar-badges`,
|
||||
sidebarPreferences: `${API_PREFIX}/sidebar-preferences`,
|
||||
resourceMemberships: `${API_PREFIX}/resource-memberships`,
|
||||
invites: `${API_PREFIX}/invites`,
|
||||
joinRequests: `${API_PREFIX}/join-requests`,
|
||||
members: `${API_PREFIX}/members`,
|
||||
|
||||
@@ -659,6 +659,18 @@ export {
|
||||
upsertSidebarOrderPreferenceSchema,
|
||||
type UpsertSidebarOrderPreference,
|
||||
} from "./validators/sidebar-preferences.js";
|
||||
export {
|
||||
resourceMembershipStateSchema,
|
||||
updateResourceMembershipSchema,
|
||||
type UpdateResourceMembership,
|
||||
} from "./validators/resource-memberships.js";
|
||||
export {
|
||||
RESOURCE_MEMBERSHIP_STATES,
|
||||
type ResourceMembershipResourceType,
|
||||
type ResourceMembershipState,
|
||||
type ResourceMemberships,
|
||||
type ResourceMembershipUpdateResult,
|
||||
} from "./types/resource-memberships.js";
|
||||
|
||||
export { workspaceRuntimeControlTargetSchema } from "./validators/execution-workspace.js";
|
||||
export {
|
||||
|
||||
@@ -327,6 +327,14 @@ export type {
|
||||
} from "./user-profile.js";
|
||||
export type { SidebarBadges } from "./sidebar-badges.js";
|
||||
export type { SidebarOrderPreference } from "./sidebar-preferences.js";
|
||||
export type {
|
||||
ResourceMembershipResourceType,
|
||||
ResourceMembershipState,
|
||||
ResourceMemberships,
|
||||
ResourceMembershipUpdateResult,
|
||||
UpdateResourceMembership,
|
||||
} from "./resource-memberships.js";
|
||||
export { RESOURCE_MEMBERSHIP_STATES } from "./resource-memberships.js";
|
||||
export type { InboxDismissal } from "./inbox-dismissal.js";
|
||||
export type {
|
||||
AccessUserProfile,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export const RESOURCE_MEMBERSHIP_STATES = ["joined", "left"] as const;
|
||||
|
||||
export type ResourceMembershipState = (typeof RESOURCE_MEMBERSHIP_STATES)[number];
|
||||
export type ResourceMembershipResourceType = "project" | "agent";
|
||||
|
||||
export interface ResourceMemberships {
|
||||
projectMemberships: Record<string, ResourceMembershipState>;
|
||||
agentMemberships: Record<string, ResourceMembershipState>;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface UpdateResourceMembership {
|
||||
state: ResourceMembershipState;
|
||||
}
|
||||
|
||||
export interface ResourceMembershipUpdateResult {
|
||||
resourceType: ResourceMembershipResourceType;
|
||||
resourceId: string;
|
||||
state: ResourceMembershipState;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -51,6 +51,11 @@ export {
|
||||
upsertSidebarOrderPreferenceSchema,
|
||||
type UpsertSidebarOrderPreference,
|
||||
} from "./sidebar-preferences.js";
|
||||
export {
|
||||
resourceMembershipStateSchema,
|
||||
updateResourceMembershipSchema,
|
||||
type UpdateResourceMembership,
|
||||
} from "./resource-memberships.js";
|
||||
export {
|
||||
companySkillSourceTypeSchema,
|
||||
companySkillTrustLevelSchema,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { RESOURCE_MEMBERSHIP_STATES } from "../types/resource-memberships.js";
|
||||
|
||||
export const resourceMembershipStateSchema = z.enum(RESOURCE_MEMBERSHIP_STATES);
|
||||
|
||||
export const updateResourceMembershipSchema = z.object({
|
||||
state: resourceMembershipStateSchema,
|
||||
});
|
||||
|
||||
export type UpdateResourceMembership = z.infer<typeof updateResourceMembershipSchema>;
|
||||
Reference in New Issue
Block a user