## Summary Add an opt-in source-development loop that restarts the dashboard and engine when runtime TypeScript or JSON changes. Use `pnpm dev:watch`; `pnpm dev:hmr` now combines Vite UI HMR with the same supervised API/engine restart path. The watcher filters tests, fixtures, generated declarations, build output, and task state. It coalesces bursts with a two-second maximum wait, waits for the child to acknowledge its IPC listener, and rebuilds runtime dist artifacts before a source-triggered respawn. ## Safety model - Close scheduler, triage, heartbeat, mission, routine, self-healing, and merge admission before checking for active work. - Let already-running agents reach a safe boundary; do not mutate durable pause settings. - Enter the existing graceful exit-code-86 shutdown and supervised respawn path. - Retry failed liveness reads and declined restart requests instead of dropping the pending change. - Keep ordinary `pnpm dev` behavior unchanged; inherited watch state does not break nested non-dashboard development commands. A development restart intentionally replaces the dashboard process, so transient dashboard connections and project dev-server children reconnect or restart with it. Agent work is the protected boundary. ## Validation - `pnpm lint` - `pnpm test:gate` (753 tests passed across engine, core, PostgreSQL gate, and CI-shape suites) - Focused CLI watcher/restart/supervision suites: 40 tests passed - Focused engine drain/manager suites: 52 tests passed - `pnpm --filter @runfusion/fusion typecheck` - `pnpm --filter @fusion/engine typecheck` - `pnpm verify:fast` (13 steps passed, including CLI build and real health boot smoke) - Manual unsupported-command probe confirms explicit `--watch` fails clearly outside the dashboard command ## Post-Deploy Monitoring & Validation - Watch for `[fusion:dev] source changed`, `source restart deferred`, `active work drained`, and `restart requested` logs during the first watched development session. - Healthy behavior is one exit-86 respawn per edit batch, no interrupted active agents, refreshed dist artifacts, and a healthy dashboard after respawn. - Investigate repeated restart loops, watcher attachment warnings, declined restart retries, or liveness-read failures. - Immediate mitigation is to use ordinary `pnpm dev` without `--watch`; no production runtime behavior or durable setting needs rollback. - Validation owner: Fusion maintainers during the first source edit after merge. --- [](https://github.com/EveryInc/compound-engineering-plugin) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added `pnpm dev:watch` to automatically restart development runtime processes when source files change. * Development restarts now wait for active work to finish, preventing new work from starting during the transition. * Enhanced `pnpm dev:hmr` with graceful runtime source restarts while keeping the dashboard available. * Rapid source changes are grouped to avoid unnecessary restarts. * **Documentation** * Updated development setup and contribution guides with the new watch workflow. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
220 lines
7.0 KiB
JavaScript
220 lines
7.0 KiB
JavaScript
export function buildDevNodeArgs({
|
|
inspectFlags = [],
|
|
preload,
|
|
loader,
|
|
entry,
|
|
args = [],
|
|
}) {
|
|
return [
|
|
...inspectFlags,
|
|
"--conditions=source",
|
|
"--require",
|
|
preload,
|
|
"--import",
|
|
`file://${loader}`,
|
|
entry,
|
|
...args,
|
|
];
|
|
}
|
|
|
|
export function createDevWatchRestartCoordinator({ log = console.log, warn = console.warn } = {}) {
|
|
let child;
|
|
let armed = false;
|
|
let queued = false;
|
|
let pendingPaths = [];
|
|
|
|
const requeuePaths = (changedPaths) => {
|
|
pendingPaths = [...new Set([...pendingPaths, ...changedPaths])];
|
|
};
|
|
|
|
const sendRestart = (changedPaths) => {
|
|
if (!child?.connected) {
|
|
requeuePaths(changedPaths);
|
|
warn("[fusion:dev] source restart deferred; the engine child is not connected");
|
|
return;
|
|
}
|
|
const preview = changedPaths.slice(0, 3).join(", ");
|
|
const remainder = Math.max(0, changedPaths.length - 3);
|
|
log(`[fusion:dev] source changed (${preview}${remainder > 0 ? ` +${remainder} more` : ""}) — restart queued…`);
|
|
queued = true;
|
|
try {
|
|
child.send({ type: "fusion:dev-source-changed" }, (error) => {
|
|
if (!error) return;
|
|
queued = false;
|
|
requeuePaths(changedPaths);
|
|
warn(`[fusion:dev] source restart message failed: ${error.message}`);
|
|
});
|
|
} catch (error) {
|
|
queued = false;
|
|
requeuePaths(changedPaths);
|
|
warn(`[fusion:dev] source restart message failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
};
|
|
|
|
return {
|
|
attach(nextChild) {
|
|
child = nextChild;
|
|
armed = false;
|
|
queued = false;
|
|
},
|
|
request(changedPaths) {
|
|
if (queued) return;
|
|
if (!armed) {
|
|
pendingPaths = [...new Set([...pendingPaths, ...changedPaths])];
|
|
log("[fusion:dev] source changed while the engine child is starting — restart will queue when watch is armed");
|
|
return;
|
|
}
|
|
if (!child?.connected) {
|
|
requeuePaths(changedPaths);
|
|
warn("[fusion:dev] source restart deferred; the engine child is not connected");
|
|
return;
|
|
}
|
|
const paths = [...new Set([...pendingPaths, ...changedPaths])];
|
|
pendingPaths = [];
|
|
sendRestart(paths);
|
|
},
|
|
onMessage(message) {
|
|
if (!message || typeof message !== "object" || message.type !== "fusion:dev-source-restart-armed") return;
|
|
armed = true;
|
|
if (pendingPaths.length === 0) return;
|
|
const paths = pendingPaths;
|
|
pendingPaths = [];
|
|
sendRestart(paths);
|
|
},
|
|
detach(nextChild) {
|
|
if (child !== nextChild) return false;
|
|
const sourceRestart = queued;
|
|
child = undefined;
|
|
armed = false;
|
|
return sourceRestart;
|
|
},
|
|
};
|
|
}
|
|
|
|
const VALID_PREBUILD_MODES = new Set(["auto", "none", "client", "full"]);
|
|
|
|
export function normalizePrebuildMode(value) {
|
|
const mode = value === undefined || value === null ? "auto" : String(value).toLowerCase();
|
|
if (mode === "" || !VALID_PREBUILD_MODES.has(mode)) {
|
|
throw new Error(`Invalid prebuild mode "${value}". Expected one of: auto, none, client, full.`);
|
|
}
|
|
return mode;
|
|
}
|
|
|
|
export function hasHostOverride(args) {
|
|
return args.includes("--host") || args.some((arg) => arg.startsWith("--host="));
|
|
}
|
|
|
|
export function buildForwardedDevArgs(args) {
|
|
/*
|
|
FNXC:DevWorkflow 2026-07-12-10:20:
|
|
`pnpm dev` and `pnpm start` with no command must behave exactly like
|
|
`pnpm dev dashboard` (client prebuild + LAN host injection), not fall through
|
|
to the CLI's bare default. Normalize empty/flag-only invocations to an
|
|
explicit "dashboard" command so every downstream decision (prebuild mode,
|
|
host injection) sees the same shape.
|
|
*/
|
|
const hasCommand = args.length > 0 && !String(args[0]).startsWith("-");
|
|
const normalized = hasCommand ? args : ["dashboard", ...args];
|
|
const needsDevHostInjection = normalized[0] === "dashboard" && !hasHostOverride(normalized);
|
|
return needsDevHostInjection ? [...normalized, "--host", "0.0.0.0"] : normalized;
|
|
}
|
|
|
|
export function parseDevWrapperArgs(rawArgs, env = process.env) {
|
|
const inspectFlags = [];
|
|
const args = [];
|
|
let requestedPrebuild = env.FUSION_DEV_PREBUILD ?? "auto";
|
|
let watchSource = env.FUSION_DEV_WATCH === "1";
|
|
let watchSourceFromFlag = false;
|
|
|
|
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
const arg = rawArgs[i];
|
|
if (arg === "--inspect" || arg === "--inspect-brk" || arg.startsWith("--inspect=") || arg.startsWith("--inspect-brk=")) {
|
|
inspectFlags.push(arg);
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--prebuild") {
|
|
const value = rawArgs[i + 1];
|
|
if (!value) {
|
|
throw new Error("Missing value for --prebuild. Expected one of: auto, none, client, full.");
|
|
}
|
|
requestedPrebuild = value;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith("--prebuild=")) {
|
|
requestedPrebuild = arg.slice("--prebuild=".length);
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--skip-build") {
|
|
requestedPrebuild = "none";
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--watch") {
|
|
watchSource = true;
|
|
watchSourceFromFlag = true;
|
|
continue;
|
|
}
|
|
|
|
args.push(arg);
|
|
}
|
|
|
|
return {
|
|
inspectFlags,
|
|
args,
|
|
requestedPrebuild: normalizePrebuildMode(requestedPrebuild),
|
|
watchSource,
|
|
watchSourceFromFlag,
|
|
};
|
|
}
|
|
|
|
export function resolvePrebuildMode(requestedPrebuild, forwardedArgs) {
|
|
const mode = normalizePrebuildMode(requestedPrebuild);
|
|
if (mode !== "auto") {
|
|
return mode;
|
|
}
|
|
|
|
const command = forwardedArgs[0] ?? "dashboard";
|
|
return command === "dashboard" ? "client" : "none";
|
|
}
|
|
|
|
export function getPrebuildCommand(mode) {
|
|
switch (normalizePrebuildMode(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.
|
|
|
|
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: "node",
|
|
args: ["scripts/dev-prebuild-client.mjs"],
|
|
label: "core + engine + dashboard + changed plugins build",
|
|
};
|
|
case "none":
|
|
case "auto":
|
|
return null;
|
|
}
|
|
}
|