mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Recovery releases in-progress records (leases, claims, intents) only
when it can prove their owner is gone
> - A record saved before its server process was fully recorded is
`unprovable`, and it is never released on its own
> - #20067 added a board-only action to confirm that such an owner
stopped, but only the environment run lease used it
> - Other settling kinds (for example the legacy controller lease, which
shows `missing_identity` rows in the Phase 2 reconciler data) stay stuck
with no way forward
> - This pull request opens the same confirmation to every settling kind
whose record names a partly recorded server process
> - The benefit is that an operator can release these stuck records
safely, and recovery settles them at once
## Linked Issues or Issue Description
Refs #20067, #20054
**What happened?**
The Phase 2 reconciler data shows `legacy_controller_lease` records that
stay `unprovable` with `missing_identity`: they were saved before
Paperclip stored the controller's PID space. Recovery never releases an
unprovable owner, and only the environment run lease offered the board's
"confirm the previous server stopped" action. These records, and records
of other settling kinds saved the same way, had no path forward.
Also, a board confirmation did not wake the reconciler. Unprovable
records are not watched, so the release waited for some unrelated pass.
**Expected behavior**
Recovery asks the board on the record's task. After the board confirms,
recovery releases the record through the kind's own compare-and-swap
settle, at once. A confirmation never releases a record that changed
since the board was asked.
**Steps to reproduce**
1. Have a legacy run whose `controller_process` was recorded without a
host hash (an older server).
2. Run the ownership reconciliation pass. The record is `unprovable`
(`missing_identity`) and no board action appears.
**Paperclip version or commit**
master at c55af26821.
## What Changed
- `ownership-registry.ts`: a new `ownerGoneAttestable` helper opts a
settling kind in. The kind asks the board (cause
`ownership_owner_unprovable`) only for a process-owned record that is
unprovable for a legacy reason and has a version. Opted in:
`legacy_controller_lease`, `environment_teardown_claim`,
`sandbox_creation_intent`, `workspace_runtime_control_operation`,
`native_workspace_finalize_attempt`, `native_finalizer_lease`,
`native_cleanup_lease`.
- `owner-gone-attestation.ts`:
- The request records the record version (`recordUpdatedAt`), and each
pass refreshes an open request to the current version.
- The board submits the version it was shown (`recordVersion`, a new
optional field in `confirmOwnerGoneSchema`). The confirmation is refused
with 409 unless that equals the request's current version and the locked
record's version.
- `recordVersionAttestations` reads back every resolved confirmation.
Any one that names the record's current version applies.
- `confirmOwnerGone` now wakes the ownership reconciler in its
transaction.
- Each opted-in kind has a version lock. For an `operator_attested`
proof, its settle runs in a transaction that locks the record row and
re-checks the confirmed version before the kind's own compare-and-swap.
- `ownership-invariant-report.ts`: in the periodic pass and in one-shot
settles, a matching confirmation turns a legacy `unprovable` verdict
into `gone` (`operator_attested`). A live or provable owner always wins.
The extra read runs only when a kind has such a record.
- `WorkspaceExportRecovery.tsx`: the board's confirmation names the
stuck record by kind (for example "This task's earlier run"), with a
generic fallback, and sends the version it showed.
- `doc/execution-semantics.md`:
- It describes the action, the version binding and which kinds are
offered.
- It updates the controller-lease section to match #20054: the holdings
list, and that the lease is off only on Railway deployments with an
exclusive volume.
## Verification
- `pnpm exec vitest run src/__tests__/owner-gone-attestation.test.ts`
(server): 10/10. The new legacy-controller cases fail without the
change:
- the board is asked, and nothing is released before the confirmation;
- the confirmation wakes the reconciler, and the next pass settles the
record as `operator_attested`;
- a record that changed after the confirmation is not released, and a
fresh request is raised;
- a record of these kinds with no owner stamp is not offered, because
its settle could never release it;
- a confirmation for a version the record no longer has is refused, and
the refreshed request then applies;
- a write between the reconciler's listing and its settle stops the
release inside the settle's transaction.
- The 10 server ownership suites (registry, report, owner proof,
controller lease, legacy controller, teardown, finalize, token leases):
332/332.
- `pnpm exec vitest run
src/components/WorkspaceExportRecovery.control.test.tsx` (ui): 48/48.
- `pnpm check:token-gates`: all gates clean.
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` in `server` and
`ui`: passed.
## Risks
- A wrong release would be serious. The safeguards:
- The confirmation applies only while the record's last-write time
equals the one the board was asked about, and only while the prover
still finds the recorded process unprovable for a legacy reason.
- Each kind's settle still compare-and-swaps on the exact recorded
process.
- A record of these kinds with no owner stamp at all is not offered.
Those settles cannot release it, so it keeps its existing recovery path.
- Kinds that are unowned by design (queued pending cleanup, accounting
debt) are not offered.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use
(shell, file edits, test 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 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
822 lines
34 KiB
TypeScript
822 lines
34 KiB
TypeScript
import type { SetIssueTitle } from "@paperclipai/shared";
|
|
import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared";
|
|
import type { IssueSummary } from "@paperclipai/shared";
|
|
import type { ExecutionReconciliation } from "@paperclipai/shared";
|
|
import type { IssueContextUsage } from "@paperclipai/shared";
|
|
import type {
|
|
AcceptedPlanDecompositionSummary,
|
|
AskUserQuestionsAnswer,
|
|
Approval,
|
|
CompactIssue,
|
|
InboxMineSnapshot,
|
|
CreateIssueTreeHold,
|
|
DocumentRevision,
|
|
ExactDocumentRevision,
|
|
FeedbackTargetType,
|
|
FeedbackTrace,
|
|
FeedbackVote,
|
|
Issue,
|
|
IssueAccessGrant,
|
|
IssueAccessGrantSubjectType,
|
|
IssueVisibility,
|
|
IssuePrivacyConstraints,
|
|
IssueChanges,
|
|
IssueAttachment,
|
|
IssueCostSummary,
|
|
IssueComment,
|
|
IssueRecentComments,
|
|
IssueQueuedCommentQueue,
|
|
IssueDocument,
|
|
IssueLabel,
|
|
IssueRecoveryAction,
|
|
WorkspaceSourceInspection,
|
|
IssueRetryNowResponse,
|
|
StalledReviewDecision,
|
|
StalledReviewDecisionResponse,
|
|
IssueThreadInteraction,
|
|
IssueTreeControlPreview,
|
|
IssueTreeHold,
|
|
IssueWatchdog,
|
|
IssueWorkProduct,
|
|
RunnerGoalActionAccepted,
|
|
RunnerGoalActionRequest,
|
|
RunnerGoalProjection,
|
|
PreviewIssueTreeControl,
|
|
ReleaseIssueTreeHold,
|
|
ReleaseIssueTreeHoldResponse,
|
|
UpsertIssueWatchdog,
|
|
UpsertIssueDocument,
|
|
} from "@paperclipai/shared";
|
|
import { api, ApiError, detachInflightGet, type RequestOptions } from "./client";
|
|
import { CommentSubmissionUnknownError } from "../lib/comment-submit-result";
|
|
import { classifySendFailure } from "../lib/pending-send";
|
|
import { interactionResolutionErrorCode } from "../lib/interaction-resolution-error";
|
|
|
|
function hasCommentReceipt(value: unknown): boolean {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const row = value as Record<string, unknown>;
|
|
const uuid = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
|
|
return (
|
|
typeof row.id === "string" &&
|
|
uuid.test(row.id) &&
|
|
typeof row.issueId === "string" &&
|
|
uuid.test(row.issueId) &&
|
|
typeof row.body === "string"
|
|
);
|
|
}
|
|
|
|
async function confirmedCommentResponse<T>(
|
|
request: Promise<T>,
|
|
valid: (response: T) => boolean,
|
|
): Promise<T> {
|
|
try {
|
|
const response = await request;
|
|
if (!valid(response)) throw new CommentSubmissionUnknownError();
|
|
return response;
|
|
} catch (error) {
|
|
// A rejected request is distinct from a lost/invalid receipt. A server 5xx
|
|
// may also happen after commit; it cannot establish that nothing was saved.
|
|
if (error instanceof ApiError && error.status < 500) throw error;
|
|
throw new CommentSubmissionUnknownError();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolutions that failed transiently in this tab. A resolution may land on
|
|
* the server and lose its response; resending it then gets
|
|
* `interaction_already_resolved`. That is our own answer, not a conflict, but
|
|
* only when the saved resolution is exactly the one we sent: someone else may
|
|
* have resolved the card meanwhile with the same status and different choices.
|
|
*/
|
|
const transientlyFailedResolutions = new Set<string>();
|
|
|
|
function sameSet(left: readonly string[] | null | undefined, right: readonly string[] | null | undefined): boolean {
|
|
const a = new Set(left ?? []);
|
|
const b = new Set(right ?? []);
|
|
return a.size === b.size && [...a].every((value) => b.has(value));
|
|
}
|
|
|
|
function sameText(left: string | null | undefined, right: string | null | undefined): boolean {
|
|
return (left?.trim() || null) === (right?.trim() || null);
|
|
}
|
|
|
|
type AcceptInteractionInput = {
|
|
selectedClientKeys?: string[];
|
|
selectedOptionIds?: string[];
|
|
rememberAction?: boolean;
|
|
};
|
|
|
|
/** Whether a stored accepted resolution records exactly this accept request. */
|
|
export function acceptedResolutionMatches(current: IssueThreadInteraction, data: AcceptInteractionInput | undefined): boolean {
|
|
if (current.status !== "accepted") return false;
|
|
if (current.kind === "suggest_tasks") {
|
|
// The server skips every suggested task that was not selected.
|
|
const allKeys = current.payload.tasks.map((task) => task.clientKey);
|
|
const selected = new Set(data?.selectedClientKeys ?? allKeys);
|
|
return sameSet(current.result?.skippedClientKeys, allKeys.filter((key) => !selected.has(key)));
|
|
}
|
|
if (current.kind === "request_checkbox_confirmation") {
|
|
return sameSet(current.result?.selectedOptionIds, data?.selectedOptionIds);
|
|
}
|
|
return !data?.selectedClientKeys && !data?.selectedOptionIds;
|
|
}
|
|
|
|
/** Whether a stored rejection records exactly this reject request. */
|
|
export function rejectedResolutionMatches(current: IssueThreadInteraction, reason: string | undefined): boolean {
|
|
if (current.status !== "rejected") return false;
|
|
const result = (current.result ?? null) as { reason?: string | null; rejectionReason?: string | null } | null;
|
|
return sameText(current.kind === "suggest_tasks" ? result?.rejectionReason : result?.reason, reason);
|
|
}
|
|
|
|
/** Whether stored answers record exactly these answers. */
|
|
export function answeredResolutionMatches(current: IssueThreadInteraction, answers: AskUserQuestionsAnswer[]): boolean {
|
|
if (current.status !== "answered" || current.kind !== "ask_user_questions") return false;
|
|
const saved = current.result?.answers ?? [];
|
|
if (saved.length !== answers.length) return false;
|
|
return answers.every((answer) => {
|
|
const match = saved.find((row) => row.questionId === answer.questionId);
|
|
return Boolean(match && sameSet(match.optionIds, answer.optionIds) && sameText(match.otherText, answer.otherText));
|
|
});
|
|
}
|
|
|
|
async function resolveInteractionOnce(
|
|
issueId: string,
|
|
interactionId: string,
|
|
/** Identifies this exact request; a different answer is a different resolution. */
|
|
requestFingerprint: string,
|
|
matchesRequest: (current: IssueThreadInteraction) => boolean,
|
|
request: () => Promise<IssueThreadInteraction>,
|
|
): Promise<IssueThreadInteraction> {
|
|
const key = `${issueId}:${interactionId}:${requestFingerprint}`;
|
|
try {
|
|
const result = await request();
|
|
transientlyFailedResolutions.delete(key);
|
|
return result;
|
|
} catch (error) {
|
|
if (classifySendFailure(error) === "pending") {
|
|
transientlyFailedResolutions.add(key);
|
|
throw error;
|
|
}
|
|
if (
|
|
transientlyFailedResolutions.has(key) &&
|
|
interactionResolutionErrorCode(error) === "interaction_already_resolved"
|
|
) {
|
|
const current = await api
|
|
.get<IssueThreadInteraction[]>(`/issues/${issueId}/interactions`)
|
|
.then((rows) => rows.find((row) => row.id === interactionId) ?? null)
|
|
.catch((readError: unknown) => {
|
|
// A temporary read failure is no verdict: let it escape so the
|
|
// replay policy retries, and the next attempt verifies again.
|
|
if (classifySendFailure(readError) === "pending") throw readError;
|
|
return null;
|
|
});
|
|
if (current && matchesRequest(current)) {
|
|
transientlyFailedResolutions.delete(key);
|
|
return current;
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export type IssueUpdateResponse = Issue & {
|
|
comment?: IssueComment | null;
|
|
changes: IssueChanges;
|
|
blockedByIssueIds?: string[];
|
|
};
|
|
|
|
export type ResolveRecoveryActionResponse = {
|
|
issue: Issue;
|
|
recoveryAction: IssueRecoveryAction;
|
|
};
|
|
|
|
export type IssueListFilters = {
|
|
ids?: readonly string[];
|
|
parentIds?: readonly string[];
|
|
oneChildPerParent?: boolean;
|
|
projection?: "index" | "rows";
|
|
attention?: "blocked";
|
|
status?: string;
|
|
displayStatus?: 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;
|
|
originKindPrefix?: string;
|
|
originId?: string;
|
|
descendantOf?: string;
|
|
createdFromIssueId?: string;
|
|
includeAgentCreatedTasks?: boolean;
|
|
includeParticipatedTasks?: boolean;
|
|
includeRoutineExecutions?: boolean;
|
|
includeBlockedBy?: boolean;
|
|
includeBlockedInboxAttention?: boolean;
|
|
includeLiveDescendantSummary?: boolean;
|
|
hasPlanDocument?: boolean;
|
|
q?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
sortField?: "updated" | "created" | "id";
|
|
afterId?: string;
|
|
sortDir?: "asc" | "desc";
|
|
};
|
|
|
|
function issueListSearchParams(filters?: IssueListFilters) {
|
|
const params = new URLSearchParams();
|
|
if (filters?.attention) params.set("attention", filters.attention);
|
|
if (filters?.status) params.set("status", filters.status);
|
|
if (filters?.displayStatus) params.set("displayStatus", filters.displayStatus);
|
|
if (filters?.projectId) params.set("projectId", filters.projectId);
|
|
if (filters?.parentId) params.set("parentId", filters.parentId);
|
|
if (filters?.parentIds) params.set("parentIds", filters.parentIds.join(","));
|
|
if (filters?.oneChildPerParent) params.set("oneChildPerParent", "true");
|
|
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?.originKindPrefix)
|
|
params.set("originKindPrefix", filters.originKindPrefix);
|
|
if (filters?.originId) params.set("originId", filters.originId);
|
|
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
|
|
if (filters?.createdFromIssueId) params.set("createdFromIssueId", filters.createdFromIssueId);
|
|
if (filters?.includeParticipatedTasks) params.set("includeParticipatedTasks", "true");
|
|
if (filters?.includeAgentCreatedTasks !== undefined)
|
|
params.set("includeAgentCreatedTasks", String(filters.includeAgentCreatedTasks));
|
|
if (filters?.includeRoutineExecutions)
|
|
params.set("includeRoutineExecutions", "true");
|
|
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
|
|
if (filters?.includeBlockedInboxAttention)
|
|
params.set("includeBlockedInboxAttention", "true");
|
|
if (filters?.includeLiveDescendantSummary)
|
|
params.set("includeLiveDescendantSummary", "true");
|
|
if (filters?.hasPlanDocument !== undefined) {
|
|
params.set("hasPlanDocument", filters.hasPlanDocument ? "true" : "false");
|
|
}
|
|
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));
|
|
if (filters?.sortField) params.set("sortField", filters.sortField);
|
|
if (filters?.sortDir) params.set("sortDir", filters.sortDir);
|
|
if (filters?.afterId) params.set("afterId", filters.afterId);
|
|
return params;
|
|
}
|
|
|
|
export const issuesApi = {
|
|
inboxMine: (companyId: string, options?: RequestOptions) => {
|
|
const path = `/companies/${companyId}/inbox/mine`;
|
|
detachInflightGet(path);
|
|
return api.get<InboxMineSnapshot>(path, { ...options, cache: "no-store" });
|
|
},
|
|
summaries: async (companyId: string, issueIds: readonly string[], options?: RequestOptions): Promise<IssueSummary[]> => {
|
|
if (issueIds.length > ISSUE_SUMMARIES_MAX_IDS) throw new Error("Too many issue summary IDs");
|
|
const ids = [...new Set(issueIds.map((id) => id.trim().toLowerCase()))].sort();
|
|
if (ids.length === 0) return [];
|
|
const params = new URLSearchParams({ ids: ids.join(",") });
|
|
const path = `/companies/${companyId}/issues/summaries?${params}`;
|
|
// React Query deduplicates within an account; URL-only HTTP coalescing must
|
|
// never join a request sent with the previous account's cookie.
|
|
detachInflightGet(path);
|
|
return api.get<IssueSummary[]>(path, { ...options, cache: "no-store" });
|
|
},
|
|
/** Fetch every page for bounded task-detail relations, not just the default first page. */
|
|
listAll: async (companyId: string, filters: Omit<IssueListFilters, "limit" | "offset" | "sortField" | "sortDir" | "afterId" | "attention">, options?: RequestOptions): Promise<Issue[]> => {
|
|
const pageSize = 500;
|
|
const tasks = new Map<string, Issue>();
|
|
let afterId: string | undefined;
|
|
for (;;) {
|
|
const page = await issuesApi.list(companyId, { ...filters, limit: pageSize, sortField: "id", sortDir: "asc", afterId }, options);
|
|
for (const task of page) tasks.set(task.id, task);
|
|
if (page.length < pageSize) return [...tasks.values()];
|
|
afterId = page[page.length - 1]!.id;
|
|
}
|
|
},
|
|
list: (
|
|
companyId: string,
|
|
filters?: IssueListFilters,
|
|
options?: RequestOptions,
|
|
) => {
|
|
const params = issueListSearchParams(filters);
|
|
const qs = params.toString();
|
|
const path = `/companies/${companyId}/issues${qs ? `?${qs}` : ""}`;
|
|
return options ? api.get<Issue[]>(path, options) : api.get<Issue[]>(path);
|
|
},
|
|
listCompact: (
|
|
companyId: string,
|
|
filters?: IssueListFilters,
|
|
options?: RequestOptions,
|
|
) => {
|
|
const params = issueListSearchParams(filters);
|
|
params.set("view", filters?.projection ?? "compact");
|
|
if (filters?.ids) params.set("ids", filters.ids.join(","));
|
|
const path = `/companies/${companyId}/issues?${params.toString()}`;
|
|
return options
|
|
? api.get<CompactIssue[]>(path, options)
|
|
: api.get<CompactIssue[]>(path);
|
|
},
|
|
count: (
|
|
companyId: string,
|
|
filters: {
|
|
attention: "blocked";
|
|
status?: string;
|
|
assigneeAgentId?: string;
|
|
assigneeUserId?: string;
|
|
projectId?: string;
|
|
labelId?: string;
|
|
q?: string;
|
|
},
|
|
) => {
|
|
const params = new URLSearchParams();
|
|
params.set("attention", filters.attention);
|
|
if (filters.status) params.set("status", filters.status);
|
|
if (filters.assigneeAgentId)
|
|
params.set("assigneeAgentId", filters.assigneeAgentId);
|
|
if (filters.assigneeUserId)
|
|
params.set("assigneeUserId", filters.assigneeUserId);
|
|
if (filters.projectId) params.set("projectId", filters.projectId);
|
|
if (filters.labelId) params.set("labelId", filters.labelId);
|
|
if (filters.q) params.set("q", filters.q);
|
|
return api.get<{ count: number }>(
|
|
`/companies/${companyId}/issues/count?${params.toString()}`,
|
|
);
|
|
},
|
|
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}`),
|
|
getCore: (id: string, options?: RequestOptions) => api.get<Issue>(`/issues/${id}?view=core`, options),
|
|
recentComments: (id: string, options?: RequestOptions) => api.get<IssueRecentComments>(`/issues/${id}/recent-comments`, options),
|
|
get: (id: string, options?: RequestOptions) =>
|
|
options
|
|
? api.get<Issue>(`/issues/${id}`, options)
|
|
: api.get<Issue>(`/issues/${id}`),
|
|
getContextUsage: (id: string) => api.get<IssueContextUsage | null>(`/issues/${id}/context-usage`),
|
|
compactContext: (id: string) =>
|
|
api.post<{ requestId: string; runId: string }>(`/issues/${id}/context/compact`, {}),
|
|
getRunnerGoal: (id: string, agentId?: string | null) => {
|
|
const query = agentId ? `?agentId=${encodeURIComponent(agentId)}` : "";
|
|
return api.get<RunnerGoalProjection>(`/issues/${id}/runner-goal${query}`);
|
|
},
|
|
actOnRunnerGoal: (id: string, request: RunnerGoalActionRequest) =>
|
|
api.post<RunnerGoalActionAccepted>(
|
|
`/issues/${id}/runner-goal/actions`,
|
|
request,
|
|
),
|
|
getWatchdog: (id: string) =>
|
|
api.get<IssueWatchdog | null>(`/issues/${id}/watchdog`),
|
|
upsertWatchdog: (id: string, data: UpsertIssueWatchdog) =>
|
|
api.put<IssueWatchdog>(`/issues/${id}/watchdog`, data),
|
|
deleteWatchdog: (id: string) =>
|
|
api.delete<{ ok: true }>(`/issues/${id}/watchdog`),
|
|
markRead: (id: string, throughCommentId?: string) =>
|
|
api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, throughCommentId ? { throughCommentId } : {}),
|
|
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`,
|
|
),
|
|
setTitle: (id: string, data: SetIssueTitle) =>
|
|
api.put<{ id: string; title: string; titleNeedsGeneration: boolean; changed: boolean }>(`/issues/${id}/title`, data),
|
|
create: (companyId: string, data: Record<string, unknown>) =>
|
|
api.post<Issue>(`/companies/${companyId}/issues`, data),
|
|
update: (id: string, data: Record<string, unknown>) => {
|
|
const response = api.patch<IssueUpdateResponse>(`/issues/${id}`, data);
|
|
return typeof data.comment === "string"
|
|
? confirmedCommentResponse(response, (value) =>
|
|
hasCommentReceipt(value?.comment),
|
|
)
|
|
: response;
|
|
},
|
|
decideStalledReview: (id: string, data: StalledReviewDecision) =>
|
|
api.post<StalledReviewDecisionResponse>(
|
|
`/issues/${id}/stalled-review-decision`,
|
|
data,
|
|
),
|
|
confirmControllerStopped: (id: string, data: { actionId: string; runId: string; stopEvidence: string }) =>
|
|
api.post<{ runId: string; status: "queued" }>(`/issues/${id}/recovery-actions/confirm-controller-stopped`, data),
|
|
confirmOwnerGone: (id: string, data: { actionId: string; recordId: string; stopEvidence: string; recordVersion?: string }) =>
|
|
api.post<{ recordId: string; status: "queued" | "released" }>(`/issues/${id}/recovery-actions/confirm-owner-gone`, data),
|
|
resumeWorkspaceFinalization: (id: string, data: { actionId: string; runId: string; ownerToken: string; controllerAndCopybackStopped: true; stopEvidence: string }) =>
|
|
api.post<{ runId: string; resultId: string | null; status: "queued" }>(`/issues/${id}/recovery-actions/resume-workspace-finalization`, data),
|
|
retryWorkspaceExport: (id: string, data: { actionId: string; runId: string; repairNote: string }) =>
|
|
api.post<{ runId: string; resultId: string; leaseId: string; status: "queued" }>(`/issues/${id}/recovery-actions/retry-workspace-export`, data),
|
|
retryTask: (issueId: string, body: { idempotencyKey: string; observedRunId: string | null }) =>
|
|
api.post<{ requestId: string | null; accepted: boolean; stale: boolean; presentation: import("@paperclipai/shared").TaskInterruptionPresentation | null }>(`/issues/${issueId}/retry`, body),
|
|
recoverWorkspace: (id: string, data: { actionId: string; runId: string; idempotencyKey: string }) =>
|
|
api.post<{ actionId: string; status: string }>(`/issues/${id}/recovery-actions/recover-workspace`, data),
|
|
reconstructWorkspace: (id: string, data: { actionId: string; runId: string; expectedStatusVersion: number; attachmentIds: string[];
|
|
missingWork: string; actionOutcome: "completed" | "not_performed" | "mixed"; outcomeEvidence: string; acknowledgeLostWork: true }) =>
|
|
api.post<{ runId: string; actionId: string; status: "queued"; replayed: boolean }>(`/issues/${id}/recovery-actions/reconstruct-workspace`, data),
|
|
recoveryActions: (id: string, source?: { runId: string; actionId?: string }) => {
|
|
const query = source ? new URLSearchParams({ inspectSourceRunId: source.runId,
|
|
...(source.actionId ? { inspectSourceActionId: source.actionId } : {}) }) : null;
|
|
return api.get<{ active: IssueRecoveryAction | null; actions: IssueRecoveryAction[];
|
|
sourceInspection?: WorkspaceSourceInspection }>(`/issues/${id}/recovery-actions${query ? `?${query}` : ""}`);
|
|
},
|
|
resolveRecoveryAction: (
|
|
id: string,
|
|
data: {
|
|
executionReconciliation?: ExecutionReconciliation;
|
|
workspaceBaseRef?: { requestedRef: string; branch: string };
|
|
actionId?: string;
|
|
outcome: "restored" | "false_positive" | "blocked" | "cancelled";
|
|
sourceIssueStatus: Issue["status"];
|
|
resolutionNote?: string | null;
|
|
},
|
|
) =>
|
|
api.post<ResolveRecoveryActionResponse>(
|
|
`/issues/${id}/recovery-actions/resolve`,
|
|
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<ReleaseIssueTreeHoldResponse>(
|
|
`/issues/${id}/tree-holds/${holdId}/release`,
|
|
data,
|
|
),
|
|
checkMonitorNow: (id: string) =>
|
|
api.post<{ ok: true }>(`/issues/${id}/monitor/check-now`, {}),
|
|
retryScheduledRetryNow: (id: string) =>
|
|
api.post<IssueRetryNowResponse>(
|
|
`/issues/${id}/scheduled-retry/retry-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;
|
|
},
|
|
options?: RequestOptions,
|
|
) => {
|
|
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}` : ""}`,
|
|
options,
|
|
);
|
|
},
|
|
getQueuedComments: (id: string) =>
|
|
api.get<IssueQueuedCommentQueue>(`/issues/${id}/queued-comments`),
|
|
editQueuedComment: (
|
|
id: string,
|
|
commentId: string,
|
|
data: { body: string; queueId: string; revision: string },
|
|
) =>
|
|
api.patch<IssueQueuedCommentQueue>(
|
|
`/issues/${id}/queued-comments/${commentId}`,
|
|
data,
|
|
),
|
|
reorderQueuedComments: (
|
|
id: string,
|
|
data: { orderedCommentIds: string[]; queueId: string; revision: string },
|
|
) =>
|
|
api.put<IssueQueuedCommentQueue>(
|
|
`/issues/${id}/queued-comments/order`,
|
|
data,
|
|
),
|
|
interruptQueuedComments: (
|
|
id: string,
|
|
data: { queueId: string; targetRunId: string | null; revision: string },
|
|
) => api.post<IssueQueuedCommentQueue>(`/issues/${id}/queued-comments/interrupt`, data),
|
|
interruptLatestQueuedComments: async (id: string, expectedTargetRunId: string | null): Promise<IssueQueuedCommentQueue> => {
|
|
const queue = await issuesApi.getQueuedComments(id);
|
|
if (!queue.queueId || (queue.targetRunId && queue.targetRunId !== expectedTargetRunId)) {
|
|
throw new Error("The queued messages changed. Refresh and try again.");
|
|
}
|
|
return issuesApi.interruptQueuedComments(id, {
|
|
queueId: queue.queueId, revision: queue.revision, targetRunId: queue.targetRunId,
|
|
});
|
|
},
|
|
steerQueuedComment: (
|
|
id: string,
|
|
commentId: string,
|
|
data: { queueId: string; targetRunId: string; revision: string },
|
|
) =>
|
|
api.post<IssueQueuedCommentQueue>(
|
|
`/issues/${id}/queued-comments/${commentId}/steer`,
|
|
data,
|
|
),
|
|
discardQueuedComment: (
|
|
id: string,
|
|
commentId: string,
|
|
data: { queueId: string; revision: string },
|
|
) =>
|
|
api.delete<IssueQueuedCommentQueue>(
|
|
`/issues/${id}/queued-comments/${commentId}`,
|
|
data,
|
|
),
|
|
listInteractions: (id: string) =>
|
|
api.get<IssueThreadInteraction[]>(`/issues/${id}/interactions`),
|
|
listAcceptedPlanDecompositions: (id: string) =>
|
|
api.get<AcceptedPlanDecompositionSummary[]>(
|
|
`/issues/${id}/accepted-plan-decompositions`,
|
|
),
|
|
createInteraction: (id: string, data: Record<string, unknown>) =>
|
|
api.post<IssueThreadInteraction>(`/issues/${id}/interactions`, data),
|
|
acceptInteraction: (
|
|
id: string,
|
|
interactionId: string,
|
|
data?: AcceptInteractionInput,
|
|
) =>
|
|
resolveInteractionOnce(
|
|
id,
|
|
interactionId,
|
|
`accept:${JSON.stringify([data?.selectedClientKeys ?? null, data?.selectedOptionIds ?? null])}`,
|
|
(current) => acceptedResolutionMatches(current, data),
|
|
() =>
|
|
api.post<IssueThreadInteraction>(
|
|
`/issues/${id}/interactions/${interactionId}/accept`,
|
|
data ?? {},
|
|
),
|
|
),
|
|
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
|
|
resolveInteractionOnce(
|
|
id,
|
|
interactionId,
|
|
`reject:${JSON.stringify(reason ?? null)}`,
|
|
(current) => rejectedResolutionMatches(current, reason),
|
|
() =>
|
|
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 } : {},
|
|
),
|
|
skipInteraction: (id: string, interactionId: string, reason?: string) =>
|
|
api.post<IssueThreadInteraction>(
|
|
`/issues/${id}/interactions/${interactionId}/skip`,
|
|
reason ? { reason } : {},
|
|
),
|
|
dismissInteraction: (id: string, interactionId: string) =>
|
|
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/dismiss`, {}),
|
|
respondToInteraction: (
|
|
id: string,
|
|
interactionId: string,
|
|
data: {
|
|
answers: AskUserQuestionsAnswer[];
|
|
summaryMarkdown?: string | null;
|
|
},
|
|
) =>
|
|
resolveInteractionOnce(
|
|
id,
|
|
interactionId,
|
|
`respond:${JSON.stringify(data.answers)}`,
|
|
(current) => answeredResolutionMatches(current, data.answers),
|
|
() =>
|
|
api.post<IssueThreadInteraction>(
|
|
`/issues/${id}/interactions/${interactionId}/respond`,
|
|
data,
|
|
),
|
|
),
|
|
submitInteractionVerdicts: (
|
|
id: string,
|
|
interactionId: string,
|
|
verdicts: {
|
|
id: string;
|
|
verdict: "approve" | "reject" | "defer";
|
|
reason?: string | null;
|
|
}[],
|
|
) =>
|
|
api.post<IssueThreadInteraction>(
|
|
`/issues/${id}/interactions/${interactionId}/verdicts`,
|
|
{ verdicts },
|
|
),
|
|
getComment: (id: string, commentId: string, options?: RequestOptions) =>
|
|
api.get<IssueComment>(`/issues/${id}/comments/${encodeURIComponent(commentId)}`, options),
|
|
listFeedbackVotes: (id: string) =>
|
|
api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
|
getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => {
|
|
const qs = options.excludeRoot ? "?excludeRoot=true" : "";
|
|
return api.get<IssueCostSummary>(`/issues/${id}/cost-summary${qs}`);
|
|
},
|
|
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,
|
|
attachmentIds?: string[],
|
|
clientRequestId?: string,
|
|
) =>
|
|
confirmedCommentResponse(
|
|
api.post<IssueComment>(`/issues/${id}/comments`, {
|
|
body,
|
|
...(clientRequestId ? { clientRequestId } : {}),
|
|
...(reopen === undefined ? {} : { reopen }),
|
|
...(interrupt === undefined ? {} : { interrupt }),
|
|
...(attachmentIds?.length ? { attachmentIds } : {}),
|
|
}),
|
|
hasCommentReceipt,
|
|
),
|
|
cancelComment: (id: string, commentId: string) =>
|
|
api.delete<IssueComment>(`/issues/${id}/comments/${commentId}?mode=cancel`),
|
|
deleteComment: (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,
|
|
),
|
|
lockDocument: (id: string, key: string) =>
|
|
api.post<IssueDocument>(
|
|
`/issues/${id}/documents/${encodeURIComponent(key)}/lock`,
|
|
{},
|
|
),
|
|
unlockDocument: (id: string, key: string) =>
|
|
api.post<IssueDocument>(
|
|
`/issues/${id}/documents/${encodeURIComponent(key)}/unlock`,
|
|
{},
|
|
),
|
|
listDocumentRevisions: (id: string, key: string) =>
|
|
api.get<DocumentRevision[]>(
|
|
`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`,
|
|
),
|
|
getDocumentRevision: (id: string, key: string, revisionId: string) =>
|
|
api.get<ExactDocumentRevision>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions/${encodeURIComponent(revisionId)}`),
|
|
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}`),
|
|
// --- Privacy / sharing (PAP-16066) -------------------------------------
|
|
// Enriched grants for the share sheet: implicit-by-source rows (assignment /
|
|
// project) plus explicit grants, each carrying subjectDisplayName / avatar /
|
|
// agentVisibility from the server enrichment pass.
|
|
privacyConstraints: (id: string) => api.get<IssuePrivacyConstraints>(`/issues/${id}/privacy-constraints`),
|
|
listAccessGrants: (id: string, options?: RequestOptions) =>
|
|
options
|
|
? api.get<IssueAccessGrant[]>(`/issues/${id}/access-grants`, options)
|
|
: api.get<IssueAccessGrant[]>(`/issues/${id}/access-grants`),
|
|
createAccessGrant: (
|
|
id: string,
|
|
data: { subjectType: IssueAccessGrantSubjectType; subjectId: string },
|
|
) => api.post<IssueAccessGrant>(`/issues/${id}/access-grants`, data),
|
|
revokeAccessGrant: (id: string, grantId: string) =>
|
|
api.post<IssueAccessGrant>(`/issues/${id}/access-grants/${grantId}/revoke`, {}),
|
|
setVisibility: (id: string, visibility: IssueVisibility) =>
|
|
api.patch<IssueUpdateResponse>(`/issues/${id}`, { visibility }),
|
|
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, options?: { refreshPullRequests?: boolean; signal?: AbortSignal; fresh?: boolean }) => {
|
|
const path = `/issues/${id}/work-products${options?.refreshPullRequests ? "?refreshPullRequests=true" : ""}`;
|
|
// Query invalidations must not rejoin a request that read rows before the write.
|
|
if (options?.fresh) detachInflightGet(path);
|
|
return api.get<IssueWorkProduct[]>(path, { signal: options?.signal });
|
|
},
|
|
ensureWorkProductReviewDocument: (id: string, workProductId: string) =>
|
|
api.post<IssueDocument>(
|
|
`/issues/${id}/work-products/${workProductId}/review-document`,
|
|
{},
|
|
),
|
|
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}`),
|
|
};
|