Files
fusion/scripts/lib/dev-source-watch.mjs
gsxdsm 9e4a0817db feat: restart the development engine on source changes (#3329)
## 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.

---

[![Compound
Engineering](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](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 -->
2026-08-04 08:57:30 -07:00

108 lines
3.5 KiB
JavaScript

import { watch as fsWatch } from "node:fs";
import { join } from "node:path";
export const DEFAULT_DEV_SOURCE_WATCH_PATHS = [
"packages/core/src",
"packages/engine/src",
"packages/dashboard/src",
"packages/cli/src",
];
const RUNTIME_SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?|json)$/i;
const NON_RUNTIME_SEGMENTS = new Set([
"__fixtures__",
"__generated__",
"__tests__",
"fixtures",
"generated",
"test",
"tests",
]);
export function isRestartableSourceFile(filename) {
if (filename === null || filename === undefined) return false;
const normalized = String(filename).replaceAll("\\", "/");
if (!normalized || !RUNTIME_SOURCE_EXTENSION.test(normalized)) return false;
if (/\.(?:test|spec)\.[cm]?[jt]sx?$/i.test(normalized)) return false;
if (/\.d\.[cm]?ts$/i.test(normalized)) return false;
return !normalized.split("/").some((segment) => NON_RUNTIME_SEGMENTS.has(segment));
}
/**
* FNXC:DevEngineWatch 2026-08-04-01:25:
* Watch only source roots that execute inside the long-lived dashboard/engine
* process. Tests, fixtures, generated declarations, build output, task state,
* and worktrees must not create restart storms. The supervisor owns process
* replacement; this helper only coalesces source events and reports paths.
*/
export function createDevSourceWatcher({
rootDir,
onRestart,
watchPaths = DEFAULT_DEV_SOURCE_WATCH_PATHS,
debounceMs = 350,
maxWaitMs = 2_000,
watch = fsWatch,
logger = console,
}) {
const watchers = [];
const watchedPaths = [];
const changedPaths = new Set();
let debounceTimer;
let firstChangeAt;
let closed = false;
const scheduleRestart = (watchPath, filename) => {
if (closed || !isRestartableSourceFile(filename)) return;
const normalized = String(filename).replaceAll("\\", "/");
changedPaths.add(`${watchPath}/${normalized}`);
firstChangeAt ??= Date.now();
if (debounceTimer) clearTimeout(debounceTimer);
const elapsedMs = Date.now() - firstChangeAt;
const delayMs = Math.min(debounceMs, Math.max(0, maxWaitMs - elapsedMs));
debounceTimer = setTimeout(() => {
debounceTimer = undefined;
firstChangeAt = undefined;
const paths = [...changedPaths];
changedPaths.clear();
Promise.resolve(onRestart(paths)).catch((error) => {
logger.warn(`[fusion:dev] source restart request failed: ${error instanceof Error ? error.message : String(error)}`);
});
}, delayMs);
debounceTimer.unref?.();
};
for (const watchPath of watchPaths) {
const absolutePath = join(rootDir, watchPath);
try {
const watcher = watch(absolutePath, { recursive: true }, (_eventType, filename) => {
scheduleRestart(watchPath, filename);
});
watcher.on?.("error", (error) => {
logger.warn(`[fusion:dev] source watcher error for ${watchPath}: ${error instanceof Error ? error.message : String(error)}`);
});
watchers.push(watcher);
watchedPaths.push(watchPath);
} catch (error) {
logger.warn(`[fusion:dev] could not watch ${watchPath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return {
watchedPaths,
close() {
closed = true;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = undefined;
firstChangeAt = undefined;
changedPaths.clear();
for (const watcher of watchers) {
try {
watcher.close();
} catch (error) {
logger.warn(`[fusion:dev] source watcher close failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
},
};
}