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 {