From b40e6edaea4d327b5b5a7c7e5bb3fb0dae49fb1e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:28:57 -0600 Subject: [PATCH] perf(inbox): combine startup reads and reduce refetch pressure (#18790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Mine shows the work and failures that need the viewing user's attention. > - A reload waits for account access, task rows, parent captions and failure state. > - Those reads form several browser request stages before useful rows appear. > - Live invalidations can also restart the same task-list request more than once. > - This change supplies startup data together and removes redundant refresh work. > - It preserves current authority, failure presentation, archive state and row stability. ## Linked Issues or Issue Description Refs #18645. Related: #18642. This change implements the read-receipt behavior independently on current master. It does not use that PR's branch or patch. **What happened?** Mine starts its primary request after the session, company and board-access reads. It then requests parent captions and rich failure context. Four observed loads spent another 0.45–0.70 seconds after the primary rows arrived. One slower load waited 1.71 seconds for parent context. The same run observed two HTTP 429 responses. These timings identify request waits; they do not attribute the delay to SQL or Node CPU. Read receipts also invalidate general task collections and dashboards. Overlapping parent and child invalidations can start two requests and abort the first. **Expected behavior** The initial page should use one account-safe startup projection and one complete Mine response. A read receipt should refresh the affected viewer's inbox state. Each invalidation batch should refresh a covered query once. Required captions and current failure state must be ready before first reveal. Existing rows should remain usable during a transient refresh error. **Steps to reproduce** 1. Open a populated Mine inbox with parent tasks outside the visible index and a current failed run. 2. Reload with browser HTTP cache retained and application query state empty. 3. Inspect the session, company, board access, task-index and context request sequence. 4. Open or mark a task read and inspect the unrelated collection refetches. **Paperclip version or commit** Baseline: `44a9a043b80c18314d8dd89556f444dae696d3d6`. **Deployment mode** Authenticated board UI. Local proof uses the real APIs and database with a synthetic session resolver. No hosted candidate deployment is claimed. ## What Changed - Add optional, same-user `boardBootstrap` data to the existing session response. Seed account-keyed company and board-access reads from it. Keep ordinary endpoint reads for fallback, retry and live reconciliation. A returning account cannot reuse revoked cached access. Cache collection and later real refetches remain effective. - Add the board-only Mine snapshot endpoint. Reuse the canonical issue selector, private-task filtering, run-read decisions, secret redaction and interruption/recovery presentation. Assemble the fixed 100-task page, parent captions and bounded 200-run failure window on the server. Start independent reads together. Optional source errors keep successful task rows usable. - Keep array-shaped source caches for archive and undo. Key Mine source, context and reveal state by account. Validate the response's user stamp before committing it. Remove array-only cross-tab Mine broadcasts, which cannot carry its paired failure context. The server retains authenticated in-flight coalescing. - Scope read-receipt invalidation to the affected viewer's inbox, badges and chat unread state. Keep company and task audit views fresh. Preserve genuine task mutations, run updates and subscription-gap reconciliation. - Remove covered scalar-prefix invalidations within a batch. Preserve predicates, exact matches and different query/refetch constraints. A later event can still refresh again. - Retire Mine in-flight reuse after local company activity or run lifecycle changes. A post-archive read starts fresh. Old work keeps its admission slot until it settles, even if every HTTP client aborts. The eight-compute limit stays in place. - Add API, component and browser regressions. Extend the production timing harness with matching API/UI artifact receipts, retained-cache reloads, request status counts, layout checks and optional controlled network latency. Update API and verification documentation. ## Verification - Full `pnpm -r typecheck` passes. - Full `pnpm build` passes. - Token gates, map checks and diff whitespace checks pass. - 72 Inbox component tests pass. Startup-specific checks pass, including account switching, revoked cached access, malformed/absent bootstrap data and cache collection. The canonical authenticated bootstrap route also passes against real PostgreSQL. - Six real PostgreSQL Mine route cases pass, including full equivalence with legacy authorized projections, privacy, fixed bounds, membership revocation, coalescing, archive freshness and aborted-client admission. Four focused legacy coordinator controls pass. Six assembly tests pass. - Refetch and invalidation regressions pass. Meaningful pre-fix tests observed two request starts and one abort for overlapping invalidations; the fix produces one start and no abort. The archive race test observed the post-archive GET joining the old body before the fix. - All 11 existing startup browser checks pass, including Tasks/Mine code loading, desktop/mobile bounds, non-streamlined Coordinator, task navigation, failed preloading and Back position. The nine focused startup, fallback, partial-source error, rate-limit recovery, archive/undo and geometry cases also pass. The 429 cases use the inherited retry window. Archive and keyboard undo are corroborated through the real API and reload. Authentication and failure fault injection in these cases are explicitly simulated. - Matched production before/after timings are complete. Full aggregate Vitest coverage is left to CI. At 15:15 UTC, the final-head runner gate and `review` workflow pass. The 45 build/test/policy jobs are queued; the Build job has no `ubuntu-latest` runner assigned and has executed no steps, so remote aggregate results remain unverified. Paperclip Review and Superagent Security Scan are in progress. Superseded runs for this PR were cancelled. No current-head check has reported a failure; the CI checklist remains open. The remaining step is runner allocation and completion of those external checks; persistent queueing needs the repository CI owner's attention. - Greptile completed on final head `ecde295565309275c4f2fae903caf6892f1eaafd` at 5/5. The map-scope finding is fixed and resolved; there are no open review threads. ### Matched browser timings Five alternating baseline/candidate pairs per network scenario, measured serially in headful Chrome 156.0.8078.80 on Apple silicon with Node 24.22.2 and a 1440×900 viewport. Both production UIs use their matching real API revision against one fixed disposable PostgreSQL fixture: 34 tasks, 31 visible task rows, one paused agent and one terminal failure. No active agent/provider work. HTTP cache is retained; each initial sample reloads the document and clears application query state. Warm navigation and Back retain query state. CPU and throughput are native. The 100ms scenario uses CDP minimum request-to-response-header latency without request interception. It is a controlled request-dependency experiment, not a staging reproduction. Values are median milliseconds with min–max in parentheses. Paired saving is baseline minus candidate for each alternating pair; its median differs from the difference of arm medians. Positive saving favors the candidate. | Network / journey | Baseline | Candidate | Median paired saving | | --- | ---: | ---: | ---: | | Native / initial rows | 187.6 (128.9–208.5) | 191.7 (130.3–210.9) | −23.3 | | Native / Recent → Mine | 100.17 (99.90–101.87) | 100.07 (99.88–101.37) | −0.16 | | Native / task → Back | 70.45 (56.63–72.02) | 66.64 (56.52–67.71) | +4.94 | | 100ms / initial rows | 930.3 (914.1–1015.7) | 799.0 (697.1–826.0) | +211.8 | | 100ms / Recent → Mine | 100.09 (97.73–100.16) | 100.18 (96.65–100.98) | −0.02 | | 100ms / task → Back | 74.97 (62.77–75.82) | 75.47 (61.51–76.19) | +0.35 | The controlled-latency initial pairs all improve, saving 108.3–217.0ms. Native initial pairs have mixed signs (−67.5 to +78.1ms), so they establish no reliable native page-speed improvement. Warm and Back timings are effectively unchanged. Median primary-request start is 110.4→135.2ms natively and 497.8→480.9ms with 100ms latency; these runs do not establish a large startup-only timing gain. API requests through the two-second post-reveal window fall from median 32 (32–37) to 27 (25–28) natively, and 37 (33–38) to 30 (25–32) with 100ms latency. All 20 samples preserve the same ordered 31 task links, first-row DOM identity and geometry. All recorded API responses are HTTP 200; zero unfinished requests, 429s or JavaScript errors. Lower 429 incidence on staging remains unproven. Measured source: baseline `44a9a043b80c18314d8dd89556f444dae696d3d6`, candidate `4c12b2ef0ecf0754676e99bd6b9399e84408646c`. Final head `ecde295565309275c4f2fae903caf6892f1eaafd` changes only two feature-map files and existing browser test expectations since that measured candidate; production executable inputs are identical to the measured candidate. Asset hashes, API health revision receipts and every raw sample were retained. These results do not claim hosted-page improvement or deployment of this PR. ## Risks - Startup enrichment adds work to the session read. Failure of this optional projection falls back to the ordinary company and access endpoints. It does not invalidate a valid session or replace per-request server authorization. - Account changes, stale cache entries and same-company navigation can expose old presentation data. Account keys, principal stamps, reveal scopes and canonical bootstrap replacement have dedicated negative cases. Permission failures hide cached task rows; transient network, 429 and 5xx failures preserve them. - Current failure state can differ from the historical failed run. The snapshot uses the existing canonical rich projection and verified missing-ID coverage. Partial context failures retain an explicit retry path. - Mutation retirement follows events delivered to the current server process. This adds no cross-replica invalidation protocol or distributed read-after-write guarantee. - New UI assets require the matching API. Older UI/session consumers retain their existing response and endpoint behavior. There is no database migration, provider call or deployment change. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, code execution, browser automation and delegated source review. The exact runtime model ID and context-window size are not exposed by this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/DEVELOPING.md | 16 + maps/feature-map/inbox-search.md | 6 +- packages/shared/src/board-bootstrap.ts | 47 ++ packages/shared/src/index.ts | 3 + packages/shared/src/types/board-bootstrap.ts | 16 + packages/shared/src/types/inbox-mine.ts | 19 + packages/shared/src/types/index.ts | 1 + server/src/__tests__/auth-routes.test.ts | 9 + .../__tests__/board-bootstrap-routes.test.ts | 49 ++ .../src/__tests__/inbox-mine-routes.test.ts | 296 ++++++++++++ server/src/routes/auth.ts | 17 +- server/src/routes/issues.ts | 242 +++++++--- server/src/routes/openapi.ts | 11 + server/src/services/board-bootstrap.ts | 27 ++ server/src/services/companies.ts | 7 +- server/src/services/inbox-mine-read.test.ts | 102 ++++ server/src/services/inbox-mine-read.ts | 77 +++ tests/perf/collections/README.md | 82 +++- .../perf/collections/measure-mine-context.ts | 239 +++++++--- tests/perf/collections/parent-context.spec.ts | 17 +- tests/perf/collections/playwright.config.ts | 2 +- .../collections/startup-regressions.spec.ts | 232 +++++++++ tests/perf/collections/startup.spec.ts | 18 +- ui/src/api/auth.test.ts | 20 + ui/src/api/auth.ts | 15 +- ui/src/api/board-access-query.test.ts | 46 ++ ui/src/api/board-access-query.ts | 30 ++ ui/src/api/bootstrap-query.ts | 38 ++ ui/src/api/companies-query.ts | 25 +- ui/src/api/issues.ts | 6 + ui/src/components/CloudAccessGate.test.tsx | 46 +- ui/src/components/CloudAccessGate.tsx | 17 +- ui/src/context/CompanyContext.test.tsx | 96 ++++ ui/src/context/CompanyContext.tsx | 9 +- .../LiveUpdatesProvider.read-receipts.test.ts | 135 ++++++ ui/src/context/LiveUpdatesProvider.tsx | 67 ++- ui/src/lib/query-invalidation-batcher.test.ts | 117 ++++- ui/src/lib/query-invalidation-batcher.ts | 48 +- ui/src/pages/Inbox.test.tsx | 445 +++++++++++++++--- ui/src/pages/Inbox.tsx | 112 +++-- 40 files changed, 2457 insertions(+), 350 deletions(-) create mode 100644 packages/shared/src/board-bootstrap.ts create mode 100644 packages/shared/src/types/board-bootstrap.ts create mode 100644 packages/shared/src/types/inbox-mine.ts create mode 100644 server/src/__tests__/board-bootstrap-routes.test.ts create mode 100644 server/src/__tests__/inbox-mine-routes.test.ts create mode 100644 server/src/services/board-bootstrap.ts create mode 100644 server/src/services/inbox-mine-read.test.ts create mode 100644 server/src/services/inbox-mine-read.ts create mode 100644 tests/perf/collections/startup-regressions.spec.ts create mode 100644 ui/src/api/board-access-query.test.ts create mode 100644 ui/src/api/board-access-query.ts create mode 100644 ui/src/api/bootstrap-query.ts create mode 100644 ui/src/context/LiveUpdatesProvider.read-receipts.test.ts 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);