mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
266 lines
19 KiB
TypeScript
266 lines
19 KiB
TypeScript
import { and, eq } from "drizzle-orm";
|
||
import { assets, documents, documentRevisions, heartbeatRuns, issueDocuments, issueAttachments, issueWorkProducts, mcpAttachmentUploads, dotRunnerAssignments, type Db } from "@paperclipai/db";
|
||
import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
|
||
|
||
function evidenceRefs(value: unknown): string[] {
|
||
if (!Array.isArray(value)) return [];
|
||
return value.flatMap((entry) => {
|
||
if (typeof entry === "string") return [entry];
|
||
if (entry && typeof entry === "object" && typeof entry.ref === "string") return [entry.ref];
|
||
return [];
|
||
});
|
||
}
|
||
|
||
function record(value: unknown): Record<string, unknown> {
|
||
return value && typeof value === "object" && !Array.isArray(value)
|
||
? value as Record<string, unknown> : {};
|
||
}
|
||
|
||
async function hasCurrentPublicationReceipt(db: Db, companyId: string, receipts: unknown, attachment: {
|
||
id: string; originatingRunId: string | null; filename: string | null; byteSize: number; sha256: string;
|
||
}): Promise<boolean> {
|
||
for (const value of Object.values(record(receipts))) {
|
||
const receipt = record(value);
|
||
const input = record(receipt.input);
|
||
const result = record(receipt.result);
|
||
if ((result.disposition !== "applied" && result.disposition !== "duplicate") ||
|
||
!Array.isArray(result.entityRefs) || result.entityRefs[0] !== attachment.id) continue;
|
||
if (receipt.operationId === "publish_external_deliverable" && result.source === "authenticated_upload"
|
||
&& result.commandId === `deliverable-uploaded:${attachment.id}` && typeof input.uploadId === "string"
|
||
&& typeof input.workSessionId === "string" && attachment.originatingRunId && attachment.filename
|
||
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(input.uploadId)
|
||
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(input.workSessionId)
|
||
&& input.filename === attachment.filename
|
||
&& input.byteSize === attachment.byteSize && input.sha256 === attachment.sha256) {
|
||
const [upload] = await db.select({ id: mcpAttachmentUploads.id }).from(mcpAttachmentUploads)
|
||
.innerJoin(dotRunnerAssignments, and(eq(dotRunnerAssignments.id, mcpAttachmentUploads.workSessionId),
|
||
eq(dotRunnerAssignments.companyId, companyId)))
|
||
.where(and(eq(mcpAttachmentUploads.id, input.uploadId), eq(mcpAttachmentUploads.companyId, companyId),
|
||
eq(mcpAttachmentUploads.workSessionId, input.workSessionId), eq(mcpAttachmentUploads.attachmentId, attachment.id),
|
||
eq(dotRunnerAssignments.runId, attachment.originatingRunId), eq(mcpAttachmentUploads.originalFilename, attachment.filename),
|
||
eq(mcpAttachmentUploads.byteSize, attachment.byteSize), eq(mcpAttachmentUploads.sha256, attachment.sha256))).limit(1);
|
||
if (upload) return true;
|
||
}
|
||
if (receipt.operationId === "register_deliverable" &&
|
||
result.commandId === `deliverable-prepared:${attachment.id}` &&
|
||
typeof input.filename === "string" && input.filename.trim() === attachment.filename &&
|
||
input.byteSize === attachment.byteSize &&
|
||
typeof input.sha256 === "string" && input.sha256.trim().toLowerCase() === attachment.sha256.toLowerCase()) return true;
|
||
if (receipt.operationId !== "reuse_chat_attachment" ||
|
||
result.commandId !== `chat-attachment-reused:${attachment.id}`) continue;
|
||
const prepared = record(result.prepared);
|
||
const source = record(result.source);
|
||
if (prepared.attachmentId !== attachment.id ||
|
||
source.attachmentId !== input.attachmentId || source.commentId !== input.sourceCommentId ||
|
||
typeof prepared.sha256 !== "string" || prepared.sha256.toLowerCase() !== attachment.sha256.toLowerCase() ||
|
||
typeof source.sha256 !== "string" || source.sha256.toLowerCase() !== attachment.sha256.toLowerCase()) continue;
|
||
if ("filename" in prepared || "byteSize" in prepared) {
|
||
if (prepared.filename === attachment.filename && prepared.byteSize === attachment.byteSize) return true;
|
||
continue;
|
||
}
|
||
// Older committed reuse receipts contain the authenticated source and hash,
|
||
// but not its filename/size. Only an intact, matching source can supply those
|
||
// missing facts; this does not authorize a new reuse or bypass its tool gate.
|
||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
||
if (typeof source.attachmentId !== "string" || !uuid.test(source.attachmentId) ||
|
||
typeof source.commentId !== "string" || !uuid.test(source.commentId)) continue;
|
||
const [original] = await db.select({ filename: assets.originalFilename, byteSize: assets.byteSize, sha256: assets.sha256 })
|
||
.from(issueAttachments).innerJoin(assets, and(eq(assets.id, issueAttachments.assetId), eq(assets.companyId, companyId)))
|
||
.where(and(eq(issueAttachments.id, source.attachmentId), eq(issueAttachments.companyId, companyId),
|
||
eq(issueAttachments.issueCommentId, source.commentId))).limit(1);
|
||
if (original?.filename === attachment.filename && original.byteSize === attachment.byteSize &&
|
||
original.sha256.toLowerCase() === attachment.sha256.toLowerCase()) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Recognize explicit output requests, not incidental mentions of source files.
|
||
* The current server-bound objective is authoritative; summaries cannot invent
|
||
* an output requirement or erase a user's request for a file.
|
||
*/
|
||
export function explicitlyRequestsFileOutput(objective: string): boolean {
|
||
const sentences = objective.replaceAll("\\_", "_").split(/(?:[.!?](?:\s|$)|\n)/iu);
|
||
let precedingFileOutput = false;
|
||
return sentences.some((sentence, index) => {
|
||
const file = /\b(?:files?|attachments?|downloads?|pdf|spreadsheets?|workbooks?|slide decks?|powerpoints?|docx|xlsx|csv)\b|\b[^\s/]+\.(?:md|txt|pdf|docx?|xlsx?|csv|pptx?|png|jpe?g|svg|zip)\b/giu;
|
||
const outputs = sentence.split(/(?:[;,]|\bbut\b)/iu).flatMap(clause => {
|
||
const creates = [...clause.matchAll(/\b(?:create|make|write|save|export|attach|send|generate|produce|prepare|provide|give|return|build)\b/giu)];
|
||
return creates.flatMap((create, createIndex) => {
|
||
const before = clause.slice(0, create.index);
|
||
if (/\b(?:do not|don['’]t|never|no need to)\s*$/iu.test(before)) return [];
|
||
const negation = /\b(?:do not|don['’]t|never|no need to)\b/iu.exec(before);
|
||
if (negation) {
|
||
const rest = before.slice(negation.index + negation[0].length);
|
||
if (!/\binstead\b/iu.test(rest) && /^(?:\s*|[\s\S]*\b(?:and|or|nor)\s*)$/iu.test(rest)) return [];
|
||
}
|
||
// Bind each object to its own verb. A denied write or "create no files"
|
||
// cannot suppress a separate requested report in this same clause.
|
||
const output = clause.slice(create.index + create[0].length, creates[createIndex + 1]?.index);
|
||
const objects = [...output.matchAll(file)].filter(match => {
|
||
const prefix = output.slice(0, match.index);
|
||
const suffix = output.slice(match.index + match[0].length);
|
||
// "Create no files" is a prohibition, even though it contains a creation
|
||
// verb. Negate this object only; another explicit output can still count.
|
||
if (/\b(?:no|zero|without(?:\s+any)?)\s+(?:(?:new|temporary|downloadable|attached|additional)\s+)*$/iu.test(prefix)) return false;
|
||
// "Write a summary of this PDF" names input, not a requested file.
|
||
// Explicit export destinations still count after such input references.
|
||
const destination = /\b(?:as|into|to)\s+(?:(?:a|an|the|new|separate|markdown|word|excel)\s+)*$/iu.test(prefix);
|
||
if (!destination && /\b(?:of|about|on|from|using|for|with)\b/iu.test(prefix)) return false;
|
||
if (/^files?$/iu.test(match[0]) && /^\s+(?:tools?|permissions?|systems?|formats?|names?|paths?|types?|sizes?|descriptors?)\b/iu.test(suffix)) return false;
|
||
return true;
|
||
});
|
||
// An explicit attachment/export request can refer to the file by
|
||
// pronoun. It still requires publication when its name is omitted.
|
||
const referencesOutput = /^(?:attach|export|send|provide|give|return)$/iu.test(create[0])
|
||
&& /^\s+(?:me\s+)?(?:it|them|this|that)\b/iu.test(output);
|
||
const referencedAttachment = referencesOutput && /^(?:attach|export)$/iu.test(create[0]);
|
||
const inline = /\b(?:inline|(?:in|within|inside|as|into)\s+(?:(?:a|an|the|my|your|our|final|plain|markdown|chat|fenced)\s+)*(?:chat|response|reply|message|comment|text|code block)|(?:its|the) contents)\b/iu.test(output);
|
||
const downloadable = referencedAttachment || (!/\b(?:no|without)\s+(?:downloadable|attached)/iu.test(output)
|
||
&& /\b(?:downloadable|attached)\s+(?:file|report|document|checklist|draft)\b/iu.test(output));
|
||
const publication = /\b(?:downloadable|attached|attach|export|send|provide|return|give)\b/iu.test(create[0] + output);
|
||
const deniedAttempt = create[0].toLowerCase() === "write" && objects.length === 1
|
||
&& /\b(?:attempt|try)\s+(?:the\s+)?native\s*$/iu.test(before)
|
||
&& /\b(?:must be denied|denial is (?:the )?expected|(?:this|the) negative test)\b/iu.test(objective);
|
||
if (objects.length === 0 && !downloadable && /^\s+(?:no|zero|without)\b/iu.test(output)) return [];
|
||
return [{ objects: objects.length, downloadable, publication, deniedAttempt,
|
||
referencesOutput, publicationReference: referencesOutput && !inline }];
|
||
});
|
||
});
|
||
// "This ..." qualifies a single requested file in the preceding sentence,
|
||
// even when a later clause checks it. Multiple files cannot share this
|
||
// exception and separate report requests still require publication.
|
||
const internal = outputs.reduce((count, output) => count + output.objects + Number(output.downloadable && output.objects === 0), 0) === 1
|
||
&& /^\s*This is (?:an? )?(?:personal memory|internal (?:assertion|verification) file)\b[^.!?]*\bnot a (?:task )?deliverable\b/iu.test(sentences[index + 1] ?? "");
|
||
return outputs.some(output => {
|
||
const publicationReference = output.publicationReference && precedingFileOutput;
|
||
if (!output.referencesOutput) precedingFileOutput = output.objects > 0;
|
||
return (output.objects > 0 || output.downloadable || publicationReference)
|
||
&& (output.publication || (!internal && !output.deniedAttempt));
|
||
});
|
||
});
|
||
}
|
||
|
||
/** An explicitly requested document on the task must be published there. */
|
||
export function explicitlyRequestsTaskDocumentOutput(objective: string): boolean {
|
||
// Keep comma-separated conditions with their imperative. This is a narrow
|
||
// unconditional-output guard, not an interpreter of whether a condition held.
|
||
return objective.split(/(?:[.!?](?:\s|$)|\n|;)/iu).some(statement => {
|
||
// Check conditions before separating contrastive instructions: "create a
|
||
// document, but only if ..." must not become an unconditional requirement.
|
||
if (/\b(?:if|unless|when|once|otherwise|provided that|in case)\b/iu.test(statement)) return false;
|
||
return statement.split(/\bbut\b/iu).some(clause => {
|
||
if (/\boptionally\b/iu.test(clause)) return false;
|
||
const create = /\b(?:create|make|write|save|publish|prepare|provide|attach)\b/iu.exec(clause);
|
||
if (!create) return false;
|
||
const before = clause.slice(0, create.index);
|
||
if (/\b(?:do not|don['’]t|never|no need to|may|could|can)\b/iu.test(before)) return false;
|
||
if (/\b(?:explain|describe|discuss|review)\b/iu.test(before)) return false;
|
||
const output = clause.slice(create.index + create[0].length);
|
||
return [...output.matchAll(/\b(?:document|doc)\b/giu)].some(match => {
|
||
const prefix = output.slice(0, match.index);
|
||
if (/\b(?:of|about|from|using|for|with|without|no|zero)\b/iu.test(prefix)) return false;
|
||
return /^\s+(?:on|to|in|attached to)\s+(?:this|the|current)\s+(?:task|issue)\b/iu.test(output.slice(match.index + match[0].length));
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/** Current attached revisions with server-owned publication proof. Joining the
|
||
* revision to its originating run preserves completed work across continuations
|
||
* without accepting stale, foreign-task, or provider-invented document refs.
|
||
*/
|
||
export async function publishedTaskDocuments(db: Db, binding: { companyId: string; issueId: string }) {
|
||
const saved = await db.select({ id: documents.id, revisionId: documents.latestRevisionId,
|
||
key: issueDocuments.key, resultJson: heartbeatRuns.resultJson })
|
||
.from(issueDocuments)
|
||
.innerJoin(documents, and(eq(documents.id, issueDocuments.documentId), eq(documents.companyId, binding.companyId)))
|
||
.innerJoin(documentRevisions, and(eq(documentRevisions.id, documents.latestRevisionId),
|
||
eq(documentRevisions.documentId, documents.id), eq(documentRevisions.companyId, binding.companyId)))
|
||
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, documentRevisions.createdByRunId),
|
||
eq(heartbeatRuns.companyId, binding.companyId), eq(heartbeatRuns.nativeIssueId, binding.issueId),
|
||
eq(heartbeatRuns.runtimeMode, "native")))
|
||
.where(and(eq(issueDocuments.companyId, binding.companyId), eq(issueDocuments.issueId, binding.issueId)));
|
||
return saved.filter(document => Object.values(record(document.resultJson?.semanticToolReceipts)).some(value => {
|
||
const receipt = record(value), result = record(receipt.result), published = record(result.document);
|
||
return receipt.operationId === "write_document" && ["applied", "duplicate"].includes(String(result.disposition))
|
||
&& published.id === document.id && published.latestRevisionId === document.revisionId;
|
||
})).map(({ id, revisionId, key }) => ({ id, revisionId, key }));
|
||
}
|
||
|
||
/** Files cited as completed output must be reachable outside the agent workspace. */
|
||
export async function validateNativeDeliverableEvidence(
|
||
db: Db,
|
||
binding: { companyId: string; issueId: string; runId: string; objective: string; semanticToolReceipts: unknown },
|
||
result: PrpStructuredRunResult,
|
||
): Promise<void> {
|
||
if (result.reportedWorkDisposition !== "done") return;
|
||
const fileRequested = explicitlyRequestsFileOutput(binding.objective);
|
||
const taskDocumentRequested = explicitlyRequestsTaskDocumentOutput(binding.objective);
|
||
const publishedTaskDocument = taskDocumentRequested && (await publishedTaskDocuments(db, binding)).length > 0;
|
||
const artifactRefs = new Set(evidenceRefs(result.artifacts));
|
||
const refs = new Set([
|
||
...evidenceRefs(result.evidence),
|
||
...artifactRefs,
|
||
...result.completionClaim.criteria.flatMap(({ evidenceRefs }) => evidenceRefs),
|
||
]);
|
||
let registeredAttachment = false;
|
||
for (const value of refs) {
|
||
if (typeof value !== "string") continue;
|
||
const ref = value.trim();
|
||
const attachmentPath = /^\/api\/attachments\/([^/?#]+)\/content(?:[?#].*)?$/u.exec(ref);
|
||
if (ref.startsWith("deliverable:") || attachmentPath) {
|
||
const id = attachmentPath?.[1] ?? ref.slice("deliverable:".length);
|
||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
||
const [attachment] = uuid.test(id)
|
||
? await db.select({ id: issueAttachments.id, originatingRunId: issueAttachments.originatingRunId,
|
||
filename: assets.originalFilename, byteSize: assets.byteSize, sha256: assets.sha256 }).from(issueAttachments)
|
||
.innerJoin(assets, and(eq(assets.id, issueAttachments.assetId), eq(assets.companyId, binding.companyId)))
|
||
.where(and(eq(issueAttachments.id, id), eq(issueAttachments.companyId, binding.companyId), eq(issueAttachments.issueId, binding.issueId)))
|
||
.limit(1)
|
||
: [];
|
||
if (!attachment) {
|
||
throw new Error("Completion cites no registered attachment on this task. Use register_deliverable for the requested file and cite deliverable:<attachmentId> from its receipt. No human completion approval was created.");
|
||
}
|
||
// A prior output (or user input) can be useful context, but does not prove
|
||
// this run published the newly requested output. The receipt survives a
|
||
// controller restart of this run; a replacement can re-register preserved
|
||
// workspace bytes internally rather than asking the user to confirm them.
|
||
if ((fileRequested || taskDocumentRequested) && attachment.originatingRunId !== binding.runId) continue;
|
||
if ((fileRequested || taskDocumentRequested) && !await hasCurrentPublicationReceipt(db, binding.companyId, binding.semanticToolReceipts, attachment)) {
|
||
throw new Error("This attachment has no matching verified publication receipt for this run's requested output. Inspect any preserved file and use register_deliverable to verify its current filename, size, and SHA-256, then cite the new receipt. No human completion approval was created.");
|
||
}
|
||
registeredAttachment = true;
|
||
continue;
|
||
}
|
||
// URLs and typed durable refs are not workspace paths. Verification commands
|
||
// belong in verification; do not scan prose or upload files named by a model.
|
||
const localFile = /^(?:file:|\.{0,2}\/|[a-z]:[\\/])/iu.test(ref)
|
||
|| (!/^[a-z][a-z0-9+.-]*:/iu.test(ref) && /^[^\r\n]+\.[a-z0-9]{1,16}(?::\d+(?::\d+)?)?$/iu.test(ref));
|
||
if (localFile && taskDocumentRequested && !publishedTaskDocument) {
|
||
throw new Error("The requested task document is only a workspace file. Publish it on this task with write_document, or attach the verified file with register_deliverable and cite deliverable:<attachmentId>. Reuse completed work; do not request a new completion approval.");
|
||
}
|
||
if (localFile && (fileRequested || artifactRefs.has(value))) {
|
||
throw new Error("Completion cites a workspace-only file that the user cannot download. Before finishing, use register_deliverable for requested file outputs and cite deliverable:<attachmentId> from the receipt, with /api/attachments/<attachmentId>/content as the download link. For repository changes, cite an accessible PR or registered work product instead. No human completion approval was created.");
|
||
}
|
||
}
|
||
if (taskDocumentRequested && !publishedTaskDocument && !registeredAttachment) {
|
||
throw new Error("The requested document has not been published on this task. Use write_document, or register_deliverable with its verified attachment receipt. A workspace path or final message alone is not the requested task document. Reuse completed work without requesting a new completion approval.");
|
||
}
|
||
if (fileRequested && !registeredAttachment) {
|
||
const products = refs.size ? await db.select().from(issueWorkProducts).where(and(
|
||
eq(issueWorkProducts.companyId, binding.companyId), eq(issueWorkProducts.issueId, binding.issueId),
|
||
)) : [];
|
||
const accessibleProduct = products.some(product => {
|
||
if (product.createdByRunId !== binding.runId) return false;
|
||
if (["failed", "cancelled", "archived"].includes(product.status)) return false;
|
||
// A workspace_file resource is only a locator: registration neither checks
|
||
// its current bytes nor keeps them alive after workspace cleanup. Requested
|
||
// files need a published URL or the verified attachment receipt above.
|
||
const accessible = typeof product.url === "string" && /^https?:\/\//iu.test(product.url);
|
||
return accessible && [product.url, `work_product:${product.id}`, `work-product:${product.id}`, `artifact:${product.id}`]
|
||
.some(ref => typeof ref === "string" && refs.has(ref));
|
||
});
|
||
if (!accessibleProduct) throw new Error("The requested file has no accessible delivery evidence. Use register_deliverable and cite deliverable:<attachmentId>, or cite an accessible work product registered by this run for this task. Empty evidence, prior-run output, and a verification result cannot substitute for the requested file. Continue publishing or report a concrete blocker; no human completion approval was created.");
|
||
}
|
||
}
|