mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
perf(github): reuse the inherited review wait query template (#18965)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - External review waits check current GitHub review and publication bindings. > - The GitHub service rebuilds the same seven-table Drizzle query on each check. > - A sampled production window attributed 6.3% of SQL serialization CPU to this query shape. > - This pull request prepares that query once per exact database or transaction object. > - Each call still reads current provenance, review, connection and publication state. > - The local service benchmark reduces median client CPU per read by 24.35%. ## Linked Issues or Issue Description **What existing behavior does this improve?** Repeated `readInheritedGitHubReviewWait` calls from external review wait reads and attention retention. **Subsystem affected** Server GitHub chat review waits. **Current behavior** Every call constructs and serializes the same joined SELECT. The database driver already supports prepared statements. That setting does not reuse the JavaScript query template. **Proposed behavior** Compile the existing selector once for each supplied database, transaction or savepoint object. Bind company, issue, agent and comment IDs on each execution. Keep every current database read and check. **Reason and benefit** Remove repeated query construction and serialization CPU. Do not cache rows or authorization decisions. Exact open-PR searches found no duplicate query-template change. ## What Changed - Add an exact-handle `WeakMap` for the GitHub review selector and execute it with fresh placeholders. - Preserve the SQL projection, joins, JSON types, latest-review order and all current authority checks. - Extend real PostgreSQL tests for new bindings, warm-cache revocation, latest heads and transaction/savepoint rollback. - Capture prepared executions in the existing test helper. - Add an actual-service benchmark and a report with every timing sample and setup failure. ## Verification - `github-review-wait.test.ts`: 16 tests passed on real PostgreSQL. - `external-chat-wait.integration.test.ts -t 'reads small current external review waits without saved prompts'`: four affected cases passed; 269 unrelated cases skipped. - Pinned baseline: `e54f9fbf828e06e385e0c62dc381ccb46190aa30`. Final source SHA256: `fe647e7fe2aed82609e8f1d66a0109e7a10ee28313ae2ef218ba3ab39b5cd105`. - Exclusive-window benchmark: 500 warmups per version, nine alternating pairs of 100 complete service reads on disposable PostgreSQL. Median client CPU: 1.00336 → 0.75908 ms/read (24.35% less). Median wall time: 2.87089 → 2.38731 ms/read (16.84% less). CPU excludes the PostgreSQL process. - Every generated SQL statement, parameter, query count and complete result matched. Warm joined-selector compilations per ten service reads: 10 → 0. - See `doc/performance/2026-11-04-prepared-github-review-wait.md` for reproduction, all samples and measurement limits. - Setup record: loopback required execution outside the sandbox. The fresh embedded-Postgres dependency lacked macOS dylib aliases; aliases to existing identical bytes restored the fixture. The SDK import needed a current workspace SDK/shared build. A broad external-chat suite run was interrupted and replaced by the affected test filter. Those attempts are not counted as passing suites. - Greptile: 5/5 on current head `d2e6ae8814c3a56c380bd5d60c47e6f98642f085`, with no actionable issues or inline comments. - Current-head CI is green at `d2e6ae8814c3a56c380bd5d60c47e6f98642f085`: 52 successful checks, three intentional skips and the successful Snyk status. This includes typecheck, build, the full test matrix, end-to-end shards and the aggregate gate. - Combined local validation at `4ab58ff70b` includes both measured production templates and final test fixes. Full recursive `pnpm -r typecheck` passed in 39.864 seconds. Three real PostgreSQL files passed 35/35 tests (five chat cases, 16 GitHub review cases and 14 shared prepared-query cases). The combined build passed earlier with the same production template bytes. - The initial combined typecheck failed on a separate chat test typing error and build setup. The later passing run resolves that attempt. The original full local `pnpm test:run` was deliberately interrupted after six chat shards passed and partial general tests ran; it is not a passing full local suite. Each PR has independent current-head CI for broad coverage. The initial loopback sandbox restriction was resolved by an escalated run of the same source. - Local benchmark/test dependencies were locked at the baseline; the rebase adds the upstream lockfile refresh and UI paste fix only. ## Risks - A prepared query captures its session. The exact supplied handle is the cache key so transactions and savepoints do not borrow the root session. - Placeholder mistakes could cross bindings. Tests alternate independent company/issue/agent/comment fixtures on the same handle. - The benchmark has one synthetic eligible fixture with warm sequential reads. It does not predict production endpoint latency or whole-server CPU savings. - No schema, index, database driver setting or public API changes. ## Model Used OpenAI `gpt-6.1-sol` through Codex. The context-window size is not exposed by the session. Used reasoning, tool calls, source editing and code execution. Independent Codex review found no correctness issues. ## 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 - [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>
This commit is contained in:
1 parent
76e43852ac
commit
2cbfc35a07
5 files changed
+311
-27
No files matched your search
@@ -0,0 +1,109 @@
|
||||
# Prepared GitHub review-wait read
|
||||
|
||||
Date: 2026-11-04.
|
||||
|
||||
`readInheritedGitHubReviewWait` builds a fixed seven-table selector on every
|
||||
read. A sampled production window attributed 6.3% of SQL serialization CPU to
|
||||
the matching shape (29 samples, mean 204 microseconds). This is a share of
|
||||
serialization CPU, not total server CPU.
|
||||
|
||||
## Change and preserved contract
|
||||
|
||||
The GitHub owner prepares the existing Drizzle selector once per exact supplied
|
||||
`Db` object. A `WeakMap` retains only the compiled query and decoders. Company,
|
||||
issue, agent and source-comment IDs are placeholders filled on every execution.
|
||||
Database transactions and nested savepoints have different object identities
|
||||
and therefore own separate prepared queries and sessions. No database rows,
|
||||
results, promises or authorization decisions are cached.
|
||||
|
||||
Provenance still runs before the selector, and publication still runs after it.
|
||||
The SQL, field order, JSON `->'reviewedCommit'` projection, latest-review ordering,
|
||||
company boundaries, connection/repository/agent checks and publication lookup
|
||||
are unchanged. This does not change postgres.js driver preparation settings.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Install the locked dependencies, then run with the repository's supported Node:
|
||||
|
||||
```sh
|
||||
node --import ./server/node_modules/tsx/dist/loader.mjs \
|
||||
server/scripts/benchmark-prepared-github-review-wait.ts \
|
||||
--baseline-ref=e54f9fbf828e06e385e0c62dc381ccb46190aa30
|
||||
```
|
||||
|
||||
The script imports the complete baseline service from that explicit commit,
|
||||
verifies the unchanged provenance dependency, seeds disposable real PostgreSQL,
|
||||
and compares the complete result and every generated query, parameter and query
|
||||
count. It records main-selector compilation counts separately from timed runs.
|
||||
The timed path calls the actual owning service, including provenance and
|
||||
publication reads, with no replay driver or substituted authorization result.
|
||||
|
||||
## Accepted measurement
|
||||
|
||||
Baseline: `e54f9fbf828e06e385e0c62dc381ccb46190aa30`.
|
||||
Treatment source SHA256: `fe647e7fe2aed82609e8f1d66a0109e7a10ee28313ae2ef218ba3ab39b5cd105`.
|
||||
Runtime: v24.22.2, darwin/arm64.
|
||||
Database: PostgreSQL 18.1 on x86_64-apple-darwin23.6.0.
|
||||
Fixture: one synthetic completed review and its full live binding/publication.
|
||||
|
||||
The final measurement used an exclusive build/benchmark window, 500 warmup
|
||||
reads per version and nine alternating pairs of 100 sequential reads each.
|
||||
CPU measures the Node process; it excludes PostgreSQL process CPU. It includes
|
||||
SQL construction, driver work, row decoding and the rest of the service.
|
||||
|
||||
| Median per complete service read | Baseline | Treatment | Reduction |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Process CPU | 1.00336 ms | 0.75908 ms | 24.35% |
|
||||
| Wall time | 2.87089 ms | 2.38731 ms | 16.84% |
|
||||
|
||||
Main-selector compilations per ten warm service reads: **10 → 0**. All service
|
||||
results, generated SQL, bound parameters and query counts matched. Connection
|
||||
revocation was observed by both versions after the cache was warm.
|
||||
|
||||
Each sample below totals 100 service reads; odd pairs run baseline first and even
|
||||
pairs run treatment first. No samples were discarded.
|
||||
|
||||
| Pair | Baseline CPU ms | Treatment CPU ms | Baseline wall ms | Treatment wall ms |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 1 | 109.301 | 82.314 | 301.860 | 326.252 |
|
||||
| 2 | 110.681 | 98.892 | 310.177 | 248.191 |
|
||||
| 3 | 102.627 | 94.743 | 314.427 | 317.478 |
|
||||
| 4 | 98.198 | 78.979 | 287.089 | 323.037 |
|
||||
| 5 | 100.336 | 75.908 | 288.454 | 232.344 |
|
||||
| 6 | 91.043 | 63.248 | 247.297 | 238.731 |
|
||||
| 7 | 72.471 | 59.875 | 223.578 | 206.735 |
|
||||
| 8 | 93.542 | 54.337 | 252.195 | 219.263 |
|
||||
| 9 | 119.050 | 65.356 | 265.733 | 220.582 |
|
||||
|
||||
The smallest and largest CPU samples per read were
|
||||
0.72471–1.19050 ms for baseline and
|
||||
0.54337–0.98892 ms for treatment.
|
||||
These are local synthetic service measurements. They do not predict a
|
||||
production request latency percentage or measure database execution savings.
|
||||
|
||||
## Correctness and setup record
|
||||
|
||||
The focused real-PostgreSQL suite covers malformed JSON and strict type equality,
|
||||
current connection/publication/provenance changes, company and agent boundaries,
|
||||
repository/thread checks, a newer queued head after a warm completed review,
|
||||
changing company/issue/agent/comment bindings on a shared handle, and independent
|
||||
transaction/savepoint visibility through rollback.
|
||||
|
||||
An initial sandbox run could not bind loopback. The next run exposed missing
|
||||
macOS library aliases in the freshly unpacked embedded-Postgres dependency.
|
||||
The fixture ran after restoring aliases to existing identical library bytes in
|
||||
`node_modules`; no application source or package lock changed. The first short
|
||||
warmup benchmark and the next long warmup benchmark overlapped another fixture
|
||||
and are diagnostic only. The accepted numbers above come from the final
|
||||
exclusive-window run. Setup failures are not counted as passing tests.
|
||||
|
||||
The first shared-consumer integration import and direct server typecheck lacked
|
||||
built workspace SDK/Runner outputs. Building `@paperclipai/plugin-sdk` (including
|
||||
its shared-package prerequisite) resolved the SDK import. An overly broad run of
|
||||
the full external-chat integration file was interrupted to run only its affected
|
||||
consumer test. That interrupted run is not a passing suite. Full repository
|
||||
validation is tracked separately from the focused tests and benchmark.
|
||||
|
||||
Focused verification passed: `github-review-wait.test.ts` (16 tests), plus
|
||||
`external-chat-wait.integration.test.ts -t 'reads small current external review waits without saved prompts'`
|
||||
(four affected consumer cases; 269 unrelated cases skipped).
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { SQL } from "drizzle-orm";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { applyPendingMigrations, createDb, startEmbeddedPostgresTestDatabase } from "../../packages/db/src/index.js";
|
||||
import { readInheritedGitHubReviewWait } from "../../server/src/services/chat-channels/github/review-wait.js";
|
||||
import { captureReviewWaitQueries, seedGitHubReviewWait,
|
||||
revokeReviewWaitConnection, type CapturedReviewWaitQuery } from "../../server/src/__tests__/helpers/github-review-wait.js";
|
||||
|
||||
const baselineRef = process.argv.find(arg => arg.startsWith("--baseline-ref="))?.slice(15);
|
||||
assert.ok(baselineRef, "Pass an explicit pre-fix --baseline-ref=<commit>");
|
||||
const baselineRevision = execFileSync("git", ["rev-parse", "--verify", `${baselineRef}^{commit}`], { encoding: "utf8" }).trim();
|
||||
const treatmentRevision = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
||||
const modulePath = "server/src/services/chat-channels/github/review-wait.ts";
|
||||
const helperPath = "server/src/services/chat-channels/run-origin.ts";
|
||||
const root = fileURLToPath(new URL("../../", import.meta.url));
|
||||
assert.equal(execFileSync("git", ["show", `${baselineRevision}:${helperPath}`], { encoding: "utf8" }),
|
||||
await fs.readFile(path.join(root, helperPath), "utf8"), "Baseline provenance helper must be unchanged");
|
||||
const keepAlive = setInterval(() => {}, 1_000);
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let db: ReturnType<typeof createDb> | undefined;
|
||||
let baselineDir: string | undefined;
|
||||
try {
|
||||
const oldSource = execFileSync("git", ["show", `${baselineRevision}:${modulePath}`], { encoding: "utf8" });
|
||||
const moduleUrl = new URL(`../../${modulePath}`, import.meta.url);
|
||||
baselineDir = await fs.mkdtemp(path.join(root, "server/scripts/.review-wait-baseline-"));
|
||||
const baselineFile = path.join(baselineDir, "review-wait.ts");
|
||||
await fs.writeFile(baselineFile, oldSource.replace(/from "(\.{1,2}\/[^\"]+)"/g,
|
||||
(_match, specifier: string) => `from ${JSON.stringify(fileURLToPath(new URL(specifier, moduleUrl)))}`));
|
||||
const baseline = await import(pathToFileURL(baselineFile).href) as typeof import("../../server/src/services/chat-channels/github/review-wait.js");
|
||||
const externalUrl = process.env.PAPERCLIP_TEST_DATABASE_URL;
|
||||
if (externalUrl) {
|
||||
const parsed = new URL(externalUrl);
|
||||
assert.ok(["127.0.0.1", "localhost"].includes(parsed.hostname) && parsed.pathname.startsWith("/review_wait_benchmark"),
|
||||
"External benchmark database must be a disposable local review_wait_benchmark database");
|
||||
await applyPendingMigrations(externalUrl);
|
||||
db = createDb(externalUrl);
|
||||
} else {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("github-review-wait-benchmark-");
|
||||
db = createDb(temporary.connectionString);
|
||||
}
|
||||
const f = await seedGitHubReviewWait(db);
|
||||
async function capture(reader: typeof readInheritedGitHubReviewWait) {
|
||||
const queries: CapturedReviewWaitQuery[] = [];
|
||||
const result = await reader(captureReviewWaitQueries(db!, queries), f.run);
|
||||
return { result, queries };
|
||||
}
|
||||
const before = await capture(baseline.readInheritedGitHubReviewWait);
|
||||
const after = await capture(readInheritedGitHubReviewWait);
|
||||
assert.deepEqual(after, before, "Complete service output, SQL, bound parameters and query count must match");
|
||||
assert.equal(before.result?.kind, "published");
|
||||
|
||||
async function compilations(reader: typeof readInheritedGitHubReviewWait) {
|
||||
const original = SQL.prototype.toQuery;
|
||||
let joined = 0;
|
||||
SQL.prototype.toQuery = function (...args: Parameters<typeof original>) {
|
||||
const query = original.apply(this, args);
|
||||
if (query.sql.includes('join "chat_github_reviews"')) joined++;
|
||||
return query;
|
||||
};
|
||||
try {
|
||||
for (let i = 0; i < 10; i++) await reader(db!, f.run);
|
||||
} finally { SQL.prototype.toQuery = original; }
|
||||
return joined;
|
||||
}
|
||||
const buildCounts = { baseline: await compilations(baseline.readInheritedGitHubReviewWait),
|
||||
treatment: await compilations(readInheritedGitHubReviewWait) };
|
||||
buildCounts.treatment = await compilations(readInheritedGitHubReviewWait);
|
||||
assert.equal(buildCounts.baseline, 10);
|
||||
assert.equal(buildCounts.treatment, 0);
|
||||
|
||||
await revokeReviewWaitConnection(db, f);
|
||||
assert.deepEqual(await readInheritedGitHubReviewWait(db, f.run), { kind: "revoked" });
|
||||
assert.deepEqual(await baseline.readInheritedGitHubReviewWait(db, f.run), { kind: "revoked" });
|
||||
await db.$client.unsafe("UPDATE tool_connections SET enabled = true WHERE id = $1::uuid", [f.connectionId]);
|
||||
|
||||
const iterations = Number(process.env.REVIEW_WAIT_BENCH_ITERATIONS ?? 100);
|
||||
assert.ok(Number.isInteger(iterations) && iterations > 0);
|
||||
const readers = { baseline: baseline.readInheritedGitHubReviewWait, treatment: readInheritedGitHubReviewWait };
|
||||
const samples = { baseline: [] as { cpuMs: number; wallMs: number }[], treatment: [] as { cpuMs: number; wallMs: number }[] };
|
||||
for (const reader of Object.values(readers)) for (let i = 0; i < 500; i++) await reader(db, f.run);
|
||||
for (let pair = 0; pair < 9; pair++) {
|
||||
for (const version of pair % 2 ? ["treatment", "baseline"] as const : ["baseline", "treatment"] as const) {
|
||||
const cpu = process.cpuUsage(), wall = performance.now();
|
||||
for (let i = 0; i < iterations; i++) await readers[version](db, f.run);
|
||||
const elapsed = process.cpuUsage(cpu);
|
||||
samples[version].push({ cpuMs: (elapsed.user + elapsed.system) / 1_000, wallMs: performance.now() - wall });
|
||||
}
|
||||
}
|
||||
const stats = (values: typeof samples.baseline) => {
|
||||
const cpu = values.map(value => value.cpuMs / iterations).sort((a, b) => a - b);
|
||||
const wall = values.map(value => value.wallMs / iterations).sort((a, b) => a - b);
|
||||
return { samples: values, medianCpuMsPerRead: cpu[Math.floor(cpu.length / 2)],
|
||||
minCpuMsPerRead: cpu[0], maxCpuMsPerRead: cpu.at(-1), medianWallMsPerRead: wall[Math.floor(wall.length / 2)] };
|
||||
};
|
||||
console.log(JSON.stringify({ baselineRevision, treatmentRevision,
|
||||
treatmentDiff: execFileSync("git", ["diff", "HEAD", "--", modulePath], { cwd: root, encoding: "utf8" }),
|
||||
runtime: process.version, platform: process.platform, arch: process.arch,
|
||||
postgresVersion: (await db.$client.unsafe("SELECT version()"))[0]!.version,
|
||||
fixture: { reviews: 1, synthetic: true }, correctness: { completeOutputEqual: true,
|
||||
everyGeneratedQueryAndParameterEqual: true, currentConnectionRevocationEqual: true },
|
||||
generatedQueries: before.queries, compilationCountsPerTenWarmReads: buildCounts,
|
||||
measurement: "Complete actual service, sequential real PostgreSQL reads; process CPU includes client work, excludes database process CPU",
|
||||
timings: { iterations, warmups: 500, pairs: 9, order: "alternating", baseline: stats(samples.baseline), treatment: stats(samples.treatment) },
|
||||
}, null, 2));
|
||||
} finally {
|
||||
try {
|
||||
await db?.$client.end({ timeout: 0 });
|
||||
await temporary?.cleanup();
|
||||
if (baselineDir) await fs.rm(baselineDir, { recursive: true, force: true });
|
||||
} finally { clearInterval(keepAlive); }
|
||||
}
|
||||
@@ -80,8 +80,41 @@ describe("inherited GitHub review wait projection", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("binds each call afresh while reusing the compiled query", async () => {
|
||||
const first = await seedGitHubReviewWait(db), second = await seedGitHubReviewWait(db);
|
||||
const queries: CapturedReviewWaitQuery[] = [];
|
||||
const captured = captureReviewWaitQueries(db, queries);
|
||||
for (const f of [first, second, first]) {
|
||||
expect(await readInheritedGitHubReviewWait(captured, f.run)).toMatchObject({ reviewId: f.reviewId });
|
||||
}
|
||||
const joined = queries.filter(query => query.sql.includes('join "chat_github_reviews"'));
|
||||
expect(joined).toHaveLength(3);
|
||||
expect(joined[0]).toEqual(joined[2]);
|
||||
expect(joined[0]!.sql).toBe(joined[1]!.sql);
|
||||
expect(joined[0]!.params).not.toEqual(joined[1]!.params);
|
||||
});
|
||||
|
||||
it("keeps transaction and savepoint reads on their own sessions", async () => {
|
||||
const f = await seedGitHubReviewWait(db);
|
||||
expect((await readInheritedGitHubReviewWait(db, f.run))?.kind).toBe("published");
|
||||
await expect(db.transaction(async tx => {
|
||||
const scoped = tx as unknown as Db;
|
||||
await tx.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.connectionId));
|
||||
expect(await readInheritedGitHubReviewWait(scoped, f.run)).toEqual({ kind: "revoked" });
|
||||
await expect(tx.transaction(async savepoint => {
|
||||
await savepoint.update(toolConnections).set({ enabled: true }).where(eq(toolConnections.id, f.connectionId));
|
||||
expect((await readInheritedGitHubReviewWait(savepoint as unknown as Db, f.run))?.kind).toBe("published");
|
||||
throw new Error("rollback savepoint");
|
||||
})).rejects.toThrow("rollback savepoint");
|
||||
expect(await readInheritedGitHubReviewWait(scoped, f.run)).toEqual({ kind: "revoked" });
|
||||
throw new Error("rollback transaction");
|
||||
})).rejects.toThrow("rollback transaction");
|
||||
expect((await readInheritedGitHubReviewWait(db, f.run))?.kind).toBe("published");
|
||||
});
|
||||
|
||||
it("does not reuse an older completed review when a newer head is queued", async () => {
|
||||
const f = await seedGitHubReviewWait(db);
|
||||
expect((await readInheritedGitHubReviewWait(db, f.run))?.kind).toBe("published");
|
||||
const [review] = await db.select().from(chatGitHubReviews).where(eq(chatGitHubReviews.id, f.reviewId));
|
||||
await db.insert(chatGitHubReviews).values({ ...review!, id: randomUUID(), deliveryId: randomUUID(),
|
||||
headSha: "2".repeat(40), state: "queued", createdAt: new Date(review!.createdAt.getTime() + 1_000) });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, fillPlaceholders } from "drizzle-orm";
|
||||
import { agents, chatActions, chatConversations, chatEndpointResources, chatEndpoints,
|
||||
chatGitHubReviews, chatMessageLinks, companies, heartbeatRuns, issueComments, issues,
|
||||
runIdentityContexts, toolApplications, toolConnections, type Db } from "@paperclipai/db";
|
||||
@@ -61,6 +61,16 @@ export function captureReviewWaitQueries(db: Db, queries: CapturedReviewWaitQuer
|
||||
const builder = target.select(...fields), from = builder.from.bind(builder);
|
||||
builder.from = ((...args: Parameters<typeof from>) => {
|
||||
const selection = from(...args), execute = selection.execute.bind(selection);
|
||||
const prepare = selection.prepare.bind(selection);
|
||||
selection.prepare = ((...args: Parameters<typeof prepare>) => {
|
||||
const prepared = prepare(...args), executePrepared = prepared.execute.bind(prepared);
|
||||
prepared.execute = async (values = {}) => {
|
||||
const query = prepared.getQuery();
|
||||
queries.push({ sql: query.sql, params: fillPlaceholders(query.params, values) });
|
||||
return executePrepared(values);
|
||||
};
|
||||
return prepared;
|
||||
}) as typeof selection.prepare;
|
||||
selection.execute = async () => { queries.push(selection.toSQL()); return execute(); };
|
||||
return selection;
|
||||
}) as typeof builder.from;
|
||||
|
||||
@@ -33,6 +33,47 @@ export async function hasPublishedGitHubReview(db: Db, binding: {
|
||||
type WaitRun = Pick<typeof heartbeatRuns.$inferSelect,
|
||||
"id" | "companyId" | "agentId" | "responsibleUserId" | "activeIdentityContextId" | "nativeIssueId" | "issueId" | "contextSnapshot">;
|
||||
|
||||
const inheritedReviewQueries = new WeakMap<Db, ReturnType<typeof prepareInheritedReviewQuery>>();
|
||||
|
||||
function prepareInheritedReviewQuery(db: Db) {
|
||||
return db.select({
|
||||
id: chatGitHubReviews.id, endpointId: chatGitHubReviews.endpointId,
|
||||
state: chatGitHubReviews.state, repository: chatGitHubReviews.repository,
|
||||
repositoryId: chatGitHubReviews.repositoryId, headSha: chatGitHubReviews.headSha,
|
||||
reviewedCommit: sql<unknown>`${chatGitHubReviews.assessment}->'reviewedCommit'`, reviewRunId: chatGitHubReviews.runId,
|
||||
connectionEnabled: toolConnections.enabled, connectionStatus: toolConnections.status,
|
||||
reviewAgentId: heartbeatRuns.agentId, reviewCompanyId: heartbeatRuns.companyId,
|
||||
resourceRepository: chatEndpointResources.providerResourceId,
|
||||
resourceRepositoryId: sql<string | null>`${chatEndpointResources.metadata}->>'providerRepositoryId'`,
|
||||
pullNumber: chatGitHubReviews.pullNumber, thread: chatConversations.externalThreadId,
|
||||
}).from(chatMessageLinks)
|
||||
.innerJoin(chatConversations, and(eq(chatConversations.id, chatMessageLinks.conversationId),
|
||||
eq(chatConversations.companyId, sql.placeholder("companyId")), eq(chatConversations.issueId, sql.placeholder("issueId"))))
|
||||
.innerJoin(chatEndpoints, and(eq(chatEndpoints.id, chatConversations.endpointId),
|
||||
eq(chatEndpoints.companyId, sql.placeholder("companyId")), eq(chatEndpoints.provider, "github"),
|
||||
eq(chatEndpoints.assignedAgentId, sql.placeholder("agentId"))))
|
||||
.leftJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId),
|
||||
eq(toolConnections.companyId, sql.placeholder("companyId"))))
|
||||
.innerJoin(chatEndpointResources, and(eq(chatEndpointResources.id, chatConversations.resourceId),
|
||||
eq(chatEndpointResources.companyId, sql.placeholder("companyId")), eq(chatEndpointResources.endpointId, chatEndpoints.id)))
|
||||
.innerJoin(chatGitHubReviews, and(eq(chatGitHubReviews.endpointId, chatEndpoints.id),
|
||||
eq(chatGitHubReviews.companyId, sql.placeholder("companyId")), eq(chatGitHubReviews.issueId, sql.placeholder("issueId"))))
|
||||
.leftJoin(heartbeatRuns, eq(heartbeatRuns.id, chatGitHubReviews.runId))
|
||||
.where(and(eq(chatMessageLinks.companyId, sql.placeholder("companyId")), eq(chatMessageLinks.commentId, sql.placeholder("commentId")),
|
||||
eq(chatMessageLinks.direction, "inbound")))
|
||||
.orderBy(desc(chatGitHubReviews.createdAt), desc(chatGitHubReviews.id)).limit(1)
|
||||
.prepare("inherited_github_review_wait");
|
||||
}
|
||||
|
||||
function inheritedReviewQuery(db: Db) {
|
||||
let query = inheritedReviewQueries.get(db);
|
||||
if (!query) {
|
||||
query = prepareInheritedReviewQuery(db);
|
||||
inheritedReviewQueries.set(db, query);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
/** Passive review waits inherit the same accepted requester lineage as GitHub
|
||||
* tools. This is not a grant to publish, run a monitor, or reuse attachments. */
|
||||
export async function readInheritedGitHubReviewWait(db: Db, run: WaitRun) {
|
||||
@@ -45,32 +86,9 @@ export async function readInheritedGitHubReviewWait(db: Db, run: WaitRun) {
|
||||
throw error;
|
||||
}
|
||||
if (!commentId) return null;
|
||||
const [review] = await db.select({
|
||||
id: chatGitHubReviews.id, endpointId: chatGitHubReviews.endpointId,
|
||||
state: chatGitHubReviews.state, repository: chatGitHubReviews.repository,
|
||||
repositoryId: chatGitHubReviews.repositoryId, headSha: chatGitHubReviews.headSha,
|
||||
reviewedCommit: sql<unknown>`${chatGitHubReviews.assessment}->'reviewedCommit'`, reviewRunId: chatGitHubReviews.runId,
|
||||
connectionEnabled: toolConnections.enabled, connectionStatus: toolConnections.status,
|
||||
reviewAgentId: heartbeatRuns.agentId, reviewCompanyId: heartbeatRuns.companyId,
|
||||
resourceRepository: chatEndpointResources.providerResourceId,
|
||||
resourceRepositoryId: sql<string | null>`${chatEndpointResources.metadata}->>'providerRepositoryId'`,
|
||||
pullNumber: chatGitHubReviews.pullNumber, thread: chatConversations.externalThreadId,
|
||||
}).from(chatMessageLinks)
|
||||
.innerJoin(chatConversations, and(eq(chatConversations.id, chatMessageLinks.conversationId),
|
||||
eq(chatConversations.companyId, run.companyId), eq(chatConversations.issueId, run.nativeIssueId)))
|
||||
.innerJoin(chatEndpoints, and(eq(chatEndpoints.id, chatConversations.endpointId),
|
||||
eq(chatEndpoints.companyId, run.companyId), eq(chatEndpoints.provider, "github"),
|
||||
eq(chatEndpoints.assignedAgentId, run.agentId)))
|
||||
.leftJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId),
|
||||
eq(toolConnections.companyId, run.companyId)))
|
||||
.innerJoin(chatEndpointResources, and(eq(chatEndpointResources.id, chatConversations.resourceId),
|
||||
eq(chatEndpointResources.companyId, run.companyId), eq(chatEndpointResources.endpointId, chatEndpoints.id)))
|
||||
.innerJoin(chatGitHubReviews, and(eq(chatGitHubReviews.endpointId, chatEndpoints.id),
|
||||
eq(chatGitHubReviews.companyId, run.companyId), eq(chatGitHubReviews.issueId, run.nativeIssueId)))
|
||||
.leftJoin(heartbeatRuns, eq(heartbeatRuns.id, chatGitHubReviews.runId))
|
||||
.where(and(eq(chatMessageLinks.companyId, run.companyId), eq(chatMessageLinks.commentId, commentId),
|
||||
eq(chatMessageLinks.direction, "inbound")))
|
||||
.orderBy(desc(chatGitHubReviews.createdAt), desc(chatGitHubReviews.id)).limit(1);
|
||||
const [review] = await inheritedReviewQuery(db).execute({
|
||||
companyId: run.companyId, issueId: run.nativeIssueId, agentId: run.agentId, commentId,
|
||||
});
|
||||
// A newer unreviewed head cannot inherit the prior head's publication.
|
||||
if (!review) return null;
|
||||
if (!review.connectionEnabled || review.connectionStatus !== "active" || review.state !== "completed" ||
|
||||
|
||||
Reference in new issue
Block a user