mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## Thinking Path > - Paperclip manages agents and tasks. > - Plugins contribute views to the task sidebar. > - A ready view can belong to an earlier task. > - Runner records activity under its authenticated run. > - This PR maps that activity to installed task-panel declarations. > - The sidebar checks readiness after current-run use and restores use after reconnect. ## Linked Issues or Issue Description Builds on merged contract #18129 and Runner publisher #18050. This PR targets master and is ready for review. Earlier combined verification is retained in [the verification archive](https://github.com/paperclipai/paperclip/pull/18050#issuecomment-6489369676). **Subsystem affected** Run-event reads, plugin task contributions and the task sidebar. **Problem or motivation** Readiness polling can show a view left ready by another task. Reload and reconnect must recover current-run use without losing concurrent live activity. **Proposed solution** Map recorded activity to installed task-panel declarations. Merge authorized history and live activity, then perform bounded readiness checks for the running agent. Preserve the existing read-only view. ## What Changed - Add optional `{ namespace, activityKey }` activation mappings to task-panel declarations. Legacy declarations remain valid but contribute no task panel. - Add `view=plugin-panels` to the existing run-events route. Preserve run-read and telemetry-read authorization, secret redaction and private-run denial. Map activity to installed contributions and return at most 128 panel identities. - Scope activation caches to current panel versions and activity declarations. Preserve unchanged panels during unrelated plugin updates and history reads. - Merge history and live hints without losing concurrent activations. Filter by company, task, run and agent. Restore history on mount and reconnect. - Replace periodic availability checks with up to three readiness attempts after activity. Fresh activity restarts settled transient failures. Pending checks, successful readiness and HTTP 4xx failures are left alone. - Reuse live run updates with the panel cache key. Recover only panel history after a pending subscription read. Mark expected not-ready query results so the global error reporter skips them; real failures remain reportable. - Preserve current-run dismissal and the read-only plugin view. Update manifest comments and producer/consumer documentation. ## Verification - Current head: `5e04d757c`, based on master `09c05c550`. Range comparison confirmed all six feature commits were unchanged by the rebase. The final follow-up keys readiness by the plugin installation ID and availability path. - `cd ui && node ../node_modules/vitest/vitest.mjs run src/hooks/useTaskPluginPanels.test.tsx` — all 45 cases passed. `cd ui && node ../node_modules/typescript/bin/tsc -b` passed. - The shared query-client and live-update recovery selection passed 96 cases on `49c544cd2`. Token gates passed before the final hook-only changes. - Earlier focused verification passed 57 manifest cases, 69 database/history cases, event-registry cases and route authorization cases, including private-run denial. - Regression coverage includes subscription/history races, task changes during recovery, unfocused run completion, bounded readiness retries, denied reads, expected not-ready responses, changed activity mappings, unchanged panel preservation, old cache scopes, and same-version route or installation changes. - [Current-head CI](https://github.com/paperclipai/paperclip/actions/runs/40942001707) passed: 54 successful checks, two skips, no failures or pending checks. - [Standard and final review](https://github.com/paperclipai/paperclip/pull/18131#issuecomment-6489842168) both scored 5/5 on `5e04d757c`. The final review used the Apex tier, added no comments, and left no unresolved findings. The PR is conflict-free and ready for review. - Local checks stay targeted. GitHub CI provides the broad build, test, typecheck, browser and Runner coverage. No deployment or live-provider acceptance is claimed. <details> <summary>Retained failures and recovery evidence</summary> - `ac9329548` twice failed a browser reload assertion while conversation history was concealed. Master fixed its readiness wait in #17923. The approve journey then passed locally on `fea63a418` with installed Chrome (1 passed, 39 seconds). All eight browser shards passed in later CI. The browser test is outside this diff. - A canary checkout failed with Git HTTP 408; its unchanged-head retry passed. - `de07d09fa` failed the mobile Cases card click after a ten-second stability wait. The same read-only journey passed locally on that exact head (1 passed, 26 seconds). Its unchanged-head CI retry passed 22 cases, with one skip. The original artifact is retained; the cause of that click timeout is not established. - [CI on `49c544cd2`](https://github.com/paperclipai/paperclip/actions/runs/40903679075) passed 54 checks with two skips. CI also passed on `de07d09fa` after the recorded retry. - The `1e59ab441` merge checkout passed all browser and server suites. Runner shard 2 passed 2,252 cases and timed out waiting for a controller-recovery command. The test had landed on master in #18161 after the former branch base. Rebasing aligned the local checkout with that code; Runner shard 2 then passed on `fcf7380d3`. The original timeout cause remains unproven; no unrelated timeout was changed. - [CI on `fcf7380d3`](https://github.com/paperclipai/paperclip/actions/runs/40940235193) recorded runner shutdown signals in typecheck and a workspace shard. A chat recovery test observed a null wake deadline, and one Runner transport fixture failed during close. Those separate assertion failures remain unexplained. All of those jobs passed on the final readiness-key head. Those later passes establish recovery; they do not prove the cause of the earlier assertion failures. - Local browser setup first stopped at pnpm version-download verification, then a sandbox IPC restriction, then a missing bundled browser. Installed pnpm 9.15.4 and Chrome completed the journeys. No dependency files were changed. - Earlier optional-property TypeScript errors were corrected. Embedded PostgreSQL needed its install hook run from its package directory. Older CI also recorded fixture-cleanup lock timeouts, attention-fixture timeouts, and canceled server shards after runner shutdown. The parent fixed its FIFO snapshot-copy failure before merge. The linked verification archive retains earlier combined evidence. </details> ## Risks Activity is a hint and grants no access. Missing plugins, denied reads and unavailable views contribute no panel. Older runs without activity records show no task plugin tab. A used view stays available for the running task; reconnect reconciles readiness. Matching producer and consumer builds are required. No migration, dependency, image publication or deployment is included. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository tools and code execution. The exact serving snapshot is not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
132 lines
8.6 KiB
TypeScript
132 lines
8.6 KiB
TypeScript
import { useEffect, useMemo } from "react";
|
|
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { Issue } from "@paperclipai/shared";
|
|
import { api, ApiError } from "@/api/client";
|
|
import { heartbeatsApi } from "@/api/heartbeats";
|
|
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";
|
|
|
|
export interface TaskPluginPanel {
|
|
entityId: string;
|
|
runId: string;
|
|
slot: ResolvedPluginSlot;
|
|
}
|
|
|
|
const EMPTY_PANELS: TaskPluginPanel[] = [];
|
|
|
|
export function taskPluginPanelId(panel: TaskPluginPanel) {
|
|
return `plugin:${panel.slot.pluginKey}:${panel.slot.id}:${panel.entityId}`;
|
|
}
|
|
|
|
/** 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 { 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: 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 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,
|
|
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, 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,
|
|
[active, run?.agentId, run?.id, slots, availableKey]);
|
|
}
|