Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/daemon/src/plugins/plugin-preview-bakes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions apps/daemon/tests/plugin-preview-bakes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 6 additions & 0 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down
52 changes: 52 additions & 0 deletions e2e/tests/docker-image-content-directories.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading