PluginStore.updatePluginState rejects same-state transitions, so the
autoload at startup error'd with "Invalid state transition from
started to started" for every plugin whose persisted state was
"started" from a previous container generation. The new process has
no in-memory instance yet, so the right thing is to flip the
persisted state back to "installed" before loadAllPlugins() walks
the registry; loadPlugin() then drives the state machine forward to
"started" cleanly.
This unblocks the plugin route mount for telemetry-watcher.
The plugin route mount in createApiRoutes runs once at server start,
iterating pluginLoader.getPluginRoutes() to bind handlers and to
register their paths as daemon-auth exempt. Without an autoload step
the loader is empty at mount time, so plugin routes are missing
until the next restart even after a successful enable through the
dashboard API — Express routes can't be added after listen().
Add a pluginLoader.loadAllPlugins() call before createServer to
mirror what runtime-providing plugins (paperclip etc.) already
assume: enabled plugins are live the moment fusion starts. Failure
of an individual plugin's load doesn't fail startup.
@fusion/plugin-sdk's package.json points its import entry at
src/index.ts (TS source), and that file imports core/src/plugin-types.js
via relative path. Node 22 ESM has no TS loader so the chain
unraveled at runtime: the plugin loader successfully resolved our
compiled dist/index.js, but the very first `import { definePlugin }
from "@fusion/plugin-sdk"` walked into TS source and fell over.
definePlugin is a pure typed identity function — it adds no runtime
behavior. Inline its source plus a structural copy of the plugin
context/route/manifest types we use, drop the @fusion/plugin-sdk
dependency entirely. The compiled output now imports nothing outside
node:crypto and Node builtins, so it can load anywhere fusion can
spawn ESM modules.
When fusion publishes a compiled SDK build, swap the inline types
back to import statements and restore the workspace dependency.
The runtime plugin packages (paperclip, hermes, openclaw) all build to
dist/ and have package.json exports import pointing at dist/index.js.
Fusion's plugin loader uses Node ESM dynamic import on the absolute
installation path, which fails on TypeScript source because there's
no TS loader at runtime. Drop noEmit, exclude tests from compilation,
emit dist + sourcemaps + d.ts so the loader can resolve dist/index.js
the same way it resolves paperclip's.
Plugin should be re-registered with path
/app/plugins/fusion-plugin-telemetry-watcher/dist/index.js after the
fusion redeploy lands the new dist artifact.
Fusion's plugin loader imports source files directly via Node 22 ESM
without a TS loader, so relative imports like "./internal/dedup.js"
fail at runtime: there's no compiled .js artifact and Node won't fall
back to .ts. Inline severity classifier, dedup cache, rate limiter,
and Grafana parser into the single entry file. Tests now import named
exports from "../index.js" directly. The Phase-2 split into separate
source files can come back once fusion adds a TS-aware plugin loader.
All 14 unit tests still pass. Behavior unchanged.
External services (Grafana, Sentry, Slack) call plugin webhooks with
their own per-plugin shared secret — they cannot present the
dashboard's daemon token. Daemon auth was 401'ing those callbacks
before they reached the plugin handler, so even with the route
correctly mounted the secret check inside the plugin never fired.
Add a registry of dynamically-exempt paths populated at server
startup when plugin routes are mounted. Plugin management routes
(/api/plugins, /api/plugins/:id/enable, etc.) stay gated; only the
plugin-defined routes (/api/plugins/:pluginId/<route>) are exempted.
Each plugin handler is responsible for its own secret check (the
telemetry-watcher webhook compares Authorization Bearer against
settings.grafanaWebhookSecret in constant time at the handler).
Phase-1 telemetry-watcher's grafana-webhook handler 401'd because the
dashboard never mounted plugin-supplied routes — getPluginRoutes()
exists on PluginLoader but no caller consumed it. The smoke test was
working around this by injecting incident tasks directly through
/api/tasks; we want the real path to work end-to-end.
Two changes:
1. PluginLoader gains createContextFor(pluginId, { taskStore? }).
Lifecycle hooks still see the loader's bound store (the cwd
project), but REST handlers receive a project-scoped store derived
from the request's projectId so a Grafana webhook addressed to
sase opens tasks in sase even though fusion's loader is bound
to its own cwd. Settings still come from the loader's store at
load time, which is the right thing — settings don't follow the
request.
2. routes.ts iterates pluginLoader.getPluginRoutes() once at server
startup and binds /api/plugins/:pluginId/:routePath to a handler
that resolves project context per request, builds the context via
createContextFor, and forwards to the plugin's route. ApiError +
rethrowAsApiError preserve the dashboard's standard error envelope.
Plugins added after server start still need a restart for routes to
bind; reloadPlugin doesn't currently re-mount Express handlers. That
limitation matches the existing constraint and is out of scope here.
New workspace package fusion-plugin-telemetry-watcher that turns a
Grafana Alerting webhook payload into a Fusion incident task in the
triage column. Hooks the dedup/severity/rate-limit primitives that
PostHog/Sentry/Slack sources will reuse in Phase 2.
Pipeline:
POST /api/plugins/fusion-plugin-telemetry-watcher/grafana-webhook
→ bearer-secret check
→ parseGrafanaPayload (one signal per firing alert; resolved alerts
are dropped — recovery is verified post-deploy by the QA agent)
→ classifySeverity P0/P1/P2/P3 with critical-path keyword
escalation (payment/auth/billing/subscription)
→ DedupCache 4h fingerprint window — repeat fires log against the
existing task instead of opening duplicates
→ IncidentRateLimiter 5/h, 20/d — overflow becomes a "telemetry
storm" mega-task in a future phase
→ taskStore.createTask({ column: "triage", priority })
→ optional auto-assign to Triage Agent
14 unit tests cover severity buckets, critical-path escalation, dedup
windowing/eviction, hourly+daily rate caps, and the Grafana payload
parser (firing vs resolved, label-based domain inference).
Settings expose all thresholds + secret + dedup window + rate limits
through the dashboard plugin settings UI. README documents the deploy
+ register + Grafana contact-point wiring.
ProjectEngineManager runs one engine per registered project, but the
heartbeat handler in register-agent-runtime-routes.ts only used the
single global heartbeatMonitor passed via ServerOptions. That monitor
is bound to the cwd project's engine, so heartbeat triggers for any
secondary project silently no-op'd: the run id was returned but no
execution actually happened.
Add resolveHeartbeatMonitorFor(scopedStore) that walks
engineManager.getAllEngines() and returns the engine whose working
directory matches the request's scoped store, falling back to the
global monitor when its rootDir matches. Use the resolver at every
heartbeat call site (state-pause stop, /agents/:id/heartbeat,
/agents/:id/runs, /agents/:id/runs/stop).
The host's /usr/bin/codex is a symlink to the codex.js entrypoint inside
the package directory; bind-mounting it directly resolves to the file
content but breaks codex's import.meta.url-based module resolution. Add
the symlink at build time so a /usr/lib/node_modules/@openai/codex bind
mount is enough to make codex usable in the container.
The dashboard modules used a variable-specifier dynamic import
(`const m = "@fusion/engine"; await import(m)`) to defeat bundler static
analysis. tsup honored that and left the dynamic import in dist/bin.js,
so the published `@runfusion/fusion` package failed at runtime with
"createFnAgent2 is not a function" — `@fusion/engine` isn't on npm and
the silent catch set the binding to undefined. Replaces the trick with
static imports across planning, chat, subtask-breakdown, mission-interview,
agent-generation, ai-refine, roadmap-suggestions, milestone-slice-interview,
and routes. Core can't statically import engine (cycle), so it now exposes
setCreateFnAgent and engine wires itself in at module load. Documents the
pattern in AGENTS.md.
FixesRunfusion/Fusion#9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- fix(FN-2610): add changeset for health version fix
- test(FN-2610): verify health endpoint returns real package version
- fix(FN-2610): read version from package.json in health endpoint
Update test regexps to match .modal.terminal-modal (two-class selector) and assert min-height: 100dvh on mobile. Also clean up a stale comment in TaskDetailModal.
When the soft keyboard opens on mobile, the overlay's align-items:center was vertically centering the shrunken modal, pushing the header/tabs out of view. Add a rule to switch to align-items:flex-start when --keyboard-overlap is detected.
readStoredAuthProvidersFromDisk() previously returned only the first
successfully-parsed auth file, missing providers that existed only in
fallback locations (e.g. github-copilot in ~/.pi/agent/auth.json when
another provider was in ~/.fusion/agent/auth.json). Now iterates all
candidates and merges entries with first-found-wins priority.