diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 41b5de6d298..20f2d91f4c2 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1197,6 +1197,14 @@ Dashboard payload must include: - month-to-date spend and budget utilization - pending approvals count +The dashboard agent cards show each linked task at most once. The server selects +distinct task cards from bounded active and recent run samples before it applies +the dashboard card limit. When multiple runs belong to one task, an active run +takes precedence over completed runs. +Runs without a linked task remain separate cards. The dashboard keeps the +count of additional distinct cards in its link to the live runs page, which +can show every run. + ## 10.10 Error Semantics - `400` validation error diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts index ddb3f40da06..d5fabca417e 100644 --- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -420,7 +420,7 @@ describe.sequential("agent cross-tenant route authorization", () => { expect(res.body.error).toContain("Key not found"); expect(mockAgentService.getKeyById).toHaveBeenCalledWith(keyId); expect(mockAgentService.revokeKey).not.toHaveBeenCalled(); - }); + }, 30_000); it("requires board access before clearing an agent error", async () => { const app = await createApp({ diff --git a/server/src/__tests__/dashboard-service.test.ts b/server/src/__tests__/dashboard-service.test.ts index 05a1a738c9e..8642844ce5d 100644 --- a/server/src/__tests__/dashboard-service.test.ts +++ b/server/src/__tests__/dashboard-service.test.ts @@ -6,6 +6,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { dashboardService, getUtcMonthStart } from "../services/dashboard.ts"; +import { selectDashboardRunIds } from "../services/dashboard-run-selection.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -56,6 +57,42 @@ describeEmbeddedPostgres("dashboard service", () => { await tempDb?.cleanup(); }); + it("selects distinct task cards before limiting and keeps taskless runs separate", async () => { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const agentId = randomUUID(); + const otherAgentId = randomUUID(); + const repeatedIssueId = randomUUID(); + const otherIssueId = randomUUID(); + const activeRunId = randomUUID(); + const otherTaskRunId = randomUUID(); + const tasklessRunId = randomUUID(); + + await db.insert(companies).values([ + { id: companyId, name: "Dashboard cards", issuePrefix: "CARDS" }, + { id: otherCompanyId, name: "Other company", issuePrefix: "OTHER" }, + ]); + await db.insert(agents).values([ + { id: agentId, companyId, name: "Builder", role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }, + { id: otherAgentId, companyId: otherCompanyId, name: "Other", role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }, + ]); + await db.insert(heartbeatRuns).values([ + ...Array.from({ length: 6 }, (_, index) => ({ + id: randomUUID(), companyId, agentId, status: "succeeded", + contextSnapshot: { issueId: repeatedIssueId }, + createdAt: new Date(`2026-04-10T10:0${index}:00.000Z`), + })), + { id: activeRunId, companyId, agentId, status: "running", contextSnapshot: { issueId: repeatedIssueId }, createdAt: new Date("2026-04-10T08:00:00.000Z") }, + { id: otherTaskRunId, companyId, agentId, status: "succeeded", contextSnapshot: { issueId: otherIssueId }, createdAt: new Date("2026-04-10T09:04:00.000Z") }, + { id: tasklessRunId, companyId, agentId, status: "succeeded", createdAt: new Date("2026-04-10T09:03:00.000Z") }, + { id: randomUUID(), companyId: otherCompanyId, agentId: otherAgentId, status: "running", contextSnapshot: { issueId: otherIssueId }, createdAt: new Date("2026-04-10T11:00:00.000Z") }, + ]); + + expect(await selectDashboardRunIds(db, companyId, 3)).toEqual([ + activeRunId, otherTaskRunId, tasklessRunId, + ]); + }); + it("aggregates the full 14-day run activity window without recent-run truncation", async () => { const companyId = randomUUID(); const otherCompanyId = randomUUID(); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 3f15799110a..494fdd5ccee 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -15,6 +15,7 @@ import { applyConnectorSkills, resolveConnectorAssignments, annotateConnectorSki import { getExecutionBlocker } from "../services/execution-blocker.js"; import { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, isPaperclipRunnerProvider } from "@paperclipai/adapter-utils"; import { executionProjectionForRun, executionProjectionsForRuns } from "../services/execution-projection.js"; +import { selectDashboardRunIds } from "../services/dashboard-run-selection.js"; import { Router, type NextFunction, type Request, type Response } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import { readFile, rm } from "node:fs/promises"; @@ -6873,6 +6874,7 @@ export function agentRoutes( // padded in and renders bogus "live" counts. const minCount = readLiveRunsQueryInt(req.query.minCount, 50, 0); const limit = readLiveRunsQueryInt(req.query.limit, 50, 50); + const distinctTasks = req.query.distinctTasks === "true"; const columns = { id: heartbeatRuns.id, @@ -6888,7 +6890,7 @@ export function agentRoutes( createdAt: heartbeatRuns.createdAt, agentId: heartbeatRuns.agentId, agentName: agentsTable.name, - agentAppearance: agentsTable.appearance, + agentAppearance: agentsTable.appearance, adapterType: agentsTable.adapterType, logBytes: heartbeatRuns.logBytes, livenessState: heartbeatRuns.livenessState, @@ -6916,10 +6918,25 @@ export function agentRoutes( ) .orderBy(desc(heartbeatRuns.createdAt)); - const liveRuns = await liveRunsQuery.limit(limit); + const liveRuns = distinctTasks ? [] : await liveRunsQuery.limit(limit); + let rows = liveRuns; const targetRunCount = Math.min(minCount, limit); - if (targetRunCount > 0 && liveRuns.length < targetRunCount) { + if (distinctTasks) { + // Return enough representatives for the dashboard to count cards beyond + // its visible four, rather than stopping at the minimum display count. + const selectedIds = await selectDashboardRunIds(db, companyId, limit); + const selectedRows = selectedIds.length === 0 ? [] : await db + .select(columns) + .from(heartbeatRuns) + .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) + .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, selectedIds))); + const byId = new Map(selectedRows.map((run) => [run.id, run])); + rows = selectedIds.flatMap((id) => { + const run = byId.get(id); + return run ? [run] : []; + }); + } else if (targetRunCount > 0 && liveRuns.length < targetRunCount) { const activeIds = liveRuns.map((r) => r.id); const recentRuns = await db .select(columns) @@ -6935,24 +6952,15 @@ export function agentRoutes( .orderBy(desc(heartbeatRuns.createdAt)) .limit(targetRunCount - liveRuns.length); - const rows = [...liveRuns, ...recentRuns]; - const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id)); - res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => ({ - ...heartbeat.decorateActiveRunStatus(run), - agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId), - avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512), - execution: projections.get(run.id) ?? null, - outputSilence: await heartbeat.buildRunOutputSilence(run), - }))))); - return; + rows = [...liveRuns, ...recentRuns]; } - const projections = await executionProjectionsForRuns(db, companyId, liveRuns.map(run => run.id)); - res.json(await runRedactions.redactForRuns(companyId, await Promise.all(liveRuns.map(async (run) => ({ + const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id)); + res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => ({ ...heartbeat.decorateActiveRunStatus(run), - agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId), - avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512), - execution: projections.get(run.id) ?? null, + agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId), + avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512), + execution: projections.get(run.id) ?? null, outputSilence: await heartbeat.buildRunOutputSilence(run), }))))); }); diff --git a/server/src/services/dashboard-run-selection.ts b/server/src/services/dashboard-run-selection.ts new file mode 100644 index 00000000000..1efe82f9dae --- /dev/null +++ b/server/src/services/dashboard-run-selection.ts @@ -0,0 +1,49 @@ +import type { Db } from "@paperclipai/db"; +import { heartbeatRuns } from "@paperclipai/db"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; + +const MAX_CANDIDATES_PER_POOL = 1_000; +const ACTIVE_STATUSES = ["queued", "running"]; + +/** Select recent dashboard cards without ranking the company's full run history. */ +export async function selectDashboardRunIds(db: Db, companyId: string, limit: number): Promise { + if (limit <= 0) return []; + + const columns = { + id: heartbeatRuns.id, + status: heartbeatRuns.status, + createdAt: heartbeatRuns.createdAt, + issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, + }; + const [activeRuns, recentRuns] = await Promise.all([ + db.select(columns) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, ACTIVE_STATUSES))) + .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)) + .limit(MAX_CANDIDATES_PER_POOL), + db.select(columns) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.companyId, companyId)) + .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)) + .limit(MAX_CANDIDATES_PER_POOL), + ]); + + const candidates = [...new Map([...activeRuns, ...recentRuns].map((run) => [run.id, run])).values()]; + candidates.sort((a, b) => { + const activeOrder = Number(ACTIVE_STATUSES.includes(b.status)) - Number(ACTIVE_STATUSES.includes(a.status)); + if (activeOrder !== 0) return activeOrder; + const createdOrder = b.createdAt.getTime() - a.createdAt.getTime(); + return createdOrder || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0); + }); + + const selectedIds: string[] = []; + const seenCards = new Set(); + for (const run of candidates) { + const cardKey = run.issueId ? `issue:${run.issueId}` : `run:${run.id}`; + if (seenCards.has(cardKey)) continue; + seenCards.add(cardKey); + selectedIds.push(run.id); + if (selectedIds.length === limit) break; + } + return selectedIds; +} diff --git a/ui/src/api/heartbeats.test.ts b/ui/src/api/heartbeats.test.ts index 8d65eeb9ab3..4de7d5a69f7 100644 --- a/ui/src/api/heartbeats.test.ts +++ b/ui/src/api/heartbeats.test.ts @@ -55,6 +55,12 @@ describe("heartbeatsApi.liveRunsForCompany", () => { expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=50&limit=50"); }); + + it("requests distinct task cards before the server applies the dashboard limit", async () => { + await heartbeatsApi.liveRunsForCompany("company-1", { minCount: 4, limit: 4, distinctTasks: true }); + + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=4&limit=4&distinctTasks=true"); + }); }); describe("heartbeatsApi.downloadProviderTrace", () => { diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index cf913ef0054..84c7990f664 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -232,7 +232,7 @@ export const heartbeatsApi = { api.get(`/issues/${issueId}/active-run`), liveRunsForCompany: ( companyId: string, - options?: number | { minCount?: number; limit?: number }, + options?: number | { minCount?: number; limit?: number; distinctTasks?: boolean }, ) => { const searchParams = new URLSearchParams(); if (typeof options === "number") { @@ -241,6 +241,7 @@ export const heartbeatsApi = { if (options.minCount) searchParams.set("minCount", String(options.minCount)); if (options.limit) searchParams.set("limit", String(options.limit)); + if (options.distinctTasks) searchParams.set("distinctTasks", "true"); } const qs = searchParams.toString(); return api.get( diff --git a/ui/src/components/ActiveAgentsPanel.test.tsx b/ui/src/components/ActiveAgentsPanel.test.tsx index 80a7b2813ff..532785a228c 100644 --- a/ui/src/components/ActiveAgentsPanel.test.tsx +++ b/ui/src/components/ActiveAgentsPanel.test.tsx @@ -146,6 +146,7 @@ describe("ActiveAgentsPanel", () => { expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", { minCount: 4, limit: undefined, + distinctTasks: false, }); const moreLink = [...container.querySelectorAll("a")].find((anchor) => @@ -159,7 +160,54 @@ describe("ActiveAgentsPanel", () => { }); }); + it("shows each linked task once on the dashboard while keeping distinct taskless runs", async () => { + const repeatedIssueId = "issue-repeated"; + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([ + createIssueRun(1, repeatedIssueId), + { ...createIssueRun(2, repeatedIssueId), status: "succeeded", finishedAt: "2026-04-24T11:00:00.000Z" }, + createIssueRun(3, "issue-other"), + createRun(4), + createRun(5), + createRun(6), + ]); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", { + minCount: 4, + limit: undefined, + distinctTasks: true, + }); + expect([...container.querySelectorAll(".dashboard-agent-card")].map((card) => + card.querySelector('a[aria-label$=". View run"]')?.getAttribute("href"), + )).toEqual([ + "/agents/agent-1/runs/run-1", + "/agents/agent-3/runs/run-3", + "/agents/agent-4/runs/run-4", + "/agents/agent-5/runs/run-5", + ]); + expect(container.textContent).toContain("1 more active/recent run"); + + await act(async () => root.unmount()); + }); + it("can request the full live dashboard page limit without a hidden-runs link", async () => { + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([ + createIssueRun(1, "issue-repeated"), + { ...createIssueRun(2, "issue-repeated"), status: "succeeded" }, + ]); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -184,9 +232,11 @@ describe("ActiveAgentsPanel", () => { expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", { minCount: 50, limit: 50, + distinctTasks: false, }); expect(container.textContent).not.toContain("more active/recent"); expect(container.textContent).not.toContain("Run output"); + expect(container.querySelectorAll(".dashboard-agent-card")).toHaveLength(2); await act(async () => { root.unmount(); diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 34e12a65ce6..b3aaf50a2d7 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -44,6 +44,7 @@ interface ActiveAgentsPanelProps { queryScope?: string; showMoreLink?: boolean; showTranscripts?: boolean; + dedupeLinkedTasks?: boolean; } export function ActiveAgentsPanel({ @@ -58,25 +59,44 @@ export function ActiveAgentsPanel({ queryScope = "dashboard", showMoreLink = true, showTranscripts = false, + dedupeLinkedTasks = false, }: ActiveAgentsPanelProps) { - const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit }] as const; + const effectiveFetchLimit = fetchLimit; + const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit: effectiveFetchLimit, dedupeLinkedTasks }] as const; const sharedLiveRuns = useSharedPollingQuery({ companyId, - resourceKey: `live-runs:${queryScope}:${minRunCount}:${fetchLimit ?? "default"}`, + resourceKey: `live-runs:${queryScope}:${minRunCount}:${effectiveFetchLimit ?? "default"}:${dedupeLinkedTasks}`, queryKey: liveRunsQueryKey, enabled: !!companyId, leaderOnly: true, }); const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ queryKey: liveRunsQueryKey, - queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, { minCount: minRunCount, limit: fetchLimit }), + queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, { + minCount: minRunCount, + limit: effectiveFetchLimit, + distinctTasks: dedupeLinkedTasks, + }), enabled: sharedLiveRuns.enabled, }); usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt); const runs = liveRuns ?? []; - const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]); - const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length); + const cardRuns = useMemo(() => { + if (!dedupeLinkedTasks) return runs; + + // The endpoint orders active runs first, then recent completed runs. Keep + // the first run for each task so an active attempt wins over its history. + const seenIssueIds = new Set(); + return runs.filter((run) => { + if (!run.issueId) return true; + if (seenIssueIds.has(run.issueId)) return false; + seenIssueIds.add(run.issueId); + return true; + }); + }, [dedupeLinkedTasks, runs]); + const visibleRuns = useMemo(() => cardRuns.slice(0, cardLimit), [cardLimit, cardRuns]); + const hiddenRunCount = Math.max(0, cardRuns.length - visibleRuns.length); const visibleIssueIds = useMemo( () => [...new Set(visibleRuns.map((run) => run.issueId).filter((issueId): issueId is string => Boolean(issueId)))], [visibleRuns], diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 756727fbaf1..38bf9e51efb 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -374,7 +374,7 @@ export function Dashboard() { )} - + {data && ( <>