Files
paperclip/ui/src/api/issues.ts
T
Dotta 57229d0f24 [codex] Add issue monitor liveness controls (#4988)
## Thinking Path

> - Paperclip is a control plane for autonomous AI companies where work
must stay observable, governable, and recoverable.
> - The task/heartbeat subsystem owns agent execution continuity, issue
state transitions, and visible recovery behavior.
> - Waiting on an external service is not the same as being blocked when
the assignee still owns a future check.
> - The gap was that agents had no first-class one-shot monitor state
for external-service waits, so recovery could look stalled or require ad
hoc comments.
> - This pull request adds bounded issue monitors that can wake the
owner, clear exhausted waits, and produce explicit recovery behavior.
> - It also surfaces monitor status in the board UI and documents when
to use monitors versus `blocked`.
> - The benefit is clearer liveness semantics for asynchronous waits
without weakening single-assignee task ownership.

## What Changed

- Added issue monitor fields, shared types, validators, constants, and
an idempotent `0075` migration for scheduled monitor state.
- Added server-side monitor scheduling, dispatch, recovery bounds,
activity logging, and external-ref redaction.
- Added board/agent route coverage for monitor permissions and child
monitor scheduling.
- Added issue detail/property UI for monitor state, a monitor activity
card, and Storybook stories for review surfaces.
- Documented monitor semantics and recovery policy behavior in
`doc/execution-semantics.md`.
- Addressed Greptile review feedback by preserving monitor state in
skipped-stage builders and making board monitor saves send `scheduledBy:
"board"`.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
server/src/__tests__/issue-execution-policy-routes.test.ts
server/src/__tests__/issue-execution-policy.test.ts
server/src/__tests__/issue-monitor-scheduler.test.ts
server/src/__tests__/recovery-classifiers.test.ts
ui/src/components/IssueMonitorActivityCard.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/lib/activity-format.test.ts`
- First run passed 5 files and failed to collect 2 server suites because
the worktree was missing the optional `acpx/runtime` dependency.
- After `pnpm install --frozen-lockfile`, reran the 2 failed suites
successfully.
- `pnpm exec vitest run
server/src/__tests__/issue-monitor-scheduler.test.ts
server/src/__tests__/recovery-classifiers.test.ts`
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/db typecheck && pnpm --filter @paperclipai/server typecheck
&& pnpm --filter @paperclipai/ui typecheck`
- `pnpm exec vitest run
server/src/__tests__/issue-execution-policy.test.ts
ui/src/components/IssueProperties.test.tsx`
- `pnpm --filter @paperclipai/server typecheck && pnpm --filter
@paperclipai/ui typecheck`
- `pnpm exec vitest run
ui/src/components/IssueMonitorActivityCard.test.tsx
ui/src/components/IssueProperties.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- Storybook screenshot captured from
`http://127.0.0.1:6006/iframe.html?viewMode=story&id=product-issue-monitor-surfaces--monitor-surfaces`
with Playwright.

## Screenshots

![Issue monitor Storybook
surfaces](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-2945-when-a-task-is-waiting-for-an-_external-service_-what-state-should-it-be-in-and-what-recovery-method-could-it-h/docs/pr-screenshots/pap-2945/monitor-surfaces.png)

## Risks

- Medium: this changes heartbeat recovery behavior for scheduled
external-service waits, so regressions could affect wake timing or
recovery issue creation.
- Migration risk is reduced by using `IF NOT EXISTS` for the new issue
monitor columns and index.
- External monitor references are treated as secret-adjacent and are
intentionally omitted from visible activity/wake payloads.

> 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 with repository tool use and terminal
execution.

## 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
- [x] If this change affects the UI, I have included before/after
screenshots or Storybook review surfaces
- [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-03 08:58:53 -05:00

246 lines
11 KiB
TypeScript

import type {
AskUserQuestionsAnswer,
Approval,
CreateIssueTreeHold,
DocumentRevision,
FeedbackTargetType,
FeedbackTrace,
FeedbackVote,
Issue,
IssueAttachment,
IssueCostSummary,
IssueComment,
IssueDocument,
IssueLabel,
IssueThreadInteraction,
IssueTreeControlPreview,
IssueTreeHold,
IssueWorkProduct,
PreviewIssueTreeControl,
ReleaseIssueTreeHold,
UpsertIssueDocument,
} from "@paperclipai/shared";
import { api } from "./client";
export type IssueUpdateResponse = Issue & {
comment?: IssueComment | null;
};
export const issuesApi = {
list: (
companyId: string,
filters?: {
status?: string;
projectId?: string;
parentId?: string;
assigneeAgentId?: string;
participantAgentId?: string;
assigneeUserId?: string;
touchedByUserId?: string;
inboxArchivedByUserId?: string;
unreadForUserId?: string;
labelId?: string;
workspaceId?: string;
executionWorkspaceId?: string;
originKind?: string;
originId?: string;
descendantOf?: string;
includeRoutineExecutions?: boolean;
includeBlockedBy?: boolean;
q?: string;
limit?: number;
offset?: number;
},
) => {
const params = new URLSearchParams();
if (filters?.status) params.set("status", filters.status);
if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.parentId) params.set("parentId", filters.parentId);
if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
if (filters?.inboxArchivedByUserId) params.set("inboxArchivedByUserId", filters.inboxArchivedByUserId);
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
if (filters?.labelId) params.set("labelId", filters.labelId);
if (filters?.workspaceId) params.set("workspaceId", filters.workspaceId);
if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId);
if (filters?.originKind) params.set("originKind", filters.originKind);
if (filters?.originId) params.set("originId", filters.originId);
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
if (filters?.q) params.set("q", filters.q);
if (filters?.limit) params.set("limit", String(filters.limit));
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
const qs = params.toString();
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
},
listLabels: (companyId: string) => api.get<IssueLabel[]>(`/companies/${companyId}/labels`),
createLabel: (companyId: string, data: { name: string; color: string }) =>
api.post<IssueLabel>(`/companies/${companyId}/labels`, data),
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
get: (id: string) => api.get<Issue>(`/issues/${id}`),
markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}),
markUnread: (id: string) => api.delete<{ id: string; removed: boolean }>(`/issues/${id}/read`),
archiveFromInbox: (id: string) =>
api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}),
unarchiveFromInbox: (id: string) =>
api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Issue>(`/companies/${companyId}/issues`, data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<IssueUpdateResponse>(`/issues/${id}`, data),
previewTreeControl: (id: string, data: PreviewIssueTreeControl) =>
api.post<IssueTreeControlPreview>(`/issues/${id}/tree-control/preview`, data),
createTreeHold: (id: string, data: CreateIssueTreeHold) =>
api.post<{ hold: IssueTreeHold; preview: IssueTreeControlPreview }>(`/issues/${id}/tree-holds`, data),
getTreeHold: (id: string, holdId: string) =>
api.get<IssueTreeHold>(`/issues/${id}/tree-holds/${holdId}`),
listTreeHolds: (
id: string,
filters?: {
status?: "active" | "released";
mode?: "pause" | "resume" | "cancel" | "restore";
includeMembers?: boolean;
},
) => {
const params = new URLSearchParams();
if (filters?.status) params.set("status", filters.status);
if (filters?.mode) params.set("mode", filters.mode);
if (filters?.includeMembers) params.set("includeMembers", "true");
const qs = params.toString();
return api.get<IssueTreeHold[]>(`/issues/${id}/tree-holds${qs ? `?${qs}` : ""}`);
},
getTreeControlState: (id: string) =>
api.get<{
activePauseHold: {
holdId: string;
rootIssueId: string;
issueId: string;
isRoot: boolean;
mode: "pause";
reason: string | null;
releasePolicy: { strategy: "manual" | "after_active_runs_finish"; note?: string | null } | null;
} | null;
}>(`/issues/${id}/tree-control/state`),
releaseTreeHold: (id: string, holdId: string, data: ReleaseIssueTreeHold) =>
api.post<IssueTreeHold>(`/issues/${id}/tree-holds/${holdId}/release`, data),
checkMonitorNow: (id: string) => api.post<{ ok: true }>(`/issues/${id}/monitor/check-now`, {}),
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
checkout: (id: string, agentId: string) =>
api.post<Issue>(`/issues/${id}/checkout`, {
agentId,
expectedStatuses: ["todo", "backlog", "blocked", "in_review"],
}),
release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}),
listComments: (
id: string,
filters?: {
after?: string;
order?: "asc" | "desc";
limit?: number;
},
) => {
const params = new URLSearchParams();
if (filters?.after) params.set("after", filters.after);
if (filters?.order) params.set("order", filters.order);
if (filters?.limit) params.set("limit", String(filters.limit));
const qs = params.toString();
return api.get<IssueComment[]>(`/issues/${id}/comments${qs ? `?${qs}` : ""}`);
},
listInteractions: (id: string) =>
api.get<IssueThreadInteraction[]>(`/issues/${id}/interactions`),
createInteraction: (id: string, data: Record<string, unknown>) =>
api.post<IssueThreadInteraction>(`/issues/${id}/interactions`, data),
acceptInteraction: (
id: string,
interactionId: string,
data?: { selectedClientKeys?: string[] },
) =>
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}),
cancelInteraction: (id: string, interactionId: string, reason?: string) =>
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/cancel`, reason ? { reason } : {}),
respondToInteraction: (
id: string,
interactionId: string,
data: { answers: AskUserQuestionsAnswer[]; summaryMarkdown?: string | null },
) =>
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/respond`, data),
getComment: (id: string, commentId: string) =>
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
getCostSummary: (id: string) => api.get<IssueCostSummary>(`/issues/${id}/cost-summary`),
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters ?? {})) {
if (value === undefined) continue;
params.set(key, String(value));
}
const qs = params.toString();
return api.get<FeedbackTrace[]>(`/issues/${id}/feedback-traces${qs ? `?${qs}` : ""}`);
},
upsertFeedbackVote: (
id: string,
data: {
targetType: FeedbackTargetType;
targetId: string;
vote: "up" | "down";
reason?: string;
allowSharing?: boolean;
},
) => api.post<FeedbackVote>(`/issues/${id}/feedback-votes`, data),
addComment: (id: string, body: string, reopen?: boolean, interrupt?: boolean) =>
api.post<IssueComment>(
`/issues/${id}/comments`,
{
body,
...(reopen === undefined ? {} : { reopen }),
...(interrupt === undefined ? {} : { interrupt }),
},
),
cancelComment: (id: string, commentId: string) =>
api.delete<IssueComment>(`/issues/${id}/comments/${commentId}`),
listDocuments: (id: string, options?: { includeSystem?: boolean }) =>
api.get<IssueDocument[]>(
`/issues/${id}/documents${options?.includeSystem ? "?includeSystem=true" : ""}`,
),
getDocument: (id: string, key: string) => api.get<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
upsertDocument: (id: string, key: string, data: UpsertIssueDocument) =>
api.put<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`, data),
listDocumentRevisions: (id: string, key: string) =>
api.get<DocumentRevision[]>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`),
restoreDocumentRevision: (id: string, key: string, revisionId: string) =>
api.post<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions/${revisionId}/restore`, {}),
deleteDocument: (id: string, key: string) =>
api.delete<{ ok: true }>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
listAttachments: (id: string) => api.get<IssueAttachment[]>(`/issues/${id}/attachments`),
uploadAttachment: (
companyId: string,
issueId: string,
file: File,
issueCommentId?: string | null,
) => {
const form = new FormData();
form.append("file", file);
if (issueCommentId) {
form.append("issueCommentId", issueCommentId);
}
return api.postForm<IssueAttachment>(`/companies/${companyId}/issues/${issueId}/attachments`, form);
},
deleteAttachment: (id: string) => api.delete<{ ok: true }>(`/attachments/${id}`),
listApprovals: (id: string) => api.get<Approval[]>(`/issues/${id}/approvals`),
linkApproval: (id: string, approvalId: string) =>
api.post<Approval[]>(`/issues/${id}/approvals`, { approvalId }),
unlinkApproval: (id: string, approvalId: string) =>
api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`),
listWorkProducts: (id: string) => api.get<IssueWorkProduct[]>(`/issues/${id}/work-products`),
createWorkProduct: (id: string, data: Record<string, unknown>) =>
api.post<IssueWorkProduct>(`/issues/${id}/work-products`, data),
updateWorkProduct: (id: string, data: Record<string, unknown>) =>
api.patch<IssueWorkProduct>(`/work-products/${id}`, data),
deleteWorkProduct: (id: string) => api.delete<IssueWorkProduct>(`/work-products/${id}`),
};