diff --git a/.github/scripts/publish-storybook.cjs b/.github/scripts/publish-storybook.cjs index e0578a4bc7..10f00dd534 100644 --- a/.github/scripts/publish-storybook.cjs +++ b/.github/scripts/publish-storybook.cjs @@ -24,7 +24,10 @@ for (const name of ['index.html', 'iframe.html', 'index.json']) { throw new Error(`Missing Storybook output: ${name}`); } } -fs.writeFileSync(path.join(source, 'deployment.json'), JSON.stringify(destination, null, 2) + '\n'); +fs.writeFileSync(path.join(source, 'deployment.json'), JSON.stringify({ ...destination, + ...(fs.existsSync(path.join(source, 'agent-avatar-images/manifest.json')) + ? { avatarManifest: 'agent-avatar-images/manifest.json' } : {}), +}, null, 2) + '\n'); const aws = (args) => execFileSync('aws', args, { stdio: 'inherit' }); // Complete a unique build before changing the branch's entry point. No deletion // permissions, shared root writes or mixed-version branch assets are needed. diff --git a/.github/scripts/verify-storybook.cjs b/.github/scripts/verify-storybook.cjs index fd70299f6a..d9dc9431e1 100644 --- a/.github/scripts/verify-storybook.cjs +++ b/.github/scripts/verify-storybook.cjs @@ -1,4 +1,40 @@ const { branchIndex } = require('./storybook-destination.cjs'); +const { createHash } = require('node:crypto'); + +async function verifyAvatarImages({ buildUrl, fetch = globalThis.fetch }) { + const response = await fetch(new URL('agent-avatar-images/manifest.json', buildUrl), { signal: AbortSignal.timeout(15000) }); + if (!response.ok) throw new Error(`Avatar manifest returned HTTP ${response.status}.`); + const manifest = await response.json(); + if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.images) || !manifest.images.length || manifest.images.length > 10000) { + throw new Error('Invalid avatar image manifest.'); + } + const paths = new Set(); + for (const image of manifest.images) { + if (!/^agent-avatar-images\/[a-z0-9-]+\/[a-z0-9-]+\/[a-z0-9-]+\.png$/.test(image.path) + || !/^[a-f0-9]{64}$/.test(image.sha256) || !Number.isInteger(image.pixels) + || image.pixels < 16 || image.pixels > 1024 || paths.has(image.path)) { + throw new Error('Invalid avatar image entry.'); + } + paths.add(image.path); + } + let next = 0; + await Promise.all(Array.from({ length: 8 }, async () => { + while (next < manifest.images.length) { + const image = manifest.images[next++]; + const result = await fetch(new URL(image.path, buildUrl), { signal: AbortSignal.timeout(15000) }); + if (!result.ok || !result.headers.get('content-type')?.startsWith('image/png')) { + throw new Error(`Avatar ${image.path} returned HTTP ${result.status} or a non-PNG content type.`); + } + const png = Buffer.from(await result.arrayBuffer()); + if (png.length < 24 || png.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' + || png.readUInt32BE(16) !== image.pixels || png.readUInt32BE(20) !== image.pixels + || createHash('sha256').update(png).digest('hex') !== image.sha256) { + throw new Error(`Avatar ${image.path} has incorrect PNG bytes or dimensions.`); + } + } + })); + console.log(`Verified ${manifest.images.length} public avatar PNGs.`); +} async function verifyStorybook({ branchUrl, buildUrl, sha, fetch = globalThis.fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), attempts = 6 }) { @@ -9,8 +45,10 @@ async function verifyStorybook({ branchUrl, buildUrl, sha, fetch = globalThis.fe fetch(branchUrl, { signal: AbortSignal.timeout(15000) }), ]); if (!metadata.ok || !index.ok) throw new Error(`Public deployment returned HTTP ${metadata.status}/${index.status}.`); - if ((await metadata.json()).sha !== sha) throw new Error('Public build has the wrong source commit.'); + const build = await metadata.json(); + if (build.sha !== sha) throw new Error('Public build has the wrong source commit.'); if ((await index.text()) !== branchIndex(buildUrl)) throw new Error('Public branch URL does not point to this build.'); + if (build.avatarManifest) await verifyAvatarImages({ buildUrl, fetch }); return; } catch (error) { if (attempt === attempts) throw error; @@ -19,7 +57,7 @@ async function verifyStorybook({ branchUrl, buildUrl, sha, fetch = globalThis.fe } } -module.exports = { verifyStorybook }; +module.exports = { verifyStorybook, verifyAvatarImages }; if (require.main === module) { verifyStorybook({ branchUrl: process.env.BRANCH_URL, buildUrl: process.env.BUILD_URL, sha: process.env.SOURCE_SHA }).catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 3528a0e4ce..dc960f671b 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1370,7 +1370,8 @@ See [execution GitHub identity](execution-github-identity.md) for the operation- See [agent-personas.md](agent-personas.md) for the dynamic avatar endpoint, cache, and character stories. Set `PAPERCLIP_STORYBOOK_API_URL` to your isolated -Paperclip API URL when running Storybook. Avatar PNGs are generated on demand. +Paperclip API URL when running dev Storybook. Published Storybook builds automatically +package avatar PNGs using the API renderer; static hosting needs no API proxy. ### Investigating polling load diff --git a/doc/agent-personas.md b/doc/agent-personas.md index 12bd9debd9..6a4120e739 100644 --- a/doc/agent-personas.md +++ b/doc/agent-personas.md @@ -69,7 +69,16 @@ the TS palette data; `--check` detects drift. This does not generate images. Storybook: **Agents / Personas**. Run with `PAPERCLIP_STORYBOOK_API_URL=http://localhost: pnpm storybook`. -Static Storybook hosting must proxy `/api/agent-avatars` to an instance. +`pnpm build-storybook` automatically packages all finite avatar presets (17 +palettes plus muted gray, nine poses, eleven logical sizes, both densities). +The build uses the same bounded Node worker pool, SVG renderer, and Sharp pipeline +as the API. Storybook-only URL resolution points to relative PNG paths under the +published build, including branch-prefixed deployments. Production Paperclip +continues to use its on-demand API; no image generation runs during agent creation. +The generated files are build output, never committed. A manifest records image +hashes and pixel dimensions; deployment verification fetches every image and checks +its content type, PNG signature, dimensions, and hash. Dev Storybook still uses the +API proxy for cold-cache and regeneration testing. Focused checks: @@ -85,7 +94,7 @@ Use fixed poses/times for screenshots and Linux for authoritative visual baselines. Verify cold and warm URLs, reduced motion, reconnect, and saved appearance after refresh alongside normal typecheck/test/build checks. -Linux visual/performance checks (against the built Storybook with the API proxy): +Linux visual/performance checks (against the self-contained built Storybook): ```sh pnpm exec playwright test --config tests/storybook-visual/agent-personas.config.ts diff --git a/scripts/__tests__/storybook-deploy.test.mjs b/scripts/__tests__/storybook-deploy.test.mjs index edbe830f13..59bfedf0d5 100644 --- a/scripts/__tests__/storybook-deploy.test.mjs +++ b/scripts/__tests__/storybook-deploy.test.mjs @@ -215,3 +215,43 @@ test('public verification rejects a permanently stale branch URL or wrong source wrong === 'branch' ? /does not point to this build/ : /wrong source commit/); } }); + +import { createHash } from 'node:crypto'; +import { verifyAvatarImages } from '../../.github/scripts/verify-storybook.cjs'; +function avatarVerificationFixture(change = {}) { + const png = Buffer.alloc(24); + Buffer.from('89504e470d0a1a0a', 'hex').copy(png); + png.writeUInt32BE(48, 16); png.writeUInt32BE(48, 20); + const entry = { path: 'agent-avatar-images/cap-v1/bubblegum-sky/rest-24-2.png', pixels: 48, + sha256: createHash('sha256').update(png).digest('hex'), ...change.entry }; + const requested = []; + return { requested, fetch: async (url) => { + requested.push(String(url)); + if (String(url).endsWith('manifest.json')) return Response.json({ schemaVersion: 1, images: [entry] }); + return new Response(change.body ?? png, { status: change.status ?? 200, + headers: { 'content-type': change.type ?? 'image/png' } }); + } }; +} +test('public avatar verification checks relative PNG paths, bytes and density dimensions', async () => { + const fixture = avatarVerificationFixture(); + const d = storybookDestination(input); + await verifyAvatarImages({ buildUrl: d.buildUrl, fetch: fixture.fetch }); + assert.ok(fixture.requested.every(url => url.startsWith(d.buildUrl.replace('index.html', '')))); + assert.equal(fixture.requested.length, 2); +}); +test('public avatar verification rejects missing, HTML, corrupt or wrong-density images', async () => { + for (const change of [{ status: 403 }, { type: 'text/html' }, { body: 'broken PNG' }, + { entry: { pixels: 24 } }, { entry: { sha256: '0'.repeat(64) } }, + { entry: { path: '../../api/agent-avatars/portrait.png' } }]) { + await assert.rejects(verifyAvatarImages({ buildUrl: storybookDestination(input).buildUrl, + fetch: avatarVerificationFixture(change).fetch }), /Avatar|avatar/); + } +}); +test('deployment metadata opts into avatar verification and fails on missing images', async () => { + const d = storybookDestination(input); + await assert.rejects(verifyStorybook({ branchUrl: d.url, buildUrl: d.buildUrl, sha: d.sha, attempts: 1, + fetch: async url => String(url).endsWith('deployment.json') + ? Response.json({ sha: d.sha, avatarManifest: 'agent-avatar-images/manifest.json' }) + : String(url) === d.url ? new Response(branchIndex(d.buildUrl)) : new Response('', { status: 403 }), + }), /Avatar manifest returned HTTP 403/); +}); diff --git a/scripts/storybook-agent-avatar-assets.d.mts b/scripts/storybook-agent-avatar-assets.d.mts new file mode 100644 index 0000000000..0db850645a --- /dev/null +++ b/scripts/storybook-agent-avatar-assets.d.mts @@ -0,0 +1,5 @@ +export function storybookAgentAvatarAssets(): { + name: string; + apply: "build"; + generateBundle(this: { emitFile(asset: { type: "asset"; fileName: string; source: string | Uint8Array }): unknown }): Promise; +}; diff --git a/scripts/storybook-agent-avatar-assets.mjs b/scripts/storybook-agent-avatar-assets.mjs new file mode 100644 index 0000000000..736bcb2864 --- /dev/null +++ b/scripts/storybook-agent-avatar-assets.mjs @@ -0,0 +1,43 @@ +import { createRequire } from "node:module"; +import { createHash } from "node:crypto"; + +/** Build-only: reuse the API worker and finite preset contract, never a browser renderer. */ +export function storybookAgentAvatarAssets() { + return { + name: "storybook-agent-avatar-assets", + apply: "build", + async generateBundle() { + const serverRequire = createRequire(new URL("../server/package.json", import.meta.url)); + const { tsImport } = await import(serverRequire.resolve("tsx/esm/api")); + const { createAgentAvatarPool } = await tsImport("../server/src/services/agent-avatar-pool.ts", import.meta.url); + const { AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette } = + await tsImport("../packages/shared/src/agent-appearance.ts", import.meta.url); + const { agentAvatarUrl } = await tsImport("../ui/storybook/fixtures/agent-avatar-url.ts", import.meta.url); + const requests = [false, true].flatMap(muted => + (muted ? [AGENT_PALETTE_IDS[0]] : AGENT_PALETTE_IDS).flatMap(palette => + CHARACTER_STATES.flatMap(pose => AGENT_AVATAR_SIZES.flatMap(size => + [1, 2].map(scale => ({ appearance: appearanceForPalette(palette), size, scale, pose, muted })))))); + const pool = createAgentAvatarPool(2); + const images = []; + let next = 0; + try { + await Promise.all(Array.from({ length: 2 }, async () => { + while (next < requests.length) { + const request = requests[next++]; + const source = await pool.render(request); + const { appearance, size, scale, pose, muted } = request; + const fileName = agentAvatarUrl(appearance, size, scale, pose, muted).slice(2); + this.emitFile({ type: "asset", fileName, source }); + images.push({ path: fileName, sha256: createHash("sha256").update(source).digest("hex"), pixels: size * scale }); + } + })); + } finally { + await pool.close(); + } + images.sort((a, b) => a.path.localeCompare(b.path)); + this.emitFile({ type: "asset", fileName: "agent-avatar-images/manifest.json", + source: JSON.stringify({ schemaVersion: 1, images }) }); + console.log(`Packaged ${images.length} agent avatar PNGs using the API renderer.`); + }, + }; +} diff --git a/tests/storybook-visual/agent-personas.spec.ts b/tests/storybook-visual/agent-personas.spec.ts index 29a2ea0aec..555a8e653e 100644 --- a/tests/storybook-visual/agent-personas.spec.ts +++ b/tests/storybook-visual/agent-personas.spec.ts @@ -45,7 +45,7 @@ test("500 avatars load images without WebGL, live modules or avatar frame loops" test("image failures and slow cold responses preserve dimensions", async ({ page }) => { let release!: () => void; const held = new Promise(resolve => { release = resolve; }); - await page.route("**/api/agent-avatars/**", async route => { await held; await route.abort(); }); + await page.route("**/agent-avatar-images/**", async route => { await held; await route.abort(); }); await story(page, "cache-miss-loading"); const image = page.locator("#storybook-root img"); const before = await image.boundingBox(); @@ -117,7 +117,7 @@ for (const id of fullPages) { await page.goto(`/iframe.html?id=agents-personas-full-pages--${id}&viewMode=story&globals=theme:dark`); await expect(page.locator("main")).toBeVisible(); await expect(page.locator("main")).not.toContainText("This page hit an error"); - await expect(page.locator('img[src*="/api/agent-avatars/"]').first()).toBeAttached(); + await expect(page.locator('img[src*="/agent-avatar-images/"]').first()).toBeAttached(); if (id === "company-dashboard") await expect(page.getByText("Live now", { exact: true })).toBeVisible(); await imagesLoaded(page); await expect(page.locator("canvas")).toHaveCount(0); diff --git a/ui/src/components/AgentAvatar.tsx b/ui/src/components/AgentAvatar.tsx index dfe802797b..8ab2d16624 100644 --- a/ui/src/components/AgentAvatar.tsx +++ b/ui/src/components/AgentAvatar.tsx @@ -1,5 +1,6 @@ +import { agentAvatarUrl } from "@/lib/agent-avatar-url"; import { useState } from "react"; -import { agentAvatarUrl, resolveAgentAppearance, type AgentAppearance, type AgentAvatarSize, type CharacterState } from "@paperclipai/shared"; +import { resolveAgentAppearance, type AgentAppearance, type AgentAvatarSize, type CharacterState } from "@paperclipai/shared"; import { cn } from "@/lib/utils"; import { deriveInitials } from "./Identity"; diff --git a/ui/src/components/timeline/WorkTimelineChart.tsx b/ui/src/components/timeline/WorkTimelineChart.tsx index 255582603b..b982a51da9 100644 --- a/ui/src/components/timeline/WorkTimelineChart.tsx +++ b/ui/src/components/timeline/WorkTimelineChart.tsx @@ -1,4 +1,5 @@ -import { agentAvatarUrl, resolveAgentAppearance } from "@paperclipai/shared"; +import { agentAvatarUrl } from "@/lib/agent-avatar-url"; +import { resolveAgentAppearance } from "@paperclipai/shared"; /** * Work Timeline — custom-SVG Gantt (board-locked Direction C, PAP-12422). * diff --git a/ui/src/lib/agent-avatar-url.ts b/ui/src/lib/agent-avatar-url.ts new file mode 100644 index 0000000000..1df5b27e73 --- /dev/null +++ b/ui/src/lib/agent-avatar-url.ts @@ -0,0 +1,2 @@ +// Storybook replaces this module at build time with its packaged-image resolver. +export { agentAvatarUrl } from "@paperclipai/shared"; diff --git a/ui/storybook/.storybook/main.ts b/ui/storybook/.storybook/main.ts index 48d3f3187c..0357165753 100644 --- a/ui/storybook/.storybook/main.ts +++ b/ui/storybook/.storybook/main.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url"; import type { StorybookConfig } from "@storybook/react-vite"; import tailwindcss from "@tailwindcss/vite"; import { mergeConfig } from "vite"; +import { storybookAgentAvatarAssets } from "../../../scripts/storybook-agent-avatar-assets.mjs"; const storybookConfigDir = path.dirname(fileURLToPath(import.meta.url)); @@ -17,9 +18,9 @@ const config: StorybookConfig = { docs: { autodocs: true, }, - viteFinal: async (baseConfig) => + viteFinal: async (baseConfig, { configType }) => mergeConfig(baseConfig, { - plugins: [tailwindcss()], + plugins: [tailwindcss(), storybookAgentAvatarAssets()], server: { proxy: { "/api/agent-avatars": { target: process.env.PAPERCLIP_STORYBOOK_API_URL ?? "http://localhost:3100", changeOrigin: true } } }, optimizeDeps: { include: ["motion/react", "react", "react-dom"] }, resolve: { @@ -30,6 +31,9 @@ const config: StorybookConfig = { // The app's own dev server hoists one React and never hit this. dedupe: ["react", "react-dom"], alias: { + ...(configType === "PRODUCTION" ? { + "@/lib/agent-avatar-url": path.resolve(storybookConfigDir, "../fixtures/agent-avatar-url.ts"), + } : {}), "@": path.resolve(storybookConfigDir, "../../src"), lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/dist/Lexical.mjs"), // Vite's bundled `node:crypto` polyfill omits `createHash`, which diff --git a/ui/storybook/fixtures/agent-avatar-url.test.ts b/ui/storybook/fixtures/agent-avatar-url.test.ts new file mode 100644 index 0000000000..f1aaebf8d0 --- /dev/null +++ b/ui/storybook/fixtures/agent-avatar-url.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { AGENT_AVATAR_SIZES, AGENT_PALETTE_IDS, CHARACTER_STATES, appearanceForPalette } from "@paperclipai/shared"; +import { agentAvatarUrl as productionUrl } from "../../src/lib/agent-avatar-url"; +import { agentAvatarUrl } from "./agent-avatar-url"; + +describe("packaged Storybook avatar URLs", () => { + it("keeps production on the public API and resolves preview assets under a branch prefix", () => { + const appearance = appearanceForPalette("bubblegum-sky"); + expect(productionUrl(appearance, 24, 2)).toBe("/api/agent-avatars/cap-v1/bubblegum-sky/rest.png?size=24&scale=2"); + expect(new URL(agentAvatarUrl(appearance, 24, 2), "https://example.com/branches/personas/builds/123/iframe.html").href) + .toBe("https://example.com/branches/personas/builds/123/agent-avatar-images/cap-v1/bubblegum-sky/rest-24-2.png"); + expect(agentAvatarUrl(appearance)).toBe("./agent-avatar-images/cap-v1/bubblegum-sky/rest-512-1.png"); + }); + + it("gives every finite preset a distinct filename, retaining logical size separately from density", () => { + const paths = new Set(); + for (const palette of AGENT_PALETTE_IDS) for (const pose of CHARACTER_STATES) + for (const size of AGENT_AVATAR_SIZES) for (const scale of [1, 2] as const) { + paths.add(agentAvatarUrl(appearanceForPalette(palette), size, scale, pose)); + paths.add(agentAvatarUrl(appearanceForPalette(palette), size, scale, pose, true)); + } + expect(paths.size).toBe((AGENT_PALETTE_IDS.length + 1) * CHARACTER_STATES.length * AGENT_AVATAR_SIZES.length * 2); + expect(agentAvatarUrl(appearanceForPalette("arctic-blue"), 24, 2)) + .not.toBe(agentAvatarUrl(appearanceForPalette("arctic-blue"), 48, 1)); + expect([...paths].every(path => !path.includes("?") && path.startsWith("./agent-avatar-images/cap-v1/"))).toBe(true); + }); +}); diff --git a/ui/storybook/fixtures/agent-avatar-url.ts b/ui/storybook/fixtures/agent-avatar-url.ts new file mode 100644 index 0000000000..efbe5c3ce5 --- /dev/null +++ b/ui/storybook/fixtures/agent-avatar-url.ts @@ -0,0 +1,8 @@ +import { agentAvatarUrl as apiAvatarUrl } from "@paperclipai/shared"; + +/** Relative to iframe.html, including when a preview is hosted under a branch prefix. */ +export const agentAvatarUrl: typeof apiAvatarUrl = (...args) => { + const url = new URL(apiAvatarUrl(...args), "https://storybook.invalid"); + const preset = url.pathname.slice("/api/agent-avatars/".length).replace(/\.png$/, ""); + return `./agent-avatar-images/${preset}-${url.searchParams.get("size")}-${url.searchParams.get("scale")}.png`; +}; diff --git a/ui/storybook/stories/agent-persona-pages.stories.tsx b/ui/storybook/stories/agent-persona-pages.stories.tsx index ee36bb6242..0ae8a72759 100644 --- a/ui/storybook/stories/agent-persona-pages.stories.tsx +++ b/ui/storybook/stories/agent-persona-pages.stories.tsx @@ -1,3 +1,4 @@ +import { agentAvatarUrl } from "@/lib/agent-avatar-url"; import { useEffect, useState, useRef } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { useQueryClient } from "@tanstack/react-query"; @@ -12,7 +13,7 @@ import { Dashboard } from "@/pages/Dashboard"; import { NewAgent } from "@/pages/NewAgent"; import { AgentBasicsDialog } from "@/components/new-agent/AgentBasicsDialog"; import { queryKeys } from "@/lib/queryKeys"; -import { resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared"; +import { resolveAgentAppearance } from "@paperclipai/shared"; import { storybookAgents, storybookIssues, storybookActivityEvents, storybookLiveRuns, storybookDashboardSummary } from "../fixtures/paperclipData"; const companyId = "company-storybook"; diff --git a/ui/storybook/stories/agent-personas.stories.tsx b/ui/storybook/stories/agent-personas.stories.tsx index 1f5e2b27d9..007972cf98 100644 --- a/ui/storybook/stories/agent-personas.stories.tsx +++ b/ui/storybook/stories/agent-personas.stories.tsx @@ -1,4 +1,4 @@ -import { agentAvatarUrl } from "@paperclipai/shared"; +import { agentAvatarUrl } from "@/lib/agent-avatar-url"; import { expect, waitFor } from "storybook/test"; import { useEffect, useRef, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -14,7 +14,7 @@ const meta = { title: "Agents/Personas", component: AgentCharacter, args: { appearance, size: 256, state: "listening", label: "Chief of Staff" }, - parameters: { docs: { description: { component: "Persistent cap-v1 identities. Avatars request on-demand PNGs from Paperclip; the hero alone loads ClipLab. Run Paperclip locally, or set PAPERCLIP_STORYBOOK_API_URL to an isolated API when starting Storybook. Static Storybook hosting must route /api/agent-avatars to Paperclip. No images are baked into Storybook." } } }, + parameters: { docs: { description: { component: "Persistent cap-v1 identities. Avatars request on-demand PNGs from Paperclip; the hero alone loads ClipLab. Development Storybook uses PAPERCLIP_STORYBOOK_API_URL. Published Storybook packages PNGs from the same API renderer automatically during its build, including every preset and both densities; no running API is required." } } }, argTypes: { state: { control: "select", options: CHARACTER_STATES }, size: { control: "select", options: AGENT_AVATAR_SIZES },