mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 20:53:23 +02:00
Add private task and project ownership, downward access grants, and immutable run/workspace provenance. Apply migration 0313 with bounded batch commits and concurrent indexes. Keep the enforcement and sharing changes in their dependent PRs. Co-Authored-By: Paperclip <noreply@paperclip.ing>
212 lines
12 KiB
TypeScript
212 lines
12 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import {
|
|
type AnyPgColumn,
|
|
pgTable,
|
|
uuid,
|
|
text,
|
|
timestamp,
|
|
integer,
|
|
jsonb,
|
|
index,
|
|
uniqueIndex,
|
|
unique,
|
|
bigint,
|
|
boolean,
|
|
check,
|
|
} from "drizzle-orm/pg-core";
|
|
import { agents } from "./agents.js";
|
|
import { projects } from "./projects.js";
|
|
import { goals } from "./goals.js";
|
|
import { companies } from "./companies.js";
|
|
import { heartbeatRuns } from "./heartbeat_runs.js";
|
|
import { projectWorkspaces } from "./project_workspaces.js";
|
|
import { executionWorkspaces } from "./execution_workspaces.js";
|
|
import type { IssueReviewPolicy, IssueUnblockDescriptor, SourceTrustMetadata } from "@paperclipai/shared";
|
|
|
|
export const issues = pgTable(
|
|
"issues",
|
|
{
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
|
// Conversation identity and session boundaries are owned by the server.
|
|
conversationAgentId: uuid("conversation_agent_id").references(() => agents.id),
|
|
conversationUserId: text("conversation_user_id"),
|
|
conversationState: text("conversation_state").$type<"active" | "waiting">(),
|
|
conversationSessionGeneration: integer("conversation_session_generation").notNull().default(0),
|
|
conversationBoundaryCommentId: uuid("conversation_boundary_comment_id"),
|
|
projectId: uuid("project_id").references(() => projects.id),
|
|
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(),
|
|
titleNeedsGeneration: boolean("title_needs_generation").notNull().default(false),
|
|
description: text("description"),
|
|
status: text("status").notNull().default("backlog"),
|
|
statusVersion: bigint("status_version", { mode: "number" }).notNull().default(0),
|
|
lastStatusDecisionId: uuid("last_status_decision_id"),
|
|
workMode: text("work_mode").notNull().default("standard"),
|
|
harnessKind: text("harness_kind"),
|
|
priority: text("priority").notNull().default("medium"),
|
|
reviewPolicy: text("review_policy").$type<IssueReviewPolicy>(),
|
|
assigneeAgentId: uuid("assignee_agent_id").references(() => agents.id),
|
|
assigneeUserId: text("assignee_user_id"),
|
|
checkoutRunId: uuid("checkout_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
|
executionRunId: uuid("execution_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
|
executionAgentNameKey: text("execution_agent_name_key"),
|
|
executionLockedAt: timestamp("execution_locked_at", { withTimezone: true }),
|
|
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id),
|
|
createdByUserId: text("created_by_user_id"),
|
|
responsibleUserId: text("responsible_user_id"),
|
|
issueNumber: integer("issue_number"),
|
|
identifier: text("identifier"),
|
|
originKind: text("origin_kind").notNull().default("manual"),
|
|
originId: text("origin_id"),
|
|
originRunId: text("origin_run_id"),
|
|
originIdentityContextId: uuid("origin_identity_context_id"),
|
|
continuationIdentityContextId: uuid("continuation_identity_context_id"),
|
|
originFingerprint: text("origin_fingerprint").notNull().default("default"),
|
|
requestDepth: integer("request_depth").notNull().default(0),
|
|
billingCode: text("billing_code"),
|
|
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
|
|
executionPolicy: jsonb("execution_policy").$type<Record<string, unknown>>(),
|
|
executionState: jsonb("execution_state").$type<Record<string, unknown>>(),
|
|
monitorNextCheckAt: timestamp("monitor_next_check_at", { withTimezone: true }),
|
|
monitorWakeRequestedAt: timestamp("monitor_wake_requested_at", { withTimezone: true }),
|
|
monitorLastTriggeredAt: timestamp("monitor_last_triggered_at", { withTimezone: true }),
|
|
monitorAttemptCount: integer("monitor_attempt_count").notNull().default(0),
|
|
monitorNotes: text("monitor_notes"),
|
|
monitorScheduledBy: text("monitor_scheduled_by"),
|
|
executionWorkspaceId: uuid("execution_workspace_id")
|
|
.references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }),
|
|
executionWorkspacePreference: text("execution_workspace_preference"),
|
|
executionWorkspaceSettings: jsonb("execution_workspace_settings").$type<Record<string, unknown>>(),
|
|
sourceTrust: jsonb("source_trust").$type<SourceTrustMetadata | null>(),
|
|
unblockDescriptor: jsonb("unblock_descriptor").$type<IssueUnblockDescriptor | null>(),
|
|
blockedTransitionAt: timestamp("blocked_transition_at", { withTimezone: true }),
|
|
blockedOwnerNotifiedAt: timestamp("blocked_owner_notified_at", { withTimezone: true }),
|
|
startedAt: timestamp("started_at", { withTimezone: true }),
|
|
completedAt: timestamp("completed_at", { withTimezone: true }),
|
|
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
|
|
hiddenAt: timestamp("hidden_at", { withTimezone: true }),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
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
|
|
) or (
|
|
${table.conversationAgentId} is not null and ${table.conversationUserId} is not null
|
|
and ${table.assigneeAgentId} = ${table.conversationAgentId} and ${table.assigneeAgentId} is not null
|
|
and ${table.assigneeUserId} is null and ${table.conversationState} is not null
|
|
and ${table.conversationState} in ('active', 'waiting')
|
|
and ${table.status} not in ('done', 'cancelled')
|
|
)`),
|
|
companyIdUq: unique("issues_company_id_uq").on(table.companyId, table.id),
|
|
companyStatusIdx: index("issues_company_status_idx").on(table.companyId, table.status),
|
|
companyHarnessKindIdx: index("issues_company_harness_kind_idx").on(table.companyId, table.harnessKind),
|
|
assigneeStatusIdx: index("issues_company_assignee_status_idx").on(
|
|
table.companyId,
|
|
table.assigneeAgentId,
|
|
table.status,
|
|
),
|
|
assigneeUserStatusIdx: index("issues_company_assignee_user_status_idx").on(
|
|
table.companyId,
|
|
table.assigneeUserId,
|
|
table.status,
|
|
),
|
|
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),
|
|
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
|
dueMonitorIdx: index("issues_company_monitor_due_idx").on(table.companyId, table.monitorNextCheckAt),
|
|
companyUpdatedIdx: index("issues_company_updated_idx").on(table.companyId, table.updatedAt),
|
|
companyCreatedIdx: index("issues_company_created_idx").on(table.companyId, table.createdAt),
|
|
openNormalizedTitleCreatedIdx: index("issues_open_normalized_title_created_idx")
|
|
.on(
|
|
table.companyId,
|
|
table.parentId,
|
|
sql`lower(regexp_replace(btrim(${table.title}), '\\s+', ' ', 'g'))`,
|
|
table.createdAt,
|
|
)
|
|
.where(sql`${table.hiddenAt} is null and ${table.status} not in ('done', 'cancelled')`),
|
|
companyPriorityIdx: index("issues_company_priority_idx").on(table.companyId, table.priority),
|
|
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
|
|
titleSearchIdx: index("issues_title_search_idx").using("gin", table.title.op("gin_trgm_ops")),
|
|
identifierSearchIdx: index("issues_identifier_search_idx").using("gin", table.identifier.op("gin_trgm_ops")),
|
|
descriptionSearchIdx: index("issues_description_search_idx").using("gin", table.description.op("gin_trgm_ops")),
|
|
openRoutineExecutionIdx: uniqueIndex("issues_open_routine_execution_uq")
|
|
.on(table.companyId, table.originKind, table.originId, table.originFingerprint)
|
|
.where(
|
|
sql`${table.originKind} = 'routine_execution'
|
|
and ${table.originId} is not null
|
|
and ${table.hiddenAt} is null
|
|
and ${table.executionRunId} is not null
|
|
and ${table.status} in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked')`,
|
|
),
|
|
activeLivenessRecoveryIncidentIdx: uniqueIndex("issues_active_liveness_recovery_incident_uq")
|
|
.on(table.companyId, table.originKind, table.originId)
|
|
.where(
|
|
sql`${table.originKind} = 'harness_liveness_escalation'
|
|
and ${table.originId} is not null
|
|
and ${table.hiddenAt} is null
|
|
and ${table.status} not in ('done', 'cancelled')`,
|
|
),
|
|
activeLivenessRecoveryLeafIdx: uniqueIndex("issues_active_liveness_recovery_leaf_uq")
|
|
.on(table.companyId, table.originKind, table.originFingerprint)
|
|
.where(
|
|
sql`${table.originKind} = 'harness_liveness_escalation'
|
|
and ${table.originFingerprint} <> 'default'
|
|
and ${table.hiddenAt} is null
|
|
and ${table.status} not in ('done', 'cancelled')`,
|
|
),
|
|
activeStaleRunEvaluationIdx: uniqueIndex("issues_active_stale_run_evaluation_uq")
|
|
.on(table.companyId, table.originKind, table.originId)
|
|
.where(
|
|
sql`${table.originKind} = 'stale_active_run_evaluation'
|
|
and ${table.originId} is not null
|
|
and ${table.hiddenAt} is null
|
|
and ${table.status} not in ('done', 'cancelled')`,
|
|
),
|
|
activeTaskWatchdogIdx: uniqueIndex("issues_active_task_watchdog_uq")
|
|
.on(table.companyId, table.originKind, table.originId)
|
|
.where(
|
|
sql`${table.originKind} = 'task_watchdog'
|
|
and ${table.originId} is not null
|
|
and ${table.hiddenAt} is null
|
|
and ${table.status} not in ('done', 'cancelled')`,
|
|
),
|
|
activeProductivityReviewIdx: uniqueIndex("issues_active_productivity_review_uq")
|
|
.on(table.companyId, table.originKind, table.originId)
|
|
.where(
|
|
sql`${table.originKind} = 'issue_productivity_review'
|
|
and ${table.originId} is not null
|
|
and ${table.hiddenAt} is null
|
|
and ${table.status} not in ('done', 'cancelled')`,
|
|
),
|
|
activeStrandedIssueRecoveryIdx: uniqueIndex("issues_active_stranded_issue_recovery_uq")
|
|
.on(table.companyId, table.originKind, table.originId)
|
|
.where(
|
|
sql`${table.originKind} = 'stranded_issue_recovery'
|
|
and ${table.originId} is not null
|
|
and ${table.hiddenAt} is null
|
|
and ${table.status} not in ('done', 'cancelled')`,
|
|
),
|
|
// The onboarding first-task origin grants privileged behavior (agent-attributed
|
|
// greeting, description suppression), so at most one issue per company may ever
|
|
// carry it — concurrent creates race on the pre-insert count check and this
|
|
// index is what atomically rejects the loser.
|
|
onboardingFirstTaskIdx: uniqueIndex("issues_onboarding_first_task_uq")
|
|
.on(table.companyId)
|
|
.where(sql`${table.originKind} = 'onboarding_first_task'`),
|
|
}),
|
|
);
|