From 538bf42e3262e85c9febd1798a19c9eebfbd9493 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 18 Jun 2026 13:41:37 -0700 Subject: [PATCH] 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. --- .../src/__tests__/dev-with-memory-lib.test.ts | 16 ++- scripts/__tests__/dist-freshness.test.mjs | 90 ++++++++++++++ scripts/dev-with-memory-lib.mjs | 24 +++- scripts/dev-with-memory.mjs | 19 +++ scripts/lib/dist-freshness.mjs | 110 ++++++++++++++++++ 5 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 scripts/__tests__/dist-freshness.test.mjs create mode 100644 scripts/lib/dist-freshness.mjs diff --git a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts index 7911b8d387..ddc93fee31 100644 --- a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts +++ b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts @@ -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", }); }); diff --git a/scripts/__tests__/dist-freshness.test.mjs b/scripts/__tests__/dist-freshness.test.mjs new file mode 100644 index 0000000000..2c8dc79e50 --- /dev/null +++ b/scripts/__tests__/dist-freshness.test.mjs @@ -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: { "": [{ 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 "/"; 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); +}); diff --git a/scripts/dev-with-memory-lib.mjs b/scripts/dev-with-memory-lib.mjs index 2e4679c337..bbf0f378b0 100644 --- a/scripts/dev-with-memory-lib.mjs +++ b/scripts/dev-with-memory-lib.mjs @@ -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": diff --git a/scripts/dev-with-memory.mjs b/scripts/dev-with-memory.mjs index f5ec1c371b..46bb94b8be 100644 --- a/scripts/dev-with-memory.mjs +++ b/scripts/dev-with-memory.mjs @@ -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 { diff --git a/scripts/lib/dist-freshness.mjs b/scripts/lib/dist-freshness.mjs new file mode 100644 index 0000000000..7da4d37221 --- /dev/null +++ b/scripts/lib/dist-freshness.mjs @@ -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"); +}