mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +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>
266 lines
9.5 KiB
TypeScript
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}` : ""}`,
|
|
);
|
|
},
|
|
};
|