diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 6f7b2b181cc..20f874a17ae 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -3382,6 +3382,35 @@ pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-workspace-prepar It compares actual preparation guards with synthetic history. Local timings are separate from Git materialization, environment acquisition, and provider startup. +### Sidebar badge run-query benchmark + +The badge query keeps the newest 200 runs of nonterminated same-company agents. +It selects each agent's first observed run before status or Mine filters. An +observation ordinal preserves equal-time input order. Per-agent ranking orders +by full creation time first, then that ordinal for exact ties. SQL returns only failure +IDs, issue bindings, and creation times. Dismissals and fresh issue authorization +still run in the service. + +Run the synthetic PostgreSQL comparison with a pinned pre-change revision: + +```sh +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 starts disposable embedded PostgreSQL by default. To use an existing +isolated test database with current migrations, set +`PAPERCLIP_SIDEBAR_TEST_DATABASE_URL` to a dedicated database whose name ends in +`_test`. The harness removes only its own synthetic company fixtures. Do not use +a development or hosted company database. + +The report includes service-generated SQL, complete badge equality, candidate +bindings, alternating warm samples, plans, and UTF-8 response-value bytes before +Drizzle decoding. This byte count excludes protocol framing. The mixed fixture +covers shared agents, superseding successes, timestamp ties, Mine users, +dismissal equality, private and deleted issues, and more than 200 runs. The +all-failed, all-succeeded, and empty controls expose neutral or slower paths. +Timings describe local SQL work and do not establish hosted request latency. + ### Budget accounting performance Budget admission and settlement share the company accounting lock. They evaluate diff --git a/scripts/benchmark-sidebar-badges.ts b/scripts/benchmark-sidebar-badges.ts new file mode 100644 index 00000000000..6e45bb45a65 --- /dev/null +++ b/scripts/benchmark-sidebar-badges.ts @@ -0,0 +1,104 @@ +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="); +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> | undefined; +let baselineDir: string | undefined; +let db: Db | undefined; +const fixtures: Awaited>[] = []; +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 = {}; + 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((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 ? "" : 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 | 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"); +} diff --git a/server/src/__tests__/helpers/sidebar-badges-fixture.ts b/server/src/__tests__/helpers/sidebar-badges-fixture.ts new file mode 100644 index 00000000000..7effeb74757 --- /dev/null +++ b/server/src/__tests__/helpers/sidebar-badges-fixture.ts @@ -0,0 +1,127 @@ +import { randomUUID } from "node:crypto"; +import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; +import { agents, approvals, companies, heartbeatRuns, issues, type Db } from "@paperclipai/db"; +import { isHeartbeatRunVisibleInMine, latestFailedRunsByAgent } from "@paperclipai/shared"; +import type { sidebarBadgeService } from "../../services/sidebar-badges.js"; + +export type BadgeOptions = Parameters["get"]>[1]; +export type CapturedQuery = { sql: string; params: unknown[] }; + +export function captureSidebarQueries(db: Db, queries: CapturedQuery[]): Db { + return new Proxy(db, { get(target, key) { + if (key !== "select") return Reflect.get(target, key); + return (...fields: Parameters) => { + const builder = target.select(...fields), from = builder.from.bind(builder); + builder.from = ((...args: Parameters) => { + const selection = from(...args), execute = selection.execute.bind(selection); + selection.execute = async () => { + const query = selection.toSQL(); + if (query.sql.includes('"heartbeat_runs"')) queries.push(query); + return execute(); + }; + return selection; + }) as typeof builder.from; + return builder; + }; + }}); +} + +export async function observedSidebarFailures(db: Db, companyId: string, currentUserId?: string | null) { + const rows = await db.select({ + id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, companyId: heartbeatRuns.companyId, + scopeKind: heartbeatRuns.scopeKind, issueId: heartbeatRuns.issueId, status: heartbeatRuns.status, + responsibleUserId: heartbeatRuns.responsibleUserId, createdAt: heartbeatRuns.createdAt, + }).from(heartbeatRuns).innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)).where(and( + eq(heartbeatRuns.companyId, companyId), eq(agents.companyId, companyId), not(eq(agents.status, "terminated")), + )).orderBy(desc(heartbeatRuns.createdAt)).limit(200); + return latestFailedRunsByAgent(rows).filter(row => currentUserId === undefined || isHeartbeatRunVisibleInMine(row, currentUserId)); +} + +export async function seedSidebarFixture(db: Db, mode: "mixed" | "all-failed" | "all-succeeded" | "empty" = "mixed") { + return db.transaction(async tx => { + const companyId = randomUUID(), foreignCompanyId = randomUUID(); + await tx.insert(companies).values([ + { id: companyId, name: "Synthetic sidebar", issuePrefix: `B${companyId.slice(0, 7)}` }, + { id: foreignCompanyId, name: "Foreign sidebar", issuePrefix: `F${foreignCompanyId.slice(0, 7)}` }, + ]); + const actorId = randomUUID(); + await tx.insert(agents).values({ id: actorId, companyId, name: "Reader", role: "engineer" }); + const actor = { type: "agent" as const, agentId: actorId, companyId }; + const privateIssueId = randomUUID(), publicIssueId = randomUUID(), deletedIssueId = randomUUID(); + await tx.insert(issues).values([ + { id: privateIssueId, companyId, title: "Private", visibility: "private", privacyRootIssueId: privateIssueId, responsibleUserId: "other-user" }, + { id: publicIssueId, companyId, title: "Public" }, + { id: deletedIssueId, companyId, title: "Deleted" }, + ]); + const origin = Date.parse("2026-10-01T00:00:00.000Z"); + const agentIds = Array.from({ length: mode === "all-failed" ? 240 : 30 }, () => randomUUID()); + await tx.insert(agents).values(agentIds.map((id, index) => ({ id, companyId, name: `Agent ${index}`, role: "engineer" }))); + const runs: (typeof heartbeatRuns.$inferInsert)[] = []; + if (mode !== "empty") { + for (let index = 0; index < 420; index++) { + const agentIndex = index % agentIds.length; + runs.push({ id: randomUUID(), companyId, agentId: agentIds[agentIndex]!, + status: mode === "all-failed" ? "failed" : mode === "all-succeeded" ? "succeeded" : ["failed", "timed_out", "succeeded", "running", "queued", "cancelled"][agentIndex % 6], + responsibleUserId: ["user-1", "user-2", null, "", "local-board"][agentIndex % 5], + createdAt: new Date(origin + index), invocationSource: "manual" }); + } + } + const special: Record = {}; + if (mode === "mixed") { + async function add(name: string, values: Partial = {}) { + const agentId = randomUUID(), id = randomUUID(); + await tx.insert(agents).values({ id: agentId, companyId, name, role: "engineer" }); + runs.push({ id, companyId, agentId, status: "failed", responsibleUserId: "user-1", createdAt: new Date(origin + 1000 + runs.length), ...values }); + special[name] = id; + return agentId; + } + await add("outside-window", { createdAt: new Date(origin - 1000) }); + const shared = await add("superseded"); + runs.push({ companyId, agentId: shared, status: "succeeded", responsibleUserId: "user-2", createdAt: new Date(origin + 2000) }); + const foreignOwner = await add("other-user-latest"); + runs.push({ companyId, agentId: foreignOwner, status: "failed", responsibleUserId: "user-2", createdAt: new Date(origin + 2001) }); + await add("private", { scopeKind: "issue", issueId: privateIssueId }); + await add("public", { scopeKind: "issue", issueId: publicIssueId }); + await add("deleted", { scopeKind: "issue", issueId: deletedIssueId }); + await add("missing", { scopeKind: "issue", issueId: null }); + await add("dismissed"); + for (const [name, status] of [["tied-failure-first", "failed"], ["tied-success-first", "succeeded"]] as const) { + const agentId = await add(name, { status, createdAt: new Date(origin + 3000) }); + runs.push({ companyId, agentId, status: status === "failed" ? "succeeded" : "failed", responsibleUserId: "user-1", createdAt: new Date(origin + 3000) }); + } + const terminated = randomUUID(), crossCompanyAgent = randomUUID(); + await tx.insert(agents).values([ + { id: terminated, companyId, name: "Terminated", status: "terminated" }, + { id: crossCompanyAgent, companyId: foreignCompanyId, name: "Foreign" }, + ]); + for (let index = 0; index < 220; index++) runs.push({ companyId, agentId: index % 2 ? terminated : crossCompanyAgent, + status: "failed", responsibleUserId: "user-1", createdAt: new Date(origin + 10000 + index) }); + runs.push({ companyId: foreignCompanyId, agentId: crossCompanyAgent, status: "failed", createdAt: new Date(origin + 20000) }); + } + if (runs.length) await tx.insert(heartbeatRuns).values(runs); + if (mode === "mixed") { + await tx.delete(issues).where(eq(issues.id, deletedIssueId)); + const microAgent = randomUUID(); + await tx.insert(agents).values({ id: microAgent, companyId, name: "Submillisecond" }); + await tx.execute(sql`insert into heartbeat_runs (company_id, agent_id, status, responsible_user_id, created_at) values + (${companyId}, ${microAgent}, 'failed', 'user-1', '2026-10-01 00:00:04.000100+00'), + (${companyId}, ${microAgent}, 'succeeded', 'user-2', '2026-10-01 00:00:04.000900+00')`); + } + const approvalId = randomUUID(); + await tx.insert(approvals).values({ id: approvalId, companyId, type: "hire_agent", status: "pending", payload: {}, updatedAt: new Date(origin) }); + const dismissals = new Map([[`approval:${approvalId}`, origin]]); + const dismissed = runs.find(row => row.id === special.dismissed); + if (dismissed) dismissals.set(`run:${dismissed.id}`, (dismissed.createdAt as Date).getTime()); + return { companyId, foreignCompanyId, actor, actorId, privateIssueId, publicIssueId, special, dismissals, + options: { dismissals, joinRequests: [{ id: "join", createdAt: new Date(origin), updatedAt: null }], unreadTouchedIssues: 2, actor } satisfies BadgeOptions }; + }); +} + +export async function cleanupSidebarFixture(db: Db, fixture: Awaited>) { + const companyIds = [fixture.companyId, fixture.foreignCompanyId]; + await db.delete(heartbeatRuns).where(inArray(heartbeatRuns.companyId, companyIds)); + await db.delete(approvals).where(inArray(approvals.companyId, companyIds)); + await db.delete(issues).where(inArray(issues.companyId, companyIds)); + await db.delete(agents).where(inArray(agents.companyId, companyIds)); + await db.delete(companies).where(inArray(companies.id, companyIds)); +} diff --git a/server/src/__tests__/sidebar-badges.test.ts b/server/src/__tests__/sidebar-badges.test.ts new file mode 100644 index 00000000000..9830424dde9 --- /dev/null +++ b/server/src/__tests__/sidebar-badges.test.ts @@ -0,0 +1,139 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { count, eq, inArray } from "drizzle-orm"; +import { agents, approvals, companies, createDb, getEmbeddedPostgresTestSupport, heartbeatRuns, issueAccessGrants, issues, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db"; +import { sidebarBadgeService } from "../services/sidebar-badges.js"; +import { authorizationService } from "../services/authorization.js"; +import { getHeartbeatRunReadDecisions } from "../services/heartbeat-run-privacy.js"; +import { captureSidebarQueries, cleanupSidebarFixture, observedSidebarFailures, seedSidebarFixture, type BadgeOptions, type CapturedQuery } from "./helpers/sidebar-badges-fixture.js"; + +const externalUrl = process.env.PAPERCLIP_SIDEBAR_TEST_DATABASE_URL; +const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTestSupport(); +const describePostgres = support.supported ? describe : describe.skip; +if (!support.supported) console.warn(`Skipping sidebar badge SQL tests: ${support.reason}`); + +describePostgres("sidebar badge failure candidates", () => { + let db: ReturnType; + let database: Awaited> | undefined; + const fixtures: Awaited>[] = []; + let fixture: Awaited>; + + beforeAll(async () => { + if (externalUrl && !new URL(externalUrl).pathname.endsWith("_test")) throw new Error("Use a dedicated synthetic *_test database"); + if (!externalUrl) database = await startEmbeddedPostgresTestDatabase("sidebar-badges-"); + db = createDb(externalUrl ?? database!.connectionString, { maxConnections: 1 }); + fixture = await seedSidebarFixture(db); + fixtures.push(fixture); + }, 90_000); + + afterAll(async () => { + const errors: unknown[] = []; + async function cleanup(action: () => Promise | undefined) { + try { await action(); } catch (error) { errors.push(error); } + } + for (const entry of fixtures) await cleanup(() => cleanupSidebarFixture(db, entry)); + await cleanup(() => db?.$client.end({ timeout: 0 })); + await cleanup(() => database?.cleanup()); + if (errors.length) throw new AggregateError(errors, "Sidebar test cleanup failed"); + }); + + async function verify(options?: BadgeOptions) { + const queries: CapturedQuery[] = []; + const actual = await sidebarBadgeService(captureSidebarQueries(db, queries)).get(fixture.companyId, options); + const expectedCandidates = await observedSidebarFailures(db, fixture.companyId, options?.currentUserId); + const candidates = await db.$client.unsafe(queries[0]!.sql, queries[0]!.params as never[]).values(); + const normalize = (rows: unknown[][]) => rows.map(row => JSON.stringify(row)).sort(); + expect(normalize(candidates.map(row => [...row.slice(0, 3), new Date(String(row[3])).toISOString()]))).toEqual(normalize( + expectedCandidates.map(row => [row.id, row.scopeKind, row.issueId, row.createdAt.toISOString()]), + )); + expect(expectedCandidates.map(row => row.id)).not.toContain(fixture.special.superseded); + expect(expectedCandidates.map(row => row.id)).not.toContain(fixture.special["outside-window"]); + const undismissed = expectedCandidates.filter(row => (options?.dismissals?.get(`run:${row.id}`) ?? -Infinity) < row.createdAt.getTime()); + const failedRuns = options?.actor + ? (await getHeartbeatRunReadDecisions(db, authorizationService(db), options.actor, undismissed)).filter(Boolean).length + : undismissed.length; + const approvals = options?.dismissals ? 0 : 1; + const joinRequests = options?.joinRequests?.length ?? 0; + expect(actual).toEqual({ inbox: approvals + failedRuns + joinRequests + (options?.unreadTouchedIssues ?? 0), approvals, failedRuns, joinRequests }); + return actual; + } + + it("rolls back all fixture rows when the final approval insert fails", async () => { + let companyIds: string[] = []; + let writtenRuns = 0; + function failLastInsert>(connection: T): T { + return new Proxy(connection, { get(target, key) { + if (key !== "insert") return Reflect.get(target, key); + return (table: Parameters[0]) => { + if (table === companies) return { values: (values: (typeof companies.$inferInsert)[]) => { + companyIds = values.map(row => row.id!); + fixtures.push({ ...fixture, companyId: companyIds[0]!, foreignCompanyId: companyIds[1]! }); + return target.insert(companies).values(values); + }}; + if (table === approvals) return { values: async (values: typeof approvals.$inferInsert) => { + const [row] = await target.select({ value: count() }).from(heartbeatRuns).where(inArray(heartbeatRuns.companyId, companyIds)); + writtenRuns = row!.value; + return target.insert(approvals).values({ ...values, payload: null as never }); + }}; + return target.insert(table); + }; + }}); + } + const failingDb = new Proxy(failLastInsert(db), { get(target, key) { + if (key !== "transaction") return Reflect.get(target, key); + return (callback: Parameters[0]) => db.transaction(tx => callback(failLastInsert(tx))); + }}); + await expect(seedSidebarFixture(failingDb)).rejects.toMatchObject({ cause: { code: "23502" } }); + expect(companyIds).toHaveLength(2); + expect(writtenRuns).toBeGreaterThan(600); + const rows = await Promise.all([ + db.select({ value: count() }).from(companies).where(inArray(companies.id, companyIds)), + db.select({ value: count() }).from(agents).where(inArray(agents.companyId, companyIds)), + db.select({ value: count() }).from(issues).where(inArray(issues.companyId, companyIds)), + db.select({ value: count() }).from(heartbeatRuns).where(inArray(heartbeatRuns.companyId, companyIds)), + db.select({ value: count() }).from(approvals).where(inArray(approvals.companyId, companyIds)), + ]); + expect(rows.map(([row]) => row!.value)).toEqual([0, 0, 0, 0, 0]); + }); + + it.each([undefined, null, "", "user-1", "user-2", "local-board", "nobody"])("keeps the bounded latest run before Mine filtering for %s", async currentUserId => { + await verify({ ...fixture.options, currentUserId }); + }); + + it("retains company-wide behavior without an actor or options", async () => { + await verify(); + await verify({ ...fixture.options, actor: undefined }); + await verify({ ...fixture.options, actor: { type: "none" } }); + }); + + it("rechecks private issue grants and deletion on every request", async () => { + const service = sidebarBadgeService(db); + const options = { ...fixture.options, currentUserId: "user-1" }; + const denied = await service.get(fixture.companyId, options); + const [grant] = await db.insert(issueAccessGrants).values({ issueId: fixture.privateIssueId, subjectType: "agent", subjectId: fixture.actorId, source: "explicit" }).returning(); + const allowed = await service.get(fixture.companyId, options); + expect(allowed.failedRuns).toBe(denied.failedRuns + 1); + await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant!.id)); + expect(await service.get(fixture.companyId, options)).toEqual(denied); + await db.delete(issues).where(eq(issues.id, fixture.publicIssueId)); + expect((await service.get(fixture.companyId, options)).failedRuns).toBe(denied.failedRuns - 1); + await verify(options); + }); + + it("preserves equal-time input order with index and sort plans", async () => { + await db.$client.unsafe("SET enable_indexscan = off"); + await db.$client.unsafe("SET enable_bitmapscan = off"); + try { await verify(fixture.options); } + finally { + await db.$client.unsafe("RESET enable_indexscan"); + await db.$client.unsafe("RESET enable_bitmapscan"); + } + }); + + it.each(["all-failed", "all-succeeded", "empty"] as const)("handles the %s control", async mode => { + const control = await seedSidebarFixture(db, mode); + fixtures.push(control); + const response = await sidebarBadgeService(db).get(control.companyId); + const failedRuns = mode === "all-failed" ? 200 : 0; + expect(response).toEqual({ inbox: failedRuns + 1, approvals: 1, failedRuns, joinRequests: 0 }); + }); +}); diff --git a/server/src/services/sidebar-badges.ts b/server/src/services/sidebar-badges.ts index 4f0249043c2..80348af686a 100644 --- a/server/src/services/sidebar-badges.ts +++ b/server/src/services/sidebar-badges.ts @@ -1,7 +1,7 @@ -import { and, desc, eq, inArray, not, type SQL } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, not, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, approvals, heartbeatRuns } from "@paperclipai/db"; -import { isHeartbeatRunVisibleInMine, latestFailedRunsByAgent, type SidebarBadges } from "@paperclipai/shared"; +import type { SidebarBadges } from "@paperclipai/shared"; import { authorizationService, type AuthorizationActor } from "./authorization.js"; import { getHeartbeatRunReadDecisions } from "./heartbeat-run-privacy.js"; @@ -51,11 +51,10 @@ export function sidebarBadgeService(db: Db) { rows.filter((row) => !isDismissed(extra?.dismissals ?? new Map(), `approval:${row.id}`, row.updatedAt)).length ); - const recentRuns = await db + const recentRuns = db .select({ id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, - companyId: heartbeatRuns.companyId, scopeKind: heartbeatRuns.scopeKind, issueId: heartbeatRuns.issueId, status: heartbeatRuns.status, @@ -72,14 +71,50 @@ export function sidebarBadgeService(db: Db) { ), ) .orderBy(desc(heartbeatRuns.createdAt)) - .limit(200); + .limit(200) + .as("recent_runs"); - const failures = latestFailedRunsByAgent(recentRuns).filter((row) => - (extra?.currentUserId === undefined || isHeartbeatRunVisibleInMine(row, extra.currentUserId)) - && !isDismissed(extra?.dismissals ?? new Map(), `run:${row.id}`, row.createdAt), + const observedRuns = db.select({ + id: recentRuns.id, + agentId: recentRuns.agentId, + scopeKind: recentRuns.scopeKind, + issueId: recentRuns.issueId, + status: recentRuns.status, + responsibleUserId: recentRuns.responsibleUserId, + createdAt: recentRuns.createdAt, + observedOrder: sql`row_number() over ()`.as("observed_order"), + }).from(recentRuns).as("observed_runs"); + const rankedRuns = db.select({ + id: observedRuns.id, + scopeKind: observedRuns.scopeKind, + issueId: observedRuns.issueId, + status: observedRuns.status, + responsibleUserId: observedRuns.responsibleUserId, + createdAt: observedRuns.createdAt, + agentRunRank: sql`row_number() over (partition by ${observedRuns.agentId} order by ${observedRuns.createdAt} desc, ${observedRuns.observedOrder})`.as("agent_run_rank"), + }).from(observedRuns).as("ranked_runs"); + const mineCondition = extra?.currentUserId === undefined + ? undefined + : !extra.currentUserId + ? sql`false` + : extra.currentUserId === "local-board" + ? or(eq(rankedRuns.responsibleUserId, "local-board"), isNull(rankedRuns.responsibleUserId), eq(rankedRuns.responsibleUserId, "")) + : eq(rankedRuns.responsibleUserId, extra.currentUserId); + const candidates = await db.select({ + id: rankedRuns.id, + scopeKind: rankedRuns.scopeKind, + issueId: rankedRuns.issueId, + createdAt: rankedRuns.createdAt, + }).from(rankedRuns).where(and( + eq(rankedRuns.agentRunRank, 1), + inArray(rankedRuns.status, ["failed", "timed_out"]), + mineCondition, + )); + const failures = candidates.filter((row) => + !isDismissed(extra?.dismissals ?? new Map(), `run:${row.id}`, row.createdAt), ); const failedRuns = extra?.actor - ? (await getHeartbeatRunReadDecisions(db, authorization, extra.actor, failures)).filter(Boolean).length + ? (await getHeartbeatRunReadDecisions(db, authorization, extra.actor, failures.map((row) => ({ ...row, companyId })))).filter(Boolean).length : failures.length; const joinRequests = (extra?.joinRequests ?? []).filter((row) =>