fix(deploy): ship data/ so Docker images keep their baked plugin previews (#7028)

`deploy/Dockerfile` copies skills, design-systems, craft, prompt-templates,
assets and plugins/_official, but not data/ — so
`data/plugin-previews/manifest.json`, which CI commits precisely so deployments
can find the bakes, is absent at runtime. `resolvePluginPreviewsDir()` defaults
to <projectRoot>/data/plugin-previews, `loadManifest()` returns {},
`applyBakedPreviews()` returns the records untouched, and
`inferPluginPreview(record, { preferBaked: true })` falls through to the live
iframe for every plugin.

Measured on 0.19.2 with 461 registered plugins: the checked-in manifest has 176
entries and 0 records carried od.bakedPreview. With data/ present the same
daemon reports 176 — deck 79, prototype 79, video 10, scenario 4, image 1,
audio 1, template 2. The packaged app reaches the manifest through its own
packaging path, so only Docker/self-hosted deployments are affected.

Also warn once per path when the manifest is absent. `loadManifest()` already
carries the reasoning for the malformed case — «would otherwise silently
disable every baked preview with no trace; surface it so it's diagnosable» —
but the absent branch returned {} with no message, which is the same failure
with the same lack of trace.

Fixes #7018

Co-authored-by: Jason <>
This commit is contained in:
chejiulong
2026-09-01 09:03:36 +00:00
committed by GitHub
co-authored by Jason <>
parent a36ebb0df3
commit 743ae80d71
4 changed files with 107 additions and 1 deletions
@@ -46,9 +46,25 @@ export function resolvePluginPreviewsDir(projectRoot: string): string {
let cache: { dir: string; mtimeMs: number; previews: Record<string, BakeEntry> } | null = null;
// Absent-manifest notices are one-per-path: `loadManifest` runs on every plugin
// listing, and a deployment that has no manifest has none on every call.
const warnedMissing = new Set<string>();
function loadManifest(dir: string): Record<string, BakeEntry> {
const manifestPath = path.join(dir, 'manifest.json');
if (!existsSync(manifestPath)) return {};
if (!existsSync(manifestPath)) {
// Same reasoning as the malformed case below: with no manifest every baked
// preview is disabled and every plugin falls back to a live iframe, which
// looks like nothing more than a slower gallery. Say so once, so the cause
// is greppable instead of having to be inferred from the rendering path.
if (!warnedMissing.has(manifestPath)) {
warnedMissing.add(manifestPath);
console.warn(
`[plugin-preview-bakes] no manifest at ${manifestPath}; baked previews are disabled and every plugin will use the live preview path`,
);
}
return {};
}
try {
const mtimeMs = statSync(manifestPath).mtimeMs;
if (cache && cache.dir === dir && cache.mtimeMs === mtimeMs) return cache.previews;
@@ -202,4 +202,36 @@ describe('applyBakedPreviews', () => {
});
expect(out[1]).toBe(records[1]);
});
it('says so when there is no manifest at all, instead of disabling bakes silently', async () => {
// A missing manifest disables every baked preview and sends every plugin to
// the live-iframe path, which presents as nothing worse than a slower
// gallery — the same "silently disable with no trace" failure the malformed
// branch already warns about. An image built without `data/` lands here, so
// the notice is what makes the cause greppable rather than inferred.
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'od-bakes-missing-'));
const emptyDir = tmpDir;
const records: PluginRecord[] = [
{ id: 'html-plugin', manifest: { name: 'html-plugin', od: {} } },
];
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(args.map(String).join(' '));
};
try {
const out = applyBakedPreviews(records, emptyDir);
// Same directory twice: the notice is per-path, not per-call, so a
// deployment without a manifest does not reprint it on every listing.
applyBakedPreviews(records, emptyDir);
expect(out[0]).toBe(records[0]);
} finally {
console.warn = originalWarn;
}
const matching = warnings.filter((line) => line.includes('plugin-preview-bakes'));
expect(matching).toHaveLength(1);
expect(matching[0]).toContain(path.join(emptyDir, "manifest.json"));
});
});
+6
View File
@@ -76,6 +76,11 @@ COPY design-systems ./design-systems
COPY craft ./craft
COPY prompt-templates ./prompt-templates
COPY assets ./assets
# `data/plugin-previews/manifest.json` is committed by the bake pipeline and is
# what `resolvePluginPreviewsDir()` looks for at runtime. Without it in the
# image, `loadManifest()` returns `{}` and every plugin silently falls back to
# the live-iframe preview path — the exact cost the bakes exist to avoid.
COPY data ./data
COPY plugins/_official ./plugins/_official
FROM ${RUNTIME_IMAGE}
@@ -91,6 +96,7 @@ COPY --from=build --chown=open-design:open-design /app/skills ./skills
COPY --from=build --chown=open-design:open-design /app/design-systems ./design-systems
COPY --from=build --chown=open-design:open-design /app/craft ./craft
COPY --from=build --chown=open-design:open-design /app/prompt-templates ./prompt-templates
COPY --from=build --chown=open-design:open-design /app/data ./data
COPY --from=build --chown=open-design:open-design /app/assets/frames ./assets/frames
COPY --from=build --chown=open-design:open-design /app/assets/community-pets ./assets/community-pets
# Plan §3.J4 / spec §23.3.5 — bundled atom plugins registered on
@@ -0,0 +1,52 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
// The daemon resolves several content directories relative to the project root
// at runtime. Anything it reads that way has to be COPYed into the image, in
// both the build stage and the runtime stage, or the deployment loses that
// content with no error — the daemon simply finds nothing and carries on.
//
// `data/plugin-previews/manifest.json` is the case that motivated this test: it
// is committed by the bake pipeline and is what `resolvePluginPreviewsDir()`
// looks for. Without it, `loadManifest()` returns `{}` and every plugin falls
// back to the live-iframe preview path, which is exactly the GPU cost the bakes
// exist to avoid.
const dockerfile = new URL("../../deploy/Dockerfile", import.meta.url);
function stageSections(content: string): { build: string; runtime: string } {
// The runtime stage is the last `FROM`; everything before it is the build.
const lastFrom = content.lastIndexOf("\nFROM ");
expect(lastFrom).toBeGreaterThan(0);
return { build: content.slice(0, lastFrom), runtime: content.slice(lastFrom) };
}
describe("deploy/Dockerfile content directories", () => {
it("copies every runtime-resolved content directory into both stages", async () => {
const content = await readFile(dockerfile, "utf8");
const { build, runtime } = stageSections(content);
// `assets` is copied wholesale in build but selectively in runtime, so it is
// matched loosely; the rest are whole-directory copies in both stages.
for (const dir of ["skills", "design-systems", "craft", "prompt-templates", "data"]) {
expect(build, `build stage should COPY ${dir}`).toMatch(
new RegExp(`^COPY ${dir} \\./${dir}$`, "m"),
);
expect(runtime, `runtime stage should COPY ${dir}`).toMatch(
new RegExp(`^COPY --from=build [^\\n]*/app/${dir} \\./${dir}$`, "m"),
);
}
});
it("ships the checked-in plugin preview manifest", async () => {
// Narrower than the directory check above and stated separately: this is the
// file whose absence is silent, so it is worth failing on its own terms
// rather than only as part of a directory list.
const content = await readFile(dockerfile, "utf8");
const { build, runtime } = stageSections(content);
expect(build).toMatch(/^COPY data \.\/data$/m);
expect(runtime).toMatch(/^COPY --from=build [^\n]*\/app\/data \.\/data$/m);
});
});