## Summary Bundled plugins now persist shared runtime state in project-scoped PostgreSQL tables instead of maintaining independent SQLite authority. Reports, CLI Printing Press, Compound Engineering, Roadmap, Even Realities, and WhatsApp all follow the same ownership and startup contract as Fusion core. ## Design decisions - Plugin schema hooks run through the host’s PostgreSQL owner and enforce project isolation. - The SDK exposes the host contract needed by bundled plugins without importing engine internals. - Legacy Roadmap ownership fixtures use the supported empty-owner sentinel, preserving current composite primary/foreign keys while exercising backfill behavior. - The lockfile travels with the Even Realities PostgreSQL dependency so packaged installs remain reproducible. ## Validation - All six affected plugin builds pass. - Affected plugin suites pass: 773 tests across Printing Press, Compound Engineering, Even Realities, Reports, Roadmap, and WhatsApp. - `pnpm test:gate` passes all 478 gate tests. - This PR changes 40 files. ## Stack - Depends on #2110 → #2109 → #2108. - The documentation/release PR completes the stack. Related: #2105 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Breaking Changes** * PostgreSQL is now required for runtime storage; SQLite files are used only as one-time migration inputs. * The legacy `FUSION_NO_EMBEDDED_PG` fallback has been removed. * **New Features** * Added project-isolated PostgreSQL storage for plugins, reports, tasks, notifications, and other plugin data. * Added agent tools for reports and CLI service drafts. * Added PostgreSQL schema initialization support for plugin authors. * **Bug Fixes** * Improved migration and recovery of legacy plugin state. * Prevented cross-project data access and strengthened transactional schema updates. * **Documentation** * Updated storage, migration, deployment, plugin authoring, CLI, and dashboard guidance for PostgreSQL. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
10 KiB
Dashboard Load Performance Analysis
Historical pre-cutover analysis: the SQLite query plans below do not describe the current PostgreSQL runtime. Retained for performance archaeology and regression context.
Date: 2026-04-10 Task: FN-1532
Executive Summary
Profile analysis identified several SQLite query performance issues in the dashboard boot path. These issues cause unnecessary full table scans and temp B-tree sorts that slow dashboard load times, especially as project data grows.
Methodology
-
Created a test database with representative data:
- 100 tasks
- 50 AI sessions
- 200 activity log entries
- 100 run audit events
-
Analyzed query plans using
EXPLAIN QUERY PLANfor boot-critical read operations
Query Plan Analysis Results
Issue 1: Tasks List with ORDER BY (CRITICAL)
Query: SELECT * FROM tasks ORDER BY createdAt ASC
Before:
SCAN tasks
USE TEMP B-TREE FOR ORDER BY
Problem: Full table scan with in-memory sorting. As the tasks table grows, this becomes increasingly expensive.
Solution: Add idxTasksCreatedAt on tasks(createdAt) - allows index scan with ordered retrieval.
Issue 2: AI Sessions Active List (HIGH)
Query: SELECT ... FROM ai_sessions WHERE status IN ('generating', 'awaiting_input', 'error') ORDER BY updatedAt DESC
Before:
SEARCH ai_sessions USING INDEX idxAiSessionsStatus (status=?)
USE TEMP B-TREE FOR ORDER BY
Problem: The existing idxAiSessionsStatus only indexes on status, but the query also needs updatedAt for ordering. This requires a temp B-tree to sort results.
Solution: Add idxAiSessionsStatusUpdatedAt on ai_sessions(status, updatedAt DESC) - covers both filtering and sorting.
Issue 3: Activity Log by Task ID (MEDIUM)
Query: SELECT * FROM activityLog WHERE taskId = ? ORDER BY timestamp DESC
Before:
SEARCH activityLog USING INDEX idxActivityLogTaskId (taskId=?)
USE TEMP B-TREE FOR ORDER BY
Problem: The existing idxActivityLogTaskId only indexes taskId, but the query also needs timestamp for ordering.
Solution: Add idxActivityLogTaskIdTimestamp on activityLog(taskId, timestamp DESC) - covers both filtering and ordering.
Issue 4: Activity Log by Type (MEDIUM)
Query: SELECT * FROM activityLog WHERE type = ? ORDER BY timestamp DESC
Before:
SEARCH activityLog USING INDEX idxActivityLogType (type=?)
USE TEMP B-TREE FOR ORDER BY
Problem: Similar to task ID - the type index doesn't include timestamp for ordering.
Solution: Add idxActivityLogTypeTimestamp on activityLog(type, timestamp DESC) - covers both filtering and ordering.
Issue 5: Agent Heartbeats List (MEDIUM)
Query: SELECT * FROM agentHeartbeats WHERE agentId = ? ORDER BY timestamp DESC
Before:
SEARCH agentHeartbeats USING INDEX idxAgentHeartbeatsAgentId (agentId=?)
USE TEMP B-TREE FOR ORDER BY
Problem: The agent heartbeat index doesn't include timestamp for ordering.
Solution: Add idxAgentHeartbeatsAgentIdTimestamp on agentHeartbeats(agentId, timestamp DESC) - covers both filtering and ordering.
Issue 6: Agents by State (LOW)
Query: SELECT * FROM agents WHERE state = 'idle'
Before:
SCAN agents
Problem: Full table scan on agents table. This affects any dashboard views that filter agents by state.
Solution: Add idxAgentsState on agents(state) - allows index-based lookup for state filtering.
Proposed Index Changes
| Index Name | Table | Columns | Purpose |
|---|---|---|---|
idxTasksCreatedAt |
tasks | createdAt |
Avoid temp B-tree for ORDER BY createdAt |
idxAiSessionsStatusUpdatedAt |
ai_sessions | status, updatedAt DESC |
Cover status filter + updatedAt ordering |
idxActivityLogTaskIdTimestamp |
activityLog | taskId, timestamp DESC |
Cover taskId filter + timestamp ordering |
idxActivityLogTypeTimestamp |
activityLog | type, timestamp DESC |
Cover type filter + timestamp ordering |
idxAgentHeartbeatsAgentIdTimestamp |
agentHeartbeats | agentId, timestamp DESC |
Cover agentId filter + timestamp ordering |
idxAgentsState |
agents | state |
Avoid full scan for state filtering |
Impact Assessment
- Boot time improvement: The
listTasksindex is the highest impact - it eliminates a full table scan + sort on every dashboard load - AI sessions: Eliminates sort overhead on every session list refresh
- Activity log: Eliminates sort overhead on task detail views
- Agents: Eliminates full scan on agent list filtering
Files Modified
packages/core/src/db.ts- Add migration for new indexespackages/core/src/db.test.ts- Update expected index listpackages/core/src/run-audit.test.ts- Update expected index listpackages/core/src/__tests__/task-documents.test.ts- Update expected index list
Dashboard Card Rendering Performance (FN-1544)
Date: 2026-04-10 Task: FN-1544
Executive Summary
Performance analysis identified that TaskCard components triggered expensive network requests (session-files and diff endpoints) eagerly on initial render, causing sluggish board/list views with large task sets. Additionally, the memo comparator used high-cost JSON.stringify on attachments and comments arrays.
Issues Identified
Issue 1: Eager Session Files Fetching (HIGH)
Problem: Every TaskCard unconditionally called useSessionFiles hook, which fetches from /api/tasks/:id/session-files. With many cards visible or nearly-visible, this created a flood of network requests on initial render.
Solution: Added an enabled parameter to useSessionFiles (default true for backward compatibility). The hook returns stable empty state when disabled without triggering fetches.
Issue 2: Eager Diff Stats Fetching (HIGH)
Problem: Every TaskCard for done tasks unconditionally called useTaskDiffStats hook, which fetches from /api/tasks/:id/diff. This caused repeated fetches during rerenders.
Solution:
- Added an
enabledparameter touseTaskDiffStats(defaulttruefor backward compatibility) - Added short-lived in-memory caching (30-second TTL) keyed by
taskId:projectIdto avoid repeated fetches during rerenders - Cache hits return immediately without loading flicker
Issue 3: High-Cost Memo Comparison (MEDIUM)
Problem: areTaskCardPropsEqual used JSON.stringify(attachments) and JSON.stringify(comments) for comparison. For tasks with many attachments or comments, this serialized entire arrays on every render cycle.
Solution: Replaced JSON.stringify with lightweight field-by-field comparison functions:
areAttachmentsEqual()- compares attachment counts and metadata fields (filename, mimeType, size)areCommentsEqual()- compares comment counts and metadata fields (author, content, createdAt)
Mitigation Pattern: Viewport-Gated Card Metadata Loading
The key mitigation uses the existing isInViewport state with a 200px margin:
// TaskCard.tsx - Hook calls are gated on viewport visibility
const { files: sessionFiles, loading: sessionFilesLoading } = useSessionFiles(
task.id,
task.worktree,
task.column,
projectId,
{ enabled: isInViewport }, // Only fetch when card is visible
);
const { stats: diffStats } = useTaskDiffStats(
task.id,
task.column,
task.mergeDetails?.commitSha,
projectId,
{ enabled: isInViewport }, // Only fetch when card is visible
);
Benefits:
- Offscreen cards don't trigger fetches
- Cards entering viewport trigger fetches as expected
- No new polling loops or background timers
- Preserves existing card behaviors (badges, file counts, drag/drop, etc.)
Cache Implementation Details
Startup slim listTasks memoization (FN-4027)
A short-lived startup memo now collapses duplicate listTasks({ slim: true }) reads that happen during boot choreography (watch cache warmup, scheduler PR hydration, worktree pool scan, and dashboard badge priming).
- Scope: startup-only slim reads keyed by
includeArchived+column - Safety: memo entries expire quickly (2.5s TTL) and are explicitly cleared when
watch()handoff completes - Freshness: steady-state polling/watch reads bypass the memo, so normal runtime updates are not served from stale startup data
- Mutation safety: memoized responses are cloned before return so callers cannot poison shared cached objects
useTaskDiffStats Cache
const diffStatsCache = new Map<string, { stats: DiffStats; expiresAt: number }>();
const CACHE_TTL_MS = 30_000; // 30 seconds
- Key format:
"taskId:projectId" - Entries expire after TTL to ensure freshness
- Cache is checked before initiating fetch - returns immediately on hit
- Export
__test_clearDiffStatsCache()for testing
Files Modified
packages/dashboard/app/hooks/useSessionFiles.ts- Addedenabledoptionpackages/dashboard/app/hooks/useTaskDiffStats.ts- Addedenabledoption and cachingpackages/dashboard/app/hooks/__tests__/useSessionFiles.test.ts- Tests forenabledoptionpackages/dashboard/app/hooks/__tests__/useTaskDiffStats.test.ts- Tests forenabledoption and cachingpackages/dashboard/app/components/TaskCard.tsx- Viewport-gated hook calls, lightweight memo comparisonpackages/dashboard/app/components/TaskCard.test.tsx- Fixed pre-existing test expectations
Impact Assessment
- Initial render: Significant reduction in network requests as offscreen cards don't fetch
- Rerenders: Reduced CPU usage from eliminating
JSON.stringifyon large arrays - Cache efficiency: Repeated renders of the same task use cached diff stats instead of refetching
- User experience: Board/list views feel more responsive, especially with many tasks
Key Learnings
- Viewport gating is effective - Using IntersectionObserver with a margin lets us fetch just-in-time without visible delay
- Caching with TTL prevents staleness - 30-second cache balances freshness with reduced network overhead
- Lightweight comparisons outperform serialization - Field-by-field comparison is O(n) vs JSON.stringify's O(n) + allocation overhead