mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user