mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 01:27:09 +01:00
## 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>
259 lines
10 KiB
TypeScript
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();
|
|
};
|
|
}
|