mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
perf(ui): load recent tasks with bounded summaries (#16851)
Replace passive recent-task detail reads with bounded, authorized summaries. Keep account isolation, stored history, task actions, and live recovery behavior. Overlap access checks with shell settings and avoid duplicate session and health reads on the first healthy socket opening. Retain domain reconciliation. Add focused coverage and local performance evidence. Isolate the existing cache capacity test from entry expiry on slow CI runners. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
23b2ceb0d1
commit
3e5a9f5afe
32 files changed
+4225
-85
No files matched your search
@@ -771,6 +771,14 @@ Activity-only reorderings wait for one second without further activity changes;
|
||||
new and removed tasks appear immediately. Titles, status, and live indicators stay
|
||||
current during that delay.
|
||||
|
||||
The sidebar renders saved snapshots immediately and refreshes its exact five IDs
|
||||
with one company/account-scoped summary query. Passive refreshes do not fetch
|
||||
full task details or inspect workspaces. Successful omissions remove unavailable
|
||||
tasks; failed batch reads retain saved history. Existing full-detail cache data
|
||||
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.
|
||||
|
||||
### Local Linux runner qualification
|
||||
|
||||
Before serving requests, the server reads `getconf GNU_LIBC_VERSION` once with
|
||||
|
||||
@@ -1206,6 +1206,7 @@ Invites tab does not hide this Cloud action.
|
||||
## 10.4 Tasks (Issues)
|
||||
|
||||
- `GET /companies/:companyId/issues`
|
||||
- `GET /companies/:companyId/issues/summaries?ids=<uuid>,<uuid>`
|
||||
- `POST /companies/:companyId/issues`
|
||||
- `GET /issues/:issueId`
|
||||
- `PATCH /issues/:issueId`
|
||||
@@ -1240,6 +1241,17 @@ for a duplicate request id, and return `409` with the current projection for a
|
||||
stale revision or an unconfirmed unfinished-goal replacement. These controls do
|
||||
not create issue comments.
|
||||
|
||||
The task-summary endpoint accepts one to five comma-separated UUIDs, normalizes
|
||||
case and whitespace, and removes duplicates after validating the five-entry
|
||||
bound. It returns accessible, non-hidden tasks in request order with only ID,
|
||||
company ID, title, identifier, status, update time, and external-conversation
|
||||
state. Missing, foreign-company and unauthorized tasks are omitted. Company
|
||||
access and normal task-read authorization apply; task-bridge keys are denied.
|
||||
Only the `ids` query parameter is accepted. Malformed, repeated, nested, empty
|
||||
and oversized ID parameters return `400`, including mixed scalar/nested syntax.
|
||||
Responses are private and not stored by HTTP caches. Reads do not trigger
|
||||
full-detail enrichment, workspace inspection or execution recovery.
|
||||
|
||||
### 10.4.1 Atomic Checkout Contract
|
||||
|
||||
`POST /issues/:issueId/checkout` request:
|
||||
|
||||
@@ -799,3 +799,10 @@ A company may use a shared API model to acknowledge accepted human messages acro
|
||||
|
||||
Pipelines is no longer a product feature. Its database tables remain for existing
|
||||
data preservation; Cases and Routines continue independently.
|
||||
|
||||
### Recent task navigation summaries
|
||||
|
||||
Recent-task navigation refreshes a bounded, authorized summary projection for
|
||||
its saved task IDs. The projection avoids full task/workspace enrichment and
|
||||
preserves company/task access rules. The HTTP contract is defined in
|
||||
`SPEC-implementation.md` section 10.4.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,161 @@
|
||||
# Sidebar and Apps startup performance
|
||||
|
||||
Outcome: one tested, reviewable PR and an isolated local preview. Preserve
|
||||
sidebar accuracy, account isolation, loading layout and recovery. No merge or
|
||||
staging deployment. PR: https://github.com/paperclipai/paperclip/pull/16851
|
||||
|
||||
## Implementation
|
||||
|
||||
- The five-ID summary route applies existing company/task authorization, omits
|
||||
unavailable IDs and uses a dedicated projection with no detail/workspace
|
||||
enrichment. The bound applies before deduplication. Responses are private/no-store.
|
||||
- Recent history uses one account-scoped query. Successful omissions apply only
|
||||
to the requested IDs. Revisiting a cached omission requires a fresh response;
|
||||
fetch completion remains observable when React Query shares the data object.
|
||||
Full-detail cache updates require positive access confirmation and never
|
||||
activate detail queries. Failures preserve snapshots and query errors.
|
||||
- Access checks overlap shell settings; the protected subtree waits for both.
|
||||
Only the first healthy socket opening exempts exact session/health keys.
|
||||
Domain catch-up and recovery remain active. Queued/status lifecycle events and
|
||||
pause/restart refresh summaries; progress/run-log events do not. Inbox archive
|
||||
preserves recent history.
|
||||
|
||||
Initial checkout: clean at `0ed760ce9a`. Branch:
|
||||
`codex/sidebar-apps-startup-performance`. Route-splitting PR
|
||||
[#16849](https://github.com/paperclipai/paperclip/pull/16849) landed during this
|
||||
work. The branch was rebased onto `9179d8dcc3`; the primary comparison includes
|
||||
that same route splitting on both sides. Tested application source:
|
||||
`085f7a6f1e2cac82be323ab099820767597936eb`. This includes strict rejection of mixed
|
||||
scalar/array or nested query syntax under Express's simple parser. Later edits
|
||||
only update the evidence; earlier trials remain in the sample record.
|
||||
|
||||
## Evidence
|
||||
|
||||
The [sample record](2026-10-19-sidebar-apps-startup-evidence.json) contains every
|
||||
primary sample, endpoint counts, prior trials, source revisions, environment and
|
||||
browser-journey assertions. Earlier noisy trials remain visible.
|
||||
|
||||
Primary comparison: production UI at `9179d8dcc3` versus `085f7a6f1e`, sharing
|
||||
the same final API and persistent synthetic fixture. The full-detail handler is
|
||||
unchanged. Five cold and five in-app samples per revision alternate in the same
|
||||
Chromium build. Profile: 1440x1000, 50ms latency, 9Mbps down, 1.5Mbps up, 4x CPU;
|
||||
service workers blocked. Cold contexts disable browser cache. In-app timing
|
||||
starts after dashboard recent-task refresh and two seconds of settling.
|
||||
Usability requires the existing connector list and search controls; no
|
||||
`networkidle` wait is used. Own builds/tests finished before this comparison.
|
||||
|
||||
| Median | Baseline | Candidate |
|
||||
| --- | ---: | ---: |
|
||||
| Cold Apps usable | 3748ms | 3668ms |
|
||||
| In-app Apps usable | 1019ms | 783ms |
|
||||
| Sidebar refresh complete after navigation | 3157ms | 3170ms |
|
||||
| Recent-task request span | 246ms | 244ms |
|
||||
| Recent-task requests, cold | 5 full details | 1 summary; 0 details |
|
||||
| Recent-task decoded body bytes | 45,442 | 1,160 |
|
||||
| Recent-task encoded body bytes | 5,715 | 372 |
|
||||
| All API requests, cold | 35 | 31 |
|
||||
| Cold long-task time | 1942ms | 1923ms |
|
||||
| Cold scripting time | 2.120s | 2.025s |
|
||||
| In-app long-task time | 546ms | 479ms |
|
||||
| In-app scripting time | 0.677s | 0.620s |
|
||||
|
||||
Encoded bytes fell 93.5%; decoded bytes fell 97.4%. The request span is essentially
|
||||
unchanged (246ms to 244ms), so no meaningful local API latency gain is claimed.
|
||||
Cold readiness fell 79ms (2.1%); in-app readiness fell 236ms (23.2%) in this run.
|
||||
Earlier aligned trials had both positive and negative readiness changes; all
|
||||
samples remain visible. The reliable result is smaller task refreshes, not an
|
||||
established overall Apps speedup. Final response timestamps are captured before
|
||||
body/size evidence extraction. Earlier trials included that collection overhead
|
||||
in response timings and are preliminary.
|
||||
Warm navigation reuses fresh sidebar
|
||||
data without a refresh. Each candidate cold load made one session and one
|
||||
health request. Focused socket tests also prove the initial exemption/recovery.
|
||||
|
||||
The final unthrottled smoke completed its first summary before the socket opened,
|
||||
then made the required domain catch-up batch. It recorded two summary requests,
|
||||
zero passive details, and one session/health request each. The timing trace is
|
||||
in `finalSmoke` in the sample record. Each refresh uses one batch; removing this
|
||||
second refresh would break the required fetch-to-subscription reconciliation.
|
||||
|
||||
The real browser journey passed: five recent tasks; connector search and
|
||||
permissions; task navigation and return; rename; inbox archive; pause/restart of
|
||||
an unassigned disposable task; reload persistence; delayed summary responses.
|
||||
Sidebar-row and connector-list rectangles were identical before/after releasing
|
||||
held responses. No agent run was started.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
- Passed after rebase: 85 focused UI/access/layout tests, 14 embedded PostgreSQL
|
||||
summary-route tests, 23 OpenAPI tests, full typecheck/build, token gates, maps
|
||||
and diff checks.
|
||||
- Earlier `pnpm test:run` reported five failures in the existing 1347-case chat
|
||||
integration file; four reached 15-second timeouts. All ten matched cases passed
|
||||
on an unchanged isolated rerun. The already-failed broad run was then stopped.
|
||||
A complete green local `pnpm test:run` is **not** claimed. Latest-head CI status
|
||||
belongs in the PR handoff; focused passes do not replace it.
|
||||
- Review regressions cover cached revisits, repeated omissions, in-app scripting
|
||||
scope and preparing both paired-build asset sets. Review/check status is on the PR.
|
||||
- The asset preparer canonicalizes paths and skips copying onto itself. A real
|
||||
filesystem probe reproduced `ERR_FS_CP_EINVAL` without the guard, then executed
|
||||
the actual preparation block with `ui/dist` as candidate/server. It preserved
|
||||
candidate files and prepared the complete baseline asset union.
|
||||
- Setup failures are retained in local logs: an initial build had concurrent
|
||||
writers; a compiled workspace server needs the TS loader for source exports;
|
||||
an archived UI needs tracked announcement assets. Successful builds use one
|
||||
writer per output directory. A browser observation URL typo was corrected
|
||||
before the complete passing journey.
|
||||
- Both builds show the existing local implicit-board Assistant Connection status
|
||||
error. Connector search/navigation and the synthetic connector remain usable.
|
||||
- This is local verification, not a staging deployment or proof of historical
|
||||
staging latency. Disk cleanup, workspace caching, route-splitting ownership,
|
||||
dependencies, migrations and telemetry remain outside this PR. Merging was
|
||||
excluded from the original handoff; the user later authorized a merge after
|
||||
the PR checks pass. Staging deployment remains excluded.
|
||||
|
||||
## PR check repair
|
||||
|
||||
At `453d488398`, every CI lane passed except serialized server shard 7/9.
|
||||
The [failed job](https://github.com/paperclipai/paperclip/actions/runs/39285071851/job/118169386829)
|
||||
reported one failure among 176 tests: the compact issue-list cache capacity test
|
||||
expected 256 entries and found 238. Its 261 HTTP requests took 5,631 ms, crossing
|
||||
the cache's 5,000 ms expiry. The production cache correctly prunes expired rows.
|
||||
The capacity assertion therefore depended on runner speed.
|
||||
|
||||
The test now holds `Date.now()` fixed while it fills and checks the cache, then
|
||||
restores the clock in `finally`. Real HTTP, database operations and timers still
|
||||
run. This isolates capacity eviction from expiry without changing production
|
||||
behavior. The earlier CI failure remains recorded here; a successful rerun does
|
||||
not erase it.
|
||||
|
||||
The focused local rerun passed all 29 tests across the issue-list assignee/filter
|
||||
and summary-route suites with real embedded PostgreSQL. `git diff --check` passed.
|
||||
The next remote CI run qualifies this test-only repair; the application build
|
||||
and browser measurements above remain unchanged.
|
||||
|
||||
## Preview and reproduction
|
||||
|
||||
Running preview: http://127.0.0.1:3214/APP/apps
|
||||
|
||||
The final production UI and compiled server use the PR head, with the TS loader
|
||||
for workspace dependency exports. The performance comparison used `085f7a6f1e`.
|
||||
The final preview also includes the strict query guard. Configuration/evidence:
|
||||
`/tmp/paperclip-apps-perf-20261019/`; disposable database port 55489. Fixture:
|
||||
one synthetic company, five tasks, a disabled draft connector and a paused agent
|
||||
with heartbeat disabled. Only this task's own preview/test processes were stopped.
|
||||
Native PostgreSQL package symlinks were restored with its install hook.
|
||||
|
||||
Archive `ui/` and `announcements/` at the baseline revision, link the same installed
|
||||
dependencies, and build from that UI directory with `PAPERCLIP_BUILD_COMMIT` set
|
||||
to its full SHA. Save the two production builds in separate directories, then run:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_APPS_BENCH_BASELINE_DIST=/path/to/baseline-dist \
|
||||
PAPERCLIP_APPS_BENCH_CANDIDATE_DIST=/path/to/candidate-dist \
|
||||
PAPERCLIP_APPS_BENCH_SERVER_DIST=/path/to/server-checkout/ui/dist \
|
||||
node scripts/benchmark-apps-startup.mjs comparison http://127.0.0.1:3214/ /tmp/apps-comparison
|
||||
```
|
||||
|
||||
The harness rejects non-loopback targets, creates/reuses synthetic fixtures,
|
||||
prepares both hashed asset sets in the static server directory, swaps only the
|
||||
production HTML document and measures real asset/API traffic. It writes JSON
|
||||
and screenshots. Do not run a build that clears server assets during it.
|
||||
@@ -40,6 +40,14 @@ narrower/different APIs.
|
||||
|
||||
## `organize`
|
||||
|
||||
`GET /api/companies/{companyId}/issues/summaries?ids=...` reads up to five exact
|
||||
UUIDs and returns only navigation summaries. Normal company and task-read
|
||||
permissions apply to ordinary agent REST calls; task-bridge keys are denied.
|
||||
Hidden, missing, foreign and private inaccessible IDs are omitted without an
|
||||
existence disclosure. No dedicated CLI/native tool is added. Embedded database
|
||||
route tests cover same-company agent access and company/task-bridge denials;
|
||||
this does not establish a live native `call_api` journey.
|
||||
|
||||
The issue-list API accepts optional `includeAgentCreatedTasks`; false restricts
|
||||
independent results to human creation before search/pagination. Omission retains
|
||||
existing agent/API behavior. `includeParticipatedTasks=true` requires a board user
|
||||
|
||||
@@ -70,3 +70,11 @@ Scope: browser
|
||||
Storybook: A NotFound rendering story can help polish, but missing-resource/legacy-deep-link capability principally needs real App/router/browser scenarios preserving company and query intent.
|
||||
Production: Cold-open invalid company/resource and retired URL; verify explicit fallback or correct canonical redirect without silently targeting a different resource/company.
|
||||
Sources: Source: [NotFound.tsx](../../ui/src/pages/NotFound.tsx), [App.tsx](../../ui/src/App.tsx), [company-routes.ts](../../ui/src/lib/company-routes.ts). Tests: [App.activity-routing.test.tsx](../../ui/src/App.activity-routing.test.tsx), [App.cases-routing.test.tsx](../../ui/src/App.cases-routing.test.tsx), [App.test.tsx](../../ui/src/App.test.tsx), [company-routes.test.ts](../../ui/src/lib/company-routes.test.ts)
|
||||
|
||||
### Recent task refresh performance
|
||||
|
||||
Saved recent tasks render immediately. One summary batch refreshes at most five
|
||||
IDs per company/account without passive full-detail or workspace inspection.
|
||||
Successful omissions remove unavailable entries; failed reads preserve history.
|
||||
Verify rename, inbox archive, pause/restart, reload, cross-tab updates and account
|
||||
switches. Inbox archive does not remove the task from recent history.
|
||||
@@ -1,4 +1,5 @@
|
||||
export const COMPANY_STATUSES = ["active", "paused", "archived"] as const;
|
||||
export const ISSUE_SUMMARIES_MAX_IDS = 5;
|
||||
export type CompanyStatus = (typeof COMPANY_STATUSES)[number];
|
||||
|
||||
export const DEPLOYMENT_MODES = ["local_trusted", "authenticated"] as const;
|
||||
|
||||
@@ -355,6 +355,7 @@ export {
|
||||
AGENT_ICON_NAMES,
|
||||
PROJECT_ICON_NAMES,
|
||||
ISSUE_STATUSES,
|
||||
ISSUE_SUMMARIES_MAX_IDS,
|
||||
INBOX_MINE_ISSUE_STATUSES,
|
||||
INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
ISSUE_PRIORITIES,
|
||||
@@ -1055,6 +1056,7 @@ export type {
|
||||
ExternalObjectSummary,
|
||||
ExternalObjectSummaryItem,
|
||||
CompactIssue,
|
||||
IssueSummary,
|
||||
Issue,
|
||||
IssueVisibility,
|
||||
IssuePrivacyConstraints,
|
||||
@@ -1929,6 +1931,7 @@ export {
|
||||
type CompanySearchExtractQuery,
|
||||
type CompanySearchQuery,
|
||||
createIssueSchema,
|
||||
issueSummariesQuerySchema,
|
||||
setIssueTitleSchema,
|
||||
type SetIssueTitle,
|
||||
createIssueInputSchema,
|
||||
|
||||
@@ -661,6 +661,7 @@ export type {
|
||||
} from "./external-object.js";
|
||||
export type {
|
||||
CompactIssue,
|
||||
IssueSummary,
|
||||
Issue,
|
||||
IssueChangeReceiptEntry,
|
||||
IssueChanges,
|
||||
|
||||
@@ -831,6 +831,13 @@ export interface IssueChangeReceiptEntry {
|
||||
|
||||
export type IssueChanges = Record<string, IssueChangeReceiptEntry>;
|
||||
|
||||
export type IssueSummary = Pick<
|
||||
Issue,
|
||||
"id" | "companyId" | "title" | "identifier" | "status" | "updatedAt"
|
||||
> & {
|
||||
externalConversationState: "active" | "waiting" | null;
|
||||
};
|
||||
|
||||
export interface Issue {
|
||||
/** True only while the title is the provisional slice of the initial prompt. */
|
||||
titleNeedsGeneration?: boolean;
|
||||
|
||||
@@ -420,6 +420,7 @@ export {
|
||||
|
||||
export {
|
||||
createIssueSchema,
|
||||
issueSummariesQuerySchema,
|
||||
setIssueTitleSchema,
|
||||
type SetIssueTitle,
|
||||
createIssueInputSchema,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { taskWorkspaceSelectionSchema } from "./task-workspace.js";
|
||||
import { z } from "zod";
|
||||
import { ISSUE_SUMMARIES_MAX_IDS } from "../constants.js";
|
||||
|
||||
import {
|
||||
ISSUE_EXECUTION_DECISION_OUTCOMES,
|
||||
ISSUE_EXECUTION_MONITOR_CLEAR_REASONS,
|
||||
@@ -44,6 +46,12 @@ import {
|
||||
import { objectWithoutDefaults } from "./partial.js";
|
||||
import { questionSetToAskUserQuestionsPayload } from "../question-set.js";
|
||||
|
||||
export const issueSummariesQuerySchema = z.object({
|
||||
ids: z.string().transform((value) => value.split(",").map((id) => id.trim().toLowerCase()))
|
||||
.pipe(z.array(z.string().guid()).min(1).max(ISSUE_SUMMARIES_MAX_IDS))
|
||||
.transform((ids) => [...new Set(ids)]),
|
||||
}).strict();
|
||||
|
||||
export const issueBlockedInboxStateSchema = z.enum([
|
||||
"needs_attention",
|
||||
"awaiting_decision",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Local-only browser comparison. Creates synthetic fixtures; never target staging.
|
||||
import fs from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const [phase, origin, directory] = process.argv.slice(2);
|
||||
if (!phase || !origin || !directory) throw new Error("Usage: node scripts/benchmark-apps-startup.mjs <phase> <loopback-origin> <output-directory>");
|
||||
const target = new URL(origin);
|
||||
if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || target.pathname !== "/" || target.username || target.password) {
|
||||
throw new Error("Only a disposable loopback instance is supported");
|
||||
}
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
async function api(path, body) {
|
||||
const response = await fetch(new URL(`/api${path}`, target), body ? {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||
} : undefined);
|
||||
if (!response.ok) throw new Error(`${path}: ${response.status} ${await response.text()}`);
|
||||
return response.json();
|
||||
}
|
||||
const fixturePath = `${directory}/fixture.json`;
|
||||
let fixture;
|
||||
try { fixture = JSON.parse(await fs.readFile(fixturePath, "utf8")); }
|
||||
catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
const company = await api("/companies", { name: "Apps Startup Review" });
|
||||
const tasks = [];
|
||||
for (let i = 1; i <= 5; i++) tasks.push(await api(`/companies/${company.id}/issues`, {
|
||||
title: `Review task ${i}`, description: "Synthetic task context for the sidebar performance comparison.\n".repeat(100),
|
||||
status: i === 1 ? "todo" : "done", priority: "medium",
|
||||
}));
|
||||
const application = await api(`/companies/${company.id}/tools/applications`, { name: "Review Connector", type: "mcp_http" });
|
||||
const connection = await api(`/companies/${company.id}/tools/connections`, {
|
||||
applicationId: application.id, name: "Review account", transport: "mcp_remote", status: "draft", enabled: false,
|
||||
transportConfig: { url: "https://example.com/mcp" },
|
||||
});
|
||||
const agent = await api(`/companies/${company.id}/agents`, {
|
||||
name: "Review agent", role: "engineer", adapterType: "codex_local",
|
||||
runtimeConfig: { heartbeat: { enabled: false } },
|
||||
});
|
||||
await api(`/agents/${agent.id}/pause`, {});
|
||||
const session = await api("/auth/get-session");
|
||||
fixture = { company, tasks, application, connection, agent, userId: session?.user?.id ?? null };
|
||||
await fs.writeFile(fixturePath, JSON.stringify(fixture, null, 2));
|
||||
}
|
||||
const variants = process.env.PAPERCLIP_APPS_BENCH_BASELINE_DIST ? [
|
||||
{ label: "baseline-paired", dist: process.env.PAPERCLIP_APPS_BENCH_BASELINE_DIST },
|
||||
{ label: "candidate-paired", dist: process.env.PAPERCLIP_APPS_BENCH_CANDIDATE_DIST },
|
||||
] : [{ label: phase, dist: null }];
|
||||
if (variants.length > 1) {
|
||||
if (!variants[1].dist) throw new Error("Paired mode requires PAPERCLIP_APPS_BENCH_CANDIDATE_DIST");
|
||||
const serverAssets = process.env.PAPERCLIP_APPS_BENCH_SERVER_DIST
|
||||
? `${process.env.PAPERCLIP_APPS_BENCH_SERVER_DIST}/assets`
|
||||
: new URL("../ui/dist/assets/", import.meta.url);
|
||||
await fs.mkdir(serverAssets, { recursive: true });
|
||||
const serverAssetsPath = await fs.realpath(serverAssets);
|
||||
// Preserve both content-addressed asset sets in the loopback server's
|
||||
// static directory. Selecting an HTML document alone is insufficient.
|
||||
for (const variant of variants) {
|
||||
const sourceAssetsPath = await fs.realpath(`${variant.dist}/assets`);
|
||||
if (sourceAssetsPath !== serverAssetsPath) {
|
||||
await fs.cp(sourceAssetsPath, serverAssetsPath, { recursive: true, force: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
const browser = await chromium.launch();
|
||||
const samples = [];
|
||||
try {
|
||||
for (const scenario of ["cold", "in-app"]) {
|
||||
for (let run = 0; run < 5; run++) {
|
||||
for (const variant of variants) {
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: "block" });
|
||||
await context.addInitScript(({ fixture }) => {
|
||||
const entries = fixture.tasks.map((task, index) => ({
|
||||
id: task.id, companyId: task.companyId, title: task.title, identifier: task.identifier,
|
||||
status: task.status, recordedAt: index + 1, snapshotUpdatedAt: new Date(task.updatedAt).getTime(),
|
||||
}));
|
||||
localStorage.setItem("paperclip.selectedCompanyId", fixture.company.id);
|
||||
localStorage.setItem(`paperclip.recentTasks.v2:${fixture.company.id}:${fixture.userId ?? "__local_board__"}`, JSON.stringify(entries));
|
||||
window.__appsLongTasks = [];
|
||||
new PerformanceObserver(list => window.__appsLongTasks.push(...list.getEntries().map(e => ({ start: e.startTime, duration: e.duration }))))
|
||||
.observe({ type: "longtask", buffered: true });
|
||||
}, { fixture });
|
||||
const page = await context.newPage();
|
||||
if (variant.dist) {
|
||||
// Select only the production document. Assets and APIs still travel
|
||||
// through the real local server with the same network profile.
|
||||
const html = await fs.readFile(`${variant.dist}/index.html`, "utf8");
|
||||
await page.route(url => url.origin === target.origin &&
|
||||
["apps", "dashboard"].some(name => url.pathname === `/${fixture.company.issuePrefix}/${name}`),
|
||||
route => route.fulfill({ status: 200, contentType: "text/html", body: html }));
|
||||
}
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send("Network.enable");
|
||||
await cdp.send("Performance.enable");
|
||||
await cdp.send("Network.setCacheDisabled", { cacheDisabled: scenario === "cold" });
|
||||
await cdp.send("Network.emulateNetworkConditions", { offline: false, latency: 50, downloadThroughput: 9 * 1024 * 1024 / 8, uploadThroughput: 1.5 * 1024 * 1024 / 8 });
|
||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
|
||||
if (scenario === "in-app") {
|
||||
const initialDetails = new Set();
|
||||
let finishInitialRefresh;
|
||||
const initialRefresh = new Promise(resolve => { finishInitialRefresh = resolve; });
|
||||
const observeInitialRefresh = async response => {
|
||||
const path = new URL(response.url()).pathname;
|
||||
if (path.endsWith("/issues/summaries")) {
|
||||
await response.finished();
|
||||
finishInitialRefresh();
|
||||
} else if (fixture.tasks.some(task => path === `/api/issues/${task.id}`)) {
|
||||
await response.finished();
|
||||
initialDetails.add(path);
|
||||
if (initialDetails.size === fixture.tasks.length) finishInitialRefresh();
|
||||
}
|
||||
};
|
||||
page.on("response", observeInitialRefresh);
|
||||
await page.goto(new URL(`/${fixture.company.issuePrefix}/dashboard`, target).href);
|
||||
await page.getByRole("link", { name: "Connectors", exact: true }).first().waitFor({ timeout: 90_000 });
|
||||
await Promise.race([initialRefresh, new Promise((_, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Initial recent-task refresh did not finish")), 90_000);
|
||||
timeout.unref();
|
||||
})]);
|
||||
await page.waitForTimeout(2_000);
|
||||
page.off("response", observeInitialRefresh);
|
||||
}
|
||||
const initialMetrics = await cdp.send("Performance.getMetrics");
|
||||
const scriptDurationAtStart = scenario === "in-app"
|
||||
? initialMetrics.metrics.find(metric => metric.name === "ScriptDuration")?.value ?? 0
|
||||
: 0;
|
||||
const records = [];
|
||||
const requests = [];
|
||||
page.on("request", request => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.startsWith("/api/")) requests.push({ path, startedMs: performance.now() - started });
|
||||
});
|
||||
let detailCompleted = 0;
|
||||
let detailReadyMax = 0;
|
||||
let sidebarReady = null;
|
||||
let observedNavigationStart;
|
||||
const started = performance.now();
|
||||
page.on("response", async response => {
|
||||
const url = new URL(response.url());
|
||||
if (!url.pathname.startsWith("/api/")) return;
|
||||
try {
|
||||
await response.finished();
|
||||
const completedMs = performance.now() - started;
|
||||
const body = await response.body();
|
||||
const sizes = await response.request().sizes();
|
||||
records.push({ path: url.pathname, status: response.status(), decodedBytes: body.byteLength, encodedBytes: sizes.responseBodySize, completedMs });
|
||||
if (url.pathname.endsWith("/issues/summaries")) sidebarReady = completedMs;
|
||||
if (fixture.tasks.some(task => url.pathname === `/api/issues/${task.id}`)) {
|
||||
detailCompleted++;
|
||||
detailReadyMax = Math.max(detailReadyMax, completedMs);
|
||||
if (detailCompleted >= fixture.tasks.length) sidebarReady = detailReadyMax;
|
||||
}
|
||||
} catch { /* navigation cancellation has no completed response */ }
|
||||
});
|
||||
observedNavigationStart = await page.evaluate(() => performance.now());
|
||||
if (scenario === "cold") await page.goto(new URL(`/${fixture.company.issuePrefix}/apps`, target).href);
|
||||
else await page.getByRole("link", { name: "Connectors", exact: true }).first().click();
|
||||
await page.getByRole("list", { name: "Connector list", exact: true }).waitFor({ timeout: 90_000 });
|
||||
await page.getByRole("searchbox", { name: "Search connectors" }).waitFor();
|
||||
const usableMs = performance.now() - started;
|
||||
await page.waitForTimeout(2_000);
|
||||
const metrics = await cdp.send("Performance.getMetrics");
|
||||
const longTasks = await page.evaluate(() => window.__appsLongTasks);
|
||||
const recentRecords = records.filter(record => record.path.endsWith("/issues/summaries") || fixture.tasks.some(task => record.path === `/api/issues/${task.id}`));
|
||||
samples.push({ variant: variant.label, scenario, run: run + 1, usableMs, sidebarReadyMs: sidebarReady, recentBytes: recentRecords.reduce((n, r) => n + r.decodedBytes, 0),
|
||||
recentEncodedBytes: recentRecords.reduce((n, r) => n + r.encodedBytes, 0),
|
||||
apiRequests: requests.length,
|
||||
recentRequests: requests.filter(record => record.path.endsWith("/issues/summaries") || fixture.tasks.some(task => record.path === `/api/issues/${task.id}`)).length,
|
||||
records, requests,
|
||||
longTaskMs: longTasks.filter(task => scenario === "cold" || task.start >= observedNavigationStart).reduce((n, task) => n + task.duration, 0),
|
||||
scriptDurationSeconds: (metrics.metrics.find(metric => metric.name === "ScriptDuration")?.value ?? 0) - scriptDurationAtStart,
|
||||
});
|
||||
if (run === 0) await page.screenshot({ path: `${directory}/${variant.label}-${scenario}.png`, fullPage: true });
|
||||
await context.close();
|
||||
console.log(`${variant.label} ${scenario} ${run + 1}: connectors=${Math.round(usableMs)}ms recent=${samples.at(-1).recentRequests} requests/${samples.at(-1).recentBytes} bytes`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
await fs.writeFile(`${directory}/${phase}.json`, JSON.stringify({ phase, origin, revision: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
|
||||
browser: browser.version(), viewport: "1440x1000", profile: "50ms latency; 9Mbps down; 1.5Mbps up; 4x CPU", samples,
|
||||
}, null, 2));
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, agents, companies, companyMemberships, createDb, heartbeatRuns, issues, principalPermissionGrants } from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -634,14 +634,21 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => {
|
||||
});
|
||||
|
||||
const app = createApp(companyId);
|
||||
for (let index = 0; index < ISSUE_LIST_SERVER_CACHE_MAX_ENTRIES + 5; index += 1) {
|
||||
const res = await request(app)
|
||||
.get(`/api/companies/${companyId}/issues`)
|
||||
.query({ view: "compact", limit: "20", q: `cache-key-${index}` });
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
}
|
||||
// Filling the cache can take longer than its five-second expiry on CI.
|
||||
// Keep entries alive so this test measures capacity eviction alone.
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(Date.now());
|
||||
try {
|
||||
for (let index = 0; index < ISSUE_LIST_SERVER_CACHE_MAX_ENTRIES + 5; index += 1) {
|
||||
const res = await request(app)
|
||||
.get(`/api/companies/${companyId}/issues`)
|
||||
.query({ view: "compact", limit: "20", q: `cache-key-${index}` });
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
}
|
||||
|
||||
expect(__getIssueListResponseCacheSizeForTests()).toBe(ISSUE_LIST_SERVER_CACHE_MAX_ENTRIES);
|
||||
expect(__getIssueListResponseCacheSizeForTests()).toBe(ISSUE_LIST_SERVER_CACHE_MAX_ENTRIES);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("logs request_storm_detected for identical in-flight compact issue-list fanout without query values", async () => {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents, chatConversations, chatEndpoints, issues, toolApplications, toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import * as issueServices from "../services/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { describeEmbeddedPostgres, seedCompanyWithBoardAccess, useEmbeddedPostgres } from "./helpers/route-test-harness.js";
|
||||
|
||||
describeEmbeddedPostgres("bounded issue summaries", () => {
|
||||
const ctx = useEmbeddedPostgres("paperclip-issue-summaries-");
|
||||
const oldPrivacy = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
beforeAll(() => { process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; });
|
||||
afterAll(() => {
|
||||
if (oldPrivacy === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = oldPrivacy;
|
||||
});
|
||||
|
||||
async function fixture() {
|
||||
const company = await seedCompanyWithBoardAccess(ctx.db, "Summaries");
|
||||
const rows = await ctx.db.insert(issues).values([
|
||||
{ companyId: company.companyId, title: "First", description: "Do not serialize this".repeat(100), status: "todo" },
|
||||
{ companyId: company.companyId, title: "Second", status: "done" },
|
||||
]).returning();
|
||||
return { ...company, rows };
|
||||
}
|
||||
function app(actor: Express.Request["actor"]) {
|
||||
const result = express();
|
||||
result.use(express.json());
|
||||
result.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
result.use("/api", issueRoutes(ctx.db, {} as never));
|
||||
result.use(errorHandler);
|
||||
return result;
|
||||
}
|
||||
const url = (companyId: string) => `/api/companies/${companyId}/issues/summaries`;
|
||||
|
||||
it("returns only summaries in requested order without full-detail reads", async () => {
|
||||
const f = await fixture();
|
||||
const original = issueServices.issueService;
|
||||
const forbiddenRead = vi.fn(() => { throw new Error("Full detail read invoked"); });
|
||||
const spy = vi.spyOn(issueServices, "issueService").mockImplementation((...args) => ({
|
||||
...original(...args), getById: forbiddenRead, list: forbiddenRead,
|
||||
}));
|
||||
try {
|
||||
const res = await request(app(f.actor)).get(url(f.companyId)).query({ ids: ` ${f.rows[1]!.id.toUpperCase()},${f.rows[0]!.id},${f.rows[1]!.id} ` }).expect(200);
|
||||
expect(res.headers["cache-control"]).toBe("private, no-store");
|
||||
expect(res.body.map((row: { id: string }) => row.id)).toEqual([f.rows[1]!.id, f.rows[0]!.id]);
|
||||
expect(Object.keys(res.body[0]).sort()).toEqual([
|
||||
"companyId", "externalConversationState", "id", "identifier", "status", "title", "updatedAt",
|
||||
]);
|
||||
expect(res.body[0].externalConversationState).toBeNull();
|
||||
expect(forbiddenRead).not.toHaveBeenCalled();
|
||||
} finally { spy.mockRestore(); }
|
||||
});
|
||||
|
||||
it.each(["", "?ids=", "?ids=nope", "?ids=,", "?ids[]=nope", "?ids[x]=nope",
|
||||
`?ids=${randomUUID()}&ids[]=nope`, `?ids=${randomUUID()}&ids[x]=nope`,
|
||||
`?ids=${randomUUID()}&ids=${randomUUID()}`, `?ids=${Array(6).fill(randomUUID()).join(",")}`,
|
||||
])("rejects invalid query %s", async (query) => {
|
||||
const f = await fixture();
|
||||
await request(app(f.actor)).get(url(f.companyId) + query).expect(400);
|
||||
});
|
||||
|
||||
it("omits hidden, private, foreign and missing IDs without revealing them", async () => {
|
||||
const f = await fixture();
|
||||
const foreign = await fixture();
|
||||
const hidden = f.rows[1]!;
|
||||
await ctx.db.update(issues).set({ hiddenAt: new Date() }).where(eq(issues.id, hidden.id));
|
||||
const privateId = randomUUID();
|
||||
await ctx.db.insert(issues).values({ id: privateId, companyId: f.companyId, title: "Private", visibility: "private", privacyRootIssueId: privateId, responsibleUserId: "someone-else", createdByUserId: "someone-else" });
|
||||
const ids = [f.rows[0]!.id, hidden.id, privateId, foreign.rows[0]!.id, randomUUID()];
|
||||
const res = await request(app(f.actor)).get(url(f.companyId)).query({ ids: ids.join(",") }).expect(200);
|
||||
expect(res.body.map((row: { id: string }) => row.id)).toEqual([f.rows[0]!.id]);
|
||||
const empty = await request(app(f.actor)).get(url(f.companyId)).query({ ids: ids.slice(1).join(",") }).expect(200);
|
||||
expect(empty.body).toEqual([]);
|
||||
await request(app(f.actor)).get(url(foreign.companyId)).query({ ids: foreign.rows[0]!.id }).expect(403);
|
||||
});
|
||||
|
||||
it("permits ordinary same-company agents and rejects task-bridge keys", async () => {
|
||||
const f = await fixture();
|
||||
const [agent] = await ctx.db.insert(agents).values({ companyId: f.companyId, name: "Reader", role: "engineer", adapterType: "process" }).returning();
|
||||
const actor: Express.Request["actor"] = { type: "agent", agentId: agent!.id, companyId: f.companyId, source: "agent_key" };
|
||||
const res = await request(app(actor)).get(url(f.companyId)).query({ ids: f.rows[0]!.id }).expect(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
await request(app({ ...actor, keyScope: { kind: "task_bridge", issueId: f.rows[0]!.id } } as Express.Request["actor"]))
|
||||
.get(url(f.companyId)).query({ ids: f.rows[0]!.id }).expect(403);
|
||||
});
|
||||
|
||||
it("projects conversation readiness changes without advancing the task version", async () => {
|
||||
const f = await fixture();
|
||||
const [agent] = await ctx.db.insert(agents).values({ companyId: f.companyId, name: "Chat", role: "general", adapterType: "process" }).returning();
|
||||
const issue = f.rows[0]!;
|
||||
await ctx.db.update(issues).set({ status: "in_review", assigneeAgentId: agent!.id }).where(eq(issues.id, issue.id));
|
||||
const [application] = await ctx.db.insert(toolApplications).values({ companyId: f.companyId, name: "Slack", type: "native" }).returning();
|
||||
const connectionId = randomUUID();
|
||||
await ctx.db.insert(toolConnections).values({ id: connectionId, companyId: f.companyId, applicationId: application!.id, uid: connectionId, name: "Slack", transport: "chat_sdk", connectionPurpose: "channel" });
|
||||
const endpointId = randomUUID();
|
||||
await ctx.db.insert(chatEndpoints).values({ id: endpointId, companyId: f.companyId, connectionId, provider: "slack", publicId: endpointId, assignedAgentId: agent!.id, status: "active" });
|
||||
const [conversation] = await ctx.db.insert(chatConversations).values({ companyId: f.companyId, endpointId, issueId: issue.id, externalConversationId: "channel", externalThreadId: issue.id, externalLabel: "#summary-test", state: "waiting" }).returning();
|
||||
const read = () => request(app(f.actor)).get(url(f.companyId)).query({ ids: issue.id }).expect(200);
|
||||
const waiting = (await read()).body[0];
|
||||
expect(waiting.externalConversationState).toBe("waiting");
|
||||
expect((await issueServices.issueService(ctx.db).getById(issue.id))?.externalConversationState).toBe("waiting");
|
||||
await ctx.db.update(chatConversations).set({ state: "active" }).where(eq(chatConversations.id, conversation!.id));
|
||||
const active = (await read()).body[0];
|
||||
expect(active.externalConversationState).toBe("active");
|
||||
expect(active.updatedAt).toBe(waiting.updatedAt);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { parseObject } from "../adapters/utils.js";
|
||||
import { issueSummariesQuerySchema, type IssueSummary } from "@paperclipai/shared";
|
||||
import { listIssueSummaries } from "../services/issue-summaries.js";
|
||||
import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js";
|
||||
import { enqueueFastResponse } from "../services/fast-responses.js";
|
||||
import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "../services/workspace-restore-recovery-state.js";
|
||||
@@ -8210,6 +8212,33 @@ export function issueRoutes(
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/issues/summaries", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (isTaskBridgeKeyActor(req)) {
|
||||
res.status(403).json({ error: "Task bridge keys cannot use company-wide issue list APIs" });
|
||||
return;
|
||||
}
|
||||
const parsed = issueSummariesQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: "ids must be the only query parameter and contain one to five comma-separated UUIDs" });
|
||||
return;
|
||||
}
|
||||
const rows = await listIssueSummaries(db, companyId, parsed.data.ids, await issueReadSqlCondition(db, req.actor));
|
||||
const visible = await filterIssuesForActor(req, rows);
|
||||
const summaries: IssueSummary[] = visible.map((row) => ({
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
title: row.title,
|
||||
identifier: row.identifier,
|
||||
status: row.status as IssueSummary["status"],
|
||||
updatedAt: row.updatedAt,
|
||||
externalConversationState: row.externalConversationState,
|
||||
}));
|
||||
res.setHeader("Cache-Control", "private, no-store");
|
||||
res.json(summaries);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/issues", async (req, res) => {
|
||||
const startedAt = Date.now();
|
||||
const companyId = req.params.companyId as string;
|
||||
|
||||
@@ -4227,6 +4227,19 @@ registry.registerPath({
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/issues/summaries",
|
||||
tags: ["issues"],
|
||||
summary: "Get authorized recent-task summaries",
|
||||
description: "Accepts only the ids query parameter: one to five comma-separated UUIDs, bounded before deduplication. IDs are trimmed and normalized to lowercase. Repeated, array and nested query syntax is rejected, including mixed forms. Returns accessible, visible tasks in request order; unavailable IDs are omitted. This private, no-store read excludes full task and workspace enrichment.",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
query: z.object({ ids: z.string().min(1).describe("One to five comma-separated task UUIDs; repeated query parameters are rejected") }),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/issues",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { and, eq, inArray, isNull, type SQL } from "drizzle-orm";
|
||||
import { issues, type Db } from "@paperclipai/db";
|
||||
import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared";
|
||||
import { externalConversationStateSql } from "./slack-conversation-state.js";
|
||||
|
||||
/** A bounded projection with the columns needed by task-read authorization. */
|
||||
export async function listIssueSummaries(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
ids: readonly string[],
|
||||
readCondition: SQL<boolean>,
|
||||
) {
|
||||
if (ids.length > ISSUE_SUMMARIES_MAX_IDS) throw new Error("Too many issue summary IDs");
|
||||
if (ids.length === 0) return [];
|
||||
const rows = await db.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
title: issues.title,
|
||||
identifier: issues.identifier,
|
||||
status: issues.status,
|
||||
updatedAt: issues.updatedAt,
|
||||
externalConversationState: externalConversationStateSql(),
|
||||
projectId: issues.projectId,
|
||||
parentId: issues.parentId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
}).from(issues).where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
inArray(issues.id, [...ids]),
|
||||
isNull(issues.hiddenAt),
|
||||
readCondition,
|
||||
));
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
return [...new Set(ids)].flatMap((id) => {
|
||||
const row = byId.get(id);
|
||||
return row ? [row] : [];
|
||||
});
|
||||
}
|
||||
+7
-2
@@ -18,6 +18,7 @@ import {
|
||||
import { useHiddenSettings } from "./hooks/useHiddenSettings";
|
||||
import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant";
|
||||
import { CloudAccessGate } from "./components/CloudAccessGate";
|
||||
import { ShellConfigurationGate } from "./components/ShellConfigurationGate";
|
||||
import { PaperclipLoading } from "./components/AnimatedPaperclipIcon";
|
||||
import { AGENT_FILTER_TABS } from "./lib/agent-filter-tabs";
|
||||
import { useCombinedInboxTasksEnabled } from "./hooks/useCombinedInboxTasksEnabled";
|
||||
@@ -776,17 +777,20 @@ export function App() {
|
||||
<Route path="assistant-connections" element={<AssistantConnectionsPage />} />
|
||||
<Route path="cli-auth/:id" element={<CliAuthPage />} />
|
||||
<Route path="invite/:token" element={<InviteLandingPage />} />
|
||||
<Route element={streamlinedUiLoaded ? <CloudAccessGate allowMembershipRequest /> : <PaperclipLoading />}>
|
||||
<Route element={<CloudAccessGate allowMembershipRequest />}>
|
||||
<Route element={<ShellConfigurationGate loaded={streamlinedUiLoaded} />}>
|
||||
{/* The identity APIs enforce the chat rollout flag. Nonmembers cannot
|
||||
read experimental settings, but a private invitation may request membership. */}
|
||||
<Route path="chat-identity/confirm" element={<ChatIdentityConfirm />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||
<Route path="ux-lab/bootstrap-setup" element={<BootstrapSetupUxLab />} />
|
||||
<Route path="ux-lab/responsible-user-denial" element={<ResponsibleUserDenialUxLab />} />
|
||||
<Route path="ux-lab/cross-issue-collaboration" element={<CrossIssueCollaborationUxLab />} />
|
||||
|
||||
<Route element={streamlinedUiLoaded ? <CloudAccessGate /> : <PaperclipLoading />}>
|
||||
<Route element={<CloudAccessGate />}>
|
||||
<Route element={<ShellConfigurationGate loaded={streamlinedUiLoaded} />}>
|
||||
<Route index element={<CompanyRootRedirect />} />
|
||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||
<Route path="instance" element={<LegacySettingsRedirect />} />
|
||||
@@ -852,6 +856,7 @@ export function App() {
|
||||
{boardRoutes(streamlinedUiEnabled, combinedInboxTasksEnabled)}
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage scope="global" />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes></Suspense>
|
||||
<OnboardingWizardVariant />
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { issuesApi } from "./issues";
|
||||
|
||||
const first = "11111111-1111-4111-8111-111111111111";
|
||||
const second = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
|
||||
describe("issue summary HTTP requests", () => {
|
||||
const fetchMock = vi.fn();
|
||||
beforeEach(() => { fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); });
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("normalizes IDs, forwards cancellation, and disables the HTTP cache", async () => {
|
||||
fetchMock.mockResolvedValue(Response.json([]));
|
||||
const controller = new AbortController();
|
||||
await issuesApi.summaries("company", [second.toUpperCase(), ` ${first} `, first], { signal: controller.signal });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(`/api/companies/company/issues/summaries?ids=${first}%2C${second}`);
|
||||
expect(options.cache).toBe("no-store");
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("does not send empty or oversized batches", async () => {
|
||||
expect(await issuesApi.summaries("company", [])).toEqual([]);
|
||||
await expect(issuesApi.summaries("company", Array(6).fill(first))).rejects.toThrow("Too many");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reuse an in-flight response after an account change", async () => {
|
||||
let resolveOld!: (value: Response) => void;
|
||||
fetchMock.mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve; }))
|
||||
.mockResolvedValueOnce(Response.json([{ id: first, title: "New account" }]));
|
||||
const oldAccount = issuesApi.summaries("company", [first]);
|
||||
const newAccount = issuesApi.summaries("company", [first]);
|
||||
expect((await newAccount)[0].title).toBe("New account");
|
||||
resolveOld(Response.json([{ id: first, title: "Old account" }]));
|
||||
expect((await oldAccount)[0].title).toBe("Old account");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("aborts a summary request when its observer leaves", async () => {
|
||||
fetchMock.mockImplementation((_url, options) => new Promise((_resolve, reject) => {
|
||||
options.signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
|
||||
}));
|
||||
const controller = new AbortController();
|
||||
const pending = issuesApi.summaries("company", [first], { signal: controller.signal });
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { SetIssueTitle } from "@paperclipai/shared";
|
||||
import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared";
|
||||
import type { IssueSummary } from "@paperclipai/shared";
|
||||
import type { ExecutionReconciliation } from "@paperclipai/shared";
|
||||
import type {
|
||||
AcceptedPlanDecompositionSummary,
|
||||
@@ -169,6 +171,17 @@ function issueListSearchParams(filters?: IssueListFilters) {
|
||||
}
|
||||
|
||||
export const issuesApi = {
|
||||
summaries: async (companyId: string, issueIds: readonly string[], options?: RequestOptions): Promise<IssueSummary[]> => {
|
||||
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();
|
||||
if (ids.length === 0) return [];
|
||||
const params = new URLSearchParams({ ids: ids.join(",") });
|
||||
const path = `/companies/${companyId}/issues/summaries?${params}`;
|
||||
// React Query deduplicates within an account; URL-only HTTP coalescing must
|
||||
// never join a request sent with the previous account's cookie.
|
||||
detachInflightGet(path);
|
||||
return api.get<IssueSummary[]>(path, { ...options, cache: "no-store" });
|
||||
},
|
||||
/** Fetch every page for bounded task-detail relations, not just the default first page. */
|
||||
listAll: async (companyId: string, filters: Omit<IssueListFilters, "limit" | "offset" | "sortField" | "sortDir" | "afterId" | "attention">, options?: RequestOptions): Promise<Issue[]> => {
|
||||
const pageSize = 500;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { CloudAccessGate } from "./CloudAccessGate";
|
||||
import { ShellConfigurationGate } from "./ShellConfigurationGate";
|
||||
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
|
||||
|
||||
vi.mock("@/lib/router", async () => await import("react-router-dom"));
|
||||
vi.mock("./AnimatedPaperclipIcon", () => ({ PaperclipLoading: () => <div>Loading shell</div> }));
|
||||
const { health, session, access, settings } = vi.hoisted(() => ({ health: vi.fn(), session: vi.fn(), access: vi.fn(), settings: vi.fn() }));
|
||||
vi.mock("../api/health", () => ({ healthApi: { get: health } }));
|
||||
vi.mock("../api/auth", () => ({ authApi: { getSession: session } }));
|
||||
vi.mock("../api/access", () => ({ accessApi: { getCurrentBoardAccess: access } }));
|
||||
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: settings } }));
|
||||
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => vi.resetAllMocks());
|
||||
|
||||
it.each([false, true])("runs access checks while shell settings are pending (membership=%s)", async allowMembershipRequest => {
|
||||
let resolveSettings!: (value: unknown) => void;
|
||||
health.mockResolvedValue({ status: "ok", deploymentMode: "authenticated", bootstrapStatus: "ready" });
|
||||
session.mockResolvedValue({ user: { id: "user" } });
|
||||
access.mockResolvedValue({ companyIds: ["company"] });
|
||||
settings.mockReturnValue(new Promise(resolve => { resolveSettings = resolve; }));
|
||||
function RoutesUnderTest() {
|
||||
const { loaded, enabled } = useStreamlinedUiEnabled();
|
||||
return <Routes><Route element={<CloudAccessGate allowMembershipRequest={allowMembershipRequest} />}>
|
||||
<Route element={<ShellConfigurationGate loaded={loaded} />}>
|
||||
<Route path="apps" element={<div>{enabled ? "Streamlined shell" : "Legacy shell"}</div>} />
|
||||
</Route>
|
||||
</Route></Routes>;
|
||||
}
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
try {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={["/apps"]}><RoutesUnderTest /></MemoryRouter></QueryClientProvider>));
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
expect(health).toHaveBeenCalledTimes(1);
|
||||
expect(session).toHaveBeenCalledTimes(1);
|
||||
expect(access).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).not.toContain("Streamlined shell");
|
||||
expect(container.textContent).not.toContain("Legacy shell");
|
||||
await act(async () => { resolveSettings({ enableStreamlinedUi: false }); });
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
expect(container.textContent).toBe("Legacy shell");
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
client.clear();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Outlet } from "@/lib/router";
|
||||
import { PaperclipLoading } from "./AnimatedPaperclipIcon";
|
||||
|
||||
/** Keep shell selection stable while the independent access gate loads. */
|
||||
export function ShellConfigurationGate({ loaded }: { loaded: boolean }) {
|
||||
return loaded ? <Outlet /> : <PaperclipLoading />;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() }));
|
||||
const mockAgentsApi = vi.hoisted(() => ({ wakeup: vi.fn() }));
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
summaries: vi.fn(),
|
||||
update: vi.fn(),
|
||||
archiveFromInbox: vi.fn(),
|
||||
getTreeControlState: vi.fn(),
|
||||
@@ -63,6 +64,10 @@ describe("SidebarRecentTasks", () => {
|
||||
window.localStorage.clear();
|
||||
Object.values(mockAgentsApi).forEach((mock) => mock.mockReset());
|
||||
Object.values(mockIssuesApi).forEach((mock) => mock.mockReset());
|
||||
mockIssuesApi.summaries.mockImplementation(async (_company: string, ids: string[]) => {
|
||||
const rows = await Promise.all(ids.map(id => mockIssuesApi.get.getMockImplementation()?.(id)));
|
||||
return rows.filter(row => row && !row.hiddenAt);
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -154,10 +159,9 @@ describe("SidebarRecentTasks", () => {
|
||||
});
|
||||
|
||||
const link = container.querySelector('a[href="/issues/issue-1"]');
|
||||
expect(mockIssuesApi.get).toHaveBeenCalledWith("issue-1");
|
||||
expect(queryClient.getQueryData(["issues", "detail", "issue-1"])).toMatchObject({
|
||||
title: "Refreshed title",
|
||||
});
|
||||
expect(mockIssuesApi.get).not.toHaveBeenCalled();
|
||||
expect(mockIssuesApi.summaries).toHaveBeenCalledTimes(1);
|
||||
expect(queryClient.getQueryData(["issues", "detail", "issue-1"])).toBeUndefined();
|
||||
expect(link?.textContent).toContain("Refreshed title");
|
||||
expect(link?.textContent).toContain("1 live");
|
||||
expect(link?.querySelector('[aria-label="In Progress"]')).toBeNull();
|
||||
@@ -179,6 +183,7 @@ describe("SidebarRecentTasks", () => {
|
||||
identifier: `PAP-${id}`,
|
||||
status: "done" as const,
|
||||
hiddenAt: null,
|
||||
projectId: null,
|
||||
updatedAt: new Date(id),
|
||||
}));
|
||||
tasks.forEach((task) => recordRecentTask(task, "user-1"));
|
||||
@@ -206,7 +211,8 @@ describe("SidebarRecentTasks", () => {
|
||||
expect(order()).toEqual(originalOrder);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
|
||||
expect(order()).toEqual(["/issues/issue-2", "/issues/issue-1", "/issues/issue-3"]);
|
||||
expect(mockIssuesApi.get).toHaveBeenCalledTimes(3);
|
||||
expect(mockIssuesApi.get).not.toHaveBeenCalled();
|
||||
expect(mockIssuesApi.summaries).toHaveBeenCalledTimes(1);
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Archive, MoreHorizontal, Pencil, RefreshCw } from "lucide-react";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useRecentTasks } from "@/hooks/useRecentTasks";
|
||||
import { useAccountIdentity } from "@/api/companies-query";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { useOptionalToastActions } from "@/context/ToastContext";
|
||||
import {
|
||||
@@ -76,15 +76,10 @@ export function SidebarRecentTasks({
|
||||
}) {
|
||||
const { collapsed, peeking } = useSidebar();
|
||||
const rail = collapsed && !peeking;
|
||||
const { data: session, isPending } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
const { userId, settled } = useAccountIdentity();
|
||||
|
||||
if (!companyId || isPending) return null;
|
||||
if (!companyId || !settled) return null;
|
||||
|
||||
const userId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
return (
|
||||
<RecentTasksList
|
||||
key={`${companyId}:${userId ?? "__local_board__"}`}
|
||||
@@ -236,6 +231,7 @@ function RecentTasksList({
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["issues", "tree-control-state", entry.id],
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.summaries(companyId) });
|
||||
} catch (error) {
|
||||
toastActions?.pushToast({
|
||||
title: "Task pause update failed",
|
||||
|
||||
@@ -6,15 +6,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
const { pushToast } = vi.hoisted(() => ({ pushToast: vi.fn() }));
|
||||
const { sessionRead, healthRead } = vi.hoisted(() => ({ sessionRead: vi.fn(), healthRead: vi.fn() }));
|
||||
vi.mock("./CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1" } }),
|
||||
}));
|
||||
vi.mock("./ToastContext", () => ({ useToastActions: () => ({ pushToast }) }));
|
||||
vi.mock("../lib/router", () => ({ useLocation: () => ({ pathname: "/tasks" }) }));
|
||||
vi.mock("../api/auth", () => ({
|
||||
authApi: { getSession: async () => ({ user: { id: "viewer" }, session: { id: "session", userId: "viewer" } }) },
|
||||
authApi: { getSession: sessionRead },
|
||||
}));
|
||||
vi.mock("../api/health", () => ({ healthApi: { get: async () => ({ deploymentMode: "authenticated" }) } }));
|
||||
vi.mock("../api/health", () => ({ healthApi: { get: healthRead } }));
|
||||
import { LiveUpdatesProvider } from "./LiveUpdatesProvider";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -41,6 +42,8 @@ describe("LiveUpdatesProvider connection recovery", () => {
|
||||
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
|
||||
read.mockReset();
|
||||
read.mockResolvedValue("current task data");
|
||||
sessionRead.mockReset().mockResolvedValue({ user: { id: "viewer" }, session: { id: "session", userId: "viewer" } });
|
||||
healthRead.mockReset().mockResolvedValue({ deploymentMode: "authenticated" });
|
||||
Socket.instances = [];
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: Infinity } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "viewer" }, session: { id: "session", userId: "viewer" } });
|
||||
@@ -80,9 +83,17 @@ describe("LiveUpdatesProvider connection recovery", () => {
|
||||
await act(async () => Socket.instances[0].onopen?.());
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1));
|
||||
expect(container.textContent).toBe("reply saved during connection setup");
|
||||
expect(sessionRead).not.toHaveBeenCalled();
|
||||
expect(healthRead).not.toHaveBeenCalled();
|
||||
const readsAfterConnect = read.mock.calls.length;
|
||||
await act(async () => vi.advanceTimersByTimeAsync(30_000));
|
||||
expect(read).toHaveBeenCalledTimes(readsAfterConnect);
|
||||
await act(async () => Socket.instances[0].onclose?.());
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1_001));
|
||||
await act(async () => Socket.instances.at(-1)?.onopen?.());
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1));
|
||||
expect(sessionRead).toHaveBeenCalled();
|
||||
expect(healthRead).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["missing", "throws"])("polls visible data and resumes realtime when the constructor %s", async (failure) => {
|
||||
|
||||
@@ -1233,6 +1233,7 @@ function invalidateHeartbeatQueries(
|
||||
companyId: string,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.summaries(companyId) });
|
||||
// Note: liveRuns(companyId) is intentionally NOT invalidated here — it is
|
||||
// event-sourced via applyRunLifecycleToCompanyLiveRuns in the caller.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId) });
|
||||
@@ -1897,6 +1898,8 @@ function canUseLiveSession(sessionStatus: string, hasSession: boolean, deploymen
|
||||
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
const { visible } = usePageVisibility();
|
||||
const wasHidden = useRef(!visible);
|
||||
const hasOpenedSocket = useRef(false);
|
||||
const hasNeededSocketRecovery = useRef(!visible);
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
@@ -1961,6 +1964,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
wasHidden.current = true;
|
||||
hasNeededSocketRecovery.current = true;
|
||||
invalidationBatcher.dispose();
|
||||
return;
|
||||
}
|
||||
@@ -2002,6 +2006,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
const scheduleReconnect = () => {
|
||||
if (closed || reconnectTimer !== null) return;
|
||||
reconnectAttempt += 1;
|
||||
hasNeededSocketRecovery.current = true;
|
||||
const delayMs = Math.min(
|
||||
15000,
|
||||
1000 * 2 ** Math.min(reconnectAttempt - 1, 4),
|
||||
@@ -2036,7 +2041,15 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
// The initial page queries can finish before the first subscription,
|
||||
// too. Reconcile that gap as well as reconnects: events are not replayed.
|
||||
void queryClient.invalidateQueries({ type: "active" }, { cancelRefetch: false });
|
||||
const initialHealthyOpen = !hasOpenedSocket.current && !hasNeededSocketRecovery.current;
|
||||
hasOpenedSocket.current = true;
|
||||
void queryClient.invalidateQueries({
|
||||
type: "active",
|
||||
...(initialHealthyOpen ? { predicate: (query) => {
|
||||
const key = JSON.stringify(query.queryKey);
|
||||
return key !== JSON.stringify(queryKeys.auth.session) && key !== JSON.stringify(queryKeys.health);
|
||||
} } : {}),
|
||||
}, { cancelRefetch: false });
|
||||
reconnectAttempt = 0;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
} from "@/lib/recent-tasks";
|
||||
import { useRecentTasks } from "./useRecentTasks";
|
||||
|
||||
const { getIssue } = vi.hoisted(() => ({ getIssue: vi.fn() }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { get: getIssue } }));
|
||||
const { getIssue, summaries } = vi.hoisted(() => ({ getIssue: vi.fn(), summaries: vi.fn() }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { get: getIssue, summaries } }));
|
||||
|
||||
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const task = (updatedAt: number, title: string, status: "todo" | "done" = "todo") => ({
|
||||
id: "issue-1", companyId: "company-1", identifier: "TASK-1", title, status,
|
||||
updatedAt: new Date(updatedAt), hiddenAt: null,
|
||||
updatedAt: new Date(updatedAt), hiddenAt: null, projectId: null,
|
||||
});
|
||||
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
|
||||
// Node 24 uses jsdom Storage; newer Node versions use the setup's storage shim.
|
||||
@@ -34,8 +34,8 @@ describe("recent task synchronization", () => {
|
||||
let clients: QueryClient[];
|
||||
let renders: number;
|
||||
|
||||
function RecentTasks({ name }: { name: string }) {
|
||||
const { entries } = useRecentTasks({ companyId: "company-1", userId: "user-1" });
|
||||
function RecentTasks({ name, companyId = "company-1", userId = "user-1" }: { name: string; companyId?: string; userId?: string }) {
|
||||
const { entries } = useRecentTasks({ companyId, userId });
|
||||
renders += 1;
|
||||
// Fail promptly if a regression restores the cross-cache render loop.
|
||||
if (renders > 100) throw new Error("Recent Tasks did not settle");
|
||||
@@ -44,7 +44,10 @@ describe("recent task synchronization", () => {
|
||||
|
||||
function client(snapshot?: ReturnType<typeof task>) {
|
||||
const value = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
|
||||
if (snapshot) value.setQueryData(queryKeys.issues.detail(snapshot.id), snapshot);
|
||||
if (snapshot) {
|
||||
value.setQueryData(queryKeys.issues.detail(snapshot.id), snapshot);
|
||||
value.setQueryData(queryKeys.issues.summaryBatch("company-1", "user-1", [snapshot.id]), [snapshot]);
|
||||
}
|
||||
clients.push(value);
|
||||
return value;
|
||||
}
|
||||
@@ -58,6 +61,7 @@ describe("recent task synchronization", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
getIssue.mockReset();
|
||||
summaries.mockReset();
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
@@ -146,15 +150,131 @@ describe("recent task synchronization", () => {
|
||||
expect(readRecentTasks(storageKey, "company-1")[0]?.recordedAt).toBe(100);
|
||||
});
|
||||
|
||||
it.each([403, 404])("still removes tasks whose query returns %s", async (status) => {
|
||||
it.each([403, 500])("retains snapshots when the batch fails with %s", async (status) => {
|
||||
recordRecentTask(task(10, "Unavailable"), "user-1");
|
||||
getIssue.mockRejectedValue(new ApiError("Unavailable", status, null));
|
||||
summaries.mockRejectedValue(new ApiError("Unavailable", status, null));
|
||||
await act(async () => root.render(
|
||||
<QueryClientProvider client={client()}><RecentTasks name="unavailable" /></QueryClientProvider>,
|
||||
));
|
||||
await flush();
|
||||
expect(readRecentTasks(storageKey, "company-1")).toHaveLength(1);
|
||||
expect(container.textContent).toBe("Unavailable:todo");
|
||||
expect(summaries).toHaveBeenCalledTimes(1);
|
||||
expect(getIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches five tasks once without activating detail queries", async () => {
|
||||
const tasks = Array.from({ length: 5 }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${i}` }));
|
||||
tasks.forEach(issue => recordRecentTask(issue, "user-1"));
|
||||
summaries.mockResolvedValue(tasks);
|
||||
const cache = client();
|
||||
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="batch" /></QueryClientProvider>));
|
||||
await flush();
|
||||
expect(summaries).toHaveBeenCalledTimes(1);
|
||||
expect(summaries.mock.calls[0][1]).toEqual(tasks.map(issue => issue.id).sort());
|
||||
expect(getIssue).not.toHaveBeenCalled();
|
||||
expect(cache.getQueryCache().findAll({ queryKey: ["issues", "detail"] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not fetch empty history", async () => {
|
||||
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="empty" /></QueryClientProvider>));
|
||||
await flush();
|
||||
expect(summaries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prunes successful omissions without resurrecting cached full details", async () => {
|
||||
const missing = task(10, "Removed");
|
||||
recordRecentTask(missing, "user-1");
|
||||
const cache = client();
|
||||
cache.setQueryData(queryKeys.issues.detail(missing.id), task(30, "Cached secret"));
|
||||
summaries.mockResolvedValue([]);
|
||||
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="missing" /></QueryClientProvider>));
|
||||
await flush();
|
||||
expect(readRecentTasks(storageKey, "company-1")).toEqual([]);
|
||||
expect(container.textContent).toBe("");
|
||||
expect(getIssue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not prune a task added while an older batch is pending", async () => {
|
||||
recordRecentTask(task(10, "Missing old task"), "user-1");
|
||||
let resolveFirst!: (rows: unknown[]) => void;
|
||||
summaries.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve; }))
|
||||
.mockReturnValue(new Promise(() => {}));
|
||||
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="race" /></QueryClientProvider>));
|
||||
const added = { ...task(20, "New task"), id: "issue-2" };
|
||||
await act(async () => { recordRecentTask(added, "user-1"); });
|
||||
await act(async () => { resolveFirst([]); });
|
||||
await flush();
|
||||
expect(readRecentTasks(storageKey, "company-1").some(entry => entry.id === added.id)).toBe(true);
|
||||
expect(container.textContent).toContain("New task");
|
||||
});
|
||||
|
||||
it("isolates an account switch while the old request is pending", async () => {
|
||||
recordRecentTask(task(10, "Old account task"), "user-1");
|
||||
recordRecentTask({ ...task(20, "New account task"), id: "issue-2" }, "user-2");
|
||||
let resolveOld!: (rows: unknown[]) => void;
|
||||
summaries.mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve; }))
|
||||
.mockResolvedValue([{ ...task(20, "New account task"), id: "issue-2" }]);
|
||||
const cache = client();
|
||||
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="account" /></QueryClientProvider>));
|
||||
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="account" userId="user-2" /></QueryClientProvider>));
|
||||
await act(async () => { resolveOld([task(30, "Old response")]); });
|
||||
await flush();
|
||||
expect(container.textContent).toBe("New account task:todo");
|
||||
expect(readRecentTasks(getRecentTasksStorageKey("company-1", "user-2"), "company-1")[0]?.id).toBe("issue-2");
|
||||
expect(summaries.mock.calls.map(call => call[1])).toEqual([["issue-1"], ["issue-2"]]);
|
||||
});
|
||||
|
||||
it("refreshes a cached omission when the user revisits an accessible task", async () => {
|
||||
const first = task(10, "First");
|
||||
const second = { ...task(20, "Second"), id: "issue-2" };
|
||||
recordRecentTask(first, "user-1");
|
||||
recordRecentTask(second, "user-1");
|
||||
summaries.mockResolvedValue([second]);
|
||||
const cache = client();
|
||||
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="revisited" /></QueryClientProvider>));
|
||||
await flush();
|
||||
expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id)).toEqual([second.id]);
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
expect(cache.getQueryState(queryKeys.issues.summaryBatch("company-1", "user-1", [second.id]))?.status).toBe("success");
|
||||
summaries.mockResolvedValue([first, second]);
|
||||
await act(async () => { recordRecentTask(first, "user-1", 30); });
|
||||
await flush();
|
||||
expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id).sort()).toEqual([first.id, second.id]);
|
||||
expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a revisited task when refreshing its cached omission fails", async () => {
|
||||
const first = task(10, "First");
|
||||
const second = { ...task(20, "Second"), id: "issue-2" };
|
||||
recordRecentTask(first, "user-1");
|
||||
recordRecentTask(second, "user-1");
|
||||
summaries.mockResolvedValue([second]);
|
||||
const cache = client();
|
||||
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="retry-failed" /></QueryClientProvider>));
|
||||
await flush();
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
summaries.mockRejectedValue(new ApiError("Unavailable", 500, null));
|
||||
await act(async () => { recordRecentTask(first, "user-1", 30); });
|
||||
await flush();
|
||||
expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id).sort()).toEqual([first.id, second.id]);
|
||||
expect(cache.getQueryState(queryKeys.issues.summaryBatch("company-1", "user-1", [first.id, second.id]))?.status).toBe("error");
|
||||
expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("prunes a revisited task when a fresh response repeats the cached omission", async () => {
|
||||
const first = task(10, "First");
|
||||
const second = { ...task(20, "Second"), id: "issue-2" };
|
||||
recordRecentTask(first, "user-1");
|
||||
recordRecentTask(second, "user-1");
|
||||
const rows = [second];
|
||||
summaries.mockResolvedValue(rows);
|
||||
const cache = client();
|
||||
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="still-missing" /></QueryClientProvider>));
|
||||
await flush();
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
await act(async () => { recordRecentTask(first, "user-1", 30); });
|
||||
await flush();
|
||||
expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2);
|
||||
expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id)).toEqual([second.id]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
@@ -33,9 +32,16 @@ export function useRecentTasks({
|
||||
() => companyId ? getRecentTasksStorageKey(companyId, userId) : null,
|
||||
[companyId, userId],
|
||||
);
|
||||
const [entries, setEntries] = useState<RecentTaskEntry[]>(() => (
|
||||
storageKey && companyId ? readRecentTasks(storageKey, companyId) : []
|
||||
));
|
||||
const queryClient = useQueryClient();
|
||||
const [snapshot, setSnapshot] = useState(() => ({
|
||||
storageKey,
|
||||
entries: storageKey && companyId ? readRecentTasks(storageKey, companyId) : [],
|
||||
}));
|
||||
// A scope change must not request the previous account's IDs for one render.
|
||||
const entries = snapshot.storageKey === storageKey
|
||||
? snapshot.entries
|
||||
: storageKey && companyId ? readRecentTasks(storageKey, companyId) : [];
|
||||
const setEntries = (next: RecentTaskEntry[]) => setSnapshot({ storageKey, entries: next });
|
||||
|
||||
useEffect(() => {
|
||||
if (storageKey && companyId) migrateRecentTasks(storageKey, companyId);
|
||||
@@ -62,53 +68,77 @@ export function useRecentTasks({
|
||||
};
|
||||
}, [companyId, storageKey]);
|
||||
|
||||
// Keep query observers in a fixed order when activity changes the display order.
|
||||
const queryEntries = [...entries].sort((left, right) => left.id.localeCompare(right.id));
|
||||
const membership = JSON.stringify(queryEntries.map((entry) => entry.id));
|
||||
const detailQueries = useQueries({
|
||||
queries: queryEntries.map((entry) => ({
|
||||
queryKey: queryKeys.issues.detail(entry.id),
|
||||
queryFn: () => issuesApi.get(entry.id),
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
const queryIds = useMemo(() => entries.map((entry) => entry.id).sort(), [entries]);
|
||||
const membership = JSON.stringify(queryIds);
|
||||
const scope = JSON.stringify([storageKey, queryIds]);
|
||||
const summaryKey = queryKeys.issues.summaryBatch(companyId ?? "__none__", userId, queryIds);
|
||||
// A cached omission predates IDs that the user has just added back. Only a
|
||||
// response completed during this visit to the batch may remove those IDs.
|
||||
const batchVisit = useMemo(() => ({
|
||||
initialUpdateCount: queryClient.getQueryState(summaryKey)?.dataUpdateCount ?? 0,
|
||||
// The serialized scope includes the account, company, and sorted ID set.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [queryClient, scope]);
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: summaryKey,
|
||||
queryFn: ({ signal }) => issuesApi.summaries(companyId!, queryIds, { signal }),
|
||||
enabled: Boolean(companyId && queryIds.length),
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const issueById = new Map(detailQueries.flatMap((query) => query.data ? [[query.data.id, query.data] as const] : []));
|
||||
// Observe completion even when structural sharing preserves the data object.
|
||||
// The successful-update counter itself is only available on the cache state.
|
||||
const fetchingSummaries = summaryQuery.isFetching;
|
||||
const updateCount = queryClient.getQueryState(summaryKey)?.dataUpdateCount ?? 0;
|
||||
const summaries = summaryQuery.isSuccess ? summaryQuery.data : undefined;
|
||||
const confirmed = new Map((summaries ?? []).filter((issue) => issue.companyId === companyId)
|
||||
.map((issue) => [issue.id, issue]));
|
||||
const refreshedEntries = entries.map((entry) => {
|
||||
const issue = issueById.get(entry.id);
|
||||
if (!issue || issue.companyId !== companyId || issue.hiddenAt) return entry;
|
||||
return mergeRecentTaskSnapshot(entry, issue);
|
||||
const issue = confirmed.get(entry.id);
|
||||
return issue ? mergeRecentTaskSnapshot(entry, issue) : entry;
|
||||
});
|
||||
const queryRevision = detailQueries
|
||||
.map((query) => `${query.dataUpdatedAt}:${query.errorUpdatedAt}:${query.status}`)
|
||||
.join("|");
|
||||
const processed = useRef<{ scope: string; revision: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageKey || !companyId || detailQueries.length === 0) return;
|
||||
|
||||
const resolvedIssues: Issue[] = [];
|
||||
const removeIds = new Set<string>();
|
||||
detailQueries.forEach((query, index) => {
|
||||
const entry = queryEntries[index];
|
||||
if (!entry) return;
|
||||
if (query.data) {
|
||||
if (query.data.companyId !== companyId || query.data.hiddenAt) {
|
||||
removeIds.add(entry.id);
|
||||
} else {
|
||||
resolvedIssues.push(query.data);
|
||||
}
|
||||
} else if (query.error instanceof ApiError && [403, 404].includes(query.error.status)) {
|
||||
removeIds.add(entry.id);
|
||||
}
|
||||
});
|
||||
|
||||
updateRecentTaskSnapshots(storageKey, companyId, resolvedIssues);
|
||||
pruneRecentTasks(storageKey, companyId, removeIds);
|
||||
// Publish only when queries or membership change. A storage notification must
|
||||
// not feed the same cached query results back into storage in every tab.
|
||||
if (!storageKey || !companyId || !summaries) return;
|
||||
if (processed.current?.scope === scope && processed.current.revision === updateCount) return;
|
||||
processed.current = { scope, revision: updateCount };
|
||||
const returnedIds = new Set(summaries.filter((issue) => issue.companyId === companyId).map((issue) => issue.id));
|
||||
updateRecentTaskSnapshots(storageKey, companyId, summaries);
|
||||
const omittedIds = new Set(queryIds.filter((id) => !returnedIds.has(id)));
|
||||
if (omittedIds.size && updateCount <= batchVisit.initialUpdateCount) {
|
||||
void summaryQuery.refetch({ cancelRefetch: false });
|
||||
return;
|
||||
}
|
||||
// Never prune IDs added after this particular batch was requested.
|
||||
pruneRecentTasks(storageKey, companyId, omittedIds);
|
||||
// A storage notification must not republish the same successful revision.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [companyId, membership, queryRevision, storageKey]);
|
||||
}, [batchVisit, companyId, fetchingSummaries, scope, updateCount, summaries, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageKey || !companyId || !summaries) return;
|
||||
const authorizedIds = new Set(summaries.filter((issue) => issue.companyId === companyId).map((issue) => issue.id));
|
||||
const mergeCached = (value: unknown) => {
|
||||
if (!value || typeof value !== "object") return;
|
||||
const issue = value as Issue;
|
||||
if (!authorizedIds.has(issue.id) || issue.companyId !== companyId || issue.hiddenAt
|
||||
|| typeof issue.title !== "string" || typeof issue.status !== "string"
|
||||
|| !issue.updatedAt || !Object.hasOwn(issue, "projectId")) return;
|
||||
// At equal task versions, the summary's independently computed readiness
|
||||
// is newer than an already-cached full detail response.
|
||||
const summary = summaries.find((item) => item.id === issue.id);
|
||||
if (!summary || new Date(issue.updatedAt).getTime() <= new Date(summary.updatedAt).getTime()) return;
|
||||
updateRecentTaskSnapshots(storageKey, companyId, [issue]);
|
||||
};
|
||||
for (const id of authorizedIds) mergeCached(queryClient.getQueryData(queryKeys.issues.detail(id)));
|
||||
return queryClient.getQueryCache().subscribe((event) => {
|
||||
if (event.type !== "updated" || event.action.type !== "success") return;
|
||||
const key = event.query.queryKey;
|
||||
if (key.length === 3 && key[0] === "issues" && key[1] === "detail"
|
||||
&& event.query.state.status === "success") mergeCached(event.query.state.data);
|
||||
});
|
||||
}, [companyId, queryClient, summaries, storageKey]);
|
||||
|
||||
const [settledOrder, setSettledOrder] = useState(() => entries.map((entry) => entry.id));
|
||||
const activityRevision = JSON.stringify(entries.map((entry) => [entry.id, entry.recordedAt]));
|
||||
|
||||
@@ -361,6 +361,9 @@ export const queryKeys = {
|
||||
"execution-workspace",
|
||||
executionWorkspaceId,
|
||||
] as const,
|
||||
summaries: (companyId: string) => ["issues", companyId, "summaries"] as const,
|
||||
summaryBatch: (companyId: string, userId: string | null | undefined, sortedIds: readonly string[]) =>
|
||||
["issues", companyId, "summaries", userId ?? "__local_board__", sortedIds] as const,
|
||||
detail: (id: string) => ["issues", "detail", id] as const,
|
||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||
commentsList: (issueId: string) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Issue, IssueStatus } from "@paperclipai/shared";
|
||||
import type { Issue, IssueStatus, IssueSummary } from "@paperclipai/shared";
|
||||
|
||||
export const RECENT_TASKS_LIMIT = 5;
|
||||
export const RECENT_TASKS_UPDATED_EVENT = "paperclip:recent-tasks-updated";
|
||||
@@ -104,7 +104,7 @@ export function writeRecentTasks(storageKey: string, entries: RecentTaskEntry[])
|
||||
publishRecentTasks(storageKey, bounded);
|
||||
}
|
||||
|
||||
type TaskSnapshot = Pick<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "externalConversationState" | "updatedAt">;
|
||||
type TaskSnapshot = Omit<IssueSummary, "externalConversationState"> & Pick<Issue, "externalConversationState">;
|
||||
|
||||
export function mergeRecentTaskSnapshot(entry: RecentTaskEntry, issue: TaskSnapshot): RecentTaskEntry {
|
||||
if (entry.id !== issue.id || entry.companyId !== issue.companyId) return entry;
|
||||
|
||||
Reference in new issue
Block a user