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:
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
90
scripts/__tests__/dist-freshness.test.mjs
Normal file
90
scripts/__tests__/dist-freshness.test.mjs
Normal 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);
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
110
scripts/lib/dist-freshness.mjs
Normal file
110
scripts/lib/dist-freshness.mjs
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user