mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## Thinking Path > - Paperclip is the control plane that coordinates autonomous agent work. > - Agents need to collaborate on issues beyond their current assignment. > - Cross-issue comments and updates are useful, but an unbounded run can create cascading side effects. > - The control plane must preserve company-wide collaboration while containing each run's influence. > - Comment attribution must also show the responsible user and the acting agent in audits. > - This pull request adds run-bound cross-issue containment, attribution, and agent-class wake rules. > - The benefit is safer collaboration without restoring issue-assignee ownership restrictions. ## Linked Issues or Issue Description **What existing behavior does this improve?** Agent-authenticated issue comments, updates, reopen behavior, and assignee wake routing. **Subsystem affected** Cross-cutting: server routes and services, shared contracts, database schema and migration, and implementation documentation. **Current behavior** An authenticated agent can collaborate across company issues, but one heartbeat run has no per-run side-effect boundary. Comment records also do not persist the responsible user separately from the acting agent. **Proposed behavior** Require a valid heartbeat run for agent cross-issue comments and updates. Audit each attempt and cap a run at 20 cross-issue effects. Keep the cap in log-only mode until it automatically changes to enforcement at 2026-08-11 00:00 UTC. Preserve same-issue writes. Use agent-class wakes for agent comments. Keep same-run completion comments from reopening completed work. Record the responsible user on agent-authored comments and activity. **Reason and benefit** Agents can collaborate on other issues without an assignment gate, while each run has an atomic and inspectable side-effect limit. Operators can identify both the acting agent and the responsible user. **Breaking changes** After 2026-08-11 00:00 UTC, the twenty-first cross-issue comment or update from one heartbeat run returns a containment error. Agent cross-issue writes without valid run context are rejected. The migration is additive and backfills existing agent-authored comment attribution where the source data is available. ## What Changed - Added an atomic per-run counter for cross-issue agent comments and updates. - Added audit events for allowed and rejected cross-issue effects. - Added the automatic log-only to enforcement flip at 2026-08-11 00:00 UTC. - Added responsible-user attribution to agent-authored comments, activity records, shared types, and validators. - Added an additive migration and migration coverage for existing comments. - Updated reopen, resume, and wake behavior so agent comments create agent-class wakes and same-run completion comments remain inert. - Updated the implementation specification and regression coverage. ## Verification - `pnpm exec vitest run server/src/__tests__/cross-issue-influence-limit.test.ts server/src/__tests__/issue-comment-attribution-audit-routes.test.ts server/src/__tests__/issue-comment-reopen-routes.test.ts packages/db/src/issue-comment-on-behalf-migration.test.ts` — 97 tests passed. - `pnpm -r typecheck` — passed, including migration safety checks. - `pnpm test:run` — server batch: 3,364 passed and 2 skipped; UI batch: 3,504 passed. One unrelated CLI doctor test warned because this agent runtime injects static AWS credentials. - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest run cli/src/__tests__/secrets.test.ts` — 8 tests passed and confirmed the CLI failure was ambient-environment sensitive. - `pnpm build` — passed. ## Risks - The fixed enforcement timestamp changes production behavior automatically on 2026-08-11 00:00 UTC. Audit logs before that time provide rollout visibility. - The per-run counter serializes on the heartbeat-run row. This prevents concurrent attempts from racing past the cap but adds a small lock scope for cross-issue writes. - Existing comments can only be backfilled when their acting run or agent attribution is recoverable. > 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 GPT-5 in the Codex agent runtime. The runtime did not expose a context-window size. Reasoning, shell tools, code editing, and test execution were enabled. ## 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>
157 lines
7.0 KiB
TypeScript
157 lines
7.0 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import postgres from "postgres";
|
|
import { applyPendingMigrations, inspectMigrations } from "./client.js";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./test-embedded-postgres.js";
|
|
|
|
const MIGRATION_FILE = "0205_narrow_shiva.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("issue comment on-behalf attribution migration", () => {
|
|
it("reapplies the migration and round-trips the nullable user attribution FK", async () => {
|
|
const database = await startEmbeddedPostgresTestDatabase("paperclip-comment-on-behalf-");
|
|
cleanups.push(database.cleanup);
|
|
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
|
const historicalCompanyId = randomUUID();
|
|
const historicalAgentId = randomUUID();
|
|
const historicalIssueId = randomUUID();
|
|
const historicalRunId = randomUUID();
|
|
const historicalCommentId = randomUUID();
|
|
const historicalUserId = `historical-comment-user-${randomUUID()}`;
|
|
|
|
try {
|
|
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
|
|
await sql`ALTER TABLE "issue_comments" DROP CONSTRAINT IF EXISTS "issue_comments_on_behalf_of_user_id_user_id_fk"`;
|
|
await sql`ALTER TABLE "issue_comments" DROP COLUMN IF EXISTS "on_behalf_of_user_id"`;
|
|
await sql`
|
|
INSERT INTO "companies" ("id", "name", "issue_prefix")
|
|
VALUES (${historicalCompanyId}, 'Historical attribution company', 'HAC')
|
|
`;
|
|
await sql`
|
|
INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config")
|
|
VALUES (${historicalAgentId}, ${historicalCompanyId}, 'Historical comment agent', 'engineer', 'process', '{}'::jsonb)
|
|
`;
|
|
await sql`
|
|
INSERT INTO "issues" ("id", "company_id", "title", "identifier")
|
|
VALUES (${historicalIssueId}, ${historicalCompanyId}, 'Historical comment issue', 'HAC-1')
|
|
`;
|
|
await sql`
|
|
INSERT INTO "user" ("id", "name", "email", "email_verified", "created_at", "updated_at")
|
|
VALUES (${historicalUserId}, 'Historical Comment User', 'historical-comment-user@example.test', true, now(), now())
|
|
`;
|
|
await sql`
|
|
INSERT INTO "heartbeat_runs" ("id", "company_id", "agent_id", "status", "responsible_user_id")
|
|
VALUES (${historicalRunId}, ${historicalCompanyId}, ${historicalAgentId}, 'succeeded', ${historicalUserId})
|
|
`;
|
|
await sql`
|
|
INSERT INTO "issue_comments" (
|
|
"id", "company_id", "issue_id", "author_agent_id", "created_by_run_id", "body"
|
|
) VALUES (
|
|
${historicalCommentId}, ${historicalCompanyId}, ${historicalIssueId}, ${historicalAgentId},
|
|
${historicalRunId}, 'Historical attributed comment'
|
|
)
|
|
`;
|
|
} finally {
|
|
await sql.end();
|
|
}
|
|
|
|
expect(await inspectMigrations(database.connectionString)).toMatchObject({
|
|
status: "needsMigrations",
|
|
pendingMigrations: [MIGRATION_FILE],
|
|
});
|
|
await applyPendingMigrations(database.connectionString);
|
|
|
|
const verify = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
|
try {
|
|
const columns = await verify<{ column_name: string; data_type: string; is_nullable: string }[]>`
|
|
SELECT "column_name", "data_type", "is_nullable"
|
|
FROM "information_schema"."columns"
|
|
WHERE "table_schema" = 'public'
|
|
AND "table_name" = 'issue_comments'
|
|
AND "column_name" = 'on_behalf_of_user_id'
|
|
`;
|
|
expect(columns).toEqual([{
|
|
column_name: "on_behalf_of_user_id",
|
|
data_type: "text",
|
|
is_nullable: "YES",
|
|
}]);
|
|
|
|
const constraints = await verify<{ constraint_name: string; delete_rule: string }[]>`
|
|
SELECT tc."constraint_name", rc."delete_rule"
|
|
FROM "information_schema"."table_constraints" tc
|
|
JOIN "information_schema"."referential_constraints" rc
|
|
ON rc."constraint_schema" = tc."constraint_schema"
|
|
AND rc."constraint_name" = tc."constraint_name"
|
|
WHERE tc."table_schema" = 'public'
|
|
AND tc."table_name" = 'issue_comments'
|
|
AND tc."constraint_name" = 'issue_comments_on_behalf_of_user_id_user_id_fk'
|
|
`;
|
|
expect(constraints).toEqual([{
|
|
constraint_name: "issue_comments_on_behalf_of_user_id_user_id_fk",
|
|
delete_rule: "SET NULL",
|
|
}]);
|
|
|
|
const historical = await verify<{ on_behalf_of_user_id: string | null }[]>`
|
|
SELECT "on_behalf_of_user_id"
|
|
FROM "issue_comments"
|
|
WHERE "id" = ${historicalCommentId}
|
|
`;
|
|
expect(historical).toEqual([{ on_behalf_of_user_id: historicalUserId }]);
|
|
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const commentId = randomUUID();
|
|
const userId = `comment-user-${randomUUID()}`;
|
|
await verify`
|
|
INSERT INTO "companies" ("id", "name", "issue_prefix")
|
|
VALUES (${companyId}, 'Comment attribution company', 'CAC')
|
|
`;
|
|
await verify`
|
|
INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config")
|
|
VALUES (${agentId}, ${companyId}, 'Comment agent', 'engineer', 'process', '{}'::jsonb)
|
|
`;
|
|
await verify`
|
|
INSERT INTO "issues" ("id", "company_id", "title", "identifier")
|
|
VALUES (${issueId}, ${companyId}, 'Comment issue', 'CAC-1')
|
|
`;
|
|
await verify`
|
|
INSERT INTO "user" ("id", "name", "email", "email_verified", "created_at", "updated_at")
|
|
VALUES (${userId}, 'Comment User', 'comment-user@example.test', true, now(), now())
|
|
`;
|
|
await verify`
|
|
INSERT INTO "issue_comments" (
|
|
"id", "company_id", "issue_id", "author_agent_id", "on_behalf_of_user_id", "body"
|
|
) VALUES (${commentId}, ${companyId}, ${issueId}, ${agentId}, ${userId}, 'Attributed comment')
|
|
`;
|
|
const inserted = await verify<{ on_behalf_of_user_id: string | null }[]>`
|
|
SELECT "on_behalf_of_user_id" FROM "issue_comments" WHERE "id" = ${commentId}
|
|
`;
|
|
expect(inserted).toEqual([{ on_behalf_of_user_id: userId }]);
|
|
|
|
await verify`DELETE FROM "user" WHERE "id" = ${userId}`;
|
|
const afterDelete = await verify<{ on_behalf_of_user_id: string | null }[]>`
|
|
SELECT "on_behalf_of_user_id" FROM "issue_comments" WHERE "id" = ${commentId}
|
|
`;
|
|
expect(afterDelete).toEqual([{ on_behalf_of_user_id: null }]);
|
|
} finally {
|
|
await verify.end();
|
|
}
|
|
}, 30_000);
|
|
});
|