Files
PaperClipAI/cli/src/commands/client/zip.ts
T
Nicky LeachandClaude Opus 5.5 444b4de693 fix(cli): use native CRC-32 for import zips and give the e2e server time to start (#19667)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Contributors wait for PR CI before merge, and a flaky test forces a
re-run of the whole CI run
> - Two CLI tests failed several times on 2026-11-06 on commits that
passed on a later attempt
> - Both failures came from time limits on CPU-contended 4-vCPU runners,
and both appeared in the same jobs
> - One cause is a slow checksum loop that the CLI's own zip writer also
uses
> - This pull request removes that cost and gives the e2e child server a
startup budget that covers a cold start
> - The benefit is fewer whole-run re-runs, and faster large-package
zips for CLI users

## Linked Issues or Issue Description

**What happened?**

Two tests in `cli/src/__tests__` failed in PR CI and passed on a re-run
of the same commit:

- `company-import-transfer.test.ts` › "resolveChunkedImportZip › takes
the chunked path…" failed with `Test timed out in 5000ms` (2 times).
- `company-import-export-e2e.test.ts` failed with `Timed out waiting for
the child server` (3 times).

**Expected behavior**

Both tests pass on a loaded CI runner.

**Steps to reproduce**

1. Run the `cli` Vitest project with `--maxWorkers=4` in a 4-CPU
container (`docker run --cpus=4 --memory=16g`), with a 4-thread CPU load
in that container and a fresh `TMPDIR`.
2. Before this change, both tests fail in 3 of 3 runs.

## What Changed

- `cli/src/commands/client/zip.ts`: the stored-zip writer uses `crc32`
from `node:zlib` instead of a bitwise JavaScript loop. The CLI uses this
writer for packages past the chunked-import threshold. Over about 64 MB,
the old loop takes about 14 seconds and `zlib.crc32` takes 35 ms.
`zlib.crc32` has been in Node since 22.2, and the engines field requires
Node 24.11 or later.
- `cli/src/__tests__/company-import-transfer.test.ts`: the fixture's zip
builder uses `zlib.crc32` too.
- `cli/src/__tests__/company-import-export-e2e.test.ts`:
- add `SERVER_STARTUP_TIMEOUT_MS = 90_000` for `waitForServer`, which
was 30 seconds;
- set the `beforeAll` timeout to that value plus 30 seconds for the
database start;
- a comment gives the measurements. A child that exits early still fails
at once. No assertions change.

## Verification

- **Cause of the e2e failure:** `paperclipai run` loads the CLI and the
server from source through tsx, and every CI run starts with a cold tsx
cache.
- The server is slow to start, not hung. With a larger budget at heavy
load, startup took 50 to 55 seconds.
- Passing CI jobs already used 15 to 25 seconds of the 30-second budget.
- In the failed logs, nothing comes from the server after "Starting
Paperclip server...".
- **Container reproduction** (4 CPUs, 16 GB, a 4-thread load in the same
container, cold tsx cache, full `cli` project with 4 workers):

| State | Result | Dense-zip test | Oversized-folder test |
| --- | --- | --- | --- |
| Before | failed 3/3 (child-server timeout; the dense test also timed
out in one run) | 4.6–5.5 s | 2.7–3.2 s |
| CRC fix only | the e2e test still failed 3/3 | 0.8–1.3 s | — |
| Full fix | all 65 files passed 3/3 | 0.7–1.3 s | 0.15–0.2 s |

- `pnpm typecheck` in `cli`: passes.
- Host run with `--maxWorkers=2` of the changed and related files: 4
files, 23/23 pass.

## Risks

- The e2e change is a larger time budget, not a faster start. A real
startup regression now takes up to 90 seconds to fail.
- Each "after" result comes from 3 runs, which is a small sample.
- `zlib.crc32` returns the same unsigned 32-bit value as the old loop.
The existing zip round-trip tests cover this.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and
code execution. A Claude sub-agent found the causes and made the fix,
and the parent session reviewed it.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:09:15 -08:00

109 lines
4.4 KiB
TypeScript

import { crc32 } from "node:zlib";
// The node-side portability zip reader lives in @paperclipai/shared so the
// server can consume the same codec (a raw uploaded zip is unzipped into the
// exact `{ rootPath, files }` bundle the inline import source carries). This
// module re-exports it to keep the CLI's existing import paths stable.
export {
binaryContentTypeByExtension,
bytesToPortableFileEntry,
isBlobStorePath,
readZipArchive,
} from "@paperclipai/shared/portability-zip";
// STORE-only zip writer used to package a local folder in memory for the
// chunked import transfer path. STORE keeps the writer dependency-free; the
// transfer slices the raw archive bytes, so compression only trades CPU for
// part count. Classic zip only: entry counts and offsets past the 16/32-bit
// header fields would need zip64, which the reader side does not require and
// this writer refuses to emit.
const ZIP_MAX_ENTRIES = 0xffff;
const ZIP_MAX_OFFSET_BYTES = 0xffffffff;
function writeUint16(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
}
function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
target[offset + 2] = (value >>> 16) & 0xff;
target[offset + 3] = (value >>> 24) & 0xff;
}
/**
* Build a stored (uncompressed) zip of `files` under a single `rootPath/`
* top-level directory — the layout `readZipArchive` folds back into the
* inline `{ rootPath, files }` bundle. Entries are written in sorted path
* order and carry no timestamps, so the same content always produces the
* same bytes and a re-run resumes its content-addressed transfer.
*/
export function createStoredZipArchive(files: Record<string, Uint8Array>, rootPath: string): Uint8Array {
const entries = Object.entries(files).sort(([left], [right]) => left.localeCompare(right));
if (entries.length > ZIP_MAX_ENTRIES) {
throw new Error(`Package has too many files to zip (${entries.length}; the zip format caps at ${ZIP_MAX_ENTRIES}).`);
}
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
for (const [relativePath, body] of entries) {
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
const checksum = crc32(body);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, 0);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, body.length);
writeUint32(localHeader, 22, body.length);
writeUint16(localHeader, 26, fileName.length);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, 0);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, body.length);
writeUint32(centralHeader, 24, body.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, body);
centralChunks.push(centralHeader);
localOffset += localHeader.length + body.length;
if (body.length > ZIP_MAX_OFFSET_BYTES || localOffset > ZIP_MAX_OFFSET_BYTES) {
throw new Error("Package is too large to zip in memory (zip64 archives are not supported).");
}
}
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(localOffset + centralDirectoryLength + 22);
let offset = 0;
for (const chunk of localChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
const centralDirectoryOffset = offset;
for (const chunk of centralChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
writeUint32(archive, offset, 0x06054b50);
writeUint16(archive, offset + 8, entries.length);
writeUint16(archive, offset + 10, entries.length);
writeUint32(archive, offset + 12, centralDirectoryLength);
writeUint32(archive, offset + 16, centralDirectoryOffset);
return archive;
}