[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:
Dotta 2026-04-28 16:46:45 -05:00 committed by GitHub
parent d9f540c331
commit 1991ec9d6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 34186 additions and 148 deletions

View file

@ -0,0 +1 @@
ALTER TABLE "companies" ADD COLUMN "attachment_max_bytes" integer DEFAULT 10485760 NOT NULL;

View file

@ -0,0 +1,4 @@
CREATE UNIQUE INDEX "issues_active_productivity_review_uq" ON "issues" USING btree ("company_id","origin_kind","origin_id") WHERE "issues"."origin_kind" = 'issue_productivity_review'
and "issues"."origin_id" is not null
and "issues"."hidden_at" is null
and "issues"."status" not in ('done', 'cancelled');

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -512,6 +512,20 @@
"when": 1777305216238,
"tag": "0072_large_sandman",
"breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1777382021347,
"tag": "0073_shiny_salo",
"breakpoints": true
},
{
"idx": 74,
"version": "7",
"when": 1777384535070,
"tag": "0074_striped_genesis",
"breakpoints": true
}
]
}
}

View file

@ -13,6 +13,9 @@ export const companies = pgTable(
issueCounter: integer("issue_counter").notNull().default(0),
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
attachmentMaxBytes: integer("attachment_max_bytes")
.notNull()
.default(10 * 1024 * 1024),
requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents")
.notNull()
.default(false),

View file

@ -115,6 +115,14 @@ export const issues = pgTable(
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(

View file

@ -26,6 +26,20 @@ describe("dynamic adapter type validation schemas", () => {
).toThrow();
});
it("accepts an explicit managed instructions bundle for new agents", () => {
expect(
createAgentSchema.parse({
name: "Bundle Agent",
adapterType: "codex_local",
instructionsBundle: {
files: {
"AGENTS.md": "Use AGENTS.md.",
},
},
}).instructionsBundle?.files["AGENTS.md"],
).toBe("Use AGENTS.md.");
});
it("accepts external adapter types in invite acceptance schema", () => {
expect(
acceptInviteSchema.parse({

View file

@ -1,6 +1,9 @@
export const COMPANY_STATUSES = ["active", "paused", "archived"] as const;
export type CompanyStatus = (typeof COMPANY_STATUSES)[number];
export const DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
export const MAX_COMPANY_ATTACHMENT_MAX_BYTES = 1024 * 1024 * 1024;
export const DEPLOYMENT_MODES = ["local_trusted", "authenticated"] as const;
export type DeploymentMode = (typeof DEPLOYMENT_MODES)[number];
@ -138,6 +141,12 @@ export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join(","
export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
export const MAX_ISSUE_REQUEST_DEPTH = 1024;
export function clampIssueRequestDepth(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.min(MAX_ISSUE_REQUEST_DEPTH, Math.max(0, Math.floor(value)));
}
export const ISSUE_THREAD_INTERACTION_KINDS = [
"suggest_tasks",
@ -164,7 +173,14 @@ export const ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES = [
export type IssueThreadInteractionContinuationPolicy =
(typeof ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES)[number];
export const ISSUE_ORIGIN_KINDS = ["manual", "routine_execution", "stale_active_run_evaluation"] as const;
export const ISSUE_ORIGIN_KINDS = [
"manual",
"routine_execution",
"stale_active_run_evaluation",
"harness_liveness_escalation",
"issue_productivity_review",
"stranded_issue_recovery",
] as const;
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export type PluginIssueOriginKind = `plugin:${string}`;
export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind;

View file

@ -1,6 +1,8 @@
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
export {
COMPANY_STATUSES,
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
DEPLOYMENT_MODES,
DEPLOYMENT_EXPOSURES,
BIND_MODES,
@ -16,6 +18,8 @@ export {
INBOX_MINE_ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
ISSUE_PRIORITIES,
MAX_ISSUE_REQUEST_DEPTH,
clampIssueRequestDepth,
ISSUE_THREAD_INTERACTION_KINDS,
ISSUE_THREAD_INTERACTION_STATUSES,
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
@ -329,6 +333,8 @@ export type {
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,

View file

@ -46,8 +46,10 @@ describe("routine variable helpers", () => {
it("identifies built-in variable names", () => {
expect(isBuiltinRoutineVariable("date")).toBe(true);
expect(isBuiltinRoutineVariable("timestamp")).toBe(true);
expect(isBuiltinRoutineVariable("repo")).toBe(false);
expect(BUILTIN_ROUTINE_VARIABLE_NAMES.has("date")).toBe(true);
expect(BUILTIN_ROUTINE_VARIABLE_NAMES.has("timestamp")).toBe(true);
});
it("getBuiltinRoutineVariableValues returns date in YYYY-MM-DD format", () => {
@ -56,9 +58,17 @@ describe("routine variable helpers", () => {
expect(values.date).toBe(new Date().toISOString().slice(0, 10));
});
it("getBuiltinRoutineVariableValues returns a human-readable timestamp with year, time, and UTC", () => {
const values = getBuiltinRoutineVariableValues();
const year = String(new Date().getUTCFullYear());
expect(values.timestamp).toContain(year);
expect(values.timestamp).toMatch(/\d{1,2}:\d{2}\s?(AM|PM)/);
expect(values.timestamp).toContain("UTC");
});
it("excludes built-in variables from syncRoutineVariablesWithTemplate", () => {
const result = syncRoutineVariablesWithTemplate(
"Daily report for {{date}} — {{repo}}",
"Daily report for {{date}} at {{timestamp}} — {{repo}}",
[],
);
expect(result).toEqual([
@ -66,11 +76,11 @@ describe("routine variable helpers", () => {
]);
});
it("interpolates built-in date variable alongside user variables", () => {
it("interpolates built-in variables alongside user variables", () => {
const builtins = getBuiltinRoutineVariableValues();
const allVars = { ...builtins, repo: "paperclip" };
expect(
interpolateRoutineTemplate("Report for {{date}} on {{repo}}", allVars),
).toBe(`Report for ${builtins.date} on paperclip`);
interpolateRoutineTemplate("Report for {{date}} ({{timestamp}}) on {{repo}}", allVars),
).toBe(`Report for ${builtins.date} (${builtins.timestamp}) on paperclip`);
});
});

View file

@ -7,19 +7,33 @@ type RoutineTemplateInput = string | null | undefined | Array<string | null | un
* Built-in variable names that are automatically available in routine templates
* without needing to be defined in the routine's variables list.
*/
export const BUILTIN_ROUTINE_VARIABLE_NAMES = new Set(["date"]);
export const BUILTIN_ROUTINE_VARIABLE_NAMES = new Set(["date", "timestamp"]);
export function isBuiltinRoutineVariable(name: string): boolean {
return BUILTIN_ROUTINE_VARIABLE_NAMES.has(name);
}
const HUMAN_TIMESTAMP_FORMATTER = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
timeZone: "UTC",
timeZoneName: "short",
});
/**
* Returns current values for all built-in routine variables.
* `date` expands to the current date in YYYY-MM-DD format (UTC).
* `timestamp` expands to a human-readable date and time (e.g. "April 28, 2026 at 12:17 PM UTC").
*/
export function getBuiltinRoutineVariableValues(): Record<string, string> {
const now = new Date();
return {
date: new Date().toISOString().slice(0, 10),
date: now.toISOString().slice(0, 10),
timestamp: HUMAN_TIMESTAMP_FORMATTER.format(now),
};
}

View file

@ -34,6 +34,7 @@ export interface CompanyPortabilityCompanyManifestEntry {
description: string | null;
brandColor: string | null;
logoPath: string | null;
attachmentMaxBytes: number | null;
requireBoardApprovalForNewAgents: boolean;
feedbackDataSharingEnabled: boolean;
feedbackDataSharingConsentAt: string | null;

View file

@ -11,6 +11,7 @@ export interface Company {
issueCounter: number;
budgetMonthlyCents: number;
spentMonthlyCents: number;
attachmentMaxBytes: number;
requireBoardApprovalForNewAgents: boolean;
feedbackDataSharingEnabled: boolean;
feedbackDataSharingConsentAt: Date | null;

View file

@ -136,6 +136,8 @@ export type {
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,

View file

@ -139,6 +139,22 @@ export interface IssueBlockerAttention {
sampleStalledBlockerIdentifier: string | null;
}
export type IssueProductivityReviewTrigger =
| "no_comment_streak"
| "long_active_duration"
| "high_churn";
export interface IssueProductivityReview {
reviewIssueId: string;
reviewIdentifier: string | null;
status: IssueStatus;
priority: IssuePriority;
trigger: IssueProductivityReviewTrigger | null;
noCommentStreak: number | null;
createdAt: Date;
updatedAt: Date;
}
export interface IssueRelation {
id: string;
companyId: string;
@ -264,6 +280,7 @@ export interface Issue {
blockedBy?: IssueRelationIssueSummary[];
blocks?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention;
productivityReview?: IssueProductivityReview | null;
relatedWork?: IssueRelatedWorkSummary;
referencedIssueIdentifiers?: string[];
planDocument?: IssueDocument | null;

View file

@ -44,6 +44,13 @@ const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => {
}
});
export const createAgentInstructionsBundleSchema = z.object({
entryFile: z.string().trim().min(1).optional(),
files: z.record(z.string()).refine((files) => Object.keys(files).length > 0, {
message: "instructionsBundle.files must contain at least one file",
}),
});
export const createAgentSchema = z.object({
name: z.string().min(1),
role: z.enum(AGENT_ROLES).optional().default("general"),
@ -54,6 +61,7 @@ export const createAgentSchema = z.object({
desiredSkills: z.array(z.string().min(1)).optional(),
adapterType: agentAdapterTypeSchema,
adapterConfig: adapterConfigSchema.optional().default({}),
instructionsBundle: createAgentInstructionsBundleSchema.optional(),
runtimeConfig: z.record(z.unknown()).optional().default({}),
defaultEnvironmentId: z.string().uuid().optional().nullable(),
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),

View file

@ -1,4 +1,5 @@
import { z } from "zod";
import { MAX_COMPANY_ATTACHMENT_MAX_BYTES } from "../constants.js";
import { routineVariableSchema } from "./routine.js";
export const portabilityIncludeSchema = z
@ -37,6 +38,7 @@ export const portabilityCompanyManifestEntrySchema = z.object({
description: z.string().nullable(),
brandColor: z.string().nullable(),
logoPath: z.string().nullable(),
attachmentMaxBytes: z.number().int().min(1).max(MAX_COMPANY_ATTACHMENT_MAX_BYTES).nullable().default(null),
requireBoardApprovalForNewAgents: z.boolean(),
feedbackDataSharingEnabled: z.boolean().default(false),
feedbackDataSharingConsentAt: z.string().datetime().nullable().default(null),

View file

@ -1,14 +1,23 @@
import { z } from "zod";
import { COMPANY_STATUSES } from "../constants.js";
import {
COMPANY_STATUSES,
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
} from "../constants.js";
const logoAssetIdSchema = z.string().uuid().nullable().optional();
const brandColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional();
const feedbackDataSharingTermsVersionSchema = z.string().min(1).nullable().optional();
const attachmentMaxBytesSchema = z
.number()
.int()
.min(1)
.max(MAX_COMPANY_ATTACHMENT_MAX_BYTES);
export const createCompanySchema = z.object({
name: z.string().min(1),
description: z.string().optional().nullable(),
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
attachmentMaxBytes: attachmentMaxBytesSchema.optional(),
});
export type CreateCompany = z.infer<typeof createCompanySchema>;
@ -25,6 +34,7 @@ export const updateCompanySchema = createCompanySchema
feedbackDataSharingTermsVersion: feedbackDataSharingTermsVersionSchema,
brandColor: brandColorSchema,
logoAssetId: logoAssetIdSchema,
attachmentMaxBytes: attachmentMaxBytesSchema.optional(),
});
export type UpdateCompany = z.infer<typeof updateCompanySchema>;

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { MAX_ISSUE_REQUEST_DEPTH } from "../index.js";
import {
addIssueCommentSchema,
createIssueSchema,
@ -75,4 +76,21 @@ describe("issue validators", () => {
expect(response.summaryMarkdown).toBe("Summary\n\nNext action");
expect(document.body).toBe("# Plan\n\nShip it");
});
it("clamps oversized requestDepth values on create", () => {
const parsed = createIssueSchema.parse({
title: "Clamp request depth",
requestDepth: MAX_ISSUE_REQUEST_DEPTH + 500,
});
expect(parsed.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
});
it("clamps oversized requestDepth values on update", () => {
const parsed = updateIssueSchema.parse({
requestDepth: MAX_ISSUE_REQUEST_DEPTH + 1,
});
expect(parsed.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
});
});

View file

@ -5,6 +5,7 @@ import {
ISSUE_EXECUTION_STAGE_TYPES,
ISSUE_EXECUTION_STATE_STATUSES,
ISSUE_PRIORITIES,
clampIssueRequestDepth,
ISSUE_STATUSES,
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
ISSUE_THREAD_INTERACTION_KINDS,
@ -123,6 +124,12 @@ export const issueExecutionStateSchema = z.object({
lastDecisionOutcome: z.enum(ISSUE_EXECUTION_DECISION_OUTCOMES).nullable(),
});
const issueRequestDepthInputSchema = z
.number()
.int()
.nonnegative()
.transform((value) => clampIssueRequestDepth(value));
export const createIssueSchema = z.object({
projectId: z.string().uuid().optional().nullable(),
projectWorkspaceId: z.string().uuid().optional().nullable(),
@ -136,7 +143,7 @@ export const createIssueSchema = z.object({
priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"),
assigneeAgentId: z.string().uuid().optional().nullable(),
assigneeUserId: z.string().optional().nullable(),
requestDepth: z.number().int().nonnegative().optional().default(0),
requestDepth: issueRequestDepthInputSchema.optional().default(0),
billingCode: z.string().optional().nullable(),
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
executionPolicy: issueExecutionPolicySchema.optional().nullable(),
@ -168,6 +175,7 @@ export const createIssueLabelSchema = z.object({
export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
export const updateIssueSchema = createIssueSchema.partial().extend({
requestDepth: issueRequestDepthInputSchema.optional(),
assigneeAgentId: z.string().trim().min(1).optional().nullable(),
comment: multilineTextSchema.pipe(z.string().min(1)).optional(),
reviewRequest: issueReviewRequestSchema.optional().nullable(),