Map runtime activity to authorized task plugin panels (#18131)

## Thinking Path

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

## Linked Issues or Issue Description

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

**Subsystem affected**

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

**Problem or motivation**

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

**Proposed solution**

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

## What Changed

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

## Verification

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

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

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

</details>

## Risks

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

## Model Used

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

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-29 13:56:48 -07:00
1 parent 9e09d8f51f
commit 35ab90ad0f
21 files changed
+601 -51

No files matched your search

+5 -1
View File
@@ -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;
}>;
+7
View File
@@ -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.
+7 -1
View File
@@ -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.
+27 -11
View File
@@ -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.
+8 -2
View File
@@ -500,10 +500,16 @@ export interface PluginUiSlotDeclaration {
*/
entityTypes?: PluginUiSlotEntityType[];
/** Also offer this agent detail tab beside a task while its agent is running.
* GET this plugin-relative path with :entityId replaced by the agent ID.
* The run must record the declared runtime activity before a task panel appears.
* GET this plugin-relative path with :entityId replaced by the agent ID,
* and runId/issueId query parameters for the active task.
* The board/agent-scoped route returns { available: boolean }.
*/
taskSidePanel?: { availabilityPath: string };
taskSidePanel?: {
availabilityPath: string;
/** Opaque runtime activity mapped to this view. Absent mappings add no task panel. */
activation?: { namespace: string; activityKey: string };
};
/**
* Optional company-scoped route segment for page, routeSidebar, and
@@ -505,6 +505,17 @@ describe("plugin task side-panel declarations", () => {
it("accepts an optional panel backed by an authorized plugin-local agent route", () => {
expect(pluginManifestV1Schema.parse(manifest).ui?.slots?.[0]?.taskSidePanel).toEqual(slot.taskSidePanel);
});
it("accepts an explicit UI-independent activity mapping and preserves legacy declarations", () => {
const activation = { namespace: "example.runtime", activityKey: "desktop.used" };
expect(pluginUiSlotDeclarationSchema.parse({ ...slot, taskSidePanel: { ...slot.taskSidePanel, activation } }).taskSidePanel?.activation).toEqual(activation);
expect(pluginUiSlotDeclarationSchema.parse(slot).taskSidePanel?.activation).toBeUndefined();
for (const value of ["视".repeat(60), "😀".repeat(160)]) {
expect(pluginUiSlotDeclarationSchema.safeParse({ ...slot, taskSidePanel: { ...slot.taskSidePanel, activation: { namespace: value, activityKey: value } } }).success).toBe(true);
}
for (const activation of [{ namespace: "", activityKey: "used" }, { namespace: "runtime", activityKey: "😀".repeat(161) }, { namespace: "runtime", activityKey: "\u007f" }, { namespace: "runtime", activityKey: "used", runId: "other" }]) {
expect(pluginUiSlotDeclarationSchema.safeParse({ ...slot, taskSidePanel: { ...slot.taskSidePanel, activation } }).success).toBe(false);
}
});
it("accepts a GET availability route when a POST at the same path is declared first", () => {
expect(pluginManifestV1Schema.safeParse({ ...manifest, apiRoutes: [{ ...route, routeKey: "view.update", method: "POST" }, route] }).success).toBe(true);
});
+5
View File
@@ -384,6 +384,10 @@ export type PluginManagedSkillDeclarationInput = z.infer<typeof pluginManagedSki
*
* @see PLUGIN_SPEC.md §19 — UI Extension Model
*/
const runtimeActivityIdentifierSchema = z.string().min(1)
.refine(value => Array.from(value).length <= 160, "Activity identifiers must contain at most 160 code points")
.regex(/^[^\u0000-\u001f\u007f]+$/);
export const pluginUiSlotDeclarationSchema = z.object({
type: z.enum(PLUGIN_UI_SLOT_TYPES),
id: z.string().min(1),
@@ -392,6 +396,7 @@ export const pluginUiSlotDeclarationSchema = z.object({
entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(),
taskSidePanel: z.object({
availabilityPath: z.string().regex(/^\/(?:[a-zA-Z0-9_-]+\/)*:entityId(?:\/[a-zA-Z0-9_-]+)*$/),
activation: z.object({ namespace: runtimeActivityIdentifierSchema, activityKey: runtimeActivityIdentifierSchema }).strict().optional(),
}).strict().optional(),
routePath: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, {
message: "routePath must be a lowercase single-segment slug (letters, numbers, hyphens)",
@@ -674,6 +674,7 @@ describe("agent live run routes", () => {
"/api/companies/company-1/live-runs",
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/events",
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/events?view=plugin-panels",
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/log",
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/workspace-operations",
"/api/workspace-operations/operation-1/log",
@@ -694,6 +695,23 @@ describe("agent live run routes", () => {
},
);
it("reads persisted plugin activation only after normal run-read authorization", async () => {
const limit = vi.fn(async () => [{ payload: { namespace: "example.runtime", activityKey: "desktop.used" }, pluginKey: "example.live-view",
manifest: { ui: { slots: [{ id: "screen", type: "detailTab", entityTypes: ["agent"], taskSidePanel: { availabilityPath: "/agents/:entityId/ready", activation: { namespace: "example.runtime", activityKey: "desktop.used" } } }] } } }]);
const query = { from: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), limit };
const db = { selectDistinct: vi.fn(() => query) };
const response = await requestApp(await createApp(db), url => request(url).get(
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/events?view=plugin-panels"));
expect(response.status).toBe(200);
expect(response.body).toEqual([{ pluginKey: "example.live-view", slotId: "screen" }]);
expect(limit).toHaveBeenCalledWith(128);
expect(mockRunSecretRedactionRegistry.redactForRun).toHaveBeenCalledWith("company-1", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", [{ pluginKey: "example.live-view", slotId: "screen" }]);
mockHeartbeatService.getRunReadAccess.mockResolvedValue(null);
const denied = await requestApp(await createApp(db), url => request(url).get(
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/events?view=plugin-panels"));
expect(denied.status).toBe(404); expect(limit).toHaveBeenCalledTimes(1);
});
it("lists workspace operations using narrow current run access", async () => {
mockHeartbeatService.getRunWorkspaceAccess.mockResolvedValue({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", scopeKind: "company", issueId: null,
@@ -288,6 +288,7 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
for (const path of [
`/api/heartbeat-runs/${fixture.privateRunId}`,
`/api/heartbeat-runs/${fixture.privateRunId}/events`,
`/api/heartbeat-runs/${fixture.privateRunId}/events?view=plugin-panels`,
`/api/heartbeat-runs/${fixture.privateRunId}/log`,
`/api/heartbeat-runs/${fixture.privateRunId}/workspace-operations`,
`/api/workspace-operations/${fixture.operationId}/log`,
@@ -1,3 +1,6 @@
import { readTaskPanelActivations } from "../services/plugin-task-panels.js";
import { plugins, heartbeatRunEvents } from "@paperclipai/db";
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { homedir } from "node:os";
import { redactEventPayload } from "../redaction.js";
@@ -43,6 +46,28 @@ describe("live-event run read boundaries", () => {
await temporary?.cleanup();
});
it.each(["ready", "disabled", "error", "upgrade_pending"] as const)("restores a structured activation for an installed %s plugin", async status => {
const companyId = randomUUID(), pluginKey = `example.recovered-${status}`;
await db.insert(companies).values({ id: companyId, name: "Panel recovery", issuePrefix: `PNL${status.toUpperCase()}` });
const [agent] = await db.insert(agents).values({ companyId, name: "Panel fixture", adapterType: "process" }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id, status: "running" }).returning();
await db.insert(plugins).values({ pluginKey, packageName: pluginKey, version: "1.0.0", status,
manifestJson: { ui: { slots: [{ type: "detailTab", id: "screen", entityTypes: ["agent"], taskSidePanel: { availabilityPath: "/agents/:entityId/ready", activation: { namespace: `example.runtime-${status}`, activityKey: "desktop.used" } } }] } } as never });
const write = (seq: number, panel: unknown) => db.insert(heartbeatRunEvents).values({ companyId, agentId: agent.id, runId: run.id,
seq, eventType: "runtime.activity.recorded", payload: { prpEvent: { sourceKind: "runner", eventType: "runtime.activity.recorded", payload: panel } } });
await write(1, { namespace: `example.runtime-${status}`, activityKey: "desktop.used" });
await write(2, { namespace: `example.runtime-${status}`, activityKey: "unknown.activity" });
await write(3, { namespace: "unknown.runtime", activityKey: "desktop.used" });
await write(4, { namespace: `example.runtime-${status}`, activityKey: "desktop.used" }); // Replay may repeat activity with a new source ID.
await write(5, { namespace: `example.runtime-${status}`, activityKey: "desktop.used", runId: "caller-run" });
await db.insert(heartbeatRunEvents).values({ companyId, agentId: agent.id, runId: run.id, seq: 6,
eventType: "runtime.activity.recorded", payload: { prpEvent: { sourceKind: "control_plane", eventType: "runtime.activity.recorded", payload: { namespace: `example.runtime-${status}`, activityKey: "desktop.used" } } } });
expect(await readTaskPanelActivations(db, companyId, run.id)).toEqual([{ pluginKey, slotId: "screen" }]);
expect(await readTaskPanelActivations(db, randomUUID(), run.id)).toEqual([]);
await db.update(plugins).set({ status: "uninstalled" }).where(eq(plugins.pluginKey, pluginKey));
expect(await readTaskPanelActivations(db, companyId, run.id)).toEqual([]);
});
it("reads only run bindings and reauthorizes every delivery after revocation or deletion", async () => {
const companyId = randomUUID(), foreignCompanyId = randomUUID();
const owner = randomUUID(), viewer = randomUUID();
+8
View File
@@ -1,3 +1,4 @@
import { readTaskPanelActivations } from "../services/plugin-task-panels.js";
import { latestFailedRunsByAgent } from "@paperclipai/shared";
import { agentConfigurationSpec } from "@paperclipai/shared";
import { agentConfigurationRevision, restoreAgentConfigurationRedactions } from "../lib/agent-configuration-revision.js";
@@ -7600,6 +7601,13 @@ export function agentRoutes(
if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return;
if (!(await assertRunReadAllowed(req, res, run))) return;
if (req.query.view === "plugin-panels") {
const options = await getCurrentUserRedactionOptions();
const panels = redactCurrentUserValue(await readTaskPanelActivations(db, run.companyId, run.id), options);
res.json(await runRedactions.redactForRun(run.companyId, run.id, panels.map(panel => redactEventPayload({ ...panel }))));
return;
}
const afterSeq = Number(req.query.afterSeq ?? 0);
const limit = Number(req.query.limit ?? 200);
const cursor = Number.isFinite(afterSeq) ? afterSeq : 0;
@@ -1,5 +1,7 @@
import { nativeResponsePresentationWork } from "./native-response-presentation-work.js";
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
import { plugins } from "@paperclipai/db";
import { readTaskPanelActivations } from "../plugin-task-panels.js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { asc, eq, sql } from "drizzle-orm";
import { generateKeyPairSync, randomUUID } from "node:crypto";
@@ -405,6 +407,8 @@ describe("PaperclipControlPlanePort conformance", () => {
});
it("persists panel activation under the authenticated run and recovers it across source replay", async () => {
await db.insert(plugins).values({ pluginKey: "example.live-view", packageName: "example-live-view", version: "1.0.0", status: "ready",
manifestJson: { ui: { slots: [{ type: "detailTab", id: "screen", entityTypes: ["agent"], taskSidePanel: { availabilityPath: "/agents/:entityId/ready", activation: { namespace: "example.runtime", activityKey: "desktop.used" } } }] } } as never });
const identity = { ...CONTROL_PLANE_CONFORMANCE_OPEN.identity, runId: randomUUID(), sessionId: randomUUID() };
const runnerId = randomUUID();
await db.insert(heartbeatRuns).values({
@@ -433,10 +437,13 @@ describe("PaperclipControlPlanePort conformance", () => {
});
try { await port.appendEvent(event); } finally { unsubscribe(); }
expect(notifications).toEqual(["runtime.activity.recorded"]);
expect(await readTaskPanelActivations(db, identity.companyId, identity.runId)).toEqual([{ pluginKey: "example.live-view", slotId: "screen" }]);
const restarted = new PaperclipControlPlanePort(db, replayBinding);
expect((await restarted.appendEvent(event)).disposition).toBe("duplicate");
const activations = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, identity.runId));
expect(activations.filter(row => row.eventType === "runtime.activity.recorded")).toHaveLength(1);
expect(await readTaskPanelActivations(db, identity.companyId, CONTROL_PLANE_CONFORMANCE_OPEN.identity.runId)).toEqual([]);
expect(await readTaskPanelActivations(db, randomUUID(), identity.runId)).toEqual([]);
});
it("omits API credentials from persisted tool events and checkpoints without changing wire results", async () => {
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { runtimeActivity } from "./plugin-task-panels.js";
describe("structured runtime activity", () => {
it("counts Unicode code points and matches the schema's control-character rules", () => {
for (const activityKey of ["视".repeat(60), "😀".repeat(160), "view-0", "\u0085"]) {
const panel = { namespace: "example.runtime", activityKey };
expect(runtimeActivity(panel)).toEqual(panel);
}
for (const activityKey of ["😀".repeat(161), "\u001f", "\u007f"]) {
expect(runtimeActivity({ namespace: "example.runtime", activityKey })).toBeNull();
}
});
it("accepts only bounded panel identifiers without caller run identity", () => {
const panel = { namespace: "example.runtime", activityKey: "screen" };
expect(runtimeActivity(panel)).toEqual(panel);
for (const payload of [null, { output: "[paperclip-task-panel:example.runtime:screen]" },
{ ...panel, runId: "caller-run" }, { ...panel, activityKey: "" }, { ...panel, activityKey: "x".repeat(161) },
{ ...panel, namespace: "invalid\nkey" }]) expect(runtimeActivity(payload)).toBeNull();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { and, eq, ne, sql } from "drizzle-orm";
import { heartbeatRunEvents, plugins, type Db } from "@paperclipai/db";
export const RUNTIME_ACTIVITY_EVENT = "runtime.activity.recorded";
export interface TaskPanelActivation { pluginKey: string; slotId: string }
export function runtimeActivity(payload: Record<string, unknown> | null): { namespace: string; activityKey: string } | null {
const valid = (value: unknown): value is string => typeof value === "string" && value.length > 0
&& Array.from(value).length <= 160 && !/[\u0000-\u001f\u007f]/.test(value);
return payload && Object.keys(payload).length === 2 && valid(payload.namespace) && valid(payload.activityKey)
? { namespace: payload.namespace, activityKey: payload.activityKey } : null;
}
/** Core maps authenticated runtime facts to installed UI declarations. */
export async function readTaskPanelActivations(db: Db, companyId: string, runId: string): Promise<TaskPanelActivation[]> {
const rows = await db.selectDistinct({
payload: sql<Record<string, unknown>>`${heartbeatRunEvents.payload}->'prpEvent'->'payload'`, manifest: plugins.manifestJson, pluginKey: plugins.pluginKey,
}).from(heartbeatRunEvents)
.innerJoin(plugins, sql`EXISTS (
SELECT 1 FROM jsonb_array_elements(${plugins.manifestJson}->'ui'->'slots') AS declared_slot
WHERE declared_slot->'taskSidePanel'->'activation' = ${heartbeatRunEvents.payload}->'prpEvent'->'payload'
)`)
.where(and(eq(heartbeatRunEvents.companyId, companyId), eq(heartbeatRunEvents.runId, runId),
eq(heartbeatRunEvents.eventType, RUNTIME_ACTIVITY_EVENT), ne(plugins.status, "uninstalled"),
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'sourceKind' = 'runner'`,
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'eventType' = ${RUNTIME_ACTIVITY_EVENT}`)).limit(128);
return rows.flatMap(({ payload, manifest, pluginKey }) => {
const activity = runtimeActivity(payload);
return activity ? (manifest.ui?.slots ?? []).filter(slot => slot.type === "detailTab"
&& slot.entityTypes?.includes("agent") && slot.taskSidePanel?.activation?.namespace === activity.namespace
&& slot.taskSidePanel?.activation?.activityKey === activity.activityKey)
.map(slot => ({ pluginKey, slotId: slot.id })) : [];
}).slice(0, 128);
}
+2
View File
@@ -145,6 +145,8 @@ export const heartbeatsApi = {
`/heartbeat-runs/${runId}/events?view=conversation&afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
options,
),
taskPanelActivations: (runId: string, options?: RequestOptions) =>
api.get<Array<{ pluginKey: string; slotId: string }>>(`/heartbeat-runs/${encodeURIComponent(runId)}/events?view=plugin-panels`, options),
events: (runId: string, afterSeq = 0, limit = 200, options?: RequestOptions) =>
api.get<HeartbeatRunEvent[]>(
`/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
@@ -278,10 +278,9 @@ describe("LiveUpdatesProvider connection recovery", () => {
expect(pollRead).toHaveBeenCalledTimes(afterOpen);
});
it("discovers matching new runs after empty polling stops on an unfocused non-task route", async () => {
it.each(["DEMO-1", "issue-1"])("discovers and finishes runs with cache key %s on an unfocused non-task route", async queryRef => {
vi.stubGlobal("WebSocket", Socket);
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const queryRef = "DEMO-1";
const runRead = vi.fn<() => Promise<unknown>>().mockResolvedValue([]);
const activeRead = vi.fn<() => Promise<unknown>>().mockResolvedValue(null);
function TaskRuns() {
@@ -300,9 +299,9 @@ describe("LiveUpdatesProvider connection recovery", () => {
await advancePolls(5);
expect(runRead).not.toHaveBeenCalled();
expect(activeRead).not.toHaveBeenCalled();
const deliver = async (companyId: string, payload: Record<string, unknown>) => {
const deliver = async (companyId: string, payload: Record<string, unknown>, type = "heartbeat.run.queued") => {
await act(async () => socket.onmessage?.({ data: JSON.stringify({ id: 1, companyId,
type: "heartbeat.run.queued", createdAt: "2020-01-01T00:00:00Z", payload }) }));
type, createdAt: "2020-01-01T00:00:00Z", payload }) }));
await act(async () => vi.advanceTimersByTimeAsync(1));
};
await deliver("company-2", { issueId: "issue-1", agentId: "agent-1", runId: "foreign" });
@@ -318,6 +317,11 @@ describe("LiveUpdatesProvider connection recovery", () => {
await deliver("company-1", { agentId: "reviewer-agent", runId: "reviewer-run-without-task-id" });
expect(runRead).toHaveBeenCalledTimes(2);
expect(activeRead).toHaveBeenCalledTimes(2);
runRead.mockResolvedValue([]);
activeRead.mockResolvedValue(null);
await deliver("company-1", { issueId: "issue-1", agentId: "agent-1", runId: "new-run", status: "succeeded" }, "heartbeat.run.status");
expect(client.getQueryData(queryKeys.issues.liveRuns(queryRef))).toEqual([]);
expect(container.textContent).not.toContain("new-run");
});
it.each(["initial", "background", "connection setup"])("refetches after an older empty %s read settles when run events arrive during it", async (phase) => {
+305 -10
View File
@@ -2,20 +2,22 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import type { Issue, LiveEvent } from "@paperclipai/shared";
import { afterEach, beforeEach, expect, it, vi, type MockInstance } from "vitest";
import { api } from "@/api/client";
import { api, ApiError } from "@/api/client";
import { heartbeatsApi, type LiveRunForIssue } from "@/api/heartbeats";
import { useTaskRunLiveUpdates } from "@/context/LiveUpdatesProvider";
import { createAppQueryClient } from "@/lib/query-client";
import { queryKeys } from "@/lib/queryKeys";
import type { ResolvedPluginSlot } from "@/plugins/slots";
import { useTaskPluginPanels, type TaskPluginPanel } from "./useTaskPluginPanels";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const plugin = vi.hoisted(() => ({ connected: true, slots: [] as ResolvedPluginSlot[], errorMessage: null as string | null }));
const plugin = vi.hoisted(() => ({ connected: true, slots: [] as ResolvedPluginSlot[], healthy: true, epoch: 0, handler: null as ((event: LiveEvent) => void) | null, errorMessage: null as string | null }));
vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => plugin }));
vi.mock("@/context/LiveUpdatesProvider", () => ({ useHasCompanyLiveConnection: () => plugin.connected }));
const slot = { pluginId: "example-plugin", pluginKey: "example.live-view", id: "screen", pluginVersion: "1.0.0", taskSidePanel: { availabilityPath: "/agents/:entityId/view/availability" } } as ResolvedPluginSlot;
const issue = { id: "task", companyId: "company", executionRunId: "run" } as Issue;
vi.mock("@/context/LiveUpdatesProvider", () => ({ useTaskRunLiveUpdates: vi.fn(() => plugin.connected), useTranscriptLiveStream: (_ids: string[], _options: unknown, handler: (event: LiveEvent) => void) => { plugin.handler = handler; return { healthy: plugin.healthy, epoch: plugin.epoch }; } }));
const slot = { pluginId: "example-plugin", pluginKey: "example.live-view", id: "screen", pluginVersion: "1.0.0", taskSidePanel: { availabilityPath: "/agents/:entityId/view/availability", activation: { namespace: "example.runtime", activityKey: "desktop.used" } } } as ResolvedPluginSlot;
const issue = { id: "task", companyId: "company", executionRunId: "run", assigneeAgentId: "agent" } as Issue;
const run = { id: "run", agentId: "agent", status: "running" } as LiveRunForIssue;
let root: Root, container: HTMLDivElement, client: QueryClient;
let current: TaskPluginPanel[];
@@ -23,6 +25,7 @@ let task: Issue | null;
let enabled: boolean;
let get: MockInstance<typeof api.get>;
let list: MockInstance<typeof heartbeatsApi.liveRunsForIssue>;
let history: MockInstance<typeof heartbeatsApi.taskPanelActivations>;
function Harness() { current = useTaskPluginPanels(task, enabled); return null; }
async function render(next: Issue | null = issue, active = true) {
task = next; enabled = active;
@@ -34,11 +37,12 @@ async function refresh() {
await act(async () => { if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(30); else await new Promise(resolve => setTimeout(resolve, 30)); });
}
beforeEach(() => {
plugin.slots = [slot]; plugin.errorMessage = null; plugin.connected = true;
plugin.slots = [slot]; plugin.errorMessage = null; plugin.connected = true; plugin.healthy = false; plugin.epoch = 0; plugin.handler = null;
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
get = vi.spyOn(api, "get").mockResolvedValue({ available: true });
list = vi.spyOn(heartbeatsApi, "liveRunsForIssue").mockResolvedValue([run]);
history = vi.spyOn(heartbeatsApi, "taskPanelActivations").mockImplementation(async () => plugin.slots.map(slot => ({ pluginKey: slot.pluginKey, slotId: slot.id })));
});
afterEach(async () => {
await act(async () => root.unmount()); client.clear(); container.remove(); vi.restoreAllMocks(); vi.useRealTimers();
@@ -46,7 +50,7 @@ afterEach(async () => {
it("uses the running agent's authorized status and existing plugin slot", async () => {
await render();
expect(list).toHaveBeenCalledWith("task");
expect(get).toHaveBeenCalledWith("/plugins/example-plugin/api/agents/agent/view/availability", { signal: expect.any(AbortSignal), cache: "no-store" });
expect(get).toHaveBeenCalledWith("/plugins/example-plugin/api/agents/agent/view/availability?runId=run&issueId=task", { signal: expect.any(AbortSignal), cache: "no-store" });
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
});
it("keeps unavailable panels hidden", async () => {
@@ -88,7 +92,7 @@ it("stops idle polling with live updates and resumes fallback polling on disconn
expect(list).toHaveBeenCalledTimes(3);
});
it("uses each plugin's own availability path and excludes only the failing contribution", async () => {
const second = { ...slot, pluginId: "other-plugin", pluginKey: "example.other-view", taskSidePanel: { availabilityPath: "/views/:entityId/ready" } };
const second = { ...slot, pluginId: "other-plugin", pluginKey: "example.other-view", taskSidePanel: { availabilityPath: "/views/:entityId/ready", activation: { namespace: "example.other-runtime", activityKey: "desktop.used" } } };
plugin.slots = [slot, second];
get.mockImplementation(async path => {
if (path.includes("other-plugin")) throw new Error("Unavailable");
@@ -96,5 +100,296 @@ it("uses each plugin's own availability path and excludes only the failing contr
});
await render();
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
expect(get).toHaveBeenCalledWith("/plugins/other-plugin/api/views/agent/ready", expect.anything());
expect(get).toHaveBeenCalledWith("/plugins/other-plugin/api/views/agent/ready?runId=run&issueId=task", expect.anything());
});
function event(overrides: Record<string, unknown> = {}): LiveEvent {
return { id: 1, companyId: "company", type: "heartbeat.run.event", createdAt: "2099-01-01T00:00:00.000Z",
payload: { runId: "run", agentId: "agent", issueId: "task", eventType: "runtime.activity.recorded",
payload: { prpEvent: { eventType: "runtime.activity.recorded", sourceKind: "runner", payload: { ...slot.taskSidePanel!.activation } } }, ...overrides } } as LiveEvent;
}
async function emit(value: LiveEvent) {
await act(async () => { plugin.handler?.(value); });
await act(async () => { await vi.advanceTimersByTimeAsync(30); });
}
it("does no availability polling or Launcher discovery until this run activates the panel", async () => {
vi.useFakeTimers(); history.mockResolvedValue([]);
await render(); expect(get).not.toHaveBeenCalled(); expect(history).toHaveBeenCalledTimes(1);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).not.toHaveBeenCalled(); expect(history).toHaveBeenCalledTimes(1);
for (const overrides of [{ runId: "other" }, { agentId: "other" }, { issueId: "other" }, { eventType: "item.completed" }]) await emit(event(overrides));
await emit({ ...event(), companyId: "other" });
await emit(event({ payload: { pluginKey: "example.other", slotId: "screen" } }));
for (const native of [
{ eventType: "runtime.activity.recorded", sourceKind: "control_plane", payload: { ...slot.taskSidePanel!.activation } },
{ eventType: "runtime.activity.recorded", sourceKind: "runner", payload: { namespace: "example.other-runtime", activityKey: "desktop.used" } },
{ eventType: "runtime.activity.recorded", sourceKind: "runner", payload: { namespace: "example.runtime", activityKey: "other.used" } },
]) await emit(event({ payload: { prpEvent: native } }));
expect(get).not.toHaveBeenCalled();
await emit(event());
expect(get).toHaveBeenCalledTimes(1); expect(current[0]?.runId).toBe("run");
await emit(event());
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(1);
});
it("restores an activation from durable run history after reload", async () => {
await render(); expect(current).toHaveLength(1);
expect(history).toHaveBeenCalledWith("run", { signal: expect.any(AbortSignal), cache: "no-store" });
});
it("reconciles activation history and availability after reconnect", async () => {
vi.useFakeTimers(); history.mockResolvedValue([]); await render(); expect(current).toEqual([]);
plugin.healthy = false; await render();
history.mockResolvedValue([{ pluginKey: slot.pluginKey, slotId: slot.id }]);
plugin.healthy = true; plugin.epoch++;
await render(); expect(current).toHaveLength(1);
});
it("does not lose an activation to an older in-flight history read", async () => {
vi.useFakeTimers(); let resolve!: (value: []) => void;
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
await render(); expect(get).not.toHaveBeenCalled();
await emit(event()); expect(current).toHaveLength(1);
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
expect(current).toHaveLength(1);
});
it("merges earlier panel history with a different panel activated during the read", async () => {
vi.useFakeTimers();
const second = { ...slot, id: "second-screen", taskSidePanel: { ...slot.taskSidePanel!, activation: { namespace: "example.runtime", activityKey: "other.used" } } };
plugin.slots = [slot, second];
let resolve!: (value: Array<{ pluginKey: string; slotId: string }>) => void;
history.mockReturnValueOnce(new Promise(done => { resolve = done; }));
await render();
expect(get).not.toHaveBeenCalled();
await emit(event({ payload: { prpEvent: {
eventType: "runtime.activity.recorded", sourceKind: "runner",
payload: { ...second.taskSidePanel.activation },
} } }));
expect(current.map(panel => panel.slot.id)).toEqual([second.id]);
await act(async () => {
resolve([{ pluginKey: slot.pluginKey, slotId: slot.id }]);
await vi.advanceTimersByTimeAsync(30);
});
expect(current.map(panel => panel.slot.id)).toEqual([slot.id, second.id]);
expect(history).toHaveBeenCalledTimes(1);
});
it("fails closed when activation history cannot be read", async () => {
history.mockRejectedValue(new Error("Access denied")); await render();
expect(get).not.toHaveBeenCalled(); expect(current).toEqual([]);
});
it("bounds readiness retries to three attempts after an activation", async () => {
vi.useFakeTimers(); get.mockResolvedValue({ available: false });
await render(); expect(get).toHaveBeenCalledTimes(1);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(3); expect(current).toEqual([]);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(3);
});
it.each(["not-ready", "server-error"])("restarts bounded readiness on fresh activity after %s", async failure => {
vi.useFakeTimers();
if (failure === "not-ready") get.mockResolvedValue({ available: false });
else get.mockRejectedValue(new ApiError("Unavailable", 500, {}));
await render();
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(3);
await emit(event());
await emit(event());
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(6);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(6);
get.mockResolvedValue({ available: true });
await emit(event());
expect(get).toHaveBeenCalledTimes(7); expect(current).toHaveLength(1);
});
it("does not retry denied readiness on fresh activity", async () => {
vi.useFakeTimers(); get.mockRejectedValue(new ApiError("Denied", 403, {}));
await render();
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(1);
get.mockResolvedValue({ available: true });
await emit(event());
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(1); expect(current).toEqual([]);
});
it.each(["runs", "history"])("recovers from a transient %s read without recurring polling", async source => {
vi.useFakeTimers();
const error = new ApiError("Unavailable", 500, {});
if (source === "runs") list.mockRejectedValueOnce(error);
else history.mockRejectedValueOnce(error);
await render();
expect(current).toEqual([]);
await act(async () => { await vi.advanceTimersByTimeAsync(1200); });
expect(current).toHaveLength(1);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(source === "runs" ? list : history).toHaveBeenCalledTimes(2);
});
it.each([500, 403])("bounds failed discovery reads for HTTP %s", async status => {
vi.useFakeTimers();
list.mockRejectedValue(new ApiError("Unavailable", status, {}));
await render();
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(list).toHaveBeenCalledTimes(status === 500 ? 3 : 1);
expect(history).not.toHaveBeenCalled();
expect(current).toEqual([]);
});
it("does not discover a task view without a declared activity mapping", async () => {
plugin.slots = [{ ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } }];
await render();
expect(list).not.toHaveBeenCalled(); expect(history).not.toHaveBeenCalled();
expect(get).not.toHaveBeenCalled(); expect(current).toEqual([]);
});
it("observes run lifecycle updates using the same UUID key as panel discovery", async () => {
await render({ ...issue, identifier: "DEMO-1" });
expect(useTaskRunLiveUpdates).toHaveBeenLastCalledWith({ companyId: "company", issueId: "task", issueQueryRef: "task", assigneeAgentId: "agent" });
});
it.each(["initial", "reconnect"])("reads again after pending history settles across %s subscription", async phase => {
vi.useFakeTimers();
let resolve!: (value: []) => void;
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
if (phase === "initial") plugin.healthy = true;
await render();
if (phase === "reconnect") { plugin.healthy = true; plugin.epoch++; await render(); }
expect(history).toHaveBeenCalledTimes(1);
// The first snapshot predates activity that arrived before subscription.
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
expect(history).toHaveBeenCalledTimes(2);
expect(list).toHaveBeenCalledTimes(1);
expect(current).toHaveLength(1);
});
it("discards pending subscription recovery after the task changes", async () => {
vi.useFakeTimers(); plugin.healthy = true;
let resolve!: (value: []) => void;
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
await render();
await render({ ...issue, id: "next-task" });
const reads = history.mock.calls.length;
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
expect(history).toHaveBeenCalledTimes(reads);
});
it.each(["not-ready", "server-error"])("reports unexpected failures only after bounded %s readiness checks", async failure => {
vi.useFakeTimers();
const reportError = vi.fn();
client = createAppQueryClient({ connectivity: { reportError: vi.fn(), getSnapshot: () => ({ status: "online" } as never) }, reportError });
if (failure === "not-ready") get.mockResolvedValue({ available: false });
else get.mockRejectedValue(new ApiError("Unexpected failure", 500, {}));
await render();
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(3);
expect(current).toEqual([]);
expect(reportError).toHaveBeenCalledTimes(failure === "not-ready" ? 0 : 1);
});
it.each(["1.0.0", "2.0.0"])("requires current activity after a mapping change at version %s", async pluginVersion => {
vi.useFakeTimers();
await render(); expect(current).toHaveLength(1);
const changed = { ...slot, pluginVersion, taskSidePanel: { ...slot.taskSidePanel!,
activation: { namespace: "example.runtime", activityKey: "new-view.used" } } };
history.mockResolvedValue([]); plugin.slots = [changed];
await render(); expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
await emit(event()); expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
plugin.healthy = true; plugin.epoch++; await render();
expect(current).toEqual([]);
await emit(event({ payload: { prpEvent: { eventType: "runtime.activity.recorded", sourceKind: "runner",
payload: changed.taskSidePanel.activation } } }));
expect(current).toEqual([{ entityId: "agent", runId: "run", slot: changed }]);
expect(get).toHaveBeenCalledTimes(2);
});
it("does not carry an old in-flight activation read into a changed declaration", async () => {
vi.useFakeTimers();
let resolve!: (value: Array<{ pluginKey: string; slotId: string }>) => void;
history.mockReturnValueOnce(new Promise(done => { resolve = done; }));
await render(); await emit(event()); expect(current).toHaveLength(1);
history.mockResolvedValue([]);
plugin.slots = [{ ...slot, pluginVersion: "2.0.0", taskSidePanel: { ...slot.taskSidePanel!,
activation: { namespace: "example.runtime", activityKey: "new-view.used" } } }];
await render(); expect(current).toEqual([]);
await act(async () => { resolve([{ pluginKey: slot.pluginKey, slotId: slot.id }]); await vi.advanceTimersByTimeAsync(30); });
expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
});
it("keeps an unchanged panel active while another declaration changes and history is pending", async () => {
vi.useFakeTimers(); client.setDefaultOptions({ queries: { retry: false, staleTime: 60_000 } });
const second = { ...slot, id: "other-screen", pluginKey: "example.other", pluginId: "other-plugin" };
plugin.slots = [slot, second];
await render(); expect(current).toHaveLength(2);
let resolve!: (value: []) => void;
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
const changed = { ...second, pluginVersion: "2.0.0", taskSidePanel: { ...second.taskSidePanel!,
activation: { namespace: "example.runtime", activityKey: "new-view.used" } } };
plugin.slots = [slot, changed];
await render();
expect(history).toHaveBeenCalledTimes(2);
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
await emit(event({ payload: { prpEvent: { eventType: "runtime.activity.recorded", sourceKind: "runner",
payload: changed.taskSidePanel.activation } } }));
expect(current.map(panel => panel.slot)).toEqual([slot, changed]);
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
expect(current.map(panel => panel.slot)).toEqual([slot, changed]);
});
it("does not seed another task's activation from cached declarations", async () => {
await render(); expect(current).toHaveLength(1);
history.mockResolvedValue([]);
await render({ ...issue, id: "other-task" });
expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
});
it("does not seed activations from a history read that lost access", async () => {
vi.useFakeTimers();
await render(); expect(current).toHaveLength(1);
history.mockRejectedValue(new ApiError("Denied", 403, {}));
await refresh(); expect(current).toEqual([]);
let resolve!: (value: []) => void;
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
plugin.slots = [slot, { ...slot, id: "other-screen", pluginKey: "example.other" }];
await render(); expect(current).toEqual([]);
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
expect(current).toEqual([]);
});
it("preserves activity when returning to an earlier cached declaration set", async () => {
vi.useFakeTimers();
const second = { ...slot, id: "other-screen", pluginKey: "example.other" };
plugin.slots = [slot, second]; history.mockResolvedValue([]);
await render(); expect(current).toEqual([]);
plugin.slots = [slot, { ...second, pluginVersion: "2.0.0" }];
await render(); await emit(event()); expect(current.map(panel => panel.slot.id)).toEqual([slot.id, second.id]);
let resolve!: (value: []) => void;
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
plugin.slots = [slot, second]; await render();
expect(current.map(panel => panel.slot.id)).toEqual([slot.id]);
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
expect(current.map(panel => panel.slot.id)).toEqual([slot.id]);
await act(async () => client.removeQueries({ predicate: query => String(query.queryKey[5]).includes("2.0.0") }));
await render(); expect(current.map(panel => panel.slot.id)).toEqual([slot.id]);
});
it.each(["route", "installation"])("rechecks readiness after a same-version %s change", async change => {
vi.useFakeTimers(); await render(); expect(current).toHaveLength(1);
get.mockResolvedValue({ available: false });
const changed = change === "route"
? { ...slot, taskSidePanel: { ...slot.taskSidePanel!, availabilityPath: "/views/:entityId/ready" } }
: { ...slot, pluginId: "replacement-plugin" };
plugin.slots = [changed]; await render();
expect(current).toEqual([]);
expect(get).toHaveBeenLastCalledWith(`/plugins/${changed.pluginId}/api${changed.taskSidePanel!.availabilityPath.replace(":entityId", "agent")}?runId=run&issueId=task`, expect.anything());
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(4); expect(current).toEqual([]);
get.mockResolvedValue({ available: true }); await emit(event());
expect(get).toHaveBeenCalledTimes(5); expect(current).toHaveLength(1);
});
+93 -18
View File
@@ -1,10 +1,11 @@
import { useMemo } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { api } from "@/api/client";
import { api, ApiError } from "@/api/client";
import { heartbeatsApi } from "@/api/heartbeats";
import { useHasCompanyLiveConnection } from "@/context/LiveUpdatesProvider";
import { liveRunRefetchInterval } from "@/lib/live-runs-cache";
import { useTaskRunLiveUpdates, useTranscriptLiveStream } from "@/context/LiveUpdatesProvider";
import { createLiveQueryRecovery } from "@/lib/live-query-recovery";
import { QueryNotReadyError } from "@/lib/query-client";
import { queryKeys } from "@/lib/queryKeys";
import { usePluginSlots, type ResolvedPluginSlot } from "@/plugins/slots";
@@ -22,33 +23,107 @@ export function taskPluginPanelId(panel: TaskPluginPanel) {
/** Plugins own availability and content; the host supplies the running agent. */
export function useTaskPluginPanels(issue: Issue | null | undefined, enabled = true): TaskPluginPanel[] {
const queryClient = useQueryClient();
const savedTask = enabled && !!issue && !issue.id.startsWith("chat:");
const connected = useHasCompanyLiveConnection(issue?.companyId);
const { slots, errorMessage } = usePluginSlots({
const { slots: declaredSlots, errorMessage } = usePluginSlots({
slotTypes: ["detailTab"], entityType: "agent", taskSidePanel: true,
companyId: issue?.companyId, enabled: savedTask,
});
const slots = useMemo(() => declaredSlots.filter(slot => slot.taskSidePanel?.activation), [declaredSlots]);
const connected = useTaskRunLiveUpdates({
companyId: savedTask && slots.length > 0 && !errorMessage ? issue?.companyId : null,
issueId: issue?.id, issueQueryRef: issue?.id ?? "pending", assigneeAgentId: issue?.assigneeAgentId,
});
const { data: runs, isError: runsError } = useQuery({
queryKey: queryKeys.issues.liveRuns(issue?.id ?? "pending"),
queryFn: () => heartbeatsApi.liveRunsForIssue(issue!.id),
enabled: savedTask && slots.length > 0 && !errorMessage,
refetchInterval: query => liveRunRefetchInterval(connected, query.state, 3000),
retry: false,
refetchInterval: connected ? false : 3000,
retry: (failures: number, error: Error) => failures < 2 && !(error instanceof ApiError && error.status >= 400 && error.status < 500),
retryDelay: 1000,
});
const run = runs?.find(run => run.status === "running"
&& (!issue?.executionRunId || run.id === issue.executionRunId));
const active = savedTask && slots.length > 0 && !!run && !runsError && !errorMessage;
const availability = useQueries({ queries: slots.map(slot => ({
queryKey: ["task-plugin-panel", issue?.companyId, issue?.id, run?.id, run?.agentId, slot.pluginKey, slot.id, slot.pluginVersion],
queryFn: ({ signal }: { signal: AbortSignal }) => api.get<{ available: boolean }>(
`/plugins/${encodeURIComponent(slot.pluginId)}/api${slot.taskSidePanel!.availabilityPath.replace(":entityId", encodeURIComponent(run!.agentId))}`,
{ signal, cache: "no-store" },
),
const declarations = slots.map(slot => [slot.pluginKey, slot.id, slot.pluginVersion,
slot.taskSidePanel!.activation!.namespace, slot.taskSidePanel!.activation!.activityKey]);
const activationScope = JSON.stringify(declarations);
const activationKey = ["task-plugin-panel-activations", issue?.companyId, issue?.id, run?.id, run?.agentId, activationScope];
// Keep unchanged declarations active while a new declaration scope loads.
const retained = new Map<string, { pluginKey: string; slotId: string }>();
for (const [key, panels] of queryClient.getQueriesData<Array<{ pluginKey: string; slotId: string }>>({ queryKey: activationKey.slice(0, 5) })) {
if (typeof key[5] !== "string" || queryClient.getQueryState(key)?.status !== "success") continue;
const unchanged = (JSON.parse(key[5]) as string[][]).filter(previous =>
declarations.some(current => current.every((part, index) => part === previous[index])));
for (const panel of panels ?? []) if (unchanged.some(declaration => declaration[0] === panel.pluginKey && declaration[1] === panel.slotId)) {
retained.set(JSON.stringify([panel.pluginKey, panel.slotId]), panel);
}
}
const { data: recordedActivations = [], isError: activationsError } = useQuery({
queryKey: activationKey,
queryFn: ({ signal }) => heartbeatsApi.taskPanelActivations(run!.id, { signal, cache: "no-store" }),
initialData: () => retained.size ? [...retained.values()] : undefined,
initialDataUpdatedAt: 0,
// Run activations only grow; merge history and live hints when committing cache data.
structuralSharing: (previous, incoming) => {
const merged = new Map(retained);
for (const panel of [...((previous ?? []) as Array<{ pluginKey: string; slotId: string }>), ...(incoming as Array<{ pluginKey: string; slotId: string }>)]) {
merged.set(JSON.stringify([panel.pluginKey, panel.slotId]), panel);
}
return [...merged.values()];
},
staleTime: 0,
enabled: active,
refetchInterval: 3000,
retry: false,
retry: (failures: number, error: Error) => failures < 2 && !(error instanceof ApiError && error.status >= 400 && error.status < 500),
retryDelay: 1000,
});
const activations = [...recordedActivations, ...retained.values()];
const stream = useTranscriptLiveStream(active ? [run!.id] : [], { companyId: issue?.companyId, enabled: active }, event => {
if (!active || !run || event.companyId !== issue?.companyId || event.payload.runId !== run.id
|| event.payload.agentId !== run.agentId || event.payload.issueId !== issue?.id
|| event.type !== "heartbeat.run.event" || event.payload.eventType !== "runtime.activity.recorded") return;
const native = event.payload.payload as { prpEvent?: { eventType?: string; sourceKind?: string; payload?: unknown } } | null;
if (native?.prpEvent?.eventType !== "runtime.activity.recorded" || native.prpEvent.sourceKind !== "runner") return;
const activity = native.prpEvent.payload as { namespace?: unknown; activityKey?: unknown } | null;
if (!activity || Object.keys(activity).length !== 2 || typeof activity.namespace !== "string" || typeof activity.activityKey !== "string") return;
const panels = slots.filter(slot => slot.taskSidePanel?.activation?.namespace === activity.namespace
&& slot.taskSidePanel?.activation?.activityKey === activity.activityKey)
.map(slot => ({ pluginKey: slot.pluginKey, slotId: slot.id }));
if (!panels.length) return;
queryClient.setQueryData<Array<{ pluginKey: string; slotId: string }>>(activationKey, previous => {
const missing = panels.filter(panel => !previous?.some(item => item.pluginKey === panel.pluginKey && item.slotId === panel.slotId));
return missing.length ? [...(previous ?? []), ...missing] : previous;
});
for (const panel of panels) void queryClient.invalidateQueries({
queryKey: ["task-plugin-panel", issue?.companyId, issue?.id, run.id, run.agentId, panel.pluginKey, panel.slotId],
predicate: ({ state }) => state.status === "error" && state.fetchStatus === "idle"
&& !(state.error instanceof ApiError && state.error.status >= 400 && state.error.status < 500),
});
});
useEffect(() => {
if (!active || !stream.healthy) return;
let current = true;
void queryClient.invalidateQueries({ queryKey: ["task-plugin-panel", issue?.companyId, issue?.id, run?.id, run?.agentId] });
// A pre-subscription history read must settle before the recovery read.
createLiveQueryRecovery(queryClient, () => current, { queryKey: activationKey, exact: true })();
return () => { current = false; };
}, [active, stream.healthy, stream.epoch, queryClient, issue?.companyId, issue?.id, run?.id, run?.agentId, activationScope]);
const availability = useQueries({ queries: slots.map(slot => ({
queryKey: ["task-plugin-panel", issue?.companyId, issue?.id, run?.id, run?.agentId, slot.pluginKey, slot.id, slot.pluginVersion, slot.pluginId, slot.taskSidePanel!.availabilityPath],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const result = await api.get<{ available: boolean }>(
`/plugins/${encodeURIComponent(slot.pluginId)}/api${slot.taskSidePanel!.availabilityPath.replace(":entityId", encodeURIComponent(run!.agentId))}?runId=${encodeURIComponent(run!.id)}&issueId=${encodeURIComponent(issue!.id)}`,
{ signal, cache: "no-store" },
);
if (result.available !== true) throw new QueryNotReadyError("Plugin panel is not ready");
return result;
},
enabled: active && !activationsError && activations.some(panel => panel.pluginKey === slot.pluginKey && panel.slotId === slot.id),
retry: (failures: number, error: Error) => failures < 2 && !(error instanceof ApiError && error.status >= 400 && error.status < 500),
retryDelay: 1000,
})) });
const available = availability.map(result => !result.isError && result.data?.available === true);
const available = availability.map((result, index) => !activationsError && !result.isError && result.data?.available === true
&& activations.some(panel => panel.pluginKey === slots[index]?.pluginKey && panel.slotId === slots[index]?.id));
const availableKey = available.join(",");
return useMemo(() => active && run ? slots.flatMap((slot, index) => available[index]
? [{ entityId: run.agentId, runId: run.id, slot }] : []) : EMPTY_PANELS,
+3 -3
View File
@@ -1,10 +1,10 @@
import type { Query, QueryClient } from "@tanstack/react-query";
import type { Query, QueryClient, QueryFilters } from "@tanstack/react-query";
export function createLiveQueryRecovery(client: QueryClient, isCurrent: () => boolean) {
export function createLiveQueryRecovery(client: QueryClient, isCurrent: () => boolean, filters: QueryFilters = {}) {
const waiting = new WeakSet<Query>();
return () => {
if (!isCurrent()) return;
for (const query of client.getQueryCache().findAll({ type: "active" })) {
for (const query of client.getQueryCache().findAll({ ...filters, type: "active" })) {
if (waiting.has(query)) continue;
const refresh = () => client.invalidateQueries({ queryKey: query.queryKey, exact: true }, { cancelRefetch: false });
if (query.state.fetchStatus !== "fetching") {
+2
View File
@@ -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);
+4 -1
View File
@@ -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 {