mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its Cloud connector delivers encrypted GitHub events to an instance. > - One failed inner event currently rejects the whole authenticated batch. > - Healthy events then remain unacknowledged and are downloaded again. > - GitHub response and check reconciliation also reads full run records when it needs only ownership and status. > - This pull request isolates inner failures and narrows those run projections. > - Valid events can finish while failed events remain available for recovery. ## Linked Issues or Issue Description **What happened?** An inner App event encrypted with an unavailable key causes `leaseEvents` to reject the complete batch. No sibling can finish or acknowledge. Repeated retries download the same encrypted payloads. Separately, GitHub response and check reads load prompts, runtime profiles, results, and log excerpts that their callers do not use. **Expected behavior** Authenticate the outer batch and lease before handling individual events. Process valid siblings independently. Retain failed events without acknowledging them. Read only the current run fields needed for response ownership and status checks. **Steps to reproduce** 1. Seal one event for an old instance key and another for the current key. 2. Put both events in a correctly authenticated outer lease. 3. Observe that the old client rejects the whole lease. 4. With this change, the valid event processes, the failed ID is reported locally, and only completed events acknowledge. No public issue exists. The change does not add a provider integration or duplicate a roadmap feature. ## What Changed - Keep outer authentication, environment checks, and lease binding strict. - Isolate inner decryption and route-binding failures. Return their authenticated IDs only to the local poller and count them as failures. - Leave failed events unacknowledged in the broker. Continue processing and acknowledging healthy and duplicate siblings. - Read only run identity, status, and required request context for GitHub responses. Read only run ID and status for check reconciliation. - Cover stale keys, swapped routes, invalid outer bindings, all-failed batches, large unused run payloads, and cross-company/task refusals. ## Verification - Connector cryptography suite: 47 tests passed. - Final connector suite: 50 tests passed, including cryptography and three real PostgreSQL connection-event tests. These cover acknowledgment isolation, all-failed batches, and the exact safe warning fields. - GitHub workflow regression suite after the master refresh: 307 tests passed, including the large-payload/company/task test (1,084 unrelated tests skipped by the selection). - Server typecheck passed after the final source changes. - `pnpm -r typecheck` and `pnpm build` passed after the latest conflict-free master refresh. - The broad local `pnpm test:run` was stopped when the branch was refreshed; it has no complete passing result. All CI gates passed on the final commit, including general and serialized server suites, runner verification, build, typecheck, and end-to-end tests. Apex is 5/5 with all review comments resolved. The last change removes source comments and expands docs; both edited TypeScript files emit identical JavaScript before and after that change. - A read-only sample compared full run records with the new field sets: response data decreased by about 96%, and check data by over 99%. This is serialized query output, not a claim of realized billing savings or CPU reduction. - An initial database test setup failed because the local dependency install omitted native-library symlinks. The package's standard setup script repaired them. The unchanged test timeout then passed. - Added lines checked for credentials, customer data, private links, and internal issue references. ## Risks Failed inner events remain pending and retry under the broker's existing lease and retention policy. This change does not recover a lost original private key or introduce a dead-letter deletion path. Outer authentication failures still reject the complete batch. Run projections must retain request provenance and task binding fields. Existing predicates, ordering, identity validation, and publication authority remain unchanged. The change has no schema migration and adds no background timer or new provider call. ## Model Used OpenAI GPT-6 through Codex, with reasoning and shell/GitHub CLI tools. ## 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 - [x] I have described the issue in-PR following the bug report template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket ID - [x] Focused local regressions and all CI gates pass - [x] I have added and updated tests - [x] I have updated relevant documentation - [x] I have documented risks - [x] 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>
470 lines
17 KiB
TypeScript
470 lines
17 KiB
TypeScript
import {
|
|
GitHubPublicationLeaseLost,
|
|
withGitHubPublicationLease,
|
|
} from "./chat-github-publication-lease.js";
|
|
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
|
import {
|
|
chatActions,
|
|
chatDeliveries,
|
|
chatEndpoints,
|
|
chatEndpointResources,
|
|
chatGitHubConfigurations,
|
|
chatGitHubReviews,
|
|
chatMessageLinks,
|
|
companies,
|
|
heartbeatRuns,
|
|
type Db,
|
|
} from "@paperclipai/db";
|
|
import type { GitHubReviewEventContext } from "@paperclipai/shared";
|
|
import { runtimePublicOrigin } from "./cloud-runtime-identity.js";
|
|
import {
|
|
githubBotRepositoryToken,
|
|
githubBotRequest,
|
|
} from "./chat-github-client.js";
|
|
|
|
/** Check status follows the ordinary chat delivery/task/run. This outbox never
|
|
* schedules an agent. Assessments are published by the governed review tool. */
|
|
export function githubReviewCheckService(db: Db, fetchImpl = fetch) {
|
|
async function enqueue(
|
|
endpoint: typeof chatEndpoints.$inferSelect,
|
|
event: GitHubReviewEventContext,
|
|
admitted: boolean,
|
|
reason: string,
|
|
) {
|
|
const [config] = await db
|
|
.select()
|
|
.from(chatGitHubConfigurations)
|
|
.where(eq(chatGitHubConfigurations.endpointId, endpoint.id));
|
|
if (!config?.configuration.toolsEnabled) return;
|
|
const policy = {
|
|
...config.configuration.defaults,
|
|
...config.configuration.repositories[event.repositoryId],
|
|
};
|
|
if (policy.ratingThreshold === null && !admitted) return;
|
|
await db
|
|
.insert(chatActions)
|
|
.values({
|
|
companyId: endpoint.companyId,
|
|
endpointId: endpoint.id,
|
|
kind: "github_review_check",
|
|
providerActionId: `github-check:${event.deliveryId}`,
|
|
payload: {
|
|
version: 1,
|
|
event,
|
|
admitted,
|
|
reason,
|
|
runtimeGeneration:
|
|
(endpoint.setup as { runtimeGeneration?: number })
|
|
.runtimeGeneration ?? 0,
|
|
},
|
|
status: "received",
|
|
})
|
|
.onConflictDoNothing();
|
|
}
|
|
async function dispatch(id: string) {
|
|
const [action] = await db
|
|
.select()
|
|
.from(chatActions)
|
|
.where(
|
|
and(
|
|
eq(chatActions.id, id),
|
|
eq(chatActions.kind, "github_review_check"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!action || ["processed", "cancelled"].includes(action.status)) return;
|
|
const event = action.payload.event as GitHubReviewEventContext;
|
|
await withGitHubPublicationLease(
|
|
db,
|
|
{
|
|
companyId: action.companyId,
|
|
endpointId: action.endpointId,
|
|
repositoryId: event.repositoryId,
|
|
number: event.pullNumber,
|
|
},
|
|
fetchImpl,
|
|
async (lease) => {
|
|
const [fresh] = await db
|
|
.select()
|
|
.from(chatActions)
|
|
.where(eq(chatActions.id, id));
|
|
if (!fresh || ["processed", "cancelled"].includes(fresh.status)) return;
|
|
let attempts = Number(fresh.result?.attempts ?? 0);
|
|
try {
|
|
const [source] = await db
|
|
.select({
|
|
endpoint: chatEndpoints,
|
|
resource: chatEndpointResources,
|
|
configuration: chatGitHubConfigurations.configuration,
|
|
companyPrefix: companies.issuePrefix,
|
|
})
|
|
.from(chatEndpoints)
|
|
.innerJoin(companies, eq(companies.id, chatEndpoints.companyId))
|
|
.innerJoin(
|
|
chatGitHubConfigurations,
|
|
eq(chatGitHubConfigurations.endpointId, chatEndpoints.id),
|
|
)
|
|
.innerJoin(
|
|
chatEndpointResources,
|
|
eq(chatEndpointResources.endpointId, chatEndpoints.id),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(chatEndpoints.id, action.endpointId),
|
|
eq(chatEndpoints.companyId, action.companyId),
|
|
eq(chatEndpointResources.providerResourceId, event.repository),
|
|
),
|
|
);
|
|
if (
|
|
!source ||
|
|
!source.configuration.toolsEnabled ||
|
|
!["active", "verifying"].includes(source.endpoint.status) ||
|
|
!source.resource.enabled ||
|
|
source.resource.availability !== "available" ||
|
|
String(source.resource.metadata?.providerRepositoryId) !==
|
|
event.repositoryId ||
|
|
((source.endpoint.setup as { runtimeGeneration?: number })
|
|
.runtimeGeneration ?? 0) !== action.payload.runtimeGeneration
|
|
) {
|
|
await lease.commit(async (tx) => {
|
|
await tx
|
|
.update(chatActions)
|
|
.set({
|
|
status: "cancelled",
|
|
result: { code: "connection_changed" },
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(chatActions.id, id));
|
|
});
|
|
return;
|
|
}
|
|
const [review] = await db
|
|
.select()
|
|
.from(chatGitHubReviews)
|
|
.where(
|
|
and(
|
|
eq(chatGitHubReviews.endpointId, action.endpointId),
|
|
eq(chatGitHubReviews.repositoryId, event.repositoryId),
|
|
eq(chatGitHubReviews.pullNumber, event.pullNumber),
|
|
eq(chatGitHubReviews.headSha, event.headSha),
|
|
),
|
|
)
|
|
.orderBy(desc(chatGitHubReviews.createdAt))
|
|
.limit(1);
|
|
if (review?.assessment) {
|
|
// The assessment outbox owns the terminal score and its retries.
|
|
await lease.commit(async (tx) => {
|
|
await tx
|
|
.update(chatActions)
|
|
.set({
|
|
status: "processed",
|
|
result: {
|
|
code: "assessment_owns_check",
|
|
reviewId: review.id,
|
|
},
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(chatActions.id, id));
|
|
});
|
|
return;
|
|
}
|
|
let run = review?.runId
|
|
? (
|
|
await db
|
|
.select({ id: heartbeatRuns.id, status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, review.runId))
|
|
)[0]
|
|
: undefined;
|
|
if (review && !run) {
|
|
const [sourceRun] = await db
|
|
.select({ run: { id: heartbeatRuns.id, status: heartbeatRuns.status } })
|
|
.from(chatDeliveries)
|
|
.innerJoin(
|
|
chatMessageLinks,
|
|
eq(chatMessageLinks.deliveryId, chatDeliveries.id),
|
|
)
|
|
.innerJoin(
|
|
heartbeatRuns,
|
|
and(
|
|
eq(heartbeatRuns.companyId, action.companyId),
|
|
eq(heartbeatRuns.agentId, source.endpoint.assignedAgentId),
|
|
sql`${heartbeatRuns.contextSnapshot}->>'wakeCommentId' = ${chatMessageLinks.commentId}::text`,
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(chatDeliveries.endpointId, action.endpointId),
|
|
sql`${chatDeliveries.normalizedEvent}->'githubAutomatic'->'context'->>'deliveryId' = ${event.deliveryId}`,
|
|
),
|
|
)
|
|
.orderBy(desc(heartbeatRuns.createdAt))
|
|
.limit(1);
|
|
run = sourceRun?.run;
|
|
}
|
|
const [delivery] = await db
|
|
.select({ state: chatDeliveries.state })
|
|
.from(chatDeliveries)
|
|
.where(
|
|
and(
|
|
eq(chatDeliveries.endpointId, action.endpointId),
|
|
sql`${chatDeliveries.normalizedEvent}->'githubAutomatic'->'context'->>'deliveryId' = ${event.deliveryId}`,
|
|
),
|
|
)
|
|
.limit(1);
|
|
const admissionEnded =
|
|
!review &&
|
|
(delivery?.state === "filtered" || delivery?.state === "failed");
|
|
const terminal =
|
|
!!run &&
|
|
[
|
|
"succeeded",
|
|
"failed",
|
|
"cancelled",
|
|
"timed_out",
|
|
"interrupted",
|
|
].includes(run.status);
|
|
const state =
|
|
(!action.payload.admitted && !review) || admissionEnded
|
|
? "manual_required"
|
|
: terminal
|
|
? run!.status === "succeeded"
|
|
? "incomplete"
|
|
: "error"
|
|
: run?.status === "running"
|
|
? "running"
|
|
: "queued";
|
|
const status =
|
|
state === "running"
|
|
? "in_progress"
|
|
: state === "queued"
|
|
? "queued"
|
|
: "completed";
|
|
const policy = review?.policySnapshot ?? {
|
|
...source.configuration.defaults,
|
|
...source.configuration.repositories[event.repositoryId],
|
|
};
|
|
const conclusion = policy.ratingThreshold === null ? "neutral" : "action_required";
|
|
const title =
|
|
state === "manual_required"
|
|
? "Authorized manual review required"
|
|
: state === "incomplete"
|
|
? "Agent finished without a complete assessment"
|
|
: state === "error"
|
|
? "Review execution did not complete"
|
|
: state === "running"
|
|
? "Agent is reviewing this commit"
|
|
: "Waiting for the assigned agent";
|
|
const token = await githubBotRepositoryToken(
|
|
db,
|
|
action.companyId,
|
|
action.endpointId,
|
|
event.repositoryId,
|
|
lease.fetch,
|
|
);
|
|
const prefix = `/repos/${event.repository.split("/").map(encodeURIComponent).join("/")}`;
|
|
const request = <T>(
|
|
path: string,
|
|
options?: Parameters<typeof githubBotRequest>[3],
|
|
) =>
|
|
githubBotRequest<T>(
|
|
lease.fetch,
|
|
token,
|
|
`${prefix}${path}`,
|
|
options,
|
|
);
|
|
const current = await request<{ head: { sha: string } }>(
|
|
`/pulls/${event.pullNumber}`,
|
|
);
|
|
if (current.head.sha !== event.headSha) {
|
|
if (review)
|
|
await lease.commit(async (tx) => {
|
|
await tx
|
|
.update(chatGitHubReviews)
|
|
.set({ state: "superseded", updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(chatGitHubReviews.id, review.id),
|
|
isNull(chatGitHubReviews.assessment),
|
|
),
|
|
);
|
|
});
|
|
await lease.commit(async (tx) => {
|
|
await tx
|
|
.update(chatActions)
|
|
.set({
|
|
status: "cancelled",
|
|
result: { code: "stale_head" },
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(chatActions.id, id));
|
|
});
|
|
return;
|
|
}
|
|
if (fresh.result?.state !== state) {
|
|
const origin = runtimePublicOrigin();
|
|
const detailsPath = review
|
|
? `issues/${review.issueId}`
|
|
: `apps/chat/${source.endpoint.id}/reviews`;
|
|
const detailsUrl = origin
|
|
? `${origin}/${encodeURIComponent(source.companyPrefix)}/${detailsPath}`
|
|
: null;
|
|
const externalId = `${action.endpointId}:${event.pullNumber}:${event.headSha}`;
|
|
const checks = await request<{
|
|
check_runs: Array<{
|
|
id: number;
|
|
status?: string;
|
|
external_id?: string;
|
|
app?: { id?: number };
|
|
}>;
|
|
}>(
|
|
`/commits/${event.headSha}/check-runs?check_name=Paperclip%20Review&per_page=100`,
|
|
);
|
|
const check = checks.check_runs.find(
|
|
(check) =>
|
|
check.external_id === externalId &&
|
|
String(check.app?.id) === source.endpoint.botExternalId,
|
|
);
|
|
// GitHub retains a completed check's conclusion when PATCHed back to
|
|
// queued/in_progress. A fresh attempt needs a new check run with the
|
|
// same stable name; otherwise a prior success still looks passing.
|
|
const updateCheck =
|
|
check &&
|
|
!(check.status === "completed" && status !== "completed");
|
|
const posted = await request<{ id: number; html_url: string }>(
|
|
updateCheck ? `/check-runs/${check.id}` : "/check-runs",
|
|
{
|
|
method: updateCheck ? "PATCH" : "POST",
|
|
body: {
|
|
name: "Paperclip Review",
|
|
head_sha: event.headSha,
|
|
external_id: externalId,
|
|
...(detailsUrl ? { details_url: detailsUrl } : {}),
|
|
status,
|
|
...(status === "completed"
|
|
? { conclusion }
|
|
: {}),
|
|
output: {
|
|
title,
|
|
summary: `${title}. ${state === "manual_required" ? "An authorized person can mention the bot to request a review of this head. Automatic review was not authorized by the bot configuration." : "This check follows the assigned Paperclip agent's task execution. Only a validated assessment can produce a passing score."}\n\nCommit: ${event.headSha}`,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
if (review)
|
|
await lease.commit(async (tx) => {
|
|
await tx
|
|
.update(chatGitHubReviews)
|
|
.set({
|
|
state,
|
|
runId: run?.id ?? review.runId,
|
|
checkId: String(posted.id),
|
|
checkUrl: posted.html_url,
|
|
...(terminal
|
|
? { conclusion }
|
|
: {}),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(chatGitHubReviews.id, review.id),
|
|
isNull(chatGitHubReviews.assessment),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
await lease.commit(async (tx) => {
|
|
await tx
|
|
.update(chatActions)
|
|
.set({
|
|
status: status === "completed" ? "processed" : "received",
|
|
result: {
|
|
state,
|
|
attempts: 0,
|
|
retryable: true,
|
|
retryAt: new Date(Date.now() + 5000).toISOString(),
|
|
},
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(chatActions.id, id));
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof GitHubPublicationLeaseLost) return;
|
|
attempts++;
|
|
await lease.commit(async (tx) => {
|
|
await tx
|
|
.update(chatActions)
|
|
.set({
|
|
status: "failed",
|
|
result: {
|
|
attempts,
|
|
retryable: attempts < 8,
|
|
retryAt: new Date(
|
|
Date.now() + Math.min(300000, 1000 * 2 ** attempts),
|
|
).toISOString(),
|
|
code: "check_publication_failed",
|
|
},
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(chatActions.id, id));
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|
|
async function processPending(limit = 10) {
|
|
// Check delivery and task admission run independently. A rapid push can
|
|
// cancel an obsolete check before admission inserts its review projection.
|
|
// Reconcile that late row from the durable cancellation receipt, without
|
|
// starting work or borrowing another delivery's authority.
|
|
await db
|
|
.update(chatGitHubReviews)
|
|
.set({
|
|
state: "superseded",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
inArray(chatGitHubReviews.state, ["queued", "running"]),
|
|
isNull(chatGitHubReviews.assessment),
|
|
sql`exists (
|
|
select 1 from ${chatActions} action
|
|
join ${chatDeliveries} delivery
|
|
on delivery.id::text = ${chatGitHubReviews.deliveryId}
|
|
where action.company_id = ${chatGitHubReviews.companyId}
|
|
and action.endpoint_id = ${chatGitHubReviews.endpointId}
|
|
and delivery.endpoint_id = action.endpoint_id
|
|
and action.kind = 'github_review_check'
|
|
and action.status = 'cancelled'
|
|
and action.result->>'code' = 'stale_head'
|
|
and action.payload->'event'->>'deliveryId' =
|
|
delivery.normalized_event->'githubAutomatic'->'context'->>'deliveryId'
|
|
)`,
|
|
),
|
|
);
|
|
const rows = await db
|
|
.select({ id: chatActions.id })
|
|
.from(chatActions)
|
|
.where(
|
|
and(
|
|
eq(chatActions.kind, "github_review_check"),
|
|
or(
|
|
and(
|
|
eq(chatActions.status, "received"),
|
|
sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= now())`,
|
|
),
|
|
and(
|
|
eq(chatActions.status, "failed"),
|
|
sql`${chatActions.result}->>'retryable' = 'true'`,
|
|
sql`(${chatActions.result}->>'retryAt')::timestamptz <= now()`,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.orderBy(chatActions.updatedAt)
|
|
.limit(limit);
|
|
for (const row of rows) await dispatch(row.id);
|
|
}
|
|
return { enqueue, processPending };
|
|
}
|