import type { Db } from "@paperclipai/db"; import { issueThreadInteractions } from "@paperclipai/db"; import { and, desc, eq, or, sql } from "drizzle-orm"; import type { RequestConfirmationPayload, RequestConfirmationResult } from "@paperclipai/shared"; import { forbidden } from "../errors.js"; export const AGENT_PROFILE_CHANGE_CONSENT_FIELDS = ["name", "role", "title", "capabilities"] as const; type ConsumedRequestConfirmationResult = RequestConfirmationResult & { consumedAt?: string | null; consumedByRunId?: string | null; }; export function agentInstructionsChangeTargetKey(agentId: string) { return `agent:${agentId}:instructions`; } export function agentProfileChangeTargetKey(agentId: string) { return `agent:${agentId}:profile`; } export function skillChangeTargetKey(skillId: string) { return `skill:${skillId}`; } export function skillSlugChangeTargetKey(slug: string) { return `skill-slug:${slug}`; } export function skillImportChangeTargetKey(source: string) { return `skill-import:${source}`; } export function skillsScanProjectsChangeTargetKey() { return "skills:scan-projects"; } export function touchesAgentProfileChangeConsentFields(patchData: Record) { return AGENT_PROFILE_CHANGE_CONSENT_FIELDS.some((key) => Object.prototype.hasOwnProperty.call(patchData, key), ); } function readNonEmptyString(value: unknown) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } function payloadHasDisplayedDiff(payload: RequestConfirmationPayload) { const details = readNonEmptyString(payload.detailsMarkdown); if (!details) return false; if (/```diff\b/i.test(details)) return true; return /(^|\n)[+-][^\n]+/.test(details); } function requestConfirmationResultConsumed(result: RequestConfirmationResult | null) { const consumed = result as ConsumedRequestConfirmationResult | null; return Boolean(readNonEmptyString(consumed?.consumedByRunId) || readNonEmptyString(consumed?.consumedAt)); } function markRequestConfirmationResultConsumed( result: RequestConfirmationResult, actorRunId: string, consumedAt: Date, ): ConsumedRequestConfirmationResult { return { ...result, consumedAt: consumedAt.toISOString(), consumedByRunId: actorRunId, }; } function legacyTargetKeysFor(targetKey: string) { if (targetKey.startsWith("agent:") && targetKey.endsWith(":instructions")) { const agentId = targetKey.slice("agent:".length, -":instructions".length); if (agentId) return [`reflection-coach:agent-instructions:${agentId}`]; } if (targetKey.startsWith("agent:") && targetKey.endsWith(":profile")) { const agentId = targetKey.slice("agent:".length, -":profile".length); if (agentId) return [`reflection-coach:agent-description:${agentId}`]; } if (targetKey.startsWith("skill:")) { const skillId = targetKey.slice("skill:".length); if (skillId) return [`reflection-coach:company-skill:${skillId}`]; } if (targetKey.startsWith("skill-slug:")) { const slug = targetKey.slice("skill-slug:".length); if (slug) return [`reflection-coach:company-skill-slug:${slug}`]; } if (targetKey.startsWith("skill-import:")) { const source = targetKey.slice("skill-import:".length); if (source) { return [ `reflection-coach:company-skill-import:${source}`, `reflection-coach:company-skill-catalog:${source}`, ]; } } if (targetKey === "skills:scan-projects") { return ["reflection-coach:company-skills:scan-projects"]; } return []; } function expandTargetKeysForLegacyCompatibility(targetKeys: string[]) { const expanded = new Set(); for (const targetKey of targetKeys) { expanded.add(targetKey); for (const legacyTargetKey of legacyTargetKeysFor(targetKey)) { expanded.add(legacyTargetKey); } } return [...expanded]; } export function changeConsentGateService(db: Db | Parameters[0]>[0]) { return { assertConsented: async (input: { companyId: string; actorAgentId: string | null | undefined; actorRunId: string | null | undefined; targetKeys: string[]; }): Promise => { const actorAgentId = readNonEmptyString(input.actorAgentId); if (!actorAgentId) return false; const actorRunId = readNonEmptyString(input.actorRunId); if (!actorRunId) { throw forbidden("Reflection Coach mutations require a run id", { code: "reflection_coach_mutation_run_id_required", }); } const targetKeys = [...new Set(input.targetKeys.map(readNonEmptyString).filter((key): key is string => Boolean(key)))]; if (targetKeys.length === 0) { throw forbidden("Reflection Coach mutation target is not gateable", { code: "reflection_coach_mutation_target_required", }); } const queryTargetKeys = expandTargetKeysForLegacyCompatibility(targetKeys); const targetKeyPredicate = or( ...queryTargetKeys.map((targetKey) => sql`${issueThreadInteractions.payload}->'target'->>'key' = ${targetKey}`, ), ); const rows = await db .select({ id: issueThreadInteractions.id, sourceRunId: issueThreadInteractions.sourceRunId, payload: issueThreadInteractions.payload, result: issueThreadInteractions.result, }) .from(issueThreadInteractions) .where(and( eq(issueThreadInteractions.companyId, input.companyId), eq(issueThreadInteractions.createdByAgentId, actorAgentId), eq(issueThreadInteractions.kind, "request_confirmation"), eq(issueThreadInteractions.status, "accepted"), targetKeyPredicate, )) .orderBy(desc(issueThreadInteractions.resolvedAt), desc(issueThreadInteractions.createdAt)) .limit(10); const accepted = rows.find((row) => { const payload = row.payload as RequestConfirmationPayload; const result = row.result as RequestConfirmationResult | null; return payload.target?.type === "custom" && queryTargetKeys.includes(payload.target.key) && result?.outcome === "accepted" && !requestConfirmationResultConsumed(result) && payloadHasDisplayedDiff(payload) && Boolean(row.sourceRunId) && row.sourceRunId !== actorRunId; }); if (!accepted) { throw forbidden( "Reflection Coach mutations require an accepted request_confirmation with a displayed diff for this target, " + "created in a previous run and not already consumed.", { code: "reflection_coach_mutation_gate_required", targetKeys, }, ); } const acceptedResult = accepted.result as RequestConfirmationResult | null; if (!acceptedResult) { throw forbidden( "Reflection Coach mutations require an accepted request_confirmation with a displayed diff for this target, " + "created in a previous run and not already consumed.", { code: "reflection_coach_mutation_gate_required", targetKeys, }, ); } const now = new Date(); const [consumed] = await db .update(issueThreadInteractions) .set({ result: markRequestConfirmationResultConsumed(acceptedResult, actorRunId, now), updatedAt: now, }) .where(and( eq(issueThreadInteractions.id, accepted.id), eq(issueThreadInteractions.companyId, input.companyId), eq(issueThreadInteractions.createdByAgentId, actorAgentId), eq(issueThreadInteractions.kind, "request_confirmation"), eq(issueThreadInteractions.status, "accepted"), sql`${issueThreadInteractions.result}->>'outcome' = 'accepted'`, sql`coalesce(${issueThreadInteractions.result}->>'consumedByRunId', ${issueThreadInteractions.result}->>'consumedAt') is null`, )) .returning({ id: issueThreadInteractions.id }); if (!consumed) { throw forbidden( "Reflection Coach mutations require an accepted request_confirmation with a displayed diff for this target, " + "created in a previous run and not already consumed.", { code: "reflection_coach_mutation_gate_required", targetKeys, }, ); } return true; }, }; }