mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Users create tasks with a title and a description. > - A required title adds work when the prompt already explains the request. > - An agent can name the task once it reads that request. > - This pull request accepts prompt-only tasks and starts them with a short prompt slice. > - A scoped title tool lets the assigned agent replace that slice early without changing execution state. > - A live browser eval checks the real agent call, saved title, audit entry, and preservation of user titles. ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: task creation, shared contracts, database, server, runner tools, and board UI. **Problem or motivation** Users must currently write a title before they can submit a detailed task prompt. The agent has enough context to write a useful title itself. **Proposed solution** Make the title optional when a description is present. Save the first 120 characters of the normalized prompt as a provisional title. Ask the assigned agent to call `set_task_title` early. Use an atomic provisional-title guard to preserve titles supplied or edited by users. Keep explicit titles supported. Related: #14543 and #14556 concern empty-title submission. This change intentionally enables that submission when a prompt is present, instead of requiring a title. ## What Changed - Add the `titleNeedsGeneration` field with an idempotent migration. Keep existing titles unchanged. - Add `PUT /api/issues/:id/title` and the native and legacy `set_task_title` tool. Enforce company access, active-run ownership, shared, bounded retry receipts across native/HTTP calls, and transactional audit logging. Refresh external-object links after commit, with the same feature gate and plugin detectors as ordinary title edits. - Add early naming guidance in Standard, Ask, and Plan task context. Preserve the description, status, and assignment. - Allow prompt-only root and child task creation, plus draft restoration in the New Task dialog. Keep user titles supported. - Add an opt-in Product E2E suite for prompt-only Standard and Ask tasks, plus an explicit-title control. It checks actual provider calls within the first five tools, persisted state, audit attribution, and the reloaded UI. - Preserve a closed vocabulary of API key maintenance phrases in declared prose while rejecting opaque credential suffixes. Add one bounded naming retry after wording is rejected, without treating the rejected call as a saved title. - Repair the native cleanup receipt check exposed during full verification: accept matching input digests, retain legacy input checks, and reject conflicting receipts. ## Verification - Live Product E2E on `f43478473800e3a46b85c5ee79677efdb15108e7`: **3/3 passed** with native Codex `gpt-5.4-mini`, first attempts only, automatic retries disabled. Standard and Ask each saved “Rotate expired API key” on their first tool call, with matching persisted state and a single same-run audit entry. The explicit-title control retained its user title with zero title writes. All three verified the reloaded browser UI. - Campaign: `local-2026-09-30T21-30-11-021Z`. Earlier failed campaigns are retained separately; they exposed credential-prose handling and prompted the naming recovery fix. No failed result was regraded or deleted. - Reproduce with `pnpm test:e2e:runner -- --id task-titles.runner-codex-mini.local.prompt-title-standard --id task-titles.runner-codex-mini.local.prompt-title-ask --id task-titles.runner-codex-mini.local.preserve-explicit-title --max-automatic-retries 0` and an authorized provider key. - Full `pnpm -r typecheck` and `pnpm build` passed on the latest commit. The runner build used the configured external eval source tree. - Product E2E unit suite: **61 files, 818 tests passed**; E2E typecheck and UI token gates passed. - Title API/native regressions cover prompt-only and explicit child creation, user edits, ownership/company isolation, external reference refresh, cross-surface retry replay, and the 64-key limit without receipt eviction. All passed. Prompt-context coverage: **44 tests passed**. - Rust credential regressions: **35 tests passed**, including benign maintenance qualifiers and opaque credential rejection in every declared prose field. Catalog/report reconciliation: **28 tests passed**. Native recovery: **560 tests passed**. - Broad local `pnpm test:run`: **14,555 tests passed** in the general server group; two suites failed to initialize embedded PostgreSQL and the existing 40,000-file Git streaming stress test exceeded its 300-second macOS timeout. All three suites then passed in isolation (**5 tests passed**) without code or timeout changes. The original full local command exited nonzero and is not being represented as a clean full run. - Latest-head GitHub checks are green: **53 passed, 4 skipped, zero failed or pending**, including all test shards and the canary packaging dry run. Greptile reviewed the same commit at **5/5**, with zero unresolved review threads. ## Risks - The additive database field must reach the server and UI together. The migration uses `IF NOT EXISTS` and defaults existing tasks to a final title. - Title generation depends on the assigned agent running. Tasks without a run keep their provisional title. - Live qualification covers the native Codex path in Standard and Ask modes. API/legacy and Plan behavior have deterministic coverage. - The credential-prose exception validates the entire suffix against a closed maintenance vocabulary. Unknown suffixes, assignments, quoted values, credential prefixes, and diagnostics retain strict checks. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, tool use, and code execution. The exact deployment ID and context window are not exposed in this session. The live eval uses the native Codex `gpt-5.4-mini` profile. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
206 lines
11 KiB
TypeScript
206 lines
11 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),
|
|
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) => ({
|
|
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),
|
|
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'`),
|
|
}),
|
|
);
|