From 35ab90ad0fc01b3b46b5496d85146b2d926814b6 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 29 Oct 2026 13:56:48 -0700 Subject: [PATCH] Map runtime activity to authorized task plugin panels (#18131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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.
Retained failures and recovery evidence - `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.
## 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 --- doc/plugins/PLUGIN_SPEC.md | 6 +- doc/run-log-events.md | 7 + maps/feature-map/task-coordination.md | 8 +- packages/plugins/sdk/README.md | 38 ++- packages/shared/src/types/plugin.ts | 10 +- packages/shared/src/validators/plugin.test.ts | 11 + packages/shared/src/validators/plugin.ts | 5 + .../__tests__/agent-live-run-routes.test.ts | 18 + .../heartbeat-run-privacy-routes.test.ts | 1 + .../__tests__/live-events-run-read.test.ts | 25 ++ server/src/routes/agents.ts | 8 + .../paperclip-control-plane-port.test.ts | 7 + .../src/services/plugin-task-panels.test.ts | 21 ++ server/src/services/plugin-task-panels.ts | 34 ++ ui/src/api/heartbeats.ts | 2 + .../LiveUpdatesProvider.recovery.test.tsx | 12 +- ui/src/hooks/useTaskPluginPanels.test.tsx | 315 +++++++++++++++++- ui/src/hooks/useTaskPluginPanels.ts | 111 +++++- ui/src/lib/live-query-recovery.ts | 6 +- ui/src/lib/query-client.test.ts | 2 + ui/src/lib/query-client.ts | 5 +- 21 files changed, 601 insertions(+), 51 deletions(-) create mode 100644 server/src/services/plugin-task-panels.test.ts create mode 100644 server/src/services/plugin-task-panels.ts diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index d04a724c385..a6c744476d8 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -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; }>; diff --git a/doc/run-log-events.md b/doc/run-log-events.md index 1c6d900e926..e5fb2720f21 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -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. diff --git a/maps/feature-map/task-coordination.md b/maps/feature-map/task-coordination.md index 7220b51e6cc..b7ef92dbf7f 100644 --- a/maps/feature-map/task-coordination.md +++ b/maps/feature-map/task-coordination.md @@ -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. diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 9fd5b84ab8c..378b90481ff 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -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. diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 9b8e5f24844..b78a1e9c83a 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -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 diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index f5ce7de3315..1331dadc16c 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -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); }); diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 258991ee764..530889d91c7 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -384,6 +384,10 @@ export type PluginManagedSkillDeclarationInput = z.infer 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)", diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index 7d877cd9e54..1dedcddc243 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -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, diff --git a/server/src/__tests__/heartbeat-run-privacy-routes.test.ts b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts index 6aaa6a7d84d..572e721996a 100644 --- a/server/src/__tests__/heartbeat-run-privacy-routes.test.ts +++ b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts @@ -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`, diff --git a/server/src/__tests__/live-events-run-read.test.ts b/server/src/__tests__/live-events-run-read.test.ts index d16f8a4c41e..00bda1efaa2 100644 --- a/server/src/__tests__/live-events-run-read.test.ts +++ b/server/src/__tests__/live-events-run-read.test.ts @@ -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(); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 5e066f8f0ba..a0ca2cc304b 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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; diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts index 57c2d3e6289..20fc3e0bfb1 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts @@ -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 () => { diff --git a/server/src/services/plugin-task-panels.test.ts b/server/src/services/plugin-task-panels.test.ts new file mode 100644 index 00000000000..cbd4f42c35a --- /dev/null +++ b/server/src/services/plugin-task-panels.test.ts @@ -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(); + }); +}); diff --git a/server/src/services/plugin-task-panels.ts b/server/src/services/plugin-task-panels.ts new file mode 100644 index 00000000000..00af4b2e18b --- /dev/null +++ b/server/src/services/plugin-task-panels.ts @@ -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 | 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 { + const rows = await db.selectDistinct({ + payload: sql>`${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); +} diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 8fac92cdb1a..7756130160b 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -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>(`/heartbeat-runs/${encodeURIComponent(runId)}/events?view=plugin-panels`, options), events: (runId: string, afterSeq = 0, limit = 200, options?: RequestOptions) => api.get( `/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`, diff --git a/ui/src/context/LiveUpdatesProvider.recovery.test.tsx b/ui/src/context/LiveUpdatesProvider.recovery.test.tsx index db874fed347..2f7f9d8813d 100644 --- a/ui/src/context/LiveUpdatesProvider.recovery.test.tsx +++ b/ui/src/context/LiveUpdatesProvider.recovery.test.tsx @@ -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>().mockResolvedValue([]); const activeRead = vi.fn<() => Promise>().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) => { + const deliver = async (companyId: string, payload: Record, 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) => { diff --git a/ui/src/hooks/useTaskPluginPanels.test.tsx b/ui/src/hooks/useTaskPluginPanels.test.tsx index 6a931edd15a..9e7e4212512 100644 --- a/ui/src/hooks/useTaskPluginPanels.test.tsx +++ b/ui/src/hooks/useTaskPluginPanels.test.tsx @@ -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; let list: MockInstance; +let history: MockInstance; 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 = {}): 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); }); diff --git a/ui/src/hooks/useTaskPluginPanels.ts b/ui/src/hooks/useTaskPluginPanels.ts index 77a43e39887..12c84a73254 100644 --- a/ui/src/hooks/useTaskPluginPanels.ts +++ b/ui/src/hooks/useTaskPluginPanels.ts @@ -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(); + for (const [key, panels] of queryClient.getQueriesData>({ 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>(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, diff --git a/ui/src/lib/live-query-recovery.ts b/ui/src/lib/live-query-recovery.ts index 1b04bdf3bb9..abc43e3a787 100644 --- a/ui/src/lib/live-query-recovery.ts +++ b/ui/src/lib/live-query-recovery.ts @@ -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(); 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") { diff --git a/ui/src/lib/query-client.test.ts b/ui/src/lib/query-client.test.ts index dec156ec762..951aa1600f3 100644 --- a/ui/src/lib/query-client.test.ts +++ b/ui/src/lib/query-client.test.ts @@ -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); diff --git a/ui/src/lib/query-client.ts b/ui/src/lib/query-client.ts index bba35c0257b..9fcc4332862 100644 --- a/ui/src/lib/query-client.ts +++ b/ui/src/lib/query-client.ts @@ -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 {