From c4bf673309566cdeb83ef6edb9e8b6751894318e Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Mon, 3 Aug 2026 17:02:22 +0800 Subject: [PATCH] fix(tooling): clear the web Vite dep cache when skills/core output changes (#163) Co-authored-by: Claude Fable 5 --- CONTRIBUTING.md | 14 ++++++++- scripts/dev-prebuild.mjs | 67 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ad09bc..cdcb19f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,9 +29,21 @@ serializes concurrent invocations) **keeps `pnpm install` current automatically* fresh clone or a pulled lockfile change installs before starting, and an up-to-date tree pays nothing (the lockfile hash is stamped) — then prebuilds the workspace deps (skills, core) with back-to-back builds deduped: starting `dev:server` and `dev:web` at the same -time (or just `pnpm dev`) installs and builds exactly once. `dev:docs` / `dev:landing` +time (or just `pnpm dev`) installs and builds exactly once. When that build changes +skills/core output, the prestep also clears the web app's Vite dep cache +(`packages/web/node_modules/.vite`), which is keyed by lockfile/config only and would +otherwise keep serving the browser the previous core. `dev:docs` / `dev:landing` run the install check only (`--install-only`). +One rule when bypassing the dev commands: **rebuild skills/core through pnpm, in that +order** (`pnpm build`, or restart `pnpm dev`) — the workspace uses injected dependencies +(`injectWorkspacePackages` in pnpm-workspace.yaml), so web/server consume snapshot copies +that only re-sync when the package's `build` script runs via pnpm +(`syncInjectedDepsAfterScripts`). A bare `npx tsup` in packages/core updates +`packages/core/dist` but leaves those snapshots — and any already-populated Vite dep +cache — on the old build; if a running dev web app still serves stale core after a manual +rebuild, delete `packages/web/node_modules/.vite` and restart. + Dev entry points that touch data (`pnpm dev`, `pnpm dev:server`, `pnpm penguin`) default to a separate data root, `~/.penguin/dev-data`, kept apart from the installed CLI/server's `~/.penguin/data` — hacking on the repo never mixes state with your real agents. Export diff --git a/scripts/dev-prebuild.mjs b/scripts/dev-prebuild.mjs index 29bb484..d439abb 100644 --- a/scripts/dev-prebuild.mjs +++ b/scripts/dev-prebuild.mjs @@ -31,7 +31,15 @@ */ import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -48,6 +56,8 @@ const INSTALL_STAMP = path.join(os.tmpdir(), `penguin-dev-${KEY}.install-stamp`) const BUILD_STAMP = path.join(os.tmpdir(), `penguin-dev-${KEY}.build-stamp`); const STALE_LOCK_MS = 10 * 60 * 1000; // no install+build takes 10 minutes; older locks are leftovers const SKIP_WINDOW_MS = 5_000; +const VITE_STAMP = path.join(os.tmpdir(), `penguin-dev-${KEY}.vite-stamp`); +const WEB_VITE_CACHE = path.join(ROOT, "packages", "web", "node_modules", ".vite"); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -97,6 +107,51 @@ function readText(file) { } } +/** + * Content fingerprint of the injected workspace deps' build output (skills + core dist): + * every file's bytes are hashed — chunk renames alone would not do, because tsup's entry + * outputs keep stable filenames and hold entry-resident code, so an equal-length change + * there would slip past a path+size check. mtimes are deliberately excluded (tsup runs + * with clean:true, so every rebuild rewrites them even when nothing changed). The two + * dist trees are a few MB, so this stays well under the cost of anything else here. + */ +function injectedDistFingerprint() { + const parts = []; + for (const rel of ["packages/skills/dist", "packages/core/dist"]) { + const base = path.join(ROOT, rel); + try { + for (const entry of readdirSync(base, { recursive: true, withFileTypes: true })) { + if (!entry.isFile()) continue; + const abs = path.join(entry.parentPath, entry.name); + const digest = createHash("sha256").update(readFileSync(abs)).digest("hex"); + parts.push(`${rel}/${path.relative(base, abs)}:${digest}`); + } + } catch { + parts.push(`${rel}:missing`); + } + } + return createHash("sha256").update(parts.sort().join("\n")).digest("hex"); +} + +/** + * Drop Vite's dependency-prebundle cache when the injected deps' output changed. + * + * The workspace runs with injectWorkspacePackages, so the web app consumes skills/core as + * snapshot copies inside node_modules/.pnpm (kept current by syncInjectedDepsAfterScripts + * when the build above runs). Vite then prebundles those copies into + * packages/web/node_modules/.vite/deps — and that cache is keyed by lockfile/config only, + * never by dependency content, so a rebuilt core would keep serving the browser the OLD + * code (e.g. a stale model catalog) until the cache is deleted. Purging only on a real + * output change keeps the usual dev start free of Vite's re-optimize cost. + */ +function refreshViteCache() { + const fingerprint = injectedDistFingerprint(); + if (readText(VITE_STAMP) === fingerprint) return; + rmSync(WEB_VITE_CACHE, { recursive: true, force: true }); + writeFileSync(VITE_STAMP, fingerprint); + console.log("[dev-prebuild] skills/core output changed; cleared the web Vite dep cache."); +} + /** * Ensure `pnpm install` is current: runs it when node_modules is missing or the * pnpm-lock.yaml hash differs from the last stamped install. Returns false on failure. @@ -149,6 +204,11 @@ try { } if (stampAge < SKIP_WINDOW_MS) { console.log("[dev-prebuild] deps were just built by a concurrent invocation; skipping."); + // The concurrent builder normally refreshed the cache too, but verify against the + // current output anyway — the check is two directory walks and a hash, and it keeps + // the invariant unconditional: after any successful prestep, the Vite dep cache + // matches the injected deps actually on disk. + refreshViteCache(); exitCode = 0; } else { console.log("[dev-prebuild] building skills + core..."); @@ -164,7 +224,10 @@ try { // shell on Windows: pnpm is a .cmd shim there (see ensureInstalled). { cwd: ROOT, stdio: "inherit", shell: process.platform === "win32" }, ); - if (res.status === 0) writeFileSync(BUILD_STAMP, String(Date.now())); + if (res.status === 0) { + writeFileSync(BUILD_STAMP, String(Date.now())); + refreshViteCache(); + } exitCode = res.status ?? 1; } }