mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 11:13:44 +02:00
## Thinking Path > - Paperclip is the control plane people use to manage AI-agent companies and their work > - The activity log is the generic audit spine for mutations across the control plane > - Activity rows identify agents and runs, but they do not persist the responsible human upstream > - Relying only on run joins loses attribution after run pruning and misses agent API-key actions outside a run > - This pull request resolves responsible-user attribution when each activity row is written and stores it directly > - The benefit is durable, queryable agent audit feeds without rewriting historical provenance ## Linked Issues or Issue Description ### Problem or motivation Agent activity records do not persist the responsible user, so attribution can disappear when runs are pruned and no-run API-key mutations cannot be attributed correctly. ### Proposed solution Resolve attribution for each new activity row from the run, related issue, active agent API key, or company default, in that order, and persist the result directly. ### Alternatives considered Read-time joins alone were rejected because pruned runs lose durable attribution and out-of-run agent-key actions have no run to join. Historical backfill was rejected because it would invent provenance. ### Roadmap alignment This strengthens the durable audit-trail direction described in `ROADMAP.md` without adding a new product surface. ## What Changed - Added nullable `activity_log.responsible_user_id` plus company/agent/time and company/responsible-user/time indexes. - Added an idempotent forward-only migration with no historical backfill. - Added centralized write-time resolution: heartbeat run → issue attribution → active agent API key → company default. - Propagated authenticated API-key IDs through existing request-backed `logActivity()` calls. - Added unit coverage for every fallback and an embedded-Postgres assertion for the no-run API-key stamping path. ## Verification - `pnpm --filter @paperclipai/db typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/activity-log-responsible-user.test.ts src/__tests__/authz-company-access.test.ts` - Initial focused verification: 25 tests passed; migration safety passed. - Follow-up regression verification: 54 focused attribution/company-skill/environment/issue-tree/tool-gateway tests passed; server typecheck passed. - GitHub: full build, typecheck, server shards, serialized suites, e2e, security, and policy checks passed. ## Risks - Adding two indexes to an existing large table can hold a write lock while the transactional migration runs. The migration safety suppressions document why `CONCURRENTLY` is unavailable under the current Drizzle migration runner. - Historical rows remain nullable by design; this avoids inventing provenance and keeps the migration forward-only. - API-key attribution requires request-backed activity call sites to pass the authenticated key ID; this PR mechanically updates the existing actor-based activity calls and covers the no-run path with integration testing. > 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.4`; context-window size was not exposed by the runtime. Medium reasoning with repository editing, terminal execution, and test execution capabilities was used. ## 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>
186 lines
6.9 KiB
TypeScript
186 lines
6.9 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;
|
|
}
|
|
|
|
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.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 async function logActivity(db: Db, input: LogActivityInput) {
|
|
const currentUserRedactionOptions = {
|
|
enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs,
|
|
};
|
|
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
|
|
const redactedDetails = sanitizedDetails
|
|
? redactCurrentUserValue(sanitizedDetails, currentUserRedactionOptions)
|
|
: null;
|
|
const responsibleUserId = await resolveResponsibleUserIdForActivity(db, input);
|
|
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,
|
|
});
|
|
|
|
publishLiveEvent({
|
|
companyId: input.companyId,
|
|
type: "activity.logged",
|
|
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);
|
|
if (pluginEventType) {
|
|
const event: PluginEvent = {
|
|
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,
|
|
},
|
|
};
|
|
publishPluginDomainEvent(event);
|
|
}
|
|
}
|