mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Add private task storage, contracts, and durable source bindings
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d30b03bd8c
commit
8c6fc1b4d4
36 files changed
+51121
-28
No files matched your search
@@ -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 $$;
|
||||
+50441
File diff suppressed because it is too large.
Load diff
+7
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
@@ -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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user