Files
paperclip/ui/src/api/heartbeats.ts
T
Dotta 3cd26a78fc [codex] Surface live run comment context (#4957)
## Thinking Path

> - Paperclip orchestrates AI agents through issue comments and
heartbeat runs
> - The board UI needs to distinguish a comment that triggered a live
run from comments queued after that run started
> - The run payload already stores comment context, but active-run API
responses did not expose the ids the UI needs
> - Without those ids, the triggering comment can flash as queued while
the agent is already responding to it
> - This pull request exposes live-run comment context and teaches the
optimistic comment helper to ignore the trigger comment
> - The benefit is clearer issue-chat state during comment-triggered
agent interruptions

## What Changed

- Added `contextCommentId` and `contextWakeCommentId` to active/live run
payloads.
- Threaded those ids through server routes, heartbeat summaries, UI API
types, and issue detail rendering.
- Updated optimistic comment classification to avoid marking the
triggering comment as queued.
- Added server and UI regression coverage.

## Verification

- `pnpm exec vitest run
server/src/__tests__/agent-live-run-routes.test.ts
ui/src/lib/optimistic-issue-comments.test.ts`

## Risks

- Low-to-medium risk: adds optional fields to existing run payloads.
Existing consumers should ignore unknown fields, and UI handling is
null-safe.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool use and local command
execution. Exact context window was not exposed in the 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-01 10:44:11 -05:00

125 lines
4.7 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"];
}
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"];
}
export interface WatchdogDecisionInput {
runId: string;
decision: "snooze" | "continue" | "dismissed_false_positive";
evaluationIssueId?: string | null;
reason?: string | null;
snoozedUntil?: string | null;
}
export const heartbeatsApi = {
list: (companyId: string, agentId?: string, limit?: number) => {
const searchParams = new URLSearchParams();
if (agentId) searchParams.set("agentId", agentId);
if (limit) searchParams.set("limit", String(limit));
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"),
};