mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 06:02:18 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The heartbeat and issue-comment routes decide when an assigned agent wakes up and what context it receives. > - Passive comments and annotation notes can currently wake assignees even when no actionable state changed. > - Accepted planning confirmations also need to preserve recent plan comments so child-issue creation does not lose board/user constraints. > - Runtime skill mentions should only send UUID ids into database lookups, because legacy slug-like ids are not valid runtime skill ids. > - This pull request tightens those wake and handoff rules in one server-side branch. > - The benefit is fewer noisy agent wakeups and better accepted-plan continuation context without changing the task model. ## Linked Issues or Issue Description Internal Paperclip task: [PAP-10535](/PAP/issues/PAP-10535). Problem or motivation: Passive comments and annotation notes could wake the current assignee even when no actionable state changed, and accepted plan continuations needed recent plan comments preserved in the wake handoff. Runtime skill mentions also needed to ignore non-UUID ids before database lookup. Proposed solution: Tighten server-side wake routing so passive comments do not wake assignees unless they reopen the issue, preserve mention-targeted wakeups, include recent non-deleted plan comments in accepted confirmation wake payloads, and guard runtime skill mention lookup to UUID-like ids. Alternatives considered: Leaving passive assignee wakeups in place was rejected because it keeps generating noisy non-actionable heartbeats. Treating every skill mention-like token as a runtime skill id was rejected because legacy slug-like ids are not valid runtime skill ids. Roadmap alignment: This aligns with the V1 control-plane heartbeat contract by making wakeups more intentional and preserving handoff context for approved plans. This PR was split from the local `master` branch on June 7, 2026. It covers server-side heartbeat and comment-wakeup behavior only. I searched GitHub for duplicate/related PRs; the results were broader heartbeat/run PRs, not this exact passive-comment and accepted-plan handoff change. ## What Changed - Filter runtime skill mention extraction so only UUID-like skill ids are looked up. - Stop ordinary issue comments and document annotation comments from waking the current assignee unless the comment reopens the issue. - Keep mention-targeted wakeups intact while removing passive assignee wakeups. - Include recent non-deleted issue comments in accepted-plan confirmation wake payloads and task markdown. - Updated focused server tests for the new wakeup and accepted-plan behavior. ## Verification - `git diff --check origin/master..HEAD` - `NODE_ENV=test pnpm exec vitest run server/src/__tests__/heartbeat-project-env.test.ts server/src/__tests__/document-annotation-routes.test.ts server/src/__tests__/issue-comment-reopen-routes.test.ts server/src/__tests__/issue-update-comment-wakeup-routes.test.ts server/src/__tests__/heartbeat-context-summary.test.ts server/src/__tests__/issue-comment-redaction.test.ts` - `NODE_ENV=test pnpm exec vitest run server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts server/src/__tests__/issue-comment-redaction.test.ts server/src/__tests__/heartbeat-context-summary.test.ts` - `pnpm --filter /server typecheck` - PR checks green on head `a379a0264d384510ff8ac4a47fb1e44d7b556f68` - Greptile rerun green on head `a379a0264d384510ff8ac4a47fb1e44d7b556f68`: 9 files reviewed, 0 comments added, 0 unresolved review threads ## Risks - Medium behavioral risk: agents will no longer wake for passive comments unless mentioned or unless the comment reopens/resumes the issue. That is intentional, but any workflow relying on passive assignee comment wakeups should use explicit mentions or structured resume paths. - Low migration risk: no schema or migration changes. > 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 coding agent based on GPT-5, with shell, git, GitHub CLI, and local test execution. Exact hosted model variant and context-window size were not exposed by the runtime. ## 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 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] 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>
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { sql } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
companies,
|
|
createDb,
|
|
issueComments,
|
|
issueReferenceMentions,
|
|
issues,
|
|
} from "@paperclipai/db";
|
|
import { companySearchQuerySchema } from "@paperclipai/shared";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { errorHandler } from "../middleware/index.js";
|
|
import { issueRoutes } from "../routes/issues.js";
|
|
import { companySearchService } from "../services/company-search.js";
|
|
import { buildPaperclipWakePayload } from "../services/heartbeat.js";
|
|
import { issueReferenceService } from "../services/issue-references.js";
|
|
import { issueService } from "../services/issues.js";
|
|
import type { StorageService } from "../storage/types.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres issue comment redaction tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("deleted issue comment redaction", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-comment-redaction-");
|
|
db = createDb(tempDb.connectionString);
|
|
await db.execute(sql.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm"));
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueReferenceMentions);
|
|
await db.delete(issueComments);
|
|
await db.delete(issues);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedIssue() {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Comment Redaction Co",
|
|
issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
identifier: "RED-1",
|
|
title: "Deleted comment redaction",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
return { companyId, issueId };
|
|
}
|
|
|
|
function createApp(companyId: string) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = {
|
|
type: "board",
|
|
userId: "board-user-1",
|
|
companyIds: [companyId],
|
|
memberships: [{ companyId, membershipRole: "owner", status: "active" }],
|
|
source: "cloud_tenant",
|
|
isInstanceAdmin: true,
|
|
};
|
|
next();
|
|
});
|
|
const storage: StorageService = {
|
|
provider: "local_disk",
|
|
putFile: vi.fn(async () => {
|
|
throw new Error("Unexpected storage.putFile call");
|
|
}),
|
|
getObject: vi.fn(async () => {
|
|
throw new Error("Unexpected storage.getObject call");
|
|
}),
|
|
headObject: vi.fn(async () => ({ exists: false })),
|
|
deleteObject: vi.fn(async () => undefined),
|
|
};
|
|
app.use("/api", issueRoutes(db, storage));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
it("redacts deleted comment bodies from ordinary reads, heartbeat context, and wake payloads", async () => {
|
|
const { companyId, issueId } = await seedIssue();
|
|
const commentId = randomUUID();
|
|
const deletedAt = new Date("2026-06-03T12:00:00.000Z");
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "board-user-1",
|
|
body: "secret deleted body",
|
|
presentation: { kind: "system_notice", tone: "warning" },
|
|
metadata: { version: 1, sections: [{ rows: [{ type: "text", text: "secret metadata" }] }] },
|
|
deletedAt,
|
|
deletedByType: "user",
|
|
deletedByUserId: "board-user-1",
|
|
});
|
|
|
|
const comments = await issueService(db).listComments(issueId, { order: "asc" });
|
|
expect(comments).toHaveLength(1);
|
|
expect(comments[0]).toMatchObject({
|
|
id: commentId,
|
|
body: "",
|
|
presentation: null,
|
|
metadata: null,
|
|
deletedAt,
|
|
deletedByType: "user",
|
|
deletedByUserId: "board-user-1",
|
|
});
|
|
|
|
const exactComment = await issueService(db).getComment(commentId);
|
|
expect(exactComment?.body).toBe("");
|
|
expect(exactComment?.metadata).toBeNull();
|
|
|
|
const heartbeatContext = await request(createApp(companyId))
|
|
.get(`/api/issues/${issueId}/heartbeat-context`)
|
|
.query({ wakeCommentId: commentId });
|
|
expect(heartbeatContext.status, JSON.stringify(heartbeatContext.body)).toBe(200);
|
|
expect(heartbeatContext.body.wakeComment).toMatchObject({
|
|
id: commentId,
|
|
body: "",
|
|
metadata: null,
|
|
deletedByUserId: "board-user-1",
|
|
});
|
|
expect(JSON.stringify(heartbeatContext.body)).not.toContain("secret deleted body");
|
|
expect(JSON.stringify(heartbeatContext.body)).not.toContain("secret metadata");
|
|
|
|
const wakePayload = await buildPaperclipWakePayload({
|
|
db,
|
|
companyId,
|
|
contextSnapshot: {
|
|
issueId,
|
|
commentId,
|
|
wakeCommentIds: [commentId],
|
|
wakeReason: "issue_commented",
|
|
},
|
|
});
|
|
|
|
expect(wakePayload?.comments).toEqual([
|
|
expect.objectContaining({
|
|
id: commentId,
|
|
body: "",
|
|
bodyTruncated: false,
|
|
presentation: null,
|
|
metadata: null,
|
|
deletedAt: deletedAt.toISOString(),
|
|
deletedByUserId: "board-user-1",
|
|
}),
|
|
]);
|
|
expect(JSON.stringify(wakePayload)).not.toContain("secret deleted body");
|
|
expect(JSON.stringify(wakePayload)).not.toContain("secret metadata");
|
|
});
|
|
|
|
it("adds recent issue comments to accepted-plan continuation wake payloads", async () => {
|
|
const { companyId, issueId } = await seedIssue();
|
|
const firstCommentId = randomUUID();
|
|
const secondCommentId = randomUUID();
|
|
const deletedCommentId = randomUUID();
|
|
await db.insert(issueComments).values([
|
|
{
|
|
id: firstCommentId,
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "board-user-1",
|
|
body: "Keep the migration backwards compatible.",
|
|
createdAt: new Date("2026-06-03T12:00:00.000Z"),
|
|
},
|
|
{
|
|
id: deletedCommentId,
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "board-user-1",
|
|
body: "Do not pass this deleted text downstream.",
|
|
deletedAt: new Date("2026-06-03T12:01:00.000Z"),
|
|
deletedByType: "user",
|
|
deletedByUserId: "board-user-1",
|
|
createdAt: new Date("2026-06-03T12:01:00.000Z"),
|
|
},
|
|
{
|
|
id: secondCommentId,
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "board-user-1",
|
|
body: "Split the rollout into a verification child task.",
|
|
createdAt: new Date("2026-06-03T12:02:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
const wakePayload = await buildPaperclipWakePayload({
|
|
db,
|
|
companyId,
|
|
contextSnapshot: {
|
|
issueId,
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
workspaceRefreshReason: "accepted_plan_confirmation",
|
|
},
|
|
issueSummary: {
|
|
id: issueId,
|
|
identifier: "RED-1",
|
|
title: "Deleted comment redaction",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
workMode: "planning",
|
|
},
|
|
});
|
|
|
|
expect(wakePayload?.commentContextSource).toBe("accepted_plan_confirmation");
|
|
expect(wakePayload?.commentIds).toEqual([firstCommentId, secondCommentId]);
|
|
expect(wakePayload?.comments.map((comment) => comment.body)).toEqual([
|
|
"Keep the migration backwards compatible.",
|
|
"Split the rollout into a verification child task.",
|
|
]);
|
|
expect(JSON.stringify(wakePayload)).not.toContain("Do not pass this deleted text downstream.");
|
|
});
|
|
|
|
it("excludes deleted comment bodies from company search", async () => {
|
|
const { companyId, issueId } = await seedIssue();
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId,
|
|
body: "vanished-search-needle",
|
|
deletedAt: new Date("2026-06-03T12:00:00.000Z"),
|
|
deletedByType: "user",
|
|
deletedByUserId: "board-user-1",
|
|
});
|
|
|
|
const result = await companySearchService(db).search(
|
|
companyId,
|
|
companySearchQuerySchema.parse({ q: "vanished-search-needle", scope: "comments" }),
|
|
);
|
|
|
|
expect(result.results).toEqual([]);
|
|
});
|
|
|
|
it("clears issue references sourced from deleted comment bodies", async () => {
|
|
const companyId = randomUUID();
|
|
const sourceIssueId = randomUUID();
|
|
const targetIssueId = randomUUID();
|
|
const commentId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Reference Redaction Co",
|
|
issuePrefix: "REF",
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(issues).values([
|
|
{
|
|
id: sourceIssueId,
|
|
companyId,
|
|
identifier: "REF-1",
|
|
title: "Source issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: targetIssueId,
|
|
companyId,
|
|
identifier: "REF-2",
|
|
title: "Target issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId: sourceIssueId,
|
|
body: "Follow up in REF-2",
|
|
});
|
|
|
|
const refs = issueReferenceService(db);
|
|
await refs.syncComment(commentId);
|
|
expect((await refs.listIssueReferenceSummary(sourceIssueId)).outbound.map((item) => item.issue.id)).toEqual([
|
|
targetIssueId,
|
|
]);
|
|
|
|
await db.update(issueComments).set({
|
|
deletedAt: new Date("2026-06-03T12:00:00.000Z"),
|
|
deletedByType: "user",
|
|
deletedByUserId: "board-user-1",
|
|
});
|
|
await refs.syncComment(commentId);
|
|
|
|
expect((await refs.listIssueReferenceSummary(sourceIssueId)).outbound).toEqual([]);
|
|
});
|
|
});
|