Skip to content

Commit 07f9a3d

Browse files
authored
refactor(producer): add remote-ready plan v2 publisher (#2792)
1 parent 0499a5c commit 07f9a3d

8 files changed

Lines changed: 492 additions & 84 deletions

File tree

packages/producer/src/distributed.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export {
6060
listPlanV2ArtifactsForTarget,
6161
materializePlanV2Target,
6262
planV2,
63+
planV2WithPublisher,
64+
publishPlanV2FromV1,
6365
readPlanV2Manifest,
6466
validatePlanV2MaterializedTarget,
6567
PLAN_V2_INTEGRITY_UNRECOVERABLE,
@@ -71,7 +73,14 @@ export {
7173
type PlanV2MaterializationResult,
7274
type PlanV2MaterializationTarget,
7375
type PlanV2Result,
76+
type PlanV2WithPublisherOptions,
7477
} from "./services/distributed/planV2.js";
78+
export {
79+
LocalPlanV2ArtifactPublisher,
80+
type LocalPlanV2ArtifactPublisherOptions,
81+
type PlanV2ArtifactPublisher,
82+
type PlanV2PublishBlob,
83+
} from "./services/distributed/planV2Publisher.js";
7584
export { assembleV2, renderChunkV2 } from "./services/distributed/planV2Execution.js";
7685

7786
// ── RenderChunk (Activity B) ────────────────────────────────────────────────

packages/producer/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,13 @@ export {
152152
materializePlanV2Target,
153153
plan,
154154
planV2,
155+
planV2WithPublisher,
155156
PlanV2IntegrityError,
156157
PlanProtocolUnsupportedError,
157158
readPlanProtocol,
158159
readPlanProtocolV1,
159160
readPlanV2Manifest,
161+
publishPlanV2FromV1,
160162
renderChunk,
161163
renderChunkV2,
162164
validatePlanV2MaterializedTarget,
@@ -175,5 +177,8 @@ export {
175177
type PlanV2MaterializationResult,
176178
type PlanV2MaterializationTarget,
177179
type PlanV2Result,
180+
type PlanV2WithPublisherOptions,
181+
type PlanV2ArtifactPublisher,
182+
type PlanV2PublishBlob,
178183
type SupportedPlanProtocolDescriptor,
179184
} from "./distributed.js";

packages/producer/src/services/distributed/planV2.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
mkdtempSync,
66
readFileSync,
77
rmSync,
8+
statSync,
89
symlinkSync,
910
writeFileSync,
1011
} from "node:fs";
@@ -20,9 +21,11 @@ import {
2021
materializePlanV2Target,
2122
PLAN_V2_INTEGRITY_UNRECOVERABLE,
2223
PlanV2IntegrityError,
24+
publishPlanV2FromV1,
2325
readPlanV2Manifest,
2426
validatePlanV2MaterializedTarget,
2527
} from "./planV2.js";
28+
import { LocalPlanV2ArtifactPublisher, type PlanV2ArtifactPublisher } from "./planV2Publisher.js";
2629

2730
const tempDirs: string[] = [];
2831

@@ -399,6 +402,149 @@ describe("Plan v2 manifest", () => {
399402
});
400403
});
401404

405+
describe("Plan v2 artifact publisher", () => {
406+
it("publishes the manifest last and hard-links local immutable blobs", async () => {
407+
const root = tempPath("hf-plan-v2-publisher-");
408+
const v1 = createV1Plan(root, { audio: true });
409+
const destination = join(root, "v2");
410+
const publisher = new LocalPlanV2ArtifactPublisher(destination);
411+
const manifest = await publishPlanV2FromV1(v1, publisher);
412+
const artifact = manifest.artifacts.find(
413+
(candidate) => candidate.path === "compiled/asset.txt",
414+
);
415+
if (artifact === undefined) throw new Error("test fixture is missing compiled/asset.txt");
416+
const sourceStat = statSync(join(v1, artifact.path));
417+
const blobStat = statSync(
418+
join(destination, "artifacts", "sha256", artifact.sha256.slice(0, 2), artifact.sha256),
419+
);
420+
421+
expect(readPlanV2Manifest(destination)).toEqual(manifest);
422+
expect({ dev: blobStat.dev, ino: blobStat.ino }).toEqual({
423+
dev: sourceStat.dev,
424+
ino: sourceStat.ino,
425+
});
426+
});
427+
428+
it("falls back to an atomic copy when hard-linking is unavailable", async () => {
429+
const root = tempPath("hf-plan-v2-publisher-copy-");
430+
const v1 = createV1Plan(root);
431+
const destination = join(root, "v2");
432+
const publisher = new LocalPlanV2ArtifactPublisher(destination, {
433+
linkFile() {
434+
throw Object.assign(new Error("cross-device link"), { code: "EXDEV" });
435+
},
436+
});
437+
const manifest = await publishPlanV2FromV1(v1, publisher);
438+
const artifact = manifest.artifacts.find(
439+
(candidate) => candidate.path === "compiled/asset.txt",
440+
);
441+
if (artifact === undefined) throw new Error("test fixture is missing compiled/asset.txt");
442+
const sourcePath = join(v1, artifact.path);
443+
const blobPath = join(
444+
destination,
445+
"artifacts",
446+
"sha256",
447+
artifact.sha256.slice(0, 2),
448+
artifact.sha256,
449+
);
450+
451+
expect(readFileSync(blobPath)).toEqual(readFileSync(sourcePath));
452+
expect({ dev: statSync(blobPath).dev, ino: statSync(blobPath).ino }).not.toEqual({
453+
dev: statSync(sourcePath).dev,
454+
ino: statSync(sourcePath).ino,
455+
});
456+
});
457+
458+
it("produces byte-identical local CAS output through both publication paths", async () => {
459+
const root = tempPath("hf-plan-v2-publisher-parity-");
460+
const v1 = createV1Plan(root, { audio: true });
461+
const directDir = join(root, "direct");
462+
const publishedDir = join(root, "published");
463+
createPlanV2FromV1(v1, directDir);
464+
const publisher = new LocalPlanV2ArtifactPublisher(publishedDir);
465+
const manifest = await publishPlanV2FromV1(v1, publisher);
466+
467+
expect(readFileSync(join(publishedDir, "plan.json"))).toEqual(
468+
readFileSync(join(directDir, "plan.json")),
469+
);
470+
for (const artifact of manifest.artifacts) {
471+
const suffix = join("artifacts", "sha256", artifact.sha256.slice(0, 2), artifact.sha256);
472+
expect(readFileSync(join(publishedDir, suffix))).toEqual(
473+
readFileSync(join(directDir, suffix)),
474+
);
475+
}
476+
});
477+
478+
it("supports a remote publisher contract with no shared destination filesystem", async () => {
479+
const root = tempPath("hf-plan-v2-remote-publisher-");
480+
const v1 = createV1Plan(root, { audio: true });
481+
const blobs = new Map<string, Buffer>();
482+
let committedManifest: string | undefined;
483+
const publisher: PlanV2ArtifactPublisher = {
484+
async putBlob(blob) {
485+
blobs.set(blob.sha256, readFileSync(blob.sourcePath));
486+
},
487+
async commitManifest(manifestBytes) {
488+
committedManifest = manifestBytes;
489+
},
490+
async abort() {},
491+
};
492+
493+
const manifest = await publishPlanV2FromV1(v1, publisher);
494+
expect(committedManifest).toBe(canonicalJsonStringify(manifest));
495+
expect(blobs.size).toBe(new Set(manifest.artifacts.map((artifact) => artifact.sha256)).size);
496+
for (const artifact of manifest.artifacts) {
497+
expect(blobs.get(artifact.sha256)?.byteLength).toBe(artifact.sizeBytes);
498+
}
499+
});
500+
501+
it("rejects malformed digests before constructing a local CAS path", async () => {
502+
const root = tempPath("hf-plan-v2-publisher-digest-");
503+
const sourcePath = join(root, "source");
504+
writeFileSync(sourcePath, "bytes");
505+
const publisher = new LocalPlanV2ArtifactPublisher(join(root, "v2"));
506+
507+
await expect(
508+
publisher.putBlob({ sourcePath, sha256: "../escape", sizeBytes: 5 }),
509+
).rejects.toThrow("must be a lowercase sha256 digest");
510+
await publisher.abort();
511+
});
512+
513+
it("refuses to commit a manifest until every referenced blob is durable", async () => {
514+
const root = tempPath("hf-plan-v2-publisher-incomplete-");
515+
const publisher = new LocalPlanV2ArtifactPublisher(join(root, "v2"));
516+
const digest = "a".repeat(64);
517+
518+
await expect(
519+
publisher.commitManifest(JSON.stringify({ artifacts: [{ sha256: digest }] })),
520+
).rejects.toThrow("cannot commit manifest before referenced blob is durable");
521+
await publisher.abort();
522+
});
523+
524+
it("aborts without committing a manifest when a blob publish fails", async () => {
525+
const root = tempPath("hf-plan-v2-publisher-failure-");
526+
const calls: string[] = [];
527+
const publisher: PlanV2ArtifactPublisher = {
528+
async putBlob(blob) {
529+
calls.push(`blob:${blob.sha256}`);
530+
throw new Error("injected blob failure");
531+
},
532+
async commitManifest() {
533+
calls.push("manifest");
534+
},
535+
async abort() {
536+
calls.push("abort");
537+
},
538+
};
539+
540+
await expect(publishPlanV2FromV1(createV1Plan(root), publisher)).rejects.toThrow(
541+
"injected blob failure",
542+
);
543+
expect(calls.at(-1)).toBe("abort");
544+
expect(calls).not.toContain("manifest");
545+
});
546+
});
547+
402548
describe("Plan v2 hash schema", () => {
403549
it("does not reuse a raw artifact digest as its manifest hash", () => {
404550
const root = tempPath("hf-plan-v2-hash-");

0 commit comments

Comments
 (0)