diff --git a/.changeset/faster-startup-p0.md b/.changeset/faster-startup-p0.md new file mode 100644 index 0000000000..294df7c1a7 --- /dev/null +++ b/.changeset/faster-startup-p0.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Speed up dashboard and serve startup by sharing the PostgreSQL store and deferring non-route work. +category: performance +dev: Dashboard injects externalTaskStore for cwd engine (serve parity); multi-project engines only share when working directories match. ProjectEngine defers notifiers/OAuth (refresh-before-monitor), automation syncs, and merge sweep. Serve no longer awaits startAll before listen. Phase timing logs on both surfaces. diff --git a/docs/plans/2026-07-14-001-feat-faster-startup-plan.md b/docs/plans/2026-07-14-001-feat-faster-startup-plan.md new file mode 100644 index 0000000000..aa7e79464c --- /dev/null +++ b/docs/plans/2026-07-14-001-feat-faster-startup-plan.md @@ -0,0 +1,447 @@ +--- +title: "feat: Faster dashboard and serve startup" +status: completed +date: 2026-07-14 +completed: 2026-07-15 +pr: https://github.com/Runfusion/Fusion/pull/2132 +type: feat +origin: conversation (startup path analysis on feature/faster-startup) +--- + +# feat: Faster dashboard and serve startup + +## Summary + +Shorten **time-to-HTTP-ready / TUI-usable** for `fn dashboard` and `fn serve` by eliminating dual TaskStore boots, moving non-route-critical engine work off the pre-listen path, parallelizing independent satellite inits, and extending phase timing — without reintroducing the historical 3s cwd-engine race that degraded webhooks, and without weakening PostgreSQL migration integrity. + +## Problem Frame + +After the PostgreSQL cutover, local startup is dominated by: + +1. **Backend boot** — embedded PG (cold `initdb` / warm `pg_ctl`) plus schema baseline and optional SQLite→PG auto-migration. +2. **Dual store construction on dashboard** — dashboard already boots a PostgreSQL `TaskStore`, then `ensureEngine(cwd)` boots a **second** factory path because `externalTaskStore` is not wired (serve already shares one store). +3. **Serial engine bring-up** — `ProjectEngine.start()` awaits notifiers, OAuth refresh/monitor, automation syncs, and `startupMergeSweep` before returning, which blocks `createServer` / listen on the dashboard critical path. +4. **Extension resolution** — `packageManager.resolve()` is called out in-code as a slow dashboard phase; serve blocks on the same walk before listen. +5. **Incomplete phase metrics** — dashboard logs coarse `startup phase *` labels; serve and engine internals lack matching substep timing, so regressions are hard to attribute. + +Primary success metric: **time from process start to HTTP listening (and TUI `setReady`)** on warm multi-project and single-project boots. Full orchestration readiness may lag slightly if deferred work remains fail-soft and does not leave route closures unbound. + +## Requirements + +- R1. `fn dashboard` with engine on reuses a single PostgreSQL-backed `TaskStore` (and connection pool) for the cwd project HTTP layer and cwd engine — parity with serve’s `externalTaskStore` wiring. +- R2. Multi-project engines must not receive a cwd-bound store for a different project root; store injection is cwd/path-matched (or projectId-matched), not “one store for every registered project.” +- R3. `createServer` always receives a live cwd `options.engine` when engine mode is on — no timed race / partial-undefined engine that unbinds webhook, automation, mission, or routine routes. +- R4. PostgreSQL schema baseline and first-boot SQLite auto-migration remain pre-first-write, single-owner, and fatal on verification failure. +- R5. Non-route-critical engine startup work (merge sweep, notification/OAuth stack, automation schedule syncs) may complete after the engine object is returned / after HTTP listen, with fail-soft logging and no silent permanent skip. +- R6. OAuth refresh-before-expiry-monitor order is preserved whenever both run (avoids false “token expired” ntfy on restart). +- R7. Plugin module load may remain pre-`createServer` while plugin routes depend on a loaded `pluginLoader`; schema init failures stay integrity-critical (serve fatal; dashboard must not leave a half-bound plugin schema silently). +- R8. Operators and developers can see per-phase wall times for backend factory substeps, engine blocks, and total time-to-listen on both dashboard and serve (extend existing `phaseTime` style; optional EL lag via `FUSION_TRACE_EL_LAG`). +- R9. Boot smoke and thin merge gate remain green: real `fn serve` → `/api/health` on ephemeral port, clean SIGTERM; no binding/killing port 4040. +- R10. Regression tests prove single factory boot for dashboard engine-on cwd path and that deferred work still eventually runs; tests stay file-scoped and free of real-network / slow full-suite habits (FN-5048). + +## Scope Boundaries + +### In scope + +- `fn dashboard` and `fn serve` process startup critical path. +- `ProjectEngine` / `InProcessRuntime` pre-return awaits that are not required for route closures. +- Satellite store init ordering on dashboard after shared layer boot. +- Phase timing instrumentation and lightweight extension-path improvements (cache/defer only where chat/provider readiness is not blocked incorrectly). + +### Deferred to Follow-Up Work + +- Desktop embedded runtime dual-store parity (`packages/cli/src/commands/desktop.ts`, Electron local-runtime) — same seams, not primary. +- Embedded PG keep-alive daemon / always-on external `DATABASE_URL` as the default operator workflow (document as operational tip; no product redesign here). +- Full CLI bundle code-split / 14MB `bin.js` parse cost. +- Deep SQLite→PG migration throughput rewrite (bulk copy algorithms) beyond ensuring progress remains visible. +- Capturing the 3s webhook race as a dedicated `docs/solutions/` entry (recommended after land via ce-compound). + +### Out of scope + +- Re-enabling HybridExecutor for ordinary local multi-project (prior ~7s duplicate runtime cost). +- Changing engine singleton lock semantics (`has` vs `hasRunningEngine`). +- Dashboard query/index load work already covered by `docs/performance/dashboard-load.md`. +- Product UX redesign of TUI loading copy beyond accurate phase status. + +## Assumptions + +- Confirmed primary metric is **time-to-HTTP-ready / TUI usable**, not full orchestration quiet-state. +- Surfaces: **dashboard + serve**; desktop only if a unit can reuse the same API without expanding risk. +- Aggressiveness includes **backgrounding non-route-critical engine work** after a correct engine handle exists, not only store-sharing. + +## Key Technical Decisions + +1. **Share store via existing `externalTaskStore` seam, not a new abstraction.** Serve already proves the pattern: factory once → CentralCore `asyncLayer` → `ProjectEngineManager({ externalTaskStore })` → single shutdown. Dashboard adopts the same seam. + +2. **Prefer cwd-scoped injection over manager-global blind share.** Manager today forwards one `externalTaskStore` to every engine in `buildEngineOptions`. For multi-project correctness under project-partitioned PG, inject only when the engine’s working directory / projectId matches the booted store’s root (cwd-only override on `ensureEngine`, or clear/undefined for other projects so they factory-boot their own bound store). Do not leave multi-project engines silently writing through the cwd partition. + +3. **Engine handle before `createServer` remains a hard invariant.** Defer work **inside** `ProjectEngine.start()` / post-`start()` background phases so `options.engine` is non-null and route closures bind real subsystem getters. Do **not** reintroduce `Promise.race(ensureEngine, 3s)`. + +4. **Deferral allowlist (post engine object, preferably post-listen for pure side effects):** + - `startupMergeSweep` (self-healing / periodic merge retry already cover related cases; accept brief stale `merging*` window) + - NotificationService + OAuth refresh/monitor/validity + NtfyNotifier (preserve refresh→monitor order inside the deferred chain) + - Automation schedule sync helpers (CronRunner can start; syncs fill in with degraded→ready health) + - Already allowed: custom provider `/models` refresh, mDNS (needs bound port), Claude skill FS backfill, runtime startup recovery sequence + +5. **Keep pre-listen:** PostgreSQL factory + migration, store init/watch (or equivalent ownership), plugin load required for `getPluginRoutes()`, cwd `ensureEngine` completion for engine mode, HybridExecutor only when gate enables multi-node. + +6. **Serve: stop awaiting `startAll()` before listen.** Match dashboard: `void startAll()` + reconciliation + await only primary/cwd engine needed for `createServer`’s primary store/engine. Multi-project engines warm in background. + +7. **Instrumentation first-class but cheap.** Shared helper (or duplicated minimal `phaseTime`) for serve; add factory substeps (`embedded.start`, `schema.baseline`, `sqlite.migrate?`, `taskStore.construct`) and engine blocks (`runtime.start`, `notifiers`, `automations`, `mergeSweep`) behind normal log lines — not a new metrics product. + +8. **Tests stay mock-first.** Assert factory call count / `externalTaskStore` wiring in CLI unit tests (extend serve/dashboard tests). Use `pnpm smoke:boot` / `verify:fast` for real process proof; avoid new real embedded-PG cold boots in the CLI vitest suite. + +## High-Level Technical Design + +### Target boot topology (cwd project) + +```mermaid +flowchart TD + A[bin / command load] --> B[createTaskStoreForBackend once] + B --> C[CentralCore + asyncLayer] + B --> D[Dashboard satellite inits parallel-safe] + C --> E[ProjectEngineManager externalTaskStore for cwd only] + D --> E + E --> F[await ensureEngine cwd] + F --> G[runtime.start core path] + G --> H[return engine handle] + H --> I[createServer with engine] + I --> J[listen + TUI ready] + H --> K[background: notifiers OAuth order] + H --> L[background: automation syncs] + H --> M[background: merge sweep] + J --> N[background: custom providers mDNS skills] +``` + +### Critical-path vs deferred + +| Phase | Pre-listen / pre-engine-return | Deferred | +|-------|--------------------------------|----------| +| Embedded PG + schema + migrate | Yes | Never | +| TaskStore + CentralCore layer | Yes | Never | +| Satellite inits (agent/plugin/automation for HTTP) | Yes (parallelize) | — | +| Plugin load for routes | Yes | Module-only defer only with route readiness redesign (out of scope) | +| `InProcessRuntime` scheduler/executor/self-healing construct | Yes | Recovery sequence already deferred | +| Notifiers / OAuth stack | No (after handle) | Yes | +| Automation sync ×4 | No | Yes | +| `startupMergeSweep` | No | Yes | +| `packageManager.resolve` | Prefer overlap; cache if safe | Non-essential providers post-listen | +| Non-cwd `startAll` engines | Background | Yes | + +### Ownership rules when sharing store + +- **One** `createTaskStoreForBackend` for cwd on dashboard engine-on path. +- Engine `InProcessRuntime` takes `externalTaskStore` and skips factory + does not own `backendShutdown`. +- Process teardown: engines stop → then factory `shutdown()` once (serve pattern). +- Dashboard continues to pass the **same** store instance into `createServer(store, …)` so HTTP and engine see one writer. + +## Sequenced Delivery (phases) + +Land as dependency-ordered commits/PRs. Each phase is independently shippable and measurable. + +| Phase | Units | Outcome | Risk | +|-------|-------|---------|------| +| **P0 — Measure & share store** | U1, U2 | Factory substep + serve timing; dashboard single store for cwd engine | Low | +| **P1 — Serve multi-project non-block** | U3 | Serve listens without awaiting every project engine | Medium | +| **P2 — Defer non-route-critical engine work** | U4 | Shorter `ensureEngine` / `ProjectEngine.start` critical path | Medium–High | +| **P3 — Parallel satellite inits** | U5 | Shorter dashboard pre-engine path | Low | +| **P4 — Extension path** | U6 | Reduce `packageManager.resolve` wall time / blocking | Medium | +| **P5 — Verification & docs** | U7 | Boot smoke, gate, FNXC, optional solutions capture | Low | + +Do not start P2 until P0 is green: store sharing is the largest structural win and simplifies measuring engine-internal deferrals. + +--- + +## Implementation Units + +### U1. Extend startup phase instrumentation (dashboard factory + serve + engine hooks) + +**Goal:** Make time-to-listen and bottleneck attribution first-class on both surfaces so later units can be validated with numbers, not guesswork. + +**Requirements:** R8, R9 + +**Dependencies:** None + +**Files:** + +- `packages/cli/src/commands/dashboard.ts` (extend labels / wrap factory) +- `packages/cli/src/commands/serve.ts` (add phase logging) +- `packages/core/src/postgres/startup-factory.ts` (optional substep logs) +- `packages/engine/src/project-engine.ts` and/or `packages/engine/src/runtimes/in-process-runtime.ts` (block timings) +- `packages/cli/src/commands/__tests__/dashboard.test.ts` or a small new helper test if extracting `phaseTime` +- Prefer extracting a tiny shared helper under `packages/cli/src/` only if both commands can import without cycles + +**Approach:** + +- Keep cheap wall-clock logs (`startup phase