mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Issue interactions give agents and people a structured decision record. > - Resolver routes used different authorization rules. > - Some routes blocked valid agents, including task watchdogs with normal issue access. > - The API did not show who could resolve a pending interaction. > - This pull request gives every interaction kind one resolver policy evaluator. > - The benefit is a clear decision path with consistent governance and company isolation. ## Linked Issues or Issue Description Fixes: #8087 Refs: #7403 Related PR: #11082 proposes board-only confirmation rules. This change keeps human-only review as an explicit policy. **What happened?** Agents could create issue interactions. Some resolver routes still required board access. This left valid agent confirmations pending. Task watchdogs could see the same problem without board identity. **Expected behavior** Every interaction kind must use one resolver policy contract. The contract must support `anyone`, `not_creator`, and `human_only`. It must also apply all normal governance controls. **Steps to reproduce** 1. Create a `request_confirmation` interaction as an agent. 2. Resolve it with another authorized agent. 3. Observe the board-only denial. **Paperclip version or commit** The problem exists on `master` before this change. **Deployment mode** Local development with `pnpm dev`. ## What Changed - Add canonical policies for `anyone`, `not_creator`, and `human_only`. - Use one server evaluator for every interaction kind. - Apply named addressees, company limits, review rules, and task watchdog scope. - Charge cross-issue resolutions to the existing per-run action limit. - Return the effective resolver audience in attention and interaction data. - Show the audience, governance choices, and denial reasons in the board UI. - Add telemetry, API documents, product documents, and regression fixtures. - Add migration provenance for safe legacy behavior. - Make migration `0218` safe for complete replays and partial prior runs. ## Product Rules - An interaction records a response. It does not grant authority for the next action. - `anyone` lets any authorized issue participant respond. - `not_creator` requires a responder other than the interaction creator. - `human_only` requires an authorized person. - A named addressee, company policy, or governed action can narrow the audience. - These controls cannot widen the audience. - A task watchdog uses the same rules as an ordinary agent. - A task watchdog does not receive board authority. - An agent resolution on another issue uses the shared cross-issue action limit. - Legacy pending interactions keep their earlier restrictions. - The UI shows the effective audience and a permanent denial reason. ## Verification - `pnpm --filter @paperclipai/db check:migrations` - `pnpm --filter @paperclipai/db typecheck` - `pnpm exec vitest run packages/db/src/issue-thread-interaction-resolver-policy-migration.test.ts` - The focused PostgreSQL test applies migration `0218` twice. - The test also completes a partial prior run and preserves existing provenance. - The latest GitHub head has 29 successful checks. - The opt-in Storybook visual check skipped as expected. - Greptile reports 5/5 with no open comments. ## Risks - New interaction writes use `anyone` by default. - Callers must select `not_creator` or `human_only` when they need stricter review. - Legacy pending interactions keep the old creator and human restrictions. - Migration `0218` fills only missing provenance fields during recovery. - Cross-issue resolutions can reach the existing action limit. - The shared evaluator affects every interaction kind. - Route, service, database, shared contract, and UI tests cover these rules. > This work matches the Agent Reviews and Approvals direction in `ROADMAP.md`. It does not duplicate a planned item. ## Model Used OpenAI Codex, GPT-5. The runtime does not expose the exact deployment ID or context window. The agent used reasoning, repository tools, shell commands, and test execution. ## 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 linked public issues or described the issue with the required labels - [x] I have not referenced internal Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented the risks - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open comments - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
560 lines
16 KiB
TypeScript
560 lines
16 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
agents,
|
|
companies,
|
|
createDb,
|
|
documentRevisions,
|
|
documents,
|
|
goals,
|
|
heartbeatRuns,
|
|
issueComments,
|
|
issueDocuments,
|
|
issueThreadInteractions,
|
|
issues,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
|
|
|
const telemetryMocks = vi.hoisted(() => ({
|
|
track: vi.fn(),
|
|
getTelemetryClient: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../telemetry.js", () => ({
|
|
getTelemetryClient: telemetryMocks.getTelemetryClient,
|
|
}));
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let interactionsSvc!: ReturnType<typeof issueThreadInteractionService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-interaction-telemetry-");
|
|
db = createDb(tempDb.connectionString);
|
|
interactionsSvc = issueThreadInteractionService(db);
|
|
}, 20_000);
|
|
|
|
beforeEach(() => {
|
|
telemetryMocks.track.mockClear();
|
|
telemetryMocks.getTelemetryClient.mockReturnValue({
|
|
track: telemetryMocks.track,
|
|
hashPrivateRef: vi.fn((value: string) => `hashed:${value}`),
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueThreadInteractions);
|
|
await db.delete(issueComments);
|
|
await db.delete(issueDocuments);
|
|
await db.delete(documentRevisions);
|
|
await db.delete(documents);
|
|
await db.delete(issues);
|
|
await db.delete(goals);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedIssue(title = "Interaction telemetry") {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title,
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
title,
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
});
|
|
|
|
return { companyId, goalId, issueId };
|
|
}
|
|
|
|
async function seedAgent(companyId: string, role: string) {
|
|
const agentId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: role,
|
|
role,
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
return agentId;
|
|
}
|
|
|
|
function expectNoRawInteractionIds(dimensions: Record<string, unknown>) {
|
|
expect(dimensions).not.toHaveProperty("interaction_id");
|
|
expect(dimensions).not.toHaveProperty("created_by_agent_id");
|
|
expect(dimensions).not.toHaveProperty("source_run_id");
|
|
}
|
|
|
|
function lastInteractionResolvedDimensions() {
|
|
expect(telemetryMocks.track).toHaveBeenCalledWith("interaction.resolved", expect.any(Object));
|
|
const calls = telemetryMocks.track.mock.calls.filter((call) => call[0] === "interaction.resolved");
|
|
return calls.at(-1)?.[1] as Record<string, unknown>;
|
|
}
|
|
|
|
function lastInteractionCreatedDimensions() {
|
|
expect(telemetryMocks.track).toHaveBeenCalledWith("interaction.created", expect.any(Object));
|
|
const calls = telemetryMocks.track.mock.calls.filter((call) => call[0] === "interaction.created");
|
|
return calls.at(-1)?.[1] as Record<string, unknown>;
|
|
}
|
|
|
|
it("emits deprecated resolver alias use at creation without interaction content", async () => {
|
|
const { companyId, issueId } = await seedIssue("Deprecated alias telemetry issue title");
|
|
|
|
await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
resolverPolicy: "board_only",
|
|
title: "Private confirmation title",
|
|
summary: "Private confirmation summary",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Private confirmation prompt",
|
|
detailsMarkdown: "Private confirmation details",
|
|
acceptLabel: "Private accept label",
|
|
rejectLabel: "Private reject label",
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(lastInteractionCreatedDimensions()).toEqual({
|
|
interaction_kind: "request_confirmation",
|
|
used_deprecated_resolver_policy_alias: true,
|
|
});
|
|
});
|
|
|
|
it("emits accepted suggested-task telemetry with created and skipped task counts", async () => {
|
|
const { companyId, goalId, issueId } = await seedIssue("Accept suggested tasks telemetry");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "suggest_tasks",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
tasks: [
|
|
{
|
|
clientKey: "root",
|
|
title: "Create the root follow-up",
|
|
},
|
|
{
|
|
clientKey: "child",
|
|
parentClientKey: "root",
|
|
title: "Create the nested follow-up",
|
|
},
|
|
{
|
|
clientKey: "sibling",
|
|
title: "Create the sibling follow-up",
|
|
},
|
|
],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
await interactionsSvc.acceptInteraction({
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
projectId: null,
|
|
}, created.id, {
|
|
selectedClientKeys: ["root"],
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "suggest_tasks",
|
|
status: "accepted",
|
|
resolved_by_kind: "user",
|
|
resolution_reason: "accepted",
|
|
created_by_kind: "user",
|
|
continuation_policy: "wake_assignee",
|
|
target_type: "none",
|
|
created_task_count: 1,
|
|
skipped_task_count: 2,
|
|
});
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
|
|
it("emits accepted checkbox telemetry with raw role, target, and counts", async () => {
|
|
const { companyId, goalId, issueId } = await seedIssue("Accept checkbox telemetry");
|
|
const creatorAgentId = await seedAgent(companyId, "Backend Engineer");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_checkbox_confirmation",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Which files should be deleted?",
|
|
options: [
|
|
{ id: "file-a", label: "a.txt" },
|
|
{ id: "file-b", label: "b.txt" },
|
|
],
|
|
target: {
|
|
type: "custom",
|
|
key: "cleanup-plan",
|
|
},
|
|
},
|
|
}, {
|
|
agentId: creatorAgentId,
|
|
});
|
|
|
|
await interactionsSvc.acceptInteraction({
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
projectId: null,
|
|
}, created.id, {
|
|
selectedOptionIds: ["file-b"],
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "request_checkbox_confirmation",
|
|
status: "accepted",
|
|
resolved_by_kind: "user",
|
|
resolution_reason: "accepted",
|
|
created_by_kind: "agent",
|
|
creator_agent_role: "Backend Engineer",
|
|
continuation_policy: "wake_assignee",
|
|
target_type: "custom",
|
|
option_count: 2,
|
|
selected_option_count: 1,
|
|
});
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
|
|
it("emits rejected confirmation telemetry and omits creator_agent_role for user-created interactions", async () => {
|
|
const { companyId, issueId } = await seedIssue("Reject confirmation telemetry");
|
|
const resolverAgentId = await seedAgent(companyId, "SecurityEngineer");
|
|
const resolverRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: resolverRunId,
|
|
companyId,
|
|
agentId: resolverAgentId,
|
|
status: "running",
|
|
});
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
resolverPolicy: "board_or_agents",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve this?",
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
await interactionsSvc.rejectInteraction({
|
|
id: issueId,
|
|
companyId,
|
|
}, created.id, {
|
|
reason: "Needs edits before approval.",
|
|
}, {
|
|
agentId: resolverAgentId,
|
|
runId: resolverRunId,
|
|
});
|
|
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "request_confirmation",
|
|
status: "rejected",
|
|
resolved_by_kind: "agent",
|
|
resolution_reason: "rejected",
|
|
created_by_kind: "user",
|
|
continuation_policy: "none",
|
|
target_type: "none",
|
|
});
|
|
expect(dimensions).not.toHaveProperty("creator_agent_role");
|
|
expect(dimensions).not.toHaveProperty("reason");
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
|
|
it("emits answered question telemetry with system resolver and legacy restriction signal", async () => {
|
|
const { companyId, issueId } = await seedIssue("Answer question telemetry");
|
|
const creatorAgentId = await seedAgent(companyId, "Wizard");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "ask_user_questions",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
questions: [
|
|
{
|
|
id: "scope",
|
|
prompt: "Choose the scope",
|
|
selectionMode: "single",
|
|
required: true,
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
},
|
|
{
|
|
id: "extras",
|
|
prompt: "Optional extras",
|
|
selectionMode: "multi",
|
|
options: [{ id: "docs", label: "Docs" }],
|
|
},
|
|
],
|
|
},
|
|
}, {
|
|
agentId: creatorAgentId,
|
|
});
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
continuationPolicy: "",
|
|
resolverPolicyProvenance: "legacy_inherited_restriction",
|
|
})
|
|
.where(eq(issueThreadInteractions.id, created.id));
|
|
|
|
await interactionsSvc.answerQuestions({
|
|
id: issueId,
|
|
companyId,
|
|
}, created.id, {
|
|
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
|
|
summaryMarkdown: "Do not emit this free text.",
|
|
}, {
|
|
systemId: "system:pr-merged",
|
|
});
|
|
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "ask_user_questions",
|
|
status: "answered",
|
|
resolved_by_kind: "system",
|
|
created_by_kind: "agent",
|
|
creator_agent_role: "Wizard",
|
|
target_type: "none",
|
|
question_count: 2,
|
|
answered_question_count: 1,
|
|
legacy_inherited_restriction: true,
|
|
});
|
|
expect(dimensions).not.toHaveProperty("continuation_policy");
|
|
expect(dimensions).not.toHaveProperty("summaryMarkdown");
|
|
expect(dimensions).not.toHaveProperty("answers");
|
|
expect(JSON.stringify(dimensions)).not.toContain("Choose the scope");
|
|
expect(JSON.stringify(dimensions)).not.toContain("Phase 1");
|
|
expect(JSON.stringify(dimensions)).not.toContain("Do not emit this free text.");
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
|
|
it("emits expired question telemetry with zero answered question count", async () => {
|
|
const { companyId, issueId } = await seedIssue("Expired question telemetry");
|
|
const commentId = randomUUID();
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "ask_user_questions",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
questions: [
|
|
{
|
|
id: "scope",
|
|
prompt: "Choose the scope",
|
|
selectionMode: "single",
|
|
required: true,
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
},
|
|
],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: commentId,
|
|
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
|
|
authorUserId: "local-board",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(expired).toHaveLength(1);
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "ask_user_questions",
|
|
status: "expired",
|
|
resolved_by_kind: "user",
|
|
resolution_reason: "superseded_by_comment",
|
|
created_by_kind: "user",
|
|
continuation_policy: "wake_assignee",
|
|
target_type: "none",
|
|
question_count: 1,
|
|
answered_question_count: 0,
|
|
});
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
|
|
it("emits expired stale-target telemetry without stale target identifiers", async () => {
|
|
const { companyId, issueId } = await seedIssue("Stale target telemetry");
|
|
const documentId = randomUUID();
|
|
const revisionId = randomUUID();
|
|
|
|
await db.insert(documents).values({
|
|
id: documentId,
|
|
companyId,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
latestBody: "v1",
|
|
latestRevisionId: revisionId,
|
|
latestRevisionNumber: 1,
|
|
});
|
|
await db.insert(issueDocuments).values({
|
|
companyId,
|
|
issueId,
|
|
documentId,
|
|
key: "plan",
|
|
});
|
|
await db.insert(documentRevisions).values({
|
|
id: revisionId,
|
|
companyId,
|
|
documentId,
|
|
revisionNumber: 1,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "v1",
|
|
});
|
|
|
|
await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the plan?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId,
|
|
documentId,
|
|
key: "plan",
|
|
revisionId,
|
|
revisionNumber: 1,
|
|
},
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const expired = await interactionsSvc.expireStaleRequestConfirmationsForIssueDocument({
|
|
id: issueId,
|
|
companyId,
|
|
}, null, {});
|
|
|
|
expect(expired).toHaveLength(1);
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "request_confirmation",
|
|
status: "expired",
|
|
resolved_by_kind: "system",
|
|
resolution_reason: "stale_target",
|
|
created_by_kind: "user",
|
|
continuation_policy: "none",
|
|
target_type: "issue_document",
|
|
});
|
|
expect(dimensions).not.toHaveProperty("staleTarget");
|
|
expect(dimensions).not.toHaveProperty("revisionId");
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
|
|
it("emits superseded expiration telemetry without comment identifiers", async () => {
|
|
const { companyId, issueId } = await seedIssue("Superseded telemetry");
|
|
const commentId = randomUUID();
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve this plan?",
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: commentId,
|
|
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
|
|
authorUserId: "local-board",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(expired).toHaveLength(1);
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "request_confirmation",
|
|
status: "expired",
|
|
resolved_by_kind: "user",
|
|
resolution_reason: "superseded_by_comment",
|
|
created_by_kind: "user",
|
|
target_type: "none",
|
|
});
|
|
expect(dimensions).not.toHaveProperty("commentId");
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
});
|