mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Give tasks durable company-scoped workspace bindings independent of project assignment, channel defaults and authorized repository preparation. Reuse verified input seeds and sparse output recovery while preserving trust, privacy and controller provisioning boundaries.
Full CI and E2E, Superagent, legacy checks and Greptile Apex 5/5 passed on reviewed head 753680b61c. All review findings are resolved; the PR records focused verification and historical acceptance limits.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
160 lines
6.7 KiB
TypeScript
160 lines
6.7 KiB
TypeScript
import type {
|
|
ExecutionWorkspace,
|
|
ExecutionWorkspaceSummary,
|
|
ExecutionWorkspaceStatus,
|
|
ExecutionWorkspaceCloseReadiness,
|
|
WorkspaceOverviewResponse,
|
|
WorkspaceLoginHandoffTicketResponse,
|
|
WorkspaceOperation,
|
|
WorkspaceRuntimeControlTarget,
|
|
} from "@paperclipai/shared";
|
|
import { api } from "./client";
|
|
import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control";
|
|
|
|
type WorkspaceOverviewFilters = {
|
|
projectId?: string;
|
|
status?: ExecutionWorkspaceStatus[];
|
|
limit?: number;
|
|
offset?: number;
|
|
};
|
|
|
|
function normalizeWorkspaceOverview(response: WorkspaceOverviewResponse): WorkspaceOverviewResponse {
|
|
return {
|
|
...response,
|
|
items: response.items.map((item) => ({
|
|
...item,
|
|
lastUpdatedAt: new Date(item.lastUpdatedAt),
|
|
primaryService: item.primaryService
|
|
? {
|
|
...item.primaryService,
|
|
updatedAt: new Date(item.primaryService.updatedAt),
|
|
}
|
|
: null,
|
|
linkedIssues: item.linkedIssues.map((issue) => ({
|
|
...issue,
|
|
updatedAt: new Date(issue.updatedAt),
|
|
})),
|
|
})),
|
|
};
|
|
}
|
|
|
|
export const executionWorkspacesApi = {
|
|
listOverview: async (companyId: string, filters?: WorkspaceOverviewFilters) => {
|
|
const params = new URLSearchParams();
|
|
if (filters?.projectId) params.set("projectId", filters.projectId);
|
|
if (filters?.status?.length) params.set("status", filters.status.join(","));
|
|
if (filters?.limit !== undefined) params.set("limit", String(filters.limit));
|
|
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
|
|
const qs = params.toString();
|
|
const response = await api.get<WorkspaceOverviewResponse>(
|
|
`/companies/${companyId}/workspace-overview${qs ? `?${qs}` : ""}`,
|
|
);
|
|
return normalizeWorkspaceOverview(response);
|
|
},
|
|
listSummaries: (
|
|
companyId: string,
|
|
filters?: {
|
|
projectId?: string;
|
|
projectWorkspaceId?: string;
|
|
issueId?: string;
|
|
status?: string;
|
|
reuseEligible?: boolean;
|
|
selectableForTask?: boolean;
|
|
},
|
|
) => {
|
|
const params = new URLSearchParams();
|
|
if (filters?.projectId) params.set("projectId", filters.projectId);
|
|
if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId);
|
|
if (filters?.issueId) params.set("issueId", filters.issueId);
|
|
if (filters?.status) params.set("status", filters.status);
|
|
if (filters?.reuseEligible) params.set("reuseEligible", "true");
|
|
if (filters?.selectableForTask) params.set("selectableForTask", "true");
|
|
params.set("summary", "true");
|
|
const qs = params.toString();
|
|
return api.get<ExecutionWorkspaceSummary[]>(
|
|
`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`,
|
|
);
|
|
},
|
|
list: (
|
|
companyId: string,
|
|
filters?: {
|
|
projectId?: string;
|
|
projectWorkspaceId?: string;
|
|
issueId?: string;
|
|
status?: string;
|
|
reuseEligible?: boolean;
|
|
},
|
|
) => {
|
|
const params = new URLSearchParams();
|
|
if (filters?.projectId) params.set("projectId", filters.projectId);
|
|
if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId);
|
|
if (filters?.issueId) params.set("issueId", filters.issueId);
|
|
if (filters?.status) params.set("status", filters.status);
|
|
if (filters?.reuseEligible) params.set("reuseEligible", "true");
|
|
const qs = params.toString();
|
|
return api.get<ExecutionWorkspace[]>(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`);
|
|
},
|
|
get: (id: string) => api.get<ExecutionWorkspace>(`/execution-workspaces/${id}`),
|
|
getCloseReadiness: (id: string) =>
|
|
api.get<ExecutionWorkspaceCloseReadiness>(`/execution-workspaces/${id}/close-readiness`),
|
|
listWorkspaceOperations: (id: string) =>
|
|
api.get<WorkspaceOperation[]>(`/execution-workspaces/${id}/workspace-operations`),
|
|
controlRuntimeServices: (
|
|
id: string,
|
|
action: "start" | "stop" | "restart",
|
|
target: WorkspaceRuntimeControlTarget = {},
|
|
) =>
|
|
api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>(
|
|
`/execution-workspaces/${id}/runtime-services/${action}`,
|
|
sanitizeWorkspaceRuntimeControlTarget(target),
|
|
),
|
|
controlRuntimeCommands: (
|
|
id: string,
|
|
action: "start" | "stop" | "restart" | "run",
|
|
target: WorkspaceRuntimeControlTarget = {},
|
|
) =>
|
|
api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>(
|
|
`/execution-workspaces/${id}/runtime-commands/${action}`,
|
|
sanitizeWorkspaceRuntimeControlTarget(target),
|
|
),
|
|
repair: (id: string) =>
|
|
api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>(
|
|
`/execution-workspaces/${id}/runtime-commands/repair`,
|
|
{},
|
|
),
|
|
/**
|
|
* Mint a single-use workspace login handoff (PAP-17572).
|
|
*
|
|
* The returned URL carries a short-lived ticket, so the caller must navigate to
|
|
* it rather than store or share it. The server answers the navigation with an
|
|
* HTTP redirect, which is what keeps the ticket out of browser history.
|
|
*/
|
|
requestLoginHandoff: (id: string, next?: string) =>
|
|
api.post<WorkspaceLoginHandoffTicketResponse>(
|
|
`/execution-workspaces/${id}/login-handoff`,
|
|
next ? { next } : {},
|
|
),
|
|
update: (id: string, data: Record<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
|
|
/**
|
|
* Reconcile a git-worktree branch divergence via the S4 (`PAP-1586`) op.
|
|
*
|
|
* Hits `POST /execution-workspaces/:id/reconcile-branch`. That route is the reviewed,
|
|
* OpenAPI-documented backend contract and already ships on `master`: it was merged ahead of this
|
|
* client change in `server/src/routes/execution-workspaces.ts` (route registration:
|
|
* `router.post("/execution-workspaces/:id/reconcile-branch", ...)`, landed in PR #9170, with the
|
|
* `forward` auto-reconcile path in PR #9172). This client is therefore additive against an
|
|
* existing endpoint, not a call to a missing one. Keep this path byte-identical to the backend
|
|
* route; the drift is pinned by a regression test in `execution-workspaces.test.ts`.
|
|
* - `mode: "forward"` — server re-verifies `ancestryVerdict === "ancestor"` (client hint is
|
|
* never trusted); no `reason` needed.
|
|
* - `mode: "override"` — audited break-glass; the server rejects agent actors, re-checks
|
|
* `runtime:manage` permission, and requires a non-empty operator `reason`.
|
|
* - `mode: "quarantine_restore"` — lossless dirty-worktree repair; the server quarantines the
|
|
* dirty changes onto a rescue branch and restores the recorded branch. No `reason` needed.
|
|
*/
|
|
reconcile: (
|
|
id: string,
|
|
body: { mode: "forward" } | { mode: "override"; reason: string } | { mode: "quarantine_restore" },
|
|
) => api.post<ExecutionWorkspace>(`/execution-workspaces/${id}/reconcile-branch`, body),
|
|
};
|