Files
PaperClipAI/server/src/services/activity-log.ts
T
DottaandPaperclip 814cb33676 feat(server): allow agents to resolve review confirmations (#10939)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Issue reviews use thread confirmations to record explicit verdicts
> - The server allowed users to resolve review confirmations but
rejected all agent actors
> - This one-way rule prevented an eligible agent reviewer from
completing a review
> - The existing review policy already defines which actor can submit a
verdict
> - This pull request applies that policy to agent confirmation verdicts
on writable issues
> - The benefit is a consistent review gate for users and agents with
preserved audit attribution

## Linked Issues or Issue Description

- Builds on: #10931 (merged into master before this PR)
- Refs #8617

## What Changed

- Allow eligible agents to accept or reject pending review confirmations
on issues they can write.
- Allow a creator agent to withdraw its own pending review confirmation
when the review policy permits it.
- Reuse the review verdict policy check for users and agents.
- Require an explicit, same-run review-confirmation binding so unrelated
board-only confirmations stay protected.
- Preserve board-only tool action confirmations and existing user
attribution.
- Add route and service tests for agent accept, reject, withdrawal,
human-only denial, and user attribution.

## Verification

- `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
server/src/__tests__/issue-execution-policy-routes.test.ts
server/src/__tests__/issue-review-policy.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
server/src/__tests__/issue-stalled-review-decision-routes.test.ts` (256
passed after rebasing onto master and the atomic binding fix)
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm -r typecheck`
- `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm test:run`
- `pnpm build`

## Risks

- The change expands who can resolve pending review confirmations. The
existing issue write checks and review policy limit this access.
- Tool action confirmations remain board-only.
- The pull request depends on the review policy helper from #10931,
which is now merged into master.

> 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`.

The roadmap marks Agent Reviews and Approvals as shipped. This pull
request fixes a narrow server behavior gap in that shipped capability.

## Model Used

- OpenAI Codex, model `gpt-5.6-sol`, with reasoning, tool use, and code
execution. The runtime does not expose the context window size.

## 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>
2026-08-05 23:40:05 -05:00

230 lines
8.2 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { activityLog, agentApiKeys, companies, heartbeatRuns, issues } from "@paperclipai/db";
import { isUuidLike, PLUGIN_EVENT_TYPES, type PluginEventType } from "@paperclipai/shared";
import type { PluginEvent } from "@paperclipai/plugin-sdk";
import { publishLiveEvent } from "./live-events.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { sanitizeRecord } from "../redaction.js";
import { logger } from "../middleware/logger.js";
import type { PluginEventBus } from "./plugin-event-bus.js";
import { instanceSettingsService } from "./instance-settings.js";
const PLUGIN_EVENT_SET: ReadonlySet<string> = new Set(PLUGIN_EVENT_TYPES);
const ACTIVITY_ACTION_TO_PLUGIN_EVENT: Readonly<Record<string, PluginEventType>> = {
issue_comment_added: "issue.comment.created",
issue_comment_created: "issue.comment.created",
issue_document_created: "issue.document.created",
issue_document_updated: "issue.document.updated",
issue_document_deleted: "issue.document.deleted",
issue_blockers_updated: "issue.relations.updated",
approval_approved: "approval.decided",
approval_rejected: "approval.decided",
approval_revision_requested: "approval.decided",
budget_soft_threshold_crossed: "budget.incident.opened",
budget_hard_threshold_crossed: "budget.incident.opened",
budget_incident_resolved: "budget.incident.resolved",
};
let _pluginEventBus: PluginEventBus | null = null;
/** Wire the plugin event bus so domain events are forwarded to plugins. */
export function setPluginEventBus(bus: PluginEventBus): void {
if (_pluginEventBus) {
logger.warn("setPluginEventBus called more than once, replacing existing bus");
}
_pluginEventBus = bus;
}
function eventTypeForActivityAction(action: string): PluginEventType | null {
if (PLUGIN_EVENT_SET.has(action)) return action as PluginEventType;
return ACTIVITY_ACTION_TO_PLUGIN_EVENT[action.replaceAll(".", "_")] ?? null;
}
export function publishPluginDomainEvent(event: PluginEvent): void {
if (!_pluginEventBus) return;
void _pluginEventBus.emit(event).then(({ errors }) => {
for (const { pluginId, error } of errors) {
logger.warn({ pluginId, eventType: event.eventType, err: error }, "plugin event handler failed");
}
}).catch(() => {});
}
export interface LogActivityInput {
companyId: string;
actorType: "agent" | "user" | "system" | "plugin";
actorId: string;
action: string;
entityType: string;
entityId: string;
agentId?: string | null;
runId?: string | null;
agentApiKeyId?: string | null;
issueId?: string | null;
details?: Record<string, unknown> | null;
responsibleUserIdOverride?: string | null;
}
export interface ActivityPublication {
companyId: string;
payload: Record<string, unknown>;
pluginEvent: PluginEvent | null;
}
export async function createActivityDetailsRedactor(db: Db) {
const currentUserRedactionOptions = {
enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs,
};
return (details: Record<string, unknown> | null) => (
details ? redactCurrentUserValue(sanitizeRecord(details), currentUserRedactionOptions) : null
);
}
export async function redactActivityDetails(db: Db, details: Record<string, unknown> | null) {
if (!details) return null;
return (await createActivityDetailsRedactor(db))(details);
}
function readNonEmptyString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export async function resolveResponsibleUserIdForActivity(db: Db, input: LogActivityInput) {
if (input.responsibleUserIdOverride !== undefined) {
return readNonEmptyString(input.responsibleUserIdOverride);
}
if (input.actorType === "user") return readNonEmptyString(input.actorId);
const runId = readNonEmptyString(input.runId);
if (runId && isUuidLike(runId)) {
const run = await db
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, runId)))
.then((rows) => rows[0] ?? null);
const runResponsibleUserId = readNonEmptyString(run?.responsibleUserId);
if (runResponsibleUserId) return runResponsibleUserId;
}
const issueIdCandidate = readNonEmptyString(input.issueId)
?? (input.entityType === "issue" ? readNonEmptyString(input.entityId) : null);
const issueId = isUuidLike(issueIdCandidate) ? issueIdCandidate : null;
if (issueId) {
const issue = await db
.select({
responsibleUserId: issues.responsibleUserId,
createdByUserId: issues.createdByUserId,
})
.from(issues)
.where(and(eq(issues.companyId, input.companyId), eq(issues.id, issueId)))
.then((rows) => rows[0] ?? null);
const issueResponsibleUserId = readNonEmptyString(issue?.responsibleUserId)
?? readNonEmptyString(issue?.createdByUserId);
if (issueResponsibleUserId) return issueResponsibleUserId;
}
const agentApiKeyId = readNonEmptyString(input.agentApiKeyId);
const agentId = readNonEmptyString(input.agentId);
if (agentApiKeyId && isUuidLike(agentApiKeyId)) {
const apiKey = await db
.select({ responsibleUserId: agentApiKeys.responsibleUserId })
.from(agentApiKeys)
.where(and(
eq(agentApiKeys.companyId, input.companyId),
eq(agentApiKeys.id, agentApiKeyId),
...(agentId && isUuidLike(agentId) ? [eq(agentApiKeys.agentId, agentId)] : []),
))
.then((rows) => rows[0] ?? null);
const apiKeyResponsibleUserId = readNonEmptyString(apiKey?.responsibleUserId);
if (apiKeyResponsibleUserId) return apiKeyResponsibleUserId;
}
const company = await db
.select({ defaultResponsibleUserId: companies.defaultResponsibleUserId })
.from(companies)
.where(eq(companies.id, input.companyId))
.then((rows) => rows[0] ?? null);
return readNonEmptyString(company?.defaultResponsibleUserId);
}
export function publishActivity(publication: ActivityPublication) {
publishLiveEvent({
companyId: publication.companyId,
type: "activity.logged",
payload: publication.payload,
});
if (publication.pluginEvent) publishPluginDomainEvent(publication.pluginEvent);
}
export async function persistActivity(db: Db, input: LogActivityInput) {
const redactedDetails = await redactActivityDetails(db, input.details ?? null);
const responsibleUserId = await resolveResponsibleUserIdForActivity(db, input);
const [activity] = await db.insert(activityLog).values({
companyId: input.companyId,
actorType: input.actorType,
actorId: input.actorId,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
agentId: input.agentId ?? null,
runId: input.runId ?? null,
responsibleUserId,
details: redactedDetails,
}).returning({ id: activityLog.id });
const payload = {
actorType: input.actorType,
actorId: input.actorId,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
agentId: input.agentId ?? null,
runId: input.runId ?? null,
responsibleUserId,
details: redactedDetails,
};
const pluginEventType = eventTypeForActivityAction(input.action);
const pluginEvent: PluginEvent | null = pluginEventType
? {
eventId: randomUUID(),
eventType: pluginEventType,
occurredAt: new Date().toISOString(),
actorId: input.actorId,
actorType: input.actorType,
entityId: input.entityId,
entityType: input.entityType,
companyId: input.companyId,
payload: {
...redactedDetails,
agentId: input.agentId ?? null,
runId: input.runId ?? null,
responsibleUserId,
},
}
: null;
return {
activity,
publication: {
companyId: input.companyId,
payload,
pluginEvent,
} satisfies ActivityPublication,
};
}
export async function logActivity(
db: Db,
input: LogActivityInput,
postCommitPublications?: ActivityPublication[],
) {
const { activity, publication } = await persistActivity(db, input);
if (postCommitPublications) {
postCommitPublications.push(publication);
} else {
publishActivity(publication);
}
return activity;
}