mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
fix(github): preserve approved publication recovery
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
de6862072c
commit
a6d81b67cb
4 files changed
+87
-6
No files matched your search
@@ -240,6 +240,11 @@ still pending. Claim publication only after `status: processed`. Receipt recover
|
|||||||
does not itself mark a task Done; the agent must finish its task after confirming
|
does not itself mark a task Done; the agent must finish its task after confirming
|
||||||
the receipt.
|
the receipt.
|
||||||
|
|
||||||
|
For a call that required human approval, the durable publication keeps that
|
||||||
|
verified approval for its exact operation. An exact caller retry only reads the
|
||||||
|
current receipt; the worker owns provider retries. A new policy denial or revoked
|
||||||
|
authority still stops recovery, and new content still requires its own approval.
|
||||||
|
|
||||||
## Hosted ingress
|
## Hosted ingress
|
||||||
|
|
||||||
Dedicated Apps use Cloud as a sealed transport gateway. Cloud routes opaque
|
Dedicated Apps use Cloud as a sealed transport gateway. Cloud routes opaque
|
||||||
|
|||||||
@@ -2594,6 +2594,46 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
|||||||
)))[0];
|
)))[0];
|
||||||
return { ...f, service, head, assessment, provider, action, inline, checks, writes };
|
return { ...f, service, head, assessment, provider, action, inline, checks, writes };
|
||||||
}
|
}
|
||||||
|
it.each([false, true])("recovers an approved queued GitHub review without another approval (revoked: %s)", async revoked => {
|
||||||
|
const f = await publicationRetryFixture();
|
||||||
|
let unavailable = true;
|
||||||
|
let attempts = 0;
|
||||||
|
f.setSupplementalProviderFetch(async (input, init) => {
|
||||||
|
if (String(input).includes("/issues/comments/") && init?.method === "PATCH") {
|
||||||
|
attempts++;
|
||||||
|
if (unavailable) return new Response("Unavailable", { status: 503, headers: { "retry-after": "120" } });
|
||||||
|
}
|
||||||
|
return f.provider(input, init);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", f.providerFetch);
|
||||||
|
onTestFinished(() => { vi.unstubAllGlobals(); });
|
||||||
|
await initializeRunIdentity(db, { companyId: f.companyId, runId: f.run.id, issueId: f.conversation.issueId!,
|
||||||
|
responsibleUserId: null, cause: "company_default" });
|
||||||
|
const { createToolGatewayService } = await import("../services/tool-gateway.js");
|
||||||
|
const gateway = createToolGatewayService(db, { toolActionSigningSecret: "github-approval-retry-test-secret" });
|
||||||
|
const session = await gateway.createSession({ companyId: f.companyId, agentId: f.assignedAgentId, runId: f.run.id });
|
||||||
|
const tool = (await githubBotToolsForSession(db, f.session)).find(tool => tool.upstreamToolName === "submit_review")!;
|
||||||
|
await db.insert(toolPolicies).values({ companyId: f.companyId, name: "Review publication approval", policyType: "require_approval",
|
||||||
|
priority: 0, selectors: { catalogEntryId: tool.catalogEntryId! } });
|
||||||
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: f.assessment }))
|
||||||
|
.rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||||
|
const [approval] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.companyId));
|
||||||
|
expect(await gateway.approveActionRequest({ companyId: f.companyId, actionRequestId: approval.id, actor: { userId: "owner-user" } }))
|
||||||
|
.toMatchObject({ status: "executed" });
|
||||||
|
const action = await f.action();
|
||||||
|
expect(action).toMatchObject({ status: "failed", result: { retryable: true } });
|
||||||
|
unavailable = false;
|
||||||
|
if (revoked) await db.insert(toolPolicies).values({ companyId: f.companyId, name: "Revoke approved publication", policyType: "block",
|
||||||
|
priority: -1, selectors: { catalogEntryId: tool.catalogEntryId! } });
|
||||||
|
await db.update(chatActions).set({ result: { ...action.result, retryAt: new Date(0).toISOString() } }).where(eq(chatActions.id, action.id));
|
||||||
|
await f.service.processPending();
|
||||||
|
expect((await f.action()).status).toBe(revoked ? "cancelled" : "processed");
|
||||||
|
if (revoked) await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: f.assessment })).rejects.toThrow();
|
||||||
|
else expect(await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: f.assessment }))
|
||||||
|
.toMatchObject({ status: "replayed", result: { data: { status: "processed", score: 3 } } });
|
||||||
|
expect(attempts).toBe(revoked ? 1 : 2);
|
||||||
|
expect(await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.companyId))).toHaveLength(1);
|
||||||
|
}, 25_000);
|
||||||
it("recovers lost inline and check responses without duplicate GitHub publications", async () => {
|
it("recovers lost inline and check responses without duplicate GitHub publications", async () => {
|
||||||
const f = await publicationRetryFixture();
|
const f = await publicationRetryFixture();
|
||||||
const lost = new Set<string>();
|
const lost = new Set<string>();
|
||||||
|
|||||||
@@ -545,6 +545,28 @@ export function githubChatReviewService(db: Db, fetchImpl = fetch) {
|
|||||||
}
|
}
|
||||||
return receipt(actionId);
|
return receipt(actionId);
|
||||||
}
|
}
|
||||||
|
async function readPublicationReceipt(session: Session, name: string, input: unknown, invocationId: string) {
|
||||||
|
const source = await scope(session);
|
||||||
|
const [action] = await db.select().from(chatActions).where(and(
|
||||||
|
eq(chatActions.companyId, session.companyId), eq(chatActions.endpointId, source.endpoint.id),
|
||||||
|
eq(chatActions.conversationId, source.conversation.id), eq(chatActions.kind, "github_review_publication"),
|
||||||
|
sql`${chatActions.payload}->>'invocationId' = ${invocationId}`, sql`${chatActions.payload}->>'toolName' = ${name}`,
|
||||||
|
));
|
||||||
|
if (!action) return null;
|
||||||
|
const binding = action.payload.session as Session;
|
||||||
|
if (binding.companyId !== session.companyId || binding.agentId !== session.agentId || binding.runId !== session.runId ||
|
||||||
|
binding.issueId !== session.issueId || hash(action.payload.parameters) !== hash(input))
|
||||||
|
throw conflict("This GitHub invocation is bound to a different publication");
|
||||||
|
await assertPublicationAuthority(source, action);
|
||||||
|
const result = await receipt(action.id);
|
||||||
|
if (name !== "submit_review") return result;
|
||||||
|
const [review] = await db.select().from(chatGitHubReviews).where(and(
|
||||||
|
eq(chatGitHubReviews.companyId, session.companyId), eq(chatGitHubReviews.id, String(action.payload.reviewId)),
|
||||||
|
eq(chatGitHubReviews.runId, session.runId!), eq(chatGitHubReviews.endpointId, source.endpoint.id),
|
||||||
|
));
|
||||||
|
if (!review?.assessment) throw conflict("GitHub publication assessment is unavailable");
|
||||||
|
return { reviewId: review.id, ...result, score: review.assessment.score, conclusion: review.conclusion };
|
||||||
|
}
|
||||||
async function execute(
|
async function execute(
|
||||||
session: Session,
|
session: Session,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -977,7 +999,7 @@ export function githubChatReviewService(db: Db, fetchImpl = fetch) {
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
invocation?.approvalState === "approved" &&
|
invocation?.approvalState === "approved" &&
|
||||||
["executing", "completed"].includes(invocation.status)
|
["executing", "completed", "succeeded", "failed", "timed_out"].includes(invocation.status)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1527,5 +1549,5 @@ export function githubChatReviewService(db: Db, fetchImpl = fetch) {
|
|||||||
));
|
));
|
||||||
return row?.at ? new Date(row.at).getTime() : null;
|
return row?.at ? new Date(row.at).getTime() : null;
|
||||||
}
|
}
|
||||||
return { execute, processPending, nextPendingAt, scope };
|
return { execute, readPublicationReceipt, processPending, nextPendingAt, scope };
|
||||||
}
|
}
|
||||||
@@ -10150,12 +10150,21 @@ export function createToolGatewayService(
|
|||||||
argumentsHash: argumentValidation.summary.sha256 ?? "",
|
argumentsHash: argumentValidation.summary.sha256 ?? "",
|
||||||
});
|
});
|
||||||
if (replay?.matched) {
|
if (replay?.matched) {
|
||||||
|
let replayResult: unknown = replay.result;
|
||||||
|
if (tool.providerType === "paperclip_github_chat" && ["comment", "update_comment", "submit_review"].includes(tool.upstreamToolName ?? "")) {
|
||||||
|
const access = await policyService.decide(policyInputForTool({ session, tool, parameters: effectiveParameters, consumeRateLimit: false }));
|
||||||
|
if (!access.allowed && access.decision !== "require_approval")
|
||||||
|
throw new ToolGatewayHttpError(policyErrorStatus(access), access.explanation, access.reasonCode);
|
||||||
|
const receipt = await githubChatReviewService(db).readPublicationReceipt(session, tool.upstreamToolName ?? "", effectiveParameters, replay.invocationId);
|
||||||
|
if (receipt) replayResult = validateToolContent({ value: { content: JSON.stringify(receipt), data: receipt },
|
||||||
|
direction: "result", sensitiveMode: "redact", promptInjectionMode: "block" }).value;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
invocationId: replay.invocationId,
|
invocationId: replay.invocationId,
|
||||||
status: "replayed" as const,
|
status: "replayed" as const,
|
||||||
tool: virtualToolName ?? tool.name,
|
tool: virtualToolName ?? tool.name,
|
||||||
targetTool: virtualToolName ? tool.name : undefined,
|
targetTool: virtualToolName ? tool.name : undefined,
|
||||||
result: replay.result,
|
result: replayResult,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10546,14 +10555,19 @@ export function createToolGatewayService(
|
|||||||
tool.providerType === "paperclip_github_chat" &&
|
tool.providerType === "paperclip_github_chat" &&
|
||||||
["comment", "update_comment", "submit_review"].includes(tool.upstreamToolName ?? "") &&
|
["comment", "update_comment", "submit_review"].includes(tool.upstreamToolName ?? "") &&
|
||||||
recorded.invocation.approvalState === "not_required";
|
recorded.invocation.approvalState === "not_required";
|
||||||
const retryingGitHubPublication = gitHubPublicationReplay && accessDecision.allowed &&
|
const gitHubReplayPolicyAllowed = accessDecision.allowed;
|
||||||
|
const gitHubReplayBindingMatches =
|
||||||
recorded.invocation.agentId === session.agentId && recorded.invocation.runId === session.runId &&
|
recorded.invocation.agentId === session.agentId && recorded.invocation.runId === session.runId &&
|
||||||
recorded.invocation.issueId === session.issueId && recorded.invocation.connectionId === tool.connectionId &&
|
recorded.invocation.issueId === session.issueId && recorded.invocation.connectionId === tool.connectionId &&
|
||||||
recorded.invocation.toolName === tool.name && recorded.invocation.argumentsHash === effectiveArgumentsSummary.sha256 &&
|
recorded.invocation.toolName === tool.name && recorded.invocation.argumentsHash === effectiveArgumentsSummary.sha256 &&
|
||||||
["authorized", "executing", "succeeded", "failed", "timed_out"].includes(recorded.invocation.status);
|
["authorized", "executing", "succeeded", "failed", "timed_out"].includes(recorded.invocation.status);
|
||||||
if (gitHubPublicationReplay && accessDecision.allowed && !retryingGitHubPublication)
|
if (gitHubPublicationReplay && gitHubReplayPolicyAllowed && !gitHubReplayBindingMatches)
|
||||||
throw new ToolGatewayHttpError(409, "Idempotency key belongs to a different GitHub publication", "github_idempotency_conflict");
|
throw new ToolGatewayHttpError(409, "Idempotency key belongs to a different GitHub publication", "github_idempotency_conflict");
|
||||||
if (recorded.replayed && !retryingSlackRateLimit && !gitHubPublicationReplay) {
|
const retryingGitHubPublication = gitHubPublicationReplay && accessDecision.allowed &&
|
||||||
|
recorded.invocation.approvalState === "not_required" && gitHubReplayBindingMatches;
|
||||||
|
if (recorded.replayed && !retryingSlackRateLimit && !retryingGitHubPublication) {
|
||||||
|
if (gitHubPublicationReplay && !gitHubReplayPolicyAllowed)
|
||||||
|
throw new ToolGatewayHttpError(policyErrorStatus(accessDecision), accessDecision.explanation, accessDecision.reasonCode);
|
||||||
await writeAudit({
|
await writeAudit({
|
||||||
session,
|
session,
|
||||||
companyId: session.companyId,
|
companyId: session.companyId,
|
||||||
|
|||||||
Reference in new issue
Block a user