feat(hub): concurrent multi-PDF convert_pdf_to_md + readable skills (v0.0.40)

Teachers convert many PDFs in one tool call with bounded Docmind concurrency.
Each item keeps its own output_dir/document.md and UsageFact; failures are
per-file. Mirror role skills to .cph/runtime-skills and CPH_RUNTIME_SKILLS_DIR
so agents can Read SKILL.md instead of dead .claude/sandbox stubs.
This commit is contained in:
2026-07-21 05:55:55 +00:00
parent 54b9fee22c
commit db49a0d23d
10 changed files with 475 additions and 36 deletions
+39 -1
View File
@@ -1,8 +1,9 @@
import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createAgentSecurityPolicy } from "../../src/agent/security.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
describe("agent subprocess security policy", () => {
const roots: string[] = [];
@@ -135,6 +136,43 @@ describe("agent subprocess security policy", () => {
})).rejects.toThrow("Agent temp path is too long for sandbox bridge sockets");
});
it("mirrors selected skills under .cph/runtime-skills and exposes CPH_RUNTIME_SKILLS_DIR", async () => {
const { root, workspaceRoot, workspace } = await makeWorkspace();
const storeRoot = join(root, "skills-store");
const skillSource = join(root, "skill-src", "pdf-to-md");
await mkdir(skillSource, { recursive: true });
await writeFile(
join(skillSource, "SKILL.md"),
"---\nname: pdf-to-md\ndescription: convert\n---\n# pdf-to-md\nbatch items\n",
);
const installed = await importSkillDirectory({ sourceDir: skillSource, storeRoot });
const policy = await createAgentSecurityPolicy({
runId: "run-skills",
workspaceRoot,
workspaceDir: workspace,
skills: [{
name: "pdf-to-md",
version: "1",
contentDigest: installed.contentDigest,
}],
hostEnv: {
PATH: "/usr/bin:/bin",
HUB_SKILL_STORE_ROOT: storeRoot,
},
});
const canonicalWorkspace = await realpath(workspace);
const mirrored = join(canonicalWorkspace, ".cph", "runtime-skills", "pdf-to-md", "SKILL.md");
await expect(readFile(mirrored, "utf8")).resolves.toContain("batch items");
expect(policy.env.CPH_RUNTIME_SKILLS_DIR).toBe(join(canonicalWorkspace, ".cph", "runtime-skills"));
expect(policy.env.CPH_RUNTIME_SKILLS_REL).toBe(join(".cph", "runtime-skills"));
expect(policy.skillIds).toEqual(["cph-runtime:pdf-to-md"]);
expect(policy.sandbox.filesystem.allowRead).toEqual(
expect.arrayContaining([canonicalWorkspace, policy.skillPluginRoot]),
);
await policy.cleanup();
});
it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
const { root, workspaceRoot } = await makeWorkspace();
const outside = join(root, "outside");
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
import {
clampPdfToMdConcurrency,
invokePdfToMdBatch,
mapPool,
readPdfToMdConcurrency,
} from "../../src/capability/pdfToMdBundle.js";
import type { CapabilityAdapter, CapabilityInvocationResult } from "../../src/capability/types.js";
function okResult(label: string, pages: number): CapabilityInvocationResult {
return {
artifacts: [{ path: `out/${label}/document.md`, kind: "markdown" }],
consumption: {
provider: "aliyun_docmind",
model: null,
inputTokens: null,
outputTokens: null,
quantity: pages,
unit: "pages",
costUsd: pages * 0.0056,
correlationId: `job-${label}`,
},
};
}
describe("pdf_to_md batch concurrency helpers", () => {
it("mapPool caps in-flight workers", async () => {
let inFlight = 0;
let maxInFlight = 0;
const values = await mapPool([1, 2, 3, 4, 5], 2, async (item) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 40));
inFlight -= 1;
return item * 10;
});
expect(values).toEqual([10, 20, 30, 40, 50]);
expect(maxInFlight).toBe(2);
});
it("clamps concurrency and reads env", () => {
expect(clampPdfToMdConcurrency(99)).toBe(8);
expect(clampPdfToMdConcurrency(0)).toBe(1);
expect(readPdfToMdConcurrency({})).toBe(3);
expect(readPdfToMdConcurrency({ HUB_PDF_TO_MD_MAX_CONCURRENT: "5" })).toBe(5);
expect(() => readPdfToMdConcurrency({ HUB_PDF_TO_MD_MAX_CONCURRENT: "nope" })).toThrow(/positive integer/);
});
it("runs batch items concurrently and preserves order", async () => {
let inFlight = 0;
let maxInFlight = 0;
const adapter: CapabilityAdapter = {
capabilityId: "pdf_to_md_bundle",
invoke: vi.fn(async (input) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 60));
inFlight -= 1;
const label = input.inputPath.includes("b") ? "b" : input.inputPath.includes("c") ? "c" : "a";
return okResult(label, label === "a" ? 1 : label === "b" ? 2 : 3);
}),
};
const batch = await invokePdfToMdBatch(
adapter,
{
runId: "run-1",
organizationId: "org-1",
projectId: "proj-1",
workspaceDir: "/tmp/ws",
prisma: {} as never,
},
[
{ inputPath: "a.pdf", outputDir: "out/a" },
{ inputPath: "b.pdf", outputDir: "out/b" },
{ inputPath: "c.pdf", outputDir: "out/c" },
],
3,
);
expect(batch.map((item) => item.ok)).toEqual([true, true, true]);
expect(maxInFlight).toBe(3);
expect(adapter.invoke).toHaveBeenCalledTimes(3);
if (batch[0]?.ok && batch[1]?.ok && batch[2]?.ok) {
expect(batch[0].result.consumption.correlationId).toBe("job-a");
expect(batch[1].result.consumption.correlationId).toBe("job-b");
expect(batch[2].result.consumption.correlationId).toBe("job-c");
}
});
it("isolates per-item failures without canceling siblings", async () => {
const adapter: CapabilityAdapter = {
capabilityId: "pdf_to_md_bundle",
invoke: vi.fn(async (input) => {
if (input.inputPath.includes("bad")) {
throw new Error("boom");
}
return okResult("ok", 1);
}),
};
const batch = await invokePdfToMdBatch(
adapter,
{
runId: "run-1",
organizationId: "org-1",
projectId: "proj-1",
workspaceDir: "/tmp/ws",
prisma: {} as never,
},
[
{ inputPath: "ok.pdf", outputDir: "out/ok" },
{ inputPath: "bad.pdf", outputDir: "out/bad" },
],
2,
);
expect(batch[0]?.ok).toBe(true);
expect(batch[1]?.ok).toBe(false);
if (batch[1]?.ok === false) {
expect(batch[1].error).toMatch(/boom/);
}
});
it("rejects duplicate output dirs before starting work", async () => {
const invoke = vi.fn();
const adapter: CapabilityAdapter = {
capabilityId: "pdf_to_md_bundle",
invoke,
};
await expect(invokePdfToMdBatch(
adapter,
{
runId: "run-1",
organizationId: "org-1",
projectId: "proj-1",
workspaceDir: "/tmp/ws",
prisma: {} as never,
},
[
{ inputPath: "a.pdf", outputDir: "out/same" },
{ inputPath: "b.pdf", outputDir: "out/same/" },
],
2,
)).rejects.toThrow(/distinct output_dir/);
expect(invoke).not.toHaveBeenCalled();
});
});