fix(tooling): clear the web Vite dep cache when skills/core output changes (#163)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-03 17:02:22 +08:00
committed by GitHub
parent 77a67e95d6
commit c4bf673309
2 changed files with 78 additions and 3 deletions
+13 -1
View File
@@ -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
+65 -2
View File
@@ -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;
}
}