mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Harden LLM wiki auto-apply failures
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
aacd40a67a
commit
fc42373f70
2 files changed
+144
-27
No files matched your search
@@ -177,6 +177,11 @@ type PaperclipSourceBundle = {
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type PaperclipBundleIssueList = {
|
||||
issues: Issue[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type PaperclipDistillationRunInput = PaperclipSourceBundleInput;
|
||||
|
||||
type PaperclipDistillationOutcomeInput = {
|
||||
@@ -1409,7 +1414,7 @@ function issueInBackfillWindow(issue: Issue, input: Pick<PaperclipSourceBundleIn
|
||||
return true;
|
||||
}
|
||||
|
||||
async function listPaperclipBundleIssues(ctx: PluginContext, input: PaperclipSourceBundleInput): Promise<Issue[]> {
|
||||
async function listPaperclipBundleIssues(ctx: PluginContext, input: PaperclipSourceBundleInput): Promise<PaperclipBundleIssueList> {
|
||||
const filterAndSort = (issues: Issue[]) =>
|
||||
issues
|
||||
.filter((issue) => !isLlmWikiOperationIssue(issue))
|
||||
@@ -1423,7 +1428,7 @@ async function listPaperclipBundleIssues(ctx: PluginContext, input: PaperclipSou
|
||||
includeDocuments: true,
|
||||
includeAssignees: true,
|
||||
});
|
||||
return filterAndSort(subtree.issues);
|
||||
return { issues: filterAndSort(subtree.issues), warnings: [] };
|
||||
}
|
||||
|
||||
const issues = await ctx.issues.list({
|
||||
@@ -1432,7 +1437,10 @@ async function listPaperclipBundleIssues(ctx: PluginContext, input: PaperclipSou
|
||||
includePluginOperations: false,
|
||||
limit: 500,
|
||||
});
|
||||
return filterAndSort(issues);
|
||||
const warnings = issues.length >= 500
|
||||
? ["Source bundle may be truncated: fetched the 500 issue limit for this scope; narrow the scope or use a backfill window if older issues are missing."]
|
||||
: [];
|
||||
return { issues: filterAndSort(issues), warnings };
|
||||
}
|
||||
|
||||
export async function assemblePaperclipSourceBundle(ctx: PluginContext, input: PaperclipSourceBundleInput): Promise<PaperclipSourceBundle> {
|
||||
@@ -1442,10 +1450,11 @@ export async function assemblePaperclipSourceBundle(ctx: PluginContext, input: P
|
||||
const perSourceLimit = limits.maxCharactersPerSource;
|
||||
const includeComments = input.includeComments !== false;
|
||||
const includeDocuments = input.includeDocuments !== false;
|
||||
const issues = await listPaperclipBundleIssues(ctx, input);
|
||||
const issueList = await listPaperclipBundleIssues(ctx, input);
|
||||
const issues = issueList.issues;
|
||||
const scope = paperclipCursorScopeMetadata(input);
|
||||
const sourceRefs: PaperclipSourceRef[] = [];
|
||||
const warnings: string[] = [];
|
||||
const warnings: string[] = [...issueList.warnings];
|
||||
const lines = [
|
||||
`# Paperclip source bundle`,
|
||||
"",
|
||||
@@ -2122,7 +2131,7 @@ export async function distillPaperclipProjectPage(ctx: PluginContext, input: Pap
|
||||
}
|
||||
const wikiId = normalizeWikiId(input.wikiId);
|
||||
const scope = paperclipCursorScopeMetadata(input);
|
||||
const issues = await listPaperclipBundleIssues(ctx, input);
|
||||
const { issues } = await listPaperclipBundleIssues(ctx, input);
|
||||
const project = scope.projectId ? await ctx.projects.get(scope.projectId, input.companyId) : null;
|
||||
const rootIssue = scope.rootIssueId ? await ctx.issues.get(scope.rootIssueId, input.companyId) : null;
|
||||
const slug = projectPageSlug({ project, rootIssue });
|
||||
@@ -2278,27 +2287,51 @@ export async function distillPaperclipProjectPage(ctx: PluginContext, input: Pap
|
||||
}
|
||||
|
||||
const appliedPages: string[] = [];
|
||||
for (const patch of patches) {
|
||||
await writeWikiPage(ctx, {
|
||||
companyId: input.companyId,
|
||||
wikiId,
|
||||
path: patch.pagePath,
|
||||
contents: patch.proposedContents,
|
||||
expectedHash: patch.currentHash,
|
||||
summary: `Paperclip distillation ${patch.operationType} from ${bundle.sourceHash}`,
|
||||
sourceRefs: patch.sourceRefs,
|
||||
});
|
||||
await upsertPageBinding(ctx, {
|
||||
companyId: input.companyId,
|
||||
wikiId,
|
||||
projectId: scope.projectId,
|
||||
rootIssueId: scope.rootIssueId,
|
||||
pagePath: patch.pagePath,
|
||||
sourceHash: bundle.sourceHash,
|
||||
runId: run.runId,
|
||||
metadata: { operationType: patch.operationType },
|
||||
});
|
||||
appliedPages.push(patch.pagePath);
|
||||
try {
|
||||
for (const patch of patches) {
|
||||
const latest = await readCurrentWithHash(ctx, input.companyId, patch.pagePath);
|
||||
assertExpectedHash(patch.currentHash, latest.hash, patch.pagePath);
|
||||
}
|
||||
|
||||
for (const patch of patches) {
|
||||
await writeWikiPage(ctx, {
|
||||
companyId: input.companyId,
|
||||
wikiId,
|
||||
path: patch.pagePath,
|
||||
contents: patch.proposedContents,
|
||||
expectedHash: patch.currentHash,
|
||||
summary: `Paperclip distillation ${patch.operationType} from ${bundle.sourceHash}`,
|
||||
sourceRefs: patch.sourceRefs,
|
||||
});
|
||||
await upsertPageBinding(ctx, {
|
||||
companyId: input.companyId,
|
||||
wikiId,
|
||||
projectId: scope.projectId,
|
||||
rootIssueId: scope.rootIssueId,
|
||||
pagePath: patch.pagePath,
|
||||
sourceHash: bundle.sourceHash,
|
||||
runId: run.runId,
|
||||
metadata: { operationType: patch.operationType },
|
||||
});
|
||||
appliedPages.push(patch.pagePath);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
try {
|
||||
await recordPaperclipDistillationOutcome(ctx, {
|
||||
companyId: input.companyId,
|
||||
wikiId,
|
||||
runId: run.runId,
|
||||
cursorId: run.cursorId,
|
||||
status: "failed",
|
||||
sourceHash: bundle.sourceHash,
|
||||
sourceWindowEnd: bundle.sourceWindowEnd,
|
||||
warning: `Auto-apply failed after ${appliedPages.length} page(s): ${message}`,
|
||||
});
|
||||
} catch {
|
||||
// Preserve the original write failure for the caller.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await recordPaperclipDistillationOutcome(ctx, {
|
||||
companyId: input.companyId,
|
||||
|
||||
@@ -1287,6 +1287,37 @@ Route sidebar state stays attached to the selected wiki page.
|
||||
expect(first.sourceWindowEnd).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("warns when Paperclip source listing reaches the scoped issue limit", async () => {
|
||||
const harness = createTestHarness({ manifest });
|
||||
const project = existingProject();
|
||||
const issues = Array.from({ length: 500 }, (_, index) => paperclipIssue({
|
||||
id: `88888888-8888-4888-8888-${String(index).padStart(12, "0")}`,
|
||||
identifier: `PAP-${5000 + index}`,
|
||||
issueNumber: 5000 + index,
|
||||
title: `Bounded source issue ${index}`,
|
||||
description: "Completed implementation note for project wiki distillation.",
|
||||
status: "done",
|
||||
projectId: project.id,
|
||||
updatedAt: new Date(`2026-05-${String((index % 28) + 1).padStart(2, "0")}T10:00:00Z`),
|
||||
}));
|
||||
harness.seed({ projects: [project], issues });
|
||||
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
const bundle = await harness.performAction<{
|
||||
markdown: string;
|
||||
warnings: string[];
|
||||
}>("assemble-paperclip-source-bundle", {
|
||||
companyId: COMPANY_ID,
|
||||
projectId: project.id,
|
||||
maxCharacters: 400000,
|
||||
});
|
||||
|
||||
expect(bundle.markdown).toContain("- Issue count: 500");
|
||||
expect(bundle.warnings).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining("500 issue limit"),
|
||||
]));
|
||||
});
|
||||
|
||||
it("suppresses secret-like comment and document bodies before storing distillation snapshots", async () => {
|
||||
const harness = createTestHarness({ manifest });
|
||||
const issue = paperclipIssue({
|
||||
@@ -1813,6 +1844,59 @@ Route sidebar state stays attached to the selected wiki page.
|
||||
]));
|
||||
});
|
||||
|
||||
it("records failed distillation outcomes when auto-apply writes partially fail", async () => {
|
||||
const harness = createTestHarness({ manifest, config: { autoApplyIngestPatches: true } });
|
||||
const project = existingProject();
|
||||
const issue = paperclipIssue({
|
||||
id: "77777777-7777-4777-8777-77777777779b",
|
||||
identifier: "PAP-4105",
|
||||
title: "Publish project page with shared index",
|
||||
description: "Implementation completed enough to publish the generated project page.",
|
||||
status: "done",
|
||||
projectId: project.id,
|
||||
updatedAt: new Date("2026-05-04T10:00:00Z"),
|
||||
});
|
||||
const files = new Map<string, string>([
|
||||
["wiki/index.md", DEFAULT_INDEX],
|
||||
["wiki/log.md", DEFAULT_LOG],
|
||||
]);
|
||||
const writes: string[] = [];
|
||||
harness.seed({ projects: [project], issues: [issue] });
|
||||
harness.ctx.localFolders.readText = async (_companyId, _folderKey, relativePath) => {
|
||||
const contents = files.get(relativePath);
|
||||
if (contents == null) throw new Error(`missing ${relativePath}`);
|
||||
return contents;
|
||||
};
|
||||
harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => {
|
||||
writes.push(relativePath);
|
||||
if (relativePath === "wiki/index.md") {
|
||||
throw new Error("simulated index write conflict");
|
||||
}
|
||||
files.set(relativePath, contents);
|
||||
return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root");
|
||||
};
|
||||
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
await expect(harness.performAction("distill-paperclip-project-page", {
|
||||
companyId: COMPANY_ID,
|
||||
projectId: project.id,
|
||||
autoApply: true,
|
||||
maxCharacters: 20000,
|
||||
includeSupportingPages: false,
|
||||
})).rejects.toThrow("simulated index write conflict");
|
||||
|
||||
expect(writes).toEqual([
|
||||
"wiki/projects/existing-wiki-project.md",
|
||||
"wiki/index.md",
|
||||
]);
|
||||
const failedOutcome = harness.dbExecutes.find((execute) =>
|
||||
execute.sql.trim().startsWith("UPDATE")
|
||||
&& execute.sql.includes("paperclip_distillation_runs")
|
||||
&& execute.params?.[3] === "failed");
|
||||
expect(failedOutcome?.params?.[4]).toContain("Auto-apply failed after 1 page(s)");
|
||||
expect(failedOutcome?.params?.[4]).toContain("simulated index write conflict");
|
||||
});
|
||||
|
||||
it("refuses auto-apply Paperclip project page patches in authenticated/public deployments", async () => {
|
||||
process.env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
|
||||
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "public";
|
||||
|
||||
Reference in new issue
Block a user