mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/95/changes/paperclip/95 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The sidebar badge shows failures that need attention. > - The service reads the newest 200 runs before it selects one run per agent. > - Most of those rows cannot contribute to the failure count. > - This pull request selects failure candidates in SQL and returns four required fields. > - The benefit is less database response data with the same badge and access rules. ## Linked Issues or Issue Description Refs: #12770. Refs: #17756. Refs: #17683. These are related load, inbox, and badge reports. This implementation was written independently from the current service. It does not change which issue states count as failures. **What existing behavior does this improve?** The run read used by the company sidebar badge. **Current behavior** Every call transfers up to 200 rows with eight scalar fields. The application then selects the latest run per agent and applies status, Mine, dismissal, and access rules. **Proposed behavior** SQL selects the same latest run per agent from the same 200-row window. It then applies status and Mine rules. It returns only the run ID, scope, issue ID, and creation time. The service still checks dismissals and current issue access. **Reason and benefit** A mixed synthetic fixture reduces the response from 30,689 to 1,297 value bytes. The complete badge response stays equal. **Breaking changes** None. The endpoint response and the 200-run window stay the same. ## What Changed - Keep company and active-agent filters before the existing 200-row limit. - Rank each agent by the full creation timestamp in descending order, then by the observation ordinal. The timestamp order explicitly selects the newest run. Exact ties retain the first observed row. No ID tie-break is added. - Apply failure and Mine filters after ranking. A shared agent's success cannot expose an older failure. - Return four fields for dismissal and fresh issue authorization. Supply the already constrained company ID in the service. - Add real PostgreSQL regression fixtures and a service-generated SQL comparison harness. Document the command in `doc/DEVELOPING.md`. ## Verification - `pnpm -r typecheck` passed. `pnpm build` then passed in a separate sequential step. The normal Runner verifier checked 1,509 inputs and 1,555 outputs before reuse. - `PAPERCLIP_SIDEBAR_TEST_DATABASE_URL=<dedicated-test-url> pnpm --filter @paperclipai/server exec vitest run src/__tests__/sidebar-badges.test.ts` passed all 14 tests on PostgreSQL 17.11. - The 10 unchanged `inbox-dismissals.test.ts` route tests passed against the same real PostgreSQL server through a temporary local adapter for database startup and cleanup. The adapter was removed. The standard host run passed the 13 new tests and skipped those 10 embedded-PostgreSQL tests. - The first focused attempt could not import the unbuilt plugin SDK. The normal build supplied that package before the passing reruns. - `pnpm test:run` was attempted. It recorded 16 embedded-PostgreSQL startup failures in the unrelated native-session executor suite. Other database suites also reported startup failures and skipped. The owned command was then stopped with exit 143. This is incomplete local full-suite evidence, not a pass. Full remote CI passed on final head `df5b2c81af43c282d031cc37e6bc867054217df5`: 54 successful checks, 2 neutral checks, and 2 optional Storybook skips; no pending or failed checks. Fresh exact-head Greptile review is 5/5 with zero unresolved threads. - A fixture rollback test reproduced the review finding before the fix: a failed final approval insert left 2 companies, 44 agents, 2 issues, and 657 runs. After the fix, the same PostgreSQL constraint error leaves zero owned rows in all five tables. The test also confirms that more than 600 runs were written before the deliberate failure. - Fixture seeding now uses one transaction, including the submillisecond SQL insert. The 14 focused tests and all 10 benchmark cases pass after this repair. The server typecheck also passes. Production SQL is unchanged. - The final ordering repair passed all 14 real-PostgreSQL tests, all 10 pinned-baseline benchmark cases, and affected server typecheck and build, run sequentially. The plans now explicitly sort by agent ID, full creation time descending, then observation ordinal. Complete badge responses and candidate bindings remain equal. - One server-test shard was interrupted by its CI runner receiving a shutdown signal. Its test step was canceled without an assertion failure. A single targeted retry passed; the aggregate `ci / verify` gate also passed. No source change was needed for that interruption. - `git diff --check` passed. No schema, workflow, or lockfile changed. Reproduce the benchmark with current schema migrations and a dedicated synthetic database whose name ends in `_test`: ```sh PAPERCLIP_SIDEBAR_TEST_DATABASE_URL=<dedicated-test-url> PAPERCLIP_LOG_LEVEL=error SIDEBAR_REQUIRE_BYTE_REDUCTION=1 pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-sidebar-badges.ts --baseline-ref=bf3ef12f3a78d0b15a32ad57f7e16520fbf6cd5e ``` The harness loads the baseline service from that commit. Its authorization, privacy, and shared helper dependencies are unchanged. It captures the SQL each service executes. It compares complete badge responses and every returned candidate field. Fixtures cover more than 200 runs, shared agents, superseding successes, exact and submillisecond ties, Mine user variants, dismissal equality, private and deleted issues, missing issue bindings, terminated agents, and cross-company rows. Tests also cover absent actors, grants, revocation, deletion after a prior read, and index versus sort plans. Synthetic local SQL results from PostgreSQL 17.11 on Docker, Node 24.22.1, and postgres.js 3.4.9 follow. Each timing is the median of seven alternating warm samples after two warmups. The report includes all samples, ranges, settings, SQL, parameters, and `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` plans. | Fixture | Rows before → after | Value bytes before → after | Reduction | Median ms before → after | | --- | --- | --- | --- | --- | | Mixed, company-wide | 200 → 18 | 30,689 → 1,297 | 95.8% | 1.361 → 1.305 | | Mixed, user-1 | 200 → 9 | 30,689 → 676 | 97.8% | 1.785 → 1.391 | | Mixed, local-board | 200 → 6 | 30,689 → 414 | 98.7% | 1.463 → 1.232 | | All failed, 200 distinct latest agents | 200 → 200 | 30,298 → 13,778 | 54.5% | 1.277 → 1.237 | | All succeeded | 200 → 0 | 30,898 → 0 | 100% | 1.095 → 0.898 | | Empty | 0 → 0 | 0 → 0 | Neutral | 0.703 → 1.002 | Value bytes mean UTF-8 non-null cell values before Drizzle decoding. They exclude PostgreSQL protocol framing and are not wire bytes. The primary claim is less response data. Warm timing ranges overlap. Earlier all-failed samples were slower, and the empty control remains slower. SQL adds ranking work. No production latency improvement is claimed. The plans keep the limit below both window operations. ## Risks - SQL adds two bounded window operations. The all-failed control transfers fewer bytes but can take more SQL time. - Per-agent ranking explicitly orders by full creation time descending. Exact timestamp ties retain the observation ordinal. Real PostgreSQL tests compare complete candidates with the original helper under index and sort plans. - Mine rules now also have a SQL form. Tests cover undefined, null, empty, named, and local-board users, including empty responsible-user IDs. - Issue access stays fresh and fail closed. No authorization result is cached. No migration or stored run change is required. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-6 through Codex. The exact serving model ID and context window are not exposed in this session. The model used reasoning, code execution, SQL inspection, and GitHub 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 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>
105 lines
7.3 KiB
TypeScript
105 lines
7.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
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 { createDb, startEmbeddedPostgresTestDatabase, type Db } from "../packages/db/src/index.js";
|
|
import { sidebarBadgeService } from "../server/src/services/sidebar-badges.js";
|
|
import { captureSidebarQueries, cleanupSidebarFixture, observedSidebarFailures, seedSidebarFixture, type CapturedQuery } from "../server/src/__tests__/helpers/sidebar-badges-fixture.js";
|
|
|
|
const baselineRef = process.argv.find(arg => arg.startsWith("--baseline-ref="))?.slice(15);
|
|
assert.ok(baselineRef, "Pass --baseline-ref=<pre-fix commit>");
|
|
const baselineRevision = execFileSync("git", ["rev-parse", "--verify", `${baselineRef}^{commit}`], { encoding: "utf8" }).trim();
|
|
const treatmentRevision = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
const externalUrl = process.env.PAPERCLIP_SIDEBAR_TEST_DATABASE_URL;
|
|
assert.ok(!externalUrl || new URL(externalUrl).pathname.endsWith("_test"), "Use a dedicated synthetic *_test database");
|
|
const keepAlive = setInterval(() => {}, 1000);
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
|
let baselineDir: string | undefined;
|
|
let db: Db | undefined;
|
|
const fixtures: Awaited<ReturnType<typeof seedSidebarFixture>>[] = [];
|
|
let failure: unknown;
|
|
try {
|
|
if (!externalUrl) database = await startEmbeddedPostgresTestDatabase("sidebar-benchmark-");
|
|
db = createDb(externalUrl ?? database!.connectionString);
|
|
const modulePath = "server/src/services/sidebar-badges.ts";
|
|
const moduleUrl = new URL(`../${modulePath}`, import.meta.url);
|
|
const source = execFileSync("git", ["show", `${baselineRevision}:${modulePath}`], { encoding: "utf8" });
|
|
const rewritten = source.replace(/from "(\.{1,2}\/[^\"]+)"/g,
|
|
(_match, specifier: string) => `from ${JSON.stringify(fileURLToPath(new URL(specifier, moduleUrl)))}`);
|
|
baselineDir = await fs.mkdtemp(path.join(fileURLToPath(new URL("../server/scripts/", import.meta.url)), ".sidebar-baseline-"));
|
|
const baselineFile = path.join(baselineDir, "sidebar-badges.ts");
|
|
await fs.writeFile(baselineFile, rewritten);
|
|
const baseline = await import(pathToFileURL(baselineFile).href) as typeof import("../server/src/services/sidebar-badges.js");
|
|
const [{ version }] = await db.$client.unsafe("select version()");
|
|
const settings = await db.$client.unsafe("select name, setting from pg_settings where name in ('work_mem', 'shared_buffers', 'jit', 'max_parallel_workers_per_gather')");
|
|
const results = [];
|
|
for (const mode of ["mixed", "all-failed", "all-succeeded", "empty"] as const) {
|
|
const fixture = await seedSidebarFixture(db, mode);
|
|
fixtures.push(fixture);
|
|
await db.$client.unsafe("ANALYZE heartbeat_runs");
|
|
await db.$client.unsafe("ANALYZE agents");
|
|
const users = mode === "mixed" ? [undefined, null, "", "user-1", "user-2", "local-board", "nobody"] : [undefined];
|
|
for (const currentUserId of users) {
|
|
const queries = { baseline: [] as CapturedQuery[], treatment: [] as CapturedQuery[] };
|
|
const before = baseline.sidebarBadgeService(captureSidebarQueries(db, queries.baseline));
|
|
const after = sidebarBadgeService(captureSidebarQueries(db, queries.treatment));
|
|
const options = { ...fixture.options, currentUserId };
|
|
const baselineResponse = await before.get(fixture.companyId, options);
|
|
const treatmentResponse = await after.get(fixture.companyId, options);
|
|
assert.deepEqual(treatmentResponse, baselineResponse);
|
|
assert.equal(queries.baseline.length, 1);
|
|
assert.equal(queries.treatment.length, 1);
|
|
const expected = await observedSidebarFailures(db, fixture.companyId, currentUserId);
|
|
const normalize = (rows: unknown[][]) => rows.map(row => JSON.stringify(row)).sort();
|
|
const baselineQuery = queries.baseline[0]!, treatmentQuery = queries.treatment[0]!;
|
|
const treatmentRows = await db.$client.unsafe(treatmentQuery.sql, treatmentQuery.params as never[]).values();
|
|
if (treatmentQuery.sql !== baselineQuery.sql) {
|
|
assert.deepEqual(normalize(treatmentRows.map(row => [...row.slice(0, 3), new Date(String(row[3])).toISOString()])), normalize(expected.map(row => [row.id, row.scopeKind, row.issueId, row.createdAt.toISOString()])));
|
|
}
|
|
const samples = { baseline: [] as number[], treatment: [] as number[] };
|
|
const rowsByVersion: Record<string, unknown[][]> = {};
|
|
for (let iteration = 0; iteration < 9; iteration++) {
|
|
for (const label of iteration % 2 ? ["treatment", "baseline"] as const : ["baseline", "treatment"] as const) {
|
|
const query = label === "baseline" ? baselineQuery : treatmentQuery;
|
|
const start = performance.now();
|
|
rowsByVersion[label] = await db.$client.unsafe(query.sql, query.params as never[]).values();
|
|
if (iteration >= 2) samples[label].push(performance.now() - start);
|
|
}
|
|
}
|
|
const measurements = [];
|
|
for (const label of ["baseline", "treatment"] as const) {
|
|
const query = label === "baseline" ? baselineQuery : treatmentQuery;
|
|
const rows = rowsByVersion[label]!;
|
|
const responseValueBytes = rows.flat().reduce<number>((sum, value) => sum + (value == null ? 0 : Buffer.byteLength(typeof value === "string" ? value : String(value))), 0);
|
|
const [plan] = await db.$client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query.sql}`, query.params as never[]);
|
|
const sorted = [...samples[label]].sort((a, b) => a - b);
|
|
measurements.push({ label, query, rows: rows.length, responseValueBytes, samplesMs: samples[label], medianMs: sorted[3], minMs: sorted[0], maxMs: sorted[6], plan: plan!["QUERY PLAN"] });
|
|
}
|
|
if (process.env.SIDEBAR_REQUIRE_BYTE_REDUCTION === "1" && mode !== "empty") {
|
|
assert.ok(measurements[1]!.responseValueBytes < measurements[0]!.responseValueBytes);
|
|
}
|
|
results.push({ mode, currentUserId: currentUserId === undefined ? "<undefined>" : currentUserId, equalBadgeResponse: treatmentResponse, equalCandidateRows: expected.length, measurements });
|
|
}
|
|
}
|
|
console.log(JSON.stringify({ baselineRevision, treatmentRevision,
|
|
treatmentDiff: execFileSync("git", ["diff", "--", modulePath], { encoding: "utf8" }),
|
|
dependencyPolicy: "Baseline imports unchanged current authorization, privacy, and shared helpers", version, settings,
|
|
acceptance: "Identical complete badges and candidate bindings; fewer response-value bytes for nonempty fixtures. Timings are descriptive with no latency threshold.",
|
|
byteBoundary: "UTF-8 non-null cell values before Drizzle decoding, excluding PostgreSQL protocol framing; not wire bytes",
|
|
results }, null, 2));
|
|
} catch (error) {
|
|
failure = error;
|
|
} finally {
|
|
const errors: unknown[] = failure === undefined ? [] : [failure];
|
|
async function cleanup(action: () => Promise<unknown> | undefined) {
|
|
try { await action(); } catch (error) { errors.push(error); }
|
|
}
|
|
if (db) for (const fixture of fixtures) await cleanup(() => cleanupSidebarFixture(db!, fixture));
|
|
await cleanup(() => db?.$client.end({ timeout: 0 }));
|
|
await cleanup(() => database?.cleanup());
|
|
await cleanup(() => baselineDir ? fs.rm(baselineDir, { recursive: true, force: true }) : undefined);
|
|
clearInterval(keepAlive);
|
|
if (errors.length) throw new AggregateError(errors, "Sidebar benchmark or cleanup failed");
|
|
}
|