feat(server): chunked resumable company import transfers (#11223)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Company import moves large packages into an instance, and since the
upload cap rose to 1 GB, the transport is the weak point: one HTTP
request, buffered fully in memory, with no resume
> - A dropped connection at 90% of an 800 MB upload starts the whole
transfer over, and a server restart loses all progress
> - This pull request adds the server side of chunked resumable import
transfers: a durable run ledger and routes that accept the same import
zip as verified ~32 MB parts spooled to disk
> - An interrupted transfer resumes from the parts already uploaded —
across dropped connections, page refreshes, and server restarts — and
peak upload memory drops from the whole package to one part
> - The benefit is that large imports become reliable on real-world
connections instead of all-or-nothing

## Linked Issues or Issue Description

**What happened?**

Large company imports travel as a single HTTP upload. On a slow or flaky
connection, any interruption discards all progress and the upload
restarts from zero. The server buffers the entire compressed package in
memory during upload. A server restart mid-upload loses the transfer
entirely. With the upload cap now at 1 GB, these failure modes govern
exactly the imports the cap was raised for.

**Expected behavior**

A large import upload survives interruptions: already-transferred data
is kept and verified, only the missing remainder is re-sent, and the
server's memory use during upload is bounded by a part, not the package.

**Steps to reproduce**

1. Import a multi-hundred-MB company package over a connection that
drops mid-upload.
2. The upload fails; retrying starts from byte zero.
3. Repeat on an unstable connection and the import may never complete.

## What Changed

- New `company_transfer_runs` table (drizzle schema + migration) and
`companyTransferRunService`: one row per transfer with a content-derived
idempotency key, per-part completion recorded atomically and
idempotently, resume scoped to actor and direction, completed runs
short-circuiting retries of identical content.
- New transfer routes beside the existing import routes, same
authorization: declare a sliced zip (`POST /import/transfers` —
validates cap, 64 MB part ceiling, contiguity, size sums, sha256
format), upload parts (`PUT .../parts/:n` — raw body, hash-and-size
verified before an atomic write to a disk spool under the instance root;
re-uploads are no-op successes), poll resume state (`GET .../:id` —
missing parts recomputed from disk), and apply (`POST .../:id/apply` —
requires all parts, re-verifies the assembled zip against the whole-file
hash fail-closed, then feeds the existing import pipeline through
factored helpers rather than duplicated logic).
- Hourly sweep fails and cleans spools idle for 24 h; a swept transfer
honestly reports all parts missing on resume.
- Strict UUID gating on run ids before any filesystem path construction.
- The existing single-shot upload path is untouched; clients arrive in
the follow-up PR.

## Verification

- Transfer route suite (embedded Postgres): create/upload/status/apply
round-trip with a real imported company, out-of-order parts, wrong-hash
part rejected and unrecorded, re-upload no-op, apply-with-missing-parts
rejection, resume after failure with prior progress intact,
assembled-hash mismatch failing closed with spool deletion, actor
scoping 404s, async-job apply, sweep followed by honest resume.
- Ledger suite (embedded Postgres): part idempotency, actor/direction
scoping, completed-run short-circuit, cancelled runs staying cancelled.
- Existing portability route suite unchanged and green; server + db
typechecks clean. Exact counts in the PR checks.

## Risks

- New routes are additive; the existing import path is untouched. The
transfer routes carry the same board authorization as the import routes
they sit beside.
- Disk spool: bounded by the existing upload cap per transfer, cleaned
on success, failure, hash mismatch, and by the 24 h sweep. Spool paths
are strict-UUID-gated.
- The apply step still materializes the assembled zip in memory once
(same profile as today's single-shot import at apply time); upload-time
memory drops to one part.
- Known limitation, deliberate: transfers are keyed on content alone, so
identical package content cannot be imported twice without re-exporting
(surfaced explicitly to the caller). Acceptable for v1; noted for
review.

## Model Used

- Claude Fable 5 (`claude-fable-5`) via Claude Code CLI, with extended
thinking and tool use (multi-agent implementation with independent
verification).

## 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
This commit is contained in:
Devin Foley 2026-08-11 15:25:07 -07:00 committed by GitHub
parent 2494a2a0fe
commit 23a1b025c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 41381 additions and 32 deletions

View File

@ -0,0 +1,24 @@
CREATE TABLE "company_transfer_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid,
"direction" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"actor_key" text NOT NULL,
"container_ref" jsonb NOT NULL,
"idempotency_key" text NOT NULL,
"manifest_sha256" text,
"manifest" jsonb,
"chunk_count" integer DEFAULT 0 NOT NULL,
"blob_count" integer DEFAULT 0 NOT NULL,
"completed_parts" jsonb DEFAULT '[]'::jsonb NOT NULL,
"error" text,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "company_transfer_runs" ADD CONSTRAINT "company_transfer_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "company_transfer_runs_company_idx" ON "company_transfer_runs" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX "company_transfer_runs_idempotency_direction_idx" ON "company_transfer_runs" USING btree ("idempotency_key","direction");--> statement-breakpoint
CREATE INDEX "company_transfer_runs_actor_status_idx" ON "company_transfer_runs" USING btree ("actor_key","status");

File diff suppressed because it is too large Load Diff

View File

@ -1478,6 +1478,13 @@
"when": 1786388759523,
"tag": "0212_onboarding_first_task_unique",
"breakpoints": true
},
{
"idx": 213,
"version": "7",
"when": 1786467951626,
"tag": "0213_complete_mystique",
"breakpoints": true
}
]
}
}

View File

@ -0,0 +1,49 @@
import { pgTable, uuid, text, integer, jsonb, timestamp, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
// Durable ledger for chunked company transfers (export publishes and import
// applies). One row per run; `completedParts` records every container part
// (chunk or blob name) that has been fully processed and verified, so an
// interrupted run resumes from the parts it already finished instead of
// restarting. `idempotencyKey` is the transfer manifest's content-derived key:
// a retried publish/apply of identical content finds its prior run.
export const companyTransferRuns = pgTable(
"company_transfer_runs",
{
id: uuid("id").primaryKey().defaultRandom(),
// Null while an import targeting a new company has not created it yet;
// set as soon as the destination company exists.
companyId: uuid("company_id").references(() => companies.id, { onDelete: "set null" }),
direction: text("direction").notNull(), // "export" | "import"
status: text("status").notNull().default("pending"), // pending | running | applying | completed | failed | cancelled
// The actor the run belongs to (board session / API key identity); run
// visibility and resume are scoped to the same actor.
actorKey: text("actor_key").notNull(),
// Credential-free description of where the container lives (zip upload
// reference or relay bucket/prefix). Secrets never land in this column.
containerRef: jsonb("container_ref").notNull(),
idempotencyKey: text("idempotency_key").notNull(),
manifestSha256: text("manifest_sha256"),
// The parsed transfer manifest (chunk + blob index). Persisted so resume
// can re-verify parts without refetching manifest.json first.
manifest: jsonb("manifest"),
chunkCount: integer("chunk_count").notNull().default(0),
blobCount: integer("blob_count").notNull().default(0),
// Container part names ("chunks/0000.json.gz", "blobs/<sha256>") that
// completed processing, in completion order.
completedParts: jsonb("completed_parts").notNull().default([]),
error: text("error"),
startedAt: timestamp("started_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIdx: index("company_transfer_runs_company_idx").on(table.companyId),
idempotencyDirectionIdx: index("company_transfer_runs_idempotency_direction_idx").on(
table.idempotencyKey,
table.direction,
),
actorStatusIdx: index("company_transfer_runs_actor_status_idx").on(table.actorKey, table.status),
}),
);

View File

@ -1,5 +1,6 @@
export { companies } from "./companies.js";
export { companyLogos } from "./company_logos.js";
export { companyTransferRuns } from "./company_transfer_runs.js";
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
export { instanceSettings } from "./instance_settings.js";
export { instanceUserRoles } from "./instance_user_roles.js";

View File

@ -0,0 +1,98 @@
import { z } from "zod";
// Wire contract for chunked resumable company import transfers. The server
// routes validate against these schemas and the browser and CLI clients type
// their requests/responses against the inferred types, so all three sides
// share one description of the payload shapes and route paths.
/** Transfer routes relative to the companies router (`/api/companies`). */
export const COMPANY_IMPORT_TRANSFERS_ROUTE_PATH = "/import/transfers";
export const COMPANY_IMPORT_TRANSFERS_API_PATH = `/api/companies${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}`;
/**
* Paths for one transfer, relative to the companies API root callers prefix
* their own mount ("/companies" in the browser client, "/api/companies" in
* the CLI; the server registers the same shapes as express params).
*/
export function companyImportTransferPath(transferId: string): string {
return `${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}/${encodeURIComponent(transferId)}`;
}
export function companyImportTransferPartPath(transferId: string, partIndex: number): string {
return `${companyImportTransferPath(transferId)}/parts/${partIndex}`;
}
export function companyImportTransferPreviewPath(transferId: string): string {
return `${companyImportTransferPath(transferId)}/preview`;
}
export function companyImportTransferApplyPath(transferId: string): string {
return `${companyImportTransferPath(transferId)}/apply`;
}
/** Hard cap on the number of declared byte-range parts per transfer. */
export const COMPANY_IMPORT_TRANSFER_MAX_PARTS = 4096;
const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
export const companyImportTransferDeclaredPartSchema = z.object({
index: z.number().int().min(0),
byteSize: z.number().int().min(1),
sha256: z
.string()
.regex(SHA256_HEX_RE, "sha256 must be 64 lowercase hex characters")
.describe("sha256 of this part, 64 lowercase hex characters"),
});
/**
* Declaration body for POST /api/companies/import/transfers: the caller's
* existing .zip described as contiguous content-addressed byte-range parts.
*/
export const companyImportTransferDeclarationSchema = z.object({
totalBytes: z.number().int().min(1),
zipSha256: z
.string()
.regex(SHA256_HEX_RE, "sha256 must be 64 lowercase hex characters")
.describe("sha256 of the whole .zip, 64 lowercase hex characters"),
partSizeBytes: z.number().int().min(1),
parts: z.array(companyImportTransferDeclaredPartSchema).min(1).max(COMPANY_IMPORT_TRANSFER_MAX_PARTS),
});
export type CompanyImportTransferDeclaredPart = z.infer<typeof companyImportTransferDeclaredPartSchema>;
export type CompanyImportTransferDeclaration = z.infer<typeof companyImportTransferDeclarationSchema>;
/** Ledger states a transfer run moves through (see company_transfer_runs). */
export type CompanyImportTransferRunStatus =
| "pending"
| "running"
| "applying"
| "completed"
| "failed"
| "cancelled";
/** Response of declaring (or resuming) a chunked import transfer. */
export interface CompanyImportTransferCreated {
transferId: string;
status: CompanyImportTransferRunStatus;
/** True when this exact content already finished a prior apply. */
alreadyCompleted: boolean;
totalParts: number;
missingParts: number[];
}
/** Response of the resume-polling GET for one transfer. */
export interface CompanyImportTransferStatus {
transferId: string;
status: CompanyImportTransferRunStatus;
totalParts: number;
completedParts: number;
missingParts: number[];
}
/** Response of uploading one declared part. */
export interface CompanyImportTransferPartUploadResult {
ok: true;
index: number;
/** True when the part was already spooled and verified by a prior upload. */
alreadyCompleted: boolean;
}

View File

@ -0,0 +1,803 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { companies, createDb } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { companyTransferRunService } from "../services/company-transfer-runs.js";
import { sweepAbandonedImportTransferSpools } from "../services/company-import-transfers.js";
const mockCompanyService = vi.hoisted(() => ({
list: vi.fn(),
stats: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
ensureMembership: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({
upsertPolicy: vi.fn(),
}));
const mockCompanyPortabilityService = vi.hoisted(() => ({
exportBundle: vi.fn(),
previewExport: vi.fn(),
previewImport: vi.fn(),
importBundle: vi.fn(),
}));
const mockCompanyArtifactsService = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(),
listFeedbackTraces: vi.fn(),
getFeedbackTraceById: vi.fn(),
saveIssueVote: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyArtifactsService: () => mockCompanyArtifactsService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
feedbackService: () => mockFeedbackService,
logActivity: mockLogActivity,
}));
// Passthrough wrapper around the real spool module whose removeImportTransferSpool
// can be made to throw, so tests can exercise a post-success cleanup failure
// (e.g. EACCES on the spool dir) without touching real filesystem permissions.
const spoolRemovalFailure = vi.hoisted(() => ({ error: null as Error | null }));
vi.mock("../services/company-import-transfers.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../services/company-import-transfers.js")>();
return {
...actual,
removeImportTransferSpool: async (spoolRoot: string, runId: string) => {
if (spoolRemovalFailure.error) throw spoolRemovalFailure.error;
return actual.removeImportTransferSpool(spoolRoot, runId);
},
};
});
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
? describe.sequential
: describe.skip;
const companyId = "11111111-1111-4111-8111-111111111111";
const TEST_USER_HEADER = "x-test-user-id";
function boardActor(userId: string) {
return {
type: "board",
userId,
userName: "Board User",
userEmail: `${userId}@example.com`,
companyIds: [companyId],
memberships: [{ companyId, membershipRole: "owner", status: "active" }],
isInstanceAdmin: true,
source: "session",
};
}
// A minimal STORE-only zip writer (same layout as the portability routes test)
// so apply can be exercised end-to-end through the real readZipArchive path.
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function buildStoreZip(files: Record<string, string>, rootPath: string): Buffer {
const encoder = new TextEncoder();
const localChunks: Buffer[] = [];
const centralChunks: Buffer[] = [];
let localOffset = 0;
const entries = Object.entries(files);
for (const [relativePath, content] of entries) {
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
const body = Buffer.from(encoder.encode(content));
const checksum = crc32(body);
const localHeader = Buffer.alloc(30 + fileName.length);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(0x0800, 6);
localHeader.writeUInt32LE(checksum, 14);
localHeader.writeUInt32LE(body.length, 18);
localHeader.writeUInt32LE(body.length, 22);
localHeader.writeUInt16LE(fileName.length, 26);
Buffer.from(fileName).copy(localHeader, 30);
const centralHeader = Buffer.alloc(46 + fileName.length);
centralHeader.writeUInt32LE(0x02014b50, 0);
centralHeader.writeUInt16LE(20, 4);
centralHeader.writeUInt16LE(20, 6);
centralHeader.writeUInt16LE(0x0800, 8);
centralHeader.writeUInt32LE(checksum, 16);
centralHeader.writeUInt32LE(body.length, 20);
centralHeader.writeUInt32LE(body.length, 24);
centralHeader.writeUInt16LE(fileName.length, 28);
centralHeader.writeUInt32LE(localOffset, 42);
Buffer.from(fileName).copy(centralHeader, 46);
localChunks.push(localHeader, body);
centralChunks.push(centralHeader);
localOffset += localHeader.length + body.length;
}
const centralDirectory = Buffer.concat(centralChunks);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(centralDirectory.length, 12);
eocd.writeUInt32LE(localOffset, 16);
return Buffer.concat([...localChunks, centralDirectory, eocd]);
}
function sha256Hex(bytes: Buffer): string {
return createHash("sha256").update(bytes).digest("hex");
}
// Distinct zip content per call: the transfer declaration is content-addressed
// (same content resumes the prior run), so tests that must not share runs
// build distinct fixtures.
let zipSeq = 0;
function buildFixtureZip(): Buffer {
zipSeq += 1;
return buildStoreZip(
{
"COMPANY.md": `---\nname: Chunked Import ${zipSeq}\n---\n`,
"agents/ceo/AGENTS.md": "---\nname: CEO\n---\n",
},
"paperclip",
);
}
function sliceIntoParts(zip: Buffer, partSizeBytes: number): Buffer[] {
const parts: Buffer[] = [];
for (let offset = 0; offset < zip.length; offset += partSizeBytes) {
parts.push(zip.subarray(offset, Math.min(offset + partSizeBytes, zip.length)));
}
return parts;
}
function declareTransfer(zip: Buffer, partSizeBytes: number) {
const slices = sliceIntoParts(zip, partSizeBytes);
return {
slices,
body: {
totalBytes: zip.length,
zipSha256: sha256Hex(zip),
partSizeBytes,
parts: slices.map((slice, index) => ({
index,
byteSize: slice.length,
sha256: sha256Hex(slice),
})),
},
};
}
const importMeta = {
include: { company: true, agents: true, projects: false, issues: false },
target: { mode: "existing_company", companyId },
collisionStrategy: "rename",
};
describeEmbeddedPostgres("company import transfer routes", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db!: ReturnType<typeof createDb>;
let spoolRoot!: string;
let app!: express.Express;
let appImportCounter = 0;
async function createApp() {
appImportCounter += 1;
const routeModulePath = `../routes/companies.js?company-import-transfer-routes-${appImportCounter}`;
const middlewareModulePath = `../middleware/index.js?company-import-transfer-routes-${appImportCounter}`;
const [{ companyRoutes }, { errorHandler }] = await Promise.all([
import(routeModulePath) as Promise<typeof import("../routes/companies.js")>,
import(middlewareModulePath) as Promise<typeof import("../middleware/index.js")>,
]);
const built = express();
built.use(express.json());
built.use((req, _res, next) => {
const header = req.headers[TEST_USER_HEADER];
const userId = typeof header === "string" && header.length > 0 ? header : "board-user-a";
(req as any).actor = boardActor(userId);
next();
});
built.use("/api/companies", companyRoutes(db, undefined, { importTransferSpoolRoot: spoolRoot }));
built.use(errorHandler);
return built;
}
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-import-transfer-routes-");
db = createDb(tempDb.connectionString);
// The mocked import result points at this company; the ledger's
// company_id foreign key needs the row to exist.
await db.insert(companies).values({ id: companyId, name: "Chunked Import Target" });
spoolRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-import-transfer-spool-"));
app = await createApp();
});
afterAll(async () => {
await tempDb?.cleanup();
await fs.rm(spoolRoot, { recursive: true, force: true });
});
beforeEach(() => {
vi.clearAllMocks();
spoolRemovalFailure.error = null;
mockCompanyPortabilityService.importBundle.mockResolvedValue({
company: { id: companyId, action: "updated" },
agents: [{ id: "agent-1" }],
warnings: [],
});
});
function putPart(transferId: string, index: number, bytes: Buffer, userId?: string) {
let req = request(app)
.put(`/api/companies/import/transfers/${transferId}/parts/${index}`)
.set("content-type", "application/octet-stream");
if (userId) req = req.set(TEST_USER_HEADER, userId);
return req.send(bytes);
}
it("uploads parts out of order, tracks progress, and applies through the import pipeline", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 3));
expect(slices.length).toBe(3);
const created = await request(app).post("/api/companies/import/transfers").send(body);
expect(created.status).toBe(200);
const transferId = created.body.transferId as string;
expect(transferId).toMatch(/^[0-9a-f-]{36}$/);
expect(created.body.totalParts).toBe(3);
expect(created.body.missingParts).toEqual([0, 1, 2]);
expect(created.body.alreadyCompleted).toBe(false);
// Out of order: last part first.
expect((await putPart(transferId, 2, slices[2]!)).status).toBe(200);
expect((await putPart(transferId, 0, slices[0]!)).status).toBe(200);
const midway = await request(app).get(`/api/companies/import/transfers/${transferId}`);
expect(midway.status).toBe(200);
expect(midway.body.totalParts).toBe(3);
expect(midway.body.completedParts).toBe(2);
expect(midway.body.missingParts).toEqual([1]);
expect((await putPart(transferId, 1, slices[1]!)).status).toBe(200);
const applied = await request(app)
.post(`/api/companies/import/transfers/${transferId}/apply`)
.send(importMeta);
expect(applied.status).toBe(200);
expect(applied.body.company).toEqual({ id: companyId, action: "updated" });
// The assembled zip fed the existing import pipeline unchanged.
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledTimes(1);
const importBody = mockCompanyPortabilityService.importBundle.mock.calls[0]![0];
expect(importBody.source.type).toBe("inline");
expect(importBody.source.files["COMPANY.md"]).toContain("Chunked Import");
expect(importBody.target).toEqual(importMeta.target);
expect(importBody.collisionStrategy).toBe("rename");
const run = (await companyTransferRunService.getRun(db, transferId))!;
expect(run.status).toBe("completed");
expect(run.companyId).toBe(companyId);
// The shared import pipeline writes the same activity entry as the
// single-shot upload route.
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "company.imported", entityId: companyId }),
);
// Spool is deleted on success.
await expect(fs.stat(path.join(spoolRoot, transferId))).rejects.toThrow();
const finished = await request(app).get(`/api/companies/import/transfers/${transferId}`);
expect(finished.body.status).toBe("completed");
expect(finished.body.missingParts).toEqual([]);
});
it("applies through the async import job machinery when requested", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
for (const [index, slice] of slices.entries()) {
expect((await putPart(transferId, index, slice)).status).toBe(200);
}
const accepted = await request(app)
.post(`/api/companies/import/transfers/${transferId}/apply?async=1`)
.send(importMeta);
expect(accepted.status).toBe(202);
const statusUrl = accepted.body.statusUrl as string;
let job: Record<string, any> | undefined;
for (let attempt = 0; attempt < 30; attempt += 1) {
const polled = await request(app).get(statusUrl);
job = polled.body.job;
if (job?.status === "succeeded" || job?.status === "failed") break;
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(job?.status).toBe("succeeded");
expect(job?.result?.companyId).toBe(companyId);
const run = (await companyTransferRunService.getRun(db, transferId))!;
expect(run.status).toBe("completed");
await expect(fs.stat(path.join(spoolRoot, transferId))).rejects.toThrow();
});
it("releases the claim when an async apply is refused by a running import", async () => {
// Two complete transfers for the same board actor. The first async apply
// parks a live job; the second transfer's async apply is refused by the
// one-live-import-per-actor rule — and must NOT stay stranded in
// "applying", or it would be unusable until a restart.
const firstZip = buildFixtureZip();
const first = declareTransfer(firstZip, Math.ceil(firstZip.length / 2));
const firstCreated = await request(app).post("/api/companies/import/transfers").send(first.body);
const firstId = firstCreated.body.transferId as string;
for (const [index, slice] of first.slices.entries()) {
expect((await putPart(firstId, index, slice)).status).toBe(200);
}
const secondZip = buildFixtureZip();
const second = declareTransfer(secondZip, Math.ceil(secondZip.length / 2));
const secondCreated = await request(app).post("/api/companies/import/transfers").send(second.body);
const secondId = secondCreated.body.transferId as string;
for (const [index, slice] of second.slices.entries()) {
expect((await putPart(secondId, index, slice)).status).toBe(200);
}
let releaseImport: (() => void) | undefined;
mockCompanyPortabilityService.importBundle.mockImplementationOnce(
async () => {
await new Promise<void>((resolve) => {
releaseImport = resolve;
});
return {
company: { id: companyId, action: "updated" },
agents: [{ id: "agent-1" }],
warnings: [],
};
},
);
const accepted = await request(app)
.post(`/api/companies/import/transfers/${firstId}/apply?async=1`)
.send(importMeta);
expect(accepted.status).toBe(202);
const refused = await request(app)
.post(`/api/companies/import/transfers/${secondId}/apply?async=1`)
.send(importMeta);
expect(refused.status).toBe(409);
// The refused transfer is failed (retryable), never stranded applying.
expect((await companyTransferRunService.getRun(db, secondId))!.status).toBe("failed");
releaseImport!();
let job: Record<string, any> | undefined;
for (let attempt = 0; attempt < 30; attempt += 1) {
const polled = await request(app).get(accepted.body.statusUrl as string);
job = polled.body.job;
if (job?.status === "succeeded" || job?.status === "failed") break;
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(job?.status).toBe("succeeded");
expect((await companyTransferRunService.getRun(db, firstId))!.status).toBe("completed");
// After the first import settles, the refused transfer applies cleanly.
const retried = await request(app)
.post(`/api/companies/import/transfers/${secondId}/apply`)
.send(importMeta);
expect(retried.status).toBe(200);
expect((await companyTransferRunService.getRun(db, secondId))!.status).toBe("completed");
});
it("freezes part uploads while an apply holds the claim", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
for (const [index, slice] of slices.entries()) {
expect((await putPart(transferId, index, slice)).status).toBe(200);
}
// Claim the run as an in-flight apply would, then retry a part upload:
// the route must refuse before touching the spool, and the previously
// uploaded part file must survive untouched for the apply to read.
expect(await companyTransferRunService.claimApply(db, transferId)).toBe(true);
const frozen = await putPart(transferId, 0, slices[0]!);
expect(frozen.status).toBe(409);
expect(frozen.body.error).toContain("apply is in progress");
await expect(
fs.stat(path.join(spoolRoot, transferId, "part-0")),
).resolves.toBeDefined();
await companyTransferRunService.releaseApplyClaim(db, transferId, "test release");
});
it("rejects declarations that are inconsistent or over the limits", async () => {
const zip = buildFixtureZip();
const { body } = declareTransfer(zip, Math.ceil(zip.length / 2));
const oversizedParts = await request(app)
.post("/api/companies/import/transfers")
.send({ ...body, partSizeBytes: 65 * 1024 * 1024 });
expect(oversizedParts.status).toBe(422);
const outOfOrder = await request(app)
.post("/api/companies/import/transfers")
.send({ ...body, parts: [...body.parts].reverse() });
expect(outOfOrder.status).toBe(422);
const sumMismatch = await request(app)
.post("/api/companies/import/transfers")
.send({ ...body, totalBytes: body.totalBytes + 1 });
expect(sumMismatch.status).toBe(422);
const badHash = await request(app)
.post("/api/companies/import/transfers")
.send({
...body,
parts: body.parts.map((part) => ({ ...part, sha256: "not-hex" })),
});
expect(badHash.status).toBe(400);
});
it("rejects a part whose bytes do not match the declaration and records nothing", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
// Right length, wrong content -> sha mismatch.
const corrupted = Buffer.from(slices[0]!);
corrupted[0] = corrupted[0]! ^ 0xff;
const wrongSha = await putPart(transferId, 0, corrupted);
expect(wrongSha.status).toBe(422);
// Wrong length.
const wrongLength = await putPart(transferId, 0, slices[0]!.subarray(1));
expect(wrongLength.status).toBe(422);
const status = await request(app).get(`/api/companies/import/transfers/${transferId}`);
expect(status.body.completedParts).toBe(0);
expect(status.body.missingParts).toEqual([0, 1]);
const run = (await companyTransferRunService.getRun(db, transferId))!;
expect(run.completedParts).toEqual([]);
});
it("treats a re-upload of a completed part as a no-op success", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
expect((await putPart(transferId, 0, slices[0]!)).status).toBe(200);
const again = await putPart(transferId, 0, slices[0]!);
expect(again.status).toBe(200);
expect(again.body.alreadyCompleted).toBe(true);
const run = (await companyTransferRunService.getRun(db, transferId))!;
expect(run.completedParts).toEqual(["part-0"]);
});
it("refuses to apply while parts are missing", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 3));
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
expect((await putPart(transferId, 0, slices[0]!)).status).toBe(200);
const applied = await request(app)
.post(`/api/companies/import/transfers/${transferId}/apply`)
.send(importMeta);
expect(applied.status).toBe(409);
expect(applied.body.missingParts).toEqual([1, 2]);
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
expect((await companyTransferRunService.getRun(db, transferId))!.status).not.toBe("completed");
});
it("resumes a re-declared transfer with its prior progress", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 3));
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
expect((await putPart(transferId, 0, slices[0]!)).status).toBe(200);
const redeclared = await request(app).post("/api/companies/import/transfers").send(body);
expect(redeclared.status).toBe(200);
expect(redeclared.body.transferId).toBe(transferId);
expect(redeclared.body.missingParts).toEqual([1, 2]);
expect(redeclared.body.alreadyCompleted).toBe(false);
});
it("reports an already-applied transfer on re-declaration", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, zip.length);
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
expect((await putPart(transferId, 0, slices[0]!)).status).toBe(200);
expect(
(await request(app).post(`/api/companies/import/transfers/${transferId}/apply`).send(importMeta)).status,
).toBe(200);
const redeclared = await request(app).post("/api/companies/import/transfers").send(body);
expect(redeclared.status).toBe(200);
expect(redeclared.body.transferId).toBe(transferId);
expect(redeclared.body.alreadyCompleted).toBe(true);
expect(redeclared.body.missingParts).toEqual([]);
const reApplied = await request(app)
.post(`/api/companies/import/transfers/${transferId}/apply`)
.send(importMeta);
expect(reApplied.status).toBe(409);
});
it("keeps a completed run completed when post-success spool cleanup fails", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
for (const [index, slice] of slices.entries()) {
expect((await putPart(transferId, index, slice)).status).toBe(200);
}
// The import itself succeeds; only the post-success spool deletion blows
// up. The cleanup error must not surface as an apply failure and — above
// all — must not flip the completed run back to a claimable state.
spoolRemovalFailure.error = new Error("EACCES: spool dir is busy");
try {
const applied = await request(app)
.post(`/api/companies/import/transfers/${transferId}/apply`)
.send(importMeta);
expect(applied.status).toBe(200);
expect(applied.body.company).toEqual({ id: companyId, action: "updated" });
} finally {
spoolRemovalFailure.error = null;
}
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledTimes(1);
expect((await companyTransferRunService.getRun(db, transferId))!.status).toBe("completed");
// A follow-up apply short-circuits on the completed run instead of
// claiming it for a duplicate import.
const reApplied = await request(app)
.post(`/api/companies/import/transfers/${transferId}/apply`)
.send(importMeta);
expect(reApplied.status).toBe(409);
expect(reApplied.body.error).toContain("already been applied");
const redeclared = await request(app).post("/api/companies/import/transfers").send(body);
expect(redeclared.body.transferId).toBe(transferId);
expect(redeclared.body.alreadyCompleted).toBe(true);
// The import pipeline never ran a second time.
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledTimes(1);
expect((await companyTransferRunService.getRun(db, transferId))!.status).toBe("completed");
// The leftover spool from the failed cleanup is not stranded: the sweep
// collects terminal-run spools immediately, no idle wait, and the run
// keeps its terminal status.
await expect(fs.stat(path.join(spoolRoot, transferId))).resolves.toBeDefined();
const swept = await sweepAbandonedImportTransferSpools(db, spoolRoot);
expect(swept.swept).toBeGreaterThanOrEqual(1);
await expect(fs.stat(path.join(spoolRoot, transferId))).rejects.toThrow();
expect((await companyTransferRunService.getRun(db, transferId))!.status).toBe("completed");
});
it("settles the run completed even when attaching the imported company fails", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
for (const [index, slice] of slices.entries()) {
expect((await putPart(transferId, index, slice)).status).toBe(200);
}
// The import commits, then a post-settlement step (company attachment)
// blows up. Settlement runs first, so the error must neither surface as
// an apply failure nor release the claim into a re-importable state.
const attachSpy = vi
.spyOn(companyTransferRunService, "attachCompany")
.mockRejectedValueOnce(new Error("registry write timed out"));
try {
const applied = await request(app)
.post(`/api/companies/import/transfers/${transferId}/apply`)
.send(importMeta);
expect(applied.status).toBe(200);
} finally {
attachSpy.mockRestore();
}
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledTimes(1);
expect((await companyTransferRunService.getRun(db, transferId))!.status).toBe("completed");
const reApplied = await request(app)
.post(`/api/companies/import/transfers/${transferId}/apply`)
.send(importMeta);
expect(reApplied.status).toBe(409);
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledTimes(1);
});
it("fails closed when the assembled zip does not match the declared whole-file hash", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
// Per-part hashes are honest, but the declared whole-file hash is not.
const lying = { ...body, zipSha256: sha256Hex(Buffer.from("something else entirely")) };
const created = await request(app).post("/api/companies/import/transfers").send(lying);
const transferId = created.body.transferId as string;
for (const [index, slice] of slices.entries()) {
expect((await putPart(transferId, index, slice)).status).toBe(200);
}
const applied = await request(app)
.post(`/api/companies/import/transfers/${transferId}/apply`)
.send(importMeta);
expect(applied.status).toBe(422);
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
const run = (await companyTransferRunService.getRun(db, transferId))!;
expect(run.status).toBe("failed");
// The spool is deleted so a resume re-uploads everything.
await expect(fs.stat(path.join(spoolRoot, transferId))).rejects.toThrow();
});
it("hides transfers from other actors", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
const otherStatus = await request(app)
.get(`/api/companies/import/transfers/${transferId}`)
.set(TEST_USER_HEADER, "board-user-b");
expect(otherStatus.status).toBe(404);
const otherUpload = await putPart(transferId, 0, slices[0]!, "board-user-b");
expect(otherUpload.status).toBe(404);
const otherApply = await request(app)
.post(`/api/companies/import/transfers/${transferId}/apply`)
.set(TEST_USER_HEADER, "board-user-b")
.send(importMeta);
expect(otherApply.status).toBe(404);
// Malformed ids never reach the filesystem or the database.
const malformed = await request(app).get("/api/companies/import/transfers/..%2Fescape");
expect(malformed.status).toBe(404);
});
it("sweeps abandoned spools and cancels their open runs", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
expect((await putPart(transferId, 0, slices[0]!)).status).toBe(200);
// Fresh activity: nothing to sweep at the real threshold.
expect(await sweepAbandonedImportTransferSpools(db, spoolRoot)).toEqual({ swept: 0 });
await expect(fs.stat(path.join(spoolRoot, transferId))).resolves.toBeDefined();
// 25 hours later the run saw no activity: spool deleted, run cancelled.
// Cancelled — not failed — so no apply can claim the run in the window
// between the status flip and the spool deletion (claimApply accepts
// failed runs but never cancelled ones).
const later = new Date(Date.now() + 25 * 60 * 60 * 1000);
const swept = await sweepAbandonedImportTransferSpools(db, spoolRoot, { now: later });
expect(swept.swept).toBeGreaterThanOrEqual(1);
await expect(fs.stat(path.join(spoolRoot, transferId))).rejects.toThrow();
expect((await companyTransferRunService.getRun(db, transferId))!.status).toBe("cancelled");
// A redeclaration after the sweep starts a FRESH run — the cancelled run
// is never resumed — with every part missing again, matching the part
// upload's 410 "re-create it" contract.
const redeclared = await request(app).post("/api/companies/import/transfers").send(body);
expect(redeclared.status).toBe(200);
expect(redeclared.body.transferId).not.toBe(transferId);
expect(redeclared.body.alreadyCompleted).toBe(false);
expect(redeclared.body.missingParts).toEqual([0, 1]);
// The swept run itself stays terminal.
expect((await companyTransferRunService.getRun(db, transferId))!.status).toBe("cancelled");
});
it("skips the sweep when the run saw activity after the staleness read", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
expect((await putPart(transferId, 0, slices[0]!)).status).toBe(200);
// TOCTOU simulation: the sweep's initial read sees a 25h-stale updatedAt
// — as if a part upload bumped the row right after that read — while the
// guarded UPDATE sees the fresh row in the database and must miss.
const real = (await companyTransferRunService.getRun(db, transferId))!;
const staleRead = vi.spyOn(companyTransferRunService, "getRun").mockResolvedValue({
...real,
updatedAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
});
try {
expect(await sweepAbandonedImportTransferSpools(db, spoolRoot)).toEqual({ swept: 0 });
} finally {
staleRead.mockRestore();
}
// The spool survived and the run is still open.
await expect(fs.stat(path.join(spoolRoot, transferId))).resolves.toBeDefined();
expect((await companyTransferRunService.getRun(db, transferId))!.status).toBe("running");
});
it("returns 410 for a part upload against a swept transfer and records nothing", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
expect((await putPart(transferId, 0, slices[0]!)).status).toBe(200);
const later = new Date(Date.now() + 25 * 60 * 60 * 1000);
const swept = await sweepAbandonedImportTransferSpools(db, spoolRoot, { now: later });
expect(swept.swept).toBeGreaterThanOrEqual(1);
expect((await companyTransferRunService.getRun(db, transferId))!.status).toBe("cancelled");
const upload = await putPart(transferId, 1, slices[1]!);
expect(upload.status).toBe(410);
const run = (await companyTransferRunService.getRun(db, transferId))!;
expect(run.status).toBe("cancelled");
expect(run.completedParts).toEqual(["part-0"]);
// The stray part file was discarded, not left half-spooled.
await expect(fs.stat(path.join(spoolRoot, transferId, "part-1"))).rejects.toThrow();
});
it("runs exactly one of two concurrent applies", async () => {
const zip = buildFixtureZip();
const { body, slices } = declareTransfer(zip, Math.ceil(zip.length / 2));
const created = await request(app).post("/api/companies/import/transfers").send(body);
const transferId = created.body.transferId as string;
for (const [index, slice] of slices.entries()) {
expect((await putPart(transferId, index, slice)).status).toBe(200);
}
// Hold the import open long enough for the losing apply to reach the
// claim while the winner is still importing.
mockCompanyPortabilityService.importBundle.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
return { company: { id: companyId, action: "updated" }, agents: [], warnings: [] };
});
const [first, second] = await Promise.all([
request(app).post(`/api/companies/import/transfers/${transferId}/apply`).send(importMeta),
request(app).post(`/api/companies/import/transfers/${transferId}/apply`).send(importMeta),
]);
expect([first.status, second.status].sort((a, b) => a - b)).toEqual([200, 409]);
// The non-idempotent import ran exactly once and the claim settled.
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledTimes(1);
expect((await companyTransferRunService.getRun(db, transferId))!.status).toBe("completed");
await expect(fs.stat(path.join(spoolRoot, transferId))).rejects.toThrow();
});
});

View File

@ -0,0 +1,336 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createDb } from "@paperclipai/db";
import { sha256HexOfBytes } from "@paperclipai/shared/portability-hash";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
companyTransferRunService,
type CompanyTransferPartsIndex,
} from "../services/company-transfer-runs.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
// Manifest-shaped fixture: the ledger only cares about an idempotency key and
// part names, so the fixture is built inline rather than through any container
// packer. Distinct content per call: identical content intentionally derives
// the same idempotency key, which would make separate tests resume each
// other's runs.
let manifestSeq = 0;
function demoManifest(): CompanyTransferPartsIndex & { idempotencyKey: string } {
manifestSeq += 1;
return {
idempotencyKey: sha256HexOfBytes(Buffer.from(`transfer-run-fixture-${manifestSeq}`)),
chunks: [{ name: "chunks/0000.json.gz" }, { name: "chunks/0001.json.gz" }],
blobs: [{ name: `blobs/${sha256HexOfBytes(pngBytes)}` }],
};
}
describeEmbeddedPostgres("companyTransferRunService", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db!: ReturnType<typeof createDb>;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-transfer-runs-");
db = createDb(tempDb.connectionString);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("creates a run, records parts idempotently, and resumes from the remainder", async () => {
const manifest = demoManifest();
const created = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: "user:alice",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "zip-upload" },
});
expect(created.resumed).toBe(false);
expect(created.run.status).toBe("pending");
await companyTransferRunService.recordManifest(
db,
created.run.id,
manifest,
sha256HexOfBytes(Buffer.from(JSON.stringify(manifest))),
);
await companyTransferRunService.start(db, created.run.id);
const firstChunk = manifest.chunks[0]!.name;
await companyTransferRunService.completePart(db, created.run.id, firstChunk);
// Re-completing the same part must not double-count.
await companyTransferRunService.completePart(db, created.run.id, firstChunk);
const midway = (await companyTransferRunService.getRun(db, created.run.id))!;
expect(midway.status).toBe("running");
expect(midway.completedParts).toEqual([firstChunk]);
expect(midway.chunkCount).toBe(manifest.chunks.length);
expect(midway.blobCount).toBe(manifest.blobs.length);
const remaining = companyTransferRunService.remainingParts(midway, manifest);
expect(remaining).toEqual([
manifest.chunks[1]!.name,
...manifest.blobs.map((blob) => blob.name),
]);
// Simulate an interruption: the run fails, then the same content retries.
await companyTransferRunService.fail(db, created.run.id, "connection dropped");
const resumed = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: "user:alice",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "zip-upload" },
});
expect(resumed.resumed).toBe(true);
expect(resumed.alreadyCompleted).toBe(false);
expect(resumed.run.id).toBe(created.run.id);
expect(resumed.run.completedParts).toEqual([firstChunk]);
await companyTransferRunService.start(db, resumed.run.id);
for (const part of companyTransferRunService.remainingParts(resumed.run, manifest)) {
await companyTransferRunService.completePart(db, resumed.run.id, part);
}
await companyTransferRunService.complete(db, resumed.run.id);
const done = (await companyTransferRunService.getRun(db, created.run.id))!;
expect(done.status).toBe("completed");
expect(done.error).toBeNull();
expect(companyTransferRunService.remainingParts(done, manifest)).toEqual([]);
// A retry of identical content after completion short-circuits.
const retried = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: "user:alice",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "zip-upload" },
});
expect(retried.alreadyCompleted).toBe(true);
expect(retried.run.id).toBe(created.run.id);
});
it("scopes resume to the actor and direction", async () => {
const manifest = demoManifest();
const aliceRun = await companyTransferRunService.resumeOrCreate(db, {
direction: "export",
actorKey: "user:alice",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "relay", prefix: "staging/a" },
});
const bobRun = await companyTransferRunService.resumeOrCreate(db, {
direction: "export",
actorKey: "user:bob",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "relay", prefix: "staging/b" },
});
expect(bobRun.resumed).toBe(false);
expect(bobRun.run.id).not.toBe(aliceRun.run.id);
const aliceImport = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: "user:alice",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "relay", prefix: "staging/a" },
});
expect(aliceImport.resumed).toBe(false);
expect(aliceImport.run.id).not.toBe(aliceRun.run.id);
expect(await companyTransferRunService.getRunForActor(db, aliceRun.run.id, "user:bob")).toBeNull();
expect(await companyTransferRunService.getRunForActor(db, aliceRun.run.id, "user:alice")).not.toBeNull();
});
it("creates a single run for concurrent identical declarations", async () => {
const manifest = demoManifest();
const input = {
direction: "import" as const,
actorKey: "user:dave",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "zip-upload" },
};
// Without the advisory lock both callers can miss the lookup and both
// insert; with it, exactly one inserts and the other resumes that row.
const [first, second] = await Promise.all([
companyTransferRunService.resumeOrCreate(db, input),
companyTransferRunService.resumeOrCreate(db, input),
]);
expect(first.run.id).toBe(second.run.id);
expect([first.resumed, second.resumed].filter(Boolean)).toHaveLength(1);
expect(first.alreadyCompleted).toBe(false);
expect(second.alreadyCompleted).toBe(false);
});
it("grants the apply claim to exactly one concurrent caller", async () => {
const manifest = demoManifest();
const { run } = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: "user:erin",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "zip-upload" },
});
await companyTransferRunService.start(db, run.id);
const claims = await Promise.all([
companyTransferRunService.claimApply(db, run.id),
companyTransferRunService.claimApply(db, run.id),
]);
expect(claims.filter(Boolean)).toHaveLength(1);
expect((await companyTransferRunService.getRun(db, run.id))!.status).toBe("applying");
// A concurrent re-declaration resumes the mid-apply run instead of
// spawning a duplicate, but start() must not restart it.
const redeclared = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: "user:erin",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "zip-upload" },
});
expect(redeclared.run.id).toBe(run.id);
expect(redeclared.resumed).toBe(true);
expect(redeclared.alreadyCompleted).toBe(false);
await companyTransferRunService.start(db, run.id);
expect((await companyTransferRunService.getRun(db, run.id))!.status).toBe("applying");
// A failed apply releases the claim: the run is retryable again.
await companyTransferRunService.fail(db, run.id, "import blew up");
expect(await companyTransferRunService.claimApply(db, run.id)).toBe(true);
await companyTransferRunService.complete(db, run.id);
expect((await companyTransferRunService.getRun(db, run.id))!.status).toBe("completed");
expect(await companyTransferRunService.claimApply(db, run.id)).toBe(false);
});
it("records parts only while the run is open", async () => {
const manifest = demoManifest();
const { run } = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: "user:frank",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "zip-upload" },
});
await companyTransferRunService.start(db, run.id);
expect(await companyTransferRunService.completePart(db, run.id, "part-0")).toBe(true);
await companyTransferRunService.fail(db, run.id, "swept");
expect(await companyTransferRunService.completePart(db, run.id, "part-1")).toBe(false);
expect((await companyTransferRunService.getRun(db, run.id))!.completedParts).toEqual(["part-0"]);
});
it("claims an abandoned run only while it is open and still idle", async () => {
const manifest = demoManifest();
const { run } = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: "user:grace",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "zip-upload" },
});
await companyTransferRunService.start(db, run.id);
// Fresh activity: a cutoff in the past misses the guard.
const past = new Date(Date.now() - 60 * 60 * 1000);
expect(await companyTransferRunService.cancelIfIdle(db, run.id, past, "stale")).toBe(false);
expect((await companyTransferRunService.getRun(db, run.id))!.status).toBe("running");
// Idle past the cutoff: exactly this claim cancels the run.
const future = new Date(Date.now() + 60 * 60 * 1000);
expect(await companyTransferRunService.cancelIfIdle(db, run.id, future, "stale")).toBe(true);
const swept = (await companyTransferRunService.getRun(db, run.id))!;
expect(swept.status).toBe("cancelled");
expect(swept.error).toBe("stale");
// Terminal already — a second claim finds nothing to do.
expect(await companyTransferRunService.cancelIfIdle(db, run.id, future, "stale")).toBe(false);
// Cancelled, not failed, so the swept run can never be claimed for an
// apply while its spool is being deleted.
expect(await companyTransferRunService.claimApply(db, run.id)).toBe(false);
});
it("releases an apply claim without ever reopening a settled run", async () => {
const manifest = demoManifest();
const { run } = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: "user:heidi",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "zip-upload" },
});
await companyTransferRunService.start(db, run.id);
// Releasing a held claim fails the run and makes it claimable again.
expect(await companyTransferRunService.claimApply(db, run.id)).toBe(true);
expect(await companyTransferRunService.releaseApplyClaim(db, run.id, "import blew up")).toBe(true);
const released = (await companyTransferRunService.getRun(db, run.id))!;
expect(released.status).toBe("failed");
expect(released.error).toBe("import blew up");
expect(await companyTransferRunService.claimApply(db, run.id)).toBe(true);
// Guarded on "applying": after complete() settles the claim, a late
// release (e.g. a cleanup error caught after success) is a no-op — the
// run stays completed and can never be flipped back to claimable.
await companyTransferRunService.complete(db, run.id);
expect(await companyTransferRunService.releaseApplyClaim(db, run.id, "cleanup failed")).toBe(false);
const settled = (await companyTransferRunService.getRun(db, run.id))!;
expect(settled.status).toBe("completed");
expect(settled.error).toBeNull();
expect(await companyTransferRunService.claimApply(db, run.id)).toBe(false);
});
it("recovers runs stranded in applying by a restart, leaving live runs alone", async () => {
const strandedManifest = demoManifest();
const { run: stranded } = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: "user:ivan",
idempotencyKey: strandedManifest.idempotencyKey,
containerRef: { kind: "zip-upload" },
});
await companyTransferRunService.start(db, stranded.id);
expect(await companyTransferRunService.claimApply(db, stranded.id)).toBe(true);
const openManifest = demoManifest();
const { run: open } = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: "user:ivan",
idempotencyKey: openManifest.idempotencyKey,
containerRef: { kind: "zip-upload" },
});
await companyTransferRunService.start(db, open.id);
// The process "restarted": the in-memory apply for the claimed run is
// gone, so startup recovery fails it and makes it claimable again.
const recovered = await companyTransferRunService.recoverStrandedApplyingRuns(db);
expect(recovered).toContain(stranded.id);
expect(recovered).not.toContain(open.id);
const failed = (await companyTransferRunService.getRun(db, stranded.id))!;
expect(failed.status).toBe("failed");
expect(failed.error).toBe("apply interrupted by a restart — verify whether the import completed before retrying");
expect(await companyTransferRunService.claimApply(db, stranded.id)).toBe(true);
// Runs that were merely uploading are untouched.
expect((await companyTransferRunService.getRun(db, open.id))!.status).toBe("running");
});
it("does not restart a cancelled run", async () => {
const manifest = demoManifest();
const { run } = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: "user:carol",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "zip-upload" },
});
await companyTransferRunService.cancel(db, run.id);
await companyTransferRunService.start(db, run.id);
expect((await companyTransferRunService.getRun(db, run.id))!.status).toBe("cancelled");
// Cancelled runs are not resumed; the same content starts a fresh run.
const fresh = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: "user:carol",
idempotencyKey: manifest.idempotencyKey,
containerRef: { kind: "zip-upload" },
});
expect(fresh.resumed).toBe(false);
expect(fresh.run.id).not.toBe(run.id);
});
});

View File

@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import request from "supertest";
import { describe, expect, it } from "vitest";
import { COMPANY_IMPORT_TRANSFERS_ROUTE_PATH } from "@paperclipai/shared/company-import-transfer";
import { errorHandler } from "../middleware/index.js";
import { buildOpenApiSpec, openApiRoutes } from "../routes/openapi.js";
@ -79,8 +80,18 @@ function createApp() {
return app;
}
// Route files may compose paths from shared path constants inside template
// literals; substitute the constants' values before normalizing.
const routePathConstantSubstitutions: Record<string, string> = {
"${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}": COMPANY_IMPORT_TRANSFERS_ROUTE_PATH,
};
function normalizeExpressPath(routePath: string) {
return routePath
let substituted = routePath;
for (const [placeholder, value] of Object.entries(routePathConstantSubstitutions)) {
substituted = substituted.split(placeholder).join(value);
}
return substituted
.replace(/\*([A-Za-z0-9_]+)/g, "{$1}")
.replace(/:([A-Za-z0-9_]+)/g, "{$1}")
.replace(/\/+/g, "/");
@ -126,6 +137,9 @@ function loadActualRoutes() {
if (file === "companies.ts" && source.includes("router.post(COMPANY_IMPORT_ROUTE_PATH")) {
routes.add("POST /api/companies/import");
}
if (file === "companies.ts" && source.includes("router.post(COMPANY_IMPORT_TRANSFERS_ROUTE_PATH")) {
routes.add(`POST /api/companies${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}`);
}
}
return { routes, unknownRouteFiles: unknownRouteFiles.sort() };

View File

@ -11,6 +11,12 @@ import { actorMiddleware } from "./middleware/auth.js";
import { boardMutationGuard } from "./middleware/board-mutation-guard.js";
import { privateHostnameGuard, resolvePrivateHostnameAllowSet } from "./middleware/private-hostname-guard.js";
import { applyTrustProxy, parseTrustProxyEnv } from "./middleware/trust-proxy.js";
import {
IMPORT_TRANSFER_SPOOL_SWEEP_INTERVAL_MS,
resolveDefaultImportTransferSpoolRoot,
sweepAbandonedImportTransferSpools,
} from "./services/company-import-transfers.js";
import { companyTransferRunService } from "./services/company-transfer-runs.js";
import { healthRoutes } from "./routes/health.js";
import { cloudRoutes } from "./routes/cloud.js";
import { companyRoutes } from "./routes/companies.js";
@ -672,6 +678,48 @@ export async function createApp(
if (opts.feedbackExportService) {
void flushPendingFeedbackExports();
}
// Abandoned chunked-import spool sweep: hourly (plus once at startup),
// deleting spool dirs whose transfer saw no activity for 24h and cancelling
// their still-open ledger runs. Same setInterval + unref + shutdown-clear
// shape as the feedback export flush above.
const importTransferSpoolRoot = resolveDefaultImportTransferSpoolRoot();
const sweepImportTransferSpools = () => {
sweepAbandonedImportTransferSpools(db, importTransferSpoolRoot)
.then((result) => {
if (result.swept > 0) {
logger.info(result, "swept abandoned company import transfer spools");
}
})
.catch((err) => {
logger.error({ err }, "abandoned company import transfer spool sweep failed");
});
};
let importTransferSweepTimer: ReturnType<typeof setInterval> | null = setInterval(
sweepImportTransferSpools,
IMPORT_TRANSFER_SPOOL_SWEEP_INTERVAL_MS,
);
importTransferSweepTimer.unref?.();
// Startup only (never on the hourly interval — that would kill live
// applies): apply jobs are in-memory in this single process, so any run
// still "applying" now was interrupted by the previous shutdown and would
// otherwise 409 every retry forever. Fail those stranded runs — their
// spooled parts stay reusable — then run the normal sweep once.
void companyTransferRunService
.recoverStrandedApplyingRuns(db)
.then((recovered) => {
if (recovered.length > 0) {
logger.warn(
{ count: recovered.length, runIds: recovered },
"failed company transfer runs stranded in applying by a restart",
);
}
})
.catch((err) => {
logger.error({ err }, "stranded company transfer apply recovery failed");
})
.finally(() => {
sweepImportTransferSpools();
});
void toolDispatcher.initialize().catch((err) => {
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
});
@ -736,6 +784,10 @@ export async function createApp(
if (appServicesShutdown) return;
appServicesShutdown = true;
disableFeedbackExportFlushes();
if (importTransferSweepTimer) {
clearInterval(importTransferSweepTimer);
importTransferSweepTimer = null;
}
devWatcher?.close();
viteHtmlRenderer?.dispose();
hostServiceCleanup.disposeAll();

View File

@ -24,9 +24,29 @@ import {
updateCompanyBrandingSchema,
updateCompanySchema,
} from "@paperclipai/shared";
import { badRequest, forbidden, unprocessable } from "../errors.js";
import {
COMPANY_IMPORT_TRANSFERS_ROUTE_PATH,
companyImportTransferDeclarationSchema,
type CompanyImportTransferCreated,
type CompanyImportTransferDeclaration,
type CompanyImportTransferPartUploadResult,
type CompanyImportTransferStatus,
} from "@paperclipai/shared/company-import-transfer";
import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { PORTABLE_ZIP_UPLOAD_LIMIT_BYTES } from "../http/body-limits.js";
import { logger } from "../middleware/logger.js";
import { validate } from "../middleware/validate.js";
import {
assembleImportTransferZip,
importTransferPartName,
importTransferPartSizeOnDisk,
isImportTransferRunId,
removeImportTransferPart,
removeImportTransferSpool,
resolveDefaultImportTransferSpoolRoot,
writeImportTransferPart,
} from "../services/company-import-transfers.js";
import { companyTransferRunService } from "../services/company-transfer-runs.js";
import {
accessService,
agentService,
@ -63,6 +83,28 @@ const zipPackageUpload = multer({
limits: { fileSize: PORTABLE_ZIP_UPLOAD_LIMIT_BYTES, files: 1 },
});
// Chunked resumable variant of the zip upload above: the client slices the
// exact same .zip into byte-range parts and uploads them individually, so a
// dropped connection re-uploads one part instead of the whole package. Parts
// are spooled to disk and reassembled at apply, then fed through the same
// preview/import logic — no new container format, no change to import
// semantics. Only one part is held in memory at a time during upload; the
// full-zip buffer exists only while an apply runs (matching the single-shot
// path's memory profile).
const IMPORT_TRANSFER_PART_SIZE_LIMIT_BYTES = 64 * 1024 * 1024;
// The declaration body and response shapes are the shared wire contract in
// @paperclipai/shared/company-import-transfer — the browser and CLI clients
// type against the same schemas and path builders.
const importTransferManifestSchema = companyImportTransferDeclarationSchema;
type ImportTransferManifest = CompanyImportTransferDeclaration;
const importTransferPartBodyParser = express.raw({
type: () => true,
limit: IMPORT_TRANSFER_PART_SIZE_LIMIT_BYTES,
});
const rawZipBodyParser = express.raw({
type: [...PORTABLE_ZIP_CONTENT_TYPES],
limit: PORTABLE_ZIP_UPLOAD_LIMIT_BYTES,
@ -113,6 +155,20 @@ function parseImportMeta(metaRaw: string | undefined): Record<string, unknown> {
return rest;
}
/**
* The chunked-transfer apply body is the already-parsed JSON equivalent of the
* multipart `meta` field: the import fields minus `source` (the source is
* always the assembled zip). Mirrors `parseImportMeta` for object bodies.
*/
function importTransferApplyMeta(body: unknown): Record<string, unknown> {
if (body === undefined || body === null) return {};
if (typeof body !== "object" || Array.isArray(body)) {
throw badRequest("Import transfer apply body must be a JSON object.");
}
const { source: _ignoredSource, ...rest } = body as Record<string, unknown>;
return rest;
}
/**
* Resolve the object to hand the preview/import zod schemas. For a JSON request
* this is the inline body unchanged. For a multipart or application/zip request
@ -161,6 +217,20 @@ async function resolveImportPayload(req: Request, res: Response): Promise<unknow
if (!zipBytes || zipBytes.length === 0) {
throw badRequest("Import package upload was empty.");
}
const archive = await readImportZipArchive(zipBytes);
return {
...parseImportMeta(metaRaw),
source: { type: "inline", rootPath: archive.rootPath, files: archive.files },
};
}
/**
* Read an uploaded import zip with the scaled bomb guards. Shared by the
* single-shot upload path (`resolveImportPayload`) and the chunked transfer
* apply path, so both feed the exact same `{ rootPath, files }` bundle into
* the unchanged preview/import logic.
*/
async function readImportZipArchive(zipBytes: Buffer) {
let archive: Awaited<ReturnType<typeof readZipArchive>>;
try {
// Scale the bomb guards from the configured upload cap so a legitimately
@ -179,10 +249,7 @@ async function resolveImportPayload(req: Request, res: Response): Promise<unknow
if (Object.keys(archive.files).length === 0) {
throw badRequest("Import package contained no files.");
}
return {
...parseImportMeta(metaRaw),
source: { type: "inline", rootPath: archive.rootPath, files: archive.files },
};
return archive;
}
/**
@ -197,8 +264,15 @@ function wantsAsyncImport(req: Request) {
return req.query.async === "1" || req.header("x-paperclip-cloud-async-import") === "1";
}
export function companyRoutes(db: Db, storage?: StorageService) {
export interface CompanyRoutesOptions {
/** Overridable in tests; defaults to `<instance root>/import-transfers`. */
importTransferSpoolRoot?: string;
}
export function companyRoutes(db: Db, storage?: StorageService, options?: CompanyRoutesOptions) {
const router = Router();
const importTransferSpoolRoot =
options?.importTransferSpoolRoot ?? resolveDefaultImportTransferSpoolRoot();
const svc = companyService(db);
const agents = agentService(db);
const portability = companyPortabilityService(db, storage);
@ -453,15 +527,53 @@ export function companyRoutes(db: Db, storage?: StorageService) {
res.json(importJobResponse(job));
});
router.post(COMPANY_IMPORT_ROUTE_PATH, async (req, res) => {
assertBoard(req);
// Resolve the request body up front: a JSON caller's inline body is used
// unchanged; a multipart/application-zip caller's uploaded zip is read into
// the same `{ source: { type: "inline", rootPath, files } }` bundle here,
// fast and in-memory, so the async job machinery below is transport-agnostic.
const rawImportBody: unknown = await resolveImportPayload(req, res);
/**
* Post-resolution import execution, shared by the single-shot upload route
* and the chunked transfer apply route: validate the resolved body, run the
* import synchronously or through the in-memory async job machinery, and
* fire the optional completion hooks (the transfer path uses them to settle
* its ledger run) on the import's real outcome in either mode.
*/
async function executeImportRequest(
req: Request,
res: Response,
rawImportBody: unknown,
hooks?: {
onSuccess?: (result: CompanyPortabilityImportResult) => Promise<void>;
/** Fires when the request is refused before any import work starts (e.g. another import is already running for the actor). */
onConflict?: () => Promise<void>;
onFailure?: (message: string) => Promise<void>;
},
) {
const actor = getActorInfo(req);
const boardUserId = req.actor.type === "board" ? req.actor.userId : null;
const operation = async () => {
try {
const importBody = companyPortabilityImportSchema.parse(rawImportBody);
assertImportTargetAccess(req, importBody.target);
const activity = importedCompanyActivityContext(actor, importBody.include ?? null);
const result = await portability.importBundle(importBody, boardUserId, {
pauseAutomations: importBody.pauseAutomations === true,
});
// The import is committed. Settlement (hooks) runs before the
// best-effort audit entry so a logging failure cannot make a
// committed import read as failed — or, on the transfer path, release
// the apply claim and invite a duplicate re-import.
await hooks?.onSuccess?.(result);
try {
await logImportedCompanyActivity(db, activity, result);
} catch (activityError) {
logger.warn(
{ err: activityError, companyId: result.company.id },
"failed to write the company.imported activity entry for a committed import",
);
}
return result;
} catch (error) {
await hooks?.onFailure?.(errorMessage(error));
throw error;
}
};
if (wantsAsyncImport(req)) {
// Async job path. Two kinds of callers opt in:
// - trusted Cloud tenants (original behavior, kept byte-identical),
@ -485,6 +597,10 @@ export function companyRoutes(db: Db, storage?: StorageService) {
// Terminal jobs never block a resubmit.
const running = findRunningImportJob(importJobs, actorKey);
if (running) {
// No import work starts on this path, so a caller holding a claim
// (the transfer apply) must be told to release it — otherwise the
// refused transfer would sit in "applying" until a restart.
await hooks?.onConflict?.();
if (running.signature === signature) {
res.status(409).json(importJobConflictResponse(running));
} else {
@ -499,16 +615,6 @@ export function companyRoutes(db: Db, storage?: StorageService) {
signature,
);
importJobs.set(job.id, job);
const operation = async () => {
const importBody = companyPortabilityImportSchema.parse(rawImportBody);
assertImportTargetAccess(req, importBody.target);
const activity = importedCompanyActivityContext(actor, importBody.include ?? null);
const result = await portability.importBundle(importBody, boardUserId, {
pauseAutomations: importBody.pauseAutomations === true,
});
await logImportedCompanyActivity(db, activity, result);
return result;
};
res.status(202).json(importJobAcceptedResponse(job));
setImmediate(() => {
void runImportJob(job, operation);
@ -516,14 +622,337 @@ export function companyRoutes(db: Db, storage?: StorageService) {
return;
}
const importBody = companyPortabilityImportSchema.parse(rawImportBody);
assertImportTargetAccess(req, importBody.target);
const activity = importedCompanyActivityContext(actor, importBody.include ?? null);
const result = await portability.importBundle(importBody, boardUserId, {
pauseAutomations: importBody.pauseAutomations === true,
});
await logImportedCompanyActivity(db, activity, result);
const result = await operation();
res.json(result);
}
router.post(COMPANY_IMPORT_ROUTE_PATH, async (req, res) => {
assertBoard(req);
// Resolve the request body up front: a JSON caller's inline body is used
// unchanged; a multipart/application-zip caller's uploaded zip is read into
// the same `{ source: { type: "inline", rootPath, files } }` bundle here,
// fast and in-memory, so the async job machinery below is transport-agnostic.
const rawImportBody: unknown = await resolveImportPayload(req, res);
await executeImportRequest(req, res, rawImportBody);
});
/**
* Load the transfer run for the URL's id, scoped to the caller the same way
* import jobs are (`importJobActorKey`): an unknown id, another actor's id,
* and a non-import run are all the same 404, so transfer ids cannot be
* probed across users or tenants. The id is regex-validated as a UUID before
* it is used anywhere (it is later joined into spool paths).
*/
async function requireImportTransferRun(req: Request) {
const transferId = req.params.transferId as string;
if (!isImportTransferRunId(transferId)) {
throw notFound("Import transfer not found");
}
const run = await companyTransferRunService.getRunForActor(db, transferId, importJobActorKey(req));
if (!run || run.direction !== "import") {
throw notFound("Import transfer not found");
}
return run;
}
/** The declared part list persisted on the run at create time. */
function storedImportTransferManifest(run: { manifest: unknown }): ImportTransferManifest {
const parsed = importTransferManifestSchema.safeParse(run.manifest);
if (!parsed.success) {
throw conflict("Import transfer has no recorded part manifest");
}
return parsed.data;
}
/**
* Indexes the client still has to upload. A part counts as done only when
* the ledger recorded it AND its spool file is still on disk at the declared
* size so a swept or damaged spool surfaces as missing parts to re-upload
* instead of a failing apply.
*/
async function importTransferMissingParts(
run: { id: string; completedParts: string[] },
manifest: ImportTransferManifest,
): Promise<number[]> {
const completed = new Set(run.completedParts);
const missing: number[] = [];
for (const part of manifest.parts) {
if (!completed.has(importTransferPartName(part.index))) {
missing.push(part.index);
continue;
}
const sizeOnDisk = await importTransferPartSizeOnDisk(importTransferSpoolRoot, run.id, part.index);
if (sizeOnDisk !== part.byteSize) {
missing.push(part.index);
}
}
return missing;
}
// Declare a chunked import transfer. The declaration is content-addressed:
// the idempotency key derives from the whole-zip hash plus every part hash,
// so re-declaring the same zip resumes the prior run (with its uploaded
// parts intact) instead of starting over.
router.post(COMPANY_IMPORT_TRANSFERS_ROUTE_PATH, async (req, res) => {
assertBoard(req);
const declared = importTransferManifestSchema.parse(req.body);
if (declared.totalBytes > PORTABLE_ZIP_UPLOAD_LIMIT_BYTES) {
throw unprocessable(
`Import package exceeds the ${Math.floor(PORTABLE_ZIP_UPLOAD_LIMIT_BYTES / (1024 * 1024))} MB upload limit`,
);
}
if (declared.partSizeBytes > IMPORT_TRANSFER_PART_SIZE_LIMIT_BYTES) {
throw unprocessable(
`Import transfer parts may be at most ${Math.floor(IMPORT_TRANSFER_PART_SIZE_LIMIT_BYTES / (1024 * 1024))} MB`,
);
}
declared.parts.forEach((part, position) => {
if (part.index !== position) {
throw unprocessable("Import transfer parts must be contiguous, ordered 0..n-1");
}
if (part.byteSize > declared.partSizeBytes) {
throw unprocessable(`Part ${part.index} is larger than the declared partSizeBytes`);
}
});
const declaredTotal = declared.parts.reduce((sum, part) => sum + part.byteSize, 0);
if (declaredTotal !== declared.totalBytes) {
throw unprocessable("Sum of part byte sizes must equal totalBytes");
}
const idempotencyKey = createHash("sha256")
.update(JSON.stringify([declared.zipSha256, declared.parts.map((part) => part.sha256)]))
.digest("hex");
const { run, alreadyCompleted } = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: importJobActorKey(req),
idempotencyKey,
containerRef: { kind: "chunked_zip_upload" },
});
if (alreadyCompleted) {
res.json({
transferId: run.id,
status: "completed",
alreadyCompleted: true,
totalParts: declared.parts.length,
missingParts: [],
} satisfies CompanyImportTransferCreated);
return;
}
if (run.manifest === null || run.manifest === undefined) {
const manifestSha256 = createHash("sha256").update(JSON.stringify(declared)).digest("hex");
await companyTransferRunService.recordManifest(db, run.id, declared, manifestSha256, {
chunkCount: declared.parts.length,
blobCount: 0,
});
}
await companyTransferRunService.start(db, run.id);
res.json({
transferId: run.id,
status: "running",
alreadyCompleted: false,
totalParts: declared.parts.length,
missingParts: await importTransferMissingParts(run, declared),
} satisfies CompanyImportTransferCreated);
});
// Upload one declared part as a raw body. Verified against the declared
// size and sha256 before it is spooled; re-uploading an already completed
// part is a no-op success, so clients can blindly retry.
router.put(
`${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}/:transferId/parts/:partIndex`,
importTransferPartBodyParser,
async (req, res) => {
assertBoard(req);
const run = await requireImportTransferRun(req);
if (run.status === "completed") {
throw conflict("Import transfer has already been applied");
}
if (run.status === "applying") {
// An apply is assembling the spool right now; accepting writes would
// let a retried upload race the files being read. Parts are frozen
// until the apply settles (completed short-circuits, failed resumes).
throw conflict("Import transfer apply is in progress; retry after it settles");
}
const manifest = storedImportTransferManifest(run);
const rawIndex = req.params.partIndex as string;
// Strict integer-in-bounds validation before the index goes anywhere
// near a filesystem path.
if (!/^\d{1,5}$/.test(rawIndex) || Number(rawIndex) >= manifest.parts.length) {
throw notFound("Import transfer part not found");
}
const partIndex = Number(rawIndex);
const declared = manifest.parts[partIndex]!;
const partName = importTransferPartName(partIndex);
if (run.completedParts.includes(partName)) {
const sizeOnDisk = await importTransferPartSizeOnDisk(importTransferSpoolRoot, run.id, partIndex);
if (sizeOnDisk === declared.byteSize) {
res.json({ ok: true, index: partIndex, alreadyCompleted: true } satisfies CompanyImportTransferPartUploadResult);
return;
}
}
const bytes = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
if (bytes.length !== declared.byteSize) {
throw unprocessable(
`Part ${partIndex} upload is ${bytes.length} bytes but was declared as ${declared.byteSize} bytes`,
);
}
const digest = createHash("sha256").update(bytes).digest("hex");
if (digest !== declared.sha256) {
throw unprocessable(`Part ${partIndex} content does not match its declared sha256`);
}
await writeImportTransferPart(importTransferSpoolRoot, run.id, partIndex, bytes);
const recorded = await companyTransferRunService.completePart(db, run.id, partName);
if (!recorded) {
// The run left pending/running between the load above and this
// record. Which way it went decides the file's fate: under a claimed
// or completed apply the just-written bytes are hash-verified
// identical to what the apply assembles, so the file must NOT be
// deleted out from under it; a swept or failed run is dead, so the
// stray part is dropped and the client told to re-create.
const current = await companyTransferRunService.getRun(db, run.id);
if (current?.status === "applying" || current?.status === "completed") {
throw conflict("Import transfer apply is in progress; retry after it settles");
}
await removeImportTransferPart(importTransferSpoolRoot, run.id, partIndex);
res.status(410).json({ error: "Import transfer expired — re-create it" });
return;
}
res.json({ ok: true, index: partIndex, alreadyCompleted: false } satisfies CompanyImportTransferPartUploadResult);
},
);
// Resume polling: which parts are done, which are still missing.
router.get(`${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}/:transferId`, async (req, res) => {
assertBoard(req);
const run = await requireImportTransferRun(req);
const manifest = storedImportTransferManifest(run);
const missingParts = run.status === "completed" ? [] : await importTransferMissingParts(run, manifest);
res.json({
transferId: run.id,
status: run.status,
totalParts: manifest.parts.length,
completedParts: manifest.parts.length - missingParts.length,
missingParts,
} satisfies CompanyImportTransferStatus);
});
// Assemble the spooled parts back into the original zip, verify it against
// the declared whole-file hash, and run it through the exact import path a
// single-shot zip upload takes. The body carries the same meta fields the
// multipart route's `meta` field does (include/target/collisionStrategy/...).
router.post(`${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}/:transferId/apply`, async (req, res) => {
assertBoard(req);
const run = await requireImportTransferRun(req);
if (run.status === "completed") {
throw conflict("Import transfer has already been applied");
}
const manifest = storedImportTransferManifest(run);
const missingParts = await importTransferMissingParts(run, manifest);
if (missingParts.length > 0) {
res.status(409).json({
error: "Import transfer is missing parts",
missingParts,
});
return;
}
// Atomic claim: the import pipeline is not idempotent, so of any
// overlapping applies of this transfer exactly one may run it. The claim
// is a single guarded status flip to "applying"; complete() or the
// guarded releaseApplyClaim() below settle it (a released run stays
// retryable). Losers get 409.
if (!(await companyTransferRunService.claimApply(db, run.id))) {
throw conflict("Import transfer apply is already in progress");
}
try {
const zipBytes = await assembleImportTransferZip(importTransferSpoolRoot, run.id, manifest.parts.length);
const zipSha256 = createHash("sha256").update(zipBytes).digest("hex");
if (zipBytes.length !== manifest.totalBytes || zipSha256 !== manifest.zipSha256) {
// Fail closed: every part verified individually but the whole does not
// match the declaration. The spool is deleted so a resume re-uploads
// every part instead of re-assembling the same corrupt bytes.
await companyTransferRunService.fail(db, run.id, "Assembled import package failed whole-file verification");
await removeImportTransferSpool(importTransferSpoolRoot, run.id);
throw unprocessable("Assembled import package failed verification; upload the transfer again");
}
const archive = await readImportZipArchive(zipBytes);
const meta = importTransferApplyMeta(req.body);
const rawImportBody = {
...meta,
source: { type: "inline", rootPath: archive.rootPath, files: archive.files },
};
await executeImportRequest(req, res, rawImportBody, {
onSuccess: async (result) => {
// Settle the run the moment the import is committed: every later
// step is best-effort, because any error escaping this hook reads
// as a failed apply and — before complete() — would release the
// claim on an already-committed import. complete() gets one retry;
// if both attempts fail the run is left "applying" (unclaimable, so
// no duplicate import) and logged for the operator; a restart's
// stranded-run recovery then makes it inspectable as failed.
try {
await companyTransferRunService.complete(db, run.id);
} catch {
try {
await companyTransferRunService.complete(db, run.id);
} catch (settleError) {
logger.error(
{ err: settleError, transferId: run.id },
"import committed but the transfer run could not be marked completed",
);
return;
}
}
try {
await companyTransferRunService.attachCompany(db, run.id, result.company.id);
} catch (attachError) {
logger.warn(
{ err: attachError, transferId: run.id },
"failed to attach the imported company to its transfer run",
);
}
// Best-effort cleanup: the import is committed and the run is
// completed, so a spool deletion error must not escape — it would
// reach the failure paths below and read as a failed apply. The
// leftover dir is harmless (completed runs never assemble again)
// and the sweep collects terminal-run spools immediately.
try {
await removeImportTransferSpool(importTransferSpoolRoot, run.id);
} catch (cleanupError) {
logger.warn(
{ err: cleanupError, transferId: run.id },
"failed to delete import transfer spool after a successful apply",
);
}
},
onFailure: async (message) => {
// Parts remain spooled: the failure is in the import itself, not
// the upload, so a retry of apply can reuse the verified parts.
// Guarded release, not a blind fail: this hook also fires when an
// error escapes onSuccess above, i.e. possibly after complete(),
// and a completed run must never be flipped back to claimable.
await companyTransferRunService.releaseApplyClaim(db, run.id, message);
},
onConflict: async () => {
// The request was refused before any import work (another import is
// running for this actor): release the claim so the transfer stays
// retryable instead of stranded in "applying" until a restart.
await companyTransferRunService.releaseApplyClaim(
db,
run.id,
"another import is already running for this actor",
);
},
});
} catch (error) {
// Whatever escapes here (bad meta, malformed zip, an import error
// rethrown after onFailure) must not leave the run parked in
// "applying": release the claim, which keeps the run retryable. The
// release is guarded on "applying", so a run that already settled —
// failed by onFailure, or completed before a late error — keeps its
// terminal state.
await companyTransferRunService.releaseApplyClaim(db, run.id, errorMessage(error));
throw error;
}
});
router.post("/:companyId/exports/preview", async (req, res) => {

View File

@ -225,6 +225,10 @@ import {
toolPolicyTestRequestSchema,
createToolMcpGatewaySchema,
} from "@paperclipai/shared";
import {
COMPANY_IMPORT_TRANSFERS_API_PATH,
companyImportTransferDeclarationSchema,
} from "@paperclipai/shared/company-import-transfer";
type JsonSchema = Record<string, unknown>;
type OpenApiResponse = Record<string, unknown>;
@ -5946,6 +5950,94 @@ registry.registerPath({
},
});
registry.registerPath({
method: "post",
path: COMPANY_IMPORT_TRANSFERS_API_PATH,
tags: ["companies"],
summary: "Declare a chunked company import transfer",
description:
"Declares the caller's existing company package .zip as content-addressed byte-range parts " +
"(whole-file plus per-part sha256). Re-declaring the same zip resumes the prior transfer " +
"with its uploaded parts intact; the response carries the transfer id and the part indexes " +
"still missing.",
request: { body: jsonBody(companyImportTransferDeclarationSchema) },
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 422: r.unprocessable },
});
registry.registerPath({
method: "put",
path: `${COMPANY_IMPORT_TRANSFERS_API_PATH}/{transferId}/parts/{partIndex}`,
tags: ["companies"],
summary: "Upload one declared part of a company import transfer",
description:
"Raw part bytes as the request body. The upload is verified against the declared byte size " +
"and sha256 before it is spooled; re-uploading an already completed part is a no-op success. " +
"Uploading against a transfer the abandoned-spool sweep has expired returns 410 — the client " +
"re-creates the transfer.",
request: {
params: z.object({ transferId: z.string(), partIndex: z.string() }),
body: {
content: {
"application/octet-stream": {
schema: { type: "string", format: "binary", description: "The raw part bytes." },
},
},
required: true as const,
},
},
responses: {
200: r.ok(),
401: r.unauthorized,
404: r.notFound,
409: { description: "The transfer has already been applied" },
410: { description: "The transfer expired and its spooled parts were deleted" },
422: r.unprocessable,
},
});
registry.registerPath({
method: "get",
path: `${COMPANY_IMPORT_TRANSFERS_API_PATH}/{transferId}`,
tags: ["companies"],
summary: "Get company import transfer progress",
description:
"Resume polling for a chunked import transfer: the transfer status plus which declared " +
"parts are completed and which are still missing.",
request: { params: z.object({ transferId: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
});
registry.registerPath({
method: "post",
path: `${COMPANY_IMPORT_TRANSFERS_API_PATH}/{transferId}/apply`,
tags: ["companies"],
summary: "Apply a completed company import transfer",
description:
"Assembles the spooled parts back into the original zip, verifies the whole file against " +
"the declared hash fail-closed, and runs it through the same import pipeline as the " +
"single-shot upload — including the async import job machinery via the proxy-safe " +
"`?async=1` query parameter. The JSON body carries the same import fields as the multipart " +
"route's `meta` field (include, target, collisionStrategy, ...). Overlapping applies of " +
"the same transfer are serialized: exactly one proceeds, the rest get 409.",
request: {
params: z.object({ transferId: z.string() }),
query: z.object({ async: z.enum(["1"]).optional() }),
body: jsonBody(companyPortabilityImportSchema.omit({ source: true })),
},
responses: {
200: r.ok(),
202: { description: "Async import job accepted" },
400: r.badRequest,
401: r.unauthorized,
404: r.notFound,
409: {
description:
"Parts are still missing, an apply is already in progress, or the transfer was already applied",
},
422: r.unprocessable,
},
});
// ─── Board claim & CLI auth ───────────────────────────────────────────────────
registry.registerPath({

View File

@ -0,0 +1,183 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import type { Db } from "@paperclipai/db";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { companyTransferRunService } from "./company-transfer-runs.js";
// Disk spool for chunked resumable company imports. The client slices its
// existing import .zip into byte-range parts and uploads them one at a time;
// each verified part is spooled at
//
// <instance root>/import-transfers/<runId>/part-<index>
//
// until apply assembles them back into the original zip. The layout follows
// the other per-instance state dirs that live directly under the instance root
// (telemetry/, runtime-services/, skills/, data/run-logs/); like those
// siblings it needs no backup/export exclusion wiring — database backups dump
// embedded Postgres, they do not walk the instance root tree.
/** Spool dirs with no upload/apply activity for this long are abandoned. */
export const IMPORT_TRANSFER_SPOOL_MAX_AGE_MS = 24 * 60 * 60 * 1000;
/** How often the abandoned-spool sweep runs. */
export const IMPORT_TRANSFER_SPOOL_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function resolveDefaultImportTransferSpoolRoot(): string {
return path.resolve(resolvePaperclipInstanceRoot(), "import-transfers");
}
/**
* Transfer run ids come from request URLs and are joined into filesystem
* paths, so they are accepted only as canonical UUIDs anything else (path
* separators, dots, empty segments) is rejected before any path is built.
*/
export function isImportTransferRunId(value: string): boolean {
return UUID_RE.test(value);
}
export function importTransferPartName(index: number): string {
if (!Number.isInteger(index) || index < 0) {
throw new Error(`Invalid import transfer part index: ${String(index)}`);
}
return `part-${index}`;
}
function spoolDirFor(spoolRoot: string, runId: string): string {
if (!isImportTransferRunId(runId)) {
throw new Error("Invalid import transfer run id");
}
return path.join(spoolRoot, runId);
}
function partPathFor(spoolRoot: string, runId: string, index: number): string {
return path.join(spoolDirFor(spoolRoot, runId), importTransferPartName(index));
}
/** Atomic part write: temp file in the same dir, then rename over the target. */
export async function writeImportTransferPart(
spoolRoot: string,
runId: string,
index: number,
bytes: Buffer,
): Promise<void> {
const target = partPathFor(spoolRoot, runId, index);
await fs.mkdir(path.dirname(target), { recursive: true });
const tempPath = `${target}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await fs.writeFile(tempPath, bytes);
await fs.rename(tempPath, target);
}
/** Byte size of a spooled part, or null when it is missing (or not a file). */
export async function importTransferPartSizeOnDisk(
spoolRoot: string,
runId: string,
index: number,
): Promise<number | null> {
try {
const stat = await fs.stat(partPathFor(spoolRoot, runId, index));
return stat.isFile() ? stat.size : null;
} catch {
return null;
}
}
/**
* Concatenate the spooled parts, in index order, back into the original zip.
* The full-zip buffer this produces exists only for the duration of an apply
* the same memory profile as the single-shot zip upload path.
*/
export async function assembleImportTransferZip(
spoolRoot: string,
runId: string,
partCount: number,
): Promise<Buffer> {
const buffers: Buffer[] = [];
for (let index = 0; index < partCount; index += 1) {
buffers.push(await fs.readFile(partPathFor(spoolRoot, runId, index)));
}
return Buffer.concat(buffers);
}
export async function removeImportTransferSpool(spoolRoot: string, runId: string): Promise<void> {
await fs.rm(spoolDirFor(spoolRoot, runId), { recursive: true, force: true });
}
/**
* Remove one spooled part file. Used to discard a stray part written for a
* run that turned out to be dead (e.g. expired by the sweep mid-upload).
*/
export async function removeImportTransferPart(
spoolRoot: string,
runId: string,
index: number,
): Promise<void> {
await fs.rm(partPathFor(spoolRoot, runId, index), { force: true });
}
/**
* Delete spool dirs whose transfer saw no activity for `maxAgeMs` and cancel
* their still-open ledger runs. A run-backed spool is only deleted after an
* atomic claim: one guarded UPDATE cancels the run while it is still open
* (pending/running) AND its `updatedAt` (every part upload and resume bumps
* it) is still older than the cutoff so a transfer that resumed after this
* sweep read the run makes the guard miss and keeps its spool. The claim
* cancels rather than fails on purpose: a failed run would still be claimable
* by `claimApply`, letting an apply assemble the spool while this sweep
* deletes it; a cancelled run is unclaimable the instant the UPDATE commits,
* before any file is removed. Terminal runs keep their status and their spool
* (a failed run's verified parts stay reusable for an apply retry; a
* completed run's spool was already removed at apply). Orphan dirs with no
* run row fall back to the dir's mtime no run means no upload can race the
* delete. A swept run is never resumed: `resumeOrCreate` ignores cancelled
* runs, so redeclaring the same content starts a fresh run with every part
* missing matching the 410 "re-create it" a part upload against the swept
* run gets.
*/
export async function sweepAbandonedImportTransferSpools(
db: Db,
spoolRoot: string,
options: { maxAgeMs?: number; now?: Date } = {},
): Promise<{ swept: number }> {
const maxAgeMs = options.maxAgeMs ?? IMPORT_TRANSFER_SPOOL_MAX_AGE_MS;
const nowMs = (options.now ?? new Date()).getTime();
const cutoff = new Date(nowMs - maxAgeMs);
let entries;
try {
entries = await fs.readdir(spoolRoot, { withFileTypes: true });
} catch {
return { swept: 0 };
}
let swept = 0;
for (const entry of entries) {
if (!entry.isDirectory() || !isImportTransferRunId(entry.name)) continue;
const runId = entry.name;
const run = await companyTransferRunService.getRun(db, runId).catch(() => null);
if (run) {
if (run.status === "completed" || run.status === "cancelled") {
// Terminal runs never assemble again; a leftover spool (e.g. a
// best-effort post-apply cleanup that failed) is pure garbage and is
// collected immediately, regardless of idle time.
} else {
const claimed = await companyTransferRunService.cancelIfIdle(
db,
runId,
cutoff,
"Abandoned import transfer: spooled parts were deleted after prolonged inactivity.",
);
if (!claimed) continue;
}
} else {
let mtimeMs: number;
try {
mtimeMs = (await fs.stat(path.join(spoolRoot, runId))).mtimeMs;
} catch {
continue;
}
if (nowMs - mtimeMs <= maxAgeMs) continue;
}
await fs.rm(path.join(spoolRoot, runId), { recursive: true, force: true });
swept += 1;
}
return { swept };
}

View File

@ -0,0 +1,375 @@
import { and, desc, eq, inArray, lt, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companyTransferRuns } from "@paperclipai/db";
// Durable run ledger for chunked company transfers. A run tracks one export
// publish or import apply of a transfer container. Progress is recorded per
// container part (chunk or blob name) as each part finishes processing and
// verification, so an interrupted run — process restart, dropped connection,
// failed part — resumes from the parts it already completed. The transfer
// manifest's content-derived idempotency key ties retries of the same content
// to the same run instead of starting over.
//
// Status machine — every transition site, in one place:
//
// (insert) -> pending resumeOrCreate
// pending|running|failed --start---------> running
// pending|running|failed --claimApply----> applying exactly one winner of concurrent applies
// applying --complete------> completed terminal; no path ever reopens it
// applying --releaseApplyClaim-> failed guarded on "applying", so a cleanup error
// after complete() is a no-op release
// applying --recoverStrandedApplyingRuns-> failed
// startup only: in-memory applies died with the process
// pending|running (idle) --cancelIfIdle--> cancelled sweep claim; also guarded on a stale updatedAt
// pending|running|failed --cancel--------> cancelled
// (any) --fail----------> failed unguarded settle; callers use it only on runs
// they know are open (else releaseApplyClaim)
//
// failed: retryable — in the resume lookup and claimable by claimApply.
// completed: terminal — resumeOrCreate short-circuits with alreadyCompleted.
// cancelled: terminal and unclaimable — excluded from the resume lookup, so
// redeclaring the same content after a sweep starts a fresh run.
// completePart records parts only under pending|running — never onto an
// applying or terminal run.
export type CompanyTransferDirection = "export" | "import";
export type CompanyTransferRunStatus =
| "pending"
| "running"
| "applying"
| "completed"
| "failed"
| "cancelled";
/**
* Minimal structural view of a transfer manifest's part index: the ledger only
* ever needs part names and counts, so it stays decoupled from any particular
* container format. Manifests without this shape (e.g. the chunked zip-upload
* manifest, which carries its own part list) pass explicit counts to
* `recordManifest` and track parts by their own names.
*/
export interface CompanyTransferPartsIndex {
chunks: Array<{ name: string }>;
blobs: Array<{ name: string }>;
}
function asPartsIndex(manifest: unknown): CompanyTransferPartsIndex | null {
if (typeof manifest !== "object" || manifest === null) return null;
const { chunks, blobs } = manifest as { chunks?: unknown; blobs?: unknown };
const named = (value: unknown): value is Array<{ name: string }> =>
Array.isArray(value) &&
value.every(
(entry) =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as { name?: unknown }).name === "string",
);
if (!named(chunks) || !named(blobs)) return null;
return { chunks, blobs };
}
const RESUMABLE_STATUSES: CompanyTransferRunStatus[] = ["pending", "running", "failed"];
/** Statuses under which container parts may still be recorded as completed. */
const PART_UPLOAD_STATUSES: CompanyTransferRunStatus[] = ["pending", "running"];
export interface CompanyTransferRunRow {
id: string;
companyId: string | null;
direction: CompanyTransferDirection;
status: CompanyTransferRunStatus;
actorKey: string;
containerRef: unknown;
idempotencyKey: string;
manifestSha256: string | null;
manifest: unknown;
chunkCount: number;
blobCount: number;
completedParts: string[];
error: string | null;
startedAt: Date | null;
finishedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface ResumeOrCreateTransferRunInput {
direction: CompanyTransferDirection;
actorKey: string;
idempotencyKey: string;
/** Credential-free container location descriptor. Never put secrets here. */
containerRef: unknown;
companyId?: string | null;
}
export interface ResumeOrCreateTransferRunResult {
run: CompanyTransferRunRow;
/** True when an existing run for the same content was picked up. */
resumed: boolean;
/** True when that existing run already finished — the caller can short-circuit. */
alreadyCompleted: boolean;
}
function rowFromRecord(record: typeof companyTransferRuns.$inferSelect): CompanyTransferRunRow {
return {
...record,
direction: record.direction as CompanyTransferDirection,
status: record.status as CompanyTransferRunStatus,
manifest: record.manifest ?? null,
completedParts: Array.isArray(record.completedParts) ? (record.completedParts as string[]) : [],
};
}
async function getRun(db: Db, runId: string): Promise<CompanyTransferRunRow | null> {
const [record] = await db
.select()
.from(companyTransferRuns)
.where(eq(companyTransferRuns.id, runId))
.limit(1);
return record ? rowFromRecord(record) : null;
}
export const companyTransferRunService = {
getRun,
async getRunForActor(db: Db, runId: string, actorKey: string): Promise<CompanyTransferRunRow | null> {
const run = await getRun(db, runId);
return run && run.actorKey === actorKey ? run : null;
},
/**
* Find the actor's run for this exact content (same idempotency key and
* direction) or create a fresh one. A completed prior run is returned with
* `alreadyCompleted` so the caller can skip the apply outright; a pending,
* running, failed, or mid-apply prior run is resumed with its part progress
* intact (an "applying" run resumes so a concurrent re-declaration never
* spawns a duplicate, but `start` leaves it alone until the apply settles).
*/
async resumeOrCreate(db: Db, input: ResumeOrCreateTransferRunInput): Promise<ResumeOrCreateTransferRunResult> {
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
// No unique index backs the (actor, direction, content key) lookup, so
// two concurrent identical declarations could both miss the lookup and
// both insert. Serialize them with a transaction-scoped advisory lock
// on the key: the second caller waits here until the first commits and
// then resumes the first caller's row.
const lockKey = `${input.actorKey}|${input.direction}|${input.idempotencyKey}`;
await txDb.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
const matches = await txDb
.select()
.from(companyTransferRuns)
.where(
and(
eq(companyTransferRuns.idempotencyKey, input.idempotencyKey),
eq(companyTransferRuns.direction, input.direction),
eq(companyTransferRuns.actorKey, input.actorKey),
inArray(companyTransferRuns.status, [...RESUMABLE_STATUSES, "applying", "completed"]),
),
)
.orderBy(desc(companyTransferRuns.createdAt))
.limit(1);
const existing = matches[0] ? rowFromRecord(matches[0]) : null;
if (existing) {
return { run: existing, resumed: true, alreadyCompleted: existing.status === "completed" };
}
const [created] = await txDb
.insert(companyTransferRuns)
.values({
direction: input.direction,
actorKey: input.actorKey,
idempotencyKey: input.idempotencyKey,
containerRef: input.containerRef,
companyId: input.companyId ?? null,
})
.returning();
return { run: rowFromRecord(created!), resumed: false, alreadyCompleted: false };
});
},
/**
* Persist the parsed manifest so resume can re-verify parts without
* refetching it. Counts default to the manifest's chunk/blob index when it
* has one; manifests with a different part layout pass them explicitly.
*/
async recordManifest(
db: Db,
runId: string,
manifest: unknown,
manifestSha256: string,
counts?: { chunkCount: number; blobCount: number },
): Promise<void> {
const index = asPartsIndex(manifest);
const chunkCount = counts?.chunkCount ?? index?.chunks.length ?? 0;
const blobCount = counts?.blobCount ?? index?.blobs.length ?? 0;
await db
.update(companyTransferRuns)
.set({
manifest,
manifestSha256,
chunkCount,
blobCount,
updatedAt: new Date(),
})
.where(eq(companyTransferRuns.id, runId));
},
/**
* Atomically claim a run for apply. The apply itself is not idempotent, so
* of any overlapping apply attempts exactly one caller gets `true` here
* a single guarded UPDATE moves the run to "applying" only while it is
* still open (pending/running/failed). The winner settles the claim with
* `complete` on success or `releaseApplyClaim`/`fail` on error (which makes
* the run retryable again); every concurrent loser gets `false` and must
* not run the apply.
*/
async claimApply(db: Db, runId: string): Promise<boolean> {
const claimed = await db
.update(companyTransferRuns)
.set({ status: "applying", error: null, updatedAt: new Date() })
.where(
and(eq(companyTransferRuns.id, runId), inArray(companyTransferRuns.status, RESUMABLE_STATUSES)),
)
.returning({ id: companyTransferRuns.id });
return claimed.length > 0;
},
async start(db: Db, runId: string): Promise<void> {
await db
.update(companyTransferRuns)
.set({ status: "running", error: null, startedAt: new Date(), finishedAt: null, updatedAt: new Date() })
.where(
and(eq(companyTransferRuns.id, runId), inArray(companyTransferRuns.status, RESUMABLE_STATUSES)),
);
},
async attachCompany(db: Db, runId: string, companyId: string): Promise<void> {
await db
.update(companyTransferRuns)
.set({ companyId, updatedAt: new Date() })
.where(eq(companyTransferRuns.id, runId));
},
/**
* Record one finished container part. Appends atomically in SQL and is
* idempotent: re-completing an already recorded part leaves the ledger
* unchanged, so a retried part never double-counts. Only open runs
* (pending/running) accept parts; when the run has meanwhile gone terminal
* (e.g. the abandoned-spool sweep cancelled it) or is mid-apply, nothing is
* recorded and `false` comes back so the caller can discard the part.
*/
async completePart(db: Db, runId: string, partName: string): Promise<boolean> {
const partJson = JSON.stringify([partName]);
const updated = await db
.update(companyTransferRuns)
.set({
completedParts: sql`case when ${companyTransferRuns.completedParts} @> ${partJson}::jsonb then ${companyTransferRuns.completedParts} else ${companyTransferRuns.completedParts} || ${partJson}::jsonb end`,
updatedAt: new Date(),
})
.where(
and(eq(companyTransferRuns.id, runId), inArray(companyTransferRuns.status, PART_UPLOAD_STATUSES)),
)
.returning({ id: companyTransferRuns.id });
return updated.length > 0;
},
/** Container part names the run still has to process, in manifest order. */
remainingParts(run: CompanyTransferRunRow, manifest: CompanyTransferPartsIndex): string[] {
const completed = new Set(run.completedParts);
const remaining: string[] = [];
for (const chunk of manifest.chunks) {
if (!completed.has(chunk.name)) remaining.push(chunk.name);
}
for (const blob of manifest.blobs) {
if (!completed.has(blob.name)) remaining.push(blob.name);
}
return remaining;
},
async complete(db: Db, runId: string): Promise<void> {
await db
.update(companyTransferRuns)
.set({ status: "completed", error: null, finishedAt: new Date(), updatedAt: new Date() })
.where(eq(companyTransferRuns.id, runId));
},
/**
* Atomically claim an abandoned open run: cancel it only while it is still
* open (pending/running) AND saw no activity since `idleSince` one
* guarded UPDATE, so any concurrent part upload or resume that bumped
* `updatedAt` makes the guard miss and `false` comes back. Callers must
* only touch the run's spooled data after a `true` claim.
*
* The terminal state is "cancelled", not "failed", precisely because failed
* runs stay claimable: a failed sweep target could be claimed by an apply
* and assembled while its spool is being deleted underneath it. A cancelled
* run is unclaimable the instant this UPDATE commits before any
* filesystem deletion and `resumeOrCreate` ignores it, so redeclaring the
* same content afterwards starts a fresh run.
*/
async cancelIfIdle(db: Db, runId: string, idleSince: Date, error: string): Promise<boolean> {
const claimed = await db
.update(companyTransferRuns)
.set({ status: "cancelled", error, finishedAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(companyTransferRuns.id, runId),
inArray(companyTransferRuns.status, PART_UPLOAD_STATUSES),
lt(companyTransferRuns.updatedAt, idleSince),
),
)
.returning({ id: companyTransferRuns.id });
return claimed.length > 0;
},
/**
* Release a held apply claim into "failed". Guarded on "applying", unlike
* `fail`: catch-all error paths around an apply cannot know whether the run
* already settled (a cleanup error thrown after `complete` reaches the same
* catch as a real apply failure), and an unguarded fail there would flip a
* completed run back to claimable and invite a duplicate import. When the
* run already settled this is a no-op and returns false.
*/
async releaseApplyClaim(db: Db, runId: string, error: string): Promise<boolean> {
const released = await db
.update(companyTransferRuns)
.set({ status: "failed", error, finishedAt: new Date(), updatedAt: new Date() })
.where(and(eq(companyTransferRuns.id, runId), eq(companyTransferRuns.status, "applying")))
.returning({ id: companyTransferRuns.id });
return released.length > 0;
},
/**
* Fail every run stranded in "applying" by a dead process. Apply jobs run
* in-memory in this single server process, so at boot any run still
* "applying" belongs to an apply that died without settling its claim and
* would otherwise 409 every retry forever. Failing it makes it claimable
* again with its spooled parts intact. Startup only running this while
* applies may be live (e.g. from a periodic sweep) would kill them.
*/
async recoverStrandedApplyingRuns(db: Db): Promise<string[]> {
const recovered = await db
.update(companyTransferRuns)
.set({
status: "failed",
error: "apply interrupted by a restart — verify whether the import completed before retrying",
finishedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(companyTransferRuns.status, "applying"))
.returning({ id: companyTransferRuns.id });
return recovered.map((row) => row.id);
},
async fail(db: Db, runId: string, error: string): Promise<void> {
await db
.update(companyTransferRuns)
.set({ status: "failed", error, finishedAt: new Date(), updatedAt: new Date() })
.where(eq(companyTransferRuns.id, runId));
},
async cancel(db: Db, runId: string): Promise<void> {
await db
.update(companyTransferRuns)
.set({ status: "cancelled", finishedAt: new Date(), updatedAt: new Date() })
.where(and(eq(companyTransferRuns.id, runId), inArray(companyTransferRuns.status, RESUMABLE_STATUSES)));
},
};