From 1216d6e6793f8b238331cac2a22b9b44b9aa5988 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 30 Sep 2026 09:34:27 -0500 Subject: [PATCH] Update private task storage compatibility Co-Authored-By: Paperclip --- cli/src/__tests__/worktree.test.ts | 2 +- doc/DATABASE.md | 2 + ...rtbeat-run-issue-privacy-migration.test.ts | 12 ++ .../migrations/0291_private_task_access.sql | 66 +++++++++- .../db/src/migrations/meta/0291_snapshot.json | 12 +- packages/db/src/schema/projects.ts | 1 + .../db/src/schema/workspace_operations.ts | 4 +- packages/shared/src/types/project.ts | 2 + packages/shared/src/validators/index.ts | 2 - packages/shared/src/validators/issue.test.ts | 8 -- packages/shared/src/validators/issue.ts | 1 - packages/shared/src/validators/project.ts | 8 -- .../heartbeat-process-recovery.test.ts | 28 +++-- server/src/__tests__/issues-service.test.ts | 119 +++++++++++++++++- server/src/services/issues.ts | 3 + 15 files changed, 229 insertions(+), 41 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 0fb6b2aee32..a8572935481 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -1383,7 +1383,7 @@ describe("worktree helpers", () => { .select() .from(executionWorkspaces) .where(eq(executionWorkspaces.id, executionWorkspaceId)); - expect(executionWorkspace?.metadata).toEqual({ + expect(executionWorkspace?.metadata).toMatchObject({ keep: "execution-metadata", config: { environmentId: "environment-1", diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 1622b3d6f33..932cd75f56a 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -474,3 +474,5 @@ reservation cannot silently disappear. Failed cleanup or an ambiguous storage write requires operator reconciliation before an unattached reservation is removed. The table stores no response bodies. See `doc/runner-api-tools.md` for limits and the operator override. + +Project `privacy_owner_user_id` records who may manage its audience independently of project read membership. Creation assigns the authenticated user or run responsible user; migration recovers legacy ownership from creation audit evidence. Missing evidence leaves management with administrators. diff --git a/packages/db/src/heartbeat-run-issue-privacy-migration.test.ts b/packages/db/src/heartbeat-run-issue-privacy-migration.test.ts index 922cf656015..cfae22ee170 100644 --- a/packages/db/src/heartbeat-run-issue-privacy-migration.test.ts +++ b/packages/db/src/heartbeat-run-issue-privacy-migration.test.ts @@ -64,7 +64,19 @@ describeEmbeddedPostgres("heartbeat run issue privacy migration", () => { (${maintenanceRunId}, ${companyId}, ${agentId}, 'succeeded', NULL, ${sql.json({ wakeReason: "heartbeat_timer" })}) `; + const legacyProjectId = randomUUID(); + await sql`INSERT INTO projects (id, company_id, name, visibility) VALUES (${legacyProjectId}, ${companyId}, 'Legacy private project', 'private')`; + await sql`INSERT INTO activity_log (company_id, actor_type, actor_id, action, entity_type, entity_id) + VALUES (${companyId}, 'user', 'original-owner', 'project.created', 'project', ${legacyProjectId})`; + await sql`INSERT INTO project_access_members (company_id, project_id, subject_type, subject_id) + VALUES (${companyId}, ${legacyProjectId}, 'user', 'ordinary-reader')`; + await applyPendingMigrations(database.connectionString); + const [legacyProject] = await sql`SELECT privacy_owner_user_id FROM projects WHERE id = ${legacyProjectId}`; + expect(legacyProject.privacy_owner_user_id).toBe('original-owner'); + expect(await sql`SELECT subject_id FROM project_access_members WHERE project_id = ${legacyProjectId} ORDER BY subject_id`) + .toEqual([{ subject_id: 'ordinary-reader' }, { subject_id: 'original-owner' }]); + const rows = await sql<{ id: string; issue_id: string | null; scope_kind: string }[]>` SELECT "id", "issue_id", "scope_kind" diff --git a/packages/db/src/migrations/0291_private_task_access.sql b/packages/db/src/migrations/0291_private_task_access.sql index b0cef34ef4f..a5c4f2af7a6 100644 --- a/packages/db/src/migrations/0291_private_task_access.sql +++ b/packages/db/src/migrations/0291_private_task_access.sql @@ -74,12 +74,12 @@ DO $$ BEGIN END $$;--> statement-breakpoint DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk' AND conrelid = 'public.workspace_operations'::regclass) THEN - ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; END IF; END $$;--> statement-breakpoint DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspace_operations_issue_id_issues_id_fk' AND conrelid = 'public.workspace_operations'::regclass) THEN - ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; END IF; END $$;--> statement-breakpoint CREATE INDEX IF NOT EXISTS "heartbeat_runs_company_issue_created_idx" ON "heartbeat_runs" USING btree ("company_id","issue_id","created_at");--> statement-breakpoint @@ -230,3 +230,65 @@ BEGIN cursor_id := next_id; END LOOP; END $$; + +--> statement-breakpoint +-- Preserve operation history while retaining every original task/run boundary. +CREATE OR REPLACE FUNCTION paperclip_keep_operation_privacy_sources() RETURNS trigger AS $$ +DECLARE issue_sources jsonb := '{}'::jsonb; run_sources jsonb := '{}'::jsonb; +BEGIN + IF TG_OP = 'UPDATE' THEN + issue_sources := COALESCE(OLD.metadata->'_issuePrivacySources', '{}'::jsonb); + run_sources := COALESCE(OLD.metadata->'_runPrivacySources', '{}'::jsonb); + IF OLD.issue_id IS NOT NULL THEN issue_sources := issue_sources || jsonb_build_object(OLD.issue_id::text, true); END IF; + IF OLD.heartbeat_run_id IS NOT NULL THEN run_sources := run_sources || jsonb_build_object(OLD.heartbeat_run_id::text, true); END IF; + END IF; + IF NEW.issue_id IS NOT NULL THEN issue_sources := issue_sources || jsonb_build_object(NEW.issue_id::text, true); END IF; + IF NEW.heartbeat_run_id IS NOT NULL THEN run_sources := run_sources || jsonb_build_object(NEW.heartbeat_run_id::text, true); END IF; + NEW.metadata := COALESCE(NEW.metadata, '{}'::jsonb) || jsonb_build_object('_issuePrivacySources', issue_sources, '_runPrivacySources', run_sources); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint +DROP TRIGGER IF EXISTS workspace_operations_keep_privacy_sources ON workspace_operations; +--> statement-breakpoint +CREATE TRIGGER workspace_operations_keep_privacy_sources BEFORE INSERT OR UPDATE ON workspace_operations +FOR EACH ROW EXECUTE FUNCTION paperclip_keep_operation_privacy_sources(); +--> statement-breakpoint +DO $$ +DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; batch_ids uuid[]; +BEGIN + LOOP + SELECT array_agg(id ORDER BY id) INTO batch_ids FROM ( + SELECT id FROM workspace_operations WHERE id > cursor_id ORDER BY id LIMIT 1000 + ) batch; + EXIT WHEN batch_ids IS NULL; + UPDATE workspace_operations SET metadata = metadata WHERE id = ANY(batch_ids); + cursor_id := batch_ids[array_length(batch_ids, 1)]; + END LOOP; +END $$; + +--> statement-breakpoint +ALTER TABLE projects ADD COLUMN IF NOT EXISTS privacy_owner_user_id text; +--> statement-breakpoint +-- Recover management authority from creation evidence, never from read grants. +DO $$ +DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; batch_ids uuid[]; +BEGIN + LOOP + SELECT array_agg(id ORDER BY id) INTO batch_ids FROM ( + SELECT id FROM projects WHERE id > cursor_id ORDER BY id LIMIT 1000 + ) batch; + EXIT WHEN batch_ids IS NULL; + UPDATE projects p SET privacy_owner_user_id = COALESCE(p.personal_owner_user_id, ( + SELECT CASE WHEN a.actor_type = 'user' THEN a.actor_id ELSE r.responsible_user_id END + FROM activity_log a LEFT JOIN heartbeat_runs r ON r.id = a.run_id AND r.company_id = a.company_id + WHERE a.company_id = p.company_id AND a.entity_type = 'project' AND a.entity_id = p.id::text AND a.action = 'project.created' + ORDER BY a.created_at, a.id LIMIT 1 + )) WHERE p.id = ANY(batch_ids) AND p.privacy_owner_user_id IS NULL; + INSERT INTO project_access_members (company_id, project_id, subject_type, subject_id) + SELECT company_id, id, 'user', privacy_owner_user_id FROM projects + WHERE id = ANY(batch_ids) AND visibility = 'private' AND privacy_owner_user_id IS NOT NULL + ON CONFLICT (project_id, subject_type, subject_id) DO NOTHING; + cursor_id := batch_ids[array_length(batch_ids, 1)]; + END LOOP; +END $$; diff --git a/packages/db/src/migrations/meta/0291_snapshot.json b/packages/db/src/migrations/meta/0291_snapshot.json index 37136cc36c0..3dbe8ce13d2 100644 --- a/packages/db/src/migrations/meta/0291_snapshot.json +++ b/packages/db/src/migrations/meta/0291_snapshot.json @@ -38512,6 +38512,12 @@ "notNull": true, "default": "'open'" }, + "privacy_owner_user_id": { + "name": "privacy_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "personal_owner_user_id": { "name": "personal_owner_user_id", "type": "text", @@ -49984,7 +49990,7 @@ "columnsTo": [ "id" ], - "onDelete": "cascade", + "onDelete": "set null", "onUpdate": "no action" }, "workspace_operations_issue_id_issues_id_fk": { @@ -49997,7 +50003,7 @@ "columnsTo": [ "id" ], - "onDelete": "cascade", + "onDelete": "set null", "onUpdate": "no action" } }, @@ -50438,4 +50444,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/packages/db/src/schema/projects.ts b/packages/db/src/schema/projects.ts index 813b05ff511..8cd7174625c 100644 --- a/packages/db/src/schema/projects.ts +++ b/packages/db/src/schema/projects.ts @@ -14,6 +14,7 @@ export const projects = pgTable( name: text("name").notNull(), description: text("description"), visibility: text("visibility").notNull().default("open"), + privacyOwnerUserId: text("privacy_owner_user_id"), personalOwnerUserId: text("personal_owner_user_id"), status: text("status").notNull().default("backlog"), leadAgentId: uuid("lead_agent_id").references(() => agents.id), diff --git a/packages/db/src/schema/workspace_operations.ts b/packages/db/src/schema/workspace_operations.ts index c81f4aad97f..8924d5e7b33 100644 --- a/packages/db/src/schema/workspace_operations.ts +++ b/packages/db/src/schema/workspace_operations.ts @@ -23,10 +23,10 @@ export const workspaceOperations = pgTable( onDelete: "set null", }), heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, { - onDelete: "cascade", + onDelete: "set null", }), issueId: uuid("issue_id").references(() => issues.id, { - onDelete: "cascade", + onDelete: "set null", }), phase: text("phase").notNull(), command: text("command"), diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 78a8ffcbec0..1221420f412 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -102,6 +102,8 @@ export interface Project { visibility?: ProjectVisibility; /** Present only for the lazily-created per-user "My private tasks" project. */ personalOwnerUserId?: string | null; + /** Only this user and administrators may change the project audience. */ + privacyOwnerUserId?: string | null; status: ProjectStatus; leadAgentId: string | null; targetDate: string | null; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 6f0d599d54d..66b082f2cf7 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -389,14 +389,12 @@ export { export { createProjectSchema, updateProjectSchema, - addProjectAccessMemberSchema, createProjectWorkspaceSchema, updateProjectWorkspaceSchema, projectExecutionWorkspacePolicySchema, projectWorkspaceRuntimeConfigSchema, type CreateProject, type UpdateProject, - type AddProjectAccessMember, type CreateProjectWorkspace, type UpdateProjectWorkspace, type ProjectExecutionWorkspacePolicy, diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 20ec32b9631..77ef460c9fa 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -15,14 +15,6 @@ import { import { createAgentSchema } from "./agent.js"; describe("issue validators", () => { - it("accepts private visibility without adding defaults to updates", () => { - expect(createIssueSchema.parse({ title: "Private", visibility: "private" }).visibility).toBe("private"); - expect(createIssueSchema.parse({ title: "Open" }).visibility).toBe("open"); - expect(updateIssueSchema.parse({}).visibility).toBeUndefined(); - expect(updateIssueSchema.parse({ visibility: "private" }).visibility).toBe("private"); - expect(updateIssueSchema.safeParse({ visibility: "secret" }).success).toBe(false); - }); - it("validates the typed recovery display snapshot while retaining older metadata", () => { const metadata = { version: 1, sections: [{ rows: [{ type: "text", text: "Details" }] }] }; const recovery = { kind: "disposition_repair_escalated", actionId: "9af8228f-0be7-45ae-a104-6fbe0af6f1d3", attemptCount: 2, maxAttempts: 2, reason: "unchanged_source_state_exhausted", assigneeAgentId: null }; diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 7b0320d249d..95e9b23070b 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -688,7 +688,6 @@ const createIssueBaseSchema = z.object({ projectId: z.string().guid().optional().nullable(), projectWorkspaceId: z.string().guid().optional().nullable(), goalId: z.string().guid().optional().nullable(), - visibility: z.enum(["open", "private"]).optional().default("open"), parentId: z.string().guid().optional().nullable(), blockedByIssueIds: z.array(z.string().guid()).optional(), unblockDescriptor: z diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index b4567b857db..574a8db087c 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -106,7 +106,6 @@ const projectFields = { goalIds: z.array(z.string().guid()).optional(), name: z.string().min(1), description: z.string().optional().nullable(), - visibility: z.enum(["open", "private"]).optional().default("open"), status: z.enum(PROJECT_STATUSES).optional().default("backlog"), leadAgentId: z.string().guid().optional().nullable(), targetDate: z.string().optional().nullable(), @@ -133,11 +132,4 @@ export const updateProjectSchema = objectWithoutDefaults( export type UpdateProject = z.infer; -export const addProjectAccessMemberSchema = z.object({ - subjectType: z.enum(["user", "agent"]), - subjectId: z.string().min(1), -}); - -export type AddProjectAccessMember = z.infer; - export type ProjectExecutionWorkspacePolicy = z.infer; diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 2f4c7891f23..f86f9e4c1a1 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -726,6 +726,20 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { claimedAt: now, }); + if (input?.includeIssue !== false) { + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Recover local adapter after lost process", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + responsibleUserId: "responsible-user", + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + } + await db.insert(heartbeatRuns).values({ id: runId, companyId, @@ -753,19 +767,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { seq: 1, eventType: "adapter.invoke", payload: { adapterType: input?.adapterType ?? "codex_local" } }); if (input?.includeIssue !== false) { - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Recover local adapter after lost process", - status: "in_progress", - priority: "medium", - assigneeAgentId: agentId, - checkoutRunId: runId, - executionRunId: runId, - responsibleUserId: "responsible-user", - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); + await db.update(issues).set({ checkoutRunId: runId, executionRunId: runId }).where(eq(issues.id, issueId)); } return { companyId, agentId, runId, wakeupRequestId, issueId }; diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index a3fa946571d..a71cc5b3b2d 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -18,6 +18,7 @@ import { issueComments, issueInboxArchives, issueDocuments, + issueAccessGrants, issuePlanDecompositions, issueReadStates, issueRelations, @@ -25,6 +26,7 @@ import { issueWorkProducts, issues, projectWorkspaces, + projectAccessMembers, projects, workspaceOperations, toolApplications, @@ -3280,6 +3282,121 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { expect(child.responsibleUserId).toBe(responsibleUserId); }); + it("creates children under a private issue in the same private subtree", async () => { + const companyId = randomUUID(); + const parentIssueId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Private subtree company", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }); + await db.insert(issues).values({ + id: parentIssueId, + companyId, + title: "Private root", + visibility: "private", + privacyRootIssueId: parentIssueId, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + title: "Inherited private child", + visibility: "open", + }); + + expect(child.visibility).toBe("private"); + expect(child.privacyRootIssueId).toBe(parentIssueId); + }); + + it("creates one personal private project and parks private tasks there", async () => { + const companyId = randomUUID(); + const responsibleUserId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Personal private tasks company", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }); + + const [first, second] = await Promise.all([ + svc.create(companyId, { + title: "First personal private task", + visibility: "private", + createdByUserId: responsibleUserId, + }), + svc.create(companyId, { + title: "Second personal private task", + visibility: "private", + createdByUserId: responsibleUserId, + }), + ]); + + const personalProjects = await db.select().from(projects) + .where(eq(projects.personalOwnerUserId, responsibleUserId)); + expect(personalProjects).toHaveLength(1); + expect(personalProjects[0]).toMatchObject({ + companyId, + name: "My private tasks", + visibility: "private", + }); + expect(first.projectId).toBe(personalProjects[0]!.id); + expect(second.projectId).toBe(personalProjects[0]!.id); + expect(first.privacyRootIssueId).toBe(first.id); + expect(second.privacyRootIssueId).toBe(second.id); + await expect(db.select().from(projectAccessMembers) + .where(eq(projectAccessMembers.projectId, personalProjects[0]!.id))) + .resolves.toEqual([ + expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }), + ]); + }); + + it("keeps a responsible human and grants an agent access to its explicitly private task", async () => { + const companyId = randomUUID(); + const responsibleUserId = randomUUID(); + const creatorAgentId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Agent private task company", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }); + await db.insert(agents).values({ + id: creatorAgentId, + companyId, + name: "Private task creator", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + const issue = await svc.create(companyId, { + title: "Agent email scratch task", + visibility: "private", + createdByAgentId: creatorAgentId, + actorResponsibleUserId: responsibleUserId, + }); + + expect(issue.responsibleUserId).toBe(responsibleUserId); + const personalProject = await db.select().from(projects) + .where(eq(projects.id, issue.projectId!)) + .then((rows) => rows[0]!); + expect(personalProject.personalOwnerUserId).toBe(responsibleUserId); + await expect(db.select().from(projectAccessMembers) + .where(eq(projectAccessMembers.projectId, personalProject.id))) + .resolves.toEqual([ + expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }), + ]); + await expect(db.select().from(issueAccessGrants) + .where(eq(issueAccessGrants.issueId, issue.id))) + .resolves.toEqual([ + expect.objectContaining({ + subjectType: "agent", + subjectId: creatorAgentId, + source: "explicit", + }), + ]); + }); + it("only honors explicit responsibleUserId for trusted issue create callers", async () => { const companyId = randomUUID(); const creatorUserId = randomUUID(); @@ -5668,7 +5785,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { .where(eq(executionWorkspaces.id, executionWorkspaceId)) .then((rows) => rows[0] ?? null); - expect(workspace?.metadata).toEqual({ + expect(workspace?.metadata).toMatchObject({ config: { environmentId: null, provisionCommand: "bash ./scripts/provision-new.sh", diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index d2087b3da06..26a1605a0e0 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -4838,6 +4838,9 @@ async function listIssueReviewAttentionMap( } const issueListSelect = { + visibility: issues.visibility, + privacyRootIssueId: issues.privacyRootIssueId, + privacyParentIssueId: issues.privacyParentIssueId, externalConversationState: externalConversationStateSql(), conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId,