mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
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>
This commit is contained in:
1 parent
9e09d8f51f
commit
35ab90ad0f
21 files changed
+601
-51
No files matched your search
@@ -376,7 +376,11 @@ export interface PaperclipPluginManifestV1 {
|
||||
/** Optional agent detail tab mirrored beside a running task.
|
||||
* Declared board GET route scoped to agent :entityId returns { available: boolean }.
|
||||
*/
|
||||
taskSidePanel?: { availabilityPath: string };
|
||||
taskSidePanel?: {
|
||||
availabilityPath: string;
|
||||
/** Match runtime.activity.recorded; absent mappings add no task panel. */
|
||||
activation?: { namespace: string; activityKey: string };
|
||||
};
|
||||
/** For page and companySettingsPage: single route segment */
|
||||
routePath?: string;
|
||||
}>;
|
||||
|
||||
@@ -668,3 +668,10 @@ captured before the operation. Warm run attachment and process recovery rotate
|
||||
that authority. Runner supplies the run identity from its active authenticated
|
||||
binding. Optional hint failure does not fail the underlying operation. No output
|
||||
text is scanned and no synthetic receipt is written.
|
||||
|
||||
`GET /api/heartbeat-runs/:runId/events?view=plugin-panels` reads these events and
|
||||
returns only installed agent task-panel contributions under normal run-read and
|
||||
telemetry-read authorization. Ordinary PRP reads/live delivery retain run-secret
|
||||
and current-user redaction. The task page uses one history read on mount/reconnect
|
||||
and the existing live transcript stream, then makes a bounded plugin readiness
|
||||
check. No panel discovery timer scans runs or provider state while connected.
|
||||
@@ -60,7 +60,13 @@ their execution, and uncertain prior executions must be reconciled before waking
|
||||
|
||||
An installed plugin can offer an agent detail tab in the task side panel through
|
||||
its optional `taskSidePanel` declaration. The host supplies the task's running
|
||||
agent. The plugin's authorized availability route decides whether the panel is
|
||||
agent and run. The runtime captures Runner context before use and records
|
||||
`runtime.activity.recorded` after success. Runner rejects stale contexts and knows
|
||||
no plugin or view identity. Core maps the recorded namespace/activity key to the
|
||||
panel's declared activation pair and restores it from the run log on reload or
|
||||
reconnect. Readiness gets three bounded attempts after matching activity, with
|
||||
no periodic availability poll. Declarations without a mapping add no task panel.
|
||||
The plugin's authorized availability route decides whether the panel is
|
||||
shown, and the plugin owns its label and viewer. No contribution appears for a
|
||||
missing plugin, inactive run, unavailable view or access failure. A closed panel
|
||||
can be reopened from the launcher while available.
|
||||
|
||||
@@ -259,17 +259,21 @@ A card or section rendered on the main dashboard. Use this for at-a-glance metri
|
||||
|
||||
An additional tab on a project, issue, agent, goal, or run detail page. Rendered when the user navigates to that entity's detail view. Receives `PluginDetailTabProps` with `context.companyId` set to the active company and `context.entityId` / `context.entityType` guaranteed to be non-null, so you can immediately scope data fetches to the relevant entity. Specify which entity types the tab applies to via the `entityTypes` array in the manifest slot declaration. Requires the `ui.detailTab.register` capability.
|
||||
|
||||
Agent detail tabs can also appear in task side panels. Add
|
||||
`taskSidePanel: { availabilityPath: "/agents/:entityId/view/availability" }` to the
|
||||
slot and declare that exact path as a board-only GET API route with
|
||||
`companyResolution: { from: "agent", param: "entityId" }`. The route returns
|
||||
`{ available: true }` only while its view is in use. The host replaces `:entityId`
|
||||
with the task's running agent ID, polls availability and mounts the same component
|
||||
with agent context. The plugin owns availability, labels, content and controls.
|
||||
No private plugin identifier or provider protocol belongs in Core. Unavailable
|
||||
views, access failures, inactive runs and missing plugins contribute no task tab.
|
||||
Closing a tab preserves dismissal during polling; available panels remain in the
|
||||
launcher. Only tab identity is persisted, never availability responses or secrets.
|
||||
Agent detail tabs can also appear in task side panels. Declare a mapping to a
|
||||
runtime activity independently of the plugin and slot identifiers:
|
||||
|
||||
```ts
|
||||
taskSidePanel: {
|
||||
availabilityPath: "/agents/:entityId/view/availability",
|
||||
activation: { namespace: "example.runtime", activityKey: "desktop.used" },
|
||||
}
|
||||
```
|
||||
|
||||
Declare the exact availability path as a board-only GET API route with
|
||||
`companyResolution: { from: "agent", param: "entityId" }`. Core maps the declared
|
||||
namespace/activity pair to installed agent detail tabs. Runner knows no plugin
|
||||
or UI slot. Declarations without `activation` remain valid for agent detail pages
|
||||
and contribute no task panel. Activity is a reported fact, not an authorization grant.
|
||||
|
||||
#### Runtime activity recording
|
||||
|
||||
@@ -309,6 +313,18 @@ Identifiers are 1–160 Unicode code points, excluding C0 controls and DEL.
|
||||
Core maps declared activity identifiers to its consumers and applies their
|
||||
existing discovery, authorization and availability checks.
|
||||
|
||||
Core reads durable activity on mount/reconnect and uses the existing live stream.
|
||||
The authorized run binding identifies company, task and agent; caller payloads
|
||||
cannot override them. Core replaces `:entityId` with the running agent ID, adds
|
||||
`runId` and `issueId`, and requires `{ available: true }`. History/live hints merge
|
||||
without losing earlier activity. Only matching current-task activity contributes.
|
||||
Readiness and transient discovery failures get at most three attempts; authorization
|
||||
failures are not retried. No periodic readiness polling is added. Dismissal stays
|
||||
scoped to the current run. Only tab identity is stored in browser state; activity
|
||||
remains in the authorized run log. Missing/disabled plugins, failed history reads,
|
||||
unavailable views and access failures contribute no task tab. The plugin owns
|
||||
labels, content and controls. No private integration identifier belongs in Core.
|
||||
|
||||
#### `taskDetailView`
|
||||
|
||||
A specialized slot rendered in the context of a task or issue detail view. Similar to `detailTab` but designed for inline content within the task detail layout rather than a separate tab. Receives `context.companyId`, `context.entityId`, and `context.entityType` like `detailTab`. Requires the `ui.detailTab.register` capability.
|
||||
|
||||
@@ -500,10 +500,16 @@ export interface PluginUiSlotDeclaration {
|
||||
*/
|
||||
entityTypes?: PluginUiSlotEntityType[];
|
||||
/** Also offer this agent detail tab beside a task while its agent is running.
|
||||
* GET this plugin-relative path with :entityId replaced by the agent ID.
|
||||
* The run must record the declared runtime activity before a task panel appears.
|
||||
* GET this plugin-relative path with :entityId replaced by the agent ID,
|
||||
* and runId/issueId query parameters for the active task.
|
||||
* The board/agent-scoped route returns { available: boolean }.
|
||||
*/
|
||||
taskSidePanel?: { availabilityPath: string };
|
||||
taskSidePanel?: {
|
||||
availabilityPath: string;
|
||||
/** Opaque runtime activity mapped to this view. Absent mappings add no task panel. */
|
||||
activation?: { namespace: string; activityKey: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional company-scoped route segment for page, routeSidebar, and
|
||||
|
||||
@@ -505,6 +505,17 @@ describe("plugin task side-panel declarations", () => {
|
||||
it("accepts an optional panel backed by an authorized plugin-local agent route", () => {
|
||||
expect(pluginManifestV1Schema.parse(manifest).ui?.slots?.[0]?.taskSidePanel).toEqual(slot.taskSidePanel);
|
||||
});
|
||||
it("accepts an explicit UI-independent activity mapping and preserves legacy declarations", () => {
|
||||
const activation = { namespace: "example.runtime", activityKey: "desktop.used" };
|
||||
expect(pluginUiSlotDeclarationSchema.parse({ ...slot, taskSidePanel: { ...slot.taskSidePanel, activation } }).taskSidePanel?.activation).toEqual(activation);
|
||||
expect(pluginUiSlotDeclarationSchema.parse(slot).taskSidePanel?.activation).toBeUndefined();
|
||||
for (const value of ["视".repeat(60), "😀".repeat(160)]) {
|
||||
expect(pluginUiSlotDeclarationSchema.safeParse({ ...slot, taskSidePanel: { ...slot.taskSidePanel, activation: { namespace: value, activityKey: value } } }).success).toBe(true);
|
||||
}
|
||||
for (const activation of [{ namespace: "", activityKey: "used" }, { namespace: "runtime", activityKey: "😀".repeat(161) }, { namespace: "runtime", activityKey: "\u007f" }, { namespace: "runtime", activityKey: "used", runId: "other" }]) {
|
||||
expect(pluginUiSlotDeclarationSchema.safeParse({ ...slot, taskSidePanel: { ...slot.taskSidePanel, activation } }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
it("accepts a GET availability route when a POST at the same path is declared first", () => {
|
||||
expect(pluginManifestV1Schema.safeParse({ ...manifest, apiRoutes: [{ ...route, routeKey: "view.update", method: "POST" }, route] }).success).toBe(true);
|
||||
});
|
||||
|
||||
@@ -384,6 +384,10 @@ export type PluginManagedSkillDeclarationInput = z.infer<typeof pluginManagedSki
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
const runtimeActivityIdentifierSchema = z.string().min(1)
|
||||
.refine(value => Array.from(value).length <= 160, "Activity identifiers must contain at most 160 code points")
|
||||
.regex(/^[^\u0000-\u001f\u007f]+$/);
|
||||
|
||||
export const pluginUiSlotDeclarationSchema = z.object({
|
||||
type: z.enum(PLUGIN_UI_SLOT_TYPES),
|
||||
id: z.string().min(1),
|
||||
@@ -392,6 +396,7 @@ export const pluginUiSlotDeclarationSchema = z.object({
|
||||
entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(),
|
||||
taskSidePanel: z.object({
|
||||
availabilityPath: z.string().regex(/^\/(?:[a-zA-Z0-9_-]+\/)*:entityId(?:\/[a-zA-Z0-9_-]+)*$/),
|
||||
activation: z.object({ namespace: runtimeActivityIdentifierSchema, activityKey: runtimeActivityIdentifierSchema }).strict().optional(),
|
||||
}).strict().optional(),
|
||||
routePath: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, {
|
||||
message: "routePath must be a lowercase single-segment slug (letters, numbers, hyphens)",
|
||||
|
||||
@@ -674,6 +674,7 @@ describe("agent live run routes", () => {
|
||||
"/api/companies/company-1/live-runs",
|
||||
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/events",
|
||||
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/events?view=plugin-panels",
|
||||
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/log",
|
||||
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/workspace-operations",
|
||||
"/api/workspace-operations/operation-1/log",
|
||||
@@ -694,6 +695,23 @@ describe("agent live run routes", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("reads persisted plugin activation only after normal run-read authorization", async () => {
|
||||
const limit = vi.fn(async () => [{ payload: { namespace: "example.runtime", activityKey: "desktop.used" }, pluginKey: "example.live-view",
|
||||
manifest: { ui: { slots: [{ id: "screen", type: "detailTab", entityTypes: ["agent"], taskSidePanel: { availabilityPath: "/agents/:entityId/ready", activation: { namespace: "example.runtime", activityKey: "desktop.used" } } }] } } }]);
|
||||
const query = { from: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), limit };
|
||||
const db = { selectDistinct: vi.fn(() => query) };
|
||||
const response = await requestApp(await createApp(db), url => request(url).get(
|
||||
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/events?view=plugin-panels"));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([{ pluginKey: "example.live-view", slotId: "screen" }]);
|
||||
expect(limit).toHaveBeenCalledWith(128);
|
||||
expect(mockRunSecretRedactionRegistry.redactForRun).toHaveBeenCalledWith("company-1", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", [{ pluginKey: "example.live-view", slotId: "screen" }]);
|
||||
mockHeartbeatService.getRunReadAccess.mockResolvedValue(null);
|
||||
const denied = await requestApp(await createApp(db), url => request(url).get(
|
||||
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/events?view=plugin-panels"));
|
||||
expect(denied.status).toBe(404); expect(limit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lists workspace operations using narrow current run access", async () => {
|
||||
mockHeartbeatService.getRunWorkspaceAccess.mockResolvedValue({
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", scopeKind: "company", issueId: null,
|
||||
|
||||
@@ -288,6 +288,7 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
|
||||
for (const path of [
|
||||
`/api/heartbeat-runs/${fixture.privateRunId}`,
|
||||
`/api/heartbeat-runs/${fixture.privateRunId}/events`,
|
||||
`/api/heartbeat-runs/${fixture.privateRunId}/events?view=plugin-panels`,
|
||||
`/api/heartbeat-runs/${fixture.privateRunId}/log`,
|
||||
`/api/heartbeat-runs/${fixture.privateRunId}/workspace-operations`,
|
||||
`/api/workspace-operations/${fixture.operationId}/log`,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { readTaskPanelActivations } from "../services/plugin-task-panels.js";
|
||||
import { plugins, heartbeatRunEvents } from "@paperclipai/db";
|
||||
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
@@ -43,6 +46,28 @@ describe("live-event run read boundaries", () => {
|
||||
await temporary?.cleanup();
|
||||
});
|
||||
|
||||
it.each(["ready", "disabled", "error", "upgrade_pending"] as const)("restores a structured activation for an installed %s plugin", async status => {
|
||||
const companyId = randomUUID(), pluginKey = `example.recovered-${status}`;
|
||||
await db.insert(companies).values({ id: companyId, name: "Panel recovery", issuePrefix: `PNL${status.toUpperCase()}` });
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: "Panel fixture", adapterType: "process" }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id, status: "running" }).returning();
|
||||
await db.insert(plugins).values({ pluginKey, packageName: pluginKey, version: "1.0.0", status,
|
||||
manifestJson: { ui: { slots: [{ type: "detailTab", id: "screen", entityTypes: ["agent"], taskSidePanel: { availabilityPath: "/agents/:entityId/ready", activation: { namespace: `example.runtime-${status}`, activityKey: "desktop.used" } } }] } } as never });
|
||||
const write = (seq: number, panel: unknown) => db.insert(heartbeatRunEvents).values({ companyId, agentId: agent.id, runId: run.id,
|
||||
seq, eventType: "runtime.activity.recorded", payload: { prpEvent: { sourceKind: "runner", eventType: "runtime.activity.recorded", payload: panel } } });
|
||||
await write(1, { namespace: `example.runtime-${status}`, activityKey: "desktop.used" });
|
||||
await write(2, { namespace: `example.runtime-${status}`, activityKey: "unknown.activity" });
|
||||
await write(3, { namespace: "unknown.runtime", activityKey: "desktop.used" });
|
||||
await write(4, { namespace: `example.runtime-${status}`, activityKey: "desktop.used" }); // Replay may repeat activity with a new source ID.
|
||||
await write(5, { namespace: `example.runtime-${status}`, activityKey: "desktop.used", runId: "caller-run" });
|
||||
await db.insert(heartbeatRunEvents).values({ companyId, agentId: agent.id, runId: run.id, seq: 6,
|
||||
eventType: "runtime.activity.recorded", payload: { prpEvent: { sourceKind: "control_plane", eventType: "runtime.activity.recorded", payload: { namespace: `example.runtime-${status}`, activityKey: "desktop.used" } } } });
|
||||
expect(await readTaskPanelActivations(db, companyId, run.id)).toEqual([{ pluginKey, slotId: "screen" }]);
|
||||
expect(await readTaskPanelActivations(db, randomUUID(), run.id)).toEqual([]);
|
||||
await db.update(plugins).set({ status: "uninstalled" }).where(eq(plugins.pluginKey, pluginKey));
|
||||
expect(await readTaskPanelActivations(db, companyId, run.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads only run bindings and reauthorizes every delivery after revocation or deletion", async () => {
|
||||
const companyId = randomUUID(), foreignCompanyId = randomUUID();
|
||||
const owner = randomUUID(), viewer = randomUUID();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readTaskPanelActivations } from "../services/plugin-task-panels.js";
|
||||
import { latestFailedRunsByAgent } from "@paperclipai/shared";
|
||||
import { agentConfigurationSpec } from "@paperclipai/shared";
|
||||
import { agentConfigurationRevision, restoreAgentConfigurationRedactions } from "../lib/agent-configuration-revision.js";
|
||||
@@ -7600,6 +7601,13 @@ export function agentRoutes(
|
||||
if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return;
|
||||
if (!(await assertRunReadAllowed(req, res, run))) return;
|
||||
|
||||
if (req.query.view === "plugin-panels") {
|
||||
const options = await getCurrentUserRedactionOptions();
|
||||
const panels = redactCurrentUserValue(await readTaskPanelActivations(db, run.companyId, run.id), options);
|
||||
res.json(await runRedactions.redactForRun(run.companyId, run.id, panels.map(panel => redactEventPayload({ ...panel }))));
|
||||
return;
|
||||
}
|
||||
|
||||
const afterSeq = Number(req.query.afterSeq ?? 0);
|
||||
const limit = Number(req.query.limit ?? 200);
|
||||
const cursor = Number.isFinite(afterSeq) ? afterSeq : 0;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { nativeResponsePresentationWork } from "./native-response-presentation-work.js";
|
||||
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
|
||||
import { plugins } from "@paperclipai/db";
|
||||
import { readTaskPanelActivations } from "../plugin-task-panels.js";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { asc, eq, sql } from "drizzle-orm";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
@@ -405,6 +407,8 @@ describe("PaperclipControlPlanePort conformance", () => {
|
||||
});
|
||||
|
||||
it("persists panel activation under the authenticated run and recovers it across source replay", async () => {
|
||||
await db.insert(plugins).values({ pluginKey: "example.live-view", packageName: "example-live-view", version: "1.0.0", status: "ready",
|
||||
manifestJson: { ui: { slots: [{ type: "detailTab", id: "screen", entityTypes: ["agent"], taskSidePanel: { availabilityPath: "/agents/:entityId/ready", activation: { namespace: "example.runtime", activityKey: "desktop.used" } } }] } } as never });
|
||||
const identity = { ...CONTROL_PLANE_CONFORMANCE_OPEN.identity, runId: randomUUID(), sessionId: randomUUID() };
|
||||
const runnerId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
@@ -433,10 +437,13 @@ describe("PaperclipControlPlanePort conformance", () => {
|
||||
});
|
||||
try { await port.appendEvent(event); } finally { unsubscribe(); }
|
||||
expect(notifications).toEqual(["runtime.activity.recorded"]);
|
||||
expect(await readTaskPanelActivations(db, identity.companyId, identity.runId)).toEqual([{ pluginKey: "example.live-view", slotId: "screen" }]);
|
||||
const restarted = new PaperclipControlPlanePort(db, replayBinding);
|
||||
expect((await restarted.appendEvent(event)).disposition).toBe("duplicate");
|
||||
const activations = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, identity.runId));
|
||||
expect(activations.filter(row => row.eventType === "runtime.activity.recorded")).toHaveLength(1);
|
||||
expect(await readTaskPanelActivations(db, identity.companyId, CONTROL_PLANE_CONFORMANCE_OPEN.identity.runId)).toEqual([]);
|
||||
expect(await readTaskPanelActivations(db, randomUUID(), identity.runId)).toEqual([]);
|
||||
});
|
||||
|
||||
it("omits API credentials from persisted tool events and checkpoints without changing wire results", async () => {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runtimeActivity } from "./plugin-task-panels.js";
|
||||
|
||||
describe("structured runtime activity", () => {
|
||||
it("counts Unicode code points and matches the schema's control-character rules", () => {
|
||||
for (const activityKey of ["视".repeat(60), "😀".repeat(160), "view-0", "\u0085"]) {
|
||||
const panel = { namespace: "example.runtime", activityKey };
|
||||
expect(runtimeActivity(panel)).toEqual(panel);
|
||||
}
|
||||
for (const activityKey of ["😀".repeat(161), "\u001f", "\u007f"]) {
|
||||
expect(runtimeActivity({ namespace: "example.runtime", activityKey })).toBeNull();
|
||||
}
|
||||
});
|
||||
it("accepts only bounded panel identifiers without caller run identity", () => {
|
||||
const panel = { namespace: "example.runtime", activityKey: "screen" };
|
||||
expect(runtimeActivity(panel)).toEqual(panel);
|
||||
for (const payload of [null, { output: "[paperclip-task-panel:example.runtime:screen]" },
|
||||
{ ...panel, runId: "caller-run" }, { ...panel, activityKey: "" }, { ...panel, activityKey: "x".repeat(161) },
|
||||
{ ...panel, namespace: "invalid\nkey" }]) expect(runtimeActivity(payload)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { and, eq, ne, sql } from "drizzle-orm";
|
||||
import { heartbeatRunEvents, plugins, type Db } from "@paperclipai/db";
|
||||
|
||||
export const RUNTIME_ACTIVITY_EVENT = "runtime.activity.recorded";
|
||||
export interface TaskPanelActivation { pluginKey: string; slotId: string }
|
||||
|
||||
export function runtimeActivity(payload: Record<string, unknown> | null): { namespace: string; activityKey: string } | null {
|
||||
const valid = (value: unknown): value is string => typeof value === "string" && value.length > 0
|
||||
&& Array.from(value).length <= 160 && !/[\u0000-\u001f\u007f]/.test(value);
|
||||
return payload && Object.keys(payload).length === 2 && valid(payload.namespace) && valid(payload.activityKey)
|
||||
? { namespace: payload.namespace, activityKey: payload.activityKey } : null;
|
||||
}
|
||||
|
||||
/** Core maps authenticated runtime facts to installed UI declarations. */
|
||||
export async function readTaskPanelActivations(db: Db, companyId: string, runId: string): Promise<TaskPanelActivation[]> {
|
||||
const rows = await db.selectDistinct({
|
||||
payload: sql<Record<string, unknown>>`${heartbeatRunEvents.payload}->'prpEvent'->'payload'`, manifest: plugins.manifestJson, pluginKey: plugins.pluginKey,
|
||||
}).from(heartbeatRunEvents)
|
||||
.innerJoin(plugins, sql`EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(${plugins.manifestJson}->'ui'->'slots') AS declared_slot
|
||||
WHERE declared_slot->'taskSidePanel'->'activation' = ${heartbeatRunEvents.payload}->'prpEvent'->'payload'
|
||||
)`)
|
||||
.where(and(eq(heartbeatRunEvents.companyId, companyId), eq(heartbeatRunEvents.runId, runId),
|
||||
eq(heartbeatRunEvents.eventType, RUNTIME_ACTIVITY_EVENT), ne(plugins.status, "uninstalled"),
|
||||
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'sourceKind' = 'runner'`,
|
||||
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'eventType' = ${RUNTIME_ACTIVITY_EVENT}`)).limit(128);
|
||||
return rows.flatMap(({ payload, manifest, pluginKey }) => {
|
||||
const activity = runtimeActivity(payload);
|
||||
return activity ? (manifest.ui?.slots ?? []).filter(slot => slot.type === "detailTab"
|
||||
&& slot.entityTypes?.includes("agent") && slot.taskSidePanel?.activation?.namespace === activity.namespace
|
||||
&& slot.taskSidePanel?.activation?.activityKey === activity.activityKey)
|
||||
.map(slot => ({ pluginKey, slotId: slot.id })) : [];
|
||||
}).slice(0, 128);
|
||||
}
|
||||
@@ -145,6 +145,8 @@ export const heartbeatsApi = {
|
||||
`/heartbeat-runs/${runId}/events?view=conversation&afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
|
||||
options,
|
||||
),
|
||||
taskPanelActivations: (runId: string, options?: RequestOptions) =>
|
||||
api.get<Array<{ pluginKey: string; slotId: string }>>(`/heartbeat-runs/${encodeURIComponent(runId)}/events?view=plugin-panels`, options),
|
||||
events: (runId: string, afterSeq = 0, limit = 200, options?: RequestOptions) =>
|
||||
api.get<HeartbeatRunEvent[]>(
|
||||
`/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
|
||||
|
||||
@@ -278,10 +278,9 @@ describe("LiveUpdatesProvider connection recovery", () => {
|
||||
expect(pollRead).toHaveBeenCalledTimes(afterOpen);
|
||||
});
|
||||
|
||||
it("discovers matching new runs after empty polling stops on an unfocused non-task route", async () => {
|
||||
it.each(["DEMO-1", "issue-1"])("discovers and finishes runs with cache key %s on an unfocused non-task route", async queryRef => {
|
||||
vi.stubGlobal("WebSocket", Socket);
|
||||
vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||
const queryRef = "DEMO-1";
|
||||
const runRead = vi.fn<() => Promise<unknown>>().mockResolvedValue([]);
|
||||
const activeRead = vi.fn<() => Promise<unknown>>().mockResolvedValue(null);
|
||||
function TaskRuns() {
|
||||
@@ -300,9 +299,9 @@ describe("LiveUpdatesProvider connection recovery", () => {
|
||||
await advancePolls(5);
|
||||
expect(runRead).not.toHaveBeenCalled();
|
||||
expect(activeRead).not.toHaveBeenCalled();
|
||||
const deliver = async (companyId: string, payload: Record<string, unknown>) => {
|
||||
const deliver = async (companyId: string, payload: Record<string, unknown>, type = "heartbeat.run.queued") => {
|
||||
await act(async () => socket.onmessage?.({ data: JSON.stringify({ id: 1, companyId,
|
||||
type: "heartbeat.run.queued", createdAt: "2020-01-01T00:00:00Z", payload }) }));
|
||||
type, createdAt: "2020-01-01T00:00:00Z", payload }) }));
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1));
|
||||
};
|
||||
await deliver("company-2", { issueId: "issue-1", agentId: "agent-1", runId: "foreign" });
|
||||
@@ -318,6 +317,11 @@ describe("LiveUpdatesProvider connection recovery", () => {
|
||||
await deliver("company-1", { agentId: "reviewer-agent", runId: "reviewer-run-without-task-id" });
|
||||
expect(runRead).toHaveBeenCalledTimes(2);
|
||||
expect(activeRead).toHaveBeenCalledTimes(2);
|
||||
runRead.mockResolvedValue([]);
|
||||
activeRead.mockResolvedValue(null);
|
||||
await deliver("company-1", { issueId: "issue-1", agentId: "agent-1", runId: "new-run", status: "succeeded" }, "heartbeat.run.status");
|
||||
expect(client.getQueryData(queryKeys.issues.liveRuns(queryRef))).toEqual([]);
|
||||
expect(container.textContent).not.toContain("new-run");
|
||||
});
|
||||
|
||||
it.each(["initial", "background", "connection setup"])("refetches after an older empty %s read settles when run events arrive during it", async (phase) => {
|
||||
|
||||
@@ -2,20 +2,22 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import type { Issue, LiveEvent } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, expect, it, vi, type MockInstance } from "vitest";
|
||||
import { api } from "@/api/client";
|
||||
import { api, ApiError } from "@/api/client";
|
||||
import { heartbeatsApi, type LiveRunForIssue } from "@/api/heartbeats";
|
||||
import { useTaskRunLiveUpdates } from "@/context/LiveUpdatesProvider";
|
||||
import { createAppQueryClient } from "@/lib/query-client";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import type { ResolvedPluginSlot } from "@/plugins/slots";
|
||||
import { useTaskPluginPanels, type TaskPluginPanel } from "./useTaskPluginPanels";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const plugin = vi.hoisted(() => ({ connected: true, slots: [] as ResolvedPluginSlot[], errorMessage: null as string | null }));
|
||||
const plugin = vi.hoisted(() => ({ connected: true, slots: [] as ResolvedPluginSlot[], healthy: true, epoch: 0, handler: null as ((event: LiveEvent) => void) | null, errorMessage: null as string | null }));
|
||||
vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => plugin }));
|
||||
vi.mock("@/context/LiveUpdatesProvider", () => ({ useHasCompanyLiveConnection: () => plugin.connected }));
|
||||
const slot = { pluginId: "example-plugin", pluginKey: "example.live-view", id: "screen", pluginVersion: "1.0.0", taskSidePanel: { availabilityPath: "/agents/:entityId/view/availability" } } as ResolvedPluginSlot;
|
||||
const issue = { id: "task", companyId: "company", executionRunId: "run" } as Issue;
|
||||
vi.mock("@/context/LiveUpdatesProvider", () => ({ useTaskRunLiveUpdates: vi.fn(() => plugin.connected), useTranscriptLiveStream: (_ids: string[], _options: unknown, handler: (event: LiveEvent) => void) => { plugin.handler = handler; return { healthy: plugin.healthy, epoch: plugin.epoch }; } }));
|
||||
const slot = { pluginId: "example-plugin", pluginKey: "example.live-view", id: "screen", pluginVersion: "1.0.0", taskSidePanel: { availabilityPath: "/agents/:entityId/view/availability", activation: { namespace: "example.runtime", activityKey: "desktop.used" } } } as ResolvedPluginSlot;
|
||||
const issue = { id: "task", companyId: "company", executionRunId: "run", assigneeAgentId: "agent" } as Issue;
|
||||
const run = { id: "run", agentId: "agent", status: "running" } as LiveRunForIssue;
|
||||
let root: Root, container: HTMLDivElement, client: QueryClient;
|
||||
let current: TaskPluginPanel[];
|
||||
@@ -23,6 +25,7 @@ let task: Issue | null;
|
||||
let enabled: boolean;
|
||||
let get: MockInstance<typeof api.get>;
|
||||
let list: MockInstance<typeof heartbeatsApi.liveRunsForIssue>;
|
||||
let history: MockInstance<typeof heartbeatsApi.taskPanelActivations>;
|
||||
function Harness() { current = useTaskPluginPanels(task, enabled); return null; }
|
||||
async function render(next: Issue | null = issue, active = true) {
|
||||
task = next; enabled = active;
|
||||
@@ -34,11 +37,12 @@ async function refresh() {
|
||||
await act(async () => { if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(30); else await new Promise(resolve => setTimeout(resolve, 30)); });
|
||||
}
|
||||
beforeEach(() => {
|
||||
plugin.slots = [slot]; plugin.errorMessage = null; plugin.connected = true;
|
||||
plugin.slots = [slot]; plugin.errorMessage = null; plugin.connected = true; plugin.healthy = false; plugin.epoch = 0; plugin.handler = null;
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
|
||||
get = vi.spyOn(api, "get").mockResolvedValue({ available: true });
|
||||
list = vi.spyOn(heartbeatsApi, "liveRunsForIssue").mockResolvedValue([run]);
|
||||
history = vi.spyOn(heartbeatsApi, "taskPanelActivations").mockImplementation(async () => plugin.slots.map(slot => ({ pluginKey: slot.pluginKey, slotId: slot.id })));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount()); client.clear(); container.remove(); vi.restoreAllMocks(); vi.useRealTimers();
|
||||
@@ -46,7 +50,7 @@ afterEach(async () => {
|
||||
it("uses the running agent's authorized status and existing plugin slot", async () => {
|
||||
await render();
|
||||
expect(list).toHaveBeenCalledWith("task");
|
||||
expect(get).toHaveBeenCalledWith("/plugins/example-plugin/api/agents/agent/view/availability", { signal: expect.any(AbortSignal), cache: "no-store" });
|
||||
expect(get).toHaveBeenCalledWith("/plugins/example-plugin/api/agents/agent/view/availability?runId=run&issueId=task", { signal: expect.any(AbortSignal), cache: "no-store" });
|
||||
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
|
||||
});
|
||||
it("keeps unavailable panels hidden", async () => {
|
||||
@@ -88,7 +92,7 @@ it("stops idle polling with live updates and resumes fallback polling on disconn
|
||||
expect(list).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
it("uses each plugin's own availability path and excludes only the failing contribution", async () => {
|
||||
const second = { ...slot, pluginId: "other-plugin", pluginKey: "example.other-view", taskSidePanel: { availabilityPath: "/views/:entityId/ready" } };
|
||||
const second = { ...slot, pluginId: "other-plugin", pluginKey: "example.other-view", taskSidePanel: { availabilityPath: "/views/:entityId/ready", activation: { namespace: "example.other-runtime", activityKey: "desktop.used" } } };
|
||||
plugin.slots = [slot, second];
|
||||
get.mockImplementation(async path => {
|
||||
if (path.includes("other-plugin")) throw new Error("Unavailable");
|
||||
@@ -96,5 +100,296 @@ it("uses each plugin's own availability path and excludes only the failing contr
|
||||
});
|
||||
await render();
|
||||
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
|
||||
expect(get).toHaveBeenCalledWith("/plugins/other-plugin/api/views/agent/ready", expect.anything());
|
||||
expect(get).toHaveBeenCalledWith("/plugins/other-plugin/api/views/agent/ready?runId=run&issueId=task", expect.anything());
|
||||
});
|
||||
|
||||
|
||||
function event(overrides: Record<string, unknown> = {}): LiveEvent {
|
||||
return { id: 1, companyId: "company", type: "heartbeat.run.event", createdAt: "2099-01-01T00:00:00.000Z",
|
||||
payload: { runId: "run", agentId: "agent", issueId: "task", eventType: "runtime.activity.recorded",
|
||||
payload: { prpEvent: { eventType: "runtime.activity.recorded", sourceKind: "runner", payload: { ...slot.taskSidePanel!.activation } } }, ...overrides } } as LiveEvent;
|
||||
}
|
||||
async function emit(value: LiveEvent) {
|
||||
await act(async () => { plugin.handler?.(value); });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30); });
|
||||
}
|
||||
it("does no availability polling or Launcher discovery until this run activates the panel", async () => {
|
||||
vi.useFakeTimers(); history.mockResolvedValue([]);
|
||||
await render(); expect(get).not.toHaveBeenCalled(); expect(history).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(get).not.toHaveBeenCalled(); expect(history).toHaveBeenCalledTimes(1);
|
||||
for (const overrides of [{ runId: "other" }, { agentId: "other" }, { issueId: "other" }, { eventType: "item.completed" }]) await emit(event(overrides));
|
||||
await emit({ ...event(), companyId: "other" });
|
||||
await emit(event({ payload: { pluginKey: "example.other", slotId: "screen" } }));
|
||||
for (const native of [
|
||||
{ eventType: "runtime.activity.recorded", sourceKind: "control_plane", payload: { ...slot.taskSidePanel!.activation } },
|
||||
{ eventType: "runtime.activity.recorded", sourceKind: "runner", payload: { namespace: "example.other-runtime", activityKey: "desktop.used" } },
|
||||
{ eventType: "runtime.activity.recorded", sourceKind: "runner", payload: { namespace: "example.runtime", activityKey: "other.used" } },
|
||||
]) await emit(event({ payload: { prpEvent: native } }));
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
await emit(event());
|
||||
expect(get).toHaveBeenCalledTimes(1); expect(current[0]?.runId).toBe("run");
|
||||
await emit(event());
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("restores an activation from durable run history after reload", async () => {
|
||||
await render(); expect(current).toHaveLength(1);
|
||||
expect(history).toHaveBeenCalledWith("run", { signal: expect.any(AbortSignal), cache: "no-store" });
|
||||
});
|
||||
it("reconciles activation history and availability after reconnect", async () => {
|
||||
vi.useFakeTimers(); history.mockResolvedValue([]); await render(); expect(current).toEqual([]);
|
||||
plugin.healthy = false; await render();
|
||||
history.mockResolvedValue([{ pluginKey: slot.pluginKey, slotId: slot.id }]);
|
||||
plugin.healthy = true; plugin.epoch++;
|
||||
await render(); expect(current).toHaveLength(1);
|
||||
});
|
||||
it("does not lose an activation to an older in-flight history read", async () => {
|
||||
vi.useFakeTimers(); let resolve!: (value: []) => void;
|
||||
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
|
||||
await render(); expect(get).not.toHaveBeenCalled();
|
||||
await emit(event()); expect(current).toHaveLength(1);
|
||||
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
|
||||
expect(current).toHaveLength(1);
|
||||
});
|
||||
it("merges earlier panel history with a different panel activated during the read", async () => {
|
||||
vi.useFakeTimers();
|
||||
const second = { ...slot, id: "second-screen", taskSidePanel: { ...slot.taskSidePanel!, activation: { namespace: "example.runtime", activityKey: "other.used" } } };
|
||||
plugin.slots = [slot, second];
|
||||
let resolve!: (value: Array<{ pluginKey: string; slotId: string }>) => void;
|
||||
history.mockReturnValueOnce(new Promise(done => { resolve = done; }));
|
||||
await render();
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
await emit(event({ payload: { prpEvent: {
|
||||
eventType: "runtime.activity.recorded", sourceKind: "runner",
|
||||
payload: { ...second.taskSidePanel.activation },
|
||||
} } }));
|
||||
expect(current.map(panel => panel.slot.id)).toEqual([second.id]);
|
||||
await act(async () => {
|
||||
resolve([{ pluginKey: slot.pluginKey, slotId: slot.id }]);
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
});
|
||||
expect(current.map(panel => panel.slot.id)).toEqual([slot.id, second.id]);
|
||||
expect(history).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("fails closed when activation history cannot be read", async () => {
|
||||
history.mockRejectedValue(new Error("Access denied")); await render();
|
||||
expect(get).not.toHaveBeenCalled(); expect(current).toEqual([]);
|
||||
});
|
||||
|
||||
it("bounds readiness retries to three attempts after an activation", async () => {
|
||||
vi.useFakeTimers(); get.mockResolvedValue({ available: false });
|
||||
await render(); expect(get).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(get).toHaveBeenCalledTimes(3); expect(current).toEqual([]);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(get).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it.each(["not-ready", "server-error"])("restarts bounded readiness on fresh activity after %s", async failure => {
|
||||
vi.useFakeTimers();
|
||||
if (failure === "not-ready") get.mockResolvedValue({ available: false });
|
||||
else get.mockRejectedValue(new ApiError("Unavailable", 500, {}));
|
||||
await render();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(get).toHaveBeenCalledTimes(3);
|
||||
await emit(event());
|
||||
await emit(event());
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(get).toHaveBeenCalledTimes(6);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(get).toHaveBeenCalledTimes(6);
|
||||
get.mockResolvedValue({ available: true });
|
||||
await emit(event());
|
||||
expect(get).toHaveBeenCalledTimes(7); expect(current).toHaveLength(1);
|
||||
});
|
||||
it("does not retry denied readiness on fresh activity", async () => {
|
||||
vi.useFakeTimers(); get.mockRejectedValue(new ApiError("Denied", 403, {}));
|
||||
await render();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
get.mockResolvedValue({ available: true });
|
||||
await emit(event());
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(get).toHaveBeenCalledTimes(1); expect(current).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["runs", "history"])("recovers from a transient %s read without recurring polling", async source => {
|
||||
vi.useFakeTimers();
|
||||
const error = new ApiError("Unavailable", 500, {});
|
||||
if (source === "runs") list.mockRejectedValueOnce(error);
|
||||
else history.mockRejectedValueOnce(error);
|
||||
await render();
|
||||
expect(current).toEqual([]);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1200); });
|
||||
expect(current).toHaveLength(1);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(source === "runs" ? list : history).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each([500, 403])("bounds failed discovery reads for HTTP %s", async status => {
|
||||
vi.useFakeTimers();
|
||||
list.mockRejectedValue(new ApiError("Unavailable", status, {}));
|
||||
await render();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(list).toHaveBeenCalledTimes(status === 500 ? 3 : 1);
|
||||
expect(history).not.toHaveBeenCalled();
|
||||
expect(current).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not discover a task view without a declared activity mapping", async () => {
|
||||
plugin.slots = [{ ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } }];
|
||||
await render();
|
||||
expect(list).not.toHaveBeenCalled(); expect(history).not.toHaveBeenCalled();
|
||||
expect(get).not.toHaveBeenCalled(); expect(current).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
it("observes run lifecycle updates using the same UUID key as panel discovery", async () => {
|
||||
await render({ ...issue, identifier: "DEMO-1" });
|
||||
expect(useTaskRunLiveUpdates).toHaveBeenLastCalledWith({ companyId: "company", issueId: "task", issueQueryRef: "task", assigneeAgentId: "agent" });
|
||||
});
|
||||
|
||||
it.each(["initial", "reconnect"])("reads again after pending history settles across %s subscription", async phase => {
|
||||
vi.useFakeTimers();
|
||||
let resolve!: (value: []) => void;
|
||||
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
|
||||
if (phase === "initial") plugin.healthy = true;
|
||||
await render();
|
||||
if (phase === "reconnect") { plugin.healthy = true; plugin.epoch++; await render(); }
|
||||
expect(history).toHaveBeenCalledTimes(1);
|
||||
// The first snapshot predates activity that arrived before subscription.
|
||||
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
|
||||
expect(history).toHaveBeenCalledTimes(2);
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
expect(current).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("discards pending subscription recovery after the task changes", async () => {
|
||||
vi.useFakeTimers(); plugin.healthy = true;
|
||||
let resolve!: (value: []) => void;
|
||||
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
|
||||
await render();
|
||||
await render({ ...issue, id: "next-task" });
|
||||
const reads = history.mock.calls.length;
|
||||
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
|
||||
expect(history).toHaveBeenCalledTimes(reads);
|
||||
});
|
||||
|
||||
it.each(["not-ready", "server-error"])("reports unexpected failures only after bounded %s readiness checks", async failure => {
|
||||
vi.useFakeTimers();
|
||||
const reportError = vi.fn();
|
||||
client = createAppQueryClient({ connectivity: { reportError: vi.fn(), getSnapshot: () => ({ status: "online" } as never) }, reportError });
|
||||
if (failure === "not-ready") get.mockResolvedValue({ available: false });
|
||||
else get.mockRejectedValue(new ApiError("Unexpected failure", 500, {}));
|
||||
await render();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(get).toHaveBeenCalledTimes(3);
|
||||
expect(current).toEqual([]);
|
||||
expect(reportError).toHaveBeenCalledTimes(failure === "not-ready" ? 0 : 1);
|
||||
});
|
||||
|
||||
|
||||
it.each(["1.0.0", "2.0.0"])("requires current activity after a mapping change at version %s", async pluginVersion => {
|
||||
vi.useFakeTimers();
|
||||
await render(); expect(current).toHaveLength(1);
|
||||
const changed = { ...slot, pluginVersion, taskSidePanel: { ...slot.taskSidePanel!,
|
||||
activation: { namespace: "example.runtime", activityKey: "new-view.used" } } };
|
||||
history.mockResolvedValue([]); plugin.slots = [changed];
|
||||
await render(); expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
|
||||
await emit(event()); expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
|
||||
plugin.healthy = true; plugin.epoch++; await render();
|
||||
expect(current).toEqual([]);
|
||||
await emit(event({ payload: { prpEvent: { eventType: "runtime.activity.recorded", sourceKind: "runner",
|
||||
payload: changed.taskSidePanel.activation } } }));
|
||||
expect(current).toEqual([{ entityId: "agent", runId: "run", slot: changed }]);
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not carry an old in-flight activation read into a changed declaration", async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolve!: (value: Array<{ pluginKey: string; slotId: string }>) => void;
|
||||
history.mockReturnValueOnce(new Promise(done => { resolve = done; }));
|
||||
await render(); await emit(event()); expect(current).toHaveLength(1);
|
||||
history.mockResolvedValue([]);
|
||||
plugin.slots = [{ ...slot, pluginVersion: "2.0.0", taskSidePanel: { ...slot.taskSidePanel!,
|
||||
activation: { namespace: "example.runtime", activityKey: "new-view.used" } } }];
|
||||
await render(); expect(current).toEqual([]);
|
||||
await act(async () => { resolve([{ pluginKey: slot.pluginKey, slotId: slot.id }]); await vi.advanceTimersByTimeAsync(30); });
|
||||
expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
it("keeps an unchanged panel active while another declaration changes and history is pending", async () => {
|
||||
vi.useFakeTimers(); client.setDefaultOptions({ queries: { retry: false, staleTime: 60_000 } });
|
||||
const second = { ...slot, id: "other-screen", pluginKey: "example.other", pluginId: "other-plugin" };
|
||||
plugin.slots = [slot, second];
|
||||
await render(); expect(current).toHaveLength(2);
|
||||
let resolve!: (value: []) => void;
|
||||
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
|
||||
const changed = { ...second, pluginVersion: "2.0.0", taskSidePanel: { ...second.taskSidePanel!,
|
||||
activation: { namespace: "example.runtime", activityKey: "new-view.used" } } };
|
||||
plugin.slots = [slot, changed];
|
||||
await render();
|
||||
expect(history).toHaveBeenCalledTimes(2);
|
||||
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
|
||||
await emit(event({ payload: { prpEvent: { eventType: "runtime.activity.recorded", sourceKind: "runner",
|
||||
payload: changed.taskSidePanel.activation } } }));
|
||||
expect(current.map(panel => panel.slot)).toEqual([slot, changed]);
|
||||
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
|
||||
expect(current.map(panel => panel.slot)).toEqual([slot, changed]);
|
||||
});
|
||||
|
||||
it("does not seed another task's activation from cached declarations", async () => {
|
||||
await render(); expect(current).toHaveLength(1);
|
||||
history.mockResolvedValue([]);
|
||||
await render({ ...issue, id: "other-task" });
|
||||
expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
it("does not seed activations from a history read that lost access", async () => {
|
||||
vi.useFakeTimers();
|
||||
await render(); expect(current).toHaveLength(1);
|
||||
history.mockRejectedValue(new ApiError("Denied", 403, {}));
|
||||
await refresh(); expect(current).toEqual([]);
|
||||
let resolve!: (value: []) => void;
|
||||
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
|
||||
plugin.slots = [slot, { ...slot, id: "other-screen", pluginKey: "example.other" }];
|
||||
await render(); expect(current).toEqual([]);
|
||||
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
|
||||
expect(current).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
it("preserves activity when returning to an earlier cached declaration set", async () => {
|
||||
vi.useFakeTimers();
|
||||
const second = { ...slot, id: "other-screen", pluginKey: "example.other" };
|
||||
plugin.slots = [slot, second]; history.mockResolvedValue([]);
|
||||
await render(); expect(current).toEqual([]);
|
||||
plugin.slots = [slot, { ...second, pluginVersion: "2.0.0" }];
|
||||
await render(); await emit(event()); expect(current.map(panel => panel.slot.id)).toEqual([slot.id, second.id]);
|
||||
let resolve!: (value: []) => void;
|
||||
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
|
||||
plugin.slots = [slot, second]; await render();
|
||||
expect(current.map(panel => panel.slot.id)).toEqual([slot.id]);
|
||||
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
|
||||
expect(current.map(panel => panel.slot.id)).toEqual([slot.id]);
|
||||
await act(async () => client.removeQueries({ predicate: query => String(query.queryKey[5]).includes("2.0.0") }));
|
||||
await render(); expect(current.map(panel => panel.slot.id)).toEqual([slot.id]);
|
||||
});
|
||||
|
||||
|
||||
it.each(["route", "installation"])("rechecks readiness after a same-version %s change", async change => {
|
||||
vi.useFakeTimers(); await render(); expect(current).toHaveLength(1);
|
||||
get.mockResolvedValue({ available: false });
|
||||
const changed = change === "route"
|
||||
? { ...slot, taskSidePanel: { ...slot.taskSidePanel!, availabilityPath: "/views/:entityId/ready" } }
|
||||
: { ...slot, pluginId: "replacement-plugin" };
|
||||
plugin.slots = [changed]; await render();
|
||||
expect(current).toEqual([]);
|
||||
expect(get).toHaveBeenLastCalledWith(`/plugins/${changed.pluginId}/api${changed.taskSidePanel!.availabilityPath.replace(":entityId", "agent")}?runId=run&issueId=task`, expect.anything());
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(get).toHaveBeenCalledTimes(4); expect(current).toEqual([]);
|
||||
get.mockResolvedValue({ available: true }); await emit(event());
|
||||
expect(get).toHaveBeenCalledTimes(5); expect(current).toHaveLength(1);
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { api } from "@/api/client";
|
||||
import { api, ApiError } from "@/api/client";
|
||||
import { heartbeatsApi } from "@/api/heartbeats";
|
||||
import { useHasCompanyLiveConnection } from "@/context/LiveUpdatesProvider";
|
||||
import { liveRunRefetchInterval } from "@/lib/live-runs-cache";
|
||||
import { useTaskRunLiveUpdates, useTranscriptLiveStream } from "@/context/LiveUpdatesProvider";
|
||||
import { createLiveQueryRecovery } from "@/lib/live-query-recovery";
|
||||
import { QueryNotReadyError } from "@/lib/query-client";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { usePluginSlots, type ResolvedPluginSlot } from "@/plugins/slots";
|
||||
|
||||
@@ -22,33 +23,107 @@ export function taskPluginPanelId(panel: TaskPluginPanel) {
|
||||
|
||||
/** Plugins own availability and content; the host supplies the running agent. */
|
||||
export function useTaskPluginPanels(issue: Issue | null | undefined, enabled = true): TaskPluginPanel[] {
|
||||
const queryClient = useQueryClient();
|
||||
const savedTask = enabled && !!issue && !issue.id.startsWith("chat:");
|
||||
const connected = useHasCompanyLiveConnection(issue?.companyId);
|
||||
const { slots, errorMessage } = usePluginSlots({
|
||||
const { slots: declaredSlots, errorMessage } = usePluginSlots({
|
||||
slotTypes: ["detailTab"], entityType: "agent", taskSidePanel: true,
|
||||
companyId: issue?.companyId, enabled: savedTask,
|
||||
});
|
||||
const slots = useMemo(() => declaredSlots.filter(slot => slot.taskSidePanel?.activation), [declaredSlots]);
|
||||
const connected = useTaskRunLiveUpdates({
|
||||
companyId: savedTask && slots.length > 0 && !errorMessage ? issue?.companyId : null,
|
||||
issueId: issue?.id, issueQueryRef: issue?.id ?? "pending", assigneeAgentId: issue?.assigneeAgentId,
|
||||
});
|
||||
const { data: runs, isError: runsError } = useQuery({
|
||||
queryKey: queryKeys.issues.liveRuns(issue?.id ?? "pending"),
|
||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issue!.id),
|
||||
enabled: savedTask && slots.length > 0 && !errorMessage,
|
||||
refetchInterval: query => liveRunRefetchInterval(connected, query.state, 3000),
|
||||
retry: false,
|
||||
refetchInterval: connected ? false : 3000,
|
||||
retry: (failures: number, error: Error) => failures < 2 && !(error instanceof ApiError && error.status >= 400 && error.status < 500),
|
||||
retryDelay: 1000,
|
||||
});
|
||||
const run = runs?.find(run => run.status === "running"
|
||||
&& (!issue?.executionRunId || run.id === issue.executionRunId));
|
||||
const active = savedTask && slots.length > 0 && !!run && !runsError && !errorMessage;
|
||||
const availability = useQueries({ queries: slots.map(slot => ({
|
||||
queryKey: ["task-plugin-panel", issue?.companyId, issue?.id, run?.id, run?.agentId, slot.pluginKey, slot.id, slot.pluginVersion],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) => api.get<{ available: boolean }>(
|
||||
`/plugins/${encodeURIComponent(slot.pluginId)}/api${slot.taskSidePanel!.availabilityPath.replace(":entityId", encodeURIComponent(run!.agentId))}`,
|
||||
{ signal, cache: "no-store" },
|
||||
),
|
||||
const declarations = slots.map(slot => [slot.pluginKey, slot.id, slot.pluginVersion,
|
||||
slot.taskSidePanel!.activation!.namespace, slot.taskSidePanel!.activation!.activityKey]);
|
||||
const activationScope = JSON.stringify(declarations);
|
||||
const activationKey = ["task-plugin-panel-activations", issue?.companyId, issue?.id, run?.id, run?.agentId, activationScope];
|
||||
// Keep unchanged declarations active while a new declaration scope loads.
|
||||
const retained = new Map<string, { pluginKey: string; slotId: string }>();
|
||||
for (const [key, panels] of queryClient.getQueriesData<Array<{ pluginKey: string; slotId: string }>>({ queryKey: activationKey.slice(0, 5) })) {
|
||||
if (typeof key[5] !== "string" || queryClient.getQueryState(key)?.status !== "success") continue;
|
||||
const unchanged = (JSON.parse(key[5]) as string[][]).filter(previous =>
|
||||
declarations.some(current => current.every((part, index) => part === previous[index])));
|
||||
for (const panel of panels ?? []) if (unchanged.some(declaration => declaration[0] === panel.pluginKey && declaration[1] === panel.slotId)) {
|
||||
retained.set(JSON.stringify([panel.pluginKey, panel.slotId]), panel);
|
||||
}
|
||||
}
|
||||
const { data: recordedActivations = [], isError: activationsError } = useQuery({
|
||||
queryKey: activationKey,
|
||||
queryFn: ({ signal }) => heartbeatsApi.taskPanelActivations(run!.id, { signal, cache: "no-store" }),
|
||||
initialData: () => retained.size ? [...retained.values()] : undefined,
|
||||
initialDataUpdatedAt: 0,
|
||||
// Run activations only grow; merge history and live hints when committing cache data.
|
||||
structuralSharing: (previous, incoming) => {
|
||||
const merged = new Map(retained);
|
||||
for (const panel of [...((previous ?? []) as Array<{ pluginKey: string; slotId: string }>), ...(incoming as Array<{ pluginKey: string; slotId: string }>)]) {
|
||||
merged.set(JSON.stringify([panel.pluginKey, panel.slotId]), panel);
|
||||
}
|
||||
return [...merged.values()];
|
||||
},
|
||||
staleTime: 0,
|
||||
enabled: active,
|
||||
refetchInterval: 3000,
|
||||
retry: false,
|
||||
retry: (failures: number, error: Error) => failures < 2 && !(error instanceof ApiError && error.status >= 400 && error.status < 500),
|
||||
retryDelay: 1000,
|
||||
});
|
||||
const activations = [...recordedActivations, ...retained.values()];
|
||||
const stream = useTranscriptLiveStream(active ? [run!.id] : [], { companyId: issue?.companyId, enabled: active }, event => {
|
||||
if (!active || !run || event.companyId !== issue?.companyId || event.payload.runId !== run.id
|
||||
|| event.payload.agentId !== run.agentId || event.payload.issueId !== issue?.id
|
||||
|| event.type !== "heartbeat.run.event" || event.payload.eventType !== "runtime.activity.recorded") return;
|
||||
const native = event.payload.payload as { prpEvent?: { eventType?: string; sourceKind?: string; payload?: unknown } } | null;
|
||||
if (native?.prpEvent?.eventType !== "runtime.activity.recorded" || native.prpEvent.sourceKind !== "runner") return;
|
||||
const activity = native.prpEvent.payload as { namespace?: unknown; activityKey?: unknown } | null;
|
||||
if (!activity || Object.keys(activity).length !== 2 || typeof activity.namespace !== "string" || typeof activity.activityKey !== "string") return;
|
||||
const panels = slots.filter(slot => slot.taskSidePanel?.activation?.namespace === activity.namespace
|
||||
&& slot.taskSidePanel?.activation?.activityKey === activity.activityKey)
|
||||
.map(slot => ({ pluginKey: slot.pluginKey, slotId: slot.id }));
|
||||
if (!panels.length) return;
|
||||
queryClient.setQueryData<Array<{ pluginKey: string; slotId: string }>>(activationKey, previous => {
|
||||
const missing = panels.filter(panel => !previous?.some(item => item.pluginKey === panel.pluginKey && item.slotId === panel.slotId));
|
||||
return missing.length ? [...(previous ?? []), ...missing] : previous;
|
||||
});
|
||||
for (const panel of panels) void queryClient.invalidateQueries({
|
||||
queryKey: ["task-plugin-panel", issue?.companyId, issue?.id, run.id, run.agentId, panel.pluginKey, panel.slotId],
|
||||
predicate: ({ state }) => state.status === "error" && state.fetchStatus === "idle"
|
||||
&& !(state.error instanceof ApiError && state.error.status >= 400 && state.error.status < 500),
|
||||
});
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!active || !stream.healthy) return;
|
||||
let current = true;
|
||||
void queryClient.invalidateQueries({ queryKey: ["task-plugin-panel", issue?.companyId, issue?.id, run?.id, run?.agentId] });
|
||||
// A pre-subscription history read must settle before the recovery read.
|
||||
createLiveQueryRecovery(queryClient, () => current, { queryKey: activationKey, exact: true })();
|
||||
return () => { current = false; };
|
||||
}, [active, stream.healthy, stream.epoch, queryClient, issue?.companyId, issue?.id, run?.id, run?.agentId, activationScope]);
|
||||
const availability = useQueries({ queries: slots.map(slot => ({
|
||||
queryKey: ["task-plugin-panel", issue?.companyId, issue?.id, run?.id, run?.agentId, slot.pluginKey, slot.id, slot.pluginVersion, slot.pluginId, slot.taskSidePanel!.availabilityPath],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const result = await api.get<{ available: boolean }>(
|
||||
`/plugins/${encodeURIComponent(slot.pluginId)}/api${slot.taskSidePanel!.availabilityPath.replace(":entityId", encodeURIComponent(run!.agentId))}?runId=${encodeURIComponent(run!.id)}&issueId=${encodeURIComponent(issue!.id)}`,
|
||||
{ signal, cache: "no-store" },
|
||||
);
|
||||
if (result.available !== true) throw new QueryNotReadyError("Plugin panel is not ready");
|
||||
return result;
|
||||
},
|
||||
enabled: active && !activationsError && activations.some(panel => panel.pluginKey === slot.pluginKey && panel.slotId === slot.id),
|
||||
retry: (failures: number, error: Error) => failures < 2 && !(error instanceof ApiError && error.status >= 400 && error.status < 500),
|
||||
retryDelay: 1000,
|
||||
})) });
|
||||
const available = availability.map(result => !result.isError && result.data?.available === true);
|
||||
const available = availability.map((result, index) => !activationsError && !result.isError && result.data?.available === true
|
||||
&& activations.some(panel => panel.pluginKey === slots[index]?.pluginKey && panel.slotId === slots[index]?.id));
|
||||
const availableKey = available.join(",");
|
||||
return useMemo(() => active && run ? slots.flatMap((slot, index) => available[index]
|
||||
? [{ entityId: run.agentId, runId: run.id, slot }] : []) : EMPTY_PANELS,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Query, QueryClient } from "@tanstack/react-query";
|
||||
import type { Query, QueryClient, QueryFilters } from "@tanstack/react-query";
|
||||
|
||||
export function createLiveQueryRecovery(client: QueryClient, isCurrent: () => boolean) {
|
||||
export function createLiveQueryRecovery(client: QueryClient, isCurrent: () => boolean, filters: QueryFilters = {}) {
|
||||
const waiting = new WeakSet<Query>();
|
||||
return () => {
|
||||
if (!isCurrent()) return;
|
||||
for (const query of client.getQueryCache().findAll({ type: "active" })) {
|
||||
for (const query of client.getQueryCache().findAll({ ...filters, type: "active" })) {
|
||||
if (waiting.has(query)) continue;
|
||||
const refresh = () => client.invalidateQueries({ queryKey: query.queryKey, exact: true }, { cancelRefetch: false });
|
||||
if (query.state.fetchStatus !== "fetching") {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
reconnectDelayFor,
|
||||
retryDelayFor,
|
||||
shouldReportQueryError,
|
||||
QueryNotReadyError,
|
||||
shouldRetryRequest,
|
||||
TRANSIENT_RETRY_DELAYS_MS,
|
||||
type MutationErrorToast,
|
||||
@@ -59,6 +60,7 @@ describe("retry policy", () => {
|
||||
it("reports only unexpected query errors to Sentry", () => {
|
||||
expect(shouldReportQueryError(new ApiError("boom", 500, null))).toBe(true);
|
||||
expect(shouldReportQueryError(new Error("parse failure"))).toBe(true);
|
||||
expect(shouldReportQueryError(new QueryNotReadyError("Not ready"))).toBe(false);
|
||||
expect(shouldReportQueryError(gatewayDown())).toBe(false);
|
||||
expect(shouldReportQueryError(new TypeError("Failed to fetch"))).toBe(false);
|
||||
expect(shouldReportQueryError(new ApiError("Not found", 404, null))).toBe(false);
|
||||
|
||||
@@ -101,9 +101,12 @@ export function reconnectDelayFor(failureCount: number, error: unknown): number
|
||||
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 classifyError(error) === "unknown";
|
||||
return !(error instanceof QueryNotReadyError) && classifyError(error) === "unknown";
|
||||
}
|
||||
|
||||
export interface MutationErrorToast {
|
||||
|
||||
Reference in new issue
Block a user