mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
[codex] Split backend control-plane QoL slice (#4700)
## Thinking Path > - Paperclip is the control plane for autonomous AI companies, so backend task ownership, recovery, review visibility, and company-scoped limits need to stay enforceable without UI-only coupling. > - Closed PR #4692 bundled those backend changes with UI workflow, docs, skills, workflow, and lockfile churn. > - PAP-2694 asks for a clean backend/control-plane slice from that closed branch. > - This branch starts from current `master` and mines only the `cli`, `packages/db`, `packages/shared`, and `server` contracts/tests needed for the backend behavior. > - It explicitly excludes UI workflow/performance work, `.github/workflows/pr.yml`, `pnpm-lock.yaml`, docs, skills, package-script, adapter UI build-config, and perf fixture script changes; the only UI files are fixture/test updates required by the tightened shared `Company` contract. > - The benefit is a smaller reviewable PR that preserves the control-plane fixes while staying under Greptile s 100-file review limit. ## What Changed - Added company-scoped attachment-size limits through DB schema/migrations, shared company portability contracts, CLI import/export coverage, and server attachment upload enforcement. - Added productivity review service/API behavior for no-comment streak, long-active, and high-churn review issues, including request-depth clamping and issue summary exposure. - Hardened issue ownership and recovery/control-plane paths: peer-agent mutation denial, issue tree pause/resume behavior, stranded recovery origins, and related activity/test coverage. - Preserved related backend contract updates for routine timestamp variables and managed agent instruction bundles because they live in shared/server contracts from the source branch. - Addressed Greptile feedback by making `Company.attachmentMaxBytes` non-optional, simplifying review request-depth clamping, fixing the migration final newline, and enforcing the process-level attachment cap as the final ceiling for uploads. - Added minimal company fixtures needed for repo-wide typecheck/build and kept the PR to 66 changed files with forbidden/non-slice paths excluded. ## Verification - `pnpm install --frozen-lockfile` - `git diff --check origin/master..HEAD` - `git diff --name-only origin/master..HEAD | wc -l` -> 66 files - `git diff --name-only origin/master..HEAD -- .github/workflows/pr.yml pnpm-lock.yaml package.json doc skills .agents scripts packages/adapters` -> no output - `pnpm exec vitest run --config vitest.config.ts packages/shared/src/validators/issue.test.ts packages/shared/src/routine-variables.test.ts packages/shared/src/adapter-types.test.ts cli/src/__tests__/company-import-export-e2e.test.ts cli/src/__tests__/company.test.ts server/src/__tests__/productivity-review-service.test.ts server/src/__tests__/issue-tree-control-service.test.ts server/src/__tests__/issue-tree-control-routes.test.ts server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts server/src/__tests__/issue-attachment-routes.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/issues-service.test.ts` -> 12 files, 147 tests passed - `pnpm exec vitest run --config vitest.config.ts cli/src/__tests__/company-delete.test.ts cli/src/__tests__/company-import-export-e2e.test.ts server/src/__tests__/productivity-review-service.test.ts` -> 3 files, 18 tests passed - `pnpm exec vitest run --config vitest.config.ts server/src/__tests__/issue-attachment-routes.test.ts` -> 1 file, 6 tests passed - `pnpm --filter @paperclipai/db typecheck && pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter paperclipai typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck && pnpm --filter @paperclipai/ui build` ## Risks - Includes migrations `0073_shiny_salo.sql` and `0074_striped_genesis.sql`; merge ordering matters if another PR adds migrations first. - This is intentionally backend-only apart from fixture/test updates forced by shared type correctness; UI affordances from PR #4692 are not present here and should land in separate UI slices. - The worktree install emitted plugin SDK bin-link warnings for unbuilt plugin packages, but the targeted tests and package typechecks completed successfully. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected; check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled terminal/GitHub workflow. Exact runtime context window was not exposed by the harness. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
d9f540c331
commit
1991ec9d6f
66 changed files with 34186 additions and 148 deletions
|
|
@ -27,8 +27,13 @@ import {
|
|||
projectWorkspaces,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||
import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
|
||||
import type {
|
||||
IssueBlockerAttention,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
IssueRelationIssueSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { clampIssueRequestDepth, extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import {
|
||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
|
|
@ -107,6 +112,7 @@ export interface IssueFilters {
|
|||
includeBlockedBy?: boolean;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
type IssueRow = typeof issues.$inferSelect;
|
||||
|
|
@ -666,6 +672,17 @@ const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execut
|
|||
const BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES = ["pending"];
|
||||
const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"];
|
||||
const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND = "harness_liveness_escalation";
|
||||
const PRODUCTIVITY_REVIEW_ORIGIN_KIND = "issue_productivity_review";
|
||||
const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"];
|
||||
const PRODUCTIVITY_REVIEW_ACTIVITY_ACTIONS = [
|
||||
"issue.productivity_review_created",
|
||||
"issue.productivity_review_updated",
|
||||
];
|
||||
const PRODUCTIVITY_REVIEW_TRIGGERS: readonly IssueProductivityReviewTrigger[] = [
|
||||
"no_comment_streak",
|
||||
"long_active_duration",
|
||||
"high_churn",
|
||||
];
|
||||
const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"];
|
||||
const BLOCKER_ATTENTION_MAX_DEPTH = 8;
|
||||
const BLOCKER_ATTENTION_MAX_NODES = 2000;
|
||||
|
|
@ -876,6 +893,114 @@ async function terminalExplicitBlockersByRoot(
|
|||
return terminalByRoot;
|
||||
}
|
||||
|
||||
function readProductivityReviewTrigger(value: unknown): IssueProductivityReviewTrigger | null {
|
||||
if (typeof value !== "string") return null;
|
||||
return PRODUCTIVITY_REVIEW_TRIGGERS.includes(value as IssueProductivityReviewTrigger)
|
||||
? (value as IssueProductivityReviewTrigger)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readProductivityReviewStreak(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
async function listIssueProductivityReviewMap(
|
||||
dbOrTx: any,
|
||||
companyId: string,
|
||||
sourceIssueIds: string[],
|
||||
): Promise<Map<string, IssueProductivityReview>> {
|
||||
const map = new Map<string, IssueProductivityReview>();
|
||||
if (sourceIssueIds.length === 0) return map;
|
||||
|
||||
const reviewRows: Array<{
|
||||
sourceIssueId: string | null;
|
||||
reviewIssueId: string;
|
||||
reviewIdentifier: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}> = [];
|
||||
for (const chunk of chunkList([...new Set(sourceIssueIds)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const rows = await dbOrTx
|
||||
.select({
|
||||
sourceIssueId: issues.originId,
|
||||
reviewIssueId: issues.id,
|
||||
reviewIdentifier: issues.identifier,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
createdAt: issues.createdAt,
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
|
||||
inArray(issues.originId, chunk),
|
||||
isNull(issues.hiddenAt),
|
||||
notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES),
|
||||
),
|
||||
);
|
||||
reviewRows.push(...rows);
|
||||
}
|
||||
|
||||
if (reviewRows.length === 0) return map;
|
||||
|
||||
const reviewIssueIds = reviewRows.map((row) => row.reviewIssueId);
|
||||
const triggerByReviewIssueId = new Map<
|
||||
string,
|
||||
{ trigger: IssueProductivityReviewTrigger | null; noCommentStreak: number | null }
|
||||
>();
|
||||
for (const chunk of chunkList(reviewIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const detailRows = await dbOrTx
|
||||
.select({
|
||||
entityId: activityLog.entityId,
|
||||
details: activityLog.details,
|
||||
createdAt: activityLog.createdAt,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
inArray(activityLog.entityId, chunk),
|
||||
inArray(activityLog.action, PRODUCTIVITY_REVIEW_ACTIVITY_ACTIONS),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(activityLog.createdAt));
|
||||
for (const row of detailRows as Array<{
|
||||
entityId: string;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
}>) {
|
||||
if (triggerByReviewIssueId.has(row.entityId)) continue;
|
||||
triggerByReviewIssueId.set(row.entityId, {
|
||||
trigger: readProductivityReviewTrigger(row.details?.trigger),
|
||||
noCommentStreak: readProductivityReviewStreak(row.details?.noCommentStreak),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of reviewRows) {
|
||||
if (!row.sourceIssueId) continue;
|
||||
const detail = triggerByReviewIssueId.get(row.reviewIssueId);
|
||||
map.set(row.sourceIssueId, {
|
||||
reviewIssueId: row.reviewIssueId,
|
||||
reviewIdentifier: row.reviewIdentifier,
|
||||
status: row.status as IssueProductivityReview["status"],
|
||||
priority: row.priority as IssueProductivityReview["priority"],
|
||||
trigger: detail?.trigger ?? null,
|
||||
noCommentStreak: detail?.noCommentStreak ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
async function listIssueBlockerAttentionMap(
|
||||
dbOrTx: any,
|
||||
companyId: string,
|
||||
|
|
@ -1959,6 +2084,9 @@ export function issueService(db: Db) {
|
|||
const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit)
|
||||
? Math.max(1, Math.floor(filters.limit))
|
||||
: undefined;
|
||||
const offset = typeof filters?.offset === "number" && Number.isFinite(filters.offset)
|
||||
? Math.max(0, Math.floor(filters.offset))
|
||||
: 0;
|
||||
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
|
||||
const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
|
||||
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
|
||||
|
|
@ -2081,8 +2209,12 @@ export function issueService(db: Db) {
|
|||
asc(priorityOrder),
|
||||
desc(canonicalLastActivityAt),
|
||||
desc(issues.updatedAt),
|
||||
desc(issues.id),
|
||||
);
|
||||
const rows = (limit === undefined ? await baseQuery : await baseQuery.limit(limit)).map((row) => ({
|
||||
const pageQuery = offset > 0
|
||||
? (limit === undefined ? baseQuery.offset(offset) : baseQuery.limit(limit).offset(offset))
|
||||
: (limit === undefined ? baseQuery : baseQuery.limit(limit));
|
||||
const rows = (await pageQuery).map((row) => ({
|
||||
...row,
|
||||
description: decodeDatabaseTextPreview(row.description, ISSUE_LIST_DESCRIPTION_MAX_CHARS),
|
||||
}));
|
||||
|
|
@ -2108,7 +2240,10 @@ export function issueService(db: Db) {
|
|||
]);
|
||||
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
|
||||
const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row]));
|
||||
const blockerAttentionByIssueId = await listIssueBlockerAttentionMap(db, companyId, withRuns);
|
||||
const [blockerAttentionByIssueId, productivityReviewByIssueId] = await Promise.all([
|
||||
listIssueBlockerAttentionMap(db, companyId, withRuns),
|
||||
listIssueProductivityReviewMap(db, companyId, issueIds),
|
||||
]);
|
||||
|
||||
if (!contextUserId) {
|
||||
return withRuns.map((row) => {
|
||||
|
|
@ -2123,6 +2258,9 @@ export function issueService(db: Db) {
|
|||
...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}),
|
||||
lastActivityAt,
|
||||
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
|
||||
...(productivityReviewByIssueId.has(row.id)
|
||||
? { productivityReview: productivityReviewByIssueId.get(row.id) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -2141,6 +2279,9 @@ export function issueService(db: Db) {
|
|||
...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}),
|
||||
lastActivityAt,
|
||||
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
|
||||
...(productivityReviewByIssueId.has(row.id)
|
||||
? { productivityReview: productivityReviewByIssueId.get(row.id) }
|
||||
: {}),
|
||||
...deriveIssueUserContext(row, contextUserId, {
|
||||
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
||||
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
||||
|
|
@ -2292,6 +2433,14 @@ export function issueService(db: Db) {
|
|||
return listIssueBlockerAttentionMap(dbOrTx, companyId, issueRows);
|
||||
},
|
||||
|
||||
listProductivityReviews: async (
|
||||
companyId: string,
|
||||
sourceIssueIds: string[],
|
||||
dbOrTx: any = db,
|
||||
) => {
|
||||
return listIssueProductivityReviewMap(dbOrTx, companyId, sourceIssueIds);
|
||||
},
|
||||
|
||||
listWakeableBlockedDependents: async (blockerIssueId: string) => {
|
||||
const blockerIssue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
|
|
@ -2458,7 +2607,9 @@ export function issueService(db: Db) {
|
|||
parentId: parent.id,
|
||||
projectId: issueData.projectId ?? parent.projectId,
|
||||
goalId: issueData.goalId ?? parent.goalId,
|
||||
requestDepth: Math.max(parent.requestDepth + 1, issueData.requestDepth ?? 0),
|
||||
requestDepth: clampIssueRequestDepth(
|
||||
Math.max(clampIssueRequestDepth(parent.requestDepth) + 1, issueData.requestDepth ?? 0),
|
||||
),
|
||||
description: appendAcceptanceCriteriaToDescription(issueData.description, acceptanceCriteria),
|
||||
inheritExecutionWorkspaceFromIssueId: parent.id,
|
||||
});
|
||||
|
|
@ -2615,6 +2766,7 @@ export function issueService(db: Db) {
|
|||
|
||||
const values = {
|
||||
...issueData,
|
||||
requestDepth: clampIssueRequestDepth(issueData.requestDepth),
|
||||
originKind: issueData.originKind ?? "manual",
|
||||
goalId: resolveIssueGoalId({
|
||||
projectId: issueData.projectId,
|
||||
|
|
@ -2700,6 +2852,9 @@ export function issueService(db: Db) {
|
|||
...issueData,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (issueData.requestDepth !== undefined) {
|
||||
patch.requestDepth = clampIssueRequestDepth(issueData.requestDepth);
|
||||
}
|
||||
|
||||
const nextAssigneeAgentId =
|
||||
issueData.assigneeAgentId !== undefined ? issueData.assigneeAgentId : existing.assigneeAgentId;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue