diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 500813bfe20..fcd0683e9f3 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1284,6 +1284,22 @@ can update an entry only after the current account's summary confirms access. The first healthy live subscription reconciles domain data without re-fetching session and health; reconnects and visibility recovery retain full reconciliation. +### Authenticated board startup + +`GET /api/auth/get-session` can include an optional `boardBootstrap` for the same +authenticated session or Cloud tenant actor. It contains accessible company +snapshots and the existing current-board-access projection. Directory visibility +through instance admin alone does not add accessible companies. The ordinary +session schema stays compatible with clients that ignore this projection. + +The browser seeds account-keyed navigation and board-access queries from this +response, avoiding two serial startup reads. Normal company/access readers still +own invalidation and refresh. A new successful session snapshot replaces older +cached membership/access for a returning account once; later ordinary reads and +mutations can update those queries. Missing, malformed, or failed optional bootstrap +falls back to those readers; a failed session does not become an anonymous cache +entry. Local trusted startup keeps its existing synthetic `local-board` principal. + ### Run history authorization reads Run history reuses the authorization service's batch read API for distinct task diff --git a/maps/feature-map/inbox-search.md b/maps/feature-map/inbox-search.md index e880dcc4cdf..b141ba71387 100644 --- a/maps/feature-map/inbox-search.md +++ b/maps/feature-map/inbox-search.md @@ -87,11 +87,11 @@ participation, while the cleanup remains in activity history. Record an explicit user recovery decision on a separate task and verify it still counts. This browser journey remains manual. -Cold-open verification: load Mine with the task-index response delayed while -approvals/runs resolve. Only the stable list placeholder should appear until the +Cold-open verification: load Mine with its snapshot response delayed while +approvals resolve. Only the stable list placeholder should appear until the initial sources settle; a failed-run row must not jump when tasks arrive. Release visible-row details separately and compare row/title bounds. Mine reads its own -100-row index, with parent/run context by ID; sidebar badges use the count endpoint. +100-row snapshot with parent/run context; sidebar badges use the count endpoint. The [Inbox component checks](../../ui/src/pages/Inbox.test.tsx) cover the source barrier and selected-source query; [badge tests](../../server/src/__tests__/inbox-dismissals.test.ts) cover unread counts. Browser timing and layout proof remain separate from these tests. diff --git a/packages/shared/src/board-bootstrap.ts b/packages/shared/src/board-bootstrap.ts new file mode 100644 index 00000000000..50bc87e70bb --- /dev/null +++ b/packages/shared/src/board-bootstrap.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { COMPANY_STATUSES, PAUSE_REASONS } from "./constants.js"; +import { interactionResolverGovernanceSchema } from "./validators/company.js"; +import type { BoardBootstrap } from "./types/board-bootstrap.js"; +import type { Company } from "./types/company.js"; + +const companySnapshotSchema: z.ZodType = z.object({ + id: z.string().min(1), + name: z.string(), + description: z.string().nullable(), + status: z.enum(COMPANY_STATUSES), + pauseReason: z.enum(PAUSE_REASONS).nullable(), + pausedAt: z.coerce.date().nullable(), + issuePrefix: z.string().min(1), + issueCounter: z.number(), + budgetMonthlyCents: z.number(), + spentMonthlyCents: z.number(), + defaultResponsibleUserId: z.string().nullable(), + requireBoardApprovalForNewAgents: z.boolean(), + interactionResolverGovernance: interactionResolverGovernanceSchema, + feedbackDataSharingEnabled: z.boolean(), + feedbackDataSharingConsentAt: z.coerce.date().nullable(), + feedbackDataSharingConsentByUserId: z.string().nullable(), + feedbackDataSharingTermsVersion: z.string().nullable(), + logoAssetId: z.string().nullable(), + logoUrl: z.string().nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +const bootstrapSchema: z.ZodType = z.object({ + userId: z.string().min(1), + companies: z.array(companySnapshotSchema), + boardAccess: z.object({ + userId: z.string().min(1), + isInstanceAdmin: z.boolean(), + companyIds: z.array(z.string().min(1)), + }), +}); + +/** An unusable optional projection falls back to the existing authenticated reads. */ +export function readBoardBootstrap(value: unknown, userId: string): BoardBootstrap | undefined { + const parsed = bootstrapSchema.safeParse(value); + return parsed.success && parsed.data.userId === userId && parsed.data.boardAccess.userId === userId + ? parsed.data + : undefined; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 366ed7a870e..becaa5ec7d7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ +export type { InboxMineSnapshot, InboxSourceError } from "./types/inbox-mine.js"; export { CASE_HANDOFF_STATES, handoffCaseSchema, agentCaseHandoffSchema, doneCaseHandoffSchema, caseWorkflowDocumentTextSchema, caseInitialDocumentSchema, caseInitialDocumentsSchema, type CaseInitialDocument, type CaseHandoffState, type CaseHandoffDestinationKind, type AgentCaseHandoffInput, type DoneCaseHandoffInput, type HandoffCaseInput, type HandoffCaseResult, type CaseHandoff, type CaseWorkflowTransition } from "./case-handoffs.js"; export * from "./slack-app-manifest.js"; export { composioAppSetupSchema, composioAppsRefreshSchema, composioAppsSyncSchema, composioAppAccountSchema, type ComposioAppSetupInput, type ComposioAppSetupResult, type ComposioAppAccountInput, type ComposioAppAccount, type ComposioAppSnapshot, type ComposioAppSyncState, type ComposioAppsResponse } from "./composio-app-setup.js"; @@ -2885,3 +2886,5 @@ export { } from "./remote-workspace.js"; export { readProviderFailure, type ProviderFailure } from "./provider-failure.generated.js"; +export type { BoardBootstrap, BoardBootstrapSession } from "./types/board-bootstrap.js"; +export { readBoardBootstrap } from "./board-bootstrap.js"; diff --git a/packages/shared/src/types/board-bootstrap.ts b/packages/shared/src/types/board-bootstrap.ts new file mode 100644 index 00000000000..8eb0e0ec4c9 --- /dev/null +++ b/packages/shared/src/types/board-bootstrap.ts @@ -0,0 +1,16 @@ +import type { Company } from "./company.js"; +import type { AuthSession } from "../validators/access.js"; + +/** Navigation reads taken for the same authenticated principal as the session. */ +export interface BoardBootstrap { + userId: string; + companies: Company[]; + boardAccess: { + userId: string; + isInstanceAdmin: boolean; + companyIds: string[]; + }; +} + +/** Older servers and partial bootstrap failures retain the ordinary session. */ +export type BoardBootstrapSession = AuthSession & { boardBootstrap?: BoardBootstrap }; diff --git a/packages/shared/src/types/inbox-mine.ts b/packages/shared/src/types/inbox-mine.ts new file mode 100644 index 00000000000..1d302b531d3 --- /dev/null +++ b/packages/shared/src/types/inbox-mine.ts @@ -0,0 +1,19 @@ +import type { HeartbeatRun } from "./heartbeat.js"; +import type { CompactIssue } from "./issue.js"; + +export interface InboxSourceError { + status: number; + message: string; +} + +/** The initial Mine rows and their structural context share one reveal boundary. */ +export interface InboxMineSnapshot { + userId: string; + issues: CompactIssue[]; + contextIssues: CompactIssue[]; + failureRuns: HeartbeatRun[]; + /** Includes missing tasks whose rich read completed successfully. */ + failureIssueIds: string[]; + failureError?: InboxSourceError; + contextError?: InboxSourceError; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 14b31713338..37f8b40120a 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1125,3 +1125,4 @@ export * from "./skill-source.js"; export * from "./agent-lifecycle.js"; export * from "./task-workspace.js"; +export type { BoardBootstrap, BoardBootstrapSession } from "./board-bootstrap.js"; diff --git a/server/src/__tests__/auth-routes.test.ts b/server/src/__tests__/auth-routes.test.ts index 62ef9653a4f..4b53c0c8eb5 100644 --- a/server/src/__tests__/auth-routes.test.ts +++ b/server/src/__tests__/auth-routes.test.ts @@ -98,6 +98,15 @@ describe("auth routes", () => { }); }); + it("preserves the session when optional navigation bootstrap cannot load", async () => { + const app = createApp({ type: "board", userId: "user-1", source: "session", companyIds: [] }, baseUser); + // This profile-only database cannot execute the bootstrap's company join. + const response = await request(app).get("/api/auth/get-session"); + expect(response.status).toBe(200); + expect(response.body.user.id).toBe("user-1"); + expect(response.body.boardBootstrap).toBeUndefined(); + }); + it("sends sentryDsn for a board actor when SENTRY_DSN is set", async () => { process.env.SENTRY_DSN = "https://public@o0.ingest.sentry.io/1"; const app = await createApp( diff --git a/server/src/__tests__/board-bootstrap-routes.test.ts b/server/src/__tests__/board-bootstrap-routes.test.ts new file mode 100644 index 00000000000..257174ab58d --- /dev/null +++ b/server/src/__tests__/board-bootstrap-routes.test.ts @@ -0,0 +1,49 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { authUsers, companies, companyMemberships, instanceUserRoles } from "@paperclipai/db"; +import { readBoardBootstrap } from "@paperclipai/shared"; +import { expect, it } from "vitest"; +import { actorMiddleware } from "../middleware/auth.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { authRoutes } from "../routes/auth.js"; +import { describeEmbeddedPostgres, useEmbeddedPostgres } from "./helpers/route-test-harness.js"; + +describeEmbeddedPostgres("authenticated board bootstrap", () => { + const ctx = useEmbeddedPostgres("board-bootstrap-"); + + it("uses the authenticated account and current memberships, including for instance admins", async () => { + const userId = `bootstrap-${randomUUID()}`, companyId = randomUUID(), foreignId = randomUUID(); + const now = new Date(); + await ctx.db.insert(authUsers).values({ id: userId, name: "Bootstrap user", email: `${userId}@example.com`, emailVerified: true, createdAt: now, updatedAt: now }); + await ctx.db.insert(companies).values([ + { id: companyId, name: "Accessible", issuePrefix: `A${companyId.slice(0, 7)}` }, + { id: foreignId, name: "Directory only", issuePrefix: `B${foreignId.slice(0, 7)}` }, + ]); + const [membership] = await ctx.db.insert(companyMemberships).values({ + companyId, principalType: "user", principalId: userId, membershipRole: "owner", status: "active", + }).returning(); + await ctx.db.insert(instanceUserRoles).values({ userId, role: "instance_admin" }); + const app = express(); + app.use(actorMiddleware(ctx.db, { deploymentMode: "authenticated", resolveSession: async () => ({ + session: { id: "bootstrap-session", userId }, user: { id: userId, name: "Bootstrap user", email: `${userId}@example.com` }, + }) })); + app.use("/api/auth", authRoutes(ctx.db)); + app.use(errorHandler); + + const first = await request(app).get(`/api/auth/get-session?userId=foreign-user&companyId=${foreignId}`).expect(200); + expect(first.body.user.id).toBe(userId); + expect(first.body.boardBootstrap.userId).toBe(userId); + expect(first.body.boardBootstrap.companies.map((value: { id: string }) => value.id)).toEqual([companyId]); + expect(first.body.boardBootstrap.boardAccess).toEqual({ userId, isInstanceAdmin: true, companyIds: [companyId] }); + expect(readBoardBootstrap(first.body.boardBootstrap, userId)?.companies[0]?.id).toBe(companyId); + expect(first.headers["cache-control"]).toBe("private, no-store"); + + await ctx.db.update(companyMemberships).set({ status: "archived" }).where(eq(companyMemberships.id, membership!.id)); + await ctx.db.delete(instanceUserRoles).where(eq(instanceUserRoles.userId, userId)); + const revoked = await request(app).get("/api/auth/get-session").expect(200); + expect(revoked.body.boardBootstrap.companies).toEqual([]); + expect(revoked.body.boardBootstrap.boardAccess).toEqual({ userId, isInstanceAdmin: false, companyIds: [] }); + }); +}); diff --git a/server/src/__tests__/inbox-mine-routes.test.ts b/server/src/__tests__/inbox-mine-routes.test.ts new file mode 100644 index 00000000000..201b13962b8 --- /dev/null +++ b/server/src/__tests__/inbox-mine-routes.test.ts @@ -0,0 +1,296 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { expect, it, vi } from "vitest"; +import { agents, authUsers, companyMemberships, heartbeatRuns, issueInboxArchives, issues } from "@paperclipai/db"; +import { INBOX_MINE_ISSUE_STATUS_FILTER, type CompactIssue, type InboxMineSnapshot } from "@paperclipai/shared"; +import { actorMiddleware } from "../middleware/auth.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { agentRoutes } from "../routes/agents.js"; +import { issueRoutes } from "../routes/issues.js"; +import * as issueServices from "../services/issues.js"; +import * as heartbeatServices from "../services/heartbeat.js"; +import { describeEmbeddedPostgres, seedCompanyWithBoardAccess, useEmbeddedPostgres } from "./helpers/route-test-harness.js"; + +describeEmbeddedPostgres("Mine inbox snapshot routes", () => { + const ctx = useEmbeddedPostgres("inbox-mine-routes-"); + + async function seed(name: string) { + const fixture = await seedCompanyWithBoardAccess(ctx.db, name); + await ctx.db.insert(authUsers).values({ + id: fixture.userId, name, email: `${fixture.userId}@example.test`, + emailVerified: true, createdAt: new Date(), updatedAt: new Date(), + }); + return fixture; + } + + function appFor(userId: string | null, options: Parameters[2] = {}, actor?: Express.Request["actor"]) { + const app = express(); + app.use(express.json()); + if (actor) app.use((req, _res, next) => { req.actor = actor; next(); }); + else app.use(actorMiddleware(ctx.db, { + deploymentMode: "authenticated", + resolveSession: async () => userId ? { + session: { id: `session-${userId}`, userId }, + user: { id: userId, name: "Inbox viewer", email: `${userId}@example.test` }, + } : null, + })); + app.use("/api", issueRoutes(ctx.db, {} as never, options)); + app.use("/api", agentRoutes(ctx.db)); + app.use(errorHandler); + return app; + } + + async function addAgent(companyId: string) { + const [agent] = await ctx.db.insert(agents).values({ + companyId, name: `Inbox agent ${randomUUID()}`, adapterType: "process", status: "active", + }).returning(); + return agent!; + } + + async function addTask(companyId: string, userId: string, values: Partial = {}) { + const [task] = await ctx.db.insert(issues).values({ + companyId, title: "Inbox task", status: "todo", createdByUserId: userId, ...values, + }).returning(); + return task!; + } + + const sortedContext = (rows: CompactIssue[]) => [...rows].sort((a, b) => a.id.localeCompare(b.id)); + + it("returns the legacy authorized index, archived parent captions and canonical rich failure state in one snapshot", async () => { + const f = await seed("Snapshot equivalence"); + const app = appFor(f.userId); + const [failedAgent, resolvedAgent, supersededAgent] = await Promise.all([ + addAgent(f.companyId), addAgent(f.companyId), addAgent(f.companyId), + ]); + const indexedParent = await addTask(f.companyId, f.userId, { title: "Indexed parent" }); + const archivedParent = await addTask(f.companyId, f.userId, { title: "Archived parent", status: "done" }); + const failedTask = await addTask(f.companyId, f.userId, { title: "Failed task", status: "blocked", assigneeAgentId: failedAgent.id }); + const resolvedTask = await addTask(f.companyId, f.userId, { title: "Resolved task", status: "done", assigneeAgentId: resolvedAgent.id }); + const supersededTask = await addTask(f.companyId, f.userId, { title: "Superseded failure", assigneeAgentId: supersededAgent.id }); + await ctx.db.insert(issueInboxArchives).values({ companyId: f.companyId, issueId: archivedParent.id, userId: f.userId }); + await addTask(f.companyId, f.userId, { parentId: indexedParent.id }); + await addTask(f.companyId, f.userId, { parentId: archivedParent.id }); + await ctx.db.insert(heartbeatRuns).values([ + { companyId: f.companyId, agentId: failedAgent.id, issueId: failedTask.id, status: "failed", responsibleUserId: f.userId, contextSnapshot: { issueId: failedTask.id }, createdAt: new Date("2026-11-01T00:00:01Z") }, + { companyId: f.companyId, agentId: resolvedAgent.id, issueId: resolvedTask.id, status: "failed", responsibleUserId: f.userId, contextSnapshot: { issueId: resolvedTask.id }, createdAt: new Date("2026-11-01T00:00:02Z") }, + { companyId: f.companyId, agentId: supersededAgent.id, issueId: supersededTask.id, status: "failed", responsibleUserId: f.userId, contextSnapshot: { issueId: supersededTask.id }, createdAt: new Date("2026-11-01T00:00:03Z") }, + { companyId: f.companyId, agentId: supersededAgent.id, issueId: supersededTask.id, status: "succeeded", responsibleUserId: f.userId, contextSnapshot: { issueId: supersededTask.id }, createdAt: new Date("2026-11-01T00:00:04Z") }, + ]); + + const snapshotResponse = await request(app).get(`/api/companies/${f.companyId}/inbox/mine`).expect(200); + const snapshot = snapshotResponse.body as InboxMineSnapshot; + const index = await request(app).get(`/api/companies/${f.companyId}/issues`).query({ + touchedByUserId: "me", inboxArchivedByUserId: "me", status: INBOX_MINE_ISSUE_STATUS_FILTER, + view: "index", sortField: "updated", sortDir: "desc", limit: 100, + includeRoutineExecutions: true, includeLiveDescendantSummary: true, + }).expect(200); + const failures = await request(app).get(`/api/companies/${f.companyId}/heartbeat-runs`) + .query({ summary: true, latestFailuresOnly: true, limit: 200 }).expect(200); + const parent = await request(app).get(`/api/companies/${f.companyId}/issues`) + .query({ view: "index", ids: archivedParent.id, includeRoutineExecutions: true, limit: 100 }).expect(200); + const failureIds = [failedTask.id, resolvedTask.id].sort(); + const rich = await request(app).get(`/api/companies/${f.companyId}/issues`) + .query({ view: "rows", ids: failureIds.join(","), includeRoutineExecutions: true, limit: 100 }).expect(200); + + expect(snapshot.userId).toBe(f.userId); + expect(snapshot.issues).toEqual(index.body); + expect(snapshot.failureRuns).toEqual(failures.body); + expect(sortedContext(snapshot.contextIssues)).toEqual(sortedContext([...parent.body, ...rich.body])); + expect(snapshot.failureIssueIds).toEqual(failureIds); + expect(snapshot.contextIssues).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: indexedParent.id })])); + expect(snapshot.contextIssues.find(row => row.id === archivedParent.id)).toMatchObject({ title: "Archived parent", listProjection: "index" }); + expect(snapshot.contextIssues.find(row => row.id === failedTask.id)?.interruption).toMatchObject({ state: "failed", canRetry: true }); + expect(snapshot.contextIssues.find(row => row.id === resolvedTask.id)?.interruption).toMatchObject({ state: "resolved", canRetry: false }); + expect(snapshot.failureRuns.map(run => run.agentId)).not.toContain(supersededAgent.id); + expect(snapshotResponse.headers["cache-control"]).toBe("private, no-store"); + expect(snapshot).not.toHaveProperty("failureError"); + expect(snapshot).not.toHaveProperty("contextError"); + }); + + it("keeps private and missing task runs locked and excludes another company's data", async () => { + const f = await seed("Snapshot privacy"); + const foreign = await seed("Foreign snapshot"); + const privateUser = `private-${randomUUID()}`; + const agent = await addAgent(f.companyId), missingAgent = await addAgent(f.companyId); + const privateId = randomUUID(), missingId = randomUUID(); + const publicTask = await addTask(f.companyId, f.userId, { title: "Visible task" }); + await addTask(f.companyId, privateUser, { + id: privateId, title: "PRIVATE_TASK_TITLE", visibility: "private", privacyRootIssueId: privateId, + responsibleUserId: privateUser, assigneeAgentId: agent.id, + }); + await addTask(foreign.companyId, f.userId, { title: "FOREIGN_TASK_TITLE" }); + const privateRunId = randomUUID(), missingRunId = randomUUID(); + await ctx.db.insert(heartbeatRuns).values([ + { id: privateRunId, companyId: f.companyId, agentId: agent.id, issueId: privateId, status: "failed", contextSnapshot: { issueId: privateId, secret: "PRIVATE_RUN_CONTEXT" }, error: "PRIVATE_RUN_ERROR" }, + { id: missingRunId, companyId: f.companyId, agentId: missingAgent.id, status: "failed", contextSnapshot: { issueId: missingId, secret: "MISSING_RUN_CONTEXT" } }, + ]); + const app = appFor(f.userId); + const snapshot = (await request(app).get(`/api/companies/${f.companyId}/inbox/mine`).expect(200)).body as InboxMineSnapshot; + expect(snapshot.issues.map(row => row.id)).toEqual([publicTask.id]); + expect(snapshot.contextIssues).toEqual([]); + expect(snapshot.failureIssueIds).toEqual([]); + expect(snapshot.failureRuns).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: privateRunId, redacted: true }), + expect.objectContaining({ id: missingRunId, redacted: true }), + ])); + const serialized = JSON.stringify(snapshot); + for (const secret of [privateId, missingId, "PRIVATE_TASK_TITLE", "FOREIGN_TASK_TITLE", "PRIVATE_RUN_CONTEXT", "PRIVATE_RUN_ERROR", "MISSING_RUN_CONTEXT"]) { + expect(serialized).not.toContain(secret); + } + const legacy = await request(app).get(`/api/companies/${f.companyId}/heartbeat-runs`) + .query({ summary: true, latestFailuresOnly: true, limit: 200 }).expect(200); + expect(snapshot.failureRuns).toEqual(legacy.body); + }); + + it("owns the 100-task and 200-run bounds and rejects caller filters or unauthorized identities", async () => { + const f = await seed("Snapshot bounds"); + const ids = Array.from({ length: 105 }, () => randomUUID()); + await ctx.db.insert(issues).values(ids.map((id, index) => ({ + id, companyId: f.companyId, title: `Bounded task ${index}`, status: "todo", createdByUserId: f.userId, + createdAt: new Date("2026-10-01T00:00:00Z"), + updatedAt: new Date(Date.UTC(2026, 10, 1, 0, index)), + }))); + const runAgents = await ctx.db.insert(agents).values(Array.from({ length: 205 }, (_, index) => ({ + companyId: f.companyId, name: `Bounded run agent ${index}`, adapterType: "process", + }))).returning(); + await ctx.db.insert(heartbeatRuns).values(runAgents.map((agent, index) => ({ + companyId: f.companyId, agentId: agent.id, status: "failed", + createdAt: new Date(Date.UTC(2026, 10, 1, 0, index)), + }))); + const app = appFor(f.userId); + const snapshot = (await request(app).get(`/api/companies/${f.companyId}/inbox/mine`).expect(200)).body as InboxMineSnapshot; + expect(snapshot.issues.map(row => row.id)).toEqual([...ids].reverse().slice(0, 100)); + expect(snapshot.failureRuns).toHaveLength(200); + expect(snapshot.failureRuns.map(run => run.agentId)).not.toContain(runAgents[0]!.id); + for (const query of [{ limit: 1000 }, { ids: ids[0] }, { userId: "other-user" }, { view: "rows" }]) { + await request(app).get(`/api/companies/${f.companyId}/inbox/mine`).query(query).expect(400); + } + await request(appFor(null)).get(`/api/companies/${f.companyId}/inbox/mine`).expect(403); + await request(appFor(null, {}, { type: "agent", agentId: runAgents[0]!.id, companyId: f.companyId, source: "agent_key" })) + .get(`/api/companies/${f.companyId}/inbox/mine`).expect(403); + await request(appFor(null, {}, { ...f.actor, userId: undefined })) + .get(`/api/companies/${f.companyId}/inbox/mine`).expect(401); + await ctx.db.update(companyMemberships).set({ status: "archived" }).where(eq(companyMemberships.principalId, f.userId)); + await request(app).get(`/api/companies/${f.companyId}/inbox/mine`).expect(403); + }); + + it("starts a fresh snapshot after archive while an older snapshot is still loading failure context", async () => { + const f = await seed("Snapshot mutation freshness"); + const task = await addTask(f.companyId, f.userId); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let releaseAdmission!: () => void; + const admissionGate = new Promise(resolve => { releaseAdmission = resolve; }); + let holdNewComputes = false; + const primaryRead = vi.fn(); + const heldFailureRead = vi.fn(); + const issueFactory = issueServices.issueService; + const issueSpy = vi.spyOn(issueServices, "issueService").mockImplementation((...args) => { + const service = issueFactory(...args); + return { ...service, list: async (...listArgs) => { + const rows = await service.list(...listArgs); + if (listArgs[0] === f.companyId && listArgs[1]?.touchedByUserId === f.userId) primaryRead(); + return rows; + } }; + }); + const heartbeatFactory = heartbeatServices.heartbeatService; + let firstFailureRead = true; + const heartbeatSpy = vi.spyOn(heartbeatServices, "heartbeatService").mockImplementation((...args) => { + const service = heartbeatFactory(...args); + return { ...service, list: async (...listArgs) => { + const runs = await service.list(...listArgs); + if (listArgs[0] === f.companyId && firstFailureRead) { + firstFailureRead = false; + heldFailureRead(); + await gate; + } + return runs; + } }; + }); + const started = vi.fn(async () => { if (holdNewComputes) await admissionGate; }); + const pending: Array> = []; + try { + const app = appFor(f.userId, { issueListDiagnostics: { onComputeStart: started } }); + const path = `/api/companies/${f.companyId}/inbox/mine`; + const original = request(app).get(path).then(response => response); + pending.push(original); + await vi.waitFor(() => { + expect(primaryRead).toHaveBeenCalledTimes(1); + expect(heldFailureRead).toHaveBeenCalledTimes(1); + }, { timeout: 5000 }); + await request(app).post(`/api/issues/${task.id}/inbox-archive`).send({}).expect(200); + const fresh = request(app).get(path).then(response => response); + pending.push(fresh); + await vi.waitFor(() => expect(started).toHaveBeenCalledTimes(2), { timeout: 2000 }); + const afterArchive = await fresh; + expect(afterArchive.status).toBe(200); + expect(afterArchive.headers["x-paperclip-request-cache"]).toBe("miss"); + expect(afterArchive.body.issues).toEqual([]); + // Retiring reuse does not retire the old compute's admission. It still + // occupies one of the same eight slots until its failure read settles. + holdNewComputes = true; + for (let offset = 0; offset < 7; offset += 1) { + pending.push(request(app).get(`/api/companies/${f.companyId}/issues`) + .query({ view: "index", offset }).then(response => response)); + } + await vi.waitFor(() => expect(started).toHaveBeenCalledTimes(9), { timeout: 5000 }); + await request(app).get(`/api/companies/${f.companyId}/issues`).query({ view: "index", offset: 99 }).expect(429); + expect(started).toHaveBeenCalledTimes(9); + release(); + const beforeArchive = await original; + expect(beforeArchive.body.issues.map((row: CompactIssue) => row.id)).toEqual([task.id]); + pending.push(request(app).get(`/api/companies/${f.companyId}/issues`) + .query({ view: "index", offset: 100 }).then(response => response)); + await vi.waitFor(() => expect(started).toHaveBeenCalledTimes(10), { timeout: 5000 }); + releaseAdmission(); + expect((await Promise.all(pending)).every(response => response.status === 200)).toBe(true); + } finally { + release(); + releaseAdmission(); + await Promise.allSettled(pending); + issueSpy.mockRestore(); + heartbeatSpy.mockRestore(); + } + }); + + it.each([false, true])("retains admission until aborted snapshot work settles (coalesced reader: %s)", async withCoalescedReader => { + const f = await seed("Snapshot admission"); + await addTask(f.companyId, f.userId); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = vi.fn(async () => gate); + const app = appFor(f.userId, { issueListDiagnostics: { onComputeStart: started } }); + const requests: Array> = []; + const snapshotPath = `/api/companies/${f.companyId}/inbox/mine`; + const original = request(app).get(snapshotPath); + // Retain a rejection handler before aborting the socket. + const aborted = original.then(() => undefined, () => undefined); + try { + await vi.waitFor(() => expect(started).toHaveBeenCalledTimes(1), { timeout: 5000 }); + const coalesced = withCoalescedReader ? request(app).get(snapshotPath).then(response => response) : undefined; + if (coalesced) requests.push(coalesced); + for (let offset = 0; offset < 7; offset += 1) { + requests.push(request(app).get(`/api/companies/${f.companyId}/issues`) + .query({ view: "index", offset }).then(response => response)); + } + await vi.waitFor(() => expect(started).toHaveBeenCalledTimes(8), { timeout: 5000 }); + original.abort(); + await aborted; + const denied = await request(app).get(`/api/companies/${f.companyId}/issues`).query({ view: "index", offset: 99 }).expect(429); + expect(denied.headers["retry-after"]).toBe("1"); + expect(started).toHaveBeenCalledTimes(8); + release(); + const responses = await Promise.all(requests); + expect(responses.every(response => response.status === 200)).toBe(true); + if (coalesced) expect((await coalesced).headers["x-paperclip-request-cache"]).toBe("coalesced"); + await request(app).get(snapshotPath).expect(200); + expect(started).toHaveBeenCalledTimes(9); + } finally { + release(); + await aborted; + await Promise.allSettled(requests); + } + }); +}); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index cfa65e033cb..0015979c89d 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -10,6 +10,8 @@ import { import { unauthorized } from "../errors.js"; import { validate } from "../middleware/validate.js"; import { resolveSentryDsns } from "../sentry-dsn.js"; +import { logger } from "../middleware/logger.js"; +import { loadBoardBootstrap } from "../services/board-bootstrap.js"; async function loadCurrentUserProfile(db: Db, userId: string) { const user = await db @@ -43,8 +45,16 @@ export function authRoutes(db: Db) { throw unauthorized("Board authentication required"); } - const user = await loadCurrentUserProfile(db, req.actor.userId); - res.json(authSessionSchema.parse({ + const bootstrap = (req.actor.source === "session" || req.actor.source === "cloud_tenant") + && Array.isArray(req.actor.companyIds) + ? loadBoardBootstrap(db, { userId: req.actor.userId, companyIds: req.actor.companyIds }).catch((err) => { + logger.warn({ err }, "failed to load optional board bootstrap"); + return undefined; + }) + : Promise.resolve(undefined); + const [user, boardBootstrap] = await Promise.all([loadCurrentUserProfile(db, req.actor.userId), bootstrap]); + res.setHeader("Cache-Control", "private, no-store"); + const session = authSessionSchema.parse({ session: { id: `paperclip:${req.actor.source ?? "none"}:${req.actor.userId}`, userId: req.actor.userId, @@ -57,7 +67,8 @@ export function authRoutes(db: Db) { sentryDsn: resolveSentryDsns().frontend, // Match the server SDK's runtime environment, including in reused images. sentryEnvironment: process.env.SENTRY_ENVIRONMENT || null, - })); + }); + res.json({ ...session, ...(boardBootstrap ? { boardBootstrap } : {}) }); }); router.get("/profile", async (req, res) => { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 3fba75d406f..bce9f2db1d5 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -4,6 +4,10 @@ import { notifyNativeStatusReassessment } from "../services/native-status-reasse import { notifyUnsafeWorkspaceExportRecovery } from "../services/unsafe-workspace-export-notifications.js"; import { notifyChatInboundWakeNotices } from "../services/chat-inbound-wake-notice-notifications.js"; import { completeIssueCommentReceipt } from "../services/issue-comment-receipt.js"; +import { readInboxMine } from "../services/inbox-mine-read.js"; +import { subscribeCompanyLiveEvents } from "../services/live-events.js"; +import { INBOX_MINE_ISSUE_STATUS_FILTER, latestFailedRunsByAgent } from "@paperclipai/shared"; +import { getHeartbeatRunReadDecisions, redactHeartbeatRunListRow } from "../services/heartbeat-run-privacy.js"; import { taskInterruptionPresentation, taskInterruptionPresentations } from "../services/task-interruption-presentation.js"; import { parseObject } from "../adapters/utils.js"; import { prepareRestrictedWorkspaceRetry } from "../services/restricted-workspace-retry.js"; @@ -2983,6 +2987,7 @@ const ISSUE_LIST_STORM_THRESHOLD = 4; const ISSUE_LIST_MAX_ACTOR_CLIENT_INFLIGHT = 8; type IssueListPreparedResponse = + | { kind: "inbox"; body: unknown } | { kind: "compact"; body: CompactIssue[]; @@ -3145,8 +3150,9 @@ function issueListRequestKey(input: { req: Request; companyId: string; normalizedQuery: Record; + route?: string; }) { - const route = "GET /api/companies/:companyId/issues"; + const route = input.route ?? "GET /api/companies/:companyId/issues"; const actor = issueListActorIdentity(input.req, input.companyId); const client = issueListClientIdentity(input.req); const normalizedQuery = normalizeIssueListCacheValue( @@ -3247,6 +3253,7 @@ async function coordinateIssueListGet(input: { companyId: string; requestKey: ReturnType; allowTtlCache: boolean; + retireOnCompanyChange?: boolean; diagnostics?: IssueListDiagnostics; compute: () => Promise; }): Promise<{ @@ -3336,6 +3343,17 @@ async function coordinateIssueListGet(input: { stormLogged: false, }; issueListInflight.set(input.requestKey.key, inflightEntry); + // Mine reads its primary rows before its optional context has necessarily + // settled. A mutation must make subsequent GETs read again, while this work + // and its existing readers keep their original admission until completion. + const unsubscribe = input.retireOnCompanyChange + ? subscribeCompanyLiveEvents(input.companyId, (event) => { + if (event.type !== "activity.logged" && event.type !== "heartbeat.run.queued" && event.type !== "heartbeat.run.status") return; + if (issueListInflight.get(input.requestKey.key) === inflightEntry) { + issueListInflight.delete(input.requestKey.key); + } + }) + : undefined; try { const response = await promise; @@ -3348,6 +3366,7 @@ async function coordinateIssueListGet(input: { } return { response, cacheStatus: "miss", identicalInFlightCount: 1 }; } finally { + unsubscribe?.(); if (issueListInflight.get(input.requestKey.key) === inflightEntry) { issueListInflight.delete(input.requestKey.key); } @@ -8387,6 +8406,149 @@ export function issueRoutes( res.json(summaries); }); + async function prepareIssueListForActor(req: Request, companyId: string, listFilters: IssueFilters, view: string | undefined): Promise { + const compactView = view === "compact" || view === "index" || view === "rows"; + const rawResult = await svc.list(companyId, listFilters); + const visibleRows = await actorCanReadCompanyScope(req, companyId) + ? rawResult + : await filterIssuesForActor(req, rawResult); + const result = await redactIssueRelationEdgesOnRows(req, companyId, visibleRows); + if (view === "index") { + const body = result.map((issue) => ({ ...toCompactIssue(issue), listProjection: "index" as const })); + return { kind: "compact", body, etag: compactIssueListEtag(body), cacheControl: "private, must-revalidate" }; + } + const issueIds = result.map((issue) => issue.id); + const interruptions = await taskInterruptionPresentations(db, companyId, result); + if (compactView) { + const [handoffStates, recoveryActionByIssue] = await Promise.all([ + listSuccessfulRunHandoffStates(db, companyId, issueIds), + recoveryActionsSvc.listActiveForIssues(companyId, issueIds), + ]); + const actor = getActorInfo(req); + await Promise.all( + result.map(async (issue) => { + const activeRecoveryAction = + recoveryActionByIssue.get(issue.id) ?? null; + if (!activeRecoveryAction) return; + const revalidated = await revalidateActiveSourceRecoveryForRead({ + issue, + trigger: "read_projection", + actor, + activeRecoveryAction, + }); + if (revalidated) recoveryActionByIssue.set(issue.id, revalidated); + else recoveryActionByIssue.delete(issue.id); + }), + ); + const compactResult = result.map((issue) => + ({ ...toCompactIssue({ + ...issue, + activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null, + interruption: interruptions.get(issue.id) ?? null, + successfulRunHandoff: handoffStates.get(issue.id) ?? null, + }), ...(view === "rows" ? { listProjection: "rows" as const } : {}) }), + ); + return { + kind: "compact", + body: compactResult, + etag: compactIssueListEtag(compactResult), + cacheControl: "private, must-revalidate", + }; + } + const [handoffStates, recoveryActionByIssue] = await Promise.all([ + listSuccessfulRunHandoffStates(db, companyId, issueIds), + recoveryActionsSvc.listActiveForIssues(companyId, issueIds), + ]); + const actor = getActorInfo(req); + await Promise.all( + result.map(async (issue) => { + const activeRecoveryAction = + recoveryActionByIssue.get(issue.id) ?? null; + if (!activeRecoveryAction) return; + const revalidated = await revalidateActiveSourceRecoveryForRead({ + issue, + trigger: "read_projection", + actor, + activeRecoveryAction, + }); + if (revalidated) recoveryActionByIssue.set(issue.id, revalidated); + else recoveryActionByIssue.delete(issue.id); + }), + ); + return { + kind: "full", + body: result.map((issue) => ({ + ...issue, + successfulRunHandoff: handoffStates.get(issue.id) ?? null, + activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null, + interruption: interruptions.get(issue.id) ?? null, + })), + }; + } + + router.get("/companies/:companyId/inbox/mine", async (req, res) => { + const startedAt = Date.now(); + const companyId = req.params.companyId as string; + assertBoard(req); + assertCompanyAccess(req, companyId); + if (Object.keys(req.query).length > 0) { + res.status(400).json({ error: "The Mine snapshot does not accept query parameters" }); + return; + } + const userId = req.actor.userId; + if (!userId) throw new HttpError(401, "A signed-in board user is required"); + const readCondition = await issueReadSqlCondition(db, req.actor); + const listFilters: IssueFilters = { + readCondition, touchedByUserId: userId, inboxArchivedByUserId: userId, + status: INBOX_MINE_ISSUE_STATUS_FILTER, listProjection: "index", + sortField: "updated", sortDir: "desc", limit: 100, + includeRoutineExecutions: true, includeLiveDescendantSummary: true, + }; + const readCompact = async (filters: IssueFilters, projection: "index" | "rows") => { + const prepared = await prepareIssueListForActor(req, companyId, filters, projection); + if (prepared.kind !== "compact") throw new Error("Expected compact inbox rows"); + return prepared.body; + }; + const requestKey = issueListRequestKey({ req, companyId, normalizedQuery: { view: "mine-snapshot" }, route: "GET /api/companies/:companyId/inbox/mine" }); + const coordinated = await coordinateIssueListGet({ + req, companyId, requestKey, allowTtlCache: false, retireOnCompanyChange: true, diagnostics: opts.issueListDiagnostics, + compute: async () => ({ + kind: "inbox", + body: { userId, ...await readInboxMine({ + loadIssues: () => readCompact(listFilters, "index"), + loadFailures: async () => { + const permission = await access.decide({ actor: req.actor, action: "company_scope:read", resource: { type: "company", companyId } }); + if (!permission.allowed) throw new HttpError(403, "Run telemetry is outside this actor's authorization boundary"); + const runs = latestFailedRunsByAgent(await heartbeat.list(companyId, undefined, 200, { summary: true })); + const decisions = await getHeartbeatRunReadDecisions(db, access, req.actor, runs); + return runRedactions.redactForRuns(companyId, runs.map((run, index) => decisions[index] ? run : redactHeartbeatRunListRow(run)), getActorInfo(req).actorId); + }, + loadContext: (ids, projection) => readCompact({ + readCondition, issueIds: ids, listProjection: projection, limit: 100, includeRoutineExecutions: true, + }, projection), + sourceError: (error, source) => { + logger.warn({ err: error, companyId, source }, "inbox source read failed"); + return { + status: error instanceof HttpError ? error.status : 503, + message: error instanceof HttpError && error.status < 500 ? error.message + : source === "failures" ? "Could not load failed runs" : "Could not load task context", + }; + }, + }) }, + }), + }); + res.setHeader("Cache-Control", "private, no-store"); + res.setHeader("X-Paperclip-Request-Cache", coordinated.cacheStatus); + if (!coordinated.response) { + res.setHeader("Retry-After", String(coordinated.retryAfterSeconds ?? 1)); + res.status(429).json({ error: "Too many concurrent issue-list requests for this actor/client", retryAfterSeconds: coordinated.retryAfterSeconds ?? 1 }); + return; + } + logIssueListRequest({ req, res, companyId, requestKey, startedAt, cacheStatus: coordinated.cacheStatus, + bodyBytes: estimatedJsonBytes(coordinated.response.body), etagOutcome: "none", identicalInFlightCount: coordinated.identicalInFlightCount }); + res.json(coordinated.response.body); + }); + router.get("/companies/:companyId/issues", async (req, res) => { const startedAt = Date.now(); const companyId = req.params.companyId as string; @@ -8439,7 +8601,6 @@ export function issueRoutes( const sortField = req.query.sortField as string | undefined; const sortDir = req.query.sortDir as string | undefined; const view = req.query.view as string | undefined; - const compactView = view === "compact" || view === "index" || view === "rows"; const issueIds = typeof req.query.ids === "string" ? [...new Set(req.query.ids.split(","))] : undefined; const parentIds = typeof req.query.parentIds === "string" ? [...new Set(req.query.parentIds.split(","))] : undefined; const oneChildPerParent = parseOptionalBooleanQuery(req.query.oneChildPerParent); @@ -8685,82 +8846,7 @@ export function issueRoutes( allowTtlCache: view === "compact", diagnostics: opts.issueListDiagnostics, compute: async () => { - const rawResult = await svc.list(companyId, listFilters); - const visibleRows = await actorCanReadCompanyScope(req, companyId) - ? rawResult - : await filterIssuesForActor(req, rawResult); - const result = await redactIssueRelationEdgesOnRows(req, companyId, visibleRows); - if (view === "index") { - const body = result.map((issue) => ({ ...toCompactIssue(issue), listProjection: "index" as const })); - return { kind: "compact", body, etag: compactIssueListEtag(body), cacheControl: "private, must-revalidate" }; - } - const issueIds = result.map((issue) => issue.id); - const interruptions = await taskInterruptionPresentations(db, companyId, result); - if (compactView) { - const [handoffStates, recoveryActionByIssue] = await Promise.all([ - listSuccessfulRunHandoffStates(db, companyId, issueIds), - recoveryActionsSvc.listActiveForIssues(companyId, issueIds), - ]); - const actor = getActorInfo(req); - await Promise.all( - result.map(async (issue) => { - const activeRecoveryAction = - recoveryActionByIssue.get(issue.id) ?? null; - if (!activeRecoveryAction) return; - const revalidated = await revalidateActiveSourceRecoveryForRead({ - issue, - trigger: "read_projection", - actor, - activeRecoveryAction, - }); - if (revalidated) recoveryActionByIssue.set(issue.id, revalidated); - else recoveryActionByIssue.delete(issue.id); - }), - ); - const compactResult = result.map((issue) => - ({ ...toCompactIssue({ - ...issue, - activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null, - interruption: interruptions.get(issue.id) ?? null, - successfulRunHandoff: handoffStates.get(issue.id) ?? null, - }), ...(view === "rows" ? { listProjection: "rows" as const } : {}) }), - ); - return { - kind: "compact", - body: compactResult, - etag: compactIssueListEtag(compactResult), - cacheControl: "private, must-revalidate", - }; - } - const [handoffStates, recoveryActionByIssue] = await Promise.all([ - listSuccessfulRunHandoffStates(db, companyId, issueIds), - recoveryActionsSvc.listActiveForIssues(companyId, issueIds), - ]); - const actor = getActorInfo(req); - await Promise.all( - result.map(async (issue) => { - const activeRecoveryAction = - recoveryActionByIssue.get(issue.id) ?? null; - if (!activeRecoveryAction) return; - const revalidated = await revalidateActiveSourceRecoveryForRead({ - issue, - trigger: "read_projection", - actor, - activeRecoveryAction, - }); - if (revalidated) recoveryActionByIssue.set(issue.id, revalidated); - else recoveryActionByIssue.delete(issue.id); - }), - ); - return { - kind: "full", - body: result.map((issue) => ({ - ...issue, - successfulRunHandoff: handoffStates.get(issue.id) ?? null, - activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null, - interruption: interruptions.get(issue.id) ?? null, - })), - }; + return prepareIssueListForActor(req, companyId, listFilters, view); }, }); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index ccc7bfe0bb5..faeb40e732c 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4231,6 +4231,16 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/inbox/mine", + tags: ["issues"], + summary: "Get the board user's initial Mine inbox and required task context", + description: "Board-only presentation read. Returns userId, issues, contextIssues, failureRuns and verified failureIssueIds. Uses the existing company, private-task, run-read and secret-redaction policies. Optional failureError or contextError retains successful task rows. The initial task page is bounded to 100 and failed-run history to 200. No query parameters are accepted. This GET shares issue-list coalescing and admission limits; it creates no work.", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 429: r.tooManyRequests }, +}); + registry.registerPath({ method: "get", path: "/api/companies/{companyId}/issues", @@ -7434,6 +7444,7 @@ registry.registerPath({ path: "/api/auth/get-session", tags: ["auth"], summary: "Get current session", + description: "Authenticated board sessions can include an optional same-user boardBootstrap with accessible companies and boardAccess. Existing session fields are unchanged; clients retain ordinary company and access reads when the optional projection is absent.", responses: { 200: r.ok(), 401: r.unauthorized }, }); diff --git a/server/src/services/board-bootstrap.ts b/server/src/services/board-bootstrap.ts new file mode 100644 index 00000000000..ae2a31545b8 --- /dev/null +++ b/server/src/services/board-bootstrap.ts @@ -0,0 +1,27 @@ +import type { Db } from "@paperclipai/db"; +import type { BoardBootstrap } from "@paperclipai/shared"; +import { boardAuthService } from "./board-auth.js"; +import { companyService } from "./companies.js"; + +/** The actor has already authenticated and resolved current company authority. */ +export async function loadBoardBootstrap(db: Db, principal: { + userId: string; + companyIds: readonly string[]; +}): Promise { + const [companies, access] = await Promise.all([ + companyService(db).list(), + boardAuthService(db).resolveBoardAccess(principal.userId), + ]); + const accessible = new Set(principal.companyIds); + return { + userId: principal.userId, + // Match /companies?scope=accessible: directory visibility through instance + // admin alone does not grant entry to a company's inbox or tasks. + companies: companies.filter((company) => accessible.has(company.id)), + boardAccess: { + userId: principal.userId, + isInstanceAdmin: access.isInstanceAdmin, + companyIds: access.companyIds, + }, + }; +} diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 8fbe9aa6f33..e22e35b6790 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -37,6 +37,7 @@ import { import { environmentService } from "./environments.js"; import { logActivity, type ActivityPublication } from "./activity-log.js"; import { builtInAgentService } from "./built-in-agents.js"; +import type { Company } from "@paperclipai/shared"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -117,6 +118,8 @@ export function companyService(db: Db, _budgetHooks: BudgetServiceHooks = {}) { name: companies.name, description: companies.description, status: companies.status, + pauseReason: companies.pauseReason, + pausedAt: companies.pausedAt, issuePrefix: companies.issuePrefix, issueCounter: companies.issueCounter, budgetMonthlyCents: companies.budgetMonthlyCents, @@ -133,9 +136,11 @@ export function companyService(db: Db, _budgetHooks: BudgetServiceHooks = {}) { updatedAt: companies.updatedAt, }; - function enrichCompany(company: T) { + function enrichCompany(company: T) { return { ...company, + status: company.status as Company["status"], + pauseReason: company.pauseReason as Company["pauseReason"], logoUrl: company.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null, }; } diff --git a/server/src/services/inbox-mine-read.test.ts b/server/src/services/inbox-mine-read.test.ts new file mode 100644 index 00000000000..559b3d3544d --- /dev/null +++ b/server/src/services/inbox-mine-read.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import { readInboxMine } from "./inbox-mine-read.js"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +type Row = { id: string; parentId?: string; interruption?: string }; +const sourceError = (_error: unknown, source: string) => ({ status: 503, message: `${source} unavailable` }); + +describe("Mine read composition", () => { + it("loads failure state before the primary list finishes and reveals one complete snapshot", async () => { + const primary = deferred(), rich = deferred(); + const loadContext = vi.fn((ids: string[], projection: string) => projection === "rows" + ? rich.promise : Promise.resolve(ids.map((id) => ({ id })))); + const result = readInboxMine({ + loadIssues: () => primary.promise, + loadFailures: async () => [{ id: "run", issueId: "failure" }], + loadContext, sourceError, + }); + await vi.waitFor(() => expect(loadContext).toHaveBeenCalledWith(["failure"], "rows")); + primary.resolve([{ id: "child", parentId: "parent" }]); + await vi.waitFor(() => expect(loadContext).toHaveBeenCalledWith(["parent"], "index")); + const settled = vi.fn(); + void result.then(settled); + await Promise.resolve(); + expect(settled).not.toHaveBeenCalled(); + rich.resolve([{ id: "failure", interruption: "current" }]); + expect(await result).toMatchObject({ + issues: [{ id: "child", parentId: "parent" }], + contextIssues: [{ id: "parent" }, { id: "failure", interruption: "current" }], + failureIssueIds: ["failure"], + }); + }); + + it("reuses indexed parents, preserves rich overlap, and records known missing failures", async () => { + const loadContext = vi.fn(async (ids: string[], projection: string) => ids.filter((id) => id !== "missing") + .map((id) => ({ id, ...(projection === "rows" ? { interruption: "resolved" } : {}) }))); + const result = await readInboxMine({ + loadIssues: async () => [{ id: "indexed" }, { id: "a", parentId: "indexed" }, { id: "b", parentId: "overlap" }], + loadFailures: async () => [{ id: "run-a", issueId: "overlap" }, { id: "run-b", contextSnapshot: { taskId: "missing" } }], + loadContext, sourceError, + }); + expect(loadContext.mock.calls.every(([ids]) => !ids.includes("indexed"))).toBe(true); + expect(result.contextIssues).toEqual([{ id: "overlap", interruption: "resolved" }]); + expect(result.failureIssueIds).toEqual(["missing", "overlap"]); + }); + + it("keeps tasks and captions when the optional run read fails", async () => { + const result = await readInboxMine({ + loadIssues: async () => [{ id: "child", parentId: "parent" }], + loadFailures: async (): Promise<{ id: string }[]> => { throw new Error("private database detail"); }, + loadContext: async (ids) => ids.map((id) => ({ id })), sourceError, + }); + expect(result.contextIssues).toEqual([{ id: "parent" }]); + expect(result.failureRuns).toEqual([]); + expect(result.failureError).toEqual({ status: 503, message: "failures unavailable" }); + }); + + it("keeps parent captions when rich context fails without claiming verified failure state", async () => { + const result = await readInboxMine({ + loadIssues: async () => [{ id: "child", parentId: "parent" }], + loadFailures: async () => [{ id: "run", issueId: "failure" }], + loadContext: async (ids, projection) => { + if (projection === "rows") throw new Error("read failed"); + return ids.map((id) => ({ id })); + }, sourceError, + }); + expect(result.contextIssues).toEqual([{ id: "parent" }]); + expect(result.failureIssueIds).toEqual([]); + expect(result.contextError).toEqual({ status: 503, message: "context unavailable" }); + }); + + it("bounds context batches even with a full failed-run window", async () => { + const loadContext = vi.fn(async (ids: string[]) => ids.map((id) => ({ id }))); + const result = await readInboxMine({ + loadIssues: async (): Promise => [], + loadFailures: async () => Array.from({ length: 200 }, (_, i) => ({ id: `run-${i}`, issueId: `issue-${i}` })), + loadContext, sourceError, + }); + expect(loadContext.mock.calls.map(([ids]) => ids.length)).toEqual([100, 100]); + expect(result.failureIssueIds).toHaveLength(200); + }); + + it("does not release the read while another started branch still runs after a primary error", async () => { + const rich = deferred(); + const loadContext = vi.fn(() => rich.promise); + const result = readInboxMine({ + loadIssues: async (): Promise => { throw new Error("primary unavailable"); }, + loadFailures: async () => [{ id: "run", issueId: "failure" }], + loadContext, sourceError, + }); + const rejected = vi.fn(); + const observed = result.catch(rejected); + await vi.waitFor(() => expect(loadContext).toHaveBeenCalled()); + expect(rejected).not.toHaveBeenCalled(); + rich.resolve([]); + await observed; + expect(rejected).toHaveBeenCalledWith(expect.objectContaining({ message: "primary unavailable" })); + }); +}); diff --git a/server/src/services/inbox-mine-read.ts b/server/src/services/inbox-mine-read.ts new file mode 100644 index 00000000000..1443bca241c --- /dev/null +++ b/server/src/services/inbox-mine-read.ts @@ -0,0 +1,77 @@ +import type { InboxSourceError } from "@paperclipai/shared"; + +type IssueRow = { id: string; parentId?: string | null }; +type RunRow = { id: string; issueId?: unknown; contextSnapshot?: unknown }; +type ContextResult = { issues: I[]; error?: InboxSourceError }; +const CONTEXT_BATCH_SIZE = 100; + +export function inboxFailureIssueId(run: RunRow): string | null { + if (typeof run.issueId === "string" && run.issueId) return run.issueId; + const context = run.contextSnapshot; + if (!context || typeof context !== "object" || Array.isArray(context)) return null; + for (const key of ["issueId", "taskId"] as const) { + const value = (context as Record)[key]; + if (typeof value === "string" && value) return value; + } + return null; +} + +/** Compose canonical, authorized reads without a browser round trip between them. */ +export async function readInboxMine(deps: { + loadIssues(): Promise; + loadFailures(): Promise; + loadContext(ids: string[], projection: "index" | "rows"): Promise; + sourceError(error: unknown, source: "failures" | "context"): InboxSourceError; +}) { + const loadContext = async (ids: string[], projection: "index" | "rows"): Promise> => { + try { + const issues: I[] = []; + for (let offset = 0; offset < ids.length; offset += CONTEXT_BATCH_SIZE) { + issues.push(...await deps.loadContext(ids.slice(offset, offset + CONTEXT_BATCH_SIZE), projection)); + } + return { issues }; + } catch (error) { + return { issues: [], error: deps.sourceError(error, "context") }; + } + }; + const issuesPromise = deps.loadIssues(); + const failuresPromise = deps.loadFailures().then( + (runs) => ({ runs, error: undefined as InboxSourceError | undefined }), + (error: unknown) => ({ runs: [] as R[], error: deps.sourceError(error, "failures") }), + ); + // Start each branch as soon as its own input arrives. In particular, rich + // failure reads do not wait for the primary task list or its parent captions. + const parentsPromise = issuesPromise.then((issues) => { + const loaded = new Set(issues.map((issue) => issue.id)); + const ids = [...new Set(issues.map((issue) => issue.parentId) + .filter((id): id is string => typeof id === "string" && id.length > 0 && !loaded.has(id)))].sort(); + return loadContext(ids, "index"); + }); + const failureContextPromise = failuresPromise.then(async ({ runs }) => { + const ids = [...new Set(runs.map(inboxFailureIssueId).filter((id): id is string => Boolean(id)))].sort(); + return { ...await loadContext(ids, "rows"), ids }; + }); + // Admission remains occupied until every started read settles, including when + // the critical primary query fails while a failure read is still running. + const results = await Promise.allSettled([ + issuesPromise, failuresPromise, parentsPromise, failureContextPromise, + ]); + const [issueResult, failuresResult, parentsResult, contextResult] = results; + if (issueResult.status === "rejected") throw issueResult.reason; + if (failuresResult.status === "rejected") throw failuresResult.reason; + if (parentsResult.status === "rejected") throw parentsResult.reason; + if (contextResult.status === "rejected") throw contextResult.reason; + const issues = issueResult.value, failures = failuresResult.value; + const parents = parentsResult.value, failureContext = contextResult.value; + // A task that serves both roles keeps its canonical rich failure state. + const context = new Map(parents.issues.map((issue) => [issue.id, issue])); + for (const issue of failureContext.issues) context.set(issue.id, issue); + return { + issues, + contextIssues: [...context.values()], + failureRuns: failures.runs, + failureIssueIds: failureContext.error ? [] : failureContext.ids, + ...(failures.error ? { failureError: failures.error } : {}), + ...(parents.error || failureContext.error ? { contextError: parents.error ?? failureContext.error } : {}), + }; +} diff --git a/tests/perf/collections/README.md b/tests/perf/collections/README.md index c88a05532ac..1b675f08be7 100644 --- a/tests/perf/collections/README.md +++ b/tests/perf/collections/README.md @@ -23,30 +23,84 @@ Request counts and row budgets are acceptance checks. They do not establish an end-to-end latency improvement. Measure cold, warm, and Back timings separately with the same fixture, assets, cache, viewport, and CPU/network settings. -`parent-context.spec.ts` also covers a delayed missing-parent index response, -reuse of an already indexed parent, and row geometry through deferred visible -diagnostics. Parent captions retain the initial reveal boundary. Failed-run -context and parent-to-failure cache transitions are covered by `Inbox.test.tsx`. +`parent-context.spec.ts` holds the complete Mine response, including parent +metadata, then checks row geometry through deferred visible diagnostics. Mine +uses one assembled source read; it does not fetch parent or failed-task context +from the browser before reveal. Recent retains its separate index/context path. +`startup-regressions.spec.ts` covers session bootstrap and legacy fallback, +partial source errors, resolved failure context, scoped 429 recovery, and +archive/Undo persistence. Its authenticated session and failed-run responses are +simulated transport fixtures over a real local board. They do not prove hosted +login or model execution. +The embedded bootstrap case holds separate company/access responses until Mine +rows appear. Initial WebSocket reconciliation may request those endpoints in the +background; their responses must not block the first rows. Missing or mismatched +bootstrap data still requires the ordinary fallback reads. +The two 429 cases keep the existing automatic backoff and `Retry-After` policy. +They allow 45 seconds for its bounded retry window before exercising manual +Retry. Other cases keep the normal short assertion timeout. Timing samples do +not accelerate the browser clock. -To collect timings, serve pinned baseline and candidate production assets from -two loopback origins that proxy the same disposable API. Then run: +Run these focused browser checks before the full collection suite: + +```sh +PAPERCLIP_COLLECTION_PERF_BASE_URL=http://127.0.0.1: \ + pnpm exec playwright test --config tests/perf/collections/playwright.config.ts \ + parent-context.spec.ts startup-regressions.spec.ts +``` + +To collect timings, build the pinned baseline production assets once and retain +that output. Build candidate UI assets only when their inputs change. Serve both +outputs from stable loopback origins. If server behavior changes, each origin +must proxy its matching pinned API revision against the same disposable database. +Using the candidate API for both arms proves only a UI comparison. Keep agents +and external providers inactive. Neither arm may migrate or reseed the shared +fixture during a comparison. + +The harness checks the configured API against each UI proxy, records HTML and +entry-asset SHA-256 receipts, and records both serving API revisions. Supply full +source SHAs to label the client artifacts, then run: ```sh INBOX_BASELINE_URL=http://127.0.0.1: \ INBOX_CANDIDATE_URL=http://127.0.0.1: \ +INBOX_BASELINE_API_URL=http://127.0.0.1: \ +INBOX_CANDIDATE_API_URL=http://127.0.0.1: \ +INBOX_BASELINE_SOURCE_REVISION= \ +INBOX_CANDIDATE_SOURCE_REVISION= \ node --import ./server/node_modules/tsx/dist/loader.mjs \ tests/perf/collections/measure-mine-context.ts ``` The script retains a four-parent, 30-child fixture, archives three completed -parents, and enables the personal Coordinator preference. It runs five alternating -pairs by default. `INBOX_PAIRS`, `INBOX_FIXTURE_FILE`, and `INBOX_OUTPUT` override -the count and evidence paths. Each cold sample starts with empty HTTP and query -caches. Warm Mine navigation and task-to-Mine Back retain those caches. The report -keeps every sample, errors, row membership/order, geometry, assets and API timings. -Keep upstream identity/data timings as controls: a faster cold total alone does -not attribute the gain to parent context. Use the same immutable asset cache policy -on both origins and stop builds and suites during timing runs. +parents, and enables the personal Coordinator preference. Both APIs must expose +that same company. It runs five alternating pairs by default. `INBOX_PAIRS`, +`INBOX_FIXTURE_FILE`, and `INBOX_OUTPUT` override the count and evidence paths. +No request interception is used in timings. +`INBOX_NETWORK_LATENCY_MS` accepts an integer from 0 to 2000 and defaults to 0 +(native loopback). A nonzero value uses Chromium CDP network emulation on every +page, with unlimited throughput and unchanged CPU. Record native and 100 ms +latency runs in separate `INBOX_OUTPUT` files; each sample labels its scenario. +CDP specifies minimum request-to-response-header latency. This controlled extra +round-trip scenario tests serial request dependencies; it does not prove hosted +latency or reproduce the full staging network. + +By default, `INBOX_HTTP_CACHE=warm` warms each browser context once, then reloads +the document for each initial sample. This retains HTTP cache while clearing app +query state. `INBOX_HTTP_CACHE=cold` uses fresh browser contexts; +`INBOX_HTTP_CACHE=both` records these scenarios separately. Warm Recent-to-Mine +navigation and task-to-Mine Back retain query state. Use `INBOX_HEADFUL=1` and +`PAPERCLIP_PLAYWRIGHT_CHANNEL=chrome` when installed Chrome is the intended browser. + +Initial readiness requires visible Mine rows plus two animation frames. The +report retains every sample and failure, exact row membership/order, row geometry, +asset identities, full API resource timings, request statuses and primary-request +start. Health, session, board access, companies and preferences remain timing +controls. A faster initial total alone does not attribute a gain to its final +context response. The fixed post-reveal window defaults to two seconds; +`INBOX_TAIL_MS` changes it, up to 30 seconds. Counts retain 429s and unfinished +requests rather than treating them as successful work. Use the same immutable +asset cache policy on both origins and stop builds and suites during timing runs. Startup cases also check that shared collection controls do not require the task-detail bundle before reveal, even when a different organization is remembered. diff --git a/tests/perf/collections/measure-mine-context.ts b/tests/perf/collections/measure-mine-context.ts index 36108383dee..c994b152893 100644 --- a/tests/perf/collections/measure-mine-context.ts +++ b/tests/perf/collections/measure-mine-context.ts @@ -1,8 +1,9 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { arch, loadavg, platform } from "node:os"; -import { chromium, expect, request, type APIRequestContext } from "@playwright/test"; +import { chromium, expect, request, type APIRequestContext, type BrowserContext, type Page, type Request } from "@playwright/test"; type Fixture = { company: { id: string; issuePrefix: string }; @@ -10,12 +11,13 @@ type Fixture = { children: Array<{ id: string }>; }; type Arm = "baseline" | "candidate"; +type Read = { path: string; view: string | null; ids: number; start: number; status?: number; end?: number; failed?: string }; declare global { interface Window { __mineContextMeasure: { first: number | null } } } function loopbackOrigin(value: string | undefined, name: string) { - assert.ok(value, `Set ${name} to a disposable local production UI origin.`); + assert.ok(value, `Set ${name} to a disposable local origin.`); const url = new URL(value); assert.ok(["http:", "https:"].includes(url.protocol) && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) @@ -23,13 +25,26 @@ function loopbackOrigin(value: string | undefined, name: string) { `${name} must be an exact loopback origin.`); return url.origin; } -const origins = { - baseline: loopbackOrigin(process.env.INBOX_BASELINE_URL, "INBOX_BASELINE_URL"), - candidate: loopbackOrigin(process.env.INBOX_CANDIDATE_URL, "INBOX_CANDIDATE_URL"), -}; +const arms = ["baseline", "candidate"] as const; +const origins = Object.fromEntries(arms.map(arm => [arm, + loopbackOrigin(process.env[`INBOX_${arm.toUpperCase()}_URL`], `INBOX_${arm.toUpperCase()}_URL`), +])) as Record; +const apiOrigins = Object.fromEntries(arms.map(arm => [arm, + loopbackOrigin(process.env[`INBOX_${arm.toUpperCase()}_API_URL`] ?? origins[arm], `INBOX_${arm.toUpperCase()}_API_URL`), +])) as Record; assert.notEqual(origins.baseline, origins.candidate, "Use distinct baseline and candidate UI origins."); const pairs = Number(process.env.INBOX_PAIRS ?? 5); +const tailMs = Number(process.env.INBOX_TAIL_MS ?? 2_000); +const latencyValue = process.env.INBOX_NETWORK_LATENCY_MS ?? "0"; +assert.match(latencyValue, /^\d+$/, "INBOX_NETWORK_LATENCY_MS must be an integer from 0 to 2000."); +const networkLatencyMs = Number(latencyValue); +assert.ok(Number.isSafeInteger(networkLatencyMs) && networkLatencyMs <= 2_000, "INBOX_NETWORK_LATENCY_MS must be an integer from 0 to 2000."); +const networkScenario = networkLatencyMs === 0 ? "native" : `latency-${networkLatencyMs}ms`; assert.ok(Number.isSafeInteger(pairs) && pairs > 0, "INBOX_PAIRS must be a positive integer."); +assert.ok(Number.isSafeInteger(tailMs) && tailMs >= 0 && tailMs <= 30_000, "INBOX_TAIL_MS must be between 0 and 30000."); +const cacheMode = process.env.INBOX_HTTP_CACHE ?? "warm"; +assert.ok(["warm", "cold", "both"].includes(cacheMode), "INBOX_HTTP_CACHE must be warm, cold, or both."); +const scenarios = cacheMode === "both" ? ["warm", "cold"] as const : [cacheMode as "warm" | "cold"]; const fixtureFile = process.env.INBOX_FIXTURE_FILE ?? "test-results/collections/mine-context-fixture.json"; const output = process.env.INBOX_OUTPUT ?? "test-results/collections/mine-context-timings.json"; assert.notEqual(path.resolve(fixtureFile), path.resolve(output), "Fixture and report paths must differ."); @@ -55,87 +70,170 @@ async function retainFixture(client: APIRequestContext): Promise { const row = await api(client, "POST", `/companies/${company.id}/issues`, { title, status, parentId }); return { id: row.id as string }; }; - for (let index = 0; index < 4; index++) { - fixture.parents.push(await create(`Parent ${index}`, index === 0 ? "blocked" : "done")); - } - for (let index = 0; index < 30; index++) { - fixture.children.push(await create(`Inbox child ${index}`, "todo", fixture.parents[index % 4]!.id)); - } + for (let index = 0; index < 4; index++) fixture.parents.push(await create(`Parent ${index}`, index === 0 ? "blocked" : "done")); + for (let index = 0; index < 30; index++) fixture.children.push(await create(`Inbox child ${index}`, "todo", fixture.parents[index % 4]!.id)); for (const parent of fixture.parents.slice(1)) await api(client, "POST", `/issues/${parent.id}/inbox-archive`, {}); await fs.mkdir(path.dirname(fixtureFile), { recursive: true }); await fs.writeFile(fixtureFile, JSON.stringify(fixture, null, 2)); return fixture; } -const clients = { - baseline: await request.newContext({ baseURL: origins.baseline }), - candidate: await request.newContext({ baseURL: origins.candidate }), -}; +async function artifactReceipt(client: APIRequestContext, apiClient: APIRequestContext, arm: Arm) { + const response = await client.get("/"); + assert.ok(response.ok(), `${arm} UI is unavailable.`); + const html = await response.text(); + const scripts = [...html.matchAll(/]*\bsrc=["'](\/assets\/[^"']+\.js)["']/g)].map(match => match[1]!); + assert.ok(scripts.length && !html.includes("/@vite/client"), `${arm} must serve a production UI build.`); + const assets = []; + for (const assetPath of scripts) { + const asset = await client.get(assetPath); + assert.ok(asset.ok(), `${arm} entry asset is unavailable.`); + assert.match(asset.headers()["cache-control"] ?? "", /immutable/, `${arm} assets must use the shipped immutable cache policy.`); + const body = await asset.body(); + assets.push({ path: assetPath, bytes: body.length, sha256: createHash("sha256").update(body).digest("hex") }); + } + const health = await api(apiClient, "GET", "/health"); + const proxiedHealth = await api(client, "GET", "/health"); + assert.equal(proxiedHealth.commit, health.commit, `${arm} UI proxy and configured API serve different revisions.`); + const declaredRevision = process.env[`INBOX_${arm.toUpperCase()}_SOURCE_REVISION`]; + if (declaredRevision) assert.match(declaredRevision, /^[a-f0-9]{40}$/, "Source revisions must be full Git SHAs."); + return { declaredRevision: declaredRevision ?? null, htmlSha256: createHash("sha256").update(html).digest("hex"), assets, + server: { commit: health.commit, version: health.version, deploymentMode: health.deploymentMode } }; +} + +function recordPage(page: Page, origin: string) { + const pending = new WeakMap(); + let reads: Read[] = []; + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + page.on("request", request => { + const url = new URL(request.url()); + if (url.origin !== origin || !url.pathname.startsWith("/api/")) return; + const started = request.timing().startTime; + const read = { path: url.pathname, view: url.searchParams.get("view"), + ids: url.searchParams.get("ids")?.split(",").length ?? 0, start: started >= 0 ? started : Date.now() }; + reads.push(read); + pending.set(request, read); + }); + page.on("response", response => { + const read = pending.get(response.request()); + if (read) read.status = response.status(); + }); + page.on("requestfinished", request => { + const read = pending.get(request); + if (!read) return; + const timing = request.timing(); + if (timing.startTime >= 0) read.start = timing.startTime; + if (timing.responseEnd >= 0) read.end = read.start + timing.responseEnd; + }); + page.on("requestfailed", request => { + const read = pending.get(request); + if (read) read.failed = request.failure()?.errorText ?? "failed"; + }); + return { reset() { reads = []; errors.length = 0; }, reads: () => reads, errors }; +} +async function preparePage(context: BrowserContext, origin: string) { + const page = await context.newPage(); + const networkSession = networkLatencyMs > 0 ? await context.newCDPSession(page) : undefined; + if (networkSession) { + await networkSession.send("Network.enable"); + await networkSession.send("Network.emulateNetworkConditions", { offline: false, latency: networkLatencyMs, + downloadThroughput: -1, uploadThroughput: -1, connectionType: "other" }); + } + const tracker = recordPage(page, origin); + await page.addInitScript(() => { + window.__mineContextMeasure = { first: null }; + const observer = new MutationObserver(() => { + if (location.pathname.endsWith("/inbox/mine") && document.querySelector("[data-inbox-item]")) { + observer.disconnect(); + requestAnimationFrame(() => requestAnimationFrame(() => { + window.__mineContextMeasure.first = performance.now(); + })); + } + }); + observer.observe(document, { subtree: true, childList: true }); + }); + return { page, tracker, networkSession }; +} +const frames = (page: Page) => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + +const uiClients = Object.fromEntries(await Promise.all(arms.map(async arm => [arm, await request.newContext({ baseURL: origins[arm], extraHTTPHeaders: { Origin: origins[arm] } })]))) as Record; +const apiClients = Object.fromEntries(await Promise.all(arms.map(async arm => [arm, await request.newContext({ baseURL: apiOrigins[arm], extraHTTPHeaders: { Origin: apiOrigins[arm] } })]))) as Record; const samples: Array> = []; +const artifacts: Partial>>> = {}; let expectedRowIds: Array | undefined; let fixture: Fixture | undefined; let browser: Awaited> | undefined; +let runError: string | undefined; +const warmPages: Partial> & { context: BrowserContext }>> = {}; const save = async () => { await fs.mkdir(path.dirname(output), { recursive: true }); - await fs.writeFile(output, JSON.stringify({ - pairs, fixture, origins, rowSequenceReference: expectedRowIds, - settings: { viewport: "1440x900", httpCache: "fresh context", serviceWorkers: "blocked", - queryCache: "empty for cold, retained for warm and Back", cpu: "native", network: "loopback", - readiness: "cold: browser clock to first Mine row; warm/Back: host clock around browser actions", - node: process.version, platform: platform(), arch: arch(), browser: browser?.version() }, - samples, + await fs.writeFile(output, JSON.stringify({ pairs, fixture, origins, apiOrigins, artifacts, rowSequenceReference: expectedRowIds, + completed: !runError && samples.length === pairs * scenarios.length * 2, error: runError, + settings: { viewport: "1440x900", httpCache: cacheMode, serviceWorkers: "blocked", tailMs, + queryCache: "new document for every initial sample, retained for warm navigation and Back", serverCache: "retained, not reset", cpu: "native", network: "loopback", + networkScenario, networkLatencyMs, networkEmulation: networkLatencyMs === 0 ? "none" : "Chromium CDP minimum request-to-response-header latency; unlimited throughput; no request interception", + readiness: "initial: browser clock to Mine rows plus two animation frames; warm/Back: host clock around browser actions and two frames", + node: process.version, platform: platform(), arch: arch(), browser: browser?.version() }, samples, }, null, 2)); }; try { - for (const arm of ["baseline", "candidate"] as const) { - const response = await clients[arm].get("/"); - assert.ok(response.ok(), `${arm} UI is unavailable.`); - const html = await response.text(); - assert.ok(/src=["']\/assets\/[^"']+\.js["']/.test(html) && !html.includes("/@vite/client"), - `${arm} must serve a production UI build.`); + for (const arm of arms) artifacts[arm] = await artifactReceipt(uiClients[arm], apiClients[arm], arm); + fixture = await retainFixture(apiClients.candidate); + for (const arm of arms) await api(apiClients[arm], "GET", `/companies/${fixture.company.id}`); + await api(apiClients.candidate, "PUT", `/companies/${fixture.company.id}/workspace-preferences/me`, { coordinatorWorkspaceEnabled: true }); + browser = await chromium.launch({ headless: process.env.INBOX_HEADFUL !== "1", channel: process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL }); + if (scenarios.includes("warm")) for (const arm of arms) { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" }); + const prepared = await preparePage(context, origins[arm]); + warmPages[arm] = { ...prepared, context }; + await prepared.page.goto(`${origins[arm]}/${fixture.company.issuePrefix}/inbox/mine`); + await expect(prepared.page.locator("[data-inbox-item]").first()).toBeVisible(); + await prepared.page.waitForLoadState("networkidle"); } - fixture = await retainFixture(clients.candidate); - for (const arm of ["baseline", "candidate"] as const) await api(clients[arm], "GET", `/companies/${fixture.company.id}`); - await api(clients.candidate, "PUT", `/companies/${fixture.company.id}/workspace-preferences/me`, { coordinatorWorkspaceEnabled: true }); - browser = await chromium.launch(); - for (let pair = 0; pair < pairs; pair++) { - const order: Arm[] = pair % 2 ? ["candidate", "baseline"] : ["baseline", "candidate"]; - for (const arm of order) { - const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" }); - const page = await context.newPage(); - const errors: string[] = []; - const reads: Array<{ view: string | null; ids: number }> = []; + for (const httpCache of scenarios) for (let pair = 0; pair < pairs; pair++) { + for (const arm of pair % 2 ? ["candidate", "baseline"] as const : arms) { + const retained = warmPages[arm]; + const context: BrowserContext = httpCache === "warm" ? retained!.context : await browser.newContext({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" }); + const prepared: Awaited> = httpCache === "warm" ? retained! : await preparePage(context, origins[arm]); + const { page, tracker } = prepared; let rowIds: Array | undefined; - page.on("pageerror", error => errors.push(error.message)); - page.on("request", request => { - const url = new URL(request.url()); - if (url.pathname.endsWith("/issues")) reads.push({ view: url.searchParams.get("view"), ids: url.searchParams.get("ids")?.split(",").length ?? 0 }); - }); try { - await page.addInitScript(() => { - window.__mineContextMeasure = { first: null }; - const observer = new MutationObserver(() => { - if (window.__mineContextMeasure.first === null && location.pathname.endsWith("/inbox/mine") && document.querySelector("[data-inbox-item]")) { - window.__mineContextMeasure.first = performance.now(); - observer.disconnect(); - } - }); - observer.observe(document, { subtree: true, childList: true }); - }); - await page.goto(`${origins[arm]}/${fixture.company.issuePrefix}/inbox/mine`); + await page.bringToFront(); + tracker.reset(); + if (httpCache === "warm") await page.reload(); + else await page.goto(`${origins[arm]}/${fixture.company.issuePrefix}/inbox/mine`); const rows = page.locator("[data-inbox-item]"); await expect(rows.first()).toBeVisible(); - const cold = await page.evaluate(() => window.__mineContextMeasure.first); - assert.ok(cold !== null, "Cold readiness observer did not see a row."); + await page.waitForFunction(() => window.__mineContextMeasure.first !== null); + const { initial, timeOrigin } = await page.evaluate(() => ({ initial: window.__mineContextMeasure.first!, timeOrigin: performance.timeOrigin })); rowIds = await page.locator("[data-inbox-issue-link]").evaluateAll(nodes => nodes.map(node => node.getAttribute("href"))); + const node = await rows.first().elementHandle(); const firstBox = await rows.first().boundingBox(); + await page.waitForTimeout(tailMs); + const tailEnd = await page.evaluate(() => performance.now()); + const tailBox = await rows.first().boundingBox(); + assert.ok(await node!.evaluate(element => element.isConnected), "Initial rows were replaced during the tail."); + assert.ok(Math.abs(firstBox!.y - tailBox!.y) < 1 && Math.abs(firstBox!.height - tailBox!.height) < 1, "Initial row geometry changed during the tail."); + const initialReads = tracker.reads().filter(read => read.start >= timeOrigin).map(read => ({ ...read, start: read.start - timeOrigin, + end: read.end === undefined ? null : read.end - timeOrigin, beforeReveal: read.start - timeOrigin <= initial })); + const resources = await page.evaluate(() => performance.getEntriesByType("resource").map(entry => { + const resource = entry as PerformanceResourceTiming; + const url = new URL(resource.name); + return { path: url.pathname, start: resource.startTime, ms: resource.duration, end: resource.responseEnd, + transferBytes: resource.transferSize, encodedBytes: resource.encodedBodySize }; + }).filter(resource => resource.path.startsWith("/api/") || resource.path.startsWith("/assets/"))); + const milestones = Object.fromEntries(["/api/health", "/api/auth/get-session", "/api/cli-auth/me", "/api/companies", "/api/workspace-preferences/me"].map(path => [path, resources.find(resource => resource.path === path) ?? null])); + const primary = initialReads.find(read => read.path.endsWith("/inbox/mine") || (read.path.endsWith("/issues") && read.view === "index" && read.ids === 0)); await page.getByRole("tab", { name: "Recent", exact: true }).click(); await expect(page.getByRole("tab", { name: "Recent", exact: true })).toHaveAttribute("aria-selected", "true"); await expect(rows.first()).toBeVisible(); + await frames(page); const start = performance.now(); await page.getByRole("tab", { name: "Mine", exact: true }).click(); await expect(page.getByRole("tab", { name: "Mine", exact: true })).toHaveAttribute("aria-selected", "true"); await expect(rows.first()).toBeVisible(); + await frames(page); const warm = performance.now() - start; await page.locator("[data-inbox-issue-link]").first().click(); await expect(page.getByTestId("issue-detail-header")).toBeVisible(); @@ -143,28 +241,31 @@ try { await page.goBack(); await expect(page.getByRole("tab", { name: "Mine", exact: true })).toHaveAttribute("aria-selected", "true"); await expect(rows.first()).toBeVisible(); + await frames(page); const back = performance.now() - backStart; - const resources = await page.evaluate(() => performance.getEntriesByType("resource").map(entry => { - const resource = entry as PerformanceResourceTiming; - const url = new URL(resource.name); - return { path: url.pathname, start: resource.startTime, ms: resource.duration, end: resource.responseEnd }; - }).filter(resource => resource.path.startsWith("/api/") || resource.path.startsWith("/assets/"))); - assert.deepEqual(errors, [], "The page reported JavaScript errors."); + assert.deepEqual(tracker.errors, [], "The page reported JavaScript errors."); expectedRowIds ??= rowIds; assert.deepEqual(rowIds, expectedRowIds, "Mine row order or membership changed between samples."); - samples.push({ pair, arm, status: "passed", cold, warm, back, rowIds, rowSequenceEquivalent: true, firstBox, reads, resources, errors, load: loadavg() }); - console.log(JSON.stringify({ pair, arm, cold, warm, back, rows: rowIds.length, errors })); + samples.push({ pair, arm, httpCache, networkScenario, networkLatencyMs, status: "passed", initial, warm, back, primary, milestones, tailEnd, rowIds, + rowSequenceEquivalent: true, firstBox, tailBox, initialReads, resources, + counts: { beforeReveal: initialReads.filter(read => read.beforeReveal).length, throughTail: initialReads.length, + throttled: initialReads.filter(read => read.status === 429).length, unfinished: initialReads.filter(read => read.end === null && !read.failed).length }, + errors: [...tracker.errors], load: loadavg() }); + console.log(JSON.stringify({ pair, arm, httpCache, networkScenario, networkLatencyMs, initial, warm, back, primaryStart: primary?.start, rows: rowIds.length })); } catch (error) { - samples.push({ pair, arm, status: "failed", error: error instanceof Error ? error.message : String(error), rowIds, errors, reads, load: loadavg() }); + samples.push({ pair, arm, httpCache, networkScenario, networkLatencyMs, status: "failed", error: error instanceof Error ? error.message : String(error), rowIds, errors: [...tracker.errors], reads: tracker.reads(), load: loadavg() }); throw error; } finally { await save(); - await context.close(); + if (httpCache === "cold") await context.close(); } } } +} catch (error) { + runError = error instanceof Error ? error.message : String(error); + throw error; } finally { await save(); await browser?.close(); - await Promise.all(Object.values(clients).map(client => client.dispose())); + await Promise.all([...Object.values(uiClients), ...Object.values(apiClients)].map(client => client.dispose())); } diff --git a/tests/perf/collections/parent-context.spec.ts b/tests/perf/collections/parent-context.spec.ts index a7065d340da..f51f8f65c46 100644 --- a/tests/perf/collections/parent-context.spec.ts +++ b/tests/perf/collections/parent-context.spec.ts @@ -8,7 +8,7 @@ test.afterEach(async ({ request }) => { companyId = undefined; }); -test("Mine waits for missing parent metadata without loading parent diagnostics", async ({ page, request }) => { +test("Mine reveals assembled parent metadata before loading visible diagnostics", async ({ page, request }) => { const created = await request.post("/api/companies", { data: { name: "Parent context browser check" } }); expect(created.ok(), await created.text()).toBeTruthy(); const company = await created.json() as { id: string; issuePrefix: string }; @@ -30,9 +30,7 @@ test("Mine waits for missing parent metadata without loading parent diagnostics" expect(archived.ok(), await archived.text()).toBeTruthy(); const reads: Array<{ projection: string | null; ids: string[] }> = []; - const contextRead = (url: URL) => url.pathname === `/api/companies/${company.id}/issues` - && url.searchParams.get("view") === "index" - && url.searchParams.get("ids")?.split(",").includes(missing.id) === true; + const sourceRead = (url: URL) => url.pathname === `/api/companies/${company.id}/inbox/mine`; page.on("request", request => { const url = new URL(request.url()); if (url.pathname === `/api/companies/${company.id}/issues` && url.searchParams.has("ids")) { @@ -47,14 +45,13 @@ test("Mine waits for missing parent metadata without loading parent diagnostics" && url.searchParams.get("view") === "rows" && url.searchParams.get("ids")?.split(",").includes(second.id) === true; await page.route(detailRead, async route => { await detailsHeld; await route.continue(); }); - await page.route(contextRead, async route => { await held; await route.continue(); }); - const parentRequest = page.waitForRequest(request => contextRead(new URL(request.url()))); + await page.route(sourceRead, async route => { await held; await route.continue(); }); + const sourceRequest = page.waitForRequest(request => sourceRead(new URL(request.url()))); try { await page.goto(`/${company.issuePrefix}/inbox/mine`); - await parentRequest; + await sourceRequest; expect(await page.locator("[data-inbox-item]").count()).toBe(0); - expect(reads.some(read => read.projection === "rows")).toBe(false); - expect(reads.some(read => read.ids.includes(loaded.id))).toBe(false); + expect(reads).toEqual([]); } finally { release(); } const child = page.locator(`[data-inbox-issue-link][href$="/${second.identifier}"]`); @@ -70,7 +67,7 @@ test("Mine waits for missing parent metadata without loading parent diagnostics" await (await detailsResponse).finished(); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await expect(page.locator(`[data-inbox-issue-link][href$="/${first.identifier}"]`)).toBeVisible(); - expect(reads.filter(read => read.ids.includes(missing.id)).every(read => read.projection === "index")).toBe(true); + expect(reads.some(read => read.ids.includes(missing.id))).toBe(false); expect(await node!.evaluate(element => element.isConnected)).toBe(true); const after = await row.boundingBox(); expect(Math.abs(before!.height - after!.height)).toBeLessThan(1); diff --git a/tests/perf/collections/playwright.config.ts b/tests/perf/collections/playwright.config.ts index 0e9c9c11518..828919e2d10 100644 --- a/tests/perf/collections/playwright.config.ts +++ b/tests/perf/collections/playwright.config.ts @@ -8,6 +8,6 @@ if (!["http:", "https:"].includes(target.protocol) || !["127.0.0.1", "localhost" } export default defineConfig({ testDir: ".", workers: 1, fullyParallel: false, timeout: 120_000, - use: { baseURL, browserName: "chromium", headless: true, viewport: { width: 1440, height: 900 }, serviceWorkers: "block", trace: "retain-on-failure" }, + use: { baseURL, extraHTTPHeaders: { Origin: target.origin }, browserName: "chromium", headless: true, viewport: { width: 1440, height: 900 }, serviceWorkers: "block", trace: "retain-on-failure" }, outputDir: "../../../test-results/collections/playwright", reporter: [["list"]], }); diff --git a/tests/perf/collections/startup-regressions.spec.ts b/tests/perf/collections/startup-regressions.spec.ts new file mode 100644 index 00000000000..eb147abdeff --- /dev/null +++ b/tests/perf/collections/startup-regressions.spec.ts @@ -0,0 +1,232 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; +import type { CompactIssue, HeartbeatRun, InboxMineSnapshot } from "../../../packages/shared/src/index.js"; + +type Company = { id: string; issuePrefix: string; [key: string]: unknown }; +type Task = { id: string; identifier: string; title: string }; +let company: Company; +let otherCompany: Company; +let child: Task; +let failureTask: Task; +const userId = "local-board"; + +async function json(request: APIRequestContext, method: string, url: string, data?: unknown): Promise { + const response = await request.fetch(url, { method, data }); + expect(response.ok(), `${method} ${url}: ${response.status()}`).toBeTruthy(); + return response.status() === 204 ? undefined as T : response.json(); +} +const minePath = () => `/api/companies/${company.id}/inbox/mine`; +const childLink = (page: Page) => page.locator(`[data-inbox-issue-link][href$="/${child.identifier}"]`); +const twoFrames = (page: Page) => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + +test.beforeAll(async ({ request }) => { + company = await json(request, "POST", "/api/companies", { name: "Inbox startup regressions" }); + otherCompany = await json(request, "POST", "/api/companies", { name: "Other inbox startup scope" }); + await json(request, "PUT", `/api/companies/${company.id}/workspace-preferences/me`, { coordinatorWorkspaceEnabled: true }); + const parent = await json(request, "POST", `/api/companies/${company.id}/issues`, { title: "Archived required parent", status: "done" }); + child = await json(request, "POST", `/api/companies/${company.id}/issues`, { title: "Inbox startup child", status: "todo", parentId: parent.id }); + failureTask = await json(request, "POST", `/api/companies/${company.id}/issues`, { title: "Saved failed attempt task", status: "done" }); + for (const task of [parent, failureTask]) await json(request, "POST", `/api/issues/${task.id}/inbox-archive`, {}); +}); +test.afterAll(async ({ request }) => { + for (const value of [company, otherCompany]) if (value) { + const response = await request.delete(`/api/companies/${value.id}`); + expect(response.ok(), `Fixture cleanup: ${response.status()}`).toBeTruthy(); + } +}); + +// This isolates the authenticated browser bootstrap contract. Backend access is +// still the disposable local board; it does not qualify a hosted login. +for (const bootstrap of ["embedded", "absent", "wrong-user"] as const) { + test(`authenticated startup uses ${bootstrap} session bootstrap correctly`, async ({ page }) => { + const reads: URL[] = []; + const isLegacyBootstrap = (url: URL) => url.pathname === "/api/companies" || url.pathname === "/api/cli-auth/me"; + let legacyResponses = 0; + page.on("request", request => { + const url = new URL(request.url()); + if (url.pathname.startsWith("/api/")) reads.push(url); + }); + page.on("response", response => { if (isLegacyBootstrap(new URL(response.url()))) legacyResponses += 1; }); + await page.route("**/api/health", async route => { + const response = await route.fetch(); + await route.fulfill({ json: { ...await response.json(), deploymentMode: "authenticated", bootstrapStatus: "ready" } }); + }); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let releaseLegacy!: () => void; + const legacyHeld = new Promise(resolve => { releaseLegacy = resolve; }); + if (bootstrap === "embedded") await page.route(isLegacyBootstrap, async route => { + await legacyHeld; + await route.continue(); + }); + await page.route("**/api/auth/get-session", async route => { + await held; + await route.fulfill({ json: { + user: { id: userId, name: "Local operator", email: "operator@example.test", image: null }, + session: { id: "simulated-local-session", userId }, sentryDsn: null, + ...(bootstrap === "absent" ? {} : { boardBootstrap: { + userId: bootstrap === "wrong-user" ? "previous-user" : userId, + companies: bootstrap === "wrong-user" ? [otherCompany] : [company], + boardAccess: { userId: bootstrap === "wrong-user" ? "previous-user" : userId, + isInstanceAdmin: false, companyIds: bootstrap === "wrong-user" ? [otherCompany.id] : [company.id] }, + } }), + } }); + }); + const sessionRequest = page.waitForRequest(request => new URL(request.url()).pathname === "/api/auth/get-session"); + try { + try { + await page.goto(`/${company.issuePrefix}/inbox/mine`); + await sessionRequest; + expect(await page.locator("[data-inbox-item]").count()).toBe(0); + expect(reads.some(url => url.pathname === minePath())).toBe(false); + } finally { release(); } + await expect(childLink(page)).toBeVisible(); + const legacy = reads.filter(isLegacyBootstrap); + // Initial WS gap reconciliation may start legacy reads in the background. + // Bootstrap must reveal Mine without waiting for their held responses. + if (bootstrap === "embedded") expect(legacyResponses).toBe(0); + else { + expect(legacy.some(url => url.pathname === "/api/companies")).toBe(true); + expect(legacy.some(url => url.pathname === "/api/cli-auth/me")).toBe(true); + } + expect(reads.some(url => url.pathname === minePath())).toBe(true); + expect(reads.some(url => url.pathname.startsWith(`/api/companies/${otherCompany.id}/`))).toBe(false); + expect(reads.filter(url => url.pathname === "/api/auth/get-session")).toHaveLength(1); + await test.info().attach("authenticated-startup-readiness", { contentType: "application/json", body: Buffer.from(JSON.stringify({ + bootstrap, legacyRequestsAtRows: legacy.map(url => url.pathname), legacyResponsesAtRows: legacyResponses, + mineStarted: true, sessionRequestsAtRows: reads.filter(url => url.pathname === "/api/auth/get-session").length, + })) }); + } finally { release(); releaseLegacy(); } + }); +} + +function failedRun(): HeartbeatRun { + const now = new Date(); + return { + id: "browser-failed-attempt", issueId: failureTask.id, scopeKind: "issue", companyId: company.id, + agentId: "browser-fixture-agent", responsibleUserId: userId, invocationSource: "assignment", triggerDetail: null, + status: "failed", error: "Synthetic attempt stopped", wakeupRequestId: null, exitCode: null, signal: null, + usageJson: null, resultJson: null, sessionIdBefore: null, sessionIdAfter: null, logStore: null, logRef: null, + logBytes: null, logSha256: null, logCompressed: false, stdoutExcerpt: null, stderrExcerpt: null, errorCode: null, + externalRunId: null, processPid: null, processGroupId: null, processStartedAt: null, lastOutputAt: null, + lastOutputSeq: 0, lastOutputStream: null, lastOutputBytes: null, retryOfRunId: null, processLossRetryCount: 0, + livenessState: null, livenessReason: null, continuationAttempt: 0, lastUsefulActionAt: null, nextAction: null, + contextSnapshot: null, startedAt: now, finishedAt: now, createdAt: now, updatedAt: now, + }; +} + +for (const failure of ["failureError", "contextError"] as const) { + test(`partial ${failure} keeps rows readable and retries the assembled source`, async ({ page, request }) => { + const source = await json(request, "GET", minePath()); + const failureRows = await json(request, "GET", `/api/companies/${company.id}/issues?view=rows&includeRoutineExecutions=true&limit=1&ids=${failureTask.id}`); + expect(failureRows).toHaveLength(1); + const run = failedRun(); + const resolved: CompactIssue = { ...failureRows[0]!, interruption: { + intentId: "browser-saved-input", responsibility: "system", state: "resolved", nextRetryAt: null, + canRetry: false, userAction: null, sourceRunIds: [run.id], observedRunId: run.id, + } }; + let attempts = 0; + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + await page.route(url => url.pathname === minePath(), async route => { + attempts += 1; + if (attempts === 1) { + await route.fulfill({ json: { ...source, contextIssues: failure === "contextError" ? [] : source.contextIssues, + failureRuns: [], failureIssueIds: [], + [failure]: { status: 503, message: "Optional source temporarily unavailable" } } }); + } else { + await held; + await route.fulfill({ json: { ...source, contextIssues: [...source.contextIssues, resolved], + failureRuns: [run], failureIssueIds: [failureTask.id] } }); + } + }); + try { + await page.goto(`/${company.issuePrefix}/inbox/mine`); + await expect(childLink(page)).toBeVisible(); + await expect(page.getByText("Some inbox items couldn’t load. Showing available items.", { exact: true })).toBeVisible(); + const node = await childLink(page).elementHandle(); + const before = await childLink(page).boundingBox(); + await page.getByRole("button", { name: "Retry missing items", exact: true }).click(); + await expect.poll(() => attempts).toBe(2); + await expect(childLink(page)).toBeVisible(); + expect(await node!.evaluate(element => element.isConnected)).toBe(true); + release(); + await expect(page.getByText("Some inbox items couldn’t load. Showing available items.", { exact: true })).toHaveCount(0); + await twoFrames(page); + expect(await node!.evaluate(element => element.isConnected)).toBe(true); + const after = await childLink(page).boundingBox(); + expect(Math.abs(before!.y - after!.y)).toBeLessThan(1); + expect(Math.abs(before!.height - after!.height)).toBeLessThan(1); + await expect(page.getByText("Couldn’t finish this attempt. Your message is saved.", { exact: true })).toHaveCount(0); + await expect(page.getByText(failureTask.title, { exact: true })).toHaveCount(0); + } finally { release(); } + }); +} + +test("an initial Mine 429 has a scoped retry and does not take the app offline", async ({ page }) => { + let busy = true; + await page.route(url => url.pathname === minePath(), async route => { + if (busy) await route.fulfill({ status: 429, headers: { "Retry-After": "1" }, json: { error: "Inbox source is busy" } }); + else await route.continue(); + }); + await page.goto(`/${company.issuePrefix}/inbox/mine`); + // Keep the inherited 429 backoff and Retry-After policy. The explicit Retry + // action appears after that bounded automatic retry window is exhausted. + await expect(page.getByText("Unable to load the inbox: Inbox source is busy", { exact: true })).toBeVisible({ timeout: 45_000 }); + await expect(page.getByText("Reconnecting to Paperclip", { exact: true })).toHaveCount(0); + busy = false; + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(childLink(page)).toBeVisible(); +}); + +test("a background Mine 429 preserves readable rows and a retry does not duplicate them", async ({ page, request }) => { + let busy = false; + let busyReads = 0; + await page.route(url => url.pathname === minePath(), async route => { + if (busy) { + busyReads += 1; + await route.fulfill({ status: 429, headers: { "Retry-After": "1" }, json: { error: "Inbox source is busy" } }); + } else await route.continue(); + }); + await page.goto(`/${company.issuePrefix}/inbox/mine`); + await expect(childLink(page)).toBeVisible(); + const node = await childLink(page).elementHandle(); + busy = true; + await json(request, "PATCH", `/api/issues/${child.id}`, { title: "Inbox startup child updated" }); + await expect.poll(() => busyReads).toBeGreaterThan(0); + await expect(childLink(page)).toBeVisible(); + expect(await node!.evaluate(element => element.isConnected)).toBe(true); + await expect(page.getByText("Reconnecting to Paperclip", { exact: true })).toHaveCount(0); + const retry = page.getByRole("button", { name: "Retry missing items", exact: true }); + await expect(retry).toBeVisible({ timeout: 45_000 }); + await expect(childLink(page)).toBeVisible(); + expect(await node!.evaluate(element => element.isConnected)).toBe(true); + busy = false; + const retried = page.waitForResponse(response => new URL(response.url()).pathname === minePath() + && response.request().method() === "GET"); + await retry.click(); + expect((await retried).ok()).toBeTruthy(); + await expect(childLink(page)).toHaveCount(1); + await expect(childLink(page)).toBeVisible(); +}); + +test("Mine archive and Undo persist through the assembled source and reload", async ({ page, request }) => { + await page.goto(`/${company.issuePrefix}/inbox/mine`); + const link = childLink(page); + await expect(link).toBeVisible(); + const row = page.locator("[data-inbox-item]").filter({ has: link }); + const archived = page.waitForResponse(response => new URL(response.url()).pathname === `/api/issues/${child.id}/inbox-archive` && response.request().method() === "POST"); + await row.getByRole("button", { name: "Archive", exact: true }).click(); + expect((await archived).ok()).toBeTruthy(); + await expect(link).toHaveCount(0); + const afterArchive = await json(request, "GET", minePath()); + expect(afterArchive.issues.some(issue => issue.id === child.id)).toBe(false); + const unarchived = page.waitForResponse(response => new URL(response.url()).pathname === `/api/issues/${child.id}/inbox-archive` + && response.request().method() === "DELETE"); + await page.keyboard.press("u"); + expect((await unarchived).ok()).toBeTruthy(); + await expect(link).toBeVisible(); + const restored = await json(request, "GET", minePath()); + expect(restored.issues.some(issue => issue.id === child.id)).toBe(true); + await page.reload(); + await expect(link).toBeVisible(); +}); diff --git a/tests/perf/collections/startup.spec.ts b/tests/perf/collections/startup.spec.ts index fd73d699694..ff9ca8eb8c8 100644 --- a/tests/perf/collections/startup.spec.ts +++ b/tests/perf/collections/startup.spec.ts @@ -29,14 +29,17 @@ for (const route of ["issues", "inbox/mine"]) test(`${route} leaves task-detail }); let release!: () => void; const held = new Promise(resolve => { release = resolve; }); - const isIndex = (url: URL) => url.pathname === `/api/companies/${fixture.company.id}/issues` && url.searchParams.get("view") === "index"; - await page.route(isIndex, async request => { await held; await request.continue(); }); - const indexRequest = page.waitForRequest(request => isIndex(new URL(request.url()))); + const isCollectionSource = (url: URL) => route === "inbox/mine" + ? url.pathname === `/api/companies/${fixture.company.id}/inbox/mine` + : url.pathname === `/api/companies/${fixture.company.id}/issues` && url.searchParams.get("view") === "index"; + await page.route(isCollectionSource, async request => { await held; await request.continue(); }); + const sourceRequest = page.waitForRequest(request => isCollectionSource(new URL(request.url()))); const warmed = page.waitForResponse(response => /\/assets\/issue-detail-[^/]+\.js$/.test(response.url())); try { await page.goto(`/${fixture.company.issuePrefix}/${route}`); - await indexRequest; + await sourceRequest; expect(detailReads).toEqual([]); + await expect(page.getByRole("main").locator("[data-inbox-issue-link]")).toHaveCount(0); } finally { release(); } const first = page.getByRole("main").locator("[data-inbox-issue-link]").first(); await expect(first).toBeVisible(); @@ -111,15 +114,14 @@ test("direct task opens preload their code while identity is still loading", asy await expect(page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]')).toBeEditable(); }); -test("Coordinator Inbox retains index reads when Streamlined UI is disabled", async ({ page }) => { +test("Coordinator Inbox retains aggregate Mine reads when Streamlined UI is disabled", async ({ page }) => { await coordinatorPreference(page, false); const reads: URL[] = []; page.on("request", request => { if (request.url().includes("/api/")) reads.push(new URL(request.url())); }); await page.goto(`/${fixture.company.issuePrefix}/inbox/mine`); await expect(page.locator("[data-inbox-issue-link]").first()).toBeVisible(); - const tasks = reads.filter(url => /\/companies\/[^/]+\/issues$/.test(url.pathname) && url.searchParams.get("view") !== "rows"); - expect(tasks).toHaveLength(1); - expect(tasks[0].searchParams.get("view")).toBe("index"); + expect(reads.filter(url => url.pathname === `/api/companies/${fixture.company.id}/inbox/mine`)).toHaveLength(1); + expect(reads.filter(url => /\/companies\/[^/]+\/issues$/.test(url.pathname) && url.searchParams.get("view") !== "rows")).toEqual([]); expect(reads.filter(url => url.pathname.endsWith("/dashboard"))).toEqual([]); expect(reads.filter(url => url.pathname.endsWith("/attention") && url.searchParams.get("all") === "true")).toEqual([]); }); diff --git a/ui/src/api/auth.test.ts b/ui/src/api/auth.test.ts index 0b2a760baf9..2903b06f9cd 100644 --- a/ui/src/api/auth.test.ts +++ b/ui/src/api/auth.test.ts @@ -11,6 +11,26 @@ afterEach(() => { }); describe("authApi.getSession", () => { + it("keeps a valid session when optional bootstrap is malformed or belongs to another account", async () => { + const session = { session: { id: "session", userId: "one" }, + user: { id: "one", email: "one@example.com", name: "One", image: null }, sentryDsn: null }; + const fetchMock = vi.fn().mockResolvedValue(Response.json({ ...session, boardBootstrap: { + userId: "two", companies: [], boardAccess: { userId: "two", isInstanceAdmin: true, companyIds: ["foreign"] }, + } })); + vi.stubGlobal("fetch", fetchMock); + expect(await authApi.getSession()).toEqual(session); + fetchMock.mockResolvedValue(Response.json({ ...session, boardBootstrap: { userId: "one", companies: "invalid" } })); + expect(await authApi.getSession()).toEqual(session); + }); + + it("retains a same-account bootstrap alongside the ordinary session", async () => { + const session = { session: { id: "session", userId: "one" }, + user: { id: "one", email: "one@example.com", name: "One", image: null }, sentryDsn: null }; + const boardBootstrap = { userId: "one", companies: [], boardAccess: { userId: "one", isInstanceAdmin: false, companyIds: [] } }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ ...session, boardBootstrap }))); + expect(await authApi.getSession()).toEqual({ ...session, boardBootstrap }); + }); + it("returns null for an ordinary local 401", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ error: "unauthorized" }), { diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts index d7ecc1d9086..1f1f3ec1f83 100644 --- a/ui/src/api/auth.ts +++ b/ui/src/api/auth.ts @@ -1,7 +1,8 @@ import { authSessionSchema, currentUserProfileSchema, - type AuthSession, + type BoardBootstrapSession, + readBoardBootstrap, type CurrentUserProfile, type UpdateCurrentUserProfile, } from "@paperclipai/shared"; @@ -38,13 +39,15 @@ export class AuthApiError extends Error { } } -function toSession(value: unknown): AuthSession | null { +function toSession(value: unknown): BoardBootstrapSession | null { const direct = authSessionSchema.safeParse(value); - if (direct.success) return direct.data; + if (direct.success) { + const boardBootstrap = readBoardBootstrap((value as Record).boardBootstrap, direct.data.user.id); + return boardBootstrap ? { ...direct.data, boardBootstrap } : direct.data; + } if (!value || typeof value !== "object") return null; - const nested = authSessionSchema.safeParse((value as Record).data); - return nested.success ? nested.data : null; + return toSession((value as Record).data); } /** The server's own explanation on an auth error body, if it sent one. */ @@ -160,7 +163,7 @@ async function authPatch(path: string, body: Record, parse: } export const authApi = { - getSession: async (): Promise => { + getSession: async (): Promise => { const res = await apiFetch("/api/auth/get-session", { credentials: "include", headers: { Accept: "application/json" }, diff --git a/ui/src/api/board-access-query.test.ts b/ui/src/api/board-access-query.test.ts new file mode 100644 index 00000000000..6322bb7a714 --- /dev/null +++ b/ui/src/api/board-access-query.test.ts @@ -0,0 +1,46 @@ +import { QueryClient } from "@tanstack/react-query"; +import type { BoardBootstrap } from "@paperclipai/shared"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { boardAccessQueryOptions } from "./board-access-query"; +import { queryKeys } from "../lib/queryKeys"; + +const readAccess = vi.hoisted(() => vi.fn()); +vi.mock("./access", () => ({ accessApi: { getCurrentBoardAccess: readAccess } })); +afterEach(() => readAccess.mockReset()); + +const bootstrap = (userId: string): BoardBootstrap => ({ + userId, companies: [], boardAccess: { userId, isInstanceAdmin: false, companyIds: [`company-${userId}`] }, +}); + +describe("account-scoped board bootstrap", () => { + it("opens from the session snapshot and uses the ordinary reader after invalidation", async () => { + const client = new QueryClient(); + const options = boardAccessQueryOptions("one", bootstrap("one"), Date.now(), vi.fn()); + expect(await client.fetchQuery({ ...options, staleTime: 30_000 })).toEqual(bootstrap("one").boardAccess); + expect(readAccess).not.toHaveBeenCalled(); + await client.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess }); + readAccess.mockResolvedValue({ userId: "one", isInstanceAdmin: false, companyIds: [] }); + expect(await client.fetchQuery(options)).toEqual({ userId: "one", isInstanceAdmin: false, companyIds: [] }); + expect(readAccess).toHaveBeenCalledTimes(1); + client.clear(); + }); + + it("cannot reuse another account's bootstrap or cache entry", async () => { + const client = new QueryClient(); + const first = boardAccessQueryOptions("one", bootstrap("one"), Date.now(), vi.fn()); + await client.fetchQuery({ ...first, staleTime: Infinity }); + const second = boardAccessQueryOptions("two", bootstrap("one"), Date.now(), vi.fn()); + expect(second.initialData).toBeUndefined(); + expect(second.queryKey).not.toEqual(first.queryKey); + readAccess.mockResolvedValue({ userId: "two", isInstanceAdmin: false, companyIds: [] }); + expect(await client.fetchQuery(second)).toMatchObject({ userId: "two", companyIds: [] }); + client.clear(); + }); + + it("refreshes identity instead of caching a response from a changed account", async () => { + const changed = vi.fn(); + readAccess.mockResolvedValue({ userId: "two", isInstanceAdmin: true, companyIds: ["foreign"] }); + await expect(boardAccessQueryOptions("one", undefined, 0, changed).queryFn()).rejects.toMatchObject({ status: 401 }); + expect(changed).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/src/api/board-access-query.ts b/ui/src/api/board-access-query.ts new file mode 100644 index 00000000000..c54acbc56c7 --- /dev/null +++ b/ui/src/api/board-access-query.ts @@ -0,0 +1,30 @@ +import type { BoardBootstrap } from "@paperclipai/shared"; +import { accessApi } from "./access"; +import { ApiError, detachInflightGet } from "./client"; +import { queryKeys } from "../lib/queryKeys"; + +export function boardAccessQueryOptions( + userId: string | null, + bootstrap: BoardBootstrap | undefined, + updatedAt: number, + onAccountChanged: () => void, +) { + const initialData = userId && bootstrap?.userId === userId && bootstrap.boardAccess.userId === userId + ? bootstrap.boardAccess + : undefined; + return { + queryKey: [...queryKeys.access.currentBoardAccess, userId] as const, + initialData, + initialDataUpdatedAt: initialData ? updatedAt : undefined, + queryFn: async (): Promise => { + // Path-only request coalescing must not join reads from two accounts. + detachInflightGet("/cli-auth/me"); + const access = await accessApi.getCurrentBoardAccess(); + if (access.userId !== userId) { + onAccountChanged(); + throw new ApiError("The signed-in account changed. Checking your session again.", 401, { code: "session_changed" }); + } + return { userId: access.userId, isInstanceAdmin: access.isInstanceAdmin, companyIds: access.companyIds }; + }, + }; +} diff --git a/ui/src/api/bootstrap-query.ts b/ui/src/api/bootstrap-query.ts new file mode 100644 index 00000000000..bbef2a93882 --- /dev/null +++ b/ui/src/api/bootstrap-query.ts @@ -0,0 +1,38 @@ +import { useLayoutEffect } from "react"; +import { hashKey, useQuery, useQueryClient, type QueryClient, type UseQueryOptions } from "@tanstack/react-query"; + +const appliedSnapshots = new WeakMap>(); + +/** Apply each canonical session snapshot once, including when its account has cached reads. */ +export function useBootstrapQuery( + options: UseQueryOptions, + snapshot: { identity: object; data: TData; updatedAt: number } | undefined, +) { + const client = useQueryClient(); + const key = hashKey(options.queryKey); + const pending = !!snapshot && (appliedSnapshots.get(client)?.get(key) !== snapshot.identity + || client.getQueryState(options.queryKey)?.data === undefined); + const query = useQuery({ ...options, enabled: pending ? false : options.enabled }); + + useLayoutEffect(() => { + if (!snapshot || (appliedSnapshots.get(client)?.get(key) === snapshot.identity + && client.getQueryState(options.queryKey)?.data !== undefined)) return; + let applied = appliedSnapshots.get(client); + if (!applied) appliedSnapshots.set(client, applied = new Map()); + applied.set(key, snapshot.identity); + // An older same-account read must not win after a fresh session snapshot. + void client.cancelQueries({ queryKey: options.queryKey, exact: true }); + client.setQueryData(options.queryKey, snapshot.data, { updatedAt: snapshot.updatedAt }); + }, [client, key, options.queryKey, snapshot]); + + // Effects run after render. Publish the canonical snapshot immediately so + // cached membership/access from an earlier visit cannot reach consumers. + // Once applied, ordinary readers and mutations own this entry again. + return pending && snapshot ? { + ...query, data: snapshot.data, dataUpdatedAt: snapshot.updatedAt, + status: "success" as const, fetchStatus: "idle" as const, + error: null, failureReason: null, failureCount: 0, + isLoading: false, isPending: false, isFetching: false, isRefetching: false, + isError: false, isSuccess: true, isLoadingError: false, isRefetchError: false, + } : query; +} diff --git a/ui/src/api/companies-query.ts b/ui/src/api/companies-query.ts index b43562a5e36..4475703e769 100644 --- a/ui/src/api/companies-query.ts +++ b/ui/src/api/companies-query.ts @@ -1,9 +1,10 @@ import { useQuery, type QueryClient } from "@tanstack/react-query"; -import type { Company } from "@paperclipai/shared"; +import type { BoardBootstrap, Company } from "@paperclipai/shared"; import { authApi } from "./auth"; import { companiesApi } from "./companies"; import { ApiError } from "./client"; import { queryKeys } from "../lib/queryKeys"; +import { useBootstrapQuery } from "./bootstrap-query"; export type CompanyListResult = { companies: Company[]; unauthorized: boolean }; @@ -73,9 +74,19 @@ const sessionQueryOptions = { * So: settled on success only. A failed session lookup leaves the list unfetched * until the query recovers, which it does on the next refetch. */ -export function useAccountIdentity(): { userId: string | null; settled: boolean; failed: boolean } { - const { data: session, isSuccess, isError } = useQuery(sessionQueryOptions); - return { userId: session?.user.id ?? null, settled: isSuccess, failed: isError }; +export function useAccountIdentity(): { + userId: string | null; + settled: boolean; + failed: boolean; + bootstrap: BoardBootstrap | undefined; + updatedAt: number; +} { + const { data: session, isSuccess, isError, dataUpdatedAt } = useQuery(sessionQueryOptions); + const userId = session?.user.id ?? null; + return { + userId, settled: isSuccess, failed: isError, updatedAt: dataUpdatedAt, + bootstrap: isSuccess && session?.boardBootstrap?.userId === userId ? session.boardBootstrap : undefined, + }; } /** @@ -87,12 +98,12 @@ export function useAccountIdentity(): { userId: string | null; settled: boolean; export function useCompanyListQuery( options: { enabled?: boolean; staleTime?: number; retry?: number | boolean } = {}, ) { - const { userId, settled } = useAccountIdentity(); - const query = useQuery({ + const { userId, settled, bootstrap, updatedAt } = useAccountIdentity(); + const query = useBootstrapQuery({ ...companyListQueryOptions(userId), ...options, enabled: settled && (options.enabled ?? true), - }); + }, bootstrap ? { identity: bootstrap, data: { companies: bootstrap.companies, unauthorized: false }, updatedAt } : undefined); // While the account is unknown this query is disabled, and a disabled query // reports `isLoading: false` with `data: undefined` — which consumers default diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 739487a577c..6fac8563623 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -7,6 +7,7 @@ import type { AskUserQuestionsAnswer, Approval, CompactIssue, + InboxMineSnapshot, CreateIssueTreeHold, DocumentRevision, FeedbackTargetType, @@ -281,6 +282,11 @@ function issueListSearchParams(filters?: IssueListFilters) { } export const issuesApi = { + inboxMine: (companyId: string, options?: RequestOptions) => { + const path = `/companies/${companyId}/inbox/mine`; + detachInflightGet(path); + return api.get(path, { ...options, cache: "no-store" }); + }, summaries: async (companyId: string, issueIds: readonly string[], options?: RequestOptions): Promise => { if (issueIds.length > ISSUE_SUMMARIES_MAX_IDS) throw new Error("Too many issue summary IDs"); const ids = [...new Set(issueIds.map((id) => id.trim().toLowerCase()))].sort(); diff --git a/ui/src/components/CloudAccessGate.test.tsx b/ui/src/components/CloudAccessGate.test.tsx index 0c264f6c893..932f74a7f0d 100644 --- a/ui/src/components/CloudAccessGate.test.tsx +++ b/ui/src/components/CloudAccessGate.test.tsx @@ -27,7 +27,7 @@ const responses: Record = { user: { id: "user", name: "Operator", email: "operator@example.com", image: null }, sentryDsn: null, }, - "/api/cli-auth/me": { isInstanceAdmin: false, companyIds: ["company"] }, + "/api/cli-auth/me": { userId: "user", isInstanceAdmin: false, companyIds: ["company"] }, }; const checks = [ ["/api/health", queryKeys.health], @@ -104,6 +104,50 @@ describe("CloudAccessGate restart recovery", () => { await flushReact(); } + it("opens from same-session board access without another startup request", async () => { + const previous = responses["/api/auth/get-session"]; + responses["/api/auth/get-session"] = { ...(previous as object), boardBootstrap: { + userId: "user", companies: [], boardAccess: { userId: "user", isInstanceAdmin: false, companyIds: ["company"] }, + } }; + try { + await render(); + expect(container.querySelector("textarea")).not.toBeNull(); + expect(callsTo("/api/cli-auth/me")).toBe(0); + void client.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess }); + await flushReact(); + expect(callsTo("/api/cli-auth/me")).toBe(1); + } finally { responses["/api/auth/get-session"] = previous; } + }); + + it("revokes cached board access when returning to an account with a new session snapshot", async () => { + const previous = responses["/api/auth/get-session"]; + const boot = (userId: string, companyIds: string[]) => ({ + session: { id: `session-${userId}`, userId }, + user: { id: userId, name: "Operator", email: `${userId}@example.com`, image: null }, + sentryDsn: null, + boardBootstrap: { userId, companies: [], boardAccess: { userId, isInstanceAdmin: false, companyIds } }, + }); + responses["/api/auth/get-session"] = boot("user", ["revoked-company"]); + try { + await render(); + expect(container.querySelector("textarea")).not.toBeNull(); + client.setQueryData(queryKeys.auth.session, boot("other", ["other-company"])); + await flushReact(); + expect(container.querySelector("textarea")).not.toBeNull(); + client.setQueryData(queryKeys.auth.session, boot("user", [])); + await flushReact(); + expect(container.querySelector("textarea")).toBeNull(); + expect(client.getQueryData([...queryKeys.access.currentBoardAccess, "user"])).toMatchObject({ companyIds: [] }); + expect(callsTo("/api/cli-auth/me")).toBe(0); + + // An ordinary post-bootstrap grant is allowed to replace that snapshot. + void client.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess }); + await flushReact(); + expect(callsTo("/api/cli-auth/me")).toBe(1); + expect(container.querySelector("textarea")).not.toBeNull(); + } finally { responses["/api/auth/get-session"] = previous; } + }); + it.each(checks)("recovers from an HTML response on %s without navigating", async (path) => { failingPath = path; await render(); diff --git a/ui/src/components/CloudAccessGate.tsx b/ui/src/components/CloudAccessGate.tsx index 0b9436104e5..d6e8a85aacf 100644 --- a/ui/src/components/CloudAccessGate.tsx +++ b/ui/src/components/CloudAccessGate.tsx @@ -5,6 +5,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { accessApi } from "@/api/access"; import { ApiError } from "@/api/client"; import { authApi } from "@/api/auth"; +import { boardAccessQueryOptions } from "@/api/board-access-query"; +import { useBootstrapQuery } from "@/api/bootstrap-query"; import { healthApi } from "@/api/health"; import { isTemporaryApiError } from "@/api/response"; import { useConnectivityStore } from "@/lib/connectivity"; @@ -107,13 +109,20 @@ export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembe if (sessionQuery.data) clearCloudSignInAttempt(); }, [sessionQuery.data]); - const boardAccessQuery = useQuery({ - queryKey: queryKeys.access.currentBoardAccess, - queryFn: () => accessApi.getCurrentBoardAccess(), + const sessionBootstrap = sessionQuery.isSuccess ? sessionQuery.data?.boardBootstrap : undefined; + const bootstrap = sessionBootstrap?.userId === sessionQuery.data?.user.id + && sessionBootstrap?.boardAccess.userId === sessionQuery.data?.user.id ? sessionBootstrap : undefined; + const boardAccessQuery = useBootstrapQuery({ + ...boardAccessQueryOptions( + sessionQuery.data?.user.id ?? null, + sessionQuery.data?.boardBootstrap, + sessionQuery.dataUpdatedAt, + () => { void queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); }, + ), enabled: isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data, retry: retryWhileTransient, retryDelay: reconnectDelayFor, - }); + }, bootstrap ? { identity: bootstrap, data: bootstrap.boardAccess, updatedAt: sessionQuery.dataUpdatedAt } : undefined); const claimMutation = useMutation({ mutationFn: () => accessApi.claimBootstrapAdmin(), onSuccess: async () => { diff --git a/ui/src/context/CompanyContext.test.tsx b/ui/src/context/CompanyContext.test.tsx index a9053e97177..77794945947 100644 --- a/ui/src/context/CompanyContext.test.tsx +++ b/ui/src/context/CompanyContext.test.tsx @@ -236,6 +236,102 @@ describe("CompanyProvider", () => { vi.clearAllMocks(); }); + it("seeds navigation from the session and preserves ordinary company invalidation", async () => { + queryClient.setDefaultOptions({ queries: { retry: false, staleTime: 30_000 } }); + mockAuthApi.getSession.mockResolvedValue({ ...sessionFor("one"), boardBootstrap: { + userId: "one", companies: [makeCompany("bootstrap-company")], + boardAccess: { userId: "one", isInstanceAdmin: false, companyIds: ["bootstrap-company"] }, + } }); + await act(() => root.render( + {}} /> + )); + await vi.waitFor(() => expect(captured?.selectedCompanyId).toBe("bootstrap-company")); + expect(mockCompaniesApi.list).not.toHaveBeenCalled(); + expect(queryClient.getQueryData(queryKeys.companies.list("one"))).toMatchObject({ companies: [{ id: "bootstrap-company" }] }); + mockCompaniesApi.list.mockResolvedValue([makeCompany("fresh-company")]); + await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.companies.all })); + await vi.waitFor(() => expect(captured?.selectedCompanyId).toBe("fresh-company")); + expect(mockCompaniesApi.list).toHaveBeenCalledOnce(); + }); + + it("never exposes the previous account's selection while a new bootstrap is ready", async () => { + queryClient.setDefaultOptions({ queries: { retry: false, staleTime: 30_000 } }); + const boot = (userId: string) => ({ ...sessionFor(userId), boardBootstrap: { + userId, companies: [makeCompany(`company-${userId}`)], + boardAccess: { userId, isInstanceAdmin: false, companyIds: [`company-${userId}`] }, + } }); + mockAuthApi.getSession.mockResolvedValue(boot("one")); + const snapshots: Array<{ selected: string | null; company: string | undefined }> = []; + function AccountProbe() { + const current = useCompany(); + snapshots.push({ selected: current.selectedCompanyId, company: current.companies[0]?.id }); + return null; + } + await act(() => root.render()); + await vi.waitFor(() => expect(snapshots.at(-1)?.selected).toBe("company-one")); + await act(() => { queryClient.setQueryData(queryKeys.auth.session, boot("two")); }); + await vi.waitFor(() => expect(snapshots.at(-1)?.selected).toBe("company-two")); + expect(snapshots.filter(value => value.company === "company-two").some(value => value.selected === "company-one")).toBe(false); + expect(mockCompaniesApi.list).not.toHaveBeenCalled(); + }); + + it("replaces a returning account's cached companies with its revoked session snapshot once", async () => { + queryClient.setDefaultOptions({ queries: { retry: false, staleTime: Infinity } }); + const boot = (userId: string, ids: string[]) => ({ ...sessionFor(userId), boardBootstrap: { + userId, companies: ids.map(makeCompany), + boardAccess: { userId, isInstanceAdmin: false, companyIds: ids }, + } }); + const seen: Array<{ selected: string | null; companies: string[] }> = []; + function AccountProbe() { + const current = useCompany(); + seen.push({ selected: current.selectedCompanyId, companies: current.companies.map(company => company.id) }); + return null; + } + mockAuthApi.getSession.mockResolvedValue(boot("one", ["revoked-company"])); + await act(() => root.render()); + await vi.waitFor(() => expect(seen.at(-1)?.selected).toBe("revoked-company")); + await act(() => { queryClient.setQueryData(queryKeys.auth.session, boot("two", ["company-two"])); }); + await vi.waitFor(() => expect(seen.at(-1)?.selected).toBe("company-two")); + const returningAt = seen.length; + await act(() => { queryClient.setQueryData(queryKeys.auth.session, boot("one", [])); }); + await vi.waitFor(() => expect(seen.at(-1)).toEqual({ selected: null, companies: [] })); + expect(seen.slice(returningAt).some(value => value.companies.includes("revoked-company"))).toBe(false); + expect(queryClient.getQueryData(queryKeys.companies.list("one"))).toEqual({ companies: [], unauthorized: false }); + expect(mockCompaniesApi.list).not.toHaveBeenCalled(); + + // The session seed must not overwrite a later authoritative read. + mockCompaniesApi.list.mockResolvedValue([makeCompany("new-membership")]); + await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.companies.all })); + await vi.waitFor(() => expect(seen.at(-1)?.selected).toBe("new-membership")); + expect(mockCompaniesApi.list).toHaveBeenCalledOnce(); + }); + + it("reseeds a collected bootstrap query and restores ordinary reads after remount", async () => { + queryClient.setDefaultOptions({ queries: { retry: false, staleTime: Infinity, gcTime: 0 } }); + const bootstrap = { + userId: "one", companies: [makeCompany("bootstrap-company")], + boardAccess: { userId: "one", isInstanceAdmin: false, companyIds: ["bootstrap-company"] }, + }; + mockAuthApi.getSession.mockResolvedValue({ ...sessionFor("one"), boardBootstrap: bootstrap }); + const render = (mounted: boolean) => root.render( + {mounted ? {}} /> : null} + ); + await act(() => render(true)); + await vi.waitFor(() => expect(captured?.selectedCompanyId).toBe("bootstrap-company")); + expect(mockCompaniesApi.list).not.toHaveBeenCalled(); + await act(() => render(false)); + await vi.waitFor(() => expect(queryClient.getQueryState(queryKeys.companies.list("one"))).toBeUndefined()); + captured = null; + await act(() => render(true)); + await vi.waitFor(() => expect(captured?.selectedCompanyId).toBe("bootstrap-company")); + + mockCompaniesApi.list.mockResolvedValue([makeCompany("ordinary-reader-company")]); + await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.companies.all })); + await vi.waitFor(() => expect(mockCompaniesApi.list).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(captured?.selectedCompanyId).toBe("ordinary-reader-company")); + expect(queryClient.getQueryData(queryKeys.companies.list("one"))).toMatchObject({ companies: [{ id: "ordinary-reader-company" }] }); + }); + it("publishes the URL company without first exposing the remembered company", async () => { const previousUrl = window.location.href; window.history.replaceState(null, "", "/TWO/issues"); diff --git a/ui/src/context/CompanyContext.tsx b/ui/src/context/CompanyContext.tsx index 998f51b4ddf..42bac37e236 100644 --- a/ui/src/context/CompanyContext.tsx +++ b/ui/src/context/CompanyContext.tsx @@ -144,6 +144,8 @@ export function CompanyProvider({ children }: { children: ReactNode }) { // cannot inherit it. const { userId: sessionUserId, settled: isSessionSettled } = useAccountIdentity(); const observedUserIdRef = useRef(undefined); + const selectionBelongsToPreviousAccount = isSessionSettled + && observedUserIdRef.current !== undefined && observedUserIdRef.current !== sessionUserId; useEffect(() => { // Until the session settles the account is unknown, not changed. @@ -259,14 +261,14 @@ export function CompanyProvider({ children }: { children: ReactNode }) { ); const selectedCompany = useMemo( - () => companies.find((company) => company.id === selectedCompanyId) ?? null, - [companies, selectedCompanyId], + () => selectionBelongsToPreviousAccount ? null : companies.find((company) => company.id === selectedCompanyId) ?? null, + [companies, selectedCompanyId, selectionBelongsToPreviousAccount], ); const value = useMemo( () => ({ companies, - selectedCompanyId, + selectedCompanyId: selectionBelongsToPreviousAccount ? null : selectedCompanyId, selectedCompany, selectionSource, loading: isLoading, @@ -280,6 +282,7 @@ export function CompanyProvider({ children }: { children: ReactNode }) { [ companies, selectedCompanyId, + selectionBelongsToPreviousAccount, selectedCompany, selectionSource, isLoading, diff --git a/ui/src/context/LiveUpdatesProvider.read-receipts.test.ts b/ui/src/context/LiveUpdatesProvider.read-receipts.test.ts new file mode 100644 index 00000000000..d327a07442c --- /dev/null +++ b/ui/src/context/LiveUpdatesProvider.read-receipts.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment node +import { QueryClient, QueryObserver, type QueryKey } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("./CompanyContext", () => ({ useCompany: vi.fn() })); +vi.mock("../api/auth", () => ({ authApi: { getSession: vi.fn() } })); +import { queryKeys } from "../lib/queryKeys"; +import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider"; + +describe("live read receipts", () => { + let client: QueryClient; + let subscriptions: Array<() => void>; + + beforeEach(() => { + client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: Infinity } } }); + subscriptions = []; + }); + + afterEach(() => { + subscriptions.forEach(unsubscribe => unsubscribe()); + client.clear(); + }); + + function watch(key: QueryKey, next: unknown = "fresh") { + const collection = key[0] === "agent-chats" || key[0] === "issues" && key[1] !== "detail" && key[1] !== "activity"; + client.setQueryData(key, collection ? [] : "cached"); + const read = vi.fn(async () => next); + const observer = new QueryObserver(client, { queryKey: key, queryFn: read }); + subscriptions.push(observer.subscribe(() => undefined)); + return read; + } + + function receipt(action: string, userId: string, actorId = userId) { + __liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", { + entityType: "issue", entityId: "task-1", action, actorType: "user", actorId, + details: { userId }, + }, { userId: "viewer", agentId: null }); + } + + it.each(["issue.read_marked", "issue.read_unmarked"])("refreshes the reader's personal views once without fetching task collections for %s", async action => { + const mine = [...queryKeys.issues.listMineByMe("company-1"), "index", 100]; + const touched = queryKeys.issues.listTouchedByMe("company-1"); + const unread = queryKeys.issues.listUnreadTouchedByMe("company-1"); + const badges = queryKeys.sidebarBadges("company-1"); + const chats = queryKeys.agentChats.list("company-1", "viewer"); + const ownReads = [mine, touched, unread, badges, chats].map(key => watch(key)); + const unrelatedReads = [ + queryKeys.issues.list("company-1"), + [...queryKeys.issues.list("company-1"), "row-context"], + queryKeys.issues.listByProject("company-1", "project-1"), + queryKeys.issues.detail("task-1"), + queryKeys.dashboard("company-1"), + queryKeys.agentChats.list("company-1", "other-viewer"), + queryKeys.issues.listMineByMe("company-2"), + ].map(key => watch(key)); + + receipt(action, "viewer"); + + await vi.waitFor(() => expect(client.getQueryData(mine)).toBe("fresh")); + ownReads.forEach(read => expect(read).toHaveBeenCalledOnce()); + unrelatedReads.forEach(read => expect(read).not.toHaveBeenCalled()); + }); + + it.each(["viewer", "other-viewer"])("keeps company and task audit receipts fresh when %s reads", async reader => { + const companyActivity = queryKeys.activity("company-1"); + const taskActivity = queryKeys.issues.activity("task-1"); + const otherActivity = queryKeys.issues.activity("other-task"); + const companyRead = watch(companyActivity, [{ action: "issue.read_marked", actorId: reader }]); + const taskRead = watch(taskActivity, [{ action: "issue.read_marked", actorId: reader }]); + const otherRead = watch(otherActivity); + const mineRead = watch(queryKeys.issues.listMineByMe("company-1")); + + receipt("issue.read_marked", reader); + + await vi.waitFor(() => expect(client.getQueryData(companyActivity)).toEqual([{ action: "issue.read_marked", actorId: reader }])); + await vi.waitFor(() => expect(client.getQueryData(taskActivity)).toEqual([{ action: "issue.read_marked", actorId: reader }])); + expect(companyRead).toHaveBeenCalledOnce(); + expect(taskRead).toHaveBeenCalledOnce(); + expect(otherRead).not.toHaveBeenCalled(); + expect(mineRead).toHaveBeenCalledTimes(reader === "viewer" ? 1 : 0); + }); + + it("uses the affected person rather than an incidental actor for private read state", async () => { + const activity = queryKeys.activity("company-1"); + watch(activity); + const mineRead = watch(queryKeys.issues.listMineByMe("company-1")); + const badgesRead = watch(queryKeys.sidebarBadges("company-1")); + const chatRead = watch(queryKeys.agentChats.list("company-1", "viewer")); + + receipt("issue.read_unmarked", "other-viewer", "viewer"); + + await vi.waitFor(() => expect(client.getQueryData(activity)).toBe("fresh")); + for (const read of [mineRead, badgesRead, chatRead]) expect(read).not.toHaveBeenCalled(); + }); + + it("refreshes the visible identifier's audit without reloading task detail", async () => { + const detail = queryKeys.issues.detail("PAP-1"); + client.setQueryData(detail, { id: "task-1", companyId: "company-1", identifier: "PAP-1" }); + const activity = queryKeys.issues.activity("PAP-1"); + const read = watch(activity); + __liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", { + entityType: "issue", entityId: "task-1", action: "issue.read_marked", + actorType: "user", actorId: "other-viewer", details: { userId: "other-viewer" }, + }, { userId: "viewer", agentId: null }, { pathname: "/PAP/issues/PAP-1", isForegrounded: true }); + await vi.waitFor(() => expect(client.getQueryData(activity)).toBe("fresh")); + expect(read).toHaveBeenCalledOnce(); + expect(client.getQueryState(detail)?.isInvalidated).toBe(false); + }); + + it.each(["queued", "failed", "succeeded"])("refreshes combined Mine state after a run becomes %s", async status => { + const mine = [...queryKeys.issues.listMineByMe("company-1"), "index", 100]; + const next = [{ id: "task-1", companyId: "company-1", status }]; + const read = watch(mine, next); + const otherCompanyRead = watch(queryKeys.issues.listMineByMe("company-2")); + __liveUpdatesTestUtils.invalidateHeartbeatQueries(client, "company-1", { + runId: "run-1", agentId: "agent-1", issueId: "task-1", status, + }); + await vi.waitFor(() => expect(client.getQueryData(mine)).toEqual(next)); + expect(read).toHaveBeenCalledOnce(); + expect(otherCompanyRead).not.toHaveBeenCalled(); + }); + + it("still refreshes ordinary task mutations for every interested view", async () => { + const list = queryKeys.issues.listByProject("company-1", "project-1"); + const dashboard = queryKeys.dashboard("company-1"); + const detail = queryKeys.issues.detail("task-1"); + const reads = [list, dashboard, detail].map(key => watch(key)); + const otherCompanyRead = watch(queryKeys.issues.list("company-2")); + + receipt("issue.updated", "other-viewer"); + + await vi.waitFor(() => expect(client.getQueryData(detail)).toBe("fresh")); + reads.forEach(read => expect(read).toHaveBeenCalled()); + expect(otherCompanyRead).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 9ea57e9c6e2..e66fc151b08 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -333,6 +333,27 @@ function resolveIssueQueryRefs( return Array.from(refs); } +function resolveIssueActivityQueryRefs( + queryClient: QueryClient, + companyId: string, + entityId: string, + details: Record | null, + options?: { pathname?: string; isForegrounded?: boolean }, +): string[] { + const refs = resolveIssueQueryRefs(queryClient, companyId, entityId, details); + const visible = options?.pathname + ? resolveVisibleIssueRouteContext(queryClient, options.pathname, { + isForegrounded: options.isForegrounded, + }) + : null; + if (visible && overlaps(visible.issueRefs, buildIssueRefsForPayload(entityId, details)) + && !refs.includes(visible.routeIssueRef)) { + // Authenticated events can omit the identifier used by the visible route. + refs.push(visible.routeIssueRef); + } + return refs; +} + function resolveIssueToastContext( queryClient: QueryClient, companyId: string, @@ -1338,6 +1359,7 @@ function invalidateHeartbeatQueries( // Note: liveRuns(companyId) is intentionally NOT invalidated here — it is // event-sourced via applyRunLifecycleToCompanyLiveRuns in the caller. queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) }); queryClient.invalidateQueries({ queryKey: ["costs", companyId] }); @@ -1380,18 +1402,34 @@ function invalidateActivityQueries( currentActor: { userId: string | null; agentId: string | null }, options?: { pathname?: string; isForegrounded?: boolean }, ) { - queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) }); - queryClient.invalidateQueries({ - queryKey: queryKeys.sidebarBadges(companyId), - }); - const entityType = readString(payload.entityType); const entityId = readString(payload.entityId); const action = readString(payload.action); const actorType = readString(payload.actorType); const actorId = readString(payload.actorId); const details = readRecord(payload.details); + queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) }); + if (entityType === "issue" && (action === "issue.read_marked" || action === "issue.read_unmarked")) { + // Audit views show read receipts, while unread state belongs to one person. + if (entityId) { + for (const ref of resolveIssueActivityQueryRefs(queryClient, companyId, entityId, details, options)) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) }); + } + } + const readerId = readString(details?.userId) ?? (actorType === "user" ? actorId : null); + if (readerId && readerId === currentActor.userId) { + for (const queryKey of [ + queryKeys.issues.listMineByMe(companyId), + queryKeys.issues.listTouchedByMe(companyId), + queryKeys.issues.listUnreadTouchedByMe(companyId), + queryKeys.sidebarBadges(companyId), + queryKeys.agentChats.list(companyId, readerId), + ]) queryClient.invalidateQueries({ queryKey }); + } + return; + } + queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) }); const decisionProjectionChanged = entityType === "decision" || ["inbox.dismissed", "inbox.snoozed", "inbox.restored"].includes(action ?? "") || action?.startsWith("issue.thread_interaction_") || @@ -1492,11 +1530,12 @@ function invalidateActivityQueries( payload, { isForegrounded: options.isForegrounded }, ); - const issueRefs = resolveIssueQueryRefs( + const issueRefs = resolveIssueActivityQueryRefs( queryClient, companyId, entityId, details, + options, ); const taskDocumentsChanged = !!action && ISSUE_DOCUMENT_ACTIVITY_ACTIONS.has(action); const caseWorkChanged = !!action && ISSUE_CASE_WORK_ACTIVITY_ACTIONS.has(action); @@ -1520,20 +1559,6 @@ function invalidateActivityQueries( }, }); } - const visibleContext = options?.pathname - ? resolveVisibleIssueRouteContext(queryClient, options.pathname, { - isForegrounded: options.isForegrounded, - }) - : null; - if ( - visibleContext && - overlaps(visibleContext.issueRefs, buildIssueRefsForPayload(entityId, details)) && - !issueRefs.includes(visibleContext.routeIssueRef) - ) { - // Authenticated payloads omit the identifier. Include the visible - // route's cache key when its loaded task matches the event's UUID. - issueRefs.push(visibleContext.routeIssueRef); - } for (const ref of issueRefs) { const invalidationOptions = selfCommentActivity || diff --git a/ui/src/lib/query-invalidation-batcher.test.ts b/ui/src/lib/query-invalidation-batcher.test.ts index 2fcb2976ed3..24fa701461f 100644 --- a/ui/src/lib/query-invalidation-batcher.test.ts +++ b/ui/src/lib/query-invalidation-batcher.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { createCoalescingQueryClient, createInvalidationBatcher, @@ -149,3 +149,118 @@ describe("createCoalescingQueryClient", () => { expect(realInvalidate).toHaveBeenCalledTimes(1); }); }); + +describe("overlapping live invalidations", () => { + it.each([false, true])("does not restart a request already covered by its collection invalidation (child first=%s)", async childFirst => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: Infinity } } }); + const key = ["issues", "company-1", "mine-by-me", "index"]; + client.setQueryData(key, "cached"); + const completions: Array<() => void> = []; + const aborted = vi.fn(); + const read = vi.fn(({ signal }: { signal: AbortSignal }) => new Promise((resolve, reject) => { + completions.push(() => resolve("fresh")); + signal.addEventListener("abort", () => { aborted(); reject(signal.reason); }, { once: true }); + })); + const observer = new QueryObserver(client, { queryKey: key, queryFn: read }); + const unsubscribe = observer.subscribe(() => undefined); + const batcher = createInvalidationBatcher(client); + try { + const filters = [{ queryKey: ["issues", "company-1"] }, { queryKey: ["issues", "company-1", "mine-by-me"] }]; + for (const filter of childFirst ? filters.reverse() : filters) void batcher.schedule(filter); + const flushed = batcher.flush(); + completions.forEach(complete => complete()); + await flushed; + expect(client.getQueryData(key)).toBe("fresh"); + expect(read).toHaveBeenCalledOnce(); + expect(aborted).not.toHaveBeenCalled(); + } finally { + batcher.dispose(); + unsubscribe(); + client.clear(); + } + }); + + it.each(["exact", "type", "stale", "refetch", "predicate"])("preserves a child refresh when the parent has a different %s constraint", async constraint => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: Infinity } } }); + const key = ["issues", "company-1", "mine-by-me"]; + client.setQueryData(key, "cached"); + const read = vi.fn(async () => "fresh"); + const observer = new QueryObserver(client, { queryKey: key, queryFn: read }); + const unsubscribe = observer.subscribe(() => undefined); + const batcher = createInvalidationBatcher(client); + try { + const parent = { + queryKey: ["issues", "company-1"], + ...(constraint === "exact" ? { exact: true } : {}), + ...(constraint === "type" ? { type: "inactive" as const } : {}), + ...(constraint === "stale" ? { stale: true } : {}), + ...(constraint === "refetch" ? { refetchType: "none" as const } : {}), + ...(constraint === "predicate" ? { predicate: () => false } : {}), + }; + void batcher.schedule(parent); + void batcher.schedule({ queryKey: key }); + await batcher.flush(); + expect(client.getQueryData(key)).toBe("fresh"); + expect(read).toHaveBeenCalledOnce(); + } finally { + batcher.dispose(); + unsubscribe(); + client.clear(); + } + }); + + it.each(["stale", "fetchStatus"])("retains distinct %s filters on the same key", async constraint => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: Infinity } } }); + const key = ["issues", "company-1"]; + client.setQueryData(key, "cached"); + const observer = new QueryObserver(client, { queryKey: key, queryFn: async () => "fresh" }); + const unsubscribe = observer.subscribe(() => undefined); + const batcher = createInvalidationBatcher(client); + try { + if (constraint === "stale") { + void batcher.schedule({ queryKey: key, stale: false }); + void batcher.schedule({ queryKey: key, stale: true }); + } else { + void batcher.schedule({ queryKey: key, fetchStatus: "idle" }); + void batcher.schedule({ queryKey: key, fetchStatus: "paused" }); + } + await batcher.flush(); + expect(client.getQueryData(key)).toBe("fresh"); + } finally { + batcher.dispose(); + unsubscribe(); + client.clear(); + } + }); + + it("starts a newer read when another event arrives in a later batch", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: Infinity } } }); + const key = ["issues", "company-1", "mine-by-me"]; + client.setQueryData(key, 0); + let serverValue = 1; + const completions: Array<() => void> = []; + const read = vi.fn(({ signal }: { signal: AbortSignal }) => new Promise((resolve, reject) => { + const snapshot = serverValue; + completions.push(() => resolve(snapshot)); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + })); + const observer = new QueryObserver(client, { queryKey: key, queryFn: read }); + const unsubscribe = observer.subscribe(() => undefined); + const batcher = createInvalidationBatcher(client); + try { + void batcher.schedule({ queryKey: ["issues", "company-1"] }); + const first = batcher.flush(); + serverValue = 2; + void batcher.schedule({ queryKey: key }); + const second = batcher.flush(); + completions.forEach(complete => complete()); + await Promise.all([first, second]); + expect(read).toHaveBeenCalledTimes(2); + expect(client.getQueryData(key)).toBe(2); + } finally { + batcher.dispose(); + unsubscribe(); + client.clear(); + } + }); +}); diff --git a/ui/src/lib/query-invalidation-batcher.ts b/ui/src/lib/query-invalidation-batcher.ts index 9d8807ebec4..6a1aa11fb31 100644 --- a/ui/src/lib/query-invalidation-batcher.ts +++ b/ui/src/lib/query-invalidation-batcher.ts @@ -60,7 +60,7 @@ export function createInvalidationBatcher( deferred?.resolve(); return; } - const filtersList = [...pending.values()]; + const filtersList = removeCoveredFilters([...pending.values()]); pending.clear(); try { await Promise.all(filtersList.map((filters) => queryClient.invalidateQueries( @@ -98,6 +98,42 @@ export function createInvalidationBatcher( let uniqueFilterCounter = 0; +function filterBehaviorKey(filters: InvalidateQueryFilters): string { + return JSON.stringify([ + filters.type ?? "all", + filters.refetchType ?? filters.type ?? "active", + filters.stale ?? null, + filters.fetchStatus ?? null, + ]); +} + +/** A collection prefix already refreshes its children in the same batch. */ +function removeCoveredFilters(filters: InvalidateQueryFilters[]): InvalidateQueryFilters[] { + const prefixesByBehavior = new Map>(); + const isScalar = (value: unknown) => value === null || typeof value === "string" + || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value); + for (const filter of filters) { + if (filter.predicate || filter.exact || !filter.queryKey?.every(isScalar)) continue; + const behavior = filterBehaviorKey(filter); + const prefixes = prefixesByBehavior.get(behavior) ?? new Set(); + prefixes.add(JSON.stringify(filter.queryKey)); + prefixesByBehavior.set(behavior, prefixes); + } + return filters.filter(filter => { + if (filter.predicate || !filter.queryKey) return true; + const prefixes = prefixesByBehavior.get(filterBehaviorKey(filter)); + if (!prefixes) return true; + const key = filter.queryKey; + // A non-exact filter covers itself, so only check its shorter prefixes. + const last = filter.exact ? key.length : key.length - 1; + for (let length = 0; length <= last; length += 1) { + if (length > 0 && !isScalar(key[length - 1])) break; + if (prefixes.has(JSON.stringify(key.slice(0, length)))) return false; + } + return true; + }); +} + function serializeFilters(filters: InvalidateQueryFilters): string { // A predicate is an opaque function — two predicate-based filters can never be // proven equal, so never coalesce them (that would silently drop one). Give @@ -105,14 +141,14 @@ function serializeFilters(filters: InvalidateQueryFilters): string { if (typeof filters.predicate === "function") { return `__predicate__:${(uniqueFilterCounter += 1)}`; } - // queryKey drives the identity; refetchType/exact/type change the behavior, so - // keep them distinct while still collapsing exact repeats. + // Query selection and refetch behavior both belong to the identity. In + // particular, dropping an idle/fresh filter in favor of a paused/stale one + // can lose the only refresh that actually matches a cached query. try { return JSON.stringify([ filters.queryKey ?? null, - filters.refetchType ?? null, - filters.exact ?? null, - filters.type ?? null, + filterBehaviorKey(filters), + filters.exact ?? false, ]); } catch { // Non-serializable filter (shouldn't happen for our query keys) — fall back diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index 72656aa8361..d3253527ecf 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -6,9 +6,10 @@ import type { ComponentProps } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { Approval, HeartbeatRun, Issue } from "@paperclipai/shared"; +import type { Approval, CompactIssue, HeartbeatRun, InboxMineSnapshot, Issue } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CompanyJoinRequest } from "../api/access"; +import { ApiError } from "../api/client"; import { clearLocalInboxArchive, getLocalInboxArchiveIssueIds, @@ -35,6 +36,7 @@ const apiMocks = vi.hoisted(() => ({ dashboardSummary: vi.fn(), executionWorkspaceSummaries: vi.fn(), issuesList: vi.fn(), + inboxMine: vi.fn(), issuesCount: vi.fn(), issueLabels: vi.fn(), retryTask: vi.fn(), @@ -78,6 +80,7 @@ vi.mock("../api/issues", () => ({ issuesApi: { list: apiMocks.issuesList, listCompact: apiMocks.issuesList, + inboxMine: apiMocks.inboxMine, count: apiMocks.issuesCount, listLabels: apiMocks.issueLabels, retryTask: apiMocks.retryTask, @@ -180,7 +183,9 @@ if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = () => {}; } -function createIssue(overrides: Partial = {}): Issue { +type InboxIssueFixture = Issue & CompactIssue; + +function createIssue(overrides: Partial = {}): InboxIssueFixture { return { id: "issue-1", identifier: "PAP-904", @@ -224,6 +229,8 @@ function createIssue(overrides: Partial = {}): Issue { isUnreadForMe: false, ...overrides, workMode: overrides.workMode ?? "standard", + activeRecoveryAction: overrides.activeRecoveryAction ?? null, + successfulRunHandoff: overrides.successfulRunHandoff ?? null, }; } @@ -344,9 +351,30 @@ function createFailedRun(overrides: Partial = {}): HeartbeatRun { }; } +let mineFixture: InboxMineSnapshot; + +function createMineSnapshot(overrides: Partial = {}): InboxMineSnapshot { + return { userId: "local-board", issues: [], contextIssues: [], failureRuns: [], failureIssueIds: [], ...overrides }; +} + +// Mine's API response is a complete server-owned snapshot. These fixture helpers +// also populate the independent legacy/Recent readers without calling them from +// the aggregate mock, so request-budget assertions observe actual client reads. +function setIssueRows(rows: InboxIssueFixture[]) { + apiMocks.issuesList.mockResolvedValue(rows); + mineFixture = { ...mineFixture, issues: rows }; +} + +function setFailureRuns(runs: HeartbeatRun[]) { + apiMocks.heartbeatRunsList.mockResolvedValue(runs); + mineFixture = { ...mineFixture, failureRuns: runs }; +} + function resetInboxApiMocks() { companyState.selectedCompanyId = "company-1"; for (const mock of Object.values(apiMocks)) mock.mockReset(); + mineFixture = createMineSnapshot(); + apiMocks.inboxMine.mockImplementation(async () => mineFixture); externalObjectMocks.summaries.clear(); externalObjectMocks.error = null; externalObjectMocks.retry.mockReset(); @@ -366,14 +394,14 @@ function resetInboxApiMocks() { costs: { monthBudgetCents: 0, monthUtilizationPercent: 0 }, }); apiMocks.executionWorkspaceSummaries.mockResolvedValue([]); - apiMocks.issuesList.mockResolvedValue([]); + setIssueRows([]); apiMocks.issuesCount.mockResolvedValue({ count: 0 }); apiMocks.issueLabels.mockResolvedValue([]); apiMocks.retryTask.mockReset().mockResolvedValue({ accepted: true, requestId: "retry-intent", presentation: null }); apiMocks.archiveFromInbox.mockResolvedValue({ id: "issue-1", archivedAt: new Date() }); apiMocks.unarchiveFromInbox.mockResolvedValue({ id: "issue-1", archivedAt: new Date() }); apiMocks.agentsList.mockResolvedValue([]); - apiMocks.heartbeatRunsList.mockResolvedValue([]); + setFailureRuns([]); apiMocks.liveRunsForCompany.mockResolvedValue([]); apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false, @@ -411,7 +439,8 @@ describe("Inbox toolbar", () => { apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false, enableStreamlinedUi: streamlined }); routerMock.location.pathname = `/inbox/${tab}`; apiMocks.authSession.mockResolvedValue(userId ? { user: { id: userId }, session: { userId } } : null); - apiMocks.heartbeatRunsList.mockResolvedValue([ + mineFixture.userId = userId ?? "local-board"; + setFailureRuns([ createFailedRun({ id: "own-failure", agentId: "agent-1", responsibleUserId: "user-1" }), createFailedRun({ id: "other-failure", agentId: "agent-2", responsibleUserId: "user-2", status: "timed_out" }), createFailedRun({ id: "unowned-failure", agentId: "agent-3" }), @@ -425,8 +454,13 @@ describe("Inbox toolbar", () => { root.render(); }); await vi.waitFor(() => { - expect(apiMocks.heartbeatRunsList).toHaveBeenCalled(); + if (tab === "mine" && userId) expect(apiMocks.inboxMine).toHaveBeenCalledOnce(); + if (tab !== "mine") expect(apiMocks.heartbeatRunsList).toHaveBeenCalled(); expect(queryClient.isFetching()).toBe(0); + if (tab === "mine" && !userId) { + expect(apiMocks.inboxMine).not.toHaveBeenCalled(); + expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); + } for (const id of visible) expect(container.querySelector(`a[href$="/runs/${id}"]`)).not.toBeNull(); for (const id of hidden) expect(container.querySelector(`a[href$="/runs/${id}"]`)).toBeNull(); }); @@ -443,7 +477,7 @@ describe("Inbox toolbar", () => { enableIsolatedWorkspaces: false, enableStreamlinedUi: false, }); - apiMocks.issuesList.mockResolvedValue([ + setIssueRows([ createIssue({ id: "legacy-row", identifier: "PAP-1904", title: "Legacy inbox task" }), ]); localStorage.setItem( @@ -486,7 +520,7 @@ describe("Inbox toolbar", () => { it("does not render external-object summaries in inbox rows", async () => { routerMock.location.pathname = "/inbox/mine"; const issue = createIssue({ title: "Inbox row without external object column" }); - apiMocks.issuesList.mockResolvedValue([issue]); + setIssueRows([issue]); externalObjectMocks.summaries.set(issue.id, { total: 1, highestSeverity: "failed", @@ -519,7 +553,7 @@ describe("Inbox toolbar", () => { routerMock.location.pathname = "/inbox/mine"; localStorage.setItem("paperclip:inbox:group-by", "none"); apiMocks.approvalsList.mockResolvedValue([createApproval()]); - apiMocks.heartbeatRunsList.mockResolvedValue([createFailedRun()]); + setFailureRuns([createFailedRun()]); apiMocks.joinRequestsList.mockResolvedValue([createJoinRequest()]); const queryClient = new QueryClient({ @@ -590,7 +624,7 @@ describe("Inbox toolbar", () => { parentId: child.id, title: "Deeply nested inbox task", }); - apiMocks.issuesList.mockResolvedValue([parent, child, grandchild]); + setIssueRows([parent, child, grandchild]); const mountInbox = async () => { const queryClient = new QueryClient({ @@ -753,7 +787,7 @@ describe("Inbox toolbar", () => { const now = new Date(); const localNoon = (daysAgo: number) => new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysAgo, 12, 0, 0); - apiMocks.issuesList.mockResolvedValue([ + setIssueRows([ createIssue({ id: "today", title: "Today task", lastActivityAt: localNoon(0) }), createIssue({ id: "yesterday", title: "Yesterday task", lastActivityAt: localNoon(1) }), createIssue({ id: "earlier", title: "Earlier task", lastActivityAt: localNoon(3) }), @@ -803,7 +837,7 @@ describe("Inbox toolbar", () => { const now = new Date(); const localNoon = (daysAgo: number) => new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysAgo, 12, 0, 0); - apiMocks.issuesList.mockResolvedValue([ + setIssueRows([ createIssue({ id: "today", title: "Today task", lastActivityAt: localNoon(0) }), createIssue({ id: "earlier", title: "Earlier task", lastActivityAt: localNoon(3) }), ]); @@ -844,7 +878,7 @@ describe("Inbox toolbar", () => { executionWorkspacePolicy: { defaultProjectWorkspaceId: "project-workspace-1" }, primaryWorkspace: null, }]); - apiMocks.issuesList.mockResolvedValue([createIssue({ + setIssueRows([createIssue({ projectId: "project-1", executionWorkspaceId: "execution-workspace-1", title: "Workspace-aware task", @@ -910,22 +944,28 @@ describe("Inbox toolbar", () => { }); }); - it("waits for the task index before revealing faster failed-run rows", async () => { + it("waits for the complete Mine bundle before revealing tasks or failed runs", async () => { routerMock.location.pathname = "/inbox/mine"; - let release: (rows: Issue[]) => void = () => undefined; - apiMocks.issuesList.mockImplementation((_company, filters) => filters.projection === "index" - ? new Promise((resolve) => { release = resolve; }) : Promise.resolve([])); - apiMocks.heartbeatRunsList.mockResolvedValue([createFailedRun()]); + const bundle = createDeferred(); + apiMocks.inboxMine.mockReturnValue(bundle.promise); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); - await act(async () => root.render()); - await vi.waitFor(() => expect(apiMocks.heartbeatRunsList).toHaveBeenCalled()); - expect(container.querySelector('[aria-label="Loading tasks"]')).not.toBeNull(); - expect(container.querySelector('[data-inbox-item]')).toBeNull(); - await act(async () => release([createIssue({ title: "Delayed task index row" })])); - await vi.waitFor(() => expect(container.textContent).toContain("Delayed task index row")); - expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); - act(() => root.unmount()); + try { + await act(() => root.render()); + await vi.waitFor(() => expect(apiMocks.inboxMine).toHaveBeenCalledOnce()); + expect(container.querySelector('[aria-label="Loading tasks"]')).not.toBeNull(); + expect(container.querySelector('[data-inbox-item]')).toBeNull(); + expect(apiMocks.issuesList).not.toHaveBeenCalled(); + expect(apiMocks.heartbeatRunsList).not.toHaveBeenCalled(); + await act(() => bundle.resolve(createMineSnapshot({ + issues: [createIssue({ title: "Bundled task" })], failureRuns: [createFailedRun()], + }))); + await vi.waitFor(() => { + expect(container.textContent).toContain("Bundled task"); + expect(container.textContent).toContain("Failed run"); + }); + expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); + } finally { await act(() => root.unmount()); queryClient.clear(); } }); it.each([true, false])("can select a company after a cold inbox mount (Streamlined UI=%s)", async streamlined => { @@ -939,14 +979,265 @@ describe("Inbox toolbar", () => { await act(render); await vi.waitFor(() => expect(container.textContent).toContain(streamlined ? "Select an organization" : "Select a company")); companyState.selectedCompanyId = "company-1"; - apiMocks.issuesList.mockResolvedValue([createIssue({ title: "Cold company task" })]); + setIssueRows([createIssue({ title: "Cold company task" })]); await act(render); await vi.waitFor(() => expect(container.textContent).toContain("Cold company task")); act(() => root.unmount()); }); - it("loads only missing parent metadata before revealing Mine", async () => { + it("uses bundled parent and failure context without separate startup reads", async () => { routerMock.location.pathname = "/inbox/mine"; + const parent = createIssue({ id: "context-parent", title: "Bundled parent", listProjection: "index" }); + const task = createIssue({ id: "task", title: "Bundled child", parentId: parent.id, listProjection: "index" }); + const resolvedRun = createFailedRun({ id: "resolved-run", issueId: parent.id }); + const missingRun = createFailedRun({ id: "missing-run", issueId: "deleted-task", agentId: "missing-agent" }); + mineFixture = createMineSnapshot({ + issues: [task], + contextIssues: [{ ...parent, listProjection: "rows", interruption: { + intentId: "resolved-input", responsibility: "system", state: "resolved", nextRetryAt: null, + canRetry: false, userAction: null, sourceRunIds: [resolvedRun.id], observedRunId: resolvedRun.id, + } }], + failureRuns: [resolvedRun, missingRun], + failureIssueIds: [parent.id, "deleted-task"], + }); + apiMocks.issuesList.mockImplementation((_company, filters) => Promise.resolve( + filters.ids?.includes(task.id) ? [task] : [], + )); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + try { + await act(() => root.render()); + await vi.waitFor(() => { + expect(container.textContent).toContain(task.title); + expect(container.querySelector('a[href="/issues/deleted-task"]')).not.toBeNull(); + expect(client.isFetching()).toBe(0); + }); + expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(2); + expect(apiMocks.inboxMine).toHaveBeenCalledOnce(); + expect(apiMocks.heartbeatRunsList).not.toHaveBeenCalled(); + // Post-reveal diagnostics may read the visible task, but neither a primary + // index nor parent/failure context is fetched by the Mine client. + for (const [, filters] of apiMocks.issuesList.mock.calls) { + expect(filters).toMatchObject({ projection: "rows", ids: [task.id] }); + } + } finally { await act(() => root.unmount()); client.clear(); } + }); + + it("rejects a Mine bundle stamped for another account before caching its rows or context", async () => { + routerMock.location.pathname = "/inbox/mine"; + mineFixture = createMineSnapshot({ userId: "other-account", issues: [createIssue({ title: "Private other-account task" })] }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + try { + await act(() => root.render()); + await vi.waitFor(() => expect(container.textContent).toContain("Your account changed. Refresh the inbox.")); + expect(container.textContent).not.toContain("Private other-account task"); + expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); + expect(client.getQueryData([...queryKeys.issues.list("company-1"), "mine-context", "local-board"])).toBeUndefined(); + expect(apiMocks.issuesList).not.toHaveBeenCalled(); + } finally { await act(() => root.unmount()); client.clear(); } + }); + + it.each(["network", 429, 503] as const)("keeps cached Mine rows and their DOM nodes during a %s aggregate refresh failure", async status => { + routerMock.location.pathname = "/inbox/mine"; + setIssueRows([createIssue({ title: "Cached readable task" })]); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + try { + await act(() => root.render()); + await vi.waitFor(() => expect(container.textContent).toContain("Cached readable task")); + const row = container.querySelector("[data-inbox-item]"); + apiMocks.inboxMine.mockRejectedValue(status === "network" ? new Error("Refresh unavailable") : new ApiError("Refresh unavailable", status, null)); + await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe("company-1") }); }); + await vi.waitFor(() => expect(document.body.textContent).toContain("Some inbox items couldn’t load.")); + expect(container.querySelector("[data-inbox-item]")).toBe(row); + expect(container.textContent).toContain("Cached readable task"); + expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); + expect(apiMocks.inboxMine).toHaveBeenCalledTimes(2); + } finally { await act(() => root.unmount()); client.clear(); } + }); + + it.each([401, 403])("hides cached Mine rows after a %s access denial", async status => { + routerMock.location.pathname = "/inbox/mine"; + setIssueRows([createIssue({ title: "Previously accessible private task" })]); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + const message = `Company access denied (${status})`; + try { + await act(() => root.render()); + await vi.waitFor(() => expect(container.textContent).toContain("Previously accessible private task")); + apiMocks.inboxMine.mockRejectedValue(new ApiError(message, status, null)); + await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe("company-1") }); }); + await vi.waitFor(() => expect(container.textContent).toContain(message)); + expect(container.textContent).not.toContain("Previously accessible private task"); + expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0); + expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); + expect(apiMocks.inboxMine).toHaveBeenCalledTimes(2); + expect(apiMocks.inboxMine).toHaveBeenLastCalledWith("company-1", expect.anything()); + } finally { await act(() => root.unmount()); client.clear(); } + }); + + it("fails closed when a refresh returns another account's bundle despite cached Mine rows", async () => { + routerMock.location.pathname = "/inbox/mine"; + setIssueRows([createIssue({ title: "Previous authenticated task" })]); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + try { + await act(() => root.render()); + await vi.waitFor(() => expect(container.textContent).toContain("Previous authenticated task")); + apiMocks.inboxMine.mockResolvedValue(createMineSnapshot({ + userId: "another-account", issues: [createIssue({ title: "Another account secret" })], + })); + await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe("company-1") }); }); + await vi.waitFor(() => { + expect(container.textContent).toContain("Your account changed. Refresh the inbox."); + expect(apiMocks.authSession).toHaveBeenCalledTimes(2); + }); + expect(container.textContent).not.toContain("Previous authenticated task"); + expect(container.textContent).not.toContain("Another account secret"); + expect(client.getQueryData([...queryKeys.issues.list("company-1"), "mine-context", "local-board"])?.userId).toBe("local-board"); + } finally { await act(() => root.unmount()); client.clear(); } + }); + + it("isolates an account switch from a late response for the previous account", async () => { + routerMock.location.pathname = "/inbox/mine"; + const previous = createDeferred(); + const next = createDeferred(); + apiMocks.inboxMine.mockReturnValueOnce(previous.promise).mockReturnValueOnce(next.promise); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + try { + await act(() => root.render()); + await vi.waitFor(() => expect(apiMocks.inboxMine).toHaveBeenCalledOnce()); + await act(() => { client.setQueryData(queryKeys.auth.session, { user: { id: "next-account" }, session: { userId: "next-account" } }); }); + await vi.waitFor(() => expect(apiMocks.inboxMine).toHaveBeenCalledTimes(2)); + await act(() => next.resolve(createMineSnapshot({ userId: "next-account", issues: [createIssue({ title: "Next account task" })] }))); + await vi.waitFor(() => expect(container.textContent).toContain("Next account task")); + await act(() => previous.resolve(createMineSnapshot({ issues: [createIssue({ title: "Previous account secret" })] }))); + await vi.waitFor(() => expect(client.isFetching()).toBe(0)); + expect(container.textContent).toContain("Next account task"); + expect(container.textContent).not.toContain("Previous account secret"); + expect(client.getQueryData([...queryKeys.issues.list("company-1"), "mine-context", "next-account"])?.userId).toBe("next-account"); + } finally { await act(() => root.unmount()); client.clear(); } + }); + + it("holds the initial reveal barrier for a new account in the same company", async () => { + routerMock.location.pathname = "/inbox/mine"; + setIssueRows([createIssue({ title: "First account task" })]); + const next = createDeferred(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + try { + await act(() => root.render()); + await vi.waitFor(() => expect(container.textContent).toContain("First account task")); + apiMocks.inboxMine.mockReturnValue(next.promise); + await act(() => { client.setQueryData(queryKeys.auth.session, { user: { id: "next-account" }, session: { userId: "next-account" } }); }); + await vi.waitFor(() => expect(apiMocks.inboxMine).toHaveBeenCalledTimes(2)); + expect(container.querySelector('[aria-label="Loading tasks"]')).not.toBeNull(); + expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0); + expect(container.textContent).not.toContain("First account task"); + expect(container.textContent).not.toContain("Inbox zero."); + await act(() => next.resolve(createMineSnapshot({ + userId: "next-account", issues: [createIssue({ title: "Second account task" })], + }))); + await vi.waitFor(() => expect(container.textContent).toContain("Second account task")); + expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); + } finally { await act(() => root.unmount()); client.clear(); } + }); + + it("keeps aggregate context scoped to the selected company", async () => { + routerMock.location.pathname = "/inbox/mine"; + apiMocks.inboxMine.mockImplementation(async companyId => createMineSnapshot({ + issues: [createIssue({ id: `task-${companyId}`, companyId, title: `Task for ${companyId}` })], + })); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + const render = () => root.render(); + try { + await act(render); + await vi.waitFor(() => expect(container.textContent).toContain("Task for company-1")); + companyState.selectedCompanyId = "company-2"; + await act(render); + await vi.waitFor(() => expect(container.textContent).toContain("Task for company-2")); + expect(container.textContent).not.toContain("Task for company-1"); + expect(apiMocks.inboxMine).toHaveBeenCalledWith("company-2", expect.objectContaining({ signal: expect.any(AbortSignal) })); + expect(client.getQueryData([...queryKeys.issues.list("company-2"), "mine-context", "local-board"])?.issues[0]?.companyId).toBe("company-2"); + } finally { await act(() => root.unmount()); client.clear(); } + }); + + it("cannot reuse another account's private parent caption in a cached Mine fallback", async () => { + routerMock.location.pathname = "/inbox/mine"; + localStorage.setItem("paperclip:inbox:nesting", "false"); + localStorage.setItem(taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }), JSON.stringify({ + version: 1, companyId: "company-1", collectionKey: "inbox", columns: ["parent"], viewState: {}, + })); + const parent = createIssue({ id: "private-parent", title: "First account private caption", identifier: "PAP-SECRET", listProjection: "index" }); + const child = createIssue({ id: "shared-child", title: "Cached fallback child", parentId: parent.id, listProjection: "index" }); + const pendingParent = createDeferred(); + const pendingBundle = createDeferred(); + let switched = false; + apiMocks.inboxMine.mockRejectedValueOnce(new Error("Aggregate temporarily unavailable")).mockReturnValueOnce(pendingBundle.promise); + apiMocks.issuesList.mockImplementation((_company, filters) => { + if (filters.ids?.includes(parent.id)) return switched ? pendingParent.promise : Promise.resolve([parent]); + return Promise.resolve([child]); + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); + client.setQueryData(queryKeys.auth.session, { user: { id: "local-board" }, session: { userId: "local-board" } }); + const sourceKey = (userId: string) => [...queryKeys.issues.listMineByMe("company-1"), "index", "with-routine-executions", "live-descendant-summary", 100, userId]; + client.setQueryData(sourceKey("local-board"), [child]); + const root = createRoot(container); + try { + await act(() => root.render()); + await vi.waitFor(() => expect(container.querySelector(`[title="${parent.title}"]`)?.textContent).toBe(parent.identifier)); + switched = true; + client.setQueryData(sourceKey("next-account"), [child]); + await act(() => { client.setQueryData(queryKeys.auth.session, { user: { id: "next-account" }, session: { userId: "next-account" } }); }); + await vi.waitFor(() => expect(apiMocks.inboxMine).toHaveBeenCalledTimes(2)); + expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.ids?.includes(parent.id))).toHaveLength(1); + expect(container.querySelector(`[title="${parent.title}"]`)).toBeNull(); + await act(() => pendingBundle.reject(new Error("New account aggregate temporarily unavailable"))); + await vi.waitFor(() => expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.ids?.includes(parent.id))).toHaveLength(2)); + expect(container.querySelector(`[title="${parent.title}"]`)).toBeNull(); + expect(container.textContent).not.toContain(parent.identifier); + await act(() => pendingParent.resolve([])); + await vi.waitFor(() => expect(container.textContent).toContain(child.title)); + expect(container.querySelector(`[title="${parent.title}"]`)).toBeNull(); + expect(container.textContent).not.toContain(parent.identifier); + } finally { await act(() => root.unmount()); client.clear(); } + }); + + it("cannot reuse another account's resolved rich state for a fallback failure", async () => { + routerMock.location.pathname = "/inbox/recent"; + const task = createIssue({ id: "private-failure-task", title: "First account failure context", listProjection: "rows" }); + const failure = createFailedRun({ issueId: task.id }); + setFailureRuns([failure]); + const pendingContext = createDeferred(); + let switched = false; + apiMocks.issuesList.mockImplementation((_company, filters) => { + if (!filters.ids?.includes(task.id)) return Promise.resolve([]); + return switched ? pendingContext.promise : Promise.resolve([{ ...task, interruption: { + intentId: "private-resolved", responsibility: "system", state: "resolved", nextRetryAt: null, + canRetry: false, userAction: null, sourceRunIds: [failure.id], observedRunId: failure.id, + } }]); + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } } }); + const root = createRoot(container); + try { + await act(() => root.render()); + await vi.waitFor(() => expect(client.isFetching()).toBe(0)); + expect(container.querySelector(`a[href="/issues/${task.id}"]`)).toBeNull(); + switched = true; + await act(() => { client.setQueryData(queryKeys.auth.session, { user: { id: "next-account" }, session: { userId: "next-account" } }); }); + await vi.waitFor(() => expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.ids?.includes(task.id))).toHaveLength(2)); + expect(container.textContent).not.toContain(task.title); + await act(() => pendingContext.resolve([])); + await vi.waitFor(() => expect(container.querySelector(`a[href="/issues/${task.id}"]`)).not.toBeNull()); + expect(container.textContent).not.toContain(task.title); + } finally { await act(() => root.unmount()); client.clear(); } + }); + + it("loads only missing parent metadata before revealing Recent", async () => { + routerMock.location.pathname = "/inbox/recent"; localStorage.setItem(taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }), JSON.stringify({ version: 1, companyId: "company-1", collectionKey: "inbox", columns: ["parent"], viewState: {}, })); @@ -983,7 +1274,7 @@ describe("Inbox toolbar", () => { }); it("discards old parent context when a refreshed index supplies the parent", async () => { - routerMock.location.pathname = "/inbox/mine"; + routerMock.location.pathname = "/inbox/recent"; localStorage.setItem("paperclip:inbox:nesting", "false"); localStorage.setItem(taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }), JSON.stringify({ version: 1, companyId: "company-1", collectionKey: "inbox", columns: ["parent"], viewState: {}, @@ -1007,11 +1298,11 @@ describe("Inbox toolbar", () => { }); it.each([true, false])("waits for failure context even when its parent is already indexed=%s", async (indexed) => { - routerMock.location.pathname = "/inbox/mine"; + routerMock.location.pathname = "/inbox/recent"; const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" }); const child = createIssue({ id: "child", parentId: parent.id, title: "Readable child", listProjection: "index" }); const failure = createFailedRun({ issueId: parent.id, responsibleUserId: "local-board" }); - apiMocks.heartbeatRunsList.mockResolvedValue([failure]); + setFailureRuns([failure]); let releaseFailure!: (rows: Issue[]) => void; const pendingFailure = new Promise(resolve => { releaseFailure = resolve; }); apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids @@ -1036,7 +1327,7 @@ describe("Inbox toolbar", () => { }); it("upgrades cached parent metadata when the same task becomes a failure context", async () => { - routerMock.location.pathname = "/inbox/mine"; + routerMock.location.pathname = "/inbox/recent"; const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" }); const child = createIssue({ id: "child", parentId: parent.id, title: "Readable child", listProjection: "index" }); const failure = createFailedRun({ issueId: parent.id, responsibleUserId: "local-board" }); @@ -1074,8 +1365,8 @@ describe("Inbox toolbar", () => { routerMock.location.pathname = "/inbox/mine"; const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" }); const child = createIssue({ id: "child", parentId: parent.id, title: "Mine child", listProjection: "index" }); - apiMocks.issuesList.mockImplementation((_company, filters) => Promise.resolve(filters.ids - ? [parent] : filters.inboxArchivedByUserId ? [child] : [])); + mineFixture = createMineSnapshot({ issues: [child], contextIssues: [parent] }); + apiMocks.issuesList.mockImplementation((_company, filters) => Promise.resolve(filters.ids ? [parent] : [])); const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); try { @@ -1089,7 +1380,7 @@ describe("Inbox toolbar", () => { }); it("waits for rich failure state when heartbeat data arrives after parent captions", async () => { - routerMock.location.pathname = "/inbox/mine"; + routerMock.location.pathname = "/inbox/recent"; const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" }); const child = createIssue({ id: "child", parentId: parent.id, title: "Mine child", listProjection: "index" }); const failure = createFailedRun({ issueId: parent.id, responsibleUserId: "local-board" }); @@ -1120,11 +1411,11 @@ describe("Inbox toolbar", () => { }); it("keeps known missing-task failures visible while new failure context is pending", async () => { - routerMock.location.pathname = "/inbox/mine"; + routerMock.location.pathname = "/inbox/recent"; const child = createIssue({ title: "Readable task", listProjection: "index" }); const first = createFailedRun({ id: "failure-a", issueId: "missing-a", agentId: "agent-a", responsibleUserId: "local-board" }); const second = createFailedRun({ id: "failure-b", issueId: "missing-b", agentId: "agent-b", responsibleUserId: "local-board" }); - apiMocks.heartbeatRunsList.mockResolvedValue([first]); + setFailureRuns([first]); let releaseFailures!: (rows: Issue[]) => void; const pendingFailures = new Promise(resolve => { releaseFailures = resolve; }); apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids ? Promise.resolve([child]) @@ -1151,6 +1442,7 @@ describe("Inbox toolbar", () => { it("keeps more than 25 description-only matches in Mine with archive actions", async () => { routerMock.location.pathname = "/inbox/mine"; const rows = Array.from({ length: 30 }, (_, index) => createIssue({ id: `source-${index}`, title: `Source task ${index}`, identifier: `PAP-${index}`, listProjection: "index" })); + mineFixture = createMineSnapshot({ issues: rows }); apiMocks.issuesList.mockImplementation((_company, filters) => Promise.resolve(filters.q && !filters.ids ? [] : rows)); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); @@ -1171,6 +1463,7 @@ describe("Inbox toolbar", () => { it("keeps rows readable and offers retry when optional details fail", async () => { routerMock.location.pathname = "/inbox/mine"; let failDetails = true; + mineFixture = createMineSnapshot({ issues: [createIssue({ title: "Readable task" })] }); apiMocks.issuesList.mockImplementation((_company, filters) => filters.projection === "rows" && failDetails ? Promise.reject(new Error("Details unavailable")) : Promise.resolve([createIssue({ title: "Readable task" })])); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); @@ -1188,14 +1481,14 @@ describe("Inbox toolbar", () => { it("refreshes diagnostics when a visible index row changes without changing its ID", async () => { routerMock.location.pathname = "/inbox/mine"; const row = createIssue({ title: "Live task", listProjection: "index" }); - apiMocks.issuesList.mockResolvedValue([row]); + setIssueRows([row]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); await act(() => root.render()); const detailsReads = () => apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.projection === "rows").length; await vi.waitFor(() => expect(detailsReads()).toBe(1)); const updated = { ...row, updatedAt: new Date(new Date(row.updatedAt).getTime() + 1000) }; - apiMocks.issuesList.mockResolvedValue([updated]); + setIssueRows([updated]); await act(() => { queryClient.setQueriesData({ predicate: (query) => query.queryKey.includes("index") }, [updated]); }); @@ -1204,25 +1497,31 @@ describe("Inbox toolbar", () => { act(() => root.unmount()); }); - it.each(["approvalsList", "joinRequestsList", "heartbeatRunsList"] as const)( + it.each(["approvalsList", "joinRequestsList", "failureError", "contextError"] as const)( "keeps available tasks visible when %s fails and during retry", async (source) => { routerMock.location.pathname = "/inbox/mine"; - apiMocks.issuesList.mockResolvedValue([createIssue({ title: "Available task" })]); - apiMocks[source].mockRejectedValue(new Error("Optional source unavailable")); + setIssueRows([createIssue({ title: "Available task" })]); + const bundled = source === "failureError" || source === "contextError"; + if (bundled) mineFixture = { ...mineFixture, [source]: { status: 503, message: "Optional source unavailable" } }; + else apiMocks[source].mockRejectedValue(new Error("Optional source unavailable")); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); - await act(() => root.render()); - await vi.waitFor(() => expect(container.textContent).toContain("Available task")); - expect(document.body.textContent).toContain("Some inbox items couldn’t load."); - let resolveRetry: (rows: []) => void = () => undefined; - apiMocks[source].mockImplementation(() => new Promise((resolve) => { resolveRetry = resolve; })); - await act(() => Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent === "Retry missing items")!.click()); - await vi.waitFor(() => expect(apiMocks[source]).toHaveBeenCalledTimes(2)); - expect(container.textContent).toContain("Available task"); - expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); - await act(() => resolveRetry([])); - await vi.waitFor(() => expect(document.body.textContent).not.toContain("Some inbox items couldn’t load.")); - act(() => root.unmount()); + try { + await act(() => root.render()); + await vi.waitFor(() => expect(container.textContent).toContain("Available task")); + expect(document.body.textContent).toContain("Some inbox items couldn’t load."); + const row = container.querySelector("[data-inbox-item]"); + const retry = createDeferred(); + if (bundled) apiMocks.inboxMine.mockReturnValue(retry.promise); + else apiMocks[source].mockReturnValue(retry.promise); + await act(() => Array.from(document.body.querySelectorAll("button")).find(button => button.textContent === "Retry missing items")!.click()); + await vi.waitFor(() => expect(bundled ? apiMocks.inboxMine : apiMocks[source]).toHaveBeenCalledTimes(2)); + expect(container.querySelector("[data-inbox-item]")).toBe(row); + expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); + await act(() => retry.resolve(bundled ? createMineSnapshot({ issues: mineFixture.issues }) : [])); + await vi.waitFor(() => expect(document.body.textContent).not.toContain("Some inbox items couldn’t load.")); + if (bundled) expect(apiMocks.heartbeatRunsList).not.toHaveBeenCalled(); + } finally { await act(() => root.unmount()); queryClient.clear(); } }, ); @@ -1233,7 +1532,7 @@ describe("Inbox toolbar", () => { viewState: { issueFilters: { externalObjectStatuses: ["failed"] } }, })); apiMocks.experimentalSettings.mockResolvedValue({ enableStreamlinedUi: true, enableExternalObjects: true }); - apiMocks.issuesList.mockResolvedValue([createIssue()]); + setIssueRows([createIssue()]); externalObjectMocks.error = new Error("External statuses unavailable"); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); @@ -1245,8 +1544,8 @@ describe("Inbox toolbar", () => { act(() => root.unmount()); }); - it("requests live descendant summaries for issue rows", async () => { - routerMock.location.pathname = "/inbox/mine"; + it("requests live descendant summaries for Recent issue rows", async () => { + routerMock.location.pathname = "/inbox/recent"; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, @@ -1263,7 +1562,7 @@ describe("Inbox toolbar", () => { await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledTimes(1)); expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({ - touchedByUserId: "me", inboxArchivedByUserId: "me", projection: "index", + touchedByUserId: "me", projection: "index", includeLiveDescendantSummary: true, limit: 100, sortField: "updated", sortDir: "desc", })); @@ -1276,7 +1575,7 @@ describe("Inbox toolbar", () => { routerMock.location.pathname = "/inbox/mine"; const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" }); const issueB = createIssue({ id: "issue-b", identifier: "PAP-1002", title: "Second inbox row" }); - apiMocks.issuesList.mockResolvedValue([issueA, issueB]); + setIssueRows([issueA, issueB]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, @@ -1339,7 +1638,7 @@ describe("Inbox toolbar", () => { title: "Read inbox row", isUnreadForMe: false, }); - apiMocks.issuesList.mockResolvedValue([unread, read]); + setIssueRows([unread, read]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, @@ -1398,7 +1697,7 @@ describe("Inbox toolbar", () => { const issueA = createIssue({ id: "issue-a", identifier: "PAP-2001", title: "Sync row A" }); const issueB = createIssue({ id: "issue-b", identifier: "PAP-2002", title: "Sync row B" }); const issueC = createIssue({ id: "issue-c", identifier: "PAP-2003", title: "Sync row C" }); - apiMocks.issuesList.mockResolvedValue([issueA, issueB, issueC]); + setIssueRows([issueA, issueB, issueC]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, @@ -1437,7 +1736,7 @@ describe("Inbox toolbar", () => { // A poll reshapes the list (row B's title changes → new nav array) before // the keypress. This is what used to null the hovered index and strand // j/k back at the top. - apiMocks.issuesList.mockResolvedValue([issueA, { ...issueB, title: "Sync row B (updated)" }, issueC]); + setIssueRows([issueA, { ...issueB, title: "Sync row B (updated)" }, issueC]); await act(async () => { await queryClient.invalidateQueries(); }); @@ -1479,7 +1778,7 @@ describe("Inbox toolbar", () => { title: "Pin row C", lastActivityAt: new Date(base + 1000), }); - apiMocks.issuesList.mockResolvedValue([issueA, issueB, issueC]); + setIssueRows([issueA, issueB, issueC]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, @@ -1517,7 +1816,7 @@ describe("Inbox toolbar", () => { expect(orderOf()).toEqual(["A", "B", "C"]); // A poll makes row C the most-recently-active: the fresh sort is now [C, A, B]. - apiMocks.issuesList.mockResolvedValue([ + setIssueRows([ { ...issueA }, { ...issueB }, { ...issueC, lastActivityAt: new Date(base + 9000) }, @@ -1563,7 +1862,7 @@ describe("Inbox toolbar", () => { routerMock.location.pathname = "/inbox/mine"; const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" }); const issueB = createIssue({ id: "issue-b", identifier: "PAP-1002", title: "Second inbox row" }); - apiMocks.issuesList.mockResolvedValue([issueA, issueB]); + setIssueRows([issueA, issueB]); const archiveA = createDeferred<{ id: string; archivedAt: Date }>(); apiMocks.archiveFromInbox.mockImplementation((id: string) => id === "issue-a" ? archiveA.promise : Promise.resolve({ id, archivedAt: new Date() }), @@ -1631,7 +1930,7 @@ describe("Inbox toolbar", () => { identifier: "PAP-1001", title: "Archived inbox row", }); - apiMocks.issuesList.mockResolvedValue([archivedIssue]); + setIssueRows([archivedIssue]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, @@ -1681,7 +1980,7 @@ describe("Inbox toolbar", () => { identifier: "PAP-1001", title: "Undoable inbox row", }); - apiMocks.issuesList.mockResolvedValue([archivedIssue]); + setIssueRows([archivedIssue]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, @@ -1974,13 +2273,11 @@ describe("Coordinator Inbox collection reads", () => { const root = createRoot(container); try { await act(async () => root.render()); - await vi.waitFor(() => expect(apiMocks.heartbeatRunsList).toHaveBeenCalled()); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); - const sources = apiMocks.issuesList.mock.calls.filter(call => call[1]?.projection !== "rows").map(call => call[1]); - expect(sources).toHaveLength(1); - expect(sources.every(source => source.projection === "index")).toBe(true); - expect(apiMocks.dashboardSummary).toHaveBeenCalledTimes(0); - expect(apiMocks.heartbeatRunsList.mock.calls[0]?.[3]?.latestFailuresOnly === true).toBe(true); + await vi.waitFor(() => expect(apiMocks.inboxMine).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(client.isFetching()).toBe(0)); + expect(apiMocks.issuesList).not.toHaveBeenCalled(); + expect(apiMocks.heartbeatRunsList).not.toHaveBeenCalled(); + expect(apiMocks.dashboardSummary).not.toHaveBeenCalled(); } finally { await act(async () => root.unmount()); client.clear(); container.remove(); } }); }); diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index d2ec65baa74..42fddcedf8b 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -129,7 +129,7 @@ import { } from "lucide-react"; import { Input } from "@/components/ui/input"; import { PageTabBar } from "../components/PageTabBar"; -import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; +import type { Approval, HeartbeatRun, InboxMineSnapshot, Issue, JoinRequest } from "@paperclipai/shared"; import { ACTIONABLE_APPROVAL_STATUSES, DEFAULT_INBOX_ISSUE_COLUMNS, @@ -900,6 +900,10 @@ function StreamlinedInbox({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); + const currentUserId = session?.user.id ?? session?.session.userId ?? null; + const mineContextQueryKey = [...queryKeys.issues.list(selectedCompanyId!), "mine-context", currentUserId] as const; + const { data: mineSnapshot } = useQuery({ queryKey: mineContextQueryKey, enabled: false }); + const { data: agents, isLoading: isAgentsLoading } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -999,31 +1003,26 @@ function StreamlinedInbox({ error: mineIssuesError, dataUpdatedAt: mineIssuesUpdatedAt, } = useQuery({ - queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const, - queryFn: () => - issuesApi.listCompact(selectedCompanyId!, { - touchedByUserId: "me", - inboxArchivedByUserId: "me", - status: INBOX_MINE_ISSUE_STATUS_FILTER, - projection: "index", - sortField: "updated", - sortDir: "desc", - includeRoutineExecutions: true, - includeLiveDescendantSummary: true, - limit: INBOX_ISSUE_LIST_LIMIT, - }).then((rows) => rows as Issue[]), - enabled: !!selectedCompanyId && tab === "mine", + queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT, currentUserId] as const, + queryFn: async ({ signal }) => { + const snapshot = await issuesApi.inboxMine(selectedCompanyId!, { signal }); + if (snapshot.userId !== currentUserId) { + void queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + throw new ApiError("Your account changed. Refresh the inbox.", 409, { error: "account_changed" }); + } + queryClient.setQueryData(mineContextQueryKey, snapshot); + return snapshot.issues as Issue[]; + }, + enabled: !!selectedCompanyId && !!currentUserId && tab === "mine", + refetchOnMount: mineSnapshot ? undefined : "always", refetchOnWindowFocus: false, - staleTime: INBOX_HOT_PATH_STALE_MS, + staleTime: mineSnapshot ? INBOX_HOT_PATH_STALE_MS : 0, }); - const mineIssuesQueryKey = [...queryKeys.issues.listMineByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const; - const sharedMineIssues = useSharedPollingQuery({ - companyId: selectedCompanyId, - resourceKey: "inbox:mine-issues", - queryKey: mineIssuesQueryKey, - enabled: !!selectedCompanyId && tab === "mine", - }); - usePublishSharedQueryData(sharedMineIssues, mineIssuesRaw, mineIssuesUpdatedAt); + const mineIssuesQueryKey = [...queryKeys.issues.listMineByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT, currentUserId] as const; + // Mine owns one coherent response: broadcasting only its task array would + // refresh that array while another tab retained stale parent/failure context. + // Each tab uses the authenticated aggregate read; the server coalesces it. + const { data: touchedIssuesRaw = [], isLoading: isTouchedIssuesLoading, @@ -1055,42 +1054,53 @@ function StreamlinedInbox({ }); usePublishSharedQueryData(sharedTouchedIssues, touchedIssuesRaw, touchedIssuesUpdatedAt); - const { data: heartbeatRuns, isLoading: isRunsLoading, error: runsError } = useQuery({ + const { data: otherHeartbeatRuns, isLoading: isOtherRunsLoading, error: otherRunsError } = useQuery({ queryKey: [...queryKeys.heartbeats(selectedCompanyId!), "inbox-failures", INBOX_HEARTBEAT_RUN_LIMIT], queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT, { summary: true, latestFailuresOnly: true }), - enabled: !!selectedCompanyId, + enabled: !!selectedCompanyId && tab !== "mine", refetchOnWindowFocus: false, staleTime: INBOX_HOT_PATH_STALE_MS, }); + const heartbeatRuns = tab === "mine" ? mineSnapshot?.failureRuns : otherHeartbeatRuns; + const isRunsLoading = tab === "mine" ? !!currentUserId && !mineSnapshot && !mineIssuesError : isOtherRunsLoading; + const runsError = tab === "mine" && mineSnapshot?.failureError + ? new ApiError(mineSnapshot.failureError.message, mineSnapshot.failureError.status, null) : tab === "mine" ? null : otherRunsError; + const bundledContextIds = useMemo(() => new Set(tab === "mine" && mineSnapshot ? [ + ...mineSnapshot.issues.map((issue) => issue.parentId).filter((id): id is string => Boolean(id)), + ...mineSnapshot.failureRuns.map(readIssueIdFromRun).filter((id): id is string => Boolean(id)), + ] : []), [tab, mineSnapshot]); const { parentContextIds, failureContextIds, contextIssueIds } = useMemo(() => { const sourceIssues = tab === "mine" ? mineIssuesRaw : touchedIssuesRaw; const loadedIds = new Set(sourceIssues.map((issue) => issue.id)); // Failure cards need interruption state even when their task is already in // the index. Parent captions only need index metadata. const failureIds = new Set((heartbeatRuns ?? []).map(readIssueIdFromRun) - .filter((id): id is string => Boolean(id))); + .filter((id): id is string => Boolean(id)) + .filter((id) => !bundledContextIds.has(id))); const parentIds = new Set(sourceIssues.map((issue) => issue.parentId) .filter((id): id is string => Boolean(id)) - .filter((id) => !loadedIds.has(id) && !failureIds.has(id))); + .filter((id) => !loadedIds.has(id) && !failureIds.has(id) && !bundledContextIds.has(id))); return { parentContextIds: [...parentIds].sort(), failureContextIds: [...failureIds].sort(), contextIssueIds: new Set([...parentIds, ...failureIds]), }; - }, [tab, mineIssuesRaw, touchedIssuesRaw, heartbeatRuns]); - const needsIssueContext = !!selectedCompanyId && contextIssueIds.size > 0 && tab !== "blocked"; + }, [tab, mineIssuesRaw, touchedIssuesRaw, heartbeatRuns, bundledContextIds]); + const needsIssueContext = !!selectedCompanyId && contextIssueIds.size > 0 && tab !== "blocked" + && (tab !== "mine" || !!mineSnapshot || !!mineIssuesError); const { data: issueContext, isLoading: isContextIssuesLoading, isPlaceholderData: isContextIssuesPlaceholder, - error: contextIssuesError, + error: extraContextIssuesError, } = useQuery<{ companyId: string | null; + userId: string | null; issues: Issue[]; failureIssueIds: string[]; }>({ - queryKey: [...queryKeys.issues.list(selectedCompanyId!), "row-context", "parent-index-with-failure-state", parentContextIds, failureContextIds], - placeholderData: (previous) => needsIssueContext && previous?.companyId === selectedCompanyId + queryKey: [...queryKeys.issues.list(selectedCompanyId!), "row-context", "parent-index-with-failure-state", parentContextIds, failureContextIds, currentUserId], + placeholderData: (previous) => needsIssueContext && previous?.companyId === selectedCompanyId && previous.userId === currentUserId ? { ...previous, issues: previous.issues.filter((issue) => contextIssueIds.has(issue.id)) } : undefined, queryFn: async ({ signal }) => { @@ -1110,12 +1120,25 @@ function StreamlinedInbox({ ]); // A completed failure read also establishes absence for filtered/missing // tasks. Parent-only placeholders cannot supply this coverage. - return { companyId: selectedCompanyId, issues: [...parents, ...failures], failureIssueIds: failureContextIds }; + return { companyId: selectedCompanyId, userId: currentUserId, issues: [...parents, ...failures], failureIssueIds: failureContextIds }; }, enabled: needsIssueContext, }); - const contextIssues = issueContext?.issues ?? []; - const knownFailureContextIds = useMemo(() => new Set(issueContext?.failureIssueIds ?? []), [issueContext]); + const contextIssuesError = extraContextIssuesError ?? (tab === "mine" && mineSnapshot?.contextError + ? new ApiError(mineSnapshot.contextError.message, mineSnapshot.contextError.status, null) : null); + const contextIssues = useMemo(() => { + if (tab !== "mine" || !mineSnapshot) return issueContext?.issues ?? []; + const sourceIds = new Set(mineIssuesRaw.map((issue) => issue.id)); + const parentIds = new Set(mineIssuesRaw.map((issue) => issue.parentId)); + return [ + ...mineSnapshot.issues.filter((issue) => !sourceIds.has(issue.id) && parentIds.has(issue.id)), + ...mineSnapshot.contextIssues.filter((issue) => parentIds.has(issue.id) || mineSnapshot.failureIssueIds.includes(issue.id)), + ...(issueContext?.issues ?? []), + ] as Issue[]; + }, [tab, mineSnapshot, mineIssuesRaw, issueContext]); + const knownFailureContextIds = useMemo(() => new Set([ + ...(tab === "mine" ? mineSnapshot?.failureIssueIds ?? [] : []), ...(issueContext?.failureIssueIds ?? []), + ]), [tab, mineSnapshot, issueContext]); const issues = useMemo(() => [...mineIssuesRaw, ...touchedIssuesRaw, ...contextIssues], [mineIssuesRaw, touchedIssuesRaw, contextIssues]); const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!); const sharedLiveRuns = useSharedPollingQuery({ @@ -1139,7 +1162,6 @@ function StreamlinedInbox({ queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), enabled: !!selectedCompanyId, }); - const currentUserId = session?.user.id ?? session?.session.userId ?? null; const [archivingIssueIds, setArchivingIssueIds] = useState>(new Set()); const [undoableArchiveIssueIds, setUndoableArchiveIssueIds] = useState([]); const [unarchivingIssueIds, setUnarchivingIssueIds] = useState>(new Set()); @@ -1939,6 +1961,7 @@ function StreamlinedInbox({ }, onSuccess: ({ newRun, originalRun }) => { retryRequestKeys.current.delete(originalRun.id); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(originalRun.companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId, originalRun.agentId) }); if (newRun.runId) @@ -2507,16 +2530,25 @@ function StreamlinedInbox({ !isRunsLoading && (!issueFilters.liveOnly || !isLiveRunsLoading) && (issueFilters.externalObjectStatuses.length === 0 || externalObjectSummariesReady || !!externalObjectSummariesError); - const listError = sourceSearchError ?? (tab === "mine" ? mineIssuesError : touchedIssuesError) + const sourceError = tab === "mine" ? mineIssuesError : touchedIssuesError; + const sourceUpdatedAt = tab === "mine" ? mineIssuesUpdatedAt : touchedIssuesUpdatedAt; + const canRetainSourceRows = sourceUpdatedAt > 0 && !(sourceError instanceof ApiError + && (sourceError.status < 500 && sourceError.status !== 429)); + const listError = sourceSearchError ?? (canRetainSourceRows ? null : sourceError) ?? (issueFilters.externalObjectStatuses.length > 0 ? externalObjectSummariesError : null); - const optionalSourceError = dismissalsError ?? approvalsError ?? joinRequestsError ?? runsError ?? contextIssuesError; + const optionalSourceError = dismissalsError ?? approvalsError ?? joinRequestsError ?? runsError ?? contextIssuesError + ?? (tab === "mine" ? mineIssuesError : touchedIssuesError); const retryFailedSources = () => { + // Optional failures belong to the aggregate read, whose task rows stay usable. + if (tab === "mine" && (mineSnapshot?.failureError || mineSnapshot?.contextError)) { + void queryClient.refetchQueries({ queryKey: mineIssuesQueryKey, exact: true }); + } // Retry only failed reads for this company; settled rows stay visible. void queryClient.refetchQueries({ predicate: (query) => query.state.status === "error" && query.queryKey.includes(selectedCompanyId), type: "active" }); }; - const revealScope = JSON.stringify([selectedCompanyId, tab, normalizedSearchQuery, issueFilters]); + const revealScope = JSON.stringify([currentUserId, selectedCompanyId, tab, normalizedSearchQuery, issueFilters]); const [revealedScope, setRevealedScope] = useState(null); useEffect(() => { if (initialSourcesSettled && !listError) setRevealedScope(revealScope);