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:
gsxdsm
2026-07-10 13:26:23 -07:00
parent ee796ee991
commit 8f7089ecc9
6 changed files with 178 additions and 38 deletions

View File

@@ -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",
});
});

View File

@@ -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);
});

View File

@@ -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 }));
}

View 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"]));

View File

@@ -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":

View File

@@ -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",