Files
PaperClipAI/ui/src/api/heartbeats.ts
T
Devin FoleyandPaperclip c48feee190 Improve live agent feedback during sandboxed runs (#8915)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A core part of that experience is watching active agent runs without
dropping into raw logs first
> - Local and sandbox-backed adapters already record useful run output,
progress, and tool activity
> - But active issue threads could sit visually stale while the agent
was syncing workspaces, tailing sandbox output, or emitting incremental
tool-call updates
> - Operators need timely, human-readable progress while preserving the
raw transcript underneath
> - This pull request streams sandbox run-log progress into runtime
status, keeps visible issue threads refreshed, and folds repeated ACPX
tool updates into stable transcript cards
> - The benefit is that long-running agent work becomes easier to
supervise without changing the task/comment control-plane model

## Linked Issues or Issue Description

No public GitHub issue exists for this exact change.

Problem/motivation:

- During long-running sandboxed agent work, the issue UI can appear idle
even though the agent is actively syncing, running tools, or producing
incremental output.
- Operators need realtime feedback at the issue-thread layer, not only
after opening raw logs or waiting for the final heartbeat result.
- Related public context: #1808 previously added live-run status dots to
Projects; #4362 touches heartbeat wakeup behavior but is not a duplicate
of this runtime/UI feedback change.

## What Changed

- Added sandbox run-log streaming support and defaulted sandbox-capable
local adapters into the richer live-feedback path.
- Surfaced environment/sandbox sync progress through heartbeat runtime
status with bounded, redacted snippets.
- Added live issue-thread cache patching so visible active runs update
as progress events arrive.
- Folded repeated ACPX `tool_call` updates into one transcript card
instead of stacking duplicate cards.
- Updated adapter docs and added focused regression coverage for sandbox
log streaming, runtime status, ACPX parsing, live updates, transcript
rendering, and issue chat messages.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run ui/src/context/LiveUpdatesProvider.test.ts`
- `pnpm exec vitest run
server/src/services/heartbeat-run-runtime-status.test.ts
server/src/__tests__/heartbeat-runtime-state.test.ts
ui/src/context/LiveUpdatesProvider.test.ts`
- `pnpm exec vitest run
packages/adapter-utils/src/execution-target-sandbox.test.ts
packages/adapter-utils/src/sandbox-managed-runtime.test.ts
server/src/services/heartbeat-run-runtime-status.test.ts
server/src/__tests__/agent-live-run-routes.test.ts
server/src/__tests__/heartbeat-runtime-state.test.ts
packages/adapters/acpx-local/src/ui/parse-stdout.test.ts
ui/src/context/LiveUpdatesProvider.test.ts
ui/src/components/transcript/RunTranscriptView.test.tsx
ui/src/lib/issue-chat-messages.test.ts
ui/src/components/IssueChatThread.test.tsx`
- GitHub PR workflow on head `8397953e7b41ccd42e5d9457ee7e4dfb996e4ec5`:
`verify`, build, typecheck/release-registry, e2e, general shards,
serialized server shards, and canary dry run passed.
- Greptile Review on head `8397953e7b41ccd42e5d9457ee7e4dfb996e4ec5`:
Confidence Score 5/5, no unresolved review threads.

## Risks

- Live issue-thread cache patching could miss an edge case for a route
shape not covered by tests.
- Surfacing active-run snippets needs continued care around redaction;
this PR keeps snippets bounded and adds redaction-focused coverage.
- More frequent active-run UI refreshes could expose performance issues
on very large issue threads, though updates are scoped to visible
run/query caches.

## Model Used

OpenAI GPT-5 via Codex, operating as a tool-enabled coding agent with
shell, git, and repository-editing capabilities. Context window size is
not exposed in this runtime.

## 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-07-02 22:21:56 -07:00

140 lines
5.2 KiB
TypeScript

import type {
HeartbeatRun,
HeartbeatRunEvent,
InstanceSchedulerHeartbeatAgent,
WorkspaceOperation,
} from "@paperclipai/shared";
import { api } from "./client";
export interface RunLivenessFields {
livenessState: HeartbeatRun["livenessState"];
livenessReason: string | null;
continuationAttempt: number;
lastUsefulActionAt: string | Date | null;
nextAction: string | null;
}
export interface ActiveRunForIssue {
id: string;
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;
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 {
id: string;
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;
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 interface HeartbeatRunListOptions {
summary?: boolean;
}
export const heartbeatsApi = {
list: (companyId: string, agentId?: string, limit?: number, 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");
const qs = searchParams.toString();
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`);
},
get: (runId: string) => api.get<HeartbeatRun>(`/heartbeat-runs/${runId}`),
events: (runId: string, afterSeq = 0, limit = 200) =>
api.get<HeartbeatRunEvent[]>(
`/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
),
log: (runId: string, offset = 0, limitBytes = 256000) =>
api.get<{ runId: string; store: string; logRef: string; content: string; nextOffset?: number }>(
`/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
),
workspaceOperations: (runId: string) =>
api.get<WorkspaceOperation[]>(`/heartbeat-runs/${runId}/workspace-operations`),
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`, {}),
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 },
) => {
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));
}
const qs = searchParams.toString();
return api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs${qs ? `?${qs}` : ""}`);
},
listInstanceSchedulerAgents: () =>
api.get<InstanceSchedulerHeartbeatAgent[]>("/instance/scheduler-heartbeats"),
};