Files
PaperClipAI/ui/src/lib/query-client.ts
T
Nicky LeachandPaperclip 35ab90ad0f Map runtime activity to authorized task plugin panels (#18131)
## Thinking Path

> - Paperclip manages agents and tasks.
> - Plugins contribute views to the task sidebar.
> - A ready view can belong to an earlier task.
> - Runner records activity under its authenticated run.
> - This PR maps that activity to installed task-panel declarations.
> - The sidebar checks readiness after current-run use and restores use
after reconnect.

## Linked Issues or Issue Description

Builds on merged contract #18129 and Runner publisher #18050. This PR
targets
master and is ready for review. Earlier combined verification is
retained in
[the verification
archive](https://github.com/paperclipai/paperclip/pull/18050#issuecomment-6489369676).

**Subsystem affected**

Run-event reads, plugin task contributions and the task sidebar.

**Problem or motivation**

Readiness polling can show a view left ready by another task. Reload and
reconnect must recover current-run use without losing concurrent live
activity.

**Proposed solution**

Map recorded activity to installed task-panel declarations. Merge
authorized
history and live activity, then perform bounded readiness checks for the
running agent. Preserve the existing read-only view.

## What Changed

- Add optional `{ namespace, activityKey }` activation mappings to
task-panel
declarations. Legacy declarations remain valid but contribute no task
panel.
- Add `view=plugin-panels` to the existing run-events route. Preserve
run-read and
telemetry-read authorization, secret redaction and private-run denial.
Map
activity to installed contributions and return at most 128 panel
identities.
- Scope activation caches to current panel versions and activity
declarations.
Preserve unchanged panels during unrelated plugin updates and history
reads.
- Merge history and live hints without losing concurrent activations.
Filter by
  company, task, run and agent. Restore history on mount and reconnect.
- Replace periodic availability checks with up to three readiness
attempts after
activity. Fresh activity restarts settled transient failures. Pending
checks,
  successful readiness and HTTP 4xx failures are left alone.
- Reuse live run updates with the panel cache key. Recover only panel
history
after a pending subscription read. Mark expected not-ready query results
so
  the global error reporter skips them; real failures remain reportable.
- Preserve current-run dismissal and the read-only plugin view. Update
manifest
  comments and producer/consumer documentation.

## Verification

- Current head: `5e04d757c`, based on master `09c05c550`. Range
comparison
confirmed all six feature commits were unchanged by the rebase. The
final
follow-up keys readiness by the plugin installation ID and availability
path.
- `cd ui && node ../node_modules/vitest/vitest.mjs run
src/hooks/useTaskPluginPanels.test.tsx`
— all 45 cases passed. `cd ui && node ../node_modules/typescript/bin/tsc
-b` passed.
- The shared query-client and live-update recovery selection passed 96
cases on
  `49c544cd2`. Token gates passed before the final hook-only changes.
- Earlier focused verification passed 57 manifest cases, 69
database/history cases,
event-registry cases and route authorization cases, including
private-run denial.
- Regression coverage includes subscription/history races, task changes
during
recovery, unfocused run completion, bounded readiness retries, denied
reads,
expected not-ready responses, changed activity mappings, unchanged panel
preservation, old cache scopes, and same-version route or installation
changes.
- [Current-head
CI](https://github.com/paperclipai/paperclip/actions/runs/40942001707)
passed: 54 successful checks, two skips, no failures or pending checks.
- [Standard and final
review](https://github.com/paperclipai/paperclip/pull/18131#issuecomment-6489842168)
both scored 5/5 on `5e04d757c`. The final review used the Apex tier,
added no
comments, and left no unresolved findings. The PR is conflict-free and
ready for review.
- Local checks stay targeted. GitHub CI provides the broad build, test,
typecheck,
browser and Runner coverage. No deployment or live-provider acceptance
is claimed.

<details>
<summary>Retained failures and recovery evidence</summary>

- `ac9329548` twice failed a browser reload assertion while conversation
history
was concealed. Master fixed its readiness wait in #17923. The approve
journey
then passed locally on `fea63a418` with installed Chrome (1 passed, 39
seconds).
All eight browser shards passed in later CI. The browser test is outside
this diff.
- A canary checkout failed with Git HTTP 408; its unchanged-head retry
passed.
- `de07d09fa` failed the mobile Cases card click after a ten-second
stability wait.
The same read-only journey passed locally on that exact head (1 passed,
26 seconds).
Its unchanged-head CI retry passed 22 cases, with one skip. The original
artifact
  is retained; the cause of that click timeout is not established.
- [CI on
`49c544cd2`](https://github.com/paperclipai/paperclip/actions/runs/40903679075)
passed 54 checks with two skips. CI also passed on `de07d09fa` after the
recorded retry.
- The `1e59ab441` merge checkout passed all browser and server suites.
Runner
shard 2 passed 2,252 cases and timed out waiting for a
controller-recovery command.
The test had landed on master in #18161 after the former branch base.
Rebasing
aligned the local checkout with that code; Runner shard 2 then passed on
`fcf7380d3`.
The original timeout cause remains unproven; no unrelated timeout was
changed.
- [CI on
`fcf7380d3`](https://github.com/paperclipai/paperclip/actions/runs/40940235193)
recorded runner shutdown signals in typecheck and a workspace shard. A
chat
recovery test observed a null wake deadline, and one Runner transport
fixture
failed during close. Those separate assertion failures remain
unexplained.
All of those jobs passed on the final readiness-key head. Those later
passes
establish recovery; they do not prove the cause of the earlier assertion
failures.
- Local browser setup first stopped at pnpm version-download
verification, then a
sandbox IPC restriction, then a missing bundled browser. Installed pnpm
9.15.4
  and Chrome completed the journeys. No dependency files were changed.
- Earlier optional-property TypeScript errors were corrected. Embedded
PostgreSQL
needed its install hook run from its package directory. Older CI also
recorded
fixture-cleanup lock timeouts, attention-fixture timeouts, and canceled
server
shards after runner shutdown. The parent fixed its FIFO snapshot-copy
failure
before merge. The linked verification archive retains earlier combined
evidence.

</details>

## Risks

Activity is a hint and grants no access. Missing plugins, denied reads
and
unavailable views contribute no panel. Older runs without activity
records show
no task plugin tab. A used view stays available for the running task;
reconnect
reconciles readiness. Matching producer and consumer builds are
required. No
migration, dependency, image publication or deployment is included.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools and code
execution.
The exact serving snapshot is not exposed.

## 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
- [x] 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 <noreply@paperclip.ing>
2026-10-29 13:56:48 -07:00

259 lines
10 KiB
TypeScript

/**
* The app's React Query client and its outage-aware defaults.
*
* - Queries retry transient errors with backoff for about 30 seconds and never
* retry a 4xx. `networkMode: "online"` plus `bindConnectivity` means a
* confirmed outage pauses queries instead of failing them.
* - Mutations fail fast by default (`networkMode: "always"`, no retry). A
* mutation that is safe to send twice opts in with
* `meta: { replay: "idempotent" }`: it pauses while the server is
* unreachable, retries transient errors, and resumes on reconnect.
* - `QueryCache`/`MutationCache` events feed the connectivity store; final
* query errors go to Sentry unless they are transient or client errors.
* - A mutation with `meta: { errorToast: true }` and no `onError` of its own
* gets a readable toast when it fails. Transient failures skip the toast
* while the connection banner covers them.
*/
import {
MutationCache,
QueryCache,
QueryClient,
onlineManager,
type DefaultError,
type Mutation,
type MutationOptions,
type QueryClientConfig,
type QueryKey,
type RefetchOptions,
type RefetchQueryFilters,
} from "@tanstack/react-query";
import { classifyError, describeError, errorRetryAfterMs, isTransientError } from "@/api/errors";
import { MAX_PROBE_DELAY_MS, PROBE_BACKOFF_MS, type ConnectivityStore } from "./connectivity";
export interface AppMutationMeta extends Record<string, unknown> {
/**
* `"idempotent"`: sending this mutation twice has the same effect as once
* (an absolute-value PATCH, a POST with an idempotency key). It may pause
* across an outage and resend on reconnect.
*/
replay?: "idempotent";
/**
* `true` shows the global readable error toast when this mutation has no
* `onError` of its own. Opt-in because many surfaces already render
* `mutation.error` inline, and a default toast would say it twice.
*/
errorToast?: true;
}
declare module "@tanstack/react-query" {
interface Register {
mutationMeta: AppMutationMeta;
}
}
/** Delay before each transient retry; together roughly 30 seconds. */
export const TRANSIENT_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 15_000] as const;
/** Unexpected failures (5xx other than gateway errors, parse errors) retry briefly. */
export const UNKNOWN_RETRY_LIMIT = 2;
/** Retry policy shared by queries and replayable mutations. */
export function shouldRetryRequest(failureCount: number, error: unknown): boolean {
const kind = classifyError(error);
if (kind === "transient") return failureCount < TRANSIENT_RETRY_DELAYS_MS.length;
if (kind === "unknown") return failureCount < UNKNOWN_RETRY_LIMIT;
return false;
}
/**
* Backoff for `shouldRetryRequest`; a longer server `Retry-After` wins. It is
* not shortened: retrying before the server's window ends only fails again.
* `parseRetryAfter` already bounds it.
*/
export function retryDelayFor(failureCount: number, error: unknown): number {
const base = TRANSIENT_RETRY_DELAYS_MS[Math.min(failureCount, TRANSIENT_RETRY_DELAYS_MS.length - 1)];
const retryAfterMs = errorRetryAfterMs(error);
return retryAfterMs === null ? base : Math.max(base, retryAfterMs);
}
/**
* For reads the app cannot open without (the access gate): retry transient
* errors without limit, never anything else. Retries pause while the
* connectivity store reports an outage, so this does not poll a down server.
*/
export function retryWhileTransient(_failureCount: number, error: unknown): boolean {
return isTransientError(error);
}
/**
* For the few reads that must fail fast: retry a transient error at most
* `limit` times (`0` never retries) and never anything else. Prefer this over
* `retry: false` so the call site says why it opts out of the default policy.
*/
export function retryTransientOnly(limit: number): (failureCount: number, error: unknown) => boolean {
return (failureCount, error) => isTransientError(error) && failureCount < limit;
}
/** Backoff for `retryWhileTransient`: the connectivity probe schedule, capped at 15s. */
export function reconnectDelayFor(failureCount: number, error: unknown): number {
const base = PROBE_BACKOFF_MS[Math.min(failureCount, PROBE_BACKOFF_MS.length - 1)];
const retryAfterMs = errorRetryAfterMs(error);
return retryAfterMs === null ? base : Math.min(Math.max(base, retryAfterMs), MAX_PROBE_DELAY_MS);
}
/** A successful response whose expected state has not arrived yet. */
export class QueryNotReadyError extends Error {}
/** Query errors worth a Sentry event: not outages, not denials or missing items. */
export function shouldReportQueryError(error: unknown): boolean {
return !(error instanceof QueryNotReadyError) && classifyError(error) === "unknown";
}
export interface MutationErrorToast {
title: string;
body: string;
}
export interface AppQueryClientDeps {
connectivity: Pick<ConnectivityStore, "reportError" | "getSnapshot">;
reportError?: (error: unknown) => void;
/** Show a toast for a failed opted-in mutation that has no handler of its own. */
notifyMutationError?: (toast: MutationErrorToast) => void;
}
/**
* Whether the global handler should toast for this failed mutation. A
* transient failure is skipped only while the connection banner covers it; a
* 429 or one route's worker being down leaves the app online, and the write is
* still lost.
*/
export function shouldToastMutationError(
error: unknown,
mutation: Pick<Mutation<unknown, DefaultError, unknown, unknown>, "options" | "meta">,
connectionDown: boolean,
): boolean {
if (mutation.options.onError) return false;
if (mutation.meta?.errorToast !== true) return false;
const kind = classifyError(error);
if (kind === "aborted") return false;
return kind !== "transient" || !connectionDown;
}
class AppQueryClient extends QueryClient {
override defaultMutationOptions<T extends MutationOptions<any, any, any, any>>(options?: T): T {
const defaulted = super.defaultMutationOptions(options);
if (options?._defaulted || defaulted.meta?.replay !== "idempotent") return defaulted;
return {
...defaulted,
networkMode: options?.networkMode ?? "online",
retry: options?.retry ?? shouldRetryRequest,
retryDelay: options?.retryDelay ?? retryDelayFor,
};
}
override refetchQueries<TTaggedQueryKey extends QueryKey = QueryKey>(
filters?: RefetchQueryFilters<TTaggedQueryKey>,
options?: RefetchOptions,
): Promise<void> {
const predicate = filters?.predicate;
return super.refetchQueries(
{
...filters,
predicate: (query) => query.options.queryFn !== undefined && (predicate?.(query) ?? true),
},
options,
);
}
}
/** A polling query that keeps failing reports once per window, not on every poll. */
const QUERY_ERROR_REPORT_WINDOW_MS = 10 * 60_000;
export function createAppQueryClient(deps: AppQueryClientDeps, config: QueryClientConfig = {}): QueryClient {
const lastReportedAt = new Map<string, number>();
const queryCache = new QueryCache({
onError: (error, query) => {
if (!deps.reportError || !shouldReportQueryError(error)) return;
const now = Date.now();
const last = lastReportedAt.get(query.queryHash);
if (last !== undefined && now - last < QUERY_ERROR_REPORT_WINDOW_MS) return;
lastReportedAt.set(query.queryHash, now);
deps.reportError(error);
},
});
const mutationCache = new MutationCache({
onError: (error, _variables, _context, mutation) => {
const connectionDown = deps.connectivity.getSnapshot().status !== "online";
if (!deps.notifyMutationError || !shouldToastMutationError(error, mutation, connectionDown)) return;
const { title, body } = describeError(error);
deps.notifyMutationError({ title, body });
},
});
// Retry failures are only visible as cache events, and connectivity needs
// the first failure, not the last one.
queryCache.subscribe((event) => {
if (event.type !== "updated") return;
if (event.action.type === "failed" || event.action.type === "error") deps.connectivity.reportError(event.action.error);
// Queries can read other services or return local data. Only the health
// probe and the server WebSocket can prove that Paperclip recovered.
});
mutationCache.subscribe((event) => {
if (event.type !== "updated") return;
if (event.action.type === "failed" || event.action.type === "error") deps.connectivity.reportError(event.action.error);
});
return new AppQueryClient({
...config,
queryCache,
mutationCache,
defaultOptions: {
...config.defaultOptions,
queries: {
retry: shouldRetryRequest,
retryDelay: retryDelayFor,
networkMode: "online",
...config.defaultOptions?.queries,
},
mutations: {
networkMode: "always",
retry: 0,
...config.defaultOptions?.mutations,
},
},
});
}
/** `setPendingWrites` source for replayable mutations paused by an outage. */
export const PAUSED_MUTATIONS_SOURCE = "paused-mutations";
/**
* Drive React Query's `onlineManager` from the connectivity store, refresh
* live queries once when the server comes back, and count paused replayable
* mutations as pending writes. Returns a cleanup.
*/
export function bindConnectivity(queryClient: QueryClient, store: ConnectivityStore): () => void {
const isOnline = () => store.getSnapshot().status === "online";
onlineManager.setEventListener((setOnline) => {
setOnline(isOnline());
return store.subscribe(() => setOnline(isOnline()));
});
const stopRecover = store.onRecover(() => {
void queryClient.invalidateQueries({ type: "active" }, { cancelRefetch: false });
});
const mutationCache = queryClient.getMutationCache();
const reportPausedMutations = () => {
const paused = mutationCache.findAll({ predicate: (mutation) => mutation.state.isPaused }).length;
store.setPendingWrites(PAUSED_MUTATIONS_SOURCE, paused);
};
reportPausedMutations();
const stopMutationCount = mutationCache.subscribe(reportPausedMutations);
const stopBrowserEvents = store.start();
return () => {
stopRecover();
stopMutationCount();
store.setPendingWrites(PAUSED_MUTATIONS_SOURCE, 0);
stopBrowserEvents();
};
}