mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## Thinking Path > - Paperclip is the open source app that people use to manage AI agents for work. > - Operators use the settings area to control a company and its Paperclip instance. > - The current navigation separates related settings and uses duplicate instance pages. > - Company exports also do independent reads in sequence and do extra work for previews. > - Hardened workspace commands can differ from their saved command after loopback binding. > - This pull request makes these related operator workflows consistent and faster. > - The benefit is one clear settings area, faster exports, and stable runtime command matching. ## Linked Issues or Issue Description Refs #338 Related: #9834 **What existing behavior does this improve?** This improves the company settings UI, company export preparation, and workspace runtime command matching. **Current behavior** Company and instance settings use separate navigation and duplicate pages. Export preparation reads many independent records in sequence. Preview generation can also build an unused organization image. A command with a forced loopback bind can fail to match its saved runtime command. **Proposed behavior** Use one settings navigation and put general instance controls on the company General page. Load independent export data with bounded concurrency, skip unused preview image work, and load the export page only when it is needed. Treat the loopback-bound form of a command as the same runtime command. **Reason and benefit** Operators get one clear settings area. Large company exports need fewer serialized reads. Export previews and initial UI loads do less work. Hardened runtime services remain linked to their saved command definitions. **Breaking changes** The obsolete instance General URL redirects to the unified settings page. Access and Heartbeats remain available, and legacy bookmarks keep their destinations. No API response shape or database schema changes. ## What Changed - Unified company and instance settings navigation and removed duplicate instance settings pages. - Embedded general instance controls in the company General page and kept access-sensitive navigation behavior. - Preserved instance Access and Heartbeats controls in the unified navigation and normalized old bookmarks to those destinations. - Improved environment and access-state handling when workspace seed requests overlap. - Added bounded export reads, a lighter preview path, deferred export preparation, and lazy export-page loading. - Matched loopback-bound runtime commands to their saved command definitions. - Added focused shared, server, and UI regression tests. ## Verification - `pnpm exec vitest run <18 changed test files>`: 18 files and 256 tests passed. - `pnpm check:token-gates`: passed all four token gates. - `pnpm -r typecheck`: passed for all workspace projects. - `pnpm build`: passed for all workspace projects. - `pnpm test:run`: tests ran without a reported failure, but the runner did not close after the server handoff tests. The process closed with status 0 after an interrupt. - Focused latest-head route tests: 2 files and 4 tests passed. - GitHub latest-head checks: all completed without failure. - Greptile: 5/5 with no unresolved review threads. ## Risks - Medium risk: settings routes and navigation changed across several operator roles. - Medium risk: bounded export concurrency increases simultaneous database reads. The limits stay below the normal pool size. - Low risk: runtime command matching accepts only the known Tailscale HTTPS loopback transformation. - No migrations are included. > 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 with a GPT-5-family coding model. The runtime does not expose the exact deployed model ID or context-window size. Reasoning, tool use, and local code execution were enabled. ## 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 Exception: This task requires the existing execution branch. The harness does not permit a branch rename. - [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>
175 lines
7.5 KiB
TypeScript
175 lines
7.5 KiB
TypeScript
import type {
|
|
Company,
|
|
CompanyPortabilityExportRequest,
|
|
CompanyPortabilityExportPreviewResult,
|
|
CompanyPortabilityExportResult,
|
|
CompanyPortabilityImportRequest,
|
|
CompanyPortabilityImportResult,
|
|
CompanyPortabilityPreviewRequest,
|
|
CompanyPortabilityPreviewResult,
|
|
UpdateCompanyBranding,
|
|
} from "@paperclipai/shared";
|
|
import type { ExportFidelityReport } from "@paperclipai/shared/portability-fidelity";
|
|
import {
|
|
companyImportTransferApplyPath,
|
|
companyImportTransferPartPath,
|
|
companyImportTransferPath,
|
|
companyImportTransferPreviewPath,
|
|
COMPANY_IMPORT_TRANSFERS_ROUTE_PATH,
|
|
type CompanyImportTransferCreated,
|
|
type CompanyImportTransferDeclaration,
|
|
type CompanyImportTransferPartUploadResult,
|
|
type CompanyImportTransferStatus,
|
|
} from "@paperclipai/shared/company-import-transfer";
|
|
import { api, detachInflightGet, type RequestOptions } from "./client";
|
|
|
|
const COMPANIES_LIST_PATH = "/companies";
|
|
|
|
export type CompanyStats = Record<string, { agentCount: number; issueCount: number }>;
|
|
|
|
/**
|
|
* Import fields for a zip package upload: everything the JSON request carries
|
|
* except `source` — the source is the uploaded zip, read server-side. Sent as a
|
|
* JSON-encoded `meta` form field alongside the raw `package` file.
|
|
*/
|
|
export type CompanyPortabilityPreviewMeta = Omit<CompanyPortabilityPreviewRequest, "source">;
|
|
export type CompanyPortabilityImportMeta = Omit<CompanyPortabilityImportRequest, "source">;
|
|
|
|
function importPackageForm(file: File, meta: CompanyPortabilityPreviewMeta | CompanyPortabilityImportMeta): FormData {
|
|
const form = new FormData();
|
|
form.append("package", file);
|
|
form.append("meta", JSON.stringify(meta));
|
|
return form;
|
|
}
|
|
|
|
export type CompanyImportJobState = "running" | "succeeded" | "failed";
|
|
|
|
/** 202 body from the async opt-in on POST /companies/import (and the 409 body when a job is already running). */
|
|
export interface CompanyImportJobAccepted {
|
|
job: { id: string; status: CompanyImportJobState };
|
|
statusUrl: string;
|
|
retryAfterMs?: number;
|
|
}
|
|
|
|
export interface CompanyImportJobStatus {
|
|
job: {
|
|
id: string;
|
|
status: CompanyImportJobState;
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
completedAt?: string;
|
|
error?: { message: string };
|
|
/**
|
|
* Summary retained for every terminal job (board and cloud tenant); carries
|
|
* the imported company id so the page can navigate even when the full
|
|
* `importResult` is no longer available.
|
|
*/
|
|
result?: {
|
|
companyId: string;
|
|
agentCount?: number;
|
|
warningCount?: number;
|
|
companyAction?: unknown;
|
|
};
|
|
/** Board-created jobs carry the full result for parity with the sync response. */
|
|
importResult?: CompanyPortabilityImportResult;
|
|
};
|
|
retryAfterMs?: number;
|
|
}
|
|
|
|
export const companiesApi = {
|
|
list: () => api.get<Company[]>(COMPANIES_LIST_PATH),
|
|
/**
|
|
* Call before re-reading the list for a different account: an in-flight
|
|
* `/companies` GET issued under the previous session would otherwise be
|
|
* coalesced into, and answer with that account's companies.
|
|
*/
|
|
detachInflightList: () => detachInflightGet(COMPANIES_LIST_PATH),
|
|
get: (companyId: string) => api.get<Company>(`/companies/${companyId}`),
|
|
stats: () => api.get<CompanyStats>("/companies/stats"),
|
|
create: (data: {
|
|
name: string;
|
|
description?: string | null;
|
|
budgetMonthlyCents?: number;
|
|
}) =>
|
|
api.post<Company>("/companies", data),
|
|
update: (
|
|
companyId: string,
|
|
data: Partial<
|
|
Pick<
|
|
Company,
|
|
| "name"
|
|
| "description"
|
|
| "status"
|
|
| "budgetMonthlyCents"
|
|
| "attachmentMaxBytes"
|
|
| "requireBoardApprovalForNewAgents"
|
|
| "interactionResolverGovernance"
|
|
| "feedbackDataSharingEnabled"
|
|
| "brandColor"
|
|
| "logoAssetId"
|
|
>
|
|
>,
|
|
) => api.patch<Company>(`/companies/${companyId}`, data),
|
|
updateBranding: (companyId: string, data: UpdateCompanyBranding) =>
|
|
api.patch<Company>(`/companies/${companyId}/branding`, data),
|
|
archive: (companyId: string) => api.post<Company>(`/companies/${companyId}/archive`, {}),
|
|
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
|
|
exportBundle: (
|
|
companyId: string,
|
|
data: CompanyPortabilityExportRequest,
|
|
) =>
|
|
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/exports`, data),
|
|
exportPreview: (
|
|
companyId: string,
|
|
data: CompanyPortabilityExportRequest,
|
|
options?: RequestOptions,
|
|
) =>
|
|
api.post<CompanyPortabilityExportPreviewResult>(`/companies/${companyId}/exports/preview`, data, options),
|
|
exportFidelity: (companyId: string) =>
|
|
api.get<ExportFidelityReport>(`/companies/${companyId}/export/fidelity`),
|
|
importPreview: (data: CompanyPortabilityPreviewRequest) =>
|
|
api.post<CompanyPortabilityPreviewResult>("/companies/import/preview", data),
|
|
/** Preview a local .zip package by uploading the raw compressed zip as multipart. */
|
|
importPreviewPackage: (file: File, meta: CompanyPortabilityPreviewMeta) =>
|
|
api.postForm<CompanyPortabilityPreviewResult>("/companies/import/preview", importPackageForm(file, meta)),
|
|
importBundle: (data: CompanyPortabilityImportRequest) =>
|
|
api.post<CompanyPortabilityImportResult>("/companies/import", data),
|
|
// Submit an import as a server-side job: 202 with a job id to poll, or 409
|
|
// with the already-running job. Board sessions opt in with the proxy-safe
|
|
// `?async=1` query parameter — the Cloud harness strips the inbound
|
|
// `x-paperclip-cloud-*` header a browser would otherwise use, so that header
|
|
// never survives to engage the async path.
|
|
importBundleAsync: (data: CompanyPortabilityImportRequest) =>
|
|
api.post<CompanyImportJobAccepted>("/companies/import?async=1", data),
|
|
/** Submit a local .zip package as an async import job by uploading the raw compressed zip as multipart. */
|
|
importBundlePackageAsync: (file: File, meta: CompanyPortabilityImportMeta) =>
|
|
api.postForm<CompanyImportJobAccepted>("/companies/import?async=1", importPackageForm(file, meta)),
|
|
getImportJob: (jobId: string) =>
|
|
api.get<CompanyImportJobStatus>(`/companies/import/jobs/${encodeURIComponent(jobId)}`),
|
|
// Chunked resumable transfer for large local .zip packages: declare the
|
|
// sliced zip (content-addressed, so re-declaring the same file resumes the
|
|
// prior transfer with its uploaded parts intact), upload the missing parts,
|
|
// then preview/apply against the server-side assembled spool. Shapes and
|
|
// paths come from the shared transfer contract.
|
|
importTransferCreate: (manifest: CompanyImportTransferDeclaration) =>
|
|
api.post<CompanyImportTransferCreated>(`/companies${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}`, manifest),
|
|
importTransferUploadPart: (transferId: string, index: number, bytes: Blob) =>
|
|
api.putRaw<CompanyImportTransferPartUploadResult>(
|
|
`/companies${companyImportTransferPartPath(transferId, index)}`,
|
|
bytes,
|
|
),
|
|
importTransferStatus: (transferId: string) =>
|
|
api.get<CompanyImportTransferStatus>(`/companies${companyImportTransferPath(transferId)}`),
|
|
importTransferPreview: (transferId: string, meta: CompanyPortabilityPreviewMeta) =>
|
|
api.post<CompanyPortabilityPreviewResult>(
|
|
`/companies${companyImportTransferPreviewPath(transferId)}`,
|
|
meta,
|
|
),
|
|
/** Apply a fully uploaded transfer as an async job (same 202/409 contract as importBundlePackageAsync). */
|
|
importTransferApply: (transferId: string, meta: CompanyPortabilityImportMeta) =>
|
|
api.post<CompanyImportJobAccepted>(
|
|
`/companies${companyImportTransferApplyPath(transferId)}?async=1`,
|
|
meta,
|
|
),
|
|
};
|