fix(dev): rebuild core+engine+dashboard on dev/local startup + stale-dist check

pnpm dev/local dashboard prebuild now rebuilds @fusion/core and @fusion/engine alongside the dashboard UI (was client-only), and startup warns loudly when built dist/ is older than src/. Prevents the FN-6638 class where landed engine fixes silently never run because the process loads stale dist.
This commit is contained in:
gsxdsm
2026-06-18 13:41:37 -07:00
parent b6823af049
commit 538bf42e32
5 changed files with 254 additions and 5 deletions

View File

@@ -69,12 +69,22 @@ describe("dev-with-memory prebuild options", () => {
]);
});
it("defaults dashboard startup to client-only prebuild instead of full workspace build", () => {
it("rebuilds core + engine + dashboard (UI) 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.
expect(resolvePrebuildMode("auto", ["dashboard", "--port", "4050"])).toBe("client");
expect(getPrebuildCommand("client")).toEqual({
command: "pnpm",
args: ["--filter", "@fusion/dashboard", "build:client"],
label: "dashboard client build",
args: [
"--filter",
"@fusion/core",
"--filter",
"@fusion/engine",
"--filter",
"@fusion/dashboard",
"build",
],
label: "core + engine + dashboard build",
});
});

View File

@@ -0,0 +1,90 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeDistStaleness, formatDistStalenessWarning } from "../lib/dist-freshness.mjs";
/*
FNXC:DevWorkflow 2026-06-18-16:50:
FN-6638 stale-dist guard tests. Verifies the startup freshness check flags a
src-ahead-of-dist build, stays quiet when fresh, and never false-positives for
pure-source (no dist) or packaged (no src) layouts.
*/
// In-memory fs seam: paths are exact strings; dirs list children; files carry mtimeMs.
function makeFs({ dirs, files }) {
const dirSet = new Set(dirs);
// files: { "<dir>": [{ name, mtimeMs, isDir? }] } keyed by parent dir
return {
existsSync: (p) => dirSet.has(p),
readdirSync: (dir) =>
(files[dir] ?? []).map((e) => ({
name: e.name,
isDirectory: () => Boolean(e.isDir),
})),
statSync: (p) => {
// p is "<dir>/<name>"; look it up by scanning entries
for (const [dir, entries] of Object.entries(files)) {
for (const e of entries) {
if (`${dir}/${e.name}` === p) return { mtimeMs: e.mtimeMs };
}
}
return { mtimeMs: 0 };
},
};
}
const ROOT = "/repo";
function layout({ srcMs, distMs, withSrc = true, withDist = true }) {
const dirs = [];
const files = {};
const srcDir = `${ROOT}/packages/engine/src`;
const distDir = `${ROOT}/packages/engine/dist`;
if (withSrc) {
dirs.push(srcDir);
files[srcDir] = [{ name: "executor.ts", mtimeMs: srcMs }];
}
if (withDist) {
dirs.push(distDir);
files[distDir] = [{ name: "executor.js", mtimeMs: distMs }];
}
return makeFs({ dirs, files });
}
test("flags stale when src is newer than dist beyond slack", () => {
const fs = layout({ srcMs: 10_000, distMs: 1_000 });
const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
assert.equal(result.stale, true);
assert.equal(result.packages[0].stale, true);
const warning = formatDistStalenessWarning(result);
assert.match(warning, /STALE BUILD/);
assert.match(warning, /@fusion\/engine/);
assert.match(warning, /pnpm build/);
});
test("not stale when dist is newer than src", () => {
const fs = layout({ srcMs: 1_000, distMs: 10_000 });
const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
assert.equal(result.stale, false);
assert.equal(formatDistStalenessWarning(result), null);
});
test("not stale within slack window", () => {
const fs = layout({ srcMs: 1_500, distMs: 1_000 }); // 500ms < 2000ms slack
const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
assert.equal(result.stale, false);
});
test("skips packages with no dist (pure source run)", () => {
const fs = layout({ srcMs: 10_000, distMs: 0, withDist: false });
const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
assert.equal(result.stale, false);
assert.equal(result.packages.length, 0);
});
test("skips packages with no src (packaged install)", () => {
const fs = layout({ srcMs: 0, distMs: 10_000, withSrc: false });
const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
assert.equal(result.stale, false);
assert.equal(result.packages.length, 0);
});

View File

@@ -93,10 +93,30 @@ export function getPrebuildCommand(mode) {
case "full":
return { command: "pnpm", args: ["build"], label: "workspace build" };
case "client":
/*
FNXC:DevWorkflow 2026-06-18-16:40:
FN-6638/stale-dist: `pnpm dev dashboard` must rebuild @fusion/core and
@fusion/engine alongside the dashboard UI, not only the client bundle.
Although the CLI runs under `--conditions=source` (engine/core resolve to
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.
*/
return {
command: "pnpm",
args: ["--filter", "@fusion/dashboard", "build:client"],
label: "dashboard client build",
args: [
"--filter",
"@fusion/core",
"--filter",
"@fusion/engine",
"--filter",
"@fusion/dashboard",
"build",
],
label: "core + engine + dashboard build",
};
case "none":
case "auto":

View File

@@ -129,6 +129,25 @@ async function warnIfSourceVersionBehind() {
await warnIfSourceVersionBehind();
// FNXC:DevWorkflow 2026-06-18-16:50:
// FN-6638 stale-dist guard. Warn (loudly, best-effort) when built dist/ is older
// than src/ so a never-rebuilt/never-restarted process does not silently run
// phantom-old code. When a prebuild is about to run it will refresh dist, so the
// check is informational there; for --prebuild none / dist-resolving consumers
// it is the safety net. Never let the check break startup.
async function warnIfDistStale() {
if (process.env.FUSION_SKIP_DIST_FRESHNESS_CHECK === "1") return;
try {
const { computeDistStaleness, formatDistStalenessWarning } = await import("./lib/dist-freshness.mjs");
const warning = formatDistStalenessWarning(computeDistStaleness({ rootDir: process.cwd() }));
if (warning) console.warn(warning);
} catch {
// Best-effort only. Startup must not depend on the freshness check.
}
}
await warnIfDistStale();
if (!prebuildCommand) {
runApp(forwardedArgs);
} else {

View File

@@ -0,0 +1,110 @@
/*
FNXC:DevWorkflow 2026-06-18-16:50:
FN-6638 stale-dist guard. The running Fusion process loads built `dist/` for
@fusion/core, @fusion/engine, and @fusion/dashboard (directly, via plugins, via
dist-resolving sub-imports, or whenever a non-dev/packaged `fn` runs). When a
long-lived process or a stale build runs `dist/` that is OLDER than the `src/`
on disk, landed fixes silently never execute — that is how FN-6644/6647/6648
(and others) appeared "fixed" for ~2 days while the running engine still parked
completed tasks failed. This module computes that staleness so startup can warn
loudly (rebuild + restart) instead of running phantom-old code.
Design / guardrails:
- Pure + injectable (fs + now) so it is unit-testable and never throws into the
startup path.
- A package is only evaluated when BOTH its `src/` and `dist/` exist. Missing
`dist/` = running purely from source (fresh, not stale). Missing `src/` =
packaged/published install with no source tree to compare against (not stale).
- Staleness = newest `.ts`/`.tsx` mtime under `src/` is NEWER than the package's
dist build marker (newest `.js` mtime under `dist/`), beyond a small slack to
absorb filesystem mtime jitter.
*/
import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
const DEFAULT_PACKAGES = ["core", "engine", "dashboard"];
// 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"];
const DIST_EXTENSIONS = [".js"];
// Never descend into these — they are not the package's own emitted output.
const SKIP_DIRS = new Set(["node_modules", ".git", "__tests__", "coverage"]);
function newestMtimeMs(dir, extensions, fs) {
let newest = 0;
let stack = [dir];
while (stack.length > 0) {
const current = stack.pop();
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
stack.push(join(current, entry.name));
continue;
}
if (!extensions.some((ext) => entry.name.endsWith(ext))) continue;
try {
const ms = fs.statSync(join(current, entry.name)).mtimeMs;
if (ms > newest) newest = ms;
} catch {
// unreadable file — ignore, do not let it break the scan
}
}
}
return newest;
}
/**
* Compute dist staleness for a source checkout.
*
* @param {object} [options]
* @param {string} [options.rootDir] repo root (defaults to cwd)
* @param {string[]} [options.packages] package dir names under packages/
* @param {number} [options.slackMs] mtime slack
* @param {object} [options.fs] fs seam ({ existsSync, readdirSync, statSync })
* @returns {{ stale: boolean, packages: Array<{ name: string, srcNewestMs: number, distNewestMs: number, stale: boolean }> }}
*/
export function computeDistStaleness(options = {}) {
const rootDir = options.rootDir ?? process.cwd();
const packages = options.packages ?? DEFAULT_PACKAGES;
const slackMs = options.slackMs ?? DEFAULT_SLACK_MS;
const fs = options.fs ?? { existsSync, readdirSync, statSync };
const results = [];
for (const name of packages) {
const srcDir = join(rootDir, "packages", name, "src");
const distDir = join(rootDir, "packages", name, "dist");
// 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 });
}
return { stale: results.some((r) => r.stale), packages: results };
}
/**
* Build the operator warning lines for a stale result (or null when fresh).
* Kept separate from I/O so it is testable and the caller owns logging.
*/
export function formatDistStalenessWarning(result) {
if (!result || !result.stale) return null;
const staleNames = result.packages.filter((p) => p.stale).map((p) => p.name);
return [
"",
`[fusion] ⚠ STALE BUILD: ${staleNames.map((n) => `@fusion/${n}`).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",
"[fusion] (Set FUSION_SKIP_DIST_FRESHNESS_CHECK=1 to silence this check.)",
"",
].join("\n");
}