Add private task storage, contracts, and durable source bindings

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-30 09:08:19 -05:00
1 parent d30b03bd8c
commit 8c6fc1b4d4
36 files changed
+51121 -28

No files matched your search

+2
View File
@@ -231,6 +231,8 @@ These rows are company-scoped and user-scoped. A missing row means the user is j
Both tables use a unique key on `(company_id, user_id, resource_id)` and keep `state` as `joined` or `left`. Join/leave mutations are idempotent board-user `/me` operations and write activity entries when the effective state changes.
Private-project authorization uses the separate `project_access_members` table. Its user/agent rows are security grants, not sidebar preferences, and are evaluated by the same issue-read predicate as issue-level grants. Do not merge or overload these two concepts.
## Decision training snapshot retention
`decision_training_examples` stores a point-in-time copy of an issue, its comments, relevant runs, and the selected decision. Each row carries the `scrub_deleted_comments_v1` retention policy marker, and JSONL exports include that marker alongside the snapshot.
@@ -0,0 +1,145 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import { applyPendingMigrations } from "./client.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
const MIGRATION_FILE = "0291_private_task_access.sql";
const cleanups: Array<() => Promise<void>> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function migrationHash() {
const content = await fs.promises.readFile(new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), "utf8");
return createHash("sha256").update(content).digest("hex");
}
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()?.();
});
describeEmbeddedPostgres("heartbeat run issue privacy migration", () => {
it("backfills every valid context issue binding and preserves maintenance runs", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-run-issue-privacy-");
cleanups.push(database.cleanup);
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
cleanups.push(async () => sql.end());
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
await sql`DROP TRIGGER IF EXISTS heartbeat_runs_set_scope_kind ON heartbeat_runs`;
const companyId = randomUUID();
const agentId = randomUUID();
const firstIssueId = randomUUID();
const secondIssueId = randomUUID();
const firstRunId = randomUUID();
const secondRunId = randomUUID();
const maintenanceRunId = randomUUID();
await sql`
INSERT INTO "companies" ("id", "name", "issue_prefix")
VALUES (${companyId}, 'Run Privacy', 'RPR')
`;
await sql`
INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config")
VALUES (${agentId}, ${companyId}, 'Runner', 'engineer', 'process', '{}'::jsonb)
`;
await sql`
INSERT INTO "issues" ("id", "company_id", "title", "identifier")
VALUES
(${firstIssueId}, ${companyId}, 'First private task', 'RPR-1'),
(${secondIssueId}, ${companyId}, 'Second private task', 'RPR-2')
`;
await sql`
INSERT INTO "heartbeat_runs" (
"id", "company_id", "agent_id", "status", "issue_id", "context_snapshot"
)
VALUES
(${firstRunId}, ${companyId}, ${agentId}, 'succeeded', NULL, ${sql.json({ issueId: firstIssueId })}),
(${secondRunId}, ${companyId}, ${agentId}, 'failed', NULL, ${sql.json({ issueId: secondIssueId })}),
(${maintenanceRunId}, ${companyId}, ${agentId}, 'succeeded', NULL, ${sql.json({ wakeReason: "heartbeat_timer" })})
`;
await applyPendingMigrations(database.connectionString);
const rows = await sql<{ id: string; issue_id: string | null; scope_kind: string }[]>`
SELECT "id", "issue_id", "scope_kind"
FROM "heartbeat_runs"
WHERE "id" IN (${firstRunId}, ${secondRunId}, ${maintenanceRunId})
ORDER BY "id"
`;
const issueByRun = new Map(rows.map((row) => [row.id, row.issue_id]));
expect(issueByRun.get(firstRunId)).toBe(firstIssueId);
expect(issueByRun.get(secondRunId)).toBe(secondIssueId);
expect(issueByRun.get(maintenanceRunId)).toBeNull();
expect(rows.find((row) => row.id === firstRunId)?.scope_kind).toBe("issue");
expect(rows.find((row) => row.id === secondRunId)?.scope_kind).toBe("issue");
expect(rows.find((row) => row.id === maintenanceRunId)?.scope_kind).toBe("company");
const [parity] = await sql<{ expected_count: number; populated_count: number }[]>`
SELECT
count(*) FILTER (WHERE "context_snapshot" ? 'issueId')::int AS "expected_count",
count(*) FILTER (WHERE "context_snapshot" ? 'issueId' AND "issue_id" IS NOT NULL)::int AS "populated_count"
FROM "heartbeat_runs"
WHERE "id" IN (${firstRunId}, ${secondRunId}, ${maintenanceRunId})
`;
expect(parity).toEqual({ expected_count: 2, populated_count: 2 });
const indexes = await sql<{ indexname: string }[]>`
SELECT "indexname"
FROM "pg_indexes"
WHERE "schemaname" = 'public'
AND "indexname" = 'heartbeat_runs_company_issue_created_idx'
`;
expect(indexes).toHaveLength(1);
}, 30_000);
it("fails closed when a legacy issue claim cannot be resolved", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-run-issue-privacy-invalid-");
cleanups.push(database.cleanup);
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
cleanups.push(async () => sql.end());
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
await sql`DROP TRIGGER IF EXISTS heartbeat_runs_set_scope_kind ON heartbeat_runs`;
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const agentId = randomUUID();
const foreignIssueId = randomUUID();
await sql`
INSERT INTO "companies" ("id", "name", "issue_prefix")
VALUES
(${companyId}, 'Run Privacy', 'RPI'),
(${otherCompanyId}, 'Other Company', 'RPO')
`;
await sql`
INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config")
VALUES (${agentId}, ${companyId}, 'Runner', 'engineer', 'process', '{}'::jsonb)
`;
await sql`
INSERT INTO "issues" ("id", "company_id", "title", "identifier")
VALUES (${foreignIssueId}, ${otherCompanyId}, 'Foreign task', 'RPO-1')
`;
await sql`
INSERT INTO "heartbeat_runs" ("company_id", "agent_id", "status", "context_snapshot")
VALUES
(${companyId}, ${agentId}, 'succeeded', ${sql.json({ issueId: "not-a-uuid" })}),
(${companyId}, ${agentId}, 'succeeded', ${sql.json({ issueId: randomUUID() })}),
(${companyId}, ${agentId}, 'succeeded', ${sql.json({ issueId: foreignIssueId })})
`;
await applyPendingMigrations(database.connectionString);
const rows = await sql<{ issue_id: string | null; scope_kind: string }[]>`
SELECT issue_id, scope_kind FROM heartbeat_runs WHERE company_id = ${companyId}`;
expect(rows).toHaveLength(3);
expect(rows.every(row => row.scope_kind === "issue" && row.issue_id === null)).toBe(true);
// Re-running the migration preserves fail-closed tombstones and constraints.
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
await applyPendingMigrations(database.connectionString);
}, 30_000);
});
@@ -0,0 +1,232 @@
CREATE TABLE IF NOT EXISTS "issue_access_grants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"issue_id" uuid NOT NULL,
"subject_type" text NOT NULL,
"subject_id" text NOT NULL,
"source" text NOT NULL,
"granted_by_user_id" text,
"granted_by_agent_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"revoked_at" timestamp with time zone,
CONSTRAINT "issue_access_grants_subject_type_check" CHECK ("issue_access_grants"."subject_type" in ('user', 'agent')),
CONSTRAINT "issue_access_grants_source_check" CHECK ("issue_access_grants"."source" in ('explicit', 'assignment', 'project'))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "project_access_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"project_id" uuid NOT NULL,
"subject_type" text NOT NULL,
"subject_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "project_access_members_subject_type_check" CHECK ("project_access_members"."subject_type" in ('user', 'agent'))
);
--> statement-breakpoint
ALTER TABLE "workspace_operations" DROP CONSTRAINT IF EXISTS "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace_operations" DROP CONSTRAINT IF EXISTS "workspace_operations_issue_id_issues_id_fk";
--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "scope_kind" text DEFAULT 'company' NOT NULL;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "issue_id" uuid;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "visibility" text DEFAULT 'open' NOT NULL;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "privacy_root_issue_id" uuid;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "privacy_parent_issue_id" uuid;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "visibility" text DEFAULT 'open' NOT NULL;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "personal_owner_user_id" text;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_access_grants_issue_id_issues_id_fk' AND conrelid = 'public.issue_access_grants'::regclass) THEN
ALTER TABLE "issue_access_grants" ADD CONSTRAINT "issue_access_grants_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("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 = 'issue_access_grants_granted_by_agent_id_agents_id_fk' AND conrelid = 'public.issue_access_grants'::regclass) THEN
ALTER TABLE "issue_access_grants" ADD CONSTRAINT "issue_access_grants_granted_by_agent_id_agents_id_fk" FOREIGN KEY ("granted_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 = 'project_access_members_company_id_companies_id_fk' AND conrelid = 'public.project_access_members'::regclass) THEN
ALTER TABLE "project_access_members" ADD CONSTRAINT "project_access_members_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_access_members_project_id_projects_id_fk' AND conrelid = 'public.project_access_members'::regclass) THEN
ALTER TABLE "project_access_members" ADD CONSTRAINT "project_access_members_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 "issue_access_grants_subject_active_issue_idx" ON "issue_access_grants" USING btree ("subject_type","subject_id","revoked_at","issue_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_access_grants_issue_idx" ON "issue_access_grants" USING btree ("issue_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "project_access_members_project_subject_uq" ON "project_access_members" USING btree ("project_id","subject_type","subject_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "project_access_members_subject_lookup_idx" ON "project_access_members" USING btree ("company_id","subject_type","subject_id","project_id");--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'heartbeat_runs_issue_id_issues_id_fk' AND conrelid = 'public.heartbeat_runs'::regclass) THEN
ALTER TABLE "heartbeat_runs" ADD CONSTRAINT "heartbeat_runs_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
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issues_privacy_root_issue_id_issues_id_fk' AND conrelid = 'public.issues'::regclass) THEN
ALTER TABLE "issues" ADD CONSTRAINT "issues_privacy_root_issue_id_issues_id_fk" FOREIGN KEY ("privacy_root_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issues_privacy_parent_issue_id_issues_id_fk' AND conrelid = 'public.issues'::regclass) THEN
ALTER TABLE "issues" ADD CONSTRAINT "issues_privacy_parent_issue_id_issues_id_fk" FOREIGN KEY ("privacy_parent_issue_id") REFERENCES "public"."issues"("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_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;
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;
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
CREATE INDEX IF NOT EXISTS "issues_company_privacy_parent_idx" ON "issues" USING btree ("company_id","privacy_parent_issue_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issues_company_privacy_root_idx" ON "issues" USING btree ("company_id","privacy_root_issue_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "projects_company_personal_owner_uq" ON "projects" USING btree ("company_id","personal_owner_user_id") WHERE "projects"."personal_owner_user_id" is not null;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'heartbeat_runs_scope_binding_check' AND conrelid = 'public.heartbeat_runs'::regclass) THEN
ALTER TABLE "heartbeat_runs" ADD CONSTRAINT "heartbeat_runs_scope_binding_check" CHECK (("heartbeat_runs"."scope_kind" = 'company' AND "heartbeat_runs"."issue_id" IS NULL)
OR "heartbeat_runs"."scope_kind" = 'issue');
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issues_visibility_check' AND conrelid = 'public.issues'::regclass) THEN
ALTER TABLE "issues" ADD CONSTRAINT "issues_visibility_check" CHECK ("issues"."visibility" in ('open', 'private'));
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'projects_visibility_check' AND conrelid = 'public.projects'::regclass) THEN
ALTER TABLE "projects" ADD CONSTRAINT "projects_visibility_check" CHECK ("projects"."visibility" in ('open', 'private'));
END IF;
END $$;
--> statement-breakpoint
CREATE OR REPLACE FUNCTION set_heartbeat_run_scope_kind() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE candidate uuid;
BEGIN
-- A private run never becomes a company run through context edits or deletion.
IF TG_OP = 'UPDATE' AND OLD.scope_kind = 'issue' THEN
IF OLD.issue_id IS NOT NULL AND NEW.issue_id IS NOT NULL AND OLD.issue_id <> NEW.issue_id THEN
RAISE EXCEPTION 'run task binding is immutable';
END IF;
IF OLD.issue_id IS NOT NULL AND NEW.native_issue_id IS NOT NULL AND OLD.issue_id <> NEW.native_issue_id THEN
RAISE EXCEPTION 'native run task binding disagrees with privacy binding';
END IF;
NEW.scope_kind := 'issue';
RETURN NEW;
END IF;
candidate := coalesce(NEW.native_issue_id, NEW.issue_id);
IF candidate IS NULL AND NEW.context_snapshot->>'issueId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
candidate := (NEW.context_snapshot->>'issueId')::uuid;
END IF;
IF candidate IS NOT NULL OR NEW.context_snapshot ? 'issueId' OR NEW.scope_kind = 'issue' THEN
NEW.scope_kind := 'issue';
SELECT id INTO NEW.issue_id FROM issues WHERE id = candidate AND company_id = NEW.company_id;
ELSE
NEW.scope_kind := 'company';
END IF;
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS heartbeat_runs_set_scope_kind ON heartbeat_runs;
--> statement-breakpoint
CREATE TRIGGER heartbeat_runs_set_scope_kind BEFORE INSERT OR UPDATE OF issue_id, native_issue_id, context_snapshot, scope_kind
ON heartbeat_runs FOR EACH ROW EXECUTE FUNCTION set_heartbeat_run_scope_kind();
--> statement-breakpoint
-- Indexed primary-key keyset batches visit each historical run once. Missing
-- source tasks become issue-scoped tombstones, never company-visible run content.
DO $$ DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; next_id uuid;
BEGIN LOOP
WITH batch AS MATERIALIZED (SELECT id FROM heartbeat_runs WHERE id > cursor_id ORDER BY id LIMIT 1000),
changed AS (UPDATE heartbeat_runs r SET scope_kind = r.scope_kind FROM batch b WHERE r.id = b.id RETURNING r.id)
SELECT id INTO next_id FROM changed ORDER BY id DESC LIMIT 1;
EXIT WHEN next_id IS NULL;
cursor_id := next_id;
END LOOP; END $$;
--> statement-breakpoint
-- Existing structural children acquire downward inheritance without widening
-- any task's visibility. New provenance edges are written at task creation.
DO $$ DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; next_id uuid;
BEGIN LOOP
WITH batch AS MATERIALIZED (SELECT id FROM issues WHERE id > cursor_id ORDER BY id LIMIT 1000),
changed AS (UPDATE issues i SET privacy_parent_issue_id = coalesce(i.privacy_parent_issue_id, i.parent_id)
FROM batch b WHERE i.id = b.id RETURNING i.id)
SELECT id INTO next_id FROM changed ORDER BY id DESC LIMIT 1;
EXIT WHEN next_id IS NULL;
cursor_id := next_id;
END LOOP; END $$;
--> statement-breakpoint
-- Keep workspace provenance after reassignment/deletion. A previous task may
-- have left files behind; losing the live FK must not publish those files.
CREATE OR REPLACE FUNCTION retain_workspace_privacy_sources() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE sources jsonb := '{}'::jsonb; linked jsonb;
BEGIN
IF TG_OP = 'UPDATE' AND jsonb_typeof(OLD.metadata->'_issuePrivacySources') = 'object' THEN
sources := OLD.metadata->'_issuePrivacySources';
END IF;
IF NEW.source_issue_id IS NOT NULL THEN
sources := sources || jsonb_build_object(NEW.source_issue_id::text, true);
END IF;
SELECT jsonb_object_agg(id::text, true) INTO linked FROM issues
WHERE execution_workspace_id = NEW.id AND company_id = NEW.company_id;
sources := sources || coalesce(linked, '{}'::jsonb);
NEW.metadata := coalesce(NEW.metadata, '{}'::jsonb) || jsonb_build_object('_issuePrivacySources', sources);
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS execution_workspaces_privacy_sources ON execution_workspaces;
--> statement-breakpoint
CREATE TRIGGER execution_workspaces_privacy_sources BEFORE INSERT OR UPDATE OF source_issue_id, metadata
ON execution_workspaces FOR EACH ROW EXECUTE FUNCTION retain_workspace_privacy_sources();
--> statement-breakpoint
CREATE OR REPLACE FUNCTION record_issue_workspace_privacy_source() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.execution_workspace_id IS NOT NULL THEN
UPDATE execution_workspaces SET metadata = metadata
WHERE id = NEW.execution_workspace_id AND company_id = NEW.company_id;
END IF;
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS issues_record_workspace_privacy_source ON issues;
--> statement-breakpoint
CREATE TRIGGER issues_record_workspace_privacy_source AFTER INSERT OR UPDATE OF execution_workspace_id
ON issues FOR EACH ROW EXECUTE FUNCTION record_issue_workspace_privacy_source();
--> statement-breakpoint
DO $$ DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; next_id uuid;
BEGIN LOOP
WITH batch AS MATERIALIZED (SELECT id FROM execution_workspaces WHERE id > cursor_id ORDER BY id LIMIT 1000),
changed AS (UPDATE execution_workspaces w SET metadata = metadata FROM batch b WHERE w.id = b.id RETURNING w.id)
SELECT id INTO next_id FROM changed ORDER BY id DESC LIMIT 1;
EXIT WHEN next_id IS NULL;
cursor_id := next_id;
END LOOP; END $$;
--> statement-breakpoint
-- Bind historical inline draft images to their first owned task. Unbound drafts
-- remain uploader-only. Attachment deletion never makes the draft company-open.
DO $$
DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; next_id uuid;
BEGIN
LOOP
SELECT max(id::text)::uuid INTO next_id FROM (
SELECT id FROM assets WHERE id > cursor_id AND object_key LIKE company_id::text || '/assets/issues/drafts/%'
ORDER BY id LIMIT 1000
) batch;
EXIT WHEN next_id IS NULL;
INSERT INTO issue_attachments (company_id, issue_id, asset_id)
SELECT a.company_id, first_issue.id, a.id FROM assets a
CROSS JOIN LATERAL (
SELECT i.id FROM issues i WHERE i.company_id = a.company_id
AND (i.created_by_user_id = a.created_by_user_id OR i.responsible_user_id = a.created_by_user_id
OR i.created_by_agent_id = a.created_by_agent_id)
AND strpos(coalesce(i.description, ''), '/api/assets/' || a.id::text || '/content') > 0
ORDER BY i.created_at, i.id LIMIT 1
) first_issue
WHERE a.id > cursor_id AND a.id <= next_id AND a.object_key LIKE a.company_id::text || '/assets/issues/drafts/%'
ON CONFLICT DO NOTHING;
cursor_id := next_id;
END LOOP;
END $$;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -2024,6 +2024,13 @@
"when": 1790768845200,
"tag": "0290_browser_use_cloud",
"breakpoints": true
},
{
"idx": 291,
"version": "7",
"when": 1790774049559,
"tag": "0291_private_task_access",
"breakpoints": true
}
]
}
+17
View File
@@ -10,12 +10,14 @@ import {
integer,
bigint,
boolean,
check,
unique,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { agentWakeupRequests } from "./agent_wakeup_requests.js";
import { issues } from "./issues.js";
export const heartbeatRuns = pgTable(
"heartbeat_runs",
@@ -23,6 +25,11 @@ export const heartbeatRuns = pgTable(
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
agentId: uuid("agent_id").notNull().references(() => agents.id),
scopeKind: text("scope_kind")
.$type<"company" | "issue">()
.notNull()
.default("company"),
issueId: uuid("issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }),
invocationSource: text("invocation_source").notNull().default("on_demand"),
triggerDetail: text("trigger_detail"),
status: text("status").notNull().default("queued"),
@@ -98,6 +105,11 @@ export const heartbeatRuns = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
scopeBindingCheck: check(
"heartbeat_runs_scope_binding_check",
sql`(${table.scopeKind} = 'company' AND ${table.issueId} IS NULL)
OR ${table.scopeKind} = 'issue'`,
),
executionStatusDeliveryIdx: index("heartbeat_runs_execution_status_delivery_idx")
.on(table.executionStatusDeliveryId).where(sql`${table.executionStatusDeliveryId} is not null`),
executionControlDeadlineIdx: index("heartbeat_runs_execution_control_deadline_idx")
@@ -123,6 +135,11 @@ export const heartbeatRuns = pgTable(
table.agentId,
table.startedAt,
),
companyIssueCreatedIdx: index("heartbeat_runs_company_issue_created_idx").on(
table.companyId,
table.issueId,
table.createdAt,
),
companyResponsibleUserIdx: index("heartbeat_runs_company_responsible_user_idx").on(
table.companyId,
table.responsibleUserId,
+2
View File
@@ -26,6 +26,7 @@ export { agentTaskSessions, agentSessionGoalActions } from "./agent_task_session
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
export { projects } from "./projects.js";
export { projectMemberships } from "./project_memberships.js";
export { projectAccessMembers } from "./project_access_members.js";
export { documentMemberships } from "./document_memberships.js";
export { projectWorkspaces } from "./project_workspaces.js";
export { executionWorkspaces } from "./execution_workspaces.js";
@@ -41,6 +42,7 @@ export { projectGoals } from "./project_goals.js";
export { goals } from "./goals.js";
export { folders } from "./folders.js";
export { issues } from "./issues.js";
export { issueAccessGrants } from "./issue_access_grants.js";
export { issueWatchdogs } from "./issue_watchdogs.js";
export { issuePlanDecompositions } from "./issue_plan_decompositions.js";
export { issueRecoveryActions } from "./issue_recovery_actions.js";
@@ -0,0 +1,36 @@
import { sql } from "drizzle-orm";
import { check, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { issues } from "./issues.js";
export const issueAccessGrants = pgTable(
"issue_access_grants",
{
id: uuid("id").primaryKey().defaultRandom(),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
subjectType: text("subject_type").notNull(),
subjectId: text("subject_id").notNull(),
source: text("source").notNull(),
grantedByUserId: text("granted_by_user_id"),
grantedByAgentId: uuid("granted_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
},
(table) => ({
subjectTypeCheck: check(
"issue_access_grants_subject_type_check",
sql`${table.subjectType} in ('user', 'agent')`,
),
sourceCheck: check(
"issue_access_grants_source_check",
sql`${table.source} in ('explicit', 'assignment', 'project')`,
),
predicateIdx: index("issue_access_grants_subject_active_issue_idx").on(
table.subjectType,
table.subjectId,
table.revokedAt,
table.issueId,
),
issueIdx: index("issue_access_grants_issue_idx").on(table.issueId, table.createdAt),
}),
);
+6
View File
@@ -37,6 +37,9 @@ export const issues = pgTable(
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
goalId: uuid("goal_id").references(() => goals.id),
parentId: uuid("parent_id").references((): AnyPgColumn => issues.id),
visibility: text("visibility").notNull().default("open"),
privacyRootIssueId: uuid("privacy_root_issue_id").references((): AnyPgColumn => issues.id),
privacyParentIssueId: uuid("privacy_parent_issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }),
title: text("title").notNull(),
description: text("description"),
status: text("status").notNull().default("backlog"),
@@ -90,6 +93,7 @@ export const issues = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
visibilityCheck: check("issues_visibility_check", sql`${table.visibility} in ('open', 'private')`),
conversationIdentityIdx: uniqueIndex("issues_conversation_identity_idx").on(table.companyId, table.conversationAgentId, table.conversationUserId),
conversationIdentityCheck: check("issues_conversation_identity_check", sql`(
${table.conversationAgentId} is null and ${table.conversationUserId} is null and ${table.conversationState} is null
@@ -115,6 +119,8 @@ export const issues = pgTable(
),
responsibleUserIdx: index("issues_company_responsible_user_idx").on(table.companyId, table.responsibleUserId),
parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId),
privacyParentIdx: index("issues_company_privacy_parent_idx").on(table.companyId, table.privacyParentIssueId),
privacyRootIdx: index("issues_company_privacy_root_idx").on(table.companyId, table.privacyRootIssueId),
projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId),
originIdx: index("issues_company_origin_idx").on(table.companyId, table.originKind, table.originId),
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
@@ -0,0 +1,34 @@
import { sql } from "drizzle-orm";
import { check, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { projects } from "./projects.js";
/** Authorization membership for private projects; unrelated to sidebar project_memberships. */
export const projectAccessMembers = pgTable(
"project_access_members",
{
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" }),
subjectType: text("subject_type").notNull(),
subjectId: text("subject_id").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
subjectTypeCheck: check(
"project_access_members_subject_type_check",
sql`${table.subjectType} in ('user', 'agent')`,
),
projectSubjectUq: uniqueIndex("project_access_members_project_subject_uq").on(
table.projectId,
table.subjectType,
table.subjectId,
),
subjectLookupIdx: index("project_access_members_subject_lookup_idx").on(
table.companyId,
table.subjectType,
table.subjectId,
table.projectId,
),
}),
);
+8 -1
View File
@@ -1,4 +1,5 @@
import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { pgTable, uuid, text, timestamp, date, index, jsonb, check, uniqueIndex } from "drizzle-orm/pg-core";
import type { AgentEnvConfig } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { goals } from "./goals.js";
@@ -12,6 +13,8 @@ export const projects = pgTable(
goalId: uuid("goal_id").references(() => goals.id),
name: text("name").notNull(),
description: text("description"),
visibility: text("visibility").notNull().default("open"),
personalOwnerUserId: text("personal_owner_user_id"),
status: text("status").notNull().default("backlog"),
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
targetDate: date("target_date"),
@@ -27,5 +30,9 @@ export const projects = pgTable(
},
(table) => ({
companyIdx: index("projects_company_idx").on(table.companyId),
visibilityCheck: check("projects_visibility_check", sql`${table.visibility} in ('open', 'private')`),
personalOwnerUq: uniqueIndex("projects_company_personal_owner_uq")
.on(table.companyId, table.personalOwnerUserId)
.where(sql`${table.personalOwnerUserId} is not null`),
}),
);
@@ -23,10 +23,10 @@ export const workspaceOperations = pgTable(
onDelete: "set null",
}),
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, {
onDelete: "set null",
onDelete: "cascade",
}),
issueId: uuid("issue_id").references(() => issues.id, {
onDelete: "set null",
onDelete: "cascade",
}),
phase: text("phase").notNull(),
command: text("command"),
@@ -15,6 +15,8 @@ function issue(input: Partial<Issue> & Pick<Issue, "id" | "companyId" | "title">
projectWorkspaceId: null,
goalId: null,
parentId: null,
visibility: "open",
privacyRootIssueId: null,
title,
description: null,
status: "todo",
@@ -566,6 +566,8 @@ function paperclipIssue(overrides: Partial<Issue> = {}): Issue {
projectWorkspaceId: null,
goalId: null,
parentId: null,
visibility: "open",
privacyRootIssueId: null,
title: "Design event ingestion controls",
description: "Decide which Paperclip issues, comments, and documents can be ingested into the wiki.",
status: "todo",
+2
View File
@@ -1603,6 +1603,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
projectWorkspaceId: null,
goalId: input.goalId ?? null,
parentId: input.parentId ?? null,
visibility: "open",
privacyRootIssueId: null,
title: input.title,
description: input.description ?? null,
status: input.status ?? "todo",
+11
View File
@@ -952,6 +952,8 @@ export type {
SetupTokenTransportAdvisoryCode,
AssetImage,
Project,
ProjectAccessMember,
ProjectAccessSubjectType,
ProjectBudgetSummary,
ProjectRepository,
ProjectRepositoryOptions,
@@ -959,6 +961,7 @@ export type {
ProjectCodebaseOrigin,
ProjectGoalRef,
ProjectManagedByPlugin,
ProjectVisibility,
ProjectWorkspace,
CompanySearchCountType,
CompanySearchExtractIssueResult,
@@ -1087,6 +1090,12 @@ export type {
ExternalObjectSummaryItem,
CompactIssue,
Issue,
IssueVisibility,
IssueAccessGrant,
IssueAccessGrantAgentVisibility,
IssueAccessGrantSubjectType,
IssueAccessGrantSource,
IssueLockedStub,
IssueChangeReceiptEntry,
IssueChanges,
IssueAssigneeAdapterOverrides,
@@ -1910,10 +1919,12 @@ export {
type UpdateAgentPermissions,
createProjectSchema,
updateProjectSchema,
addProjectAccessMemberSchema,
createProjectWorkspaceSchema,
updateProjectWorkspaceSchema,
type CreateProject,
type UpdateProject,
type AddProjectAccessMember,
type CreateProjectWorkspace,
type UpdateProjectWorkspace,
projectExecutionWorkspacePolicySchema,
+6
View File
@@ -158,6 +158,8 @@ export interface HeartbeatRun {
id: string;
companyId: string;
agentId: string;
scopeKind: "company" | "issue";
issueId: string | null;
invocationSource: HeartbeatInvocationSource;
triggerDetail: WakeupTriggerDetail | null;
status: HeartbeatRunStatus;
@@ -222,6 +224,10 @@ export interface HeartbeatRun {
contextSnapshot: Record<string, unknown> | null;
createdAt: Date;
updatedAt: Date;
/** Metadata-only company-list row for a run bound to an unreadable private issue. */
redacted?: boolean;
durationMs?: number | null;
costUsd?: number | null;
outputSilence?: HeartbeatRunOutputSilence;
/**
* Ephemeral, process-local current status message for an active run. Resolved
+20 -1
View File
@@ -351,7 +351,20 @@ export type {
DocumentTextRange,
UpdateDocumentAnnotationThreadRequest,
} from "./document-annotation.js";
export type { Project, ProjectRepository, ProjectRepositoryOptions, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type {
Project,
ProjectRepository,
ProjectRepositoryOptions,
ProjectAccessMember,
ProjectAccessSubjectType,
ProjectBudgetSummary,
ProjectCodebase,
ProjectCodebaseOrigin,
ProjectGoalRef,
ProjectManagedByPlugin,
ProjectVisibility,
ProjectWorkspace,
} from "./project.js";
export type {
CompanySearchCountType,
CompanySearchExtractIssueResult,
@@ -663,6 +676,12 @@ export type {
IssueSubtreeDiagnosticNode,
IssueSubtreeDiagnosticEdge,
IssueSubtreeDiagnosticsResponse,
IssueVisibility,
IssueAccessGrant,
IssueAccessGrantAgentVisibility,
IssueAccessGrantSubjectType,
IssueAccessGrantSource,
IssueLockedStub,
IssueBlockerAttention,
IssueBlockerAttentionIssueSummary,
IssueBlockerAttentionReason,
+42
View File
@@ -46,6 +46,41 @@ import type {
export type { IssueWorkMode };
export type IssueVisibility = "open" | "private";
export type IssueAccessGrantSubjectType = "user" | "agent";
export type IssueAccessGrantSource = "explicit" | "assignment" | "project" | "owner";
export type IssueAccessGrantAgentVisibility = "discoverable" | "private";
export interface IssueAccessGrant {
id: string;
issueId: string;
subjectType: IssueAccessGrantSubjectType;
subjectId: string;
source: IssueAccessGrantSource;
/** Effective access inherited from a parent or private project; not revocable here. */
inherited?: boolean;
grantedByUserId: string | null;
grantedByAgentId: string | null;
createdAt: Date;
revokedAt: Date | null;
subjectDisplayName: string | null;
subjectAvatarUrl: string | null;
subjectInitials: string | null;
agentVisibility: IssueAccessGrantAgentVisibility | null;
}
/**
* Existence-only projection used when a readable issue references an issue the
* current principal cannot read. Edge projections may return this exact shape
* in place of an IssueRelationIssueSummary; direct reads and list/search rows
* never return locked stubs.
*/
export interface IssueLockedStub {
id: string;
identifier: string | null;
locked: true;
}
export interface IssueAncestorProject {
id: string;
name: string;
@@ -790,6 +825,11 @@ export interface Issue {
projectWorkspaceId: string | null;
goalId: string | null;
parentId: string | null;
/** Present on current API responses; optional for compatibility with older plugin payloads. */
visibility?: IssueVisibility;
privacyRootIssueId?: string | null;
/** Immediate task from which private access flows downward, including chat handoffs. */
privacyParentIssueId?: string | null;
ancestors?: IssueAncestor[];
title: string;
description: string | null;
@@ -882,6 +922,8 @@ export type CompactIssue = Pick<
| "projectWorkspaceId"
| "goalId"
| "parentId"
| "visibility"
| "privacyRootIssueId"
| "title"
| "description"
| "status"
+17
View File
@@ -8,6 +8,19 @@ import type { AgentEnvConfig } from "./secrets.js";
export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
export type ProjectWorkspaceVisibility = "default" | "advanced";
export type ProjectVisibility = "open" | "private";
export type ProjectAccessSubjectType = "user" | "agent";
export interface ProjectAccessMember {
id: string;
companyId: string;
projectId: string;
subjectType: ProjectAccessSubjectType;
subjectId: string;
subjectDisplayName: string | null;
subjectAvatarUrl: string | null;
createdAt: Date;
}
export interface ProjectGoalRef {
id: string;
@@ -85,6 +98,10 @@ export interface Project {
goals: ProjectGoalRef[];
name: string;
description: string | null;
/** Omitted by older servers/fixtures; clients must treat omission as open. */
visibility?: ProjectVisibility;
/** Present only for the lazily-created per-user "My private tasks" project. */
personalOwnerUserId?: string | null;
status: ProjectStatus;
leadAgentId: string | null;
targetDate: string | null;
+2
View File
@@ -389,12 +389,14 @@ export {
export {
createProjectSchema,
updateProjectSchema,
addProjectAccessMemberSchema,
createProjectWorkspaceSchema,
updateProjectWorkspaceSchema,
projectExecutionWorkspacePolicySchema,
projectWorkspaceRuntimeConfigSchema,
type CreateProject,
type UpdateProject,
type AddProjectAccessMember,
type CreateProjectWorkspace,
type UpdateProjectWorkspace,
type ProjectExecutionWorkspacePolicy,
@@ -15,6 +15,14 @@ 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 };
+1
View File
@@ -688,6 +688,7 @@ 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
@@ -106,6 +106,7 @@ 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(),
@@ -132,4 +133,11 @@ export const updateProjectSchema = objectWithoutDefaults(
export type UpdateProject = z.infer<typeof updateProjectSchema>;
export const addProjectAccessMemberSchema = z.object({
subjectType: z.enum(["user", "agent"]),
subjectId: z.string().min(1),
});
export type AddProjectAccessMember = z.infer<typeof addProjectAccessMemberSchema>;
export type ProjectExecutionWorkspacePolicy = z.infer<typeof projectExecutionWorkspacePolicySchema>;
@@ -1484,6 +1484,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
startedAt: now,
});
// Complete the circular issue/run fixture after both FK targets exist.
await db.update(heartbeatRuns)
.set({ scopeKind: "issue", issueId })
.where(eq(heartbeatRuns.id, runId));
return { companyId, agentId, runId, wakeupRequestId, issueId };
}
@@ -474,6 +474,11 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
identifier: `${issuePrefix}-1`,
});
// Complete the circular issue/run fixture after both FK targets exist.
await db.update(heartbeatRuns)
.set({ scopeKind: "issue", issueId })
.where(eq(heartbeatRuns.id, runId));
return { companyId, agentId, issueId, runId, now };
}
@@ -1250,8 +1255,8 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
.from(executionWorkspaces)
.where(inArray(executionWorkspaces.id, [currentWorkspaceId, foreignWorkspaceId]));
expect(workspaces).toEqual(expect.arrayContaining([
expect.objectContaining({ id: currentWorkspaceId, status: "active", metadata: { current: true } }),
expect.objectContaining({ id: foreignWorkspaceId, status: "active", metadata: { foreign: true } }),
expect.objectContaining({ id: currentWorkspaceId, status: "active", metadata: expect.objectContaining({ current: true }) }),
expect.objectContaining({ id: foreignWorkspaceId, status: "active", metadata: expect.objectContaining({ foreign: true }) }),
]));
const activity = await db
@@ -1378,8 +1383,8 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
.from(executionWorkspaces)
.where(inArray(executionWorkspaces.id, [staleWorkspaceId, currentWorkspaceId]));
expect(workspaces).toEqual(expect.arrayContaining([
expect.objectContaining({ id: staleWorkspaceId, status: "active", metadata: { stale: true } }),
expect.objectContaining({ id: currentWorkspaceId, status: "active", metadata: { current: true } }),
expect.objectContaining({ id: staleWorkspaceId, status: "active", metadata: expect.objectContaining({ stale: true }) }),
expect.objectContaining({ id: currentWorkspaceId, status: "active", metadata: expect.objectContaining({ current: true }) }),
]));
const activity = await db
@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
@@ -361,12 +361,16 @@ describeEmbeddedPostgres("issue recovery actions", () => {
it.each(["company", "issue", "legacy", "finished"])("rejects an apparently live run with the wrong %s authority", async (mismatch) => {
const { companyId, sourceIssueId, runId, svc } = await seedNativeFinalizationRecovery("running");
const other = await seedCompany();
await db.update(heartbeatRuns).set({
// Exercise service rejection of historical corruption behind the immutable binding trigger.
await db.transaction(async (tx) => {
await tx.execute(sql`set local session_replication_role = replica`);
await tx.update(heartbeatRuns).set({
...(mismatch === "company" ? { companyId: other.companyId, agentId: other.coderId } : {}),
...(mismatch === "issue" ? { nativeIssueId: other.sourceIssueId } : {}),
...(mismatch === "legacy" ? { runtimeMode: "legacy" } : {}),
...(mismatch === "finished" ? { finishedAt: new Date() } : {}),
}).where(eq(heartbeatRuns.id, runId));
});
expect((await svc.getActiveForIssue(companyId, sourceIssueId))?.nativeRunActivity).toBeNull();
});
@@ -335,18 +335,21 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
permissions: {},
});
await db.update(heartbeatRuns)
.set({ nativeIssueId: randomUUID() })
.where(eq(heartbeatRuns.id, fixture.runId));
// Simulate historical corruption to retain the service-level defense test.
await db.transaction(async (tx) => {
await tx.execute(sql`set local session_replication_role = replica`);
await tx.update(heartbeatRuns).set({ nativeIssueId: randomUUID() }).where(eq(heartbeatRuns.id, fixture.runId));
});
await expect(interactionsSvc.create(
{ id: fixture.issueId, companyId: fixture.companyId },
questionCreateInput(fixture.runId),
{ agentId: fixture.agentId, runId: fixture.runId },
)).rejects.toMatchObject({ status: 422, message: "sourceRunId must belong to the same issue" });
await db.update(heartbeatRuns)
.set({ nativeIssueId: null, contextSnapshot: { issueId: randomUUID(), paperclipWake: { comments: [] } } })
.where(eq(heartbeatRuns.id, fixture.runId));
await db.transaction(async (tx) => {
await tx.execute(sql`set local session_replication_role = replica`);
await tx.update(heartbeatRuns).set({ nativeIssueId: null, contextSnapshot: { issueId: randomUUID(), paperclipWake: { comments: [] } } }).where(eq(heartbeatRuns.id, fixture.runId));
});
await expect(interactionsSvc.create(
{ id: fixture.issueId, companyId: fixture.companyId },
questionCreateInput(fixture.runId),
@@ -188,7 +188,10 @@ describeEmbeddedPostgres("reportRunFailure", () => {
exceptions: [{ message: "adapter threw" }, { message: "provider unreachable", code: "ECONNRESET", status: 503, requestId: "request-123" }],
});
expect(captured.diagnostics.exceptions[0].stack).toContain("run-failure-report.test.ts");
expect(JSON.stringify(captured)).not.toContain("private-");
// Match the sensitive fixture values, not a legitimate checkout name in the stack.
for (const value of ["private-response", "private-stderr", "private-stdout", "private-prompt", "private-provider-raw", "private-summary", "private-env", "private-adapter-response"]) {
expect(JSON.stringify(captured)).not.toContain(value);
}
expect(error.cause).toBe(cause);
});
@@ -2088,11 +2088,13 @@ describe("native external-chat response wait", () => {
.update(agentWakeupRequests)
.set({ requestedByActorId: "another-user" })
.where(eq(agentWakeupRequests.id, parent.wakeId));
if (kind === "different_parent_issue")
await db
.update(heartbeatRuns)
.set({ nativeIssueId: fixture.sourceRunId })
.where(eq(heartbeatRuns.id, parent.runId));
if (kind === "different_parent_issue") {
// Deliberately corrupt historical data; normal writes reject this rebind.
await db.transaction(async (tx) => {
await tx.execute(sql`set local session_replication_role = replica`);
await tx.update(heartbeatRuns).set({ nativeIssueId: fixture.sourceRunId }).where(eq(heartbeatRuns.id, parent.runId));
});
}
if (kind === "source_cycle") {
const [wake] = await db
.select()
@@ -943,9 +943,16 @@ describe("PaperclipControlPlanePort conformance", () => {
restore: Partial<typeof heartbeatRuns.$inferInsert>;
}>;
for (const mutation of mutations) {
await db.update(heartbeatRuns).set(mutation.invalid).where(eq(heartbeatRuns.id, runId));
// Verify service-level binding checks even for corrupted historical rows.
await db.transaction(async (tx) => {
await tx.execute(sql`set local session_replication_role = replica`);
await tx.update(heartbeatRuns).set(mutation.invalid).where(eq(heartbeatRuns.id, runId));
});
await expect(createPort().openRun(open)).rejects.toThrow("native_open_run_not_authorized");
await db.update(heartbeatRuns).set(mutation.restore).where(eq(heartbeatRuns.id, runId));
await db.transaction(async (tx) => {
await tx.execute(sql`set local session_replication_role = replica`);
await tx.update(heartbeatRuns).set(mutation.restore).where(eq(heartbeatRuns.id, runId));
});
}
const openedPort = createPort();
+11 -4
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
agentSessionGoalActions,
@@ -352,10 +352,17 @@ describeEmbeddedPostgres("runner goal service", () => {
{ runnerInstanceId: randomUUID() },
{ status: "succeeded" },
]) {
await db.update(heartbeatRuns).set(invalidOwner).where(eq(heartbeatRuns.id, nextRunId));
// Corrupt a historical binding to verify the reader's independent guard.
await db.transaction(async (tx) => {
await tx.execute(sql`set local session_replication_role = replica`);
await tx.update(heartbeatRuns).set(invalidOwner).where(eq(heartbeatRuns.id, nextRunId));
});
await expect(applyRunnerGoalPrpEvent(db, eventBinding, successorEvent)).resolves.toBeNull();
await db.update(heartbeatRuns).set({ nativeSessionId, nativeIssueId: binding.issueId, runnerInstanceId: runnerId, status: "running" })
.where(eq(heartbeatRuns.id, nextRunId));
await db.transaction(async (tx) => {
await tx.execute(sql`set local session_replication_role = replica`);
await tx.update(heartbeatRuns).set({ nativeSessionId, nativeIssueId: binding.issueId, runnerInstanceId: runnerId, status: "running" })
.where(eq(heartbeatRuns.id, nextRunId));
});
}
await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, priorRunId));
await expect(applyRunnerGoalPrpEvent(db, eventBinding, successorEvent)).resolves.toBeNull();
@@ -38,6 +38,7 @@ function createRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
issueId: null,
responsibleUserId: null,
invocationSource: "on_demand",
triggerDetail: "manual",
@@ -82,6 +83,7 @@ function createRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
createdAt: new Date("2026-04-20T11:58:00.000Z"),
updatedAt: new Date("2026-04-20T11:59:00.000Z"),
...overrides,
scopeKind: overrides.scopeKind ?? "company",
};
}
+2
View File
@@ -135,6 +135,8 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
id,
companyId: "company-1",
agentId,
scopeKind: "company",
issueId: null,
responsibleUserId: "user-1",
invocationSource: "assignment",
triggerDetail: null,
+2
View File
@@ -287,6 +287,8 @@ function createApproval(overrides: Partial<Approval> = {}): Approval {
function createFailedRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
return {
id: "run-1",
issueId: null,
scopeKind: "company",
companyId: "company-1",
agentId: "agent-1",
responsibleUserId: null,
@@ -96,6 +96,7 @@ function makeHeartbeatRun(overrides: Partial<HeartbeatRun>): HeartbeatRun {
id: "run-fixture",
companyId,
agentId: "agent-codex",
issueId: null,
responsibleUserId: null,
invocationSource: "on_demand",
triggerDetail: "manual",
@@ -139,6 +140,7 @@ function makeHeartbeatRun(overrides: Partial<HeartbeatRun>): HeartbeatRun {
nextAction: null,
contextSnapshot: null,
...overrides,
scopeKind: overrides.scopeKind ?? "company",
createdAt,
updatedAt: overrides.updatedAt ?? createdAt,
};