diff --git a/packages/db/src/inbox-archive-agent-policies-migration.test.ts b/packages/db/src/inbox-archive-agent-policies-migration.test.ts new file mode 100644 index 0000000000..ab59a16358 --- /dev/null +++ b/packages/db/src/inbox-archive-agent-policies-migration.test.ts @@ -0,0 +1,175 @@ +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import { afterEach, describe, expect, it } from "vitest"; +import postgres from "postgres"; +import { applyPendingMigrations, inspectMigrations } from "./client.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./test-embedded-postgres.js"; + +const INBOX_ARCHIVE_AGENT_POLICIES_MIGRATION = "0172_inbox_archive_agent_policies.sql"; + +const cleanups: Array<() => Promise> = []; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +async function createTempDatabase(): Promise { + const db = await startEmbeddedPostgresTestDatabase("paperclip-inbox-archive-policies-"); + cleanups.push(db.cleanup); + return db.connectionString; +} + +async function migrationHash(): Promise { + const content = await fs.promises.readFile( + new URL(`./migrations/${INBOX_ARCHIVE_AGENT_POLICIES_MIGRATION}`, import.meta.url), + "utf8", + ); + return createHash("sha256").update(content).digest("hex"); +} + +afterEach(async () => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + await cleanup?.(); + } +}); + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres inbox archive policy migration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("inbox archive agent policy migration", () => { + it( + "upgrades legacy archives and round-trips attribution and unique policies", + async () => { + const connectionString = await createTempDatabase(); + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + const legacyArchiveId = randomUUID(); + const agentArchiveId = randomUUID(); + + try { + const hash = await migrationHash(); + await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${hash}`; + await sql`DROP TABLE IF EXISTS "user_inbox_agent_policies"`; + await sql`ALTER TABLE "issue_inbox_archives" DROP CONSTRAINT IF EXISTS "issue_inbox_archives_archived_by_agent_id_agents_id_fk"`; + await sql`ALTER TABLE "issue_inbox_archives" DROP CONSTRAINT IF EXISTS "issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk"`; + await sql`ALTER TABLE "issue_inbox_archives" DROP CONSTRAINT IF EXISTS "issue_inbox_archives_archived_by_actor_type_check"`; + await sql`ALTER TABLE "issue_inbox_archives" DROP COLUMN IF EXISTS "archived_by_agent_id"`; + await sql`ALTER TABLE "issue_inbox_archives" DROP COLUMN IF EXISTS "archived_by_run_id"`; + await sql`ALTER TABLE "issue_inbox_archives" DROP COLUMN IF EXISTS "archived_by_actor_type"`; + + await sql` + INSERT INTO "companies" ("id", "name", "issue_prefix") + VALUES (${companyId}, 'Inbox migration company', 'IAM') + `; + await sql` + INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config") + VALUES (${agentId}, ${companyId}, 'Inbox agent', 'engineer', 'process', '{}'::jsonb) + `; + await sql` + INSERT INTO "issues" ("id", "company_id", "title", "identifier") + VALUES (${issueId}, ${companyId}, 'Legacy inbox issue', 'IAM-1') + `; + await sql` + INSERT INTO "heartbeat_runs" ("id", "company_id", "agent_id", "status") + VALUES (${runId}, ${companyId}, ${agentId}, 'succeeded') + `; + await sql` + INSERT INTO "issue_inbox_archives" ("id", "company_id", "issue_id", "user_id") + VALUES (${legacyArchiveId}, ${companyId}, ${issueId}, 'legacy-user') + `; + } finally { + await sql.end(); + } + + expect(await inspectMigrations(connectionString)).toMatchObject({ + status: "needsMigrations", + pendingMigrations: [INBOX_ARCHIVE_AGENT_POLICIES_MIGRATION], + }); + + await applyPendingMigrations(connectionString); + + const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const legacyRows = await verifySql<{ + archived_by_actor_type: string; + archived_by_agent_id: string | null; + archived_by_run_id: string | null; + }[]>` + SELECT "archived_by_actor_type", "archived_by_agent_id", "archived_by_run_id" + FROM "issue_inbox_archives" + WHERE "id" = ${legacyArchiveId} + `; + expect(legacyRows).toEqual([{ + archived_by_actor_type: "user", + archived_by_agent_id: null, + archived_by_run_id: null, + }]); + + await verifySql` + INSERT INTO "issue_inbox_archives" ( + "id", + "company_id", + "issue_id", + "user_id", + "archived_by_actor_type", + "archived_by_agent_id", + "archived_by_run_id" + ) + VALUES (${agentArchiveId}, ${companyId}, ${issueId}, 'agent-managed-user', 'agent', ${agentId}, ${runId}) + `; + const agentRows = await verifySql<{ + archived_by_actor_type: string; + archived_by_agent_id: string; + archived_by_run_id: string; + }[]>` + SELECT "archived_by_actor_type", "archived_by_agent_id", "archived_by_run_id" + FROM "issue_inbox_archives" + WHERE "id" = ${agentArchiveId} + `; + expect(agentRows).toEqual([{ + archived_by_actor_type: "agent", + archived_by_agent_id: agentId, + archived_by_run_id: runId, + }]); + + await verifySql` + INSERT INTO "user_inbox_agent_policies" ( + "company_id", + "user_id", + "mode", + "allowed_agent_ids" + ) + VALUES (${companyId}, 'agent-managed-user', 'allowlist', ${verifySql.json([agentId])}) + `; + const policies = await verifySql<{ + mode: string; + allowed_agent_ids: string[]; + }[]>` + SELECT "mode", "allowed_agent_ids" + FROM "user_inbox_agent_policies" + WHERE "company_id" = ${companyId} + AND "user_id" = 'agent-managed-user' + `; + expect(policies).toEqual([{ mode: "allowlist", allowed_agent_ids: [agentId] }]); + + await expect(verifySql` + INSERT INTO "user_inbox_agent_policies" ("company_id", "user_id", "mode") + VALUES (${companyId}, 'agent-managed-user', 'disabled') + `).rejects.toMatchObject({ code: "23505" }); + } finally { + await verifySql.end(); + } + + expect((await inspectMigrations(connectionString)).status).toBe("upToDate"); + }, + 30_000, + ); +}); diff --git a/packages/db/src/migrations/0172_inbox_archive_agent_policies.sql b/packages/db/src/migrations/0172_inbox_archive_agent_policies.sql new file mode 100644 index 0000000000..87f78a3d4f --- /dev/null +++ b/packages/db/src/migrations/0172_inbox_archive_agent_policies.sql @@ -0,0 +1,42 @@ +ALTER TABLE "issue_inbox_archives" ADD COLUMN IF NOT EXISTS "archived_by_actor_type" text DEFAULT 'user' NOT NULL;--> statement-breakpoint +ALTER TABLE "issue_inbox_archives" ADD COLUMN IF NOT EXISTS "archived_by_agent_id" uuid;--> statement-breakpoint +ALTER TABLE "issue_inbox_archives" ADD COLUMN IF NOT EXISTS "archived_by_run_id" uuid;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_inbox_archives_archived_by_agent_id_agents_id_fk' + ) THEN + ALTER TABLE "issue_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_archived_by_agent_id_agents_id_fk" FOREIGN KEY ("archived_by_agent_id") REFERENCES "public"."agents"("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" = 'issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk' + ) THEN + ALTER TABLE "issue_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("archived_by_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" = 'issue_inbox_archives_archived_by_actor_type_check' + ) THEN + ALTER TABLE "issue_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_archived_by_actor_type_check" CHECK ("archived_by_actor_type" IN ('user', 'agent')); + END IF; +END $$;--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "user_inbox_agent_policies" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "user_id" text NOT NULL, + "mode" text DEFAULT 'open' NOT NULL, + "allowed_agent_ids" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_inbox_agent_policies_mode_check" CHECK ("mode" IN ('open', 'allowlist', 'disabled')) +);--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'user_inbox_agent_policies_company_id_companies_id_fk' + ) THEN + ALTER TABLE "user_inbox_agent_policies" ADD CONSTRAINT "user_inbox_agent_policies_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 +CREATE UNIQUE INDEX IF NOT EXISTS "user_inbox_agent_policies_company_user_uq" ON "user_inbox_agent_policies" USING btree ("company_id", "user_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 08362a9f16..5e4450b883 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1191,6 +1191,13 @@ "when": 1784160000000, "tag": "0171_issue_create_idempotency_keys", "breakpoints": true + }, + { + "idx": 172, + "version": "7", + "when": 1784169959628, + "tag": "0172_inbox_archive_agent_policies", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 636f3154d2..ab9b93281f 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -73,6 +73,7 @@ export { issueTreeHolds } from "./issue_tree_holds.js"; export { issueTreeHoldMembers } from "./issue_tree_hold_members.js"; export { issueExecutionDecisions } from "./issue_execution_decisions.js"; export { issueInboxArchives } from "./issue_inbox_archives.js"; +export { userInboxAgentPolicies } from "./user_inbox_agent_policies.js"; export { inboxDismissals } from "./inbox_dismissals.js"; export { feedbackVotes } from "./feedback_votes.js"; export { feedbackExports } from "./feedback_exports.js"; diff --git a/packages/db/src/schema/issue_inbox_archives.ts b/packages/db/src/schema/issue_inbox_archives.ts index 73152f13d6..c1ac3f5ebe 100644 --- a/packages/db/src/schema/issue_inbox_archives.ts +++ b/packages/db/src/schema/issue_inbox_archives.ts @@ -1,5 +1,8 @@ -import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { check, pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; import { companies } from "./companies.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; import { issues } from "./issues.js"; export const issueInboxArchives = pgTable( @@ -9,6 +12,10 @@ export const issueInboxArchives = pgTable( companyId: uuid("company_id").notNull().references(() => companies.id), issueId: uuid("issue_id").notNull().references(() => issues.id), userId: text("user_id").notNull(), + archivedByActorType: text("archived_by_actor_type").$type<"user" | "agent">().notNull().default("user"), + // Agent-attributed writes must set both IDs; SET NULL preserves rows if referenced records are deleted. + archivedByAgentId: uuid("archived_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + archivedByRunId: uuid("archived_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), archivedAt: timestamp("archived_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), @@ -21,5 +28,9 @@ export const issueInboxArchives = pgTable( table.issueId, table.userId, ), + archivedByActorTypeCheck: check( + "issue_inbox_archives_archived_by_actor_type_check", + sql`${table.archivedByActorType} in ('user', 'agent')`, + ), }), ); diff --git a/packages/db/src/schema/user_inbox_agent_policies.ts b/packages/db/src/schema/user_inbox_agent_policies.ts new file mode 100644 index 0000000000..4709cee69c --- /dev/null +++ b/packages/db/src/schema/user_inbox_agent_policies.ts @@ -0,0 +1,26 @@ +import { sql } from "drizzle-orm"; +import { check, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; + +export const userInboxAgentPolicies = pgTable( + "user_inbox_agent_policies", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + userId: text("user_id").notNull(), + mode: text("mode").$type<"open" | "allowlist" | "disabled">().notNull().default("open"), + allowedAgentIds: jsonb("allowed_agent_ids").$type().notNull().default([]), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyUserUq: uniqueIndex("user_inbox_agent_policies_company_user_uq").on( + table.companyId, + table.userId, + ), + modeCheck: check( + "user_inbox_agent_policies_mode_check", + sql`${table.mode} in ('open', 'allowlist', 'disabled')`, + ), + }), +);