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 ddc93fee31..306b6b2031 100644 --- a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts +++ b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts @@ -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", }); }); diff --git a/scripts/__tests__/dist-freshness.test.mjs b/scripts/__tests__/dist-freshness.test.mjs index 2c8dc79e50..c13e906290 100644 --- a/scripts/__tests__/dist-freshness.test.mjs +++ b/scripts/__tests__/dist-freshness.test.mjs @@ -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/. +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); +}); diff --git a/scripts/build-workspace.mjs b/scripts/build-workspace.mjs index 90dca00533..3143afc765 100644 --- a/scripts/build-workspace.mjs +++ b/scripts/build-workspace.mjs @@ -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 })); } diff --git a/scripts/dev-prebuild-client.mjs b/scripts/dev-prebuild-client.mjs new file mode 100644 index 0000000000..3bf7ee7355 --- /dev/null +++ b/scripts/dev-prebuild-client.mjs @@ -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"])); diff --git a/scripts/dev-with-memory-lib.mjs b/scripts/dev-with-memory-lib.mjs index bbf0f378b0..b58a34913e 100644 --- a/scripts/dev-with-memory-lib.mjs +++ b/scripts/dev-with-memory-lib.mjs @@ -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": diff --git a/scripts/lib/dist-freshness.mjs b/scripts/lib/dist-freshness.mjs index 7da4d37221..80fc648b05 100644 --- a/scripts/lib/dist-freshness.mjs +++ b/scripts/lib/dist-freshness.mjs @@ -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/, plus plugin packages under the plugin + // roots. `label` drives the warning text; packages keep the `@fusion/` + // 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/` 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",