Files
PaperClipAI/server/src/services/activity-log.ts
T
DottaandClaude Opus 4.8 7df36ef615 Stop agent doc writes 500ing on a run-id unknown to the local DB (PAP-10583)
An agent presents its live X-Paperclip-Run-Id, which can reference a heartbeat
run that does not exist in this server's DB (e.g. a worktree dev environment
whose DB never recorded the run). That run-id is FK-constrained on
issues.checkout_run_id/execution_run_id, document_revisions.created_by_run_id
and activity_log.run_id, so persisting it raised a FK violation that surfaced as
a 500. Board users were unaffected because they carry no run-id.

Resolve the run-id to null at each FK-bearing write site when the run is unknown:
checkout adoption still grants the assigned agent ownership (without a lock),
document provenance and activity logging degrade run_id to null. In real
deployments the run exists, so the value is persisted unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 03:43:33 +00:00

137 lines
5.0 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { activityLog, heartbeatRuns } from "@paperclipai/db";
import { 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;
details?: Record<string, unknown> | null;
}
// activity_log.run_id is FK-constrained to heartbeat_runs. An agent presents its live
// run-id, which may not be recorded in this server's DB (e.g. a worktree dev environment
// whose DB never saw the run). Persisting a missing run-id raises a FK violation and turns
// every agent-token write that logs activity into a 500. Resolve provenance to null when
// the run is unknown; in real deployments the run exists and the value is unchanged.
async function persistableActivityRunId(db: Db, runId: string | null | undefined): Promise<string | null> {
if (!runId) return null;
const run = await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
return run ? runId : null;
}
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 runId = await persistableActivityRunId(db, input.runId);
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,
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,
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,
},
};
publishPluginDomainEvent(event);
}
}