mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
## Thinking Path > - Paperclip manages AI agents and keeps their results attached to tasks. > - Native tools return links to saved task documents. > - Completion feedback asks agents to include those links. > - The file-delivery validator treated every such relative link as a workspace path. > - A valid document and CSV result could therefore fail completion when it cited the supplied document link. > - This change derives document links from the same verified publication projection for guidance and validation. > - Requested files still need their own verified delivery evidence. ## Linked Issues or Issue Description **What happened?** The native document tool and completion feedback produce a link such as `/EX/issues/EX-1#document-output`. When a task also requests a downloadable CSV, citing that link causes completion to reject it as a workspace-only file. This occurs even when the document is published and the file has a valid current-run delivery receipt. **Expected behavior** Accept a server-derived document link only when it names the current company/task and a currently attached revision with a matching native publication receipt. Keep the separate requested-file requirement and all foreign, stale, missing, and workspace-file guards. **Steps to reproduce** 1. Save a task document through the native `write_document` tool. 2. Publish a CSV through `register_deliverable`. 3. Ask completion feedback for the final-response guidance. 4. Cite its returned document link alongside the file receipt in the next completion report. 5. The old validator rejects the valid document link as a workspace-only file. **Paperclip version or commit** Current source checkout. The regression exercises the real native document producer and completion validator. **Deployment mode** Local source verification on macOS arm64 with Node 24.22.1, pinned pnpm 9.15.4, locked dependencies, temporary PostgreSQL, and a controlled remote file-reader boundary. No live provider or hosted deployment is included. ## What Changed - Derive the canonical document href in the existing company/task/current-revision publication query. - Use that same verified href in completion guidance and reference validation. - Keep file registration and hash/size receipt requirements separate from document evidence. - Cover evidence, artifact, and criterion references; foreign companies/tasks; bogus keys; local paths; and lost current-revision publication proof. - Exercise actual native document writes and CSV registration, including attachment download query and fragment links. - Update the execution contract and the document feature/access maps. ## Verification - Red before the production edit: native completion generated a real saved-document link, then rejected the same link alongside a valid published file. The failure was the workspace-only-file guard. - Green: 210 tests across native completion feedback, explicit output validation, and the existing native file-handoff suite pass. These use real PostgreSQL and native tool receipts; the remote file reader is controlled. - A saved document link alone still fails the explicit CSV requirement. A document whose current revision lacks its publication receipt is rejected. Foreign task/company links, missing keys, malformed links, and workspace paths remain rejected. - Full workspace typecheck, server typecheck, and production build pass. - `node scripts/check-maps.mjs`, `node scripts/check-module-boundaries.mjs`, and `git diff --check` pass. Map checks establish structure only. - Full `pnpm test:run` is running separately. There is no full-suite verdict yet. CI and review are pending. - Hosted acceptance and any recovery of existing tasks remain separate steps. This PR does not change final-response recovery or scheduling. ## Risks - An overly broad URL exception could admit foreign or unpublished output. The validator accepts only exact hrefs from the authenticated publication projection. - Document evidence could hide a missing requested file. A separate regression keeps that delivery gate effective. - The shared projection adds current task/company joins. Existing document feedback and publication tests cover those consumers. ## Model Used OpenAI `gpt-6.1-sol` through Codex, with reasoning, code editing, shell execution, and automated tests. The context-window size is not exposed in this session. ## 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 - [ ] 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>
287 lines
21 KiB
TypeScript
287 lines
21 KiB
TypeScript
import { and, eq } from "drizzle-orm";
|
||
import { assets, companies, documents, documentRevisions, heartbeatRuns, issueDocuments, issueAttachments, issues, 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|json|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|publish)\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);
|
||
// 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|publish)$/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 inlineDestination = /\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))\b/iu;
|
||
const inline = inlineDestination.test(output) || /\b(?: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 inlineOnly = inlineDestination.test(output.split(/\s+(?:and|then)\s+/iu, 1)[0]) && !downloadable && !/^(?:attach|export|publish)$/iu.test(create[0])
|
||
&& !/\bas (?:an? )?attachment\b/iu.test(output);
|
||
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|a|an))?)\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 json = /\.json$/iu.test(match[0]);
|
||
const inputReference = /\b(?:of|about|on|from|using|for|with)\b/iu;
|
||
const destinationMatch = (json
|
||
? /\b(as|into|to|named|called)\s+(?:(?:a|an|the|new|separate|markdown|word|excel)\s+)*["'`*]*(?:[^\s]*\/)?$/iu
|
||
: /\b(?:as|into|to)\s+(?:(?:a|an|the|new|separate|markdown|word|excel)\s+)*$/iu).exec(prefix);
|
||
const beforeDestination = prefix.slice(0, destinationMatch?.index ?? 0);
|
||
const destination = destinationMatch !== null && (!json || !/^(?:to|named|called)$/iu.test(destinationMatch[1])
|
||
|| !inputReference.test(beforeDestination));
|
||
if (json) {
|
||
const directObject = /^\s*(?:(?:a|an|the|my|your|our|their|his|her|its|this|that)\s+(?:(?:new|separate|downloadable|attached)\s+)*([\p{L}\p{N}_-]+\s+)?|(?:(?:new|separate|downloadable|attached)\s+)*)["'`*]*(?:[^\s]*\/)?$/iu.exec(prefix);
|
||
if (!destination && (!directObject || inputReference.test(directObject[1] ?? ""))) return false;
|
||
} else if (!destination && inputReference.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;
|
||
});
|
||
const publication = /\b(?:downloadable|attached|attach|export|send|provide|return|give|publish)\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 [{ namesFile: objects.length > 0, objects: objects.filter(match => !inlineOnly || !/\.json$/iu.test(match[0])).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.namesFile;
|
||
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,
|
||
issuePrefix: companies.issuePrefix, issueIdentifier: issues.identifier })
|
||
.from(issueDocuments)
|
||
.innerJoin(issues, and(eq(issues.id, issueDocuments.issueId), eq(issues.companyId, binding.companyId)))
|
||
.innerJoin(companies, eq(companies.id, issues.companyId))
|
||
.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, issuePrefix, issueIdentifier }) => ({ id, revisionId, key,
|
||
href: `/${encodeURIComponent(issuePrefix)}/issues/${encodeURIComponent(issueIdentifier ?? binding.issueId)}#document-${encodeURIComponent(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 artifactRefs = new Set(evidenceRefs(result.artifacts));
|
||
const refs = new Set([
|
||
...evidenceRefs(result.evidence),
|
||
...artifactRefs,
|
||
...result.completionClaim.criteria.flatMap(({ evidenceRefs }) => evidenceRefs),
|
||
]);
|
||
const citesTaskDocument = [...refs].some(ref => /^\/[^/?#]+\/issues\/[^/?#]+#document-[^/?#]+$/u.test(ref.trim()));
|
||
const publishedDocuments = taskDocumentRequested || citesTaskDocument ? await publishedTaskDocuments(db, binding) : [];
|
||
const publishedTaskDocument = taskDocumentRequested && publishedDocuments.length > 0;
|
||
const documentHrefs = new Set(publishedDocuments.map(document => document.href));
|
||
let registeredAttachment = false;
|
||
for (const value of refs) {
|
||
if (typeof value !== "string") continue;
|
||
const ref = value.trim();
|
||
if (documentHrefs.has(ref)) continue;
|
||
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.");
|
||
}
|
||
}
|