fix(FN-7779): rebuild changed plugins on pnpm dev + warn on stale plugin dist
The Grok stale-dist bug was possible because the dev/build path never refreshed plugin dist: - The `client` prebuild (default `pnpm dev dashboard`) rebuilt only @fusion/core + @fusion/engine + @fusion/dashboard, never plugins. - The FN-6638 stale-dist startup warning only scanned packages/, never plugins/, so a source-ahead plugin dist ran phantom-old with no warning. Changes: - build-workspace.mjs: add `--plugins-only` to plan/build just the plugins that changed, reusing the existing content-hash skip cache (cheap no-op when unchanged). - scripts/dev-prebuild-client.mjs: new orchestrator — fast core/engine/ dashboard build, then incremental changed-plugin rebuild. The `client` prebuild now runs this single cross-platform command. - dist-freshness.mjs: scan plugin roots (plugins/, plugins/examples/) so a stale plugin dist is warned like a stale package dist; the warning names the plugin dir. Verified: --plugins-only plans only plugins, skips unchanged on the second run, and re-plans exactly the one plugin whose source changed. All script and CLI lib tests pass. Fusion-Task-Id: FN-7779 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,22 +69,17 @@ describe("dev-with-memory prebuild options", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("rebuilds core + engine + dashboard (UI) for dashboard startup, not the full workspace", () => {
|
||||
it("rebuilds core + engine + dashboard (UI) + changed plugins for dashboard startup, not the full workspace", () => {
|
||||
// FN-6638/stale-dist: dev dashboard must refresh engine + core dist (not
|
||||
// just the client bundle) so landed fixes are not silently stale.
|
||||
// FN-7779/stale-plugin-dist: it must ALSO incrementally rebuild changed
|
||||
// plugins (plugin dist loads at runtime), so the client prebuild is now a
|
||||
// single orchestrator command covering both.
|
||||
expect(resolvePrebuildMode("auto", ["dashboard", "--port", "4050"])).toBe("client");
|
||||
expect(getPrebuildCommand("client")).toEqual({
|
||||
command: "pnpm",
|
||||
args: [
|
||||
"--filter",
|
||||
"@fusion/core",
|
||||
"--filter",
|
||||
"@fusion/engine",
|
||||
"--filter",
|
||||
"@fusion/dashboard",
|
||||
"build",
|
||||
],
|
||||
label: "core + engine + dashboard build",
|
||||
command: "node",
|
||||
args: ["scripts/dev-prebuild-client.mjs"],
|
||||
label: "core + engine + dashboard + changed plugins build",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -88,3 +88,42 @@ test("skips packages with no src (packaged install)", () => {
|
||||
assert.equal(result.stale, false);
|
||||
assert.equal(result.packages.length, 0);
|
||||
});
|
||||
|
||||
// FN-7779: plugin dist loads at runtime, so a src-ahead-of-dist plugin must be
|
||||
// flagged too — and the warning names the plugin dir, not @fusion/<name>.
|
||||
test("flags a stale plugin under a plugin root and labels it by dir name", () => {
|
||||
const pluginsRoot = `${ROOT}/plugins`;
|
||||
const srcDir = `${pluginsRoot}/fusion-plugin-grok-runtime/src`;
|
||||
const distDir = `${pluginsRoot}/fusion-plugin-grok-runtime/dist`;
|
||||
const fs = makeFs({
|
||||
dirs: [pluginsRoot, srcDir, distDir],
|
||||
files: {
|
||||
[pluginsRoot]: [{ name: "fusion-plugin-grok-runtime", isDir: true }],
|
||||
[srcDir]: [{ name: "runtime-adapter.ts", mtimeMs: 10_000 }],
|
||||
[distDir]: [{ name: "runtime-adapter.js", mtimeMs: 1_000 }],
|
||||
},
|
||||
});
|
||||
const result = computeDistStaleness({ rootDir: ROOT, packages: [], pluginRoots: ["plugins"], fs });
|
||||
assert.equal(result.stale, true);
|
||||
const warning = formatDistStalenessWarning(result);
|
||||
assert.match(warning, /STALE BUILD/);
|
||||
assert.match(warning, /fusion-plugin-grok-runtime/);
|
||||
assert.doesNotMatch(warning, /@fusion\/fusion-plugin-grok-runtime/);
|
||||
});
|
||||
|
||||
test("does not flag a fresh plugin (dist newer than src)", () => {
|
||||
const pluginsRoot = `${ROOT}/plugins`;
|
||||
const srcDir = `${pluginsRoot}/fusion-plugin-grok-runtime/src`;
|
||||
const distDir = `${pluginsRoot}/fusion-plugin-grok-runtime/dist`;
|
||||
const fs = makeFs({
|
||||
dirs: [pluginsRoot, srcDir, distDir],
|
||||
files: {
|
||||
[pluginsRoot]: [{ name: "fusion-plugin-grok-runtime", isDir: true }],
|
||||
[srcDir]: [{ name: "runtime-adapter.ts", mtimeMs: 1_000 }],
|
||||
[distDir]: [{ name: "runtime-adapter.js", mtimeMs: 10_000 }],
|
||||
},
|
||||
});
|
||||
const result = computeDistStaleness({ rootDir: ROOT, packages: [], pluginRoots: ["plugins"], fs });
|
||||
assert.equal(result.stale, false);
|
||||
assert.equal(formatDistStalenessWarning(result), null);
|
||||
});
|
||||
|
||||
@@ -407,25 +407,36 @@ function formatPlanLine(pkg) {
|
||||
return `${pkg.name} (${pkg.buildReason})`;
|
||||
}
|
||||
|
||||
export function main({ rootDir = repoRoot, spawnFn = spawnSync, gitFn = defaultGitRunner } = {}) {
|
||||
/*
|
||||
* FNXC:WorkspaceBuild 2026-07-10-15:40:
|
||||
* FN-7779 stale-plugin-dist: `--plugins-only` narrows the plan to plugin
|
||||
* packages so the fast `pnpm dev dashboard` prebuild can incrementally rebuild
|
||||
* ONLY changed plugins (reusing the content-hash skip cache) without also
|
||||
* rebuilding every non-plugin workspace package. Plugins load their built
|
||||
* dist/ at runtime, so a never-rebuilt plugin dist silently runs phantom-old
|
||||
* code — exactly the Grok "messages aren't sending" wrong-CLI-flags failure.
|
||||
*/
|
||||
export function main({ rootDir = repoRoot, spawnFn = spawnSync, gitFn = defaultGitRunner, pluginsOnly = false } = {}) {
|
||||
const cache = readPluginBuildCache(rootDir);
|
||||
const snapshot = createRepoContentSnapshot({ rootDir, gitFn });
|
||||
const plan = planWorkspaceBuild({ rootDir, cache, gitFn, snapshot });
|
||||
const plannedNames = plan.plannedPackages.map(formatPlanLine);
|
||||
const plannedPackages = pluginsOnly ? plan.plannedPackages.filter((pkg) => pkg.isPlugin) : plan.plannedPackages;
|
||||
const plannedNames = plannedPackages.map(formatPlanLine);
|
||||
const skippedNames = plan.skippedPlugins.map((pkg) => pkg.name);
|
||||
|
||||
console.log(`[build-workspace] planned builds: ${plannedNames.join(", ") || "(none)"}`);
|
||||
const scope = pluginsOnly ? "changed plugins" : "planned builds";
|
||||
console.log(`[build-workspace] ${scope}: ${plannedNames.join(", ") || "(none)"}`);
|
||||
if (skippedNames.length > 0) {
|
||||
console.log(`[build-workspace] skipped unchanged plugins: ${skippedNames.join(", ")}`);
|
||||
}
|
||||
|
||||
const result = runPlannedBuilds(plan.plannedPackages, rootDir, spawnFn);
|
||||
const result = runPlannedBuilds(plannedPackages, rootDir, spawnFn);
|
||||
if (result.status !== 0) {
|
||||
process.stderr.write(`[build-workspace] FAILED packages: ${result.packageNames.join(", ") || "(none)"}\n`);
|
||||
return result.status;
|
||||
}
|
||||
|
||||
recordSuccessfulPluginBuilds(plan.plannedPackages, { rootDir, cache, gitFn });
|
||||
recordSuccessfulPluginBuilds(plannedPackages, { rootDir, cache, gitFn });
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -439,5 +450,6 @@ export function main({ rootDir = repoRoot, spawnFn = spawnSync, gitFn = defaultG
|
||||
* file URL of argv[1] so the guard is correct on Windows, macOS, and Linux.
|
||||
*/
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(main());
|
||||
const pluginsOnly = process.argv.slice(2).includes("--plugins-only");
|
||||
process.exit(main({ pluginsOnly }));
|
||||
}
|
||||
|
||||
43
scripts/dev-prebuild-client.mjs
Normal file
43
scripts/dev-prebuild-client.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
FNXC:DevWorkflow 2026-07-10-15:40:
|
||||
FN-7779 stale-plugin-dist: the `client` prebuild for `pnpm dev dashboard`.
|
||||
|
||||
Runs in two ordered steps so a dev restart never runs phantom-old code:
|
||||
1. The FN-6638 fast path — rebuild @fusion/core + @fusion/engine +
|
||||
@fusion/dashboard dist (NOT the full workspace) so landed engine/core/UI
|
||||
fixes take effect.
|
||||
2. Incrementally rebuild ONLY changed plugins via build-workspace's
|
||||
content-hash skip cache (`--plugins-only`). Plugins load their built
|
||||
dist/ at runtime, so a source-only plugin fix (e.g. the Grok CLI-flag fix
|
||||
that caused "messages aren't sending") was silently stale until this step
|
||||
existed — the old client prebuild rebuilt the three app packages but never
|
||||
the plugins.
|
||||
|
||||
Unchanged plugins are a cheap content-hash no-op, so step 2 stays fast. Node
|
||||
(not a shell `&&`) sequences the steps for cross-platform correctness.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
// Windows resolves `pnpm` to a `.cmd` shim Node can't spawn without a shell
|
||||
// (ENOENT since CVE-2024-27980); these args carry no shell metacharacters.
|
||||
const useShell = process.platform === "win32";
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, { cwd: repoRoot, stdio: "inherit", ...options });
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
// Step 1: fast app-package build (core -> engine -> dashboard, dependency order).
|
||||
const appStatus = run(
|
||||
"pnpm",
|
||||
["--filter", "@fusion/core", "--filter", "@fusion/engine", "--filter", "@fusion/dashboard", "build"],
|
||||
{ shell: useShell },
|
||||
);
|
||||
if (appStatus !== 0) process.exit(appStatus);
|
||||
|
||||
// Step 2: incremental changed-plugin rebuild.
|
||||
process.exit(run("node", ["scripts/build-workspace.mjs", "--plugins-only"]));
|
||||
@@ -101,22 +101,22 @@ export function getPrebuildCommand(mode) {
|
||||
src), the running process and any dist-resolving consumer (plugins,
|
||||
sub-imports, a later non-dev `fn`/`pnpm local`) load built dist. Leaving
|
||||
engine/core dist stale is exactly how landed fixes (FN-6644/6647/6648,
|
||||
etc.) silently failed to run for ~2 days. pnpm builds these in dependency
|
||||
order (core → engine → dashboard); dashboard `build` runs the vite client
|
||||
bundle + server tsc, so the UI is rebuilt too.
|
||||
etc.) silently failed to run for ~2 days.
|
||||
|
||||
FNXC:DevWorkflow 2026-07-10-15:40:
|
||||
FN-7779/stale-plugin-dist: the app-package build alone left plugin dist/
|
||||
stale — a source-only plugin fix (the Grok CLI-flag fix behind "messages
|
||||
aren't sending") never took effect until a manual rebuild. The client
|
||||
prebuild is now an orchestrator (scripts/dev-prebuild-client.mjs) that
|
||||
first runs the fast core → engine → dashboard build (dependency order;
|
||||
dashboard `build` also runs the vite client bundle + server tsc) and then
|
||||
incrementally rebuilds ONLY changed plugins via the content-hash skip
|
||||
cache. A single node command keeps the spawn contract cross-platform.
|
||||
*/
|
||||
return {
|
||||
command: "pnpm",
|
||||
args: [
|
||||
"--filter",
|
||||
"@fusion/core",
|
||||
"--filter",
|
||||
"@fusion/engine",
|
||||
"--filter",
|
||||
"@fusion/dashboard",
|
||||
"build",
|
||||
],
|
||||
label: "core + engine + dashboard build",
|
||||
command: "node",
|
||||
args: ["scripts/dev-prebuild-client.mjs"],
|
||||
label: "core + engine + dashboard + changed plugins build",
|
||||
};
|
||||
case "none":
|
||||
case "auto":
|
||||
|
||||
@@ -24,6 +24,15 @@ import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const DEFAULT_PACKAGES = ["core", "engine", "dashboard"];
|
||||
/*
|
||||
FNXC:DevWorkflow 2026-07-10-15:40:
|
||||
FN-7779 stale-plugin-dist: plugins load their built dist/ at runtime, so a
|
||||
never-rebuilt plugin dist runs phantom-old code the same way a stale package
|
||||
dist does (the Grok wrong-CLI-flags "messages aren't sending" failure). The
|
||||
freshness guard now also scans plugin package dirs one level under these roots
|
||||
so the operator is warned even if the prebuild is skipped (`--prebuild none`).
|
||||
*/
|
||||
const DEFAULT_PLUGIN_ROOTS = ["plugins", "plugins/examples"];
|
||||
// Slack absorbs build/checkout mtime jitter so we only flag a real source-ahead.
|
||||
const DEFAULT_SLACK_MS = 2_000;
|
||||
const SRC_EXTENSIONS = [".ts", ".tsx"];
|
||||
@@ -70,23 +79,64 @@ function newestMtimeMs(dir, extensions, fs) {
|
||||
* @param {object} [options.fs] fs seam ({ existsSync, readdirSync, statSync })
|
||||
* @returns {{ stale: boolean, packages: Array<{ name: string, srcNewestMs: number, distNewestMs: number, stale: boolean }> }}
|
||||
*/
|
||||
/**
|
||||
* Discover plugin package dirs one level under each plugin root. A plugin is a
|
||||
* candidate only when it has both a `src/` and a `dist/` (same src+dist gate as
|
||||
* packages). Never throws — an unreadable root yields no candidates.
|
||||
*
|
||||
* @returns {Array<{ label: string, srcDir: string, distDir: string }>}
|
||||
*/
|
||||
function discoverPluginCandidates(rootDir, pluginRoots, fs) {
|
||||
const candidates = [];
|
||||
for (const root of pluginRoots) {
|
||||
const rootDirPath = join(rootDir, root);
|
||||
if (!fs.existsSync(rootDirPath)) continue;
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(rootDirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const srcDir = join(rootDirPath, entry.name, "src");
|
||||
const distDir = join(rootDirPath, entry.name, "dist");
|
||||
if (!fs.existsSync(srcDir) || !fs.existsSync(distDir)) continue;
|
||||
candidates.push({ label: entry.name, srcDir, distDir });
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function computeDistStaleness(options = {}) {
|
||||
const rootDir = options.rootDir ?? process.cwd();
|
||||
const packages = options.packages ?? DEFAULT_PACKAGES;
|
||||
const pluginRoots = options.pluginRoots ?? DEFAULT_PLUGIN_ROOTS;
|
||||
const slackMs = options.slackMs ?? DEFAULT_SLACK_MS;
|
||||
const fs = options.fs ?? { existsSync, readdirSync, statSync };
|
||||
|
||||
// Packages under packages/<name>, plus plugin packages under the plugin
|
||||
// roots. `label` drives the warning text; packages keep the `@fusion/<name>`
|
||||
// form via a null label, plugins surface their dir name.
|
||||
const candidates = [
|
||||
...packages.map((name) => ({
|
||||
label: null,
|
||||
name,
|
||||
srcDir: join(rootDir, "packages", name, "src"),
|
||||
distDir: join(rootDir, "packages", name, "dist"),
|
||||
})),
|
||||
...discoverPluginCandidates(rootDir, pluginRoots, fs).map((c) => ({ label: c.label, name: c.label, srcDir: c.srcDir, distDir: c.distDir })),
|
||||
];
|
||||
|
||||
const results = [];
|
||||
for (const name of packages) {
|
||||
const srcDir = join(rootDir, "packages", name, "src");
|
||||
const distDir = join(rootDir, "packages", name, "dist");
|
||||
for (const { label, name, srcDir, distDir } of candidates) {
|
||||
// Both must exist: no src = packaged install; no dist = pure source run.
|
||||
if (!fs.existsSync(srcDir) || !fs.existsSync(distDir)) continue;
|
||||
const srcNewestMs = newestMtimeMs(srcDir, SRC_EXTENSIONS, fs);
|
||||
const distNewestMs = newestMtimeMs(distDir, DIST_EXTENSIONS, fs);
|
||||
if (srcNewestMs === 0 || distNewestMs === 0) continue;
|
||||
const stale = srcNewestMs - distNewestMs > slackMs;
|
||||
results.push({ name, srcNewestMs, distNewestMs, stale });
|
||||
results.push({ name, label, srcNewestMs, distNewestMs, stale });
|
||||
}
|
||||
return { stale: results.some((r) => r.stale), packages: results };
|
||||
}
|
||||
@@ -97,10 +147,11 @@ export function computeDistStaleness(options = {}) {
|
||||
*/
|
||||
export function formatDistStalenessWarning(result) {
|
||||
if (!result || !result.stale) return null;
|
||||
const staleNames = result.packages.filter((p) => p.stale).map((p) => p.name);
|
||||
// Plugins carry a `label` (their dir name); packages use the `@fusion/<name>` form.
|
||||
const staleNames = result.packages.filter((p) => p.stale).map((p) => p.label ?? `@fusion/${p.name}`);
|
||||
return [
|
||||
"",
|
||||
`[fusion] ⚠ STALE BUILD: ${staleNames.map((n) => `@fusion/${n}`).join(", ")} dist/ is OLDER than src/.`,
|
||||
`[fusion] ⚠ STALE BUILD: ${staleNames.join(", ")} dist/ is OLDER than src/.`,
|
||||
"[fusion] The running process may execute outdated compiled code, so recently landed",
|
||||
"[fusion] fixes will NOT take effect until you rebuild AND restart:",
|
||||
"[fusion] pnpm build # then restart the dashboard/engine process",
|
||||
|
||||
Reference in New Issue
Block a user