fix(server): render company-export YAML iteratively to stop stack overflow (#10854)
## Thinking Path > - Paperclip is the control plane for autonomous AI companies. > - The company export path writes `.paperclip.yaml` data for large companies. > - The YAML renderer used a spread append that can overflow the call stack on large arrays. > - That failure turns a normal export into a 500 for large companies. > - This pull request rewrites the renderer to use an iterative stack and removes the last spread append. > - The benefit is that large exports finish without a RangeError and keep the same output. ## Linked Issues or Issue Description I searched GitHub for related work. I found PR #7506. This pull request closes the last spread site that PR left open. **What happened?** The company export failed with `RangeError: Maximum call stack size exceeded` on large YAML output. **Expected behavior** The export should finish without a stack overflow. **Steps to reproduce** 1. Export a company with a very large YAML payload. 2. Render the export through `renderYamlBlock` or `renderFrontmatter`. 3. Observe that the old spread append can overflow the call stack. **Paperclip version or commit** `79f3a216215500e2ec1a928d5eb5c09364c2abf5` **Deployment mode** Local dev (`pnpm dev`) or built from source. **Additional context** Related public PR: #7506. This change keeps the YAML shape, scalar format, and key order the same. ## What Changed - Reworked `renderYamlBlock` to render iteratively. - Replaced the last spread append in `renderFrontmatter` with a loop. - Added regression tests for high-volume block and frontmatter arrays. ## Verification - `node_modules/.bin/vitest run server/src/__tests__/company-portability.test.ts` - The two new overflow tests pass. - The existing round-trip tests still pass. - `tsc --noEmit` is clean for `server/src/services/company-portability.ts`. ## Risks Low risk. The change keeps exported YAML content and ordering the same. ## Model Used OpenAI GPT-5, tool-using coding agent. ## 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] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
678728f650
commit
74416e9cd1
|
|
@ -154,7 +154,7 @@ vi.mock("../routes/org-chart-svg.js", () => ({
|
|||
renderOrgChartPng: vi.fn(async () => Buffer.from("png")),
|
||||
}));
|
||||
|
||||
const { companyPortabilityService, parseGitHubSourceUrl } = await import("../services/company-portability.js");
|
||||
const { companyPortabilityService, parseGitHubSourceUrl, renderYamlBlock, renderFrontmatter } = await import("../services/company-portability.js");
|
||||
|
||||
function asTextFile(entry: CompanyPortabilityFileEntry | undefined) {
|
||||
expect(typeof entry).toBe("string");
|
||||
|
|
@ -457,6 +457,29 @@ describe("company portability", () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it("renders high-volume YAML blocks without overflowing the call stack", () => {
|
||||
const tasks = Array.from({ length: 130_000 }, (_, index) => `issue-${index}`);
|
||||
|
||||
const lines = renderYamlBlock({ tasks }, 0);
|
||||
|
||||
expect(lines[0]).toBe("tasks:");
|
||||
expect(lines[1]).toBe(' - "issue-0"');
|
||||
expect(lines.at(-1)).toBe(' - "issue-129999"');
|
||||
});
|
||||
|
||||
it("renders high-volume frontmatter arrays without overflowing the call stack", () => {
|
||||
const tasks = Array.from({ length: 130_000 }, (_, index) => `issue-${index}`);
|
||||
|
||||
const rendered = renderFrontmatter({ tasks });
|
||||
const lines = rendered.split("\n");
|
||||
|
||||
expect(lines[0]).toBe("---");
|
||||
expect(lines[1]).toBe("tasks:");
|
||||
expect(lines[2]).toBe(' - "issue-0"');
|
||||
expect(lines[130_001]).toBe(' - "issue-129999"');
|
||||
expect(lines[130_002]).toBe("---");
|
||||
});
|
||||
|
||||
it("parses canonical GitHub import URLs with explicit ref and package path", () => {
|
||||
expect(
|
||||
parseGitHubSourceUrl("https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack"),
|
||||
|
|
|
|||
|
|
@ -2340,72 +2340,91 @@ function orderedYamlEntries(value: Record<string, unknown>) {
|
|||
return Object.entries(value).sort(([leftKey], [rightKey]) => compareYamlKeys(leftKey, rightKey));
|
||||
}
|
||||
|
||||
function renderYamlBlock(value: unknown, indentLevel: number): string[] {
|
||||
const indent = " ".repeat(indentLevel);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return [`${indent}[]`];
|
||||
const lines: string[] = [];
|
||||
for (const entry of value) {
|
||||
const scalar =
|
||||
entry === null ||
|
||||
typeof entry === "string" ||
|
||||
typeof entry === "boolean" ||
|
||||
typeof entry === "number" ||
|
||||
Array.isArray(entry) && entry.length === 0 ||
|
||||
isEmptyObject(entry);
|
||||
if (scalar) {
|
||||
lines.push(`${indent}- ${renderYamlScalar(entry)}`);
|
||||
continue;
|
||||
}
|
||||
lines.push(`${indent}-`);
|
||||
lines.push(...renderYamlBlock(entry, indentLevel + 1));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (isPlainRecord(value)) {
|
||||
const entries = orderedYamlEntries(value);
|
||||
if (entries.length === 0) return [`${indent}{}`];
|
||||
const lines: string[] = [];
|
||||
for (const [key, entry] of entries) {
|
||||
const scalar =
|
||||
entry === null ||
|
||||
typeof entry === "string" ||
|
||||
typeof entry === "boolean" ||
|
||||
typeof entry === "number" ||
|
||||
Array.isArray(entry) && entry.length === 0 ||
|
||||
isEmptyObject(entry);
|
||||
if (scalar) {
|
||||
lines.push(`${indent}${key}: ${renderYamlScalar(entry)}`);
|
||||
continue;
|
||||
}
|
||||
lines.push(`${indent}${key}:`);
|
||||
lines.push(...renderYamlBlock(entry, indentLevel + 1));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
return [`${indent}${renderYamlScalar(value)}`];
|
||||
function isYamlScalarValue(value: unknown) {
|
||||
return (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "number" ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
isEmptyObject(value)
|
||||
);
|
||||
}
|
||||
|
||||
function renderFrontmatter(frontmatter: Record<string, unknown>) {
|
||||
type YamlRenderFrame =
|
||||
| { kind: "line"; text: string }
|
||||
| { kind: "value"; value: unknown; indentLevel: number };
|
||||
|
||||
export function renderYamlBlock(value: unknown, indentLevel: number): string[] {
|
||||
const lines: string[] = [];
|
||||
const stack: YamlRenderFrame[] = [{ kind: "value", value, indentLevel }];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const frame = stack.pop()!;
|
||||
if (frame.kind === "line") {
|
||||
lines.push(frame.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
const indent = " ".repeat(frame.indentLevel);
|
||||
const current = frame.value;
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
if (current.length === 0) {
|
||||
lines.push(`${indent}[]`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let index = current.length - 1; index >= 0; index -= 1) {
|
||||
const entry = current[index];
|
||||
if (isYamlScalarValue(entry)) {
|
||||
stack.push({ kind: "line", text: `${indent}- ${renderYamlScalar(entry)}` });
|
||||
continue;
|
||||
}
|
||||
stack.push({ kind: "value", value: entry, indentLevel: frame.indentLevel + 1 });
|
||||
stack.push({ kind: "line", text: `${indent}-` });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPlainRecord(current)) {
|
||||
const entries = orderedYamlEntries(current);
|
||||
if (entries.length === 0) {
|
||||
lines.push(`${indent}{}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const [key, entry] = entries[index]!;
|
||||
if (isYamlScalarValue(entry)) {
|
||||
stack.push({ kind: "line", text: `${indent}${key}: ${renderYamlScalar(entry)}` });
|
||||
continue;
|
||||
}
|
||||
stack.push({ kind: "value", value: entry, indentLevel: frame.indentLevel + 1 });
|
||||
stack.push({ kind: "line", text: `${indent}${key}:` });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push(`${indent}${renderYamlScalar(current)}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function renderFrontmatter(frontmatter: Record<string, unknown>) {
|
||||
const lines: string[] = ["---"];
|
||||
for (const [key, value] of orderedYamlEntries(frontmatter)) {
|
||||
// Skip null/undefined values — don't export empty fields
|
||||
if (value === null || value === undefined) continue;
|
||||
const scalar =
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "number" ||
|
||||
Array.isArray(value) && value.length === 0 ||
|
||||
isEmptyObject(value);
|
||||
if (scalar) {
|
||||
if (isYamlScalarValue(value)) {
|
||||
lines.push(`${key}: ${renderYamlScalar(value)}`);
|
||||
continue;
|
||||
}
|
||||
lines.push(`${key}:`);
|
||||
lines.push(...renderYamlBlock(value, 1));
|
||||
// Append each rendered line without a spread. A spread of a large array as
|
||||
// function arguments overflows the argument limit and throws RangeError.
|
||||
for (const line of renderYamlBlock(value, 1)) lines.push(line);
|
||||
}
|
||||
lines.push("---");
|
||||
return `${lines.join("\n")}\n`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue