Files
PaperClipAI/ui/src/api/heartbeats.ts
T
Nicky LeachandPaperclip 35ab90ad0f 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>
2026-10-29 13:56:48 -07:00

266 lines
9.5 KiB
TypeScript

import type { AgentAppearance } from "@paperclipai/shared";
import { HEARTBEAT_RUN_LIST_DEFAULT_LIMIT } from "@paperclipai/shared";
import type { IssueRecoveryAction } from "@paperclipai/shared";
import type {
HeartbeatRun,
HeartbeatRunEvent,
WorkspaceOperation,
ProviderTraceFrame,
ProviderTraceMetadata,
} from "@paperclipai/shared";
import { tenantSessionRecovery } from "@/lib/tenant-session-recovery";
import { api, type RequestOptions } from "./client";
export interface RunLivenessFields {
livenessState: HeartbeatRun["livenessState"];
livenessReason: string | null;
continuationAttempt: number;
lastUsefulActionAt: string | Date | null;
nextAction: string | null;
}
export interface ActiveRunForIssue {
execution?: HeartbeatRun["execution"];
id: string;
runtimeMode?: "legacy" | "native";
wakeupRequestId?: string | null;
queuedCommentSourceWakeIds?: string[] | null;
status: string;
invocationSource: string;
triggerDetail: string | null;
contextCommentId?: string | null;
contextWakeCommentId?: string | null;
startedAt: string | Date | null;
finishedAt: string | Date | null;
createdAt: string | Date;
agentId: string;
agentName: string;
agentAppearance?: AgentAppearance | null;
avatarUrl?: string;
adapterType: string;
logBytes?: number | null;
lastOutputBytes?: number | null;
issueId?: string | null;
livenessState?: RunLivenessFields["livenessState"];
livenessReason?: string | null;
continuationAttempt?: number;
lastUsefulActionAt?: string | Date | null;
nextAction?: string | null;
outputSilence?: HeartbeatRun["outputSilence"];
currentStatusMessage?: string | null;
currentStatusUpdatedAt?: string | Date | null;
currentToolName?: string | null;
lastAssistantSnippet?: string | null;
lastEventAt?: string | Date | null;
}
export interface LiveRunForIssue {
execution?: HeartbeatRun["execution"];
id: string;
runtimeMode?: "legacy" | "native";
wakeupRequestId?: string | null;
queuedCommentSourceWakeIds?: string[] | null;
status: string;
invocationSource: string;
triggerDetail: string | null;
contextCommentId?: string | null;
contextWakeCommentId?: string | null;
startedAt: string | null;
finishedAt: string | null;
createdAt: string;
agentId: string;
agentName: string;
agentAppearance?: AgentAppearance | null;
avatarUrl?: string;
adapterType: string;
logBytes?: number | null;
lastOutputBytes?: number | null;
issueId?: string | null;
livenessState?: RunLivenessFields["livenessState"];
livenessReason?: string | null;
continuationAttempt?: number;
lastUsefulActionAt?: string | null;
nextAction?: string | null;
outputSilence?: HeartbeatRun["outputSilence"];
currentStatusMessage?: string | null;
currentStatusUpdatedAt?: string | null;
currentToolName?: string | null;
lastAssistantSnippet?: string | null;
lastEventAt?: string | null;
}
export interface WatchdogDecisionInput {
runId: string;
decision: "snooze" | "continue" | "dismissed_false_positive";
evaluationIssueId?: string | null;
reason?: string | null;
snoozedUntil?: string | null;
}
export type RuntimeRequestKind =
| "runtime"
| "command_approval"
| "file_approval"
| "permission_approval"
| "user_input"
| "elicitation";
export type RuntimeRequestResolution =
| { action: "accept" | "accept_for_session" | "decline" | "cancel" }
| { action: "submit"; answers: Record<string, { answers: string[] }> }
| { action: "submit"; content: Record<string, unknown> }
| { action: "submit"; response: import("@paperclipai/adapter-utils").PaperclipQuestionResponse };
export interface HeartbeatRunListOptions {
latestFailuresOnly?: boolean;
summary?: boolean;
}
export interface ProviderTraceInspection {
trace: ProviderTraceMetadata | null;
entries: Array<Record<string, unknown>>;
}
export const heartbeatsApi = {
executionForIssue: (issueId: string) => api.get<{ runId: string; agentId: string; recoveryAction: IssueRecoveryAction | null; execution: HeartbeatRun["execution"] } | null>(`/issues/${issueId}/execution`),
list: (
companyId: string,
agentId?: string,
limit = HEARTBEAT_RUN_LIST_DEFAULT_LIMIT,
options: HeartbeatRunListOptions = {},
) => {
const searchParams = new URLSearchParams();
if (agentId) searchParams.set("agentId", agentId);
if (limit) searchParams.set("limit", String(limit));
if (options.summary) searchParams.set("summary", "true");
if (options.latestFailuresOnly) searchParams.set("latestFailuresOnly", "true");
const qs = searchParams.toString();
return api.get<HeartbeatRun[]>(
`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`,
);
},
get: (runId: string) => api.get<HeartbeatRun>(`/heartbeat-runs/${runId}`),
conversationEvents: (runId: string, afterSeq = 0, limit = 200, options?: RequestOptions) =>
api.get<HeartbeatRunEvent[]>(
`/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))}`,
options,
),
log: (runId: string, offset = 0, limitBytes = 256000, options?: RequestOptions & { alignStartToRecord?: boolean }) =>
api.get<{
runId: string;
store: string;
logRef: string;
content: string;
nextOffset?: number;
}>(
`/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}${options?.alignStartToRecord ? "&alignStartToRecord=true" : ""}`,
options,
),
workspaceOperations: (runId: string) =>
api.get<WorkspaceOperation[]>(
`/heartbeat-runs/${runId}/workspace-operations`,
),
providerTrace: (runId: string) =>
api.get<ProviderTraceInspection>(`/heartbeat-runs/${runId}/provider-trace`),
providerTraceMetadata: (companyId: string, runIds: string[]) => {
const params = new URLSearchParams({ runIds: runIds.slice(0, 100).join(",") });
return api.get<ProviderTraceMetadata[]>(
`/companies/${companyId}/provider-traces?${params.toString()}`,
);
},
revealProviderTraceFrame: (runId: string, frameId: number) =>
api.post<ProviderTraceFrame>(
`/heartbeat-runs/${runId}/provider-trace/frames/${frameId}/reveal`,
{},
),
deleteProviderTrace: (runId: string) =>
api.delete<{ ok: true }>(`/heartbeat-runs/${runId}/provider-trace`),
downloadProviderTrace: async (runId: string): Promise<Blob> => {
const response = await fetch(
`/api/heartbeat-runs/${runId}/provider-trace/download`,
{
credentials: "include",
headers: { Accept: "application/x-ndjson" },
},
);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
error?: string;
} | null;
const recovery = tenantSessionRecovery.recoverIfNeeded(response.status, body);
if (recovery) return recovery;
throw new Error(
body?.error ?? `Trace download failed: ${response.status}`,
);
}
return response.blob();
},
workspaceOperationLog: (
operationId: string,
offset = 0,
limitBytes = 256000,
) =>
api.get<{
operationId: string;
store: string;
logRef: string;
content: string;
nextOffset?: number;
}>(
`/workspace-operations/${operationId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
),
cancel: (runId: string) =>
api.post<void>(`/heartbeat-runs/${runId}/cancel`, {}),
resolveRuntimeRequest: (input: {
runId: string;
requestId: string;
turnId: string;
requestKind: RuntimeRequestKind;
resolution: RuntimeRequestResolution;
}) =>
api.post<{ accepted: true; commandId: string }>(
`/heartbeat-runs/${input.runId}/runtime-requests/${encodeURIComponent(input.requestId)}/resolve`,
{
turnId: input.turnId,
requestKind: input.requestKind,
resolution: input.resolution,
},
),
recordWatchdogDecision: (input: WatchdogDecisionInput) =>
api.post(`/heartbeat-runs/${input.runId}/watchdog-decisions`, {
decision: input.decision,
evaluationIssueId: input.evaluationIssueId ?? null,
reason: input.reason ?? null,
snoozedUntil: input.snoozedUntil ?? null,
}),
liveRunsForIssue: (issueId: string) =>
api.get<LiveRunForIssue[]>(`/issues/${issueId}/live-runs`),
activeRunForIssue: (issueId: string) =>
api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`),
liveRunsForCompany: (
companyId: string,
options?: number | { minCount?: number; limit?: number; distinctTasks?: boolean },
) => {
const searchParams = new URLSearchParams();
if (typeof options === "number") {
searchParams.set("minCount", String(options));
} else if (options) {
if (options.minCount)
searchParams.set("minCount", String(options.minCount));
if (options.limit) searchParams.set("limit", String(options.limit));
if (options.distinctTasks) searchParams.set("distinctTasks", "true");
}
const qs = searchParams.toString();
return api.get<LiveRunForIssue[]>(
`/companies/${companyId}/live-runs${qs ? `?${qs}` : ""}`,
);
},
};