Files
PaperClipAI/ui/src/api/pipelines.ts
T
DottaandPaperclip 43b005b704 Add pipeline workflow primitives and operator UI (#7903)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The pipeline subsystem models repeatable work as items moving
through stages, with agent automation, review gates, blockers, drift
notices, and linked work.
> - Operators need this to be usable as one coherent workflow surface,
not just as backend primitives or disconnected route experiments.
> - The branch now carries the pipeline data model, service/routes,
CLI/tutorial path, aggregation feeds, operator UI, stage automation
controls, liveness/retry handling, and follow-up polish that make the
primitive reviewable end to end.
> - This pull request is the single review target for that pipeline
workflow primitive stack.
> - The benefit is that reviewers can evaluate the full operator
experience and server contract together against `master`.

## Linked Issues or Issue Description

No public GitHub issue exists for this work. The underlying feature
request is described inline.

### Problem or motivation

Paperclip needs a first-class way to model multi-stage agent/company
workflows where upstream items can spawn downstream work, request
review, carry fields across pipelines, surface drift, retry automation,
and show operators where work is blocked or active. Without a unified
pipeline primitive, these workflows spread across ad hoc issues,
routines, and comments, making the state hard to inspect or operate.

### Proposed solution

Add the pipeline workflow primitive stack: database schema and
migrations, shared validators/types, server services and REST routes,
aggregation and liveness helpers, CLI/tutorial smoke support, and the
React operator UI for pipeline lists, boards, item detail,
review/learnings views, settings, stage automation, secrets, carry-over
fields, and retry/recovery flows.

### Alternatives considered

- Keep workflows as loosely linked issues and routines: rejected because
operators need a single board/detail/settings surface for repeated
workflow patterns.
- Ship backend primitives first and defer UI: rejected for this branch
because the operator experience is the main way to validate the
primitive.
- Add a narrower one-off content workflow: rejected because the same
primitives are useful across future company processes.

## What Changed

- Added and evolved pipeline schema, migrations, shared contracts,
server services, REST routes, route tests, and CLI/tutorial smoke
support.
- Added pipeline aggregation, health/liveness, drift acknowledgment,
blocker/carry-over, automation retry, stage automation environment, and
permission recovery behavior.
- Added the operator UI for pipeline index/board/item
detail/settings/review/learnings flows, including stage secrets,
automation controls, markdown/item descriptions, linked issue assets,
liveness banners, and source automation metadata.
- Refactored issue document frame rendering through the shared
`DocumentFrameHeader` component to keep document controls consistent
with the pipeline document surfaces.
- Kept this PR as the single base-branch review target for the current
pipeline branch.

## Verification

Current branch refresh:

- `pnpm vitest run server/src/__tests__/pipelines-service.test.ts` — 31
passed
- `pnpm vitest run server/src/__tests__/pipelines-routes.test.ts` — 19
passed
- `pnpm --filter ./server typecheck` — passed
- `pnpm --filter ./ui typecheck` — passed
- Verified Pipelines remains gated by `enablePipelines === true`:
sidebar item is hidden unless the flag is enabled, direct pipeline
routes redirect to `/dashboard` when disabled, and the Experimental
settings UI still has no Pipelines toggle.
- GitHub status checks on `df071c710646de625131064c3fb6588b5e97964a` —
all complete with no failing conclusions, including Actions, Socket,
Superagent/Security, and Greptile Review
- Greptile summary on `df071c710646de625131064c3fb6588b5e97964a` —
Confidence Score 5/5
- GitHub review-thread sweep — 0 unresolved Greptile threads

Previously recorded during branch development:

- Server pipeline service/route and aggregation tests
- Shared validator tests
- UI pipeline page/settings/item-detail/learnings/liveness tests
- Pipeline tutorial smoke path

## Risks

- High review surface: this is a large feature branch spanning database,
shared contracts, server behavior, CLI/docs, and UI.
- Migration ordering and schema compatibility need reviewer attention
because this branch has been kept current across multiple `master`
syncs.
- GitHub still reports merge state `BLOCKED` because the PR is awaiting
normal human review/branch-protection completion; all current status
checks are green.
- Branch-name checklist exception: this PR uses the pre-existing
requested branch name, which predates the current public-branch naming
rule. The PR title/body avoid internal issue references.

> 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 coding agent based on GPT-5, with repository tool use,
shell execution, git/GitHub CLI operations, and local verification
commands. Earlier commits in this branch were assisted by Paperclip
agents and other AI coding agents as recorded in commit authorship.

## 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)
- [ ] 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-06-26 12:02:44 -05:00

689 lines
23 KiB
TypeScript

import type {
Issue,
PipelineAutomationRetryCleanupOptions,
PipelineAutomationRetryPlan,
PipelineAutomationRetryScope,
PipelineCaseConversationSource,
PipelineCaseDocumentPayload,
PipelineCaseDocumentRevision,
PipelineCaseLiveness,
PipelineCaseOutputsResponse,
PipelineHealthReport,
RoutineEnvConfig,
} from "@paperclipai/shared";
import { api } from "./client";
export type { PipelineHealthReport, PipelineHealthWarning } from "@paperclipai/shared";
export type PipelineConnectionRef =
| string
| {
id?: string | null;
pipelineId?: string | null;
upstreamPipelineId?: string | null;
downstreamPipelineId?: string | null;
feedsIntoPipelineId?: string | null;
fedByPipelineId?: string | null;
direction?: string | null;
};
export interface PipelineConnections {
upstreamPipelineIds?: string[];
downstreamPipelineIds?: string[];
feedsIntoPipelineId?: string | null;
downstreamPipelineId?: string | null;
feedsInto?: PipelineConnectionRef[];
fedBy?: PipelineConnectionRef[];
upstream?: PipelineConnectionRef[];
downstream?: PipelineConnectionRef[];
[key: string]: unknown;
}
export interface PipelineListItem {
id: string;
companyId: string;
key: string;
name: string;
description: string | null;
projectId: string | null;
enforceTransitions: boolean;
archivedAt: Date | string | null;
stageCount: number;
stages?: PipelineStage[];
openCaseCount: number;
attentionCount?: number | null;
inMotionCount?: number | null;
descendantActiveWorkCount?: number | null;
lastActivityAt?: Date | string | null;
connections?: PipelineConnections | null;
createdAt: Date | string;
updatedAt: Date | string;
}
export interface PipelineStage {
id: string;
pipelineId: string;
key: string;
name: string;
kind: string;
position: number;
config?: Record<string, unknown> | null;
createdAt?: Date | string;
updatedAt?: Date | string;
}
export interface PipelineDetail extends PipelineListItem {
stages: PipelineStage[];
transitions: Array<{ fromStageId: string; toStageId: string; label?: string | null }>;
documentKeys?: Array<{ key: string; documentId: string }>;
}
export interface PipelineTransitionEdge {
fromStageKey: string;
toStageKey: string;
label?: string | null;
}
export interface PipelineDocumentPayload {
link: { key: string; documentId: string; [key: string]: unknown };
document: { id: string; title: string; latestBody?: string | null; [key: string]: unknown };
revision?: { body?: string | null; title?: string | null; [key: string]: unknown } | null;
}
export interface PipelineDocumentRevision {
id: string;
companyId: string;
documentId: string;
pipelineId: string;
key: string;
revisionNumber: number;
title: string | null;
format: string;
body: string;
changeSummary: string | null;
createdByAgentId: string | null;
createdByUserId: string | null;
createdAt: Date | string;
}
export type PipelineIntakeFieldType = "select" | "text" | "multiline";
export interface PipelineIntakeField {
key: string;
label: string;
type: PipelineIntakeFieldType;
options?: string[];
required?: boolean;
}
export interface PipelineIntakeForm {
pipelineId: string;
stageId: string | null;
stageName?: string | null;
fields: PipelineIntakeField[];
}
export interface PipelineCase {
id: string;
companyId?: string;
pipelineId: string;
stageId: string | null;
caseKey?: string | null;
title: string;
summary?: string | null;
fields?: Record<string, unknown> | null;
workspaceRef?: Record<string, unknown> | null;
parentCaseId?: string | null;
parentCaseVersion?: number | null;
requestKey?: string | null;
version?: number;
pendingSuggestion?: PipelineCasePendingSuggestion | null;
terminalKind?: string | null;
terminalAt?: Date | string | null;
childCount?: number;
terminalChildCount?: number;
createdAt?: Date | string;
updatedAt?: Date | string;
}
export interface PipelineCaseActiveWork {
issueId: string;
issueIdentifier: string | null;
issueTitle: string;
issueRole?: "work" | "automation";
agentId: string;
agentName: string;
startedAt: Date | string | null;
}
export interface PipelineCasePendingSuggestion {
id: string;
toStageKey: string;
rationale: string;
confidence?: number;
suggestedByAgentId?: string;
runId?: string;
createdAt: Date | string;
}
export interface PipelineCaseDetail {
case: PipelineCase;
/** Derived from the pipeline (invisible/internal): used for display + ingest checks. */
caseType?: string;
stage: PipelineStage;
pipeline: PipelineDetail;
allowedNextStages: PipelineStage[];
links: PipelineCaseIssueLink[];
blockers: PipelineCaseBlocker[];
blocks: PipelineCaseBlocker[];
childrenSummary: {
childCount: number;
terminalChildCount: number;
loadedChildren: number;
descendantActiveWorkCount?: number;
};
parentCase?: {
case: PipelineCase;
stage: PipelineStage;
pipeline: { id: string; key: string; name: string };
} | null;
builtFromAutomation?: {
execution: {
id: string;
automationId: string;
status: string;
};
routine: {
id: string;
title: string;
};
pipeline: {
id: string;
key: string;
name: string;
};
stage: {
id: string;
key: string;
name: string;
kind: string;
} | null;
case: {
id: string;
caseKey: string | null;
title: string;
pipelineId: string;
};
} | null;
activeWork?: PipelineCaseActiveWork | null;
liveness?: PipelineCaseLiveness | null;
conversationSource?: PipelineCaseConversationSource | null;
pendingSuggestion?: PipelineCasePendingSuggestion | null;
}
export interface PipelineCaseIssueLink {
id: string;
companyId: string;
caseId: string;
issueId: string;
role: "origin" | "conversation" | "work" | "automation";
createdByRunId?: string | null;
createdAt: Date | string;
updatedAt: Date | string;
}
export interface PipelineCaseIssueLinkWithIssue {
link: PipelineCaseIssueLink;
issue: Issue;
}
export interface PipelineCaseBlocker {
id: string;
companyId: string;
caseId: string;
blockedByCaseId: string;
createdAt: Date | string;
updatedAt: Date | string;
}
export interface PipelineCaseEvent {
id: string;
companyId: string;
caseId: string;
type: string;
actorType: "user" | "agent" | "system";
actorUserId?: string | null;
actorAgentId?: string | null;
runId?: string | null;
fromStageId?: string | null;
toStageId?: string | null;
payload?: Record<string, unknown> | null;
fromStage?: { id: string; key: string; name: string; kind: string } | null;
toStage?: { id: string; key: string; name: string; kind: string } | null;
actorAgent?: { id: string; name: string } | null;
automation?: {
routine: { id: string; title: string } | null;
issue: { id: string; identifier: string | null; title: string; status: string } | null;
routineRunId?: string | null;
stage?: { id: string; key: string; name: string; kind: string } | null;
};
createdAt: Date | string;
updatedAt: Date | string;
}
export interface PipelineCaseEventsPage {
items: PipelineCaseEvent[];
pagination: {
limit: number;
offset: number;
nextOffset: number | null;
hasMore: boolean;
order: "asc" | "desc";
};
}
export interface PipelineAttentionCaseRef {
id: string;
caseKey: string | null;
title: string;
summary?: string | null;
version: number;
terminalKind?: string | null;
parentCaseId?: string | null;
updatedAt: Date | string;
createdAt: Date | string;
pipeline: { id: string; key: string; name: string };
stage: { id: string; key: string; name: string; kind: string };
}
export interface PipelineAttentionSuggestion {
case: PipelineAttentionCaseRef;
suggestion: {
id: string;
fromStageKey: string;
fromStageName: string;
toStageKey: string;
toStageName: string | null;
rationale: string;
confidence?: number | null;
createdAt: Date | string;
suggestedBy: { agentId: string; agentName: string } | null;
};
}
export interface PipelineAttentionReview {
case: PipelineAttentionCaseRef;
review: {
expectedVersion: number;
approveToStageKey: string | null;
rejectToStageKey: string | null;
requestChangesToStageKey: string | null;
requireRejectReason: boolean;
requireRequestChangesReason: boolean;
reviewerKind: string;
};
}
export interface PipelineAttentionHeadsUp {
case: PipelineAttentionCaseRef;
drift: {
eventId: string;
createdAt: Date | string;
previousVersion: number | null;
version: number | null;
upstream: {
caseId: string | null;
caseKey: string | null;
title: string | null;
pipelineId: string | null;
pipelineName: string | null;
};
};
activeWork?: PipelineCaseActiveWork | null;
workIssue?: Record<string, unknown> | null;
}
export interface PipelineAttentionFeed {
suggestions: PipelineAttentionSuggestion[];
reviews: PipelineAttentionReview[];
headsUp: PipelineAttentionHeadsUp[];
counts: { suggestions: number; reviews: number; headsUp: number };
}
export interface PipelineReviewConfig {
approveToStageKey?: string | null;
rejectToStageKey?: string | null;
requestChangesToStageKey?: string | null;
requireRejectReason?: boolean;
requireRequestChangesReason?: boolean;
reviewerKind?: string;
[key: string]: unknown;
}
export interface PipelineReviewCaseRow {
case: PipelineCase;
pipeline: { id: string; key: string; name: string; [key: string]: unknown };
stage: PipelineStage;
parentCase?: PipelineCase | null;
pendingSuggestion?: PipelineCasePendingSuggestion | null;
reviewConfig: PipelineReviewConfig;
}
export type PipelineReviewDecision = "approve" | "reject" | "request_changes";
export interface PipelineBulkReviewResult {
results: Array<{
caseId: string;
ok: boolean;
result?: unknown;
error?: { status?: number; message?: string; code?: string; details?: Record<string, unknown> };
}>;
}
export interface PipelineCompanyCaseEvent extends PipelineCaseEvent {
case: { id: string; caseKey: string | null; title: string; terminalKind?: string | null };
pipeline: { id: string; key: string; name: string };
fromStage?: { id: string; key: string; name: string; kind: string } | null;
toStage?: { id: string; key: string; name: string; kind: string } | null;
actorAgent?: { id: string; name: string } | null;
}
export interface PipelineCompanyCaseEventsPage {
items: PipelineCompanyCaseEvent[];
pagination: {
limit: number;
offset: number;
nextOffset: number | null;
hasMore: boolean;
};
}
export interface PipelineCaseChildrenTreeNode {
id: string;
caseKey: string | null;
title: string;
terminalKind?: string | null;
createdAt?: Date | string;
updatedAt?: Date | string;
pipeline: { id: string; key: string; name: string };
stage: { id: string; key: string; name: string; kind: string };
rollup?: { total: number; done: number; dropped: number; inMotion: number } | null;
childGroups?: Array<{
pipeline: { id: string; key: string; name: string };
cases: PipelineCaseChildrenTreeNode[];
}>;
}
export interface PipelineCaseChildrenTree {
case: PipelineCaseChildrenTreeNode;
rollup?: { total: number; done: number; dropped: number; inMotion: number } | null;
childGroups?: Array<{
pipeline: { id: string; key: string; name: string };
cases: PipelineCaseChildrenTreeNode[];
}>;
truncated?: boolean;
totalNodes?: number;
}
export interface PipelineCaseParentSummary {
case: {
id: string;
caseKey?: string | null;
title: string;
pipelineId: string;
};
pipeline: { id: string; key: string; name: string };
}
export interface PipelineCaseChildRow {
case: PipelineCase;
stage: PipelineStage;
parentCase?: PipelineCaseParentSummary | null;
activeWork?: PipelineCaseActiveWork | null;
descendantActiveWorkCount?: number;
}
export type PipelineCaseChildrenResponse = PipelineCaseChildRow[];
export type PipelineBatchIngestResult =
| { ok: true; case: PipelineCase; created: boolean }
| {
ok: false;
caseKey: string | null;
error?: {
status?: number;
message?: string;
details?: Record<string, unknown>;
};
};
export const pipelinesApi = {
list: (companyId: string) => api.get<PipelineListItem[]>(`/companies/${companyId}/pipelines`),
create: (
companyId: string,
data: { key: string; name: string; description?: string | null; projectId?: string | null },
) => api.post<PipelineListItem & { stages?: PipelineStage[] }>(`/companies/${companyId}/pipelines`, data),
get: (pipelineId: string) => api.get<PipelineDetail>(`/pipelines/${pipelineId}`),
getHealth: (pipelineId: string) => api.get<PipelineHealthReport>(`/pipelines/${pipelineId}/health`),
update: (
pipelineId: string,
data: { name?: string; description?: string | null; enforceTransitions?: boolean; archived?: boolean },
) => api.patch<PipelineListItem>(`/pipelines/${pipelineId}`, data),
createStage: (
pipelineId: string,
data: { key: string; name: string; kind: string; position: number; config?: Record<string, unknown> },
) => api.post<PipelineStage>(`/pipelines/${pipelineId}/stages`, data),
updateStage: (
pipelineId: string,
stageId: string,
data: { key?: string; name?: string; kind?: string; position?: number; config?: Record<string, unknown> },
) => api.patch<PipelineStage>(`/pipelines/${pipelineId}/stages/${stageId}`, data),
// Stage secrets live on the backing automation routine's env, not in stage
// config. This narrow route updates only that env (and its secret bindings)
// so saving secrets never clobbers unrelated stage settings.
updateStageAutomationEnv: (
pipelineId: string,
stageId: string,
data: { env: RoutineEnvConfig | null; baseRoutineRevisionId?: string | null },
) => api.patch<PipelineStage>(`/pipelines/${pipelineId}/stages/${stageId}/automation-env`, data),
deleteStage: (
pipelineId: string,
stageId: string,
data?: { moveCasesToStageId?: string | null },
) => {
const params = new URLSearchParams();
if (data?.moveCasesToStageId) params.set("moveCasesToStageId", data.moveCasesToStageId);
const qs = params.toString();
return api.delete<{ deleted: boolean }>(`/pipelines/${pipelineId}/stages/${stageId}${qs ? `?${qs}` : ""}`);
},
setTransitions: (
pipelineId: string,
data: { transitions: PipelineTransitionEdge[]; enforceTransitions?: boolean },
) =>
api.put<{ transitions: Array<{ fromStageId: string; toStageId: string; label?: string | null }> }>(
`/pipelines/${pipelineId}/transitions`,
data,
),
getDocument: (pipelineId: string, key: string) =>
api.get<PipelineDocumentPayload>(`/pipelines/${pipelineId}/documents/${encodeURIComponent(key)}`),
upsertDocument: (pipelineId: string, key: string, data: { title?: string; body: string; baseRevisionId?: string | null }) =>
api.put<{ document: PipelineDocumentPayload["document"]; revision: NonNullable<PipelineDocumentPayload["revision"]> }>(
`/pipelines/${pipelineId}/documents/${encodeURIComponent(key)}`,
data,
),
listDocumentRevisions: (pipelineId: string, key: string) =>
api.get<PipelineDocumentRevision[]>(`/pipelines/${pipelineId}/documents/${encodeURIComponent(key)}/revisions`),
restoreDocumentRevision: (pipelineId: string, key: string, revisionId: string) =>
api.post<{
document: PipelineDocumentPayload["document"];
revision: PipelineDocumentRevision;
restoredFromRevisionId: string;
restoredFromRevisionNumber: number;
}>(`/pipelines/${pipelineId}/documents/${encodeURIComponent(key)}/revisions/${revisionId}/restore`, {}),
getCaseDocument: (caseId: string, key: string) =>
api.get<PipelineCaseDocumentPayload>(`/cases/${caseId}/documents/${encodeURIComponent(key)}`),
upsertCaseDocument: (
caseId: string,
key: string,
data: { title?: string; format?: string; body: string; changeSummary?: string | null; baseRevisionId?: string | null },
) =>
api.put<{ document: PipelineCaseDocumentPayload["document"]; revision: PipelineCaseDocumentRevision }>(
`/cases/${caseId}/documents/${encodeURIComponent(key)}`,
data,
),
listCaseDocumentRevisions: (caseId: string, key: string) =>
api.get<PipelineCaseDocumentRevision[]>(`/cases/${caseId}/documents/${encodeURIComponent(key)}/revisions`),
restoreCaseDocumentRevision: (caseId: string, key: string, revisionId: string) =>
api.post<{
document: PipelineCaseDocumentPayload["document"];
revision: PipelineCaseDocumentRevision;
restoredFromRevisionId: string;
restoredFromRevisionNumber: number;
}>(`/cases/${caseId}/documents/${encodeURIComponent(key)}/revisions/${revisionId}/restore`, {}),
getIntakeForm: (pipelineId: string) => api.get<PipelineIntakeForm>(`/pipelines/${pipelineId}/intake-form`),
listCases: (pipelineId: string, filters?: { parentCaseId?: string; terminal?: boolean }) => {
const params = new URLSearchParams();
if (filters?.parentCaseId) params.set("parentCaseId", filters.parentCaseId);
if (filters?.terminal !== undefined) params.set("terminal", filters.terminal ? "true" : "false");
const qs = params.toString();
return api.get<PipelineCaseChildRow[]>(`/pipelines/${pipelineId}/cases${qs ? `?${qs}` : ""}`);
},
getCase: (caseId: string) => api.get<PipelineCaseDetail>(`/cases/${caseId}`),
getCaseChildren: (caseId: string) =>
api.get<PipelineCaseChildrenResponse>(`/cases/${caseId}/children`),
getCaseChildrenTree: (caseId: string) =>
api.get<PipelineCaseChildrenTree>(`/cases/${caseId}/children/tree`),
getCaseEvents: (caseId: string, filters?: { limit?: number; offset?: number; order?: "asc" | "desc" }) => {
const params = new URLSearchParams();
if (filters?.limit !== undefined) params.set("limit", String(filters.limit));
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
if (filters?.order) params.set("order", filters.order);
const qs = params.toString();
return api.get<PipelineCaseEventsPage>(`/cases/${caseId}/events${qs ? `?${qs}` : ""}`);
},
getCaseIssueLinks: (caseId: string) =>
api.get<PipelineCaseIssueLinkWithIssue[]>(`/cases/${caseId}/issue-links`),
getCaseOutputs: (caseId: string) =>
api.get<PipelineCaseOutputsResponse>(`/cases/${caseId}/outputs`),
createIssueLink: (
caseId: string,
data:
| { issueId: string; role: PipelineCaseIssueLink["role"] }
| { role: "conversation"; issueId?: undefined },
) => data.issueId
? api.post<PipelineCaseIssueLink>(`/cases/${caseId}/issue-links`, data)
: api.post<{ issue: Issue; created: boolean }>(`/cases/${caseId}/open-conversation`, {}),
updateCase: (
caseId: string,
data: {
title?: string;
summary?: string | null;
fields?: Record<string, unknown>;
parentCaseId?: string | null;
expectedVersion?: number;
leaseToken?: string | null;
},
) => api.patch<{ case: PipelineCase; event?: PipelineCaseEvent | null } | PipelineCase>(`/cases/${caseId}`, data),
acknowledgeDrift: (caseId: string, data?: { expectedVersion?: number }) =>
api.post<{ case: PipelineCase; event: PipelineCaseEvent | null; acknowledged: boolean }>(
`/cases/${caseId}/acknowledge-drift`,
data ?? {},
),
resolveSuggestion: (
caseId: string,
data: {
suggestionId: string;
resolution: "accept" | "dismiss";
expectedVersion?: number;
reason?: string | null;
leaseToken?: string | null;
},
) => api.post<unknown>(`/cases/${caseId}/resolve-suggestion`, data),
transitionCase: (
caseId: string,
data: {
toStageKey: string;
expectedVersion: number;
reason?: string | null;
leaseToken?: string | null;
acceptSuggestionId?: string;
force?: boolean;
},
) => api.post<unknown>(`/cases/${caseId}/transition`, data),
rerunCurrentStageAutomation: (caseId: string) =>
api.post<unknown>(`/cases/${caseId}/automation/current-stage/rerun`, {}),
getAutomationRetryPlan: (caseId: string, scope: PipelineAutomationRetryScope, targetStageId?: string | null) => {
const params = new URLSearchParams({ scope });
if (targetStageId) params.set("targetStageId", targetStageId);
return api.get<PipelineAutomationRetryPlan>(`/cases/${caseId}/automation/retry-plan?${params.toString()}`);
},
retryStageAutomation: (
caseId: string,
data: {
scope: PipelineAutomationRetryScope;
targetStageId?: string | null;
expectedVersion: number;
cleanup: PipelineAutomationRetryCleanupOptions;
},
) => api.post<unknown>(`/cases/${caseId}/automation/retry`, data),
retryAutomation: (caseId: string, automationId: string) =>
api.post<unknown>(`/cases/${caseId}/automations/${automationId}/retry`, {}),
ingestCasesBatch: (pipelineId: string, data: {
items: Array<{
caseKey?: string | null;
title: string;
fields?: Record<string, unknown>;
stageKey?: string | null;
parentCaseId?: string | null;
requestKey?: string | null;
blockedByCaseIds?: string[];
blockedByCaseKeys?: string[];
}>;
}) =>
api.post<PipelineBatchIngestResult[]>(`/pipelines/${pipelineId}/cases/batch`, data),
listAttention: (companyId: string, options?: { limit?: number }) => {
const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit));
const qs = params.toString();
return api.get<PipelineAttentionFeed>(`/companies/${companyId}/pipelines-attention${qs ? `?${qs}` : ""}`);
},
listReviewCases: (companyId: string, filters?: { pipelineId?: string; parentCaseId?: string }) => {
const params = new URLSearchParams();
if (filters?.pipelineId) params.set("pipelineId", filters.pipelineId);
if (filters?.parentCaseId) params.set("parentCaseId", filters.parentCaseId);
const qs = params.toString();
return api.get<PipelineReviewCaseRow[]>(`/companies/${companyId}/review-cases${qs ? `?${qs}` : ""}`);
},
reviewCase: (
caseId: string,
data: {
decision: PipelineReviewDecision;
reason?: string | null;
expectedVersion: number;
leaseToken?: string | null;
},
) => api.post<unknown>(`/cases/${caseId}/review`, data),
bulkReviewCases: (
companyId: string,
data: {
items: Array<{
caseId: string;
decision: PipelineReviewDecision;
reason?: string | null;
expectedVersion: number;
}>;
},
) => api.post<PipelineBulkReviewResult>(`/companies/${companyId}/review-cases/bulk`, data),
listCompanyCaseEvents: (
companyId: string,
filters?: { types?: string; limit?: number; offset?: number },
) => {
const params = new URLSearchParams();
if (filters?.types) params.set("types", filters.types);
if (filters?.limit !== undefined) params.set("limit", String(filters.limit));
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
const qs = params.toString();
return api.get<PipelineCompanyCaseEventsPage>(`/companies/${companyId}/case-events${qs ? `?${qs}` : ""}`);
},
};