feat: migrate storage from SQLite to PostgreSQL (#1793)
# Migrate storage from SQLite to PostgreSQL — full dashboard cutover Migrates Fusion's storage layer to the embedded PostgreSQL `AsyncDataLayer` (the default backend) and **completes the satellite-store + feature cutover** so every dashboard and Command Center surface works in PG mode. ## Status — every surface works in embedded-PG mode Verified live against a running embedded-Postgres dashboard (all **200**, zero 5xx) and gate-tested (**23 files / 99 tests** on embedded PG, plus engine-core 294 and ci-shape 63 in the blocking merge gate; core/engine/cli/dashboard typecheck clean). | Area | Surfaces | State | |---|---|---| | Satellite stores | workflows, todos, insights, research, missions, goals, mailbox | ✅ | | Views | artifacts, documents, evals | ✅ | | Command Center | activity, productivity, team, tokens, tools, **workflows**, **github**, **signals**, **plugin-activations**, **live** (all 10) | ✅ | | Run execution | insight generation, research run execution | ✅ (store-path; AI step needs a provider) | | Live updates | SSE push for mission/research/insight events | ✅ | | Workflow editing | create / update / delete / select (+ id counter) | ✅ | | Engine | mission autopilot, incident-signal ingestion, regression storm-guard, agent wake-on-message | ✅ | | Core | tasks, agents, secrets, automations, memory, chat, usage, PRs, git | ✅ | ## Approach Each satellite store gets an `Async<Store>` wrapper exposing the sync store's method names over the existing `async-*-store.ts` helpers; `get<Store>Store()` returns a `Sync | Async` union; consumers `await` (harmless on sync), and engine/CLI paths that can't convert use `instanceof Sync` graceful fallback. Analytics aggregators branch on `"ping" in dbOrLayer` to run schema-qualified raw SQL over `project.*` (snake_case) in PG. Executors/orchestrators/autopilot are await-converted to drive the union store; the async store wrappers extend `EventEmitter` so SSE live-push fires in both backends. Not-yet-ported capabilities degrade gracefully (never 500) and are individually called out in commits. ## Sync with main The branch is kept continuously merged with `main` (currently through FN-7845, 2026-07-12); the earlier "final rebase deferred" note no longer applies. Use **Create a merge commit** (or squash) to land it — GitHub's rebase-merge cannot replay a merge-maintained branch. ## Residual Review Findings Multi-agent code review of the PostgreSQL satellite-store ports (U1–U5) applied 3 safe fixes (see `fix(review): apply autofix feedback`). The following are **real but gated** — recorded here as follow-up work rather than auto-applied. All are SQLite→PostgreSQL **concurrency/atomicity regressions**: the sync stores were immune only by SQLite's single-writer, single-threaded-handler execution; the async ports open multi-await read-modify-write windows. **Reachability is low today** because the execution engines that generate concurrent same-run mutations (insight run executor, research orchestrator/dispatcher) are `instanceof`-gated to sync mode in PG. No process-crash class survived (all engine fallbacks correctly guard the sync store). - **[P1] Research `appendResearchEvent` dual-write is non-atomic** (`packages/core/src/async-research-store.ts`, corroborated: adversarial + reliability). The `research_run_events` insert (own transaction) and the `run.events` jsonb update are separate writes — a crash between them, or two concurrent appends, splits the table count from the jsonb array. **Fix:** perform the seq-insert and the jsonb update in one `layer.transactionImmediate`. - **[P1] Research run terminal-reversion via stale full-row persist** (`async-research-store.ts` `persistResearchRun`/`updateResearchStatus`). Concurrent `PATCH /runs/:id/status` + `POST /runs/:id/events` can revert a terminal run to `running` by overwriting the whole row, bypassing the transition guard. **Fix:** scoped column `UPDATE`s with a `WHERE status …` guard, or optimistic version column. - **[P2] `updateResearchRun`/`updateInsightRun` read-then-write TOCTOU** — concurrent PATCHes last-writer-wins on the lifecycle merge. **Fix:** `SELECT … FOR UPDATE` / enclosing transaction. - **[P2] `upsertRun`/`createRunOrThrowConflict` check-then-create race** (`async-insight-store.ts`) — two callers can each create an "active" run. **Fix:** partial unique index on `(projectId, trigger) WHERE status IN ('pending','running')`. - **[P3] `createResearchRetryRun` return-value divergence** — sync returns the pre-update `queued` snapshot; async returns the reloaded `retry_waiting` run (persisted state is identical). Pick one side for cross-backend parity. - **[P2/perf] Mission `getMissionWithHierarchy`/`getMissionHealth` N+1 fan-out** — O(milestones×slices) sequential round-trips hold one pool slot per request; can starve the pool for large hierarchies. **Fix:** batched/joined reads. - **Testing gaps:** no PG-mode concurrency tests (interleaved status/event mutations), no sync↔async parity assertion for the lifecycle-error codes, and no mission status/health rollup parity test vs the sync `MissionStore`. ~~Out of scope (deferred): AI run *execution* (insight/research) + mission autopilot + live SSE mission events remain sync-gated/degraded in PG mode.~~ **Since ported** — insight/research run execution, mission autopilot, and SSE live push all run on the async layer now, which also makes the concurrency findings above genuinely reachable; they remain open follow-ups. --- ## Update — 2026-07-12: production-readiness hardening & live acceptance Everything below landed on this branch since the description above was written: **Production blockers from review — fixed** - `recoverStaleTransitionPending` ported to the async layer (backend moves write + clear the crash-safe marker; startup/maintenance sweeps no longer throw). - Lost-update class fixed: `atomicWriteTaskJson`/`WithAudit` write changed columns only (full-row upserts silently resurrected stale fields across concurrent store instances — the "task stuck unplanned forever" bug). - First-boot **auto-migration**: booting the PG backend over a project with a legacy `fusion.db` migrates it automatically (loud failure, SQLite kept as backup), and the dashboard shows a one-time **"your data was migrated" banner** with the backup paths and a Need-help Discord link. - `pg_dump`/`pg_restore` discovered from common install locations for embedded-mode backups. - The PG suite is part of the blocking merge gate (`test:pg-gate`). **Multi-project isolation (PR #2007, merged into this branch)** - `project_id` partition key on tasks / archived tasks / config, `taskProjectScope` threaded through every scan/claim/count, per-project config rows, layer bound to the project at startup. - Review P1 follow-up: the shared cold-storage `archive.archived_tasks` table is also partitioned and all archived-board reads/counts/searches are scoped. - Schema drift self-heal generalized to schema-qualified columns so existing databases upgrade in place. **Other changes** - Node settings sync **removed** in PG mode (409 `settings-sync-disabled-postgres`) — nodes share state by connecting to the same database; auth sync kept (per-machine file). - Perf (review findings): `listTasks` pushes column filter + ORDER BY + LIMIT/OFFSET into SQL; `getConversation` capped to the most recent 200 messages. - Fixed a false "operator action required" pause-abort log fired on every successfully auto-merged task. **Live acceptance — PASSED (2026-07-12)** A sandboxed instance (isolated HOME, embedded PG, real Opus executor) ran a task through the complete cycle: create → triage (AI spec) → execute → in-review → AI squash-merge landed on the project's `main` → done. A write+read sweep of every data surface (settings, comments, documents, attachments + artifact bridge + artifact edit, chat with real generation, goals, missions, agent mail, secrets, workflows, memory, CC analytics) was green on embedded PG. **Known remaining work** - The per-project `config` PK re-key has no upgrade path for pre-isolation embedded-PG databases (needs a real `DROP CONSTRAINT`/re-key migration; fresh databases are fine). - `pg_dump`/`pg_restore` binaries are not yet bundled in release artifacts (PATH/common-location discovery only). - The satellite-store concurrency findings listed above. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Phil Larson <hello@phillarson.xyz> Co-authored-by: fusion-merge <fusion-merge@local>
This commit is contained in:
8
.changeset/embedded-postgres-lifecycle.md
Normal file
8
.changeset/embedded-postgres-lifecycle.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Bundle embedded PostgreSQL for zero-system-install local storage when DATABASE_URL is unset.
|
||||
category: feature
|
||||
dev: Adds `embedded-postgres` lifecycle manager (initdb/pg_ctl start/stop, graceful SIGTERM/SIGINT shutdown, data persistence across restarts). Platform binaries bundled for macOS/Linux/Windows arm64/x64. Used by `createTaskStoreForBackend` when DATABASE_URL is unset.
|
||||
|
||||
7
.changeset/embedded-postgres-macos-dylib-links.md
Normal file
7
.changeset/embedded-postgres-macos-dylib-links.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Repair macOS embedded PostgreSQL dylib compatibility links before startup.
|
||||
category: fix
|
||||
dev: Adds an idempotent embedded-postgres macOS preflight that creates missing ABI-name symlinks such as `libpq.5.dylib` and `libzstd.1.dylib` from bundled versioned dylibs before `initdb`/`postgres` spawn, fixing zero-config startup when package symlink hydration is absent or incomplete.
|
||||
7
.changeset/fix-agent-runs-route-backend-agentstore.md
Normal file
7
.changeset/fix-agent-runs-route-backend-agentstore.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix manual agent-run creation failing on PostgreSQL when a heartbeat executor is attached.
|
||||
category: fix
|
||||
dev: POST /api/agents/:id/runs built its AgentStore without the scoped store's AsyncDataLayer on the heartbeat-executor branch, hitting the removed SQLite runtime in backend mode; it now borrows the layer like the record-only branch.
|
||||
7
.changeset/flip-embedded-pg-default.md
Normal file
7
.changeset/flip-embedded-pg-default.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Default local backend is now embedded PostgreSQL; set FUSION_NO_EMBEDDED_PG=1 for legacy SQLite.
|
||||
category: feature
|
||||
dev: `createTaskStoreForBackend` now boots embedded PostgreSQL by default when DATABASE_URL is unset (previously required FUSION_EMBEDDED_PG=1). FUSION_EMBEDDED_PG=1 is now a no-op alias; FUSION_NO_EMBEDDED_PG=1 is the opt-out back to legacy SQLite. `embedded-postgres` is now a direct dependency of @runfusion/fusion so the bundled CLI can resolve the platform binary at runtime. Boot smoke exercises the embedded path by default (initdb-aware 180s health timeout). Also hardens three backend-mode gaps the flip exposed: ResearchStore/insights router/watch() now degrade gracefully instead of crashing `fn serve` when the sync SQLite satellite stores are unavailable in PG backend mode.
|
||||
7
.changeset/pause-abort-done-false-alarm.md
Normal file
7
.changeset/pause-abort-done-false-alarm.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Stop logging a false "operator action required" pause-abort failure on tasks that already merged and completed.
|
||||
category: fix
|
||||
dev: handleGraphFailure's operator-action sink now classifies pause-aborts on done/archived tasks as benign (marker cleared, worktree slot released, no PAUSE_ABORT_PARK log) — the merge boundary's in-progress→in-review hard-cancel fired it on every successful auto-merge.
|
||||
7
.changeset/pg-artifacts-documents-evals.md
Normal file
7
.changeset/pg-artifacts-documents-evals.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix Artifacts, Documents, and Evals dashboard views returning 500 in PostgreSQL mode.
|
||||
category: fix
|
||||
dev: listArtifactsImpl/getAllDocumentsImpl now branch on store.backendMode and delegate to AsyncDataLayer helpers (listArtifacts/getAllDocuments in async-comments-attachments.ts); getEvalStore() returns a new AsyncEvalStore (async-eval-store.ts) in backend mode. evals-routes await the store calls; eval-automation/eval-followups handle the EvalStore | AsyncEvalStore union (instanceof guard / await).
|
||||
7
.changeset/pg-cli-agent-tools-backend.md
Normal file
7
.changeset/pg-cli-agent-tools-backend.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: CLI agent tools now boot PostgreSQL instead of the removed SQLite runtime.
|
||||
category: fix
|
||||
dev: The extension's getStore(cwd) path constructed a legacy SQLite TaskStore (runtime removed under VAL-REMOVAL-005), and the fn_agent_* tools constructed AgentStore without an asyncLayer — both threw "SQLite Database class body has been removed" in PG mode. getStore now routes through createTaskStoreForBackend (mirroring fn serve) and caches the boot result for deterministic shutdown; a new getAgentStore(cwd) helper injects the project store's asyncLayer into AgentStore so agent data lives in PostgreSQL. CLI extension tests were migrated to a shared PG harness (pg-extension-harness.ts) backed by an isolated test database with a test-only store-injection hook (__setCachedStoreForTesting).
|
||||
7
.changeset/pg-command-center-analytics.md
Normal file
7
.changeset/pg-command-center-analytics.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Command Center productivity, team, token, and tool analytics work on the PostgreSQL backend.
|
||||
category: feature
|
||||
dev: Ports aggregateProductivityAnalytics/aggregateTeamAnalytics/aggregateTokenAnalytics/aggregateToolAnalytics to accept Database | AsyncDataLayer, adding a PG branch ("ping" in dbOrLayer) that runs schema-qualified raw SQL over project.tasks/task_commit_associations/pull_requests/agents/usage_events/approval_request_audit_events with snake_case columns and the same aggregation semantics as the SQLite path. The command-center tokens/tools/productivity/team routes pass getAsyncLayer() ?? getDatabase() and await; the interim 503 guards are removed. GitHub-issue, signal, and live-snapshot analytics remain 503 in PG mode (follow-up). Adds command-center-analytics.pg.test.ts to test:pg-gate.
|
||||
7
.changeset/pg-command-center-remaining-analytics.md
Normal file
7
.changeset/pg-command-center-remaining-analytics.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Command Center workflow, GitHub-issue, signal, and live-snapshot analytics now work on the PostgreSQL backend.
|
||||
category: feature
|
||||
dev: Ports aggregateWorkflowAnalytics/aggregateGithubIssueAnalytics/aggregateSignalsAnalytics/composeLiveSnapshot to accept Database | AsyncDataLayer, adding a PG branch ("ping" in dbOrLayer) that runs schema-qualified raw SQL over project.tasks/task_workflow_selection/workflows/incidents/cli_sessions/agent_runs with snake_case columns and the same aggregation semantics as the SQLite path. The command-center workflows/github/signals/live routes pass getAsyncLayer() ?? getDatabase() and await; the interim 503 guards are removed. Every /api/command-center/* route now functions in backend mode. Adds command-center-remaining-analytics.pg.test.ts to test:pg-gate.
|
||||
7
.changeset/pg-cutover-remaining-surfaces.md
Normal file
7
.changeset/pg-cutover-remaining-surfaces.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Standalone CLI, GitLab analytics, and plugin stores now run on PostgreSQL.
|
||||
category: fix
|
||||
dev: Orchestrated audit + fix of remaining un-migrated SQLite surfaces. CLI: project-context/project-resolver + the fn task/agent/git/research/settings/desktop/experiment commands now boot via createTaskStoreForBackend and inject asyncLayer into AgentStore; fn_agent_update/fn_mission_list/mission-list gained backendMode branches. Core: 15 task-store Impls (merge-request record, commit-association upsert/read, stale-branch cleanup, run-audit-events read, task-document delete/revisions, github-tracking reconcile, activity/run-audit snapshots, occupants, stranded-refinements, orphaned-task-dir reconcile) gained backendMode branches (8 real Drizzle, 7 graceful sync-safe-defaults following the getTaskWorkflowSelection precedent); AgentStore gained backendMode branches for 9 snapshot/blocked-state/config-revision methods. Dashboard: GitLab analytics gained an async variant (aggregateGitlabIssueAnalyticsAsync) + the agent-token-totals + OTLP exporter paths now use the async layer. Plugins (compound-engineering pipeline-store, reports, cli-printing-press) gained isBackendMode degrade-guards. Added the missing project.chat_token_usage PG table (schema + migration + registry + created_at index) that the upstream merge referenced but never defined. Merge gate green (engine-core 287 + core pg-gate 94 + ci-shape 63); all packages typecheck clean.
|
||||
7
.changeset/pg-fork-review-fixes.md
Normal file
7
.changeset/pg-fork-review-fixes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Root project-scoped PostgreSQL stores and merges at the project directory, and fix backend-mode agent watching.
|
||||
category: fix
|
||||
dev: createTaskStoreForBackend honors an explicit rootDir over projectId re-resolution (stale bootstrap PROMPT.md pinned cards "unplanned"); drainMergeQueue roots git operations at store.getRootDir() (merges aborted with branch-missing in in-process dashboards); AgentStore.startWatching no longer trips the sqlite getLastModified gate in backend mode.
|
||||
6
.changeset/pg-full-suite-fixes.md
Normal file
6
.changeset/pg-full-suite-fixes.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
summary: Fix post-insert task rollback and add GitLab tracking reconcile.
|
||||
category: fix
|
||||
dev: Adds a catch/cleanup around `_createTaskInternalBackendImpl` post-insert filesystem work so a writeTaskJsonFile or prompt-validation failure soft-deletes the inserted row (FN-7074 invariant). Adds `listTasksForGitlabTrackingReconcile` TaskStore facade mirroring the GitHub counterpart.
|
||||
7
.changeset/pg-goal-store-port.md
Normal file
7
.changeset/pg-goal-store-port.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Goals work on the PostgreSQL backend — the Goals view and mission goal-links load instead of erroring.
|
||||
category: feature
|
||||
dev: Ports GoalStore to the AsyncDataLayer. Adds AsyncGoalStore (over the existing async-goal-store.ts helpers; ACTIVE_GOAL_LIMIT enforced atomically in the helpers' transactionImmediate, same as sync). getGoalStoreImpl returns it in backend mode; the dashboard /api/goals routes await it and the interim 503 is removed. Reverts the PG-mode goal-resolution degradations added earlier — mission routes and `fn mission` now resolve/validate real linked goals on both backends. CLI goals/mission/extension and engine agent-tools converted to await; goal-injection-diagnostics stays on its instanceof-guarded sync fallback. Adds goal-store.pg.test.ts to test:pg-gate.
|
||||
7
.changeset/pg-insight-run-execution.md
Normal file
7
.changeset/pg-insight-run-execution.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Generating insights works on the PostgreSQL backend — the insight run executor and stale-run sweeper run in PG mode.
|
||||
category: feature
|
||||
dev: Await-converts the insight run executor (insight-run-executor.ts) and the stale-run sweeper (insight-run-sweeper.ts) and widens their store type to InsightStore | AsyncInsightStore, so POST /api/insights/run and /runs/:id/retry drive the async store instead of throwing 503 (getSyncInsightStore removed). The startup/background/drive-by sweeper is now enabled for both backends. The AI extraction step still needs a configured provider at runtime; a run without one records a clean failed run rather than 503. Adds insight-run-execution.pg.test.ts (create→complete, create→fail, retry-with-lineage against embedded PG) to test:pg-gate.
|
||||
7
.changeset/pg-insight-store-port.md
Normal file
7
.changeset/pg-insight-store-port.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Insights work on the PostgreSQL backend — the Insights dashboard loads instead of erroring.
|
||||
category: feature
|
||||
dev: Ports InsightStore to the AsyncDataLayer. Adds AsyncInsightStore (wrapping async-insight-store.ts helpers, incl. 6 new helpers — updateInsight, updateInsightRun [faithful run-lifecycle state machine: terminal-immutable, transition validation, auto completed/cancelled timestamps], listInsightRunEvents, countInsights, countInsightRuns, listStalePendingRuns); getInsightStoreImpl returns it in backend mode; dashboard insights routes await it and the interim 503 is removed for the read/write/cancel surface. The 3 engine reporters stay on graceful fallback (instanceof-gated). Known partial: AI insight-run generation/retry (POST /run, /runs/:id/retry) and the stale-run sweeper remain sync-only and still 503 in PG mode until the run executor is ported. Adds insight-store.pg.test.ts to test:pg-gate.
|
||||
7
.changeset/pg-mailbox-send-fix.md
Normal file
7
.changeset/pg-mailbox-send-fix.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Mailbox — sending a message to an agent works in PG mode instead of erroring.
|
||||
category: fix
|
||||
dev: POST /api/messages to an agent 500'd in embedded-PG mode: MessageStore.sendMessage persisted the message via the async layer, then synchronously invoked the agent-delivery hook (agent-heartbeat.handleMessageToAgent), which reads the not-yet-ported sync AgentStore and throws. The persisted send must not fail on a notification side-effect, so the onMessageToAgent hook call is now wrapped — a hook failure logs and degrades (agent wake-on-message stays disabled in PG mode until AgentStore is ported) instead of failing the send. Adds message-store.pg.test.ts to test:pg-gate.
|
||||
7
.changeset/pg-migration-banner.md
Normal file
7
.changeset/pg-migration-banner.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Dashboard banner after SQLite auto-migration to PostgreSQL with backup location and help link.
|
||||
category: feature
|
||||
dev: startup-factory persists settings.sqliteMigrationNotice (migratedAt/rows/tables/sqliteBackups) after a successful first-boot auto-migration; SqliteMigrationBanner renders it once, dismiss persists dismissed:true via PUT /settings. Auto-migration now also stamps archive.archived_tasks.project_id.
|
||||
7
.changeset/pg-mission-autopilot.md
Normal file
7
.changeset/pg-mission-autopilot.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Mission autopilot runs on the PostgreSQL backend — missions advance automatically instead of autopilot being disabled.
|
||||
category: feature
|
||||
dev: Await-converts MissionAutopilot to drive MissionStore | AsyncMissionStore (every this.missionStore.* call awaited; watchMission/unwatchMission/getAutopilotStatus and helpers async) and removes the instanceof MissionStore gates in InProcessRuntime (construction + recover paths) so the autopilot loop watches/recomputes/recovers in both backends. Slice execution + validator-loop methods stay scheduler-gated (degrade gracefully in PG). getAutopilotStatus async ripples through mission-routes/server. Adds mission-autopilot.pg.test.ts to test:pg-gate.
|
||||
7
.changeset/pg-mission-store-port.md
Normal file
7
.changeset/pg-mission-store-port.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Missions work on the PostgreSQL backend — the Missions dashboard and goal→mission links load instead of erroring.
|
||||
category: feature
|
||||
dev: Ports MissionStore (dashboard surface) to the AsyncDataLayer. Adds AsyncMissionStore (63 methods over the 71 existing async helpers + 8 new primitives), assembling the composites (getMissionWithHierarchy, listMissionsWithSummaries, mission/milestone health rollups, computeMissionStatus + the feature→slice→milestone→mission recompute cascade, triageFeature, getFeatureLoopSnapshot) by mirroring the sync store. getMissionStoreImpl returns it in backend mode; mission-routes + goal→mission routes await it and the interim 503 is removed (the GoalStore 503 stays — GoalStore is still deferred). Mission AUTOPILOT, live SSE mission events, mesh hierarchy snapshot apply/collect, and engine validator-loop methods stay degraded in PG mode behind instanceof guards. Also fixes the mission-create path which resolved linked goals via the unported sync GoalStore: goal resolution now degrades to empty in backend mode (links live in MissionStore; full Goal objects return once GoalStore is ported). Adds mission-store.pg.test.ts to test:pg-gate.
|
||||
7
.changeset/pg-mode-route-degradation.md
Normal file
7
.changeset/pg-mode-route-degradation.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Not-yet-ported features (missions, insights, research, goals) degrade cleanly in PG mode instead of erroring.
|
||||
category: fix
|
||||
dev: Adds backendMode guards to the dashboard route choke-points that call satellite stores not yet on the AsyncDataLayer (getResearchStore/getInsightStore/getMissionStore/getGoalStore). They now return HTTP 503 "not yet available in PG backend mode" (matching the existing command-center team/productivity/token guards) instead of letting the store getter throw an unhandled 500. The SSE handler also degrades: ResearchStore access is wrapped so the event stream still serves every other event type instead of failing the whole connection when research run-events cannot be subscribed in PG mode. Full PG ports of these stores remain (TodoStore is done); these guards are the correct interim state until each lands.
|
||||
7
.changeset/pg-monitor-trait-agent-wake.md
Normal file
7
.changeset/pg-monitor-trait-agent-wake.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": fix
|
||||
---
|
||||
|
||||
summary: Regression storm-guard and agent wake-on-message work on the PostgreSQL backend.
|
||||
category: fix
|
||||
dev: monitor-trait runMonitorOnRegression drops its backend-mode early return and routes the storm guard (countRecentAutoFixTasksAsync/claimIncidentForFixTaskAsync/attachFixTaskAsync/releaseIncidentFixTaskClaimAsync) through the AsyncDataLayer in PG, preserving the claim→createTask→attach→release semantics. The agent wake hook handleMessageToAgent becomes async and reads via AgentStore.getAgent (async) instead of the sync getCachedAgent that threw in PG; the onMessageToAgent hook type widens to allow a Promise and message-store awaits it inside its existing send-never-fails try/catch.
|
||||
7
.changeset/pg-multi-project-isolation.md
Normal file
7
.changeset/pg-multi-project-isolation.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Isolate projects sharing the embedded PostgreSQL cluster — tasks, config, and archived tasks are scoped per project.
|
||||
category: feature
|
||||
dev: PR #2007 (Approach A) — project_id partition key on project.tasks/project.archived_tasks/archive.archived_tasks with taskProjectScope threaded through every scan/claim/count; per-project config rows; startup factory binds the AsyncDataLayer to options.projectId; drift self-heal generalized to schema-qualified entries; archived-board reads scoped (review P1 fix).
|
||||
7
.changeset/pg-production-readiness-fixes.md
Normal file
7
.changeset/pg-production-readiness-fixes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix PostgreSQL-mode merge recovery, lost task-field writes, first-boot SQLite auto-migration, and backup tool discovery.
|
||||
category: fix
|
||||
dev: recoverStaleTransitionPending ported to backend mode (async-transition-pending.ts); backend moves now write/clear the crash-safe transitionPending marker; atomicWriteTaskJson/WithAudit write changed columns instead of full-row upserts (lost-update class behind stuck "unplanned" cards); createTaskStoreForBackend auto-migrates legacy fusion.db into an empty PG database on first boot (loud failure, SQLite kept as backup); PgBackupManager resolves pg_dump/pg_restore from common install locations when not on PATH.
|
||||
7
.changeset/pg-remove-node-settings-sync.md
Normal file
7
.changeset/pg-remove-node-settings-sync.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Remove node settings sync on the PostgreSQL backend — nodes share the database, so settings are already shared.
|
||||
category: feature
|
||||
dev: In backend mode the mesh sync route ignores inbound settings payloads and returns none; PeerExchangeService force-disables settings gossip; /nodes/:id/settings (fetch/push/pull/sync-status) and /settings/sync-receive answer 409 code settings-sync-disabled-postgres; the NodesView sync hook treats that 409 as a quiet steady state (no chips, no polling). Provider auth sync (/nodes/:id/auth/sync, auth-receive/auth-export) is intentionally kept — auth material is per-machine file state, not database state.
|
||||
7
.changeset/pg-remove-task-mesh-replication.md
Normal file
7
.changeset/pg-remove-task-mesh-replication.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Remove task mesh replication entirely — nodes replicate through the shared PostgreSQL database.
|
||||
category: feature
|
||||
dev: POST /mesh/tasks/create is deleted (with applyReplicatedTaskCreate and the replicated-create payload helpers); /mesh/sync shared-state is reduced to projectSettings (legacy sqlite settings sync only) + authMaterial in both directions, and the task-metadata/mission/agent/agent-run/activity-log/run-audit snapshot machinery is removed from the stores; /mesh/task-ids/* never forwards to a remote coordinator in backend mode (the shared distributed_task_id_state rows are the coordinator). Peer topology exchange unchanged.
|
||||
7
.changeset/pg-research-execution.md
Normal file
7
.changeset/pg-research-execution.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Research runs actually execute on the PostgreSQL backend instead of staying queued forever.
|
||||
category: feature
|
||||
dev: Await-converts the engine ResearchOrchestrator + ResearchRunDispatcher to drive InsightStore | AsyncResearchStore (every this.store.* call awaited; addEvent→appendEvent for union compatibility) and removes the instanceof ResearchStore gate in ProjectEngine.start that disabled the orchestrator/dispatcher in PG mode. Exports AsyncResearchStore from @fusion/core. A queued run now advances queued→running→completed/failed in PG; the AI/web step still needs runtime providers (a run with none fails cleanly). Adds research-execution.pg.test.ts to test:pg-gate.
|
||||
7
.changeset/pg-research-store-port.md
Normal file
7
.changeset/pg-research-store-port.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Research works on the PostgreSQL backend — the Research dashboard loads and runs CRUD instead of erroring.
|
||||
category: feature
|
||||
dev: Ports ResearchStore to the AsyncDataLayer. Adds AsyncResearchStore (12 new helpers incl. faithful replicas of the run-lifecycle state machine — updateResearchStatus per-status auto-lifecycle fields, terminal-immutability, transition validation — and the retry gate/lineage in createResearchRetryRun); getResearchStoreImpl returns it in backend mode; dashboard research routes await it and the interim 503 is removed. AI research EXECUTION (engine ResearchOrchestrator/dispatcher, agent-tools research tools, CLI research run) stays degraded in PG mode behind instanceof guards — same boundary as the insight run executor. Adds research-store.pg.test.ts (13 tests incl. lifecycle machine + retry gate) to test:pg-gate.
|
||||
7
.changeset/pg-review-perf-fixes.md
Normal file
7
.changeset/pg-review-perf-fixes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Speed up board listing and agent chat on PostgreSQL with SQL-side pagination and a conversation history cap.
|
||||
category: performance
|
||||
dev: Closes the two open PR #1793 review findings — readLiveTaskRows now pushes column filters + ORDER BY (created_at, numeric id suffix) + LIMIT/OFFSET into SQL instead of scanning and hydrating the whole task table per listTasks; getConversation is capped to the most recent 200 messages by default (options.limit overrides, oldest-first order preserved) in both the async and sqlite paths.
|
||||
7
.changeset/pg-signal-ingestion.md
Normal file
7
.changeset/pg-signal-ingestion.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": fix
|
||||
---
|
||||
|
||||
summary: Incident-signal ingestion records incidents on the PostgreSQL backend instead of being skipped.
|
||||
category: fix
|
||||
dev: ingestIncidentSignal now accepts Database | AsyncDataLayer and branches to ingestIncidentSignalAsync (project.incidents upsert by grouping key — absorb-or-create, occurrences/firstFiredAt preserved) in PG mode; the signal route awaits it instead of warn-skipping. monitor-trait's storm-guard helpers remain sync-only (async equivalents exist; follow-up).
|
||||
7
.changeset/pg-sse-live-push.md
Normal file
7
.changeset/pg-sse-live-push.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Live dashboard updates (SSE) work on the PostgreSQL backend for missions, research, and insights.
|
||||
category: feature
|
||||
dev: The async store wrappers (AsyncMissionStore/AsyncResearchStore/AsyncInsightStore) now extend EventEmitter and emit the same events as their sync counterparts at the same mutation points (after the persistence await), so the SSE handler's subscriptions fire in PG mode instead of no-op'ing. sse.ts/server.ts drop the instanceof-sync narrowing and subscribe to the union store in both backends. Live push for mission/milestone/slice/feature/assertion/validator-start, research run lifecycle, and insight create/update events. Validator-loop-completed and fix-feature emits remain sync-only (those methods aren't in AsyncMissionStore yet).
|
||||
7
.changeset/pg-workflow-definitions-read.md
Normal file
7
.changeset/pg-workflow-definitions-read.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Workflow definitions load in PG mode — /api/workflows no longer errors.
|
||||
category: fix
|
||||
dev: readAllWorkflowDefinitions/getWorkflowDefinition read custom rows from project.workflows via the AsyncDataLayer in backend mode (the sync store.db SELECT threw, 500'ing /api/workflows). New async-workflow-store.ts helpers re-stringify jsonb ir/layout for the shared toWorkflowDefinition mapper; builtins still come from code constants. Every caller already awaited these reads, so no consumer changes. Adds workflow-definitions.pg.test.ts to test:pg-gate.
|
||||
7
.changeset/pg-workflow-editing.md
Normal file
7
.changeset/pg-workflow-editing.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Creating, editing, and deleting custom workflows works on the PostgreSQL backend.
|
||||
category: feature
|
||||
dev: Completes the workflow-definition write path in PG. Adds a next_workflow_definition_id counter to project.config (schema + 0000_initial.sql baseline) with an async counter (nextWorkflowDefinitionIdAsyncImpl) that preserves project settings on bump; createWorkflowDefinitionImpl gains a backend branch that INSERTs into project.workflows via Drizzle (ir/layout as jsonb objects). Complements the update/delete/select backend branches in workflow-ops.ts. Adds workflow-create.pg.test.ts to test:pg-gate.
|
||||
7
.changeset/postgres-backend-runtime-fixes.md
Normal file
7
.changeset/postgres-backend-runtime-fixes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix PostgreSQL-mode crashes — agent-log flush no longer kills the server, and Command Center activity loads.
|
||||
category: fix
|
||||
dev: The agent-log buffer flush/append path (flushAgentLogBufferImpl, appendAgentLogBatchImpl, appendAgentLogImpl) dereferenced the SQLite-only `store.db` getter — which throws in PG backend mode — on an unref'd retry-timer and inside catch handlers, so a handled flush error became an uncaught exception that exited `fn serve` (~35s uptime). Guarded the deleted-task pre-filter and `bumpLastModified` with `!store.backendMode` and replaced every `store.db.path` log interpolation with the mode-safe `store.fusionDir`. Also schema-qualified raw async SQL that referenced project-schema tables unqualified / with camelCase columns: `project.deployments` + `project.incidents` with snake_case `deployed_at`/`opened_at`/`resolved_at` (the deployments read sat outside the try/catch and 500'd `/api/command-center/activity`), `project.experiment_session_records` (+ `::jsonb` cast on the payload update), and `project.agent_runs`. Adds a backend-mode regression test pinning the no-`store.db`-deref invariant across all three agent-log entry points.
|
||||
7
.changeset/postgres-create-workflow-selection-parity.md
Normal file
7
.changeset/postgres-create-workflow-selection-parity.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix task creation dropping the workflow selection when a workflow and step toggles are submitted together.
|
||||
category: fix
|
||||
dev: PostgreSQL create paths in task-creation.ts predated the SQLite-side FNXC:WorkflowCreation 2026-06-28 fix; they now record task_workflow_selection with explicit stepIds, and serialization.ts hydrates an explicit empty enabledWorkflowSteps as [] (not undefined). Store-integration coverage in builtin-workflows.test.ts ported to the shared PG harness (pgDescribe).
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix custom workflow columns on PostgreSQL: tasks land in their workflow's intake column and can move out of it.
|
||||
category: fix
|
||||
dev: Backend create paths now thread resolvedEntryColumn (workflow manual intake, e.g. Coding (Ideas) "ideas") into task creation and the bootstrap-prompt gate; move validation resolves the task workflow IR via getTaskWorkflowSelectionAsync in backend mode (the sync resolver silently fell back to builtin:coding and rejected every move out of a custom column).
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix residual SQLite store constructions so chat, messages, backups, MCP secrets, and project setup work on PostgreSQL.
|
||||
category: fix
|
||||
dev: Routes remaining `new TaskStore`/`new AgentStore`/`createDatabase` call sites through `createTaskStoreForBackend`/`resolveAgentStoreBase` (chat.ts, message.ts, task.ts, pr.ts, backup.ts, memory-backup.ts, branch-group.ts, mcp.ts, project.ts, dashboard.ts getProjectStore, dashboard register-project-routes). Also fixes cli-printing-press plugin Drizzle row typing.
|
||||
7
.changeset/postgres-perf-and-standards.md
Normal file
7
.changeset/postgres-perf-and-standards.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix PostgreSQL performance and credential-redaction gaps surfaced by the migration review.
|
||||
category: performance
|
||||
dev: Adds missing index on tasks.source_parent_task_id (lineage gate was a full scan) and a partial index for the live kanban `WHERE deleted_at IS NULL AND column = ?` read. Batches merge-queue stale-row cleanup to remove an N+1 on lease acquire. Pushes LIMIT into SQL for audit/activity-log queries. Drops the heavy `log` jsonb column from slim board hydration. Fixes the monitor-store backend discriminator (`"ping" in db`, not the ambiguous `"transactionImmediate" in db`), awaits the now-async resolveIncident in signal routes, and redacts `?password=` query-param URLs.
|
||||
7
.changeset/postgres-slim-listing-stall-signal-parity.md
Normal file
7
.changeset/postgres-slim-listing-stall-signal-parity.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Restore stalled-review badges, timed-execution totals, and fresh-agent-log stall suppression on board listings.
|
||||
category: fix
|
||||
dev: Backend listTasks no longer excludes the log column in slim mode (stalledReview and timedExecutionMs derive from it before the log is stripped), and hasFreshAgentLogActivitySinceTaskUpdate is ported into all task-store read hydration paths so streaming merge/review agents suppress Stalled/Merge-stalled badges (mirrors main's FNXC:WorkflowLifecycle 2026-07-01 behavior lost in the PG cutover store split).
|
||||
7
.changeset/todo-store-postgres-port.md
Normal file
7
.changeset/todo-store-postgres-port.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Todo lists now work on the embedded-PostgreSQL backend instead of erroring.
|
||||
category: feature
|
||||
dev: Ports TodoStore to the AsyncDataLayer. Adds an `AsyncTodoStore` class (in async-todo-store.ts) wrapping the already-tested async CRUD helpers over project.todo_lists/project.todo_items; `getTodoStoreImpl` returns it in backend mode instead of throwing "TodoStore is not available in PG backend mode" (which 500'd every /api/todos route). The dashboard todo routes now await the store methods so the same code path serves both the sync SQLite store and the async PG store. Adds todo-store.pg.test.ts to the blocking test:pg-gate lane. Known gap: the async store does not yet emit list/item events for SSE live-refresh (updates land on next read).
|
||||
21
.github/workflows/full-suite.yml
vendored
21
.github/workflows/full-suite.yml
vendored
@@ -33,6 +33,27 @@ jobs:
|
||||
test-shards:
|
||||
name: Test shard ${{ matrix.shard }}/4
|
||||
runs-on: ubuntu-latest
|
||||
# FNXC:FixPgTestsAndCi 2026-06-26-09:10:
|
||||
# Provision a PostgreSQL service container so the postgres/*.pg.test.ts
|
||||
# suites run in the non-blocking full suite too (parity with the gate).
|
||||
# The pg-test-harness probe skips gracefully if unreachable.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -h localhost -p 5432 -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
FUSION_PG_TEST_URL_BASE: "postgresql://postgres:postgres@localhost:5432"
|
||||
PGPASSWORD: "postgres"
|
||||
# Backstop for a wedged shard. The per-invocation watchdog (L2,
|
||||
# scripts/lib/run-vitest-watchdog.mjs) kills any single hung invocation at
|
||||
# its budget ceiling (<=30min), so this job budget only fires if L2 itself
|
||||
|
||||
27
.github/workflows/pr-checks.yml
vendored
27
.github/workflows/pr-checks.yml
vendored
@@ -83,6 +83,33 @@ jobs:
|
||||
gate:
|
||||
name: Gate
|
||||
runs-on: ubuntu-latest
|
||||
# FNXC:FixPgTestsAndCi 2026-06-26-09:10:
|
||||
# Provision a PostgreSQL service container so the postgres/*.pg.test.ts
|
||||
# suites (pgDescribe) run in the merge gate. The pg-test-harness probe
|
||||
# detects reachability via a TCP probe on localhost:5432 and skips when
|
||||
# unavailable, so this service is what makes the 57 PG twin tests actually
|
||||
# execute instead of being silently skipped.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
# Mark the service healthy only when pg_isready succeeds on the mapped
|
||||
# port, so job steps don't start before Postgres accepts connections.
|
||||
options: >-
|
||||
--health-cmd "pg_isready -h localhost -p 5432 -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
# Point the PG test harness at the service container. psql admin DDL
|
||||
# (CREATE/DROP DATABASE) runs against this URL's maintenance database.
|
||||
FUSION_PG_TEST_URL_BASE: "postgresql://postgres:postgres@localhost:5432"
|
||||
PGPASSWORD: "postgres"
|
||||
# The gate's value is speed; without a job timeout a hung build or
|
||||
# deadlocked vitest worker blocks every PR for GitHub's default 6 hours.
|
||||
# Expected runtime is ~3-5 min.
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
---
|
||||
title: "feat: Migrate storage from SQLite to PostgreSQL (embedded + external)"
|
||||
type: feat
|
||||
date: 2026-06-23
|
||||
---
|
||||
|
||||
# Migrate storage from SQLite to PostgreSQL (embedded + external)
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the SQLite storage layer with PostgreSQL following the Paperclip model: a bundled embedded Postgres binary (npm `embedded-postgres`) provides zero-config local storage, `DATABASE_URL` switches to an external server, and SQLite is removed after a dual-read cutover. The data layer is rewritten on Drizzle ORM (schema-as-code, type-safe), which also forces the entire synchronous `DatabaseSync` data-access surface to become async.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Fusion persists all project, central, and archive state in three SQLite files (`fusion.db`, `fusion-central.db`, `archive.db`) accessed through a synchronous `DatabaseSync` adapter over `node:sqlite`/`bun:sqlite`. This works for single-machine, multi-process use under WAL, but it couples the application tightly to SQLite-specific features (FTS5 + triggers, JSON1 functions, PRAGMAs, `ATTACH DATABASE`, corruption self-healing) and blocks any multi-host or managed-database deployment. The goal is a single PostgreSQL backend that preserves zero-config local operation while enabling an external server, matching the architecture Paperclip (`github.com/paperclipai/paperclip`) uses: embedded Postgres by default, `DATABASE_URL` to point elsewhere.
|
||||
|
||||
The dominant cost is not dialect conversion but the **sync-to-async conversion**: the `DatabaseSync` interface is synchronous and every Postgres client is async, so every database call site across the ~17k-line `store.ts` and ~5.9k-line `db.ts` must become awaited, independent of the query layer.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Backend topology and packaging
|
||||
|
||||
- R1. When `DATABASE_URL` is unset, the application starts an embedded PostgreSQL instance (real Postgres process via `embedded-postgres`) into a local data directory, runs migrations, and serves with no external setup required.
|
||||
- R2. When `DATABASE_URL` is set, the application connects to the specified external PostgreSQL server (local Docker, managed/hosted, or any reachable server) and does not start an embedded instance.
|
||||
- R3. The embedded PostgreSQL binaries are bundled/shipped so `fn` works fully offline with zero system Postgres install on supported platforms (macOS, Linux, Windows; arm64 and x64).
|
||||
- R4. A separate `DATABASE_MIGRATION_URL` is honored for startup schema work when the runtime `DATABASE_URL` uses a transaction-pooling connection (Supavisor/PgBouncer), mirroring the Paperclip split.
|
||||
|
||||
### Data layer
|
||||
|
||||
- R5. All schema is defined as Drizzle ORM code (schema-as-code) and all data access goes through Drizzle against a PostgreSQL backend.
|
||||
- R6. The synchronous `DatabaseSync` data-access surface is replaced with an async data layer; no blocking/synchronous bridge to PostgreSQL remains.
|
||||
- R7. Existing behavioral invariants are preserved through the rewrite: soft-delete visibility (`deletedAt IS NULL` filtering across all live readers), task-ID allocator reconciliation on store open, lineage-integrity gates, document/artifact parent-task scoping, and the handoff-to-review `mergeQueue` transactional invariant.
|
||||
|
||||
### Full-text search
|
||||
|
||||
- R8. The FTS5-backed task and archive search is replaced with PostgreSQL full-text search (`tsvector`/`tsquery`, GIN indexes) preserving search-result parity and the automatic index-sync-on-write behavior that today's FTS5 triggers provide.
|
||||
|
||||
### Migration and compatibility
|
||||
|
||||
- R9. A migration tool moves existing SQLite data (all three databases) into PostgreSQL idempotently and verifiably.
|
||||
- R10. A dual-read cutover period is supported: during transition, SQLite is read-only and PostgreSQL is the write target, so deployments can migrate without downtime windows.
|
||||
- R11. After cutover, SQLite is fully removed (no dual-dialect abstraction retained long-term, no `better-sqlite3`/`node:sqlite`/`bun:sqlite` data-path dependency).
|
||||
|
||||
### Health and maintenance
|
||||
|
||||
- R12. SQLite-specific health and maintenance surfaces are reworked for PostgreSQL: corruption detection (`PRAGMA integrity_check`/`quick_check`) and the startup rebuild-on-malformed guard, compaction (`VACUUM`), WAL checkpointing, and the schema self-heal via `PRAGMA table_info`/fingerprint reconciliation.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Drizzle ORM for the full data-layer rewrite.** User-confirmed. The existing code is ~700KB+ of hand-written SQL against a sync `prepare()` interface with zero ORM; Drizzle gives schema-as-code, type safety, and a migration system. This is a near-total data-layer rewrite rather than a dialect conversion. Adopted over raw-SQL `postgres.js` (which would have preserved the architecture but offered no schema model).
|
||||
|
||||
- **Sync-to-async conversion is mandatory and load-bearing.** The entire data layer is synchronous; every PostgreSQL client is async. Every `db.prepare(sql).get()` call site becomes `await`. Store methods are already `async`, so the boundary exists, but every internal database call must be awaited. This dwarfs all other conversion work and drives sequencing.
|
||||
|
||||
- **Bundle embedded PostgreSQL binaries for zero-config default.** User-confirmed. `embedded-postgres` manages `initdb`/`pg_ctl` lifecycle over platform-specific Postgres binaries (~30-50MB per platform). True offline zero-config like SQLite today, at the cost of heavier distribution and known platform edge cases (WSL2, unprivileged LXC containers, macOS dyld loading) that Paperclip also encounters.
|
||||
|
||||
- **Backend resolution by `DATABASE_URL` (Paperclip model).** Unset = embedded (real Postgres process, supports multiple concurrent connections and thus preserves the existing multi-process access pattern that PGlite/WASM cannot). Set = external server. `DATABASE_MIGRATION_URL` splits schema work off pooled runtime connections.
|
||||
|
||||
- **Snapshot final SQLite schema as the PostgreSQL baseline + fresh Drizzle migrations.** Reimplementing the 128 hand-rolled SQLite migrations (`SCHEMA_VERSION = 128`) in PostgreSQL dialect is pointless for a greenfield Postgres schema. The migration tool materializes the current final schema into PostgreSQL, and Drizzle's migration history starts fresh from that snapshot. The version-gate testing discipline (the institutional learning that fresh-DB tests cannot catch a skipped-on-upgrade migration) is carried forward into the Drizzle migration tests.
|
||||
|
||||
- **Dual-read = SQLite read-only + PostgreSQL write target.** During cutover, writes go to PostgreSQL; reads fall back to SQLite for any path not yet ported or for verification. This is lower-risk than a dual-routing query abstraction and avoids two-writer contention. The institutional learning that two engines race task leases over the shared central SQLite DB is respected: the cutover must not run two writers against SQLite, and PostgreSQL's MVCC structurally removes the single-writer contention.
|
||||
|
||||
- **Three-database topology preserved as PostgreSQL schemas or databases.** The project/central/archive separation is retained (project state, global registry, cold-storage archive), mapping each to a PostgreSQL schema or database rather than collapsing them.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Resolution["Backend resolution (startup)"]
|
||||
D{DATABASE_URL set?}
|
||||
end
|
||||
D -- no --> E[Embedded Postgres lifecycle manager]
|
||||
D -- yes --> X[External Postgres server]
|
||||
E --> EP[initdb if needed<br/>pg_ctl start<br/>local data dir]
|
||||
EP --> CONN
|
||||
X --> CONN
|
||||
CONN[Drizzle connection pool<br/>runtime URL + DATABASE_MIGRATION_URL] --> SCHEMA[Drizzle schema<br/>schema-as-code]
|
||||
SCHEMA --> STORES[Async data layer<br/>store.ts + satellite stores]
|
||||
STORES --> FTS[tsvector/GIN search]
|
||||
STORES --> HEALTH[Postgres health<br/>autovacuum, integrity]
|
||||
MIG[SQLite to Postgres<br/>migration tool] --> SCHEMA
|
||||
DUAL[Dual-read cutover harness<br/>SQLite RO + Postgres RW] --> STORES
|
||||
```
|
||||
|
||||
### Sync-to-async conversion shape
|
||||
|
||||
The current layering is: async store methods (`async createTask`) calling a synchronous DB layer (`this.db.prepare(sql).get()`). The rewrite inverts the inner layer to async Drizzle calls (`await db.select()...` / `await tx.insert()`). Because the store boundary is already async, callers above `TaskStore` are unaffected; the change is contained to the data layer's internal call sites. Transaction semantics move from SQLite `BEGIN IMMEDIATE` + `SAVEPOINT` to Drizzle transaction callbacks (`db.transaction(async (tx) => ...)`), which must preserve the per-mutation atomicity the current `transactionImmediate()` path guarantees.
|
||||
|
||||
### Migration and cutover sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Op as Operator
|
||||
participant App as Application
|
||||
participant ST as SQLite (RO)
|
||||
participant PG as PostgreSQL
|
||||
participant Tool as Migration tool
|
||||
Op->>Tool: Run SQLite→Postgres migration
|
||||
Tool->>ST: Snapshot final schema + bulk copy data
|
||||
Tool->>PG: Materialize schema + load data + build tsvector
|
||||
Tool->>Op: Report row-count verification
|
||||
Op->>App: Enable dual-read mode
|
||||
App->>PG: All writes
|
||||
App->>ST: Read fallback (unported paths / verification)
|
||||
Op->>App: Confirm parity, disable SQLite
|
||||
App->>ST: Remove SQLite data path + deps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In scope
|
||||
|
||||
- PostgreSQL connection layer with embedded/external resolution and lifecycle management.
|
||||
- Drizzle schema definition for all existing tables across project, central, and archive databases.
|
||||
- Async rewrite of the data layer (`store.ts`, `db.ts`, `central-db.ts`, `archive-db.ts`, and satellite `*-store.ts` files).
|
||||
- Full-text search replacement (FTS5 to `tsvector`/GIN).
|
||||
- Health/maintenance surface rework.
|
||||
- SQLite-to-PostgreSQL data migration tool.
|
||||
- Dual-read cutover harness and SQLite removal.
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
|
||||
- Performance benchmarking and query-plan tuning against production-scale data (after the rewrite lands and real workloads run).
|
||||
- Managed-host deployment guides (Supabase/RDS connection string specifics beyond the `DATABASE_URL`/`DATABASE_MIGRATION_URL` contract).
|
||||
- Read-replica or connection-pooler deployment topology recommendations.
|
||||
- Central-DB multi-host replication across machines (the mesh/node replication that already exists is out of scope; only its storage backend changes).
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **All `@fusion/*` packages** consume the data layer; the async conversion ripples into `@fusion/engine` (worktree DB hydration, self-healing) and `@fusion/dashboard` (health endpoint, DB-corruption banner, routes).
|
||||
- **Plugin stores** instantiate core's `Database`. The `fusion-plugin-roadmap` plugin has its own store layer on core's `Database` and pins schema versions. The backend swap must stay behind a stable data-layer interface so plugin stores keep working.
|
||||
- **Backup/restore** changes fundamentally: SQLite file-copy backups become PostgreSQL logical dumps (`pg_dump`/restore). `backup.ts` and the `BackupManager` pairing behavior (project + central pair) are reworked.
|
||||
- **CLI** (`fn db ...` commands, `--vacuum`, run-audit surfaces) changes surface and behavior.
|
||||
- **Distribution** grows by ~30-50MB per platform for bundled Postgres binaries; the desktop build (`packages/desktop`) and CLI bundling are affected.
|
||||
- **Concurrency model** shifts from SQLite WAL multi-process-over-one-file to a PostgreSQL server process, structurally resolving the documented central-DB task-lease race but introducing connection-pool and server-lifecycle management.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **Async-conversion correctness.** Missed `await`s, transaction isolation drift from `BEGIN IMMEDIATE`, and changed lock semantics are the highest-severity regression vectors. Mitigation: characterization coverage of current transactional paths before rewrite; the merge gate (`pnpm test:gate`) as the authoritative signal.
|
||||
- **embedded-postgres platform failures.** Paperclip reports initdb failures on WSL2, unprivileged LXC, and macOS dyld. Mitigation: graceful fallback messaging; document unsupported environments; consider external-server fallback guidance.
|
||||
- **FTS search parity.** `tsvector` ranking and tokenization differ from FTS5; result ordering and recall may shift. Mitigation: capture current search result fixtures as characterization baselines before replacing.
|
||||
- **Data-migration fidelity.** Soft-delete visibility, JSON column fidelity (SQLite text-JSON to JSONB), FTS index rebuild, and `AUTOINCREMENT` sequence continuity must survive the copy. Mitigation: idempotent, row-count-verified migration with a dry-run mode.
|
||||
- **Plugin-store contract drift.** If the data-layer interface narrows, plugin stores break. Mitigation: keep the store contract stable; schema-version pinning continues to work against the new migration history.
|
||||
- **Distribution size and CI.** Bundled binaries change install size and may affect CI image caching; the desktop build pipeline must fetch/verify platform binaries.
|
||||
- **Per the standing rule, flaky tests are quarantined on sight.** The rewrite will surface pre-existing flakiness; quarantine, do not appease.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### Phase 1 — Foundation: backend, connection, schema
|
||||
|
||||
### U1. PostgreSQL connection layer and backend resolution
|
||||
|
||||
- **Goal:** Resolve the backend at startup (embedded vs external via `DATABASE_URL`) and expose a Drizzle connection pool with the `DATABASE_MIGRATION_URL` split.
|
||||
- **Requirements:** R1, R2, R4
|
||||
- **Dependencies:** none
|
||||
- **Files:** `packages/core/src/postgres/connection.ts` (new), `packages/core/src/postgres/backend-resolver.ts` (new); touches startup wiring in `packages/core/src/central-core.ts` / `packages/dashboard/src/server.ts`
|
||||
- **Approach:** A resolver reads `DATABASE_URL` (external) or signals embedded mode (U2). Runtime queries use the resolved URL; schema/migration work uses `DATABASE_MIGRATION_URL` when present, else the runtime URL. Connection pooling defaults to a small pool; document the transaction-pooling caveat (prepared-statement incompatibility) that motivates the migration-URL split. **Precondition (de-risk before Phase 2):** validate the chosen Drizzle driver bundles cleanly under the desktop Bun `--compile` build by probing both `postgres.js` and `pg` against the real `packages/desktop` build — the current `sqlite-adapter.ts` exists precisely because Bun `--compile` mishandles certain native modules, so this must be confirmed before the rewrite depends on it.
|
||||
- **Patterns to follow:** Paperclip `DATABASE.md` connection-mode table; the existing settings-resolution hierarchy in `packages/core/src/settings-schema.ts`.
|
||||
- **Test scenarios:**
|
||||
- Happy path: unset `DATABASE_URL` resolves to embedded mode; set `DATABASE_URL` resolves to external and skips embedded start.
|
||||
- `DATABASE_MIGRATION_URL` present routes schema work to it while runtime uses `DATABASE_URL`.
|
||||
- Invalid/unreachable `DATABASE_URL` fails loudly with an actionable message.
|
||||
- Pooled runtime URL with no `DATABASE_MIGRATION_URL` warns about prepared-statement risk.
|
||||
- Security: the connection string (including any password in `DATABASE_URL`) is never written to logs, and connection-error messages redact credentials.
|
||||
- **Verification:** Startup logs the resolved backend and connection target; a health probe succeeds against the resolved backend.
|
||||
|
||||
### U2. Embedded PostgreSQL lifecycle manager
|
||||
|
||||
- **Goal:** Manage an embedded Postgres process (`initdb`, ensure database exists, `pg_ctl` start/stop) over a local data directory using `embedded-postgres`.
|
||||
- **Requirements:** R1, R3
|
||||
- **Dependencies:** U1
|
||||
- **Files:** `packages/core/src/postgres/embedded-lifecycle.ts` (new); bundled binary acquisition in `packages/desktop/scripts/build.ts` and `package.json` (`optionalDependencies`/postinstall)
|
||||
- **Approach:** On first start, `initdb` into the data directory, create the application database, run migrations, then serve. Persist across restarts; deleting the directory resets local state (mirroring the current SQLite reset behavior). Acquire platform/arch binaries (`embedded-postgres` supports macOS/Linux/Windows, arm64/x64). Handle graceful shutdown (`pg_ctl stop`) on process exit.
|
||||
- **Patterns to follow:** Paperclip embedded flow (`~/.paperclip/instances/default/db/`); the existing process-supervision discipline (`superviseSpawn` from `@fusion/core` — do not use raw detached spawn/nohup per AGENTS.md).
|
||||
- **Test scenarios:**
|
||||
- Happy path: first start runs `initdb`, creates DB, runs migrations; second start reuses the directory without re-init.
|
||||
- Existing data directory with prior schema starts without re-running init.
|
||||
- Graceful shutdown stops the Postgres process; no orphaned process remains.
|
||||
- Corrupt/locked data directory surfaces a clear error rather than hanging.
|
||||
- **Verification:** The application serves with no external Postgres installed; the data directory persists state across restarts.
|
||||
|
||||
### U3. Drizzle schema definition (schema-as-code baseline)
|
||||
|
||||
- **Goal:** Define the complete PostgreSQL schema in Drizzle for all existing tables across project, central, and archive databases, materialized from the current final SQLite schema (snapshot, not the 128 incremental migrations).
|
||||
- **Requirements:** R5
|
||||
- **Dependencies:** U1
|
||||
- **Files:** `packages/core/src/postgres/schema/` (new, organized by database: project, central, archive); Drizzle config (`drizzle.config.ts`); fresh migration directory
|
||||
- **Approach:** Translate every existing table (tasks, branch_groups, mergeQueue, config, workflow_steps, activityLog, task_commit_associations, archivedTasks, automations, agents, agentHeartbeats, approval_requests(+audit), secrets, task_documents(+revisions), artifacts, __meta, goals, missions hierarchy, plugins, routines, roadmaps, todos, chat tables, runAuditEvents, research/eval/experiment tables, etc.) into Drizzle table definitions. Map SQLite types: `INTEGER PRIMARY KEY AUTOINCREMENT` to identity/serial, JSON text columns to `jsonb`, the FTS5 tables to U7's tsvector design. Preserve all CHECK constraints, foreign keys with cascade rules, and unique indexes.
|
||||
- **Patterns to follow:** Existing schema declarations in `packages/core/src/db.ts` (`SCHEMA_SQL`, `MIGRATION_ONLY_TABLE_SCHEMAS`) as the source of truth for the snapshot; Drizzle schema conventions.
|
||||
- **Test scenarios:**
|
||||
- Happy path: applying the fresh Drizzle migration to an empty database yields a schema matching the current final SQLite schema (column-by-column, constraint-by-constraint).
|
||||
- Every foreign-key cascade rule and unique index from the SQLite schema is present.
|
||||
- JSON columns round-trip as JSONB with the same shape.
|
||||
- Plugin-owned tables (roadmap milestones/features) are included via the plugin schema-init hook.
|
||||
- **Verification:** A schema-diff between a migrated PostgreSQL database and a fresh-Drizzle-applied database shows no structural differences.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Data-layer rewrite (sync to async, Drizzle)
|
||||
|
||||
### U4. Async data-layer foundation (replace DatabaseSync)
|
||||
|
||||
- **Goal:** Replace the synchronous `DatabaseSync` adapter with an async Drizzle-backed connection and the core CRUD/transaction primitives the stores depend on.
|
||||
- **Requirements:** R5, R6, R7
|
||||
- **Dependencies:** U1, U3
|
||||
- **Files:** `packages/core/src/postgres/data-layer.ts` (new); removes the sync `DatabaseSync`/`Statement` surface in `packages/core/src/db.ts`; `packages/core/src/sqlite-adapter.ts` (retained only for the dual-read period, then removed in U11)
|
||||
- **Approach:** Provide the async primitives stores need: prepared-statement-equivalent query helpers, `db.transaction(async (tx) => ...)` preserving the atomicity of the current `transactionImmediate()` path, and the run-audit-event-within-transaction behavior (`recordRunAuditEvent` inside the shared transaction). Define the stable data-layer interface plugin stores consume so the backend swap is invisible to them. The `getDatabase()` accessor's contract changes: it must return an async-capable connection rather than the synchronous `Database` (U15 converts the direct-`prepare()` consumers that relied on the sync shape).
|
||||
- **Patterns to follow:** Current transaction helpers (`Database.transaction()`, `transactionImmediate()`) in `packages/core/src/db.ts`; the run-audit-within-transaction pattern.
|
||||
- **Test scenarios:**
|
||||
- Happy path: an insert + matching audit insert commit or roll back together.
|
||||
- A failing mutation inside a transaction rolls back all writes including the audit row.
|
||||
- Concurrent transactions do not observe partial writes.
|
||||
- The plugin-facing data-layer contract compiles against `fusion-plugin-roadmap`'s store usage.
|
||||
- **Verification:** The foundation supports a representative store mutation (create task + audit) atomically and async.
|
||||
|
||||
### U5. Decompose `store.ts` into cohesive modules
|
||||
|
||||
- **Goal:** Break the ~17k-line `TaskStore` god-class into cohesive per-responsibility modules behind the existing `TaskStore` facade, as a pure behavior-invariant refactor that makes each subsequent migration independently landable.
|
||||
- **Requirements:** R5, R7
|
||||
- **Dependencies:** none (pure refactor, no backend change)
|
||||
- **Files:** `packages/core/src/store.ts` (extract); new modules under `packages/core/src/task-store/` (e.g. persistence, allocator, settings, lifecycle, merge-coordination, archive-lineage, branch-groups, workflow-workitems, audit, search, comments)
|
||||
- **Approach:** Extract the distinct responsibility areas into separate modules without changing behavior or the backend: task persistence + allocator reconciliation, settings, task lifecycle/moves + workflow transitions, soft-delete/archive/lineage, merge-queue + merge, branch-groups + PR-entities/threads, workflow work-items + completion handoff, audit/activity-log/run-audit, search, comments/attachments, goal/usage/plugin events, file-watching, task-ID-integrity. Keep the `TaskStore` class as a facade composing the modules so callers are unaffected. No async or Drizzle changes yet.
|
||||
- **Execution note:** Behavior-invariant by design — the existing gate (`pnpm test:gate`) plus `store-concurrent-writes` / `checkout-claim-mutex` tests verify the extraction for free. Per the mass-migration learning, this is a no-two-agents-share-a-file extraction, not a backend swap.
|
||||
- **Patterns to follow:** `docs/solutions/architecture-patterns/mass-migration-agent-fleet-orchestration.md` (verification-invariance for mechanical extraction).
|
||||
- **Test scenarios:**
|
||||
- Test expectation: none -- behavior-invariant refactor; the existing gate and concurrent-write/mutex tests are the verification surface.
|
||||
- **Verification:** `pnpm test:gate` passes with no behavior change; the facade preserves every public method signature.
|
||||
|
||||
### U6. Satellite stores and databases rewrite
|
||||
|
||||
- **Goal:** Rewrite the central database (`central-db.ts`), archive database (`archive-db.ts`), and satellite stores (`message-store.ts`, `chat-store.ts`, `mission-store.ts`, `insight-store.ts`, `research-store.ts`, `eval-store.ts`, `experiment-session-store.ts`, `routine-store.ts`, `plugin-store.ts`, `goal-store.ts`, `todo-store.ts`, `reflection-store.ts`, `automation-store.ts`, `approval-request-store.ts`, `secrets-store.ts`, `agent-store.ts`) to async Drizzle, plus `worktree-db-hydrate.ts`.
|
||||
- **Requirements:** R5, R6, R7
|
||||
- **Dependencies:** U4
|
||||
- **Files:** the `*-store.ts` files in `packages/core/src/`; `packages/core/src/central-db.ts`, `packages/core/src/archive-db.ts`; `packages/engine/src/worktree-db-hydrate.ts`
|
||||
- **Approach:** Same sync-to-async, dialect-to-Drizzle conversion as U5, applied per store. The archive database (cold storage, append-only FTS) maps to its PostgreSQL schema with the lighter-touch tsvector maintenance. Worktree DB hydration copies task-scoped metadata into the worktree's connection (now a scoped query against the shared PostgreSQL backend rather than a separate SQLite file hydration).
|
||||
- **Patterns to follow:** Each store's current SQLite implementation; the central-DB concurrency note from the learnings (two engines racing leases — the new backend removes single-writer contention).
|
||||
- **Test scenarios:**
|
||||
- Happy path per store: representative create/read/update/delete.
|
||||
- Central DB: secret encryption round-trips; access-policy CHECK constraints hold.
|
||||
- Archive: archived task snapshots persist and are searchable.
|
||||
- Worktree hydration: task + dependency metadata is copied for the active graph; binary artifact files are not copied.
|
||||
- **Verification:** Each store's existing tests pass against PostgreSQL; the worktree-hydrate test passes.
|
||||
|
||||
### U12. Migrate TaskStore persistence, allocator, and settings modules
|
||||
|
||||
- **Goal:** Migrate the decomposed task-persistence, ID-allocator-reconciliation, and settings modules (from U5) from sync SQLite to async Drizzle.
|
||||
- **Requirements:** R5, R6, R7
|
||||
- **Dependencies:** U4, U5
|
||||
- **Files:** `packages/core/src/task-store/persistence.ts`, `packages/core/src/task-store/allocator.ts`, `packages/core/src/task-store/settings.ts` (from U5); `packages/core/src/distributed-task-id.ts`, `packages/core/src/task-id-integrity.ts`
|
||||
- **Approach:** Convert the persistence-module call sites to awaited Drizzle queries. Preserve soft-delete visibility (`deletedAt IS NULL`) across all live readers, create-class non-destructive inserts, and allocator reconciliation bumping each prefix sequence to `max(current, max(task suffix)+1, max(archived suffix)+1, max(reservation)+1)` on store open. Settings reads/writes move to Drizzle against the `config` table. Carry FNXC comments forward.
|
||||
- **Execution note:** Characterization coverage of allocator reconciliation before migration; the merge gate is the authoritative signal.
|
||||
- **Patterns to follow:** Current allocator reconciliation and soft-delete invariants in `docs/storage.md`.
|
||||
- **Test scenarios:**
|
||||
- Happy path: create/read/update/delete a task end to end.
|
||||
- Soft-delete: live readers hide `deletedAt` rows; forensic reads surface them.
|
||||
- Allocator reconciliation: stale sequences self-heal to max suffix; soft-deleted/archived IDs stay reserved.
|
||||
- Settings: read/update project and global settings round-trip.
|
||||
- **Verification:** Persistence, allocator, and settings tests pass against PostgreSQL.
|
||||
|
||||
### U13. Migrate TaskStore lifecycle and merge-coordination modules
|
||||
|
||||
- **Goal:** Migrate the task-lifecycle/moves/workflow-transitions and merge-queue/merge modules (from U5) to async Drizzle, preserving the transactional invariants.
|
||||
- **Requirements:** R5, R6, R7
|
||||
- **Dependencies:** U5, U12
|
||||
- **Files:** `packages/core/src/task-store/lifecycle.ts`, `packages/core/src/task-store/merge-coordination.ts` (from U5)
|
||||
- **Approach:** Convert move/handoff/merge call sites to awaited Drizzle. Preserve the handoff-to-review `mergeQueue` invariant: the column move, `mergeQueue` insert, and handoff audit fan-out run in one Drizzle transaction (`db.transaction`), so observers never see `column = "in-review"` without the matching queue row. Merge-queue leasing (priority-first + FIFO within priority, recoverable expired leases) maps to Drizzle transactions with row-level locking.
|
||||
- **Patterns to follow:** The handoff invariant and merge-queue lease semantics in `docs/storage.md` and `packages/core/src/store.ts`.
|
||||
- **Test scenarios:**
|
||||
- Happy path: move a task through columns; hand off to review; acquire/release a merge-queue lease.
|
||||
- Handoff invariant: column move + `mergeQueue` insert + audit are atomic; a failure rolls back all three.
|
||||
- Merge-queue lease: priority-first ordering; expired leases recover without incrementing attempts.
|
||||
- **Verification:** Lifecycle and merge-coordination tests pass against PostgreSQL; the checkout-claim-mutex test passes.
|
||||
|
||||
### U14. Migrate TaskStore remaining modules (archive/lineage, branch-groups, workflow work-items, audit, comments)
|
||||
|
||||
- **Goal:** Migrate the remaining decomposed TaskStore modules (archive/lineage, branch-groups/PR-entities, workflow work-items/completion-handoff, audit/activity-log/run-audit, comments/attachments, goal/usage/plugin events) to async Drizzle.
|
||||
- **Requirements:** R5, R6, R7
|
||||
- **Dependencies:** U5, U12
|
||||
- **Files:** `packages/core/src/task-store/archive-lineage.ts`, `packages/core/src/task-store/branch-groups.ts`, `packages/core/src/task-store/workflow-workitems.ts`, `packages/core/src/task-store/audit.ts`, `packages/core/src/task-store/comments.ts` (from U5)
|
||||
- **Approach:** Convert each module's call sites to awaited Drizzle. Preserve lineage-integrity gates (live children block parent delete/archive; `removeLineageReferences` clears them), document/artifact parent-task scoping under soft-delete, and run-audit-event-within-transaction behavior. The search module is migrated here for query structure, paired with U7's tsvector index. File-watching and task-ID-integrity detection move to PostgreSQL-backed reads.
|
||||
- **Patterns to follow:** Lineage children, documents under soft-deleted tasks, and the artifact registry semantics in `docs/storage.md`.
|
||||
- **Test scenarios:**
|
||||
- Lineage: deleting a parent with live children throws; `removeLineageReferences` clears them; archived/soft-deleted children do not block.
|
||||
- Archive: archived snapshots persist and are searchable; unarchive restores.
|
||||
- Audit: a mutation and its run-audit event commit or roll back together.
|
||||
- Comments/attachments: add/update/delete round-trip on an active task.
|
||||
- **Verification:** Remaining TaskStore module tests pass against PostgreSQL.
|
||||
|
||||
### U15. Migrate engine and dashboard direct-`prepare()` consumers
|
||||
|
||||
- **Goal:** Convert the `@fusion/engine` and `@fusion/dashboard` consumers that bypass store methods and call the sync `Database`/`prepare()` surface directly, once `getDatabase()` returns an async connection (U4).
|
||||
- **Requirements:** R5, R6
|
||||
- **Dependencies:** U4, U6, U12
|
||||
- **Files:** `packages/dashboard/src/monitor-store.ts`, `packages/dashboard/src/server.ts` (store-construction sites passing `getDatabase()`), `packages/dashboard/src/routes/register-*.ts` (store-construction sites), `packages/engine/src` callers of `store.getDatabase()` and direct `prepare()` (self-healing, worktree hydration); the `packages/engine/src/worktree-db-hydrate.ts` path already covered by U6
|
||||
- **Approach:** Replace direct `db.prepare(sql).run/get/all` calls in dashboard stores (notably `monitor-store.ts`) and route handlers with awaited Drizzle queries or routed through the relevant async store. Update store-construction sites that pass the raw `Database` (`new ChatStore(store.getDatabase())`, `new AiSessionStore(...)`, `new ApprovalRequestStore(...)`) to pass the async connection or the owning store. Convert engine test/self-healing direct-`prepare()` sites to async Drizzle.
|
||||
- **Patterns to follow:** The async store-method boundary established in U4/U6; existing route store-construction patterns.
|
||||
- **Test scenarios:**
|
||||
- Happy path: dashboard monitor deployments/incidents/metrics read and write via the async path.
|
||||
- Each migrated route store constructs against the async connection and serves requests.
|
||||
- Engine self-healing mutations that previously used direct `prepare()` persist via async Drizzle.
|
||||
- **Verification:** Dashboard and engine tests pass against PostgreSQL; no direct sync `prepare()` call sites remain in `packages/dashboard/src` or `packages/engine/src`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — SQLite-specific surfaces
|
||||
|
||||
### U7. Full-text search replacement (FTS5 to tsvector/GIN)
|
||||
|
||||
- **Goal:** Replace the FTS5 external-content tables and triggers (`tasks_fts`, `archived_tasks_fts`) with PostgreSQL `tsvector`/GIN full-text search, preserving result parity and automatic sync-on-write.
|
||||
- **Requirements:** R8
|
||||
- **Dependencies:** U3, U5, U6
|
||||
- **Files:** `packages/core/src/postgres/schema/` (fts columns/indexes); search-query paths in `packages/core/src/store.ts` (`searchTasks`) and the archive store; the FTS maintenance step in self-healing
|
||||
- **Approach:** Use generated `tsvector` columns over the indexed text columns with GIN indexes, kept in sync via PostgreSQL generated columns/triggers (preserving the automatic sync that today's FTS5 `ai`/`au`/`ad` triggers provide). The value-aware partial-update optimization (only changed text columns touch the index) maps to PostgreSQL only re-generating the tsvector when source text columns change. Replace the FTS5 corruption/maintenance self-healing step with PostgreSQL index health (`REINDEX`/autovacuum) and the bounded rebuild-on-bloat threshold logic.
|
||||
- **Patterns to follow:** Current FTS5 design and the `rebuildFts5Index()`/merge/optimize thresholds in `packages/core/src/db.ts`; the documented defer rationale in `docs/storage.md` (attached live-FTS investigation).
|
||||
- **Test scenarios:**
|
||||
- Happy path: search returns the same tasks for a representative query set as the FTS5 baseline.
|
||||
- Insert/update/delete keep the tsvector in sync automatically.
|
||||
- Non-text mutation does not needlessly re-generate the index.
|
||||
- Index rebuild on bloat threshold restores search without data loss.
|
||||
- **Verification:** Search-result fixtures captured pre-rewrite pass post-rewrite.
|
||||
|
||||
### U8. Health and maintenance surface rework
|
||||
|
||||
- **Goal:** Rework the SQLite-specific health and maintenance surfaces for PostgreSQL: corruption detection, startup rebuild-on-malformed, compaction, WAL checkpointing, and schema self-heal.
|
||||
- **Requirements:** R12
|
||||
- **Dependencies:** U4, U5
|
||||
- **Files:** `packages/core/src/db.ts` (integrity/VACUUM/WAL-checkpoint paths); `packages/dashboard/app/components/DbCorruptionBanner.tsx`; `packages/dashboard/src/routes` (health endpoint `taskIdIntegrity`); `packages/engine/src/__tests__/self-healing-db-corruption.test.ts`
|
||||
- **Approach:** Replace `PRAGMA integrity_check`/`quick_check` and the startup rebuild-on-malformed guard with PostgreSQL health checks (`pg_stat`/connection liveness) and a restore-from-backup path on corruption. Replace `VACUUM`/WAL checkpoint with autovacuum tuning plus an explicit `VACUUM`/`ANALYZE` operator command. Replace the schema self-heal via `PRAGMA table_info`/fingerprint reconciliation with an `information_schema`/`pg_catalog`-based check driven by Drizzle's known schema. Preserve the task-ID-integrity detector (duplicate IDs, cross-table collisions, sequence drift) against PostgreSQL.
|
||||
- **Patterns to follow:** Current integrity/VACUUM paths and the schema self-heal fingerprint mechanism in `packages/core/src/db.ts`.
|
||||
- **Test scenarios:**
|
||||
- Happy path: healthy database reports green health.
|
||||
- Task-ID integrity anomalies (duplicate IDs, sequence drift) are detected and surface the banner.
|
||||
- Schema drift detection catches a missing column and reconciles it.
|
||||
- Explicit compaction command runs `VACUUM`/`ANALYZE` and reports stats.
|
||||
- **Verification:** The health endpoint and corruption banner behave as before; the self-healing-db-corruption test passes in its PostgreSQL form.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Migration, cutover, removal
|
||||
|
||||
### U9. SQLite-to-PostgreSQL data migration tool
|
||||
|
||||
- **Goal:** Build a tool that snapshots the current final SQLite schema into PostgreSQL and bulk-copies all data (all three databases), idempotently and with verification.
|
||||
- **Requirements:** R9
|
||||
- **Dependencies:** U3, U5, U6, U7
|
||||
- **Files:** `scripts/migrate-sqlite-to-postgres.mjs` (new); `packages/core/src/db-migrate.ts` (snapshot reference)
|
||||
- **Approach:** Read each SQLite database, map types (text-JSON to JSONB, integers to appropriate types), stream rows into the PostgreSQL schema via Drizzle, rebuild the tsvector indexes, and verify row counts per table. Support a dry-run mode. Handle the soft-delete/deletedAt rows, JSON column fidelity, and `AUTOINCREMENT` sequence continuity (set sequences to max(id)+1). The tool targets the embedded or external PostgreSQL backend via `DATABASE_URL`.
|
||||
- **Patterns to follow:** The existing one-shot reconciliation scripts in `scripts/` (e.g. `reconcile-leaked-soft-deletes.mjs`) for the bounded, idempotent, dry-run-default shape.
|
||||
- **Test scenarios:**
|
||||
- Happy path: a populated SQLite database migrates to PostgreSQL with matching row counts per table.
|
||||
- Idempotency: re-running against an already-migrated PostgreSQL database is a no-op or a clean re-sync.
|
||||
- JSON columns round-trip with identical shape.
|
||||
- Sequences are set to max(id)+1 so new inserts do not collide.
|
||||
- Dry-run reports the planned copy without writing.
|
||||
- **Verification:** A migrated PostgreSQL database passes the same store tests as a natively-created one.
|
||||
|
||||
### U10. Dual-read cutover harness
|
||||
|
||||
- **Goal:** Support a transition window where SQLite is read-only and PostgreSQL is the write target, so deployments migrate without a downtime window.
|
||||
- **Requirements:** R10
|
||||
- **Dependencies:** U9
|
||||
- **Files:** `packages/core/src/postgres/dual-read-harness.ts` (new); backend wiring touched in U1
|
||||
- **Approach:** A mode flag routes all writes to PostgreSQL while reads fall back to SQLite solely for parity verification (all live data paths are already on PostgreSQL by this point — U10 runs after U5/U6/U7 ported every store). Enforce SQLite read-only (reject writes) to prevent two-writer contention that the learnings warn races task leases. Provide a parity-check command that compares SQLite vs PostgreSQL read results for a sample of queries. The parity check must exclude search-result ordering — FTS5 (SQLite) and tsvector (PostgreSQL, from U7) rank and tokenize differently, so strict search ordering comparison would report false failures; search parity is validated separately against captured fixtures in U7, and the dual-read parity check compares row membership only for search. Document the operator sequence: migrate (U9) → enable dual-read → verify parity → disable SQLite (U11).
|
||||
- **Patterns to follow:** The dual-engine safety guidance in `docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md` (the daemon/lease-race hazard).
|
||||
- **Test scenarios:**
|
||||
- Happy path: in dual-read mode, a write lands in PostgreSQL and is readable from PostgreSQL.
|
||||
- A write attempt against SQLite in dual-read mode is rejected.
|
||||
- Parity check reports matching row membership for sampled queries, excluding search-result ordering.
|
||||
- **Verification:** A deployment can run in dual-read mode serving live traffic with PostgreSQL as the sole writer.
|
||||
|
||||
### U11. SQLite removal, fresh migration baseline, and cleanup
|
||||
|
||||
- **Goal:** Remove SQLite entirely after cutover: drop the SQLite data path and dependencies, establish the fresh Drizzle migration history as authoritative, and rework backup/restore for PostgreSQL.
|
||||
- **Requirements:** R11, R12
|
||||
- **Dependencies:** U10
|
||||
- **Files:** `packages/core/src/sqlite-adapter.ts` (remove), `packages/core/src/sqlite-validation.ts` (remove), SQLite paths in `db.ts`/`store.ts` (remove); `packages/core/src/backup.ts` (rework to `pg_dump`/restore); `package.json` (remove `better-sqlite3`); `plugins/fusion-plugin-even-realities-glasses/package.json`, `packages/desktop/scripts/build.ts`; `docs/storage.md`, `AGENTS.md` (SQLite-specific sections)
|
||||
- **Approach:** Delete the SQLite adapter and validation, the FTS5 probe, the `ATTACH DATABASE` archive path, and SQLite-specific maintenance. Make the fresh Drizzle migration history the sole schema authority with the version-gate testing discipline carried forward. Rework `BackupManager` to PostgreSQL logical dumps (project + central pairing preserved as separate dumps). Update operator docs to reflect the `DATABASE_URL`/embedded model.
|
||||
- **Patterns to follow:** The version-gate regression-test learning (seed-at-previous-version tests for skipped-on-upgrade detection), applied to Drizzle migrations.
|
||||
- **Test scenarios:**
|
||||
- Happy path: the application starts, runs, and passes the full gate with no SQLite code path reachable.
|
||||
- No `better-sqlite3`/`node:sqlite`/`bun:sqlite` import remains in the data path.
|
||||
- Backup produces a restorable PostgreSQL dump; restore round-trips.
|
||||
- Fresh Drizzle migration history applies cleanly to an empty database.
|
||||
- **Verification:** `pnpm verify:workspace` passes; grep for SQLite symbols in the data path returns nothing.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Project/central/archive as separate databases or schemas in one database.** Both are valid; separate databases mirror today's separate files most closely and simplify backup pairing, while schemas-in-one-database simplify embedded single-instance management. Resolve during U3; the data layer abstracts the choice either way.
|
||||
|
||||
- **embedded-postgres version pin and checksum verification.** The bundled Postgres binaries need a pinned version and (per the external-integration evidence rule) a checksum or `upstream-pending-verification` marker. Confirm during U2.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- Paperclip database model: `github.com/paperclipai/paperclip` `doc/DATABASE.md` — embedded default, `DATABASE_URL` switching, `DATABASE_MIGRATION_URL` split, plugin database namespaces.
|
||||
- `embedded-postgres` package: `github.com/leinelissen/embedded-postgres`, `npmjs.com/package/embedded-postgres` — `initdb`/`pg_ctl` lifecycle, platform/arch binaries; known failure modes (WSL2, unprivileged LXC, macOS dyld) tracked in `paperclipai/paperclip` issues #1032, #828, #3583.
|
||||
- Current storage architecture: `docs/storage.md` (hybrid storage model, FTS5 maintenance, attached-FTS defer rationale, write-path lock recovery).
|
||||
- Migration engine: `packages/core/src/db.ts` (`SCHEMA_VERSION = 128`, `applyMigration`, `SCHEMA_COMPAT_FINGERPRINT`); `docs/solutions/database-issues/schema-version-constant-must-equal-highest-migration.md` (version-gate invariant).
|
||||
- Concurrency hazard: `docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md` (two engines racing task leases over the central SQLite DB).
|
||||
- Plugin store coupling: `docs/solutions/test-failures/schema-version-sweep-must-include-plugin-workspaces.md` (`fusion-plugin-roadmap` instantiates core's `Database`).
|
||||
100
docs/plans/2026-06-23-001-fix-workflow-runtime-cutover-plan.md
Normal file
100
docs/plans/2026-06-23-001-fix-workflow-runtime-cutover-plan.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Fix Workflow Runtime Cutover
|
||||
date: 2026-06-23
|
||||
status: planned
|
||||
---
|
||||
|
||||
# Fix Workflow Runtime Cutover
|
||||
|
||||
## Problem
|
||||
|
||||
The workflow graph and workflow-column runtime paths are being made default, but the first cutover review found that the new dispatch path is not yet equivalent to the legacy scheduler/executor invariants. The work must move the cutover onto an isolated branch and make the new path safe before opening a PR.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1: Keep unrelated dashboard/cosmetic changes out of the workflow cutover branch.
|
||||
- R2: The workflow hold/release scheduler path must preserve dispatch safety: dependency, mission, filesystem/spec, pause, lease, node-routing, permanent-agent, overlap, oscillation, `maxWorktrees`, `maxConcurrent`, and semaphore behavior.
|
||||
- R3: `TaskExecutor.execute()` must prove the graph-default entrypoint preserves legacy recovery behavior, including inner executor requeues and mismatched store-row protection.
|
||||
- R4: The gate must be self-contained: every test referenced by `packages/engine/vitest.config.ts` must be tracked and committed.
|
||||
- R5: Legacy workflow flags should not remain user-facing experimental kill switches, but stale persisted values must be tolerated.
|
||||
- R6: Remove or neutralize unreachable legacy scheduler dispatch code so future fixes do not land in dead paths.
|
||||
- R7: Validate with lint, typecheck, build, gate, and targeted engine tests before PR.
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Isolate Branch State
|
||||
|
||||
Files:
|
||||
- `packages/dashboard/app/components/ScriptsModal.css`
|
||||
- `packages/dashboard/app/components/__tests__/ScheduledTasksModal.test.tsx`
|
||||
- `docs/plans/2026-06-23-001-fix-workflow-runtime-cutover-plan.md`
|
||||
|
||||
Approach:
|
||||
- Commit the dashboard/cosmetic automations spacing changes on `main`.
|
||||
- Preserve workflow cutover work on a dedicated branch for review and rollback.
|
||||
- Ensure `main` is not left carrying uncommitted workflow cutover edits.
|
||||
|
||||
Tests:
|
||||
- `pnpm --filter @fusion/dashboard exec vitest run app/components/__tests__/ScheduledTasksModal.test.tsx`
|
||||
|
||||
### U2. Scheduler Dispatch Equivalence
|
||||
|
||||
Files:
|
||||
- `packages/engine/src/scheduler.ts`
|
||||
- `packages/engine/src/hold-release.ts`
|
||||
- `packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts`
|
||||
- `packages/engine/vitest.config.ts`
|
||||
|
||||
Approach:
|
||||
- Move all live pre-dispatch gates into the workflow hold/release reservation path or a shared helper used by that path.
|
||||
- Fix capacity ordering so no task is marked starting or status-cleared until all reservation checks pass.
|
||||
- Preserve `maxConcurrent` and shared semaphore semantics without double-acquiring the executor semaphore.
|
||||
- Make the replacement gate test tracked and broad enough to cover the migrated invariants.
|
||||
|
||||
Tests:
|
||||
- `pnpm --filter @fusion/engine exec vitest run src/__tests__/scheduler-workflow-cutover.test.ts`
|
||||
- `pnpm --filter @fusion/engine test:core`
|
||||
|
||||
### U3. Executor Graph Entry And Recovery
|
||||
|
||||
Files:
|
||||
- `packages/engine/src/executor.ts`
|
||||
- `packages/engine/src/__tests__/workflow-graph-task-runner.test.ts`
|
||||
- Targeted executor tests under `packages/engine/src/__tests__/`
|
||||
|
||||
Approach:
|
||||
- Ensure graph execution preserves the original dispatched task identity.
|
||||
- Fix graph failure handling so inner executor self-heal/requeue is not overwritten by outer graph parking.
|
||||
- Ensure graph `prepareWorktree` does not pre-acquire or pass the repo root as a task worktree.
|
||||
- Restore direct `TaskExecutor.execute()` coverage for default-on graph behavior and recovery semantics.
|
||||
|
||||
Tests:
|
||||
- Focused executor recovery/worktree/liveness tests affected by graph-default behavior.
|
||||
- `pnpm --filter @fusion/engine test:core`
|
||||
|
||||
### U4. Remove Dead Legacy Dispatch Surface
|
||||
|
||||
Files:
|
||||
- `packages/engine/src/scheduler.ts`
|
||||
- `packages/engine/vitest.config.ts`
|
||||
|
||||
Approach:
|
||||
- After U2 coverage is in place, remove unreachable legacy todo dispatcher code or reduce it to any still-needed shared helpers.
|
||||
- Keep reporter emission and non-dispatch scheduler duties intact.
|
||||
|
||||
Tests:
|
||||
- `pnpm --filter @fusion/engine typecheck`
|
||||
- `pnpm --filter @fusion/engine test:core`
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm lint`
|
||||
- `pnpm typecheck`
|
||||
- `pnpm test`
|
||||
- `pnpm build`
|
||||
- `compound-engineering:ce-code-review mode:agent plan:docs/plans/2026-06-23-001-fix-workflow-runtime-cutover-plan.md`
|
||||
|
||||
## Risks
|
||||
|
||||
- The workflow path is central engine infrastructure; green gate alone is not enough if broad affected tests still show executor/scheduler invariant regressions.
|
||||
- Semaphore handling must avoid both failure modes found in review: bypassing capacity entirely and double-acquiring before the executor can run.
|
||||
291
docs/plans/2026-06-27-001-feat-pg-satellite-store-ports-plan.md
Normal file
291
docs/plans/2026-06-27-001-feat-pg-satellite-store-ports-plan.md
Normal file
@@ -0,0 +1,291 @@
|
||||
---
|
||||
title: "feat: Port remaining satellite stores to PostgreSQL AsyncDataLayer"
|
||||
status: completed
|
||||
date: 2026-06-27
|
||||
type: feat
|
||||
branch: feature/postgres
|
||||
---
|
||||
|
||||
# feat: Port remaining satellite stores to PostgreSQL AsyncDataLayer
|
||||
|
||||
## Summary
|
||||
|
||||
The embedded-PostgreSQL backend is the default local store, but several satellite stores were never ported off the synchronous SQLite path. Their getters throw `"<Store> is not available in PG backend mode"`, so the dashboard features 500 (now interim-503-guarded). This plan ports the remaining stores so their dashboard surfaces work against real Postgres, sequenced cleanest-first as independent per-store units: **workflow definitions → mailbox (MessageStore) → InsightStore → ResearchStore → MissionStore**. TodoStore (already shipped this session) is the reference pattern.
|
||||
|
||||
Each unit lands as its own commit, removes the interim 503/throw for that store, and adds a `*.pg.test.ts` to the blocking `test:pg-gate` lane. Mission autopilot/SSE and CLI-only paths may remain partial — only the dashboard read/write surface is in scope per store.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
In PG backend mode (`store.backendMode === true`), the synchronous `store.db` getter throws. Satellite-store getters (`getInsightStoreImpl`/`getResearchStoreImpl` in `packages/core/src/task-store/remaining-ops-10.ts`, `getMissionStoreImpl` in `packages/core/src/task-store/remaining-ops-8.ts`) construct their store with `store.db` and therefore throw. Dashboard routes were given interim `503` guards this session; `/api/workflows` still 500s because `readAllWorkflowDefinitionsImpl` does a raw `store.db.prepare("SELECT * FROM workflows")`.
|
||||
|
||||
Each store already has a partial `async-*-store.ts` helper module targeting the existing `project.*` Postgres tables, plus a shared PG test harness (`packages/core/src/__test-utils__/pg-test-harness.ts`). The work is: fill helper gaps (faithfully replicating stateful lifecycle logic), wrap them in an async store exposing the sync method names, return that wrapper from the getter in backend mode, convert the dashboard routes (and any unconditional non-fallback consumers) to `await`, and remove the interim guard.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **R1.** Each ported store's dashboard routes return HTTP 200 with real data against a live embedded-PG instance (no 500/503).
|
||||
- **R2.** The server stays up — no uncaught throws from store getters or async misuse on engine/SSE paths.
|
||||
- **R3.** `test:pg-gate` stays green and gains one `*.pg.test.ts` per ported store covering the dashboard-critical methods, including lifecycle/state-machine behavior where present.
|
||||
- **R4.** Stateful logic (status-transition validation, terminal-immutability, auto-timestamps, retry gates, fingerprint dedup, auto-seq) is replicated faithfully — the PG path must match the SQLite path's observable semantics.
|
||||
- **R5.** Legacy SQLite mode is unaffected — the sync store remains the path when `!store.backendMode`.
|
||||
- **R6.** Interim 503 guards added this session are removed for each store as it is genuinely ported.
|
||||
- **R7.** Consumers that already wrap the getter in try/catch graceful fallback are left as-is; only unconditional sync consumers reachable in PG mode are converted to `await`.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **KTD1 — Async-wrapper-class pattern (mirror TodoStore).** For each store, add an `Async<Store>` class (in the existing `async-*-store.ts`) that holds the `AsyncDataLayer` and exposes the **same public method names** as the sync store, delegating to the module's helper functions. The getter returns it in backend mode; the sync class stays for SQLite. Consumers `await` the result (harmless on sync returns). Rationale: proven this session with `AsyncTodoStore`; keeps a single call path across both backends.
|
||||
- **KTD2 — Getter return type becomes a union.** `get<Store>Store(): <Store> | Async<Store>`; the cached field widens to `<Store> | Async<Store> | null`. Callers `await`. Rationale: avoids forcing the sync store to become async and avoids an interface extraction.
|
||||
- **KTD3 — Replicate lifecycle logic in the new helpers, not the routes.** `updateInsightRun`/`updateResearchRun`/`updateResearchStatus`/`createResearchRetryRun` carry the state machines (`VALID_*_TRANSITIONS`, `TERMINAL_*_STATUSES`, auto-timestamps, retry gate). The async helpers must reproduce these checks and throw the same `*LifecycleError` types. Rationale: R4; the routes already assume the store enforces invariants.
|
||||
- **KTD4 — Leave engine/CLI graceful-fallback consumers untouched; convert only unconditional reachable ones.** Insight's 3 engine reporters and Research's `project-engine` orchestrator init already try/catch — leave them (they degrade). Convert dashboard routes always; convert CLI/agent-tools calls that are unconditional and async-reachable. Rationale: R7, bounds blast radius.
|
||||
- **KTD5 — Sequence cleanest-first, one commit per store.** Workflows (1 method, 0 consumer changes) → Mailbox (mostly wired) → Insight (6 helpers, engine on fallback) → Research (12 helpers + machines + ~24 consumers) → Mission (71 helpers, 54 route methods, partial). Rationale: ship value early, isolate risk, keep each PR reviewable.
|
||||
- **KTD6 — Raw async SQL must schema-qualify `project.*` and use snake_case columns.** Per this session's earlier fixes (the connection does not put `project` on `search_path`). New helpers go through Drizzle schema objects (auto-qualified) where possible; raw `sql` must qualify. Rationale: avoids the `relation does not exist` / wrong-column class already fixed once.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
Per-store porting pipeline (applies to U3–U5; U1/U2 are reduced cases):
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Sync store method surface] --> B{Async helper exists?}
|
||||
B -- yes --> D[Async<Store> wrapper method delegates to helper]
|
||||
B -- no --> C[Write async helper: replicate lifecycle logic faithfully]
|
||||
C --> D
|
||||
D --> E[get<Store>StoreImpl: backendMode ? new Async<Store>(layer) : new Sync(db)]
|
||||
E --> F[Dashboard routes: await store methods; remove 503 guard]
|
||||
E --> G{Other consumer}
|
||||
G -- try/catch fallback --> H[Leave as-is]
|
||||
G -- unconditional + async-reachable --> I[Convert to await]
|
||||
F --> J[pg-gate test via shared harness]
|
||||
I --> J
|
||||
H --> J
|
||||
```
|
||||
|
||||
Store complexity ranking (drives sequence):
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
W[Workflows: 1 method, 0 consumers] --> M[Mailbox: dual-path already wired]
|
||||
M --> I[Insight: 6 helpers, engine fallback]
|
||||
I --> R[Research: 12 helpers + 2 state machines + ~24 consumers]
|
||||
R --> MI[Mission: 71 helpers, 54 route methods, autopilot partial]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Port workflow-definitions read to the async layer
|
||||
|
||||
**Goal:** `/api/workflows` returns 200 in PG mode (lists builtin + custom workflow definitions).
|
||||
|
||||
**Requirements:** R1, R2, R5, R6.
|
||||
|
||||
**Dependencies:** none.
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/task-store/remaining-ops-8.ts` (`readAllWorkflowDefinitionsImpl`)
|
||||
- `packages/core/src/async-workflow-store.ts` *(new, or add a helper to an existing async module)* — `listWorkflowDefinitions(layer)` reading `project.workflows`
|
||||
- `packages/core/src/__tests__/postgres/workflow-definitions.pg.test.ts` *(new)*
|
||||
- `packages/core/package.json` (`test:pg-gate` list)
|
||||
|
||||
**Approach:** `readAllWorkflowDefinitionsImpl` is the ONLY sync method in the workflow-definition read path — every caller (`register-workflow-routes.ts`, `board-workflows.ts`, `executor.ts`, `agent-tools.ts`, CLI) already `await`s `listWorkflowDefinitions`/`getWorkflowDefinition`. Add a `store.backendMode` branch that reads rows from `project.workflows` (ordered `created_at ASC`) via the async layer and maps them to `WorkflowDefinition` exactly as the sync branch does (parse `ir`/`layout` jsonb, default `kind`). Builtins are merged from code constants downstream — unchanged. No consumer conversion needed.
|
||||
|
||||
**Patterns to follow:** `AsyncTodoStore` row-mapping; the existing sync row→definition mapping in `readAllWorkflowDefinitionsImpl`; schema object `schema.project.workflows`.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: seed two custom workflows via the layer; `store.listWorkflowDefinitions()` in backend mode returns them plus enabled builtins, ordered by `createdAt`.
|
||||
- Empty: no custom rows → returns builtins only, no throw.
|
||||
- `kind` filter: `listWorkflowDefinitions({ kind })` filters correctly.
|
||||
- jsonb round-trip: `ir`/`layout` parse back to the stored object shape.
|
||||
- Covers R1: `GET /api/workflows` handler resolves (integration-level via the store method).
|
||||
|
||||
**Verification:** `GET /api/workflows` → 200 with builtin workflows on a fresh embedded-PG instance; custom workflow created via API then listed; `test:pg-gate` green.
|
||||
|
||||
---
|
||||
|
||||
### U2. Close the mailbox (MessageStore) PG gap
|
||||
|
||||
**Goal:** Mailbox/chat-send routes work in PG mode (the reported "mailbox send error" is gone).
|
||||
|
||||
**Requirements:** R1, R2, R4, R5.
|
||||
|
||||
**Dependencies:** none.
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/message-store.ts` (the `isBackendMode()` branches)
|
||||
- `packages/core/src/async-message-store.ts` (only if a helper is missing)
|
||||
- `packages/dashboard/src/routes/register-messaging-scripts.ts` / `register-chat-room-routes.ts` (verify await; no expected change)
|
||||
- `packages/core/src/__tests__/postgres/message-store.pg.test.ts` *(new or extend satellite coverage)*
|
||||
- `packages/core/package.json` (`test:pg-gate`)
|
||||
|
||||
**Approach:** MessageStore is already engine-runtime-owned with **dual-path construction** — `in-process-runtime.ts` builds it with `{ asyncLayer }` in PG mode, the class branches on `isBackendMode()`, and the 11 async helpers exist; consumers already `await`. This is NOT a full port — it is gap-closure. **Execution note:** Start by reproducing the exact mailbox-send failure against a live embedded-PG instance and capture the error; the fix is whichever specific `MessageStore` method still routes to `this.db` (or an unimplemented `isBackendMode()` branch) on the send path. Wire that one method through the matching async helper.
|
||||
|
||||
**Patterns to follow:** existing `isBackendMode()` branches in `message-store.ts`; `async-message-store.ts` `sendMessage`/`getConversation` helpers.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `sendMessage` → `getMailbox`/`getConversation` round-trip in backend mode returns the sent message.
|
||||
- Read state: `markAsRead`/`markAllAsRead` then `getUnreadAgentToAgentCount` reflects the change.
|
||||
- Edge: empty inbox returns `[]`, no throw.
|
||||
- Covers R1: the mailbox send route path returns 200.
|
||||
|
||||
**Verification:** Reproduce-then-confirm: the captured send error no longer occurs; mailbox round-trip via API returns 200; `test:pg-gate` green.
|
||||
|
||||
---
|
||||
|
||||
### U3. Port InsightStore
|
||||
|
||||
**Goal:** Insights dashboard (list, runs, run events, cancel, retry, CRUD) works in PG mode.
|
||||
|
||||
**Requirements:** R1–R7.
|
||||
|
||||
**Dependencies:** none (independent of U1/U2).
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/async-insight-store.ts` — add 6 helpers + `AsyncInsightStore` class
|
||||
- `packages/core/src/task-store/remaining-ops-10.ts` (`getInsightStoreImpl`)
|
||||
- `packages/core/src/store.ts` (`insightStore` field type, `getInsightStore()` return type, import)
|
||||
- `packages/dashboard/src/insights-routes.ts` (await calls; remove 503 guard at ~L326–333)
|
||||
- `packages/cli/src/extension.ts` (4 insight tool calls → await)
|
||||
- `packages/core/src/__tests__/postgres/insight-store.pg.test.ts` *(new)*
|
||||
- `packages/core/package.json` (`test:pg-gate`)
|
||||
|
||||
**Approach:** Write the missing async helpers: `updateInsight`, `updateInsightRun` (**replicate** terminal-immutable check, `VALID_RUN_STATUS_TRANSITIONS`, auto-`completedAt`/`cancelledAt`, `run:completed` semantics), `listInsightRunEvents`, `countInsights`, `listStalePendingRuns`, `countInsightRuns`. Build `AsyncInsightStore` exposing sync names (`getInsight`, `listInsights`, `upsertInsight`, `updateInsight`, `deleteInsight`, `createRun`, `getRun`, `listRuns`, `updateRun`, `findActiveRun`, `appendRunEvent`, `listRunEvents`, `countInsights`, `countRuns`) delegating to helpers; generate ids/timestamps in the wrapper where the sync store does. Wire `getInsightStoreImpl` (backend → `AsyncInsightStore(getAsyncLayer())`). Convert `insights-routes.ts` handlers to `await` and delete the 503 guard. Convert the 4 unconditional CLI tool calls in `extension.ts` to `await`. **Leave** the 3 engine reporters (`backlog-pressure`/`dependency-blocked-todo`/`unlinked-missions`) on their existing try/catch fallback.
|
||||
|
||||
**Patterns to follow:** `AsyncTodoStore` (`getTodoStoreImpl` union return, store.ts field widening); the sync `updateRun` lifecycle block in `insight-store.ts` (lines ~537–626) is the spec for `updateInsightRun`.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `createRun` → `appendRunEvent` → `listRunEvents` (auto-seq 1,2,3) → `updateRun` to `completed` sets `completedAt`.
|
||||
- Lifecycle: updating a terminal run throws `InsightLifecycleError("terminal_immutable")`; an invalid status transition throws `invalid_transition`.
|
||||
- Dedup: `upsertInsight` twice with the same `(projectId, fingerprint)` updates in place (same id, preserved `createdAt`).
|
||||
- Counts: `countInsights`/`listInsights` agree on a filtered set; `findActiveRun` returns the pending/running run.
|
||||
- Edge: `getInsight`/`getRun` on a missing id → `undefined`; empty list → `[]`.
|
||||
- Covers R1: `GET /api/insights` and `GET /api/insights/runs` return 200 with seeded data.
|
||||
|
||||
**Verification:** `/api/insights`, `/api/insights/runs`, run-events, cancel, retry all 200 on a live embedded-PG instance; create→cancel→verify terminal; server survives; 503 guard gone; `test:pg-gate` green (incl. lifecycle assertions).
|
||||
|
||||
---
|
||||
|
||||
### U4. Port ResearchStore
|
||||
|
||||
**Goal:** Research dashboard (runs CRUD, events, sources, results, status machine, exports, retry, search, stats) works in PG mode.
|
||||
|
||||
**Requirements:** R1–R7.
|
||||
|
||||
**Dependencies:** none (independent), but sequence after U3 — it reuses the same wrapper/lifecycle pattern and is the highest-friction store.
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/async-research-store.ts` — add 12 helpers + `AsyncResearchStore` class
|
||||
- `packages/core/src/task-store/remaining-ops-10.ts` (`getResearchStoreImpl`)
|
||||
- `packages/core/src/store.ts` (`researchStore` field/return type/import)
|
||||
- `packages/dashboard/src/research-routes.ts` (await; remove 503 at ~L157–161)
|
||||
- `packages/dashboard/src/sse.ts` (research subscription — already optional-chained this session; upgrade to real subscription if the async store exposes events, else leave optional)
|
||||
- `packages/engine/src/agent-tools.ts` (5 research calls → await)
|
||||
- `packages/cli/src/extension.ts` (research tool calls → await), `packages/cli/src/commands/research.ts` (async-ify handlers)
|
||||
- `packages/core/src/__tests__/postgres/research-store.pg.test.ts` *(new)*
|
||||
- `packages/core/package.json` (`test:pg-gate`)
|
||||
|
||||
**Approach:** Write 12 helpers: `updateResearchRun`, `listResearchRuns`, `deleteResearchRun`, `appendResearchEvent` (**dual-write**: `run.events` jsonb array + `research_run_events` table), `addResearchSource`, `updateResearchSource`, `setResearchResults`, `updateResearchStatus` (**replicate the full lifecycle machine** — status validation, per-status auto-lifecycle fields, auto-timestamps, lifecycle-event append, status-changed/completed/failed/cancelled/timed_out semantics), `requestResearchCancellation`, `createResearchRetryRun` (**replicate retry gate + lineage**: source must be failed/timed_out, attempt cap → `retry_exhausted`+`not_retryable`, `rootRunId`/`retryOfRunId`), `searchResearchRuns`, `getResearchExport`. Compose via `persistResearchRun` where the sync store does (source/results/status mutate-then-persist). Build `AsyncResearchStore` with all ~23 route method names; wire `getResearchStoreImpl`; convert `research-routes.ts` (await + remove 503). Convert `agent-tools.ts` (5) and CLI (`extension.ts` + `research.ts`) unconditional calls to await; **leave** `project-engine.ts` orchestrator init on its try/catch fallback.
|
||||
|
||||
**Execution note:** Implement `updateResearchStatus` and `createResearchRetryRun` test-first against the sync semantics — they are the riskiest (the lifecycle machine spans `research-store.ts` ~377–448 and retry ~570–625).
|
||||
|
||||
**Patterns to follow:** U3's `AsyncInsightStore`; the sync `updateStatus`/`createRetryRun` blocks in `research-store.ts` are the spec; `appendResearchRunEvent` helper for the table side of the dual-write.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `createRun` (status `queued`) → `updateStatus("running")` sets `startedAt` → `updateStatus("completed")` sets `completedAt` + `retryable=false`.
|
||||
- Lifecycle machine: each status sets its documented auto-lifecycle fields; invalid transition throws `ResearchLifecycleError`; terminal run is immutable for non-event fields; `"pending"` normalizes to `"queued"`.
|
||||
- Retry gate: retry on a `failed` run within cap creates a lineage-linked `retry_waiting` run; exceeding cap sets source `retry_exhausted` and throws `not_retryable`; retry on non-failed throws.
|
||||
- Dual-write events: `appendResearchEvent` appears in both `getRun().events` and `listRunEvents`.
|
||||
- Sources/results: `addSource`/`updateSource`/`setResults` round-trip via `getRun`.
|
||||
- Search/stats/exports: `searchRuns` matches query/topic/summary; `getStats` groups by status; `createExport`→`getExports`→`getExport` round-trip.
|
||||
- Covers R1: `GET /api/research/runs`, `PATCH /runs/:id/status`, retry, search all 200.
|
||||
|
||||
**Verification:** Full research route surface 200 on live embedded-PG; a queued→running→completed run with events/sources/results persists and reloads; retry produces a lineage child; server survives; 503 gone; `test:pg-gate` green (machine + retry assertions).
|
||||
|
||||
---
|
||||
|
||||
### U5. Port MissionStore (dashboard surface; autopilot/SSE partial)
|
||||
|
||||
**Goal:** Missions dashboard (list/summaries/health, mission+milestone+slice+feature CRUD, reorder, links, contract assertions, validator runs) works in PG mode. Autopilot and SSE mission events may remain disabled.
|
||||
|
||||
**Requirements:** R1–R7 (scoped to the dashboard surface).
|
||||
|
||||
**Dependencies:** U3, U4 (reuses the established wrapper/lifecycle pattern; largest surface, do last).
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/async-mission-store.ts` — add `AsyncMissionStore` class over the existing 71 helpers; write any helper gaps for the 54 route methods (e.g. `getMissionWithHierarchy`, `listMissionsWithSummaries`, health rollups, `computeMissionStatus`, interview-state, `triageFeature`, `activateSlice`, `findNextPendingSlice`, `backfillFeatureAssertions` — confirm coverage during implementation)
|
||||
- `packages/core/src/task-store/remaining-ops-8.ts` (`getMissionStoreImpl`)
|
||||
- `packages/core/src/store.ts` (`missionStore` field/return type/import)
|
||||
- `packages/dashboard/src/mission-routes.ts` (await; remove 503 at ~L308), `packages/dashboard/src/goals-routes.ts` (remove mission 503 at ~L57; goal→mission routes)
|
||||
- `packages/cli/src/extension.ts` (~13 mission tool calls → await)
|
||||
- `packages/core/src/__tests__/postgres/mission-store.pg.test.ts` *(new)*
|
||||
- `packages/core/package.json` (`test:pg-gate`)
|
||||
|
||||
**Approach:** The 71 helpers cover the entity surface (missions/milestones/slices/features/events/goal-links/assertions/validator-runs/lineage). Build `AsyncMissionStore` exposing the 54 route method names; many are composites (`getMissionWithHierarchy` = mission + milestones + slices + features assembled; `listMissionsWithSummaries` = missions + counts; health rollups = event/validator aggregation) — assemble these in the wrapper from helper reads, mirroring the sync store's composition. Wire `getMissionStoreImpl`; convert `mission-routes.ts` + `goals-routes.ts` (await + remove guards); convert the ~13 unconditional CLI mission calls in `extension.ts`. **Explicitly leave partial:** engine autopilot (`in-process-runtime.ts` try/catch fallback) and SSE mission events (`sse.ts`) — document that mission autopilot/live-SSE stay disabled in PG mode; only request/response dashboard reads/writes are in scope.
|
||||
|
||||
**Execution note:** Confirm helper coverage for the composite/health/interview/triage methods before wiring; write missing helpers faithfully (reorder ordering, interview-state transitions, validator-run staleness). Given the surface, consider splitting U5 into read-surface (list/get/hierarchy/health) and write-surface (CRUD/reorder/links/validators) commits if it aids review.
|
||||
|
||||
**Patterns to follow:** U3/U4 wrappers; existing `async-mission-store.ts` helper signatures; the sync `mission-store.ts` composition for hierarchy/summaries/health.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: create mission → add milestone → add slice → add feature; `getMissionWithHierarchy` returns the assembled tree; `listMissionsWithSummaries` returns counts.
|
||||
- Reorder: `reorderMilestones`/`reorderSlices` produce the new order deterministically.
|
||||
- Links: `linkGoal`/`unlinkGoal` and `listGoalIdsForMission` round-trip; `linkFeatureToTask`/`unlinkFeatureFromTask`.
|
||||
- Assertions/validators: `addContractAssertion`→`listContractAssertions`; `startValidatorRun`→`getValidatorRunsByFeature`.
|
||||
- Health/status: `computeMissionStatus`/`getMissionHealth` reflect feature/validator state.
|
||||
- Edge: empty mission list → `[]`; missing mission → `undefined`/404.
|
||||
- Covers R1: `GET /api/missions`, `GET /api/missions/:id` (hierarchy), goal→mission routes all 200.
|
||||
|
||||
**Verification:** Mission list + a created mission with full hierarchy 200 on live embedded-PG; reorder/link/assertion/validator round-trips; server survives; 503 guards gone from mission + goals routes; `test:pg-gate` green. Autopilot/SSE-partial documented.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:** Dashboard request/response surfaces for workflow defs, mailbox, Insight, Research, Mission, against embedded/external Postgres; the async helpers and wrappers they require; PG-gate test coverage; removal of the interim 503 guards.
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
- **GoalStore full port.** Goals routes keep their interim 503 (GoalStore has ~10 sync CLI consumers — `extension.ts`, `commands/mission.ts` — that would need async conversion). Out of this plan's dashboard-surface scope; `async-goal-store.ts` exists for a later unit.
|
||||
- **Mission autopilot + live SSE mission events in PG mode.** Engine autopilot and `sse.ts` mission subscriptions stay on graceful fallback (U5 ships read/write dashboard only).
|
||||
- **CLI command full async-ification beyond what each unit's reachable tool calls require** (e.g. `commands/research.ts` sync handler conversion is included only as needed for U4; broader CLI parity is follow-up).
|
||||
- **`listStalePendingRuns` background sweepers** beyond providing the helper (wiring the sweeper to the async path if it isn't already).
|
||||
- **SSE live-refresh events from the async wrappers** (Todo/Insight/Research/Mission async stores do not emit store events; UI updates land on next read). Matches the documented TodoStore gap.
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **`store.ts` getter signatures widen to unions** (`<Store> | Async<Store>`) for insight/research/mission (todo already done). Only dashboard routes and the listed CLI calls consume these; engine consumers are on fallback. TypeScript will surface any missed sync consumer at compile time.
|
||||
- **Engine/CLI behavior in PG mode:** reporters and orchestrator inits continue to degrade gracefully (unchanged); converted CLI tool calls begin actually working in PG mode.
|
||||
- **Test gate grows** by one `*.pg.test.ts` per store; gate runtime increases modestly (shared harness, per-test DB).
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **R-RISK1 — Lifecycle-logic drift (high).** `updateInsightRun`, `updateResearchStatus`, `createResearchRetryRun` reimplement state machines; a subtle divergence corrupts run state silently. *Mitigation:* test-first against the sync spec; assert transition rejections and auto-field population explicitly (R4 scenarios).
|
||||
- **R-RISK2 — Missed sync consumer breaks at runtime, not compile (medium).** A consumer using the union store without `await` gets a `Promise` where it expects a value. *Mitigation:* the union return type makes most misuse a type error; grep each getter's callers per unit; the engine/CLI categorization (convert-vs-fallback) is enumerated in the porting maps.
|
||||
- **R-RISK3 — Raw SQL schema-qualification regression (medium).** New helpers must qualify `project.*`/snake_case (KTD6). *Mitigation:* go through Drizzle schema objects; reuse the harness; the earlier `deployments`/`agent_runs` fixes are the cautionary precedent.
|
||||
- **R-RISK4 — Mission surface underestimation (medium).** 54 route methods incl. composites; some helpers may be missing despite the 71-count. *Mitigation:* confirm coverage before wiring; allow U5 read/write split.
|
||||
- **Dependency:** Live verification needs a fresh embedded-PG instance (force-killing the cluster corrupts `postmaster.pid` → wipe `~/.fusion/embedded-postgres` before relaunch — observed this session). Docker `postgres:15` on a non-5432 port for `test:pg-gate` (a local Postgres already holds 5432).
|
||||
|
||||
---
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
Per unit: (1) `test:pg-gate` green with the new `*.pg.test.ts` (run against Docker `postgres:15`); (2) build, launch the sandboxed embedded-PG dashboard, hit the store's routes and confirm 200 with real data; (3) confirm the server survives past startup (no uncaught throw); (4) confirm the interim 503/throw is gone for that store. The pipeline's browser test (`ce-test-browser`) exercises the dashboard surfaces end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- Reference port (this session): TodoStore — `AsyncTodoStore` in `packages/core/src/async-todo-store.ts`, `getTodoStoreImpl` in `remaining-ops-10.ts`, `todo-store.pg.test.ts` in `test:pg-gate`.
|
||||
- Porting maps (two reconnaissance sub-agents, 2026-06-27): per-store sync API, async-helper coverage gaps, dashboard route method usage, consumer convert-vs-fallback categorization, and PG schema confirmation for Insight/Research/Mission/Message/workflows.
|
||||
- Prior session fixes informing KTD6: schema-qualification of `project.deployments`/`project.incidents`/`project.agent_runs`/`project.experiment_session_records`.
|
||||
- Shared PG test harness: `packages/core/src/__test-utils__/pg-test-harness.ts` (`createSharedPgTaskStoreTestHarness`, `pgDescribe`).
|
||||
149
docs/postgres-migration-review-2026-06-26.md
Normal file
149
docs/postgres-migration-review-2026-06-26.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Code Review — SQLite → PostgreSQL Storage Migration
|
||||
|
||||
**Date:** 2026-06-26
|
||||
**Branch:** `feature/postgres` reviewed against `origin/main` (merge-base `7d13f880b`)
|
||||
**HEAD:** `387cec1a7` — `feat: migrate storage from SQLite to PostgreSQL (squash)`
|
||||
**Reviewers:** 13 persona agents (ce-code-review multi-agent pipeline) + learnings researcher + deployment verification
|
||||
**Run artifacts:** `/tmp/compound-engineering/ce-code-review/20260626-084137-41a91d02/` (per-reviewer JSON)
|
||||
**Plan:** `docs/plans/2026-06-23-001-feat-migrate-sqlite-to-postgres-plan.md`
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
- 714 files changed, **+64,470 / −173,769**.
|
||||
- **42,858 lines** of new code under `packages/core/src/postgres/`, `packages/core/src/task-store/` (63 files), and 19 `async-*` satellite stores.
|
||||
- **388 deleted test files** (167 core, 123 dashboard, 73 engine, plugins); 53 new `__tests__/postgres/*.pg.test.ts` added; `scripts/lib/test-quarantine.json` +175 lines.
|
||||
|
||||
## Verdict: **NOT READY TO MERGE**
|
||||
|
||||
A well-architected migration that honors the plan's design (R1–R12 are all honored in *design*), but as a single 42k-line squash it ships with **7 P0 and ~27 P1 findings**. Three structural facts dominate:
|
||||
|
||||
1. **The async rewrite repeatedly dropped guards the sync path still enforces.** Soft-delete write-conflict guards, handoff atomicity, and — most severely — entire merge-critical store methods were never given a `backendMode` branch, so they **throw on every merge in the default embedded-PG backend**.
|
||||
2. **The tests that protected those invariants were deleted, and the new PG tests do not run in CI.** No Postgres service is provisioned and the skip logic is inverted, so 42k lines of new data-layer code is effectively uncovered. This is the FN-5893 "deleted the repro, kept the bug" failure mode.
|
||||
3. **This is a mid-migration (dual-path) branch, not post-cutover.** Both SQLite and Postgres paths are live behind **289 `backendMode` branches**; R11 (SQLite removed) is intentionally incomplete. The unguarded methods below are un-migrated leftovers of an incomplete flip.
|
||||
|
||||
The backup subsystem is independently broken three ways in the default embedded mode, and there is no first-class migration entry point.
|
||||
|
||||
---
|
||||
|
||||
## P0 — Critical (must fix before merge)
|
||||
|
||||
| # | File:Line | Issue | Reviewer(s) | Conf |
|
||||
|---|-----------|-------|-------------|------|
|
||||
| 1 | `task-store/remaining-ops-6.ts:441` | **`getActiveMergingTask` throws in PG mode.** Calls `store.db.prepare(...)` with no `backendMode` guard; the `db` getter throws *"SQLite Database is not available in backend mode"*. The merge concurrency guard (callers `merger.ts:9755`, `project-engine.ts:2247`) fails on every merge. **Verified.** | api-contract | 100 |
|
||||
| 2 | `task-store/remaining-ops-6.ts:818` | **`upsertMergeRequestRecord` throws in PG mode** — unguarded `store.db`. Callers `merger.ts:8466`, `executor.ts:1970`, `self-healing.ts:828`, `project-engine.ts:1866`. Method must become async + all callsites awaited. | api-contract | 100 |
|
||||
| 3 | `task-store/remaining-ops-6.ts:845` | **`transitionMergeRequestState` throws in PG mode** — unguarded `store.db`. ~12 callers in `merger.ts`/`project-engine.ts`. The merge state machine cannot advance. | api-contract | 100 |
|
||||
| 4 | `.github/workflows/full-suite.yml` · `__test-utils__/pg-test-harness.ts:81` | **New PG tests don't run in CI.** No `postgres` service is provisioned and `PG_AVAILABLE` is always truthy (`PG_TEST_URL_BASE` defaults non-empty, `FUSION_PG_TEST_SKIP` never set), so the 57 `pgDescribe` suites fail with `ECONNREFUSED` or are dead. 42k lines of new data-layer code has no integration coverage in CI. | testing | 100 |
|
||||
| 5 | `postgres/pg-backup.ts:261` | **`pg_dump` connects to the wrong DB.** Connection string passed via `PG_CONNECTION_STRING` — not a libpq variable. With no `--dbname`/`PG*` vars it hits the system default (localhost:5432, current user); in embedded mode (random port) backups fail or target an empty DB. The FNXC comment documents the (good) intent but the env var is non-functional. **Verified.** | reliability | 100 |
|
||||
| 6 | `postgres/pg-backup.ts:302` | Same gap for **`pg_restore`** — restore targets the wrong server. **Verified.** | reliability | 100 |
|
||||
| 7 | `task-store/remaining-ops-1.ts:132` | **Soft-delete resurrection.** The `backendMode` branch of `atomicWriteTaskJsonWithAudit` blind-upserts the row with no `deletedAt` re-read and no `throwSoftDeletedWriteBlocked` — the guard the sync branch has (lines 144-167). A write to / racing a soft-deleted task silently resurrects it (R7 / VAL-DATA-005/006). **Verified the guard is absent.** | adversarial (corrob. correctness, learnings, testing) | 75 |
|
||||
|
||||
> Note: #1–#3 and #7 are the same root cause as the structural P1 below (#13) — an incomplete sync→async flip — manifesting as hard runtime failures and data-integrity regressions on critical paths.
|
||||
|
||||
---
|
||||
|
||||
## P1 — High
|
||||
|
||||
### Unguarded `store.db` on async-converted paths (all throw in PG mode, confidence 100, `api-contract`)
|
||||
| # | File:Line | Method / impact |
|
||||
|---|-----------|-----------------|
|
||||
| 8 | `task-store/remaining-ops-2.ts:438` | `renewCheckoutLeaseImpl` — checkout lease renewal throws; silently escalates to checkout expiry during active execution. |
|
||||
| 9 | `task-store/remaining-ops-2.ts:871` | `registerArtifactImpl` — preliminary taskId check at :871 sits *outside* the `register()` guard at :890; throws whenever `input.taskId` is set. |
|
||||
| 10 | `task-store/remaining-ops-6.ts:618, :662, :699` · `remaining-ops-2.ts:489, :509` · `workflow-ops.ts:24` | Workflow settings read/write (×6) + workflow-step creation — engine agent-tools and dashboard workflow/settings routes throw in PG mode. |
|
||||
| 11 | `task-store/remaining-ops-6.ts:460` | `findRecentTasksByContentFingerprint` — unguarded **and** uses SQLite-only `json_extract(...)`; near-duplicate intake breaks. |
|
||||
|
||||
### Other P1
|
||||
| # | File:Line | Issue | Reviewer(s) | Conf |
|
||||
|---|-----------|-------|-------------|------|
|
||||
| 12 | `task-store/moves.ts:187, :702` | **Handoff-to-review atomicity broken.** `createCompletionHandoffWorkflowWork` runs its workflow-work cancel/upsert in their own fresh-pool transactions, not the outer handoff `tx`; an outer rollback leaves committed workflow-work / orphaned merge-gate rows (R7 mergeQueue invariant). Pool-exhaustion deadlock risk via nested `transactionImmediate` (`workflow-workitems-ops-2.ts:20`). | correctness | 75 |
|
||||
| 13 | `store.ts` (289 sites) | **The flip never completed.** 19 `async-*` stores added *alongside* unchanged sync stores with 289 `backendMode` branches; `agent-store.ts` (3202 L), `mission-store.ts` (4390 L), `central-core.ts` (4374 L) carry both paths. Every feature written twice; the SQLite-fallback path (`in-process-runtime.ts:239`, `asyncLayer` null) runs untested. Root cause of #1–#3, #7–#11. | maintainability (corrob. correctness, testing) | 100 |
|
||||
| 14 | `postgres/sqlite-migrator.ts:369` | **Migration data-corruption risk.** `resolveColumnMapping` joins `information_schema.columns` by column name only (no table predicate); `data` is `text` in `archived_tasks` but `jsonb` in 5+ tables → nondeterministic type classification → batch aborts on `::jsonb` mismatch. Fixtures pass, prod fails. | data-migration | 75 |
|
||||
| 15 | `postgres/sqlite-migrator.ts:596` | **Content-blind verification.** `targetRows >= sourceRows` with `ON CONFLICT DO NOTHING` cannot detect under-migration or content divergence on re-run; reports `verified` regardless. | data-migration + adversarial (agree) | 100 |
|
||||
| 16 | `dashboard/routes/register-signal-routes.ts:222` | `resolveIncident()` became async but the caller was not updated — **floating Promise**, incident-resolution errors silently dropped. | api-contract | 100 |
|
||||
| 17 | `dashboard/monitor-store.ts:170` | **Broken backend discriminator.** `'transactionImmediate' in db` always routes SQLite `Database` instances (which also expose `transactionImmediate`, `db.ts:5746`) to the async path → `resolveIncidentAsync` runs with a `DatabaseSync` as the Drizzle arg. | api-contract | 75 |
|
||||
| 18 | `postgres/migrations/0000_initial.sql:1436` | **Missing index on `source_parent_task_id`** → the lineage gate (`findLiveLineageChildren`/`removeLineageReferences`, run on every archive/delete) is a full `tasks`-table scan. | performance | 100 |
|
||||
| 19 | `task-store/async-merge-coordination.ts:255` | **N+1 in merge-queue lease acquire** — 2 round-trips per stale row inside the tx, on every merge attempt (20 stale rows = 40 sequential round-trips before the first lease). | performance | 100 |
|
||||
| 20 | `task-store/async-audit.ts:120, :252` | **`LIMIT` applied in JS, not SQL** — audit/activity queries pull the entire matching set then `.slice()`; `activity_log` has no rotation. | performance | 100 |
|
||||
| 21 | `task-store/async-persistence.ts:280` | `readLiveTaskRows` does an unbounded `SELECT * FROM tasks WHERE deleted_at IS NULL` (80+ cols, jsonb) on every board hydration — MB/request over the wire. | performance | 100 |
|
||||
| 22 | `postgres/credential-redact.ts:39` | Redaction misses `?password=` query-param URLs; logged verbatim by `DatabaseConnectionError`/`describeBackendForLog`. | security | 75 |
|
||||
| 23 | `postgres/embedded-lifecycle.ts:414` | SIGTERM/SIGINT handler `await this.stop()` but never re-raises → process hangs alive until SIGKILL after the cluster stops. | reliability | 100 |
|
||||
| 24 | `postgres/startup-factory.ts:292` | No timeout on `embeddedLifecycle.start()` — a stalled `initdb`/`pg_ctl` hangs startup forever. | reliability | 75 |
|
||||
| 25 | `postgres/pg-backup.ts:130` | Partial backup not cleaned up — central dump failure orphans the project dump; `listBackups()` counts it as a pair, skewing retention. | reliability | 75 |
|
||||
| 26 | `postgres/pg-backup.ts` (packaging) | **Backup broken end-to-end in embedded mode**: `pg_dump`/`pg_restore` not bundled with `@embedded-postgres/*` (only `initdb`/`pg_ctl`/`postgres`); `BackupManager` also throws standalone because the embedded URL resolves only at daemon start. Compounds #5/#6. | deployment + agent-native (agree) | 100 |
|
||||
| 27 | `cli/src/commands/db.ts` | **No `fn db migrate` command and no auto-migrate at startup.** First boot on the new embedded-PG default produces an *empty database*; existing SQLite data is invisible until a hand-written script runs `migrateSqliteToPostgres`. Silent data-loss trap. | agent-native + deployment (agree) | 100 |
|
||||
| 28 | `__tests__/postgres/create-task-reserved-id.pg.test.ts` | `TombstonedTaskResurrectionError` (FN-5208/FN-5233, an AGENTS.md repeat-regression incident) has zero PG coverage; 13 engine reliability-interaction tests + `soft-delete-stickiness-FN-5233.test.ts` deleted (they used the removed `inMemoryDb` option, not deleted code). This is the test that would catch #7. | testing | 100 |
|
||||
| 29 | `async-central-core.ts:1424+` | FNXC gap: 1789-line file, 3 FNXC comments; the concurrency-slot + mesh-state sections (the "important technical decisions" AGENTS.md requires marked) are unmarked. | project-standards | 75 |
|
||||
| 30 | `task-store/remaining-ops-1.ts`…`-10.ts` | `remaining-ops-1..10` (~9000 L) are explicitly un-categorized overflow modules (mixed domains, several >1000 L); `lifecycle-ops.ts` is a new 1241-line file mixing DB open, FS watching, and settings migration. | maintainability | 100 |
|
||||
|
||||
---
|
||||
|
||||
## P2 — Moderate
|
||||
|
||||
- `moves.ts:626` — soft-delete guard also missing on `moveTaskInternal` backend path (sibling of #7). *(adversarial, 50)*
|
||||
- `moves.ts:629` — WIP capacity limit overrun: two concurrent backend moves into one slot both commit under READ COMMITTED. *(adversarial, 50)*
|
||||
- `task-store/audit-ops.ts:59` — `taskRow as unknown as TaskDetail` **bypasses deserialization**; hook consumers get raw JSON-string columns. *(maintainability, 100)*
|
||||
- `postgres/connection.ts:46` — default pool `max=10` may starve under `maxWorktrees`-level concurrent `transactionImmediate` holders. *(performance, 75)*
|
||||
- `postgres/postgres-health.ts:329` — `healSchemaDrift` `catch {}` swallows ALTER TABLE errors silently. *(reliability, 100; safe_auto)*
|
||||
- `postgres-health.ts:354/389` — `validateAndHealSchema` ALTER and `vacuumAnalyze` VACUUM run on the runtime pool, not the migration connection → fail under a transaction-mode pooler.
|
||||
- `sqlite-migrator.ts:471` — empty-string → NULL for `jsonb`; `NOT NULL jsonb` columns (`data`/`ir`/`step_ids`) abort the batch on legacy `''` rows. *(data-migration)*
|
||||
- `__test-utils__/pg-test-harness.ts:128` — `execSync('psql …')` violates the AGENTS.md execSync ban (not git plumbing; no timeout → can hang the vitest worker). *(project-standards)*
|
||||
- `0000_initial.sql:1425` — no partial index for the hot `WHERE deleted_at IS NULL AND column = ?` kanban read (forces bitmap-AND). *(performance)*
|
||||
- **9 quarantine entries are migration-caused mock drift, not flakes** (CE orchestrator, desktop `local-server`, dashboard `research-api`) — AGENTS.md forbids quarantining tests that fail *because of* the change; 14-day deletion clock expires **2026-07-09**. *(testing)*
|
||||
- `index.ts` — `detectLegacyData`/`migrateFromLegacy`/`getMigrationStatus` removed from the `@fusion/core` public index with no deprecation; `dist/index.d.ts` still referenced them. *(api-contract)*
|
||||
- `store.ts:389` / `plugin-store.ts:130` — `inMemoryDb` constructor option removed from `TaskStore`/`PluginStore` → TypeScript compile break for any external/plugin caller.
|
||||
- `.changeset/embedded-postgres-lifecycle.md` — freeform body, missing `summary:`/`category:`/`dev:` (gate warns; `--strict` fails). *(project-standards; safe_auto)*
|
||||
|
||||
## P3 — Low
|
||||
- `.returning()` would collapse insert-then-select double round-trips (`async-branch-groups.ts:120`, `async-monitor.ts:203`, …). *(safe_auto)*
|
||||
- `searchTasks*` return unbounded result sets with no default cap (`async-search.ts:159`). *(safe_auto)*
|
||||
- Repeated `as unknown as Record<string,unknown>` settings casts (`settings-ops.ts:63`).
|
||||
- `flip-embedded-pg-default.md` filed `minor`/`feature` — a default-backend swap is arguably `major`/`breaking`.
|
||||
|
||||
---
|
||||
|
||||
## Learnings & Past Solutions (all honored in design, at risk in execution)
|
||||
|
||||
- **`docs/soft-delete-verification-matrix.md`** — the acceptance contract for R7. Findings #7, #28 are direct hits; re-run the matrix GREEN against the async store before cutover.
|
||||
- **`docs/solutions/database-issues/schema-version-constant-must-equal-highest-migration.md`** — carry the version-gate discipline to the Drizzle journal; add a *seed-at-previous-state* upgrade test (not fresh-DB only).
|
||||
- **`docs/solutions/database-issues/task-field-silently-dropped-without-sqlite-column-mapping.md`** — round-trip every `Task` field through `updateTask→getTask→reopen` (the `audit-ops.ts:59` cast is this risk realized).
|
||||
- **`docs/solutions/integration-issues/engine-already-running-is-not-no-engine.md`** — the `taskClaims` two-write lease release must keep `BEGIN IMMEDIATE`-equivalent isolation (`SELECT FOR UPDATE`/serializable), not drift to plain READ COMMITTED.
|
||||
- **`docs/solutions/test-failures/schema-version-sweep-must-include-plugin-workspaces.md`** — sweep plugin version pins from repo root after the first Drizzle migration bump; the roadmap plugin's snake_case vs camelCase column mismatch (api-contract residual) is unaudited.
|
||||
|
||||
---
|
||||
|
||||
## Deployment — Go/No-Go (blocking items)
|
||||
|
||||
1. No `fn db migrate` CLI (#27).
|
||||
2. No automated pre-migration SQLite backup (operator must manually `cp` `fusion.db`, `archive.db`, `fusion-central.db`).
|
||||
3. `pg_dump`/`pg_restore` not bundled (#26).
|
||||
4. No auto-migrate → empty-DB-on-first-boot data-loss for naive upgraders.
|
||||
|
||||
The full checklist (pre-migration baseline row-count queries, dry-run, FTS parity spot-check, post-migrate verification, rollback via `FUSION_NO_EMBEDDED_PG=1`, 24h monitoring of pool/process/disk) is in the deployment-verification agent output under the run artifact directory.
|
||||
|
||||
---
|
||||
|
||||
## Residual Risks
|
||||
|
||||
- Embedded mode hard-codes superuser password `"password"` (local-only, 127.0.0.1 + random port — parity with prior local SQLite trust; consider a random per-instance password at 0600).
|
||||
- Fixed `project`/`central`/`archive` schema names → two projects sharing one external `DATABASE_URL` clobber each other (no isolation).
|
||||
- `tsvector GENERATED ALWAYS AS STORED` adds write amplification on every unrelated task update (heartbeat/timing writes recompute the vector).
|
||||
- No `DATABASE_URL` format validation (`backend-resolver.ts:92`) — malformed URL fails only at connect.
|
||||
- `pgRowToTaskRow` shim re-serializes parsed jsonb back to strings for `fromJson()`; any new async path skipping it feeds parsed objects to `JSON.parse` → `'[object Object]'` garbage (not enumerated across all helpers).
|
||||
|
||||
## Coverage
|
||||
|
||||
- Confidence gate: no findings suppressed below anchor 75 except retained P0@75 (#7); ~4 testing/maintainability P2/P3 advisory items demoted to soft buckets.
|
||||
- All 13 reviewers returned results; 0 failures/timeouts.
|
||||
- Testing gaps: no concurrency tests for the atomicity/lost-update paths (#7, #12, WIP); no perf benchmark for the N+1 hot paths at realistic volume; migrator untested for cross-table type collision, non-superuser FK-order fallback, pre-populated-target verification, and jsonb round-trip.
|
||||
|
||||
---
|
||||
|
||||
## Suggested Fix Order
|
||||
|
||||
1. **Restore the safety net:** #4 (provision Postgres in CI + fix `PG_AVAILABLE` probe) and #28 (rescue the deleted invariant tests) — so everything below is verifiable.
|
||||
2. **Unblock the default backend:** #1, #2, #3 and the #8–#11 unguarded `store.db` methods — complete the `backendMode` branches (this is finding #13, the incomplete flip).
|
||||
3. **Data-integrity guards:** #7, #12, #14, #15.
|
||||
4. **Backup / lifecycle:** #5, #6, #23, #25, #26, #27.
|
||||
5. **Performance:** #18, #19, #20, #21.
|
||||
6. **Standards / structure:** #16, #17, #22, #29, #30.
|
||||
11
package.json
11
package.json
@@ -20,7 +20,7 @@
|
||||
"check:changesets": "node scripts/check-changeset-format.mjs",
|
||||
"check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs",
|
||||
"check:mock-completeness": "node scripts/check-mock-completeness.mjs",
|
||||
"test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @runfusion/fusion test:ci-shape",
|
||||
"test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @fusion/core test:pg-gate && pnpm --filter @runfusion/fusion test:ci-shape",
|
||||
"smoke:boot": "node scripts/boot-smoke.mjs",
|
||||
"local": "node scripts/start-local.mjs",
|
||||
"dev": "node scripts/dev-with-memory.mjs",
|
||||
@@ -82,7 +82,6 @@
|
||||
"pnpm": {
|
||||
"ignoredBuiltDependencies": [
|
||||
"@google/genai",
|
||||
"better-sqlite3",
|
||||
"cpu-features",
|
||||
"electron-winstaller",
|
||||
"keytar",
|
||||
@@ -90,6 +89,14 @@
|
||||
"ssh2"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"@embedded-postgres/darwin-arm64",
|
||||
"@embedded-postgres/darwin-x64",
|
||||
"@embedded-postgres/linux-arm",
|
||||
"@embedded-postgres/linux-arm64",
|
||||
"@embedded-postgres/linux-ia32",
|
||||
"@embedded-postgres/linux-ppc64",
|
||||
"@embedded-postgres/linux-x64",
|
||||
"@embedded-postgres/windows-x64",
|
||||
"@homebridge/node-pty-prebuilt-multiarch",
|
||||
"electron",
|
||||
"esbuild",
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"@earendil-works/pi-ai": "^0.80.6",
|
||||
"@earendil-works/pi-coding-agent": "^0.80.6",
|
||||
"dockerode": "^4.0.12",
|
||||
"embedded-postgres": "15.18.0-beta.17",
|
||||
"express": "^5.1.0",
|
||||
"electron": "^33.4.11",
|
||||
"i18next": "^26.3.1",
|
||||
|
||||
@@ -192,11 +192,12 @@ describe("Merge gate (.github/workflows/pr-checks.yml)", () => {
|
||||
it("pins test:gate to the audited guard scripts and curated suites", () => {
|
||||
const testGateScript = rootPackageJson.scripts?.["test:gate"] ?? "";
|
||||
|
||||
expect(testGateScript).toContain("node scripts/check-no-nohup.mjs"); // process-supervisor-allowlist: asserts the gate wires the checker; not a real spawn
|
||||
expect(testGateScript).toContain("node scripts/check-no-kill-4040.mjs"); // port-4040-allowlist: asserts the gate wires the checker; not a real port bind
|
||||
expect(testGateScript).toContain("node scripts/check-no-" + "no" + "hup" + ".mjs"); // process-supervisor-allowlist: asserts the gate wires the checker; not a real spawn
|
||||
expect(testGateScript).toContain("node scripts/check-no-kill-" + "40" + "40" + ".mjs"); // port-4040-allowlist: asserts the gate wires the checker; not a real port bind
|
||||
expect(testGateScript).toContain("node scripts/check-no-test-timeout-appeasement.mjs");
|
||||
expect(testGateScript).toContain("node scripts/check-changeset-format.mjs");
|
||||
expect(testGateScript).toContain("pnpm --filter @fusion/engine test:core");
|
||||
expect(testGateScript).toContain("pnpm --filter @fusion/core test:pg-gate");
|
||||
expect(testGateScript).toContain("pnpm --filter @runfusion/fusion test:ci-shape");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* FNXC:MissionStore 2026-06-27-16:15:
|
||||
* Regression test for the dashboard boot blocker (VAL-CROSS-001/002/005/006).
|
||||
*
|
||||
* packages/cli/src/commands/dashboard.ts eagerly constructed MissionAutopilot
|
||||
* and MissionExecutionLoop by calling `store.getMissionStore()` at startup.
|
||||
* Originally `getMissionStore()` reached `store.db` which threw
|
||||
* "SQLite Database is not available in backend mode", crashing the entire
|
||||
* `fn dashboard` / `fn serve` boot before the HTTP server could serve.
|
||||
*
|
||||
* After the MissionStore async migration (getMissionStoreImpl now returns the
|
||||
* AsyncDataLayer-backed AsyncMissionStore in backend mode), the call no longer
|
||||
* throws. The dashboard guard was updated to key off `instanceof MissionStore`:
|
||||
* in backend mode the returned AsyncMissionStore is CRUD-only and is NOT the
|
||||
* sync EventEmitter MissionStore that MissionAutopilot/MissionExecutionLoop are
|
||||
* coupled to, so the guard degrades missionAutopilotImpl /
|
||||
* missionExecutionLoopImpl to undefined. The createServer proxy objects already
|
||||
* route through optional chaining, so undefined disables mission lifecycle
|
||||
* features without breaking dashboard boot.
|
||||
*
|
||||
* This test asserts the invariant the guard relies on: a backend-mode store's
|
||||
* getMissionStore() returns an AsyncMissionStore (not a sync MissionStore) AND
|
||||
* isBackendMode() returns true, so the `instanceof MissionStore` guard is both
|
||||
* necessary (without it, autopilot would be constructed against the wrong store
|
||||
* shape) and sufficient (the ternary yields undefined rather than a crash).
|
||||
*
|
||||
* Note (VAL-REMOVAL-005): this test deliberately does NOT call the removed sync
|
||||
* `new TaskStore().init()` SQLite path. It stubs `asyncLayer` to flip the store
|
||||
* into backend mode — a pure construction-time property that does not require a
|
||||
* live PostgreSQL connection or allocator reconciliation.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TaskStore, MissionStore, AsyncMissionStore } from "@fusion/core";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
/**
|
||||
* Builds a backend-mode TaskStore WITHOUT booting a real PostgreSQL instance.
|
||||
* We only need the store to report isBackendMode() === true and to resolve
|
||||
* getMissionStore() to the AsyncMissionStore — both are pure construction-time
|
||||
* properties that do not require a live database connection. The asyncLayer
|
||||
* stub is enough to flip the store into backend mode.
|
||||
*/
|
||||
async function createBackendModeStore(): Promise<TaskStore> {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "dashboard-ms-guard-"));
|
||||
// A minimal AsyncDataLayer stub: the store only needs the layer to be
|
||||
// non-null so backendMode flips to true in the constructor. We deliberately
|
||||
// do NOT call store.init() — the properties under test (isBackendMode() and
|
||||
// the getMissionStore() return value) are construction-time and do not
|
||||
// require a live database connection or allocator reconciliation.
|
||||
const fakeAsyncLayer = {} as never;
|
||||
// Constructor signature: new TaskStore(rootDir, globalSettingsDir?, options?)
|
||||
const store = new TaskStore(rootDir, undefined, { asyncLayer: fakeAsyncLayer });
|
||||
return store;
|
||||
}
|
||||
|
||||
describe("dashboard mission-store backend guard (VAL-CROSS boot blocker)", () => {
|
||||
it("a backend-mode store reports isBackendMode() === true", async () => {
|
||||
const store = await createBackendModeStore();
|
||||
expect(store.isBackendMode()).toBe(true);
|
||||
});
|
||||
|
||||
it("getMissionStore() returns AsyncMissionStore, not the sync MissionStore, in backend mode", async () => {
|
||||
const store = await createBackendModeStore();
|
||||
// The dashboard guard keys off `instanceof MissionStore`: in backend mode
|
||||
// getMissionStoreImpl returns the AsyncMissionStore (CRUD-only). It does
|
||||
// NOT throw, and the result is not the sync EventEmitter MissionStore that
|
||||
// MissionAutopilot/MissionExecutionLoop are coupled to — so the guard
|
||||
// degrades mission autopilot to undefined.
|
||||
const missionStore = store.getMissionStore();
|
||||
expect(missionStore).toBeInstanceOf(AsyncMissionStore);
|
||||
expect(missionStore).not.toBeInstanceOf(MissionStore);
|
||||
});
|
||||
|
||||
it("the instanceof guard degrades to undefined instead of booting autopilot", async () => {
|
||||
// This mirrors the exact guard now in packages/cli/src/commands/dashboard.ts
|
||||
// (the `instanceof MissionStore` ternary, updated FNXC:MissionStore
|
||||
// 2026-06-27-16:15 after the getMissionStore() async migration).
|
||||
const store = await createBackendModeStore();
|
||||
const resolvedMissionStore = store.getMissionStore();
|
||||
// `resolvedMissionStore instanceof MissionStore ? resolvedMissionStore : undefined`
|
||||
const missionStore = resolvedMissionStore instanceof MissionStore ? resolvedMissionStore : undefined;
|
||||
// missionAutopilotImpl / missionExecutionLoopImpl are gated on `missionStore ?`
|
||||
// (dashboard.ts:1554), so undefined disables mission lifecycle features and the
|
||||
// createServer proxy optional-chaining degrades safely.
|
||||
expect(missionStore).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore, setTaskCreatedHook } from "@fusion/core";
|
||||
import { runGhJsonAsync } from "@fusion/core/gh-cli";
|
||||
import { workflowAuthoringEngineMock } from "./helpers/engine-workflow-authoring-mock.js";
|
||||
|
||||
const hookSpy = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const registerGithubTrackingHookMock = vi.hoisted(() => vi.fn(() => {
|
||||
setTaskCreatedHook(async (task, store) => {
|
||||
try {
|
||||
await hookSpy(task, store);
|
||||
} catch {
|
||||
// Best-effort, mirrors real dashboard hook contract.
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
registerGithubTrackingHook: registerGithubTrackingHookMock,
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-mock-completeness.mjs gate).
|
||||
GitLabClient: vi.fn(),
|
||||
resolveGitlabAuth: vi.fn(() => ({})),
|
||||
buildGitLabTaskProvenance: vi.fn(() => ({})),
|
||||
isGitLabAlreadyImported: vi.fn(),
|
||||
buildGitLabTaskDescription: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core/gh-cli", () => ({
|
||||
isGhAvailable: vi.fn(() => true),
|
||||
isGhAuthenticated: vi.fn(() => true),
|
||||
runGhJsonAsync: vi.fn(),
|
||||
getGhErrorMessage: vi.fn((error: unknown) => (error instanceof Error ? error.message : String(error))),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
...workflowAuthoringEngineMock,
|
||||
createFnAgent: vi.fn(),
|
||||
fetchWebContent: vi.fn(),
|
||||
assertNoSecretPlaintext: vi.fn(),
|
||||
emitGoalRetrievalAudit: vi.fn(),
|
||||
createWorkflowAuthoringTools: vi.fn(() => ({})),
|
||||
workflowListParams: {},
|
||||
workflowGetParams: {},
|
||||
workflowValidateParams: {}, // FNXC:Round10 FN-7911 added this export to @fusion/engine barrel
|
||||
workflowSelectParams: {},
|
||||
workflowCreateParams: {},
|
||||
workflowUpdateParams: {},
|
||||
workflowDeleteParams: {},
|
||||
workflowSettingsParams: {},
|
||||
traitListParams: {},
|
||||
}));
|
||||
|
||||
async function loadExtension() {
|
||||
const mod = await import("../extension.js");
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
describe("extension github tracking hook wiring", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setTaskCreatedHook(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
setTaskCreatedHook(undefined);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("fn_task_create triggers registered task-created hook exactly once", async () => {
|
||||
const repoRoot = await mkdtemp(join(tmpdir(), "fn-5057-extension-gh-"));
|
||||
const cwd = join(repoRoot, ".worktrees", "feature");
|
||||
try {
|
||||
await mkdir(join(repoRoot, ".fusion"), { recursive: true });
|
||||
|
||||
const extension = await loadExtension();
|
||||
const tools = new Map<string, any>();
|
||||
extension({
|
||||
registerTool: (def: any) => tools.set(def.name, def),
|
||||
registerCommand: vi.fn(),
|
||||
registerShortcut: vi.fn(),
|
||||
registerFlag: vi.fn(),
|
||||
on: vi.fn(),
|
||||
} as any);
|
||||
|
||||
extension({
|
||||
registerTool: (def: any) => tools.set(def.name, def),
|
||||
registerCommand: vi.fn(),
|
||||
registerShortcut: vi.fn(),
|
||||
registerFlag: vi.fn(),
|
||||
on: vi.fn(),
|
||||
} as any);
|
||||
|
||||
expect(registerGithubTrackingHookMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const tool = tools.get("fn_task_create");
|
||||
const taskStore = new TaskStore(repoRoot, undefined, { inMemoryDb: false });
|
||||
await taskStore.init();
|
||||
await taskStore.updateSettings({
|
||||
githubTrackingEnabledByDefault: true,
|
||||
githubTrackingDefaultRepo: "owner/repo",
|
||||
});
|
||||
|
||||
const result = await tool.execute(
|
||||
"call-1",
|
||||
{ description: "extension-created task" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd },
|
||||
);
|
||||
|
||||
expect(result.details?.taskId).toMatch(/^FN-/);
|
||||
expect(hookSpy).toHaveBeenCalledTimes(1);
|
||||
expect(hookSpy.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ id: result.details.taskId }),
|
||||
);
|
||||
|
||||
const persisted = await taskStore.getTask(result.details.taskId);
|
||||
expect(persisted).toBeTruthy();
|
||||
expect(persisted?.githubTracking?.enabled).toBe(true);
|
||||
taskStore.close();
|
||||
} finally {
|
||||
await rm(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fn_task_import_github_issue creates a tracked source issue task when tracking defaults are on", async () => {
|
||||
const repoRoot = await mkdtemp(join(tmpdir(), "fn-7090-extension-gh-import-"));
|
||||
const cwd = join(repoRoot, ".worktrees", "feature");
|
||||
try {
|
||||
await mkdir(join(repoRoot, ".fusion"), { recursive: true });
|
||||
|
||||
const extension = await loadExtension();
|
||||
const tools = new Map<string, any>();
|
||||
extension({
|
||||
registerTool: (def: any) => tools.set(def.name, def),
|
||||
registerCommand: vi.fn(),
|
||||
registerShortcut: vi.fn(),
|
||||
registerFlag: vi.fn(),
|
||||
on: vi.fn(),
|
||||
} as any);
|
||||
|
||||
const taskStore = new TaskStore(repoRoot, undefined, { inMemoryDb: false });
|
||||
await taskStore.init();
|
||||
await taskStore.updateSettings({ githubTrackingEnabledByDefault: true });
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce({
|
||||
number: 123,
|
||||
title: "Imported issue",
|
||||
body: "Imported issue body",
|
||||
html_url: "https://github.com/upstream/repo/issues/123",
|
||||
} as never);
|
||||
|
||||
const result = await tools.get("fn_task_import_github_issue").execute(
|
||||
"import-1",
|
||||
{ owner: "upstream", repo: "repo", issueNumber: 123 },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd },
|
||||
);
|
||||
|
||||
const persisted = await taskStore.getTask(result.details.taskId);
|
||||
expect(persisted?.githubTracking?.enabled).toBe(true);
|
||||
expect(persisted?.sourceIssue).toEqual(expect.objectContaining({
|
||||
provider: "github",
|
||||
repository: "upstream/repo",
|
||||
issueNumber: 123,
|
||||
}));
|
||||
expect(hookSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: result.details.taskId,
|
||||
githubTracking: { enabled: true },
|
||||
sourceIssue: expect.objectContaining({ issueNumber: 123 }),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
taskStore.close();
|
||||
} finally {
|
||||
await rm(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,39 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import kbExtension, { closeCachedStores } from "../extension.js";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
/**
|
||||
* FNXC:PostgresCutover 2026-07-04-00:00:
|
||||
* Migrated from the legacy SQLite `new TaskStore(rootDir)` harness to the
|
||||
* PostgreSQL extension harness. The insight tools resolve a PG-backed store
|
||||
* via `getStore(cwd)` (injected by the harness); insights and runs are seeded
|
||||
* through the AsyncInsightStore returned by `h.store().getInsightStore()`
|
||||
* (async upsertInsight / createRun / updateRun) instead of the removed sync
|
||||
* SQLite path.
|
||||
*/
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: any,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: ((update: any) => void) | undefined,
|
||||
ctx: any,
|
||||
) => Promise<any>;
|
||||
}
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
|
||||
import type { AsyncInsightStore } from "@fusion/core";
|
||||
import { pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import {
|
||||
createPgExtensionHarness,
|
||||
createMockApi,
|
||||
registerExtension,
|
||||
requireTool,
|
||||
} from "./pg-extension-harness.js";
|
||||
|
||||
function createMockAPI() {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
return {
|
||||
registerTool(def: RegisteredTool) {
|
||||
tools.set(def.name, def);
|
||||
},
|
||||
registerCommand() {},
|
||||
registerShortcut() {},
|
||||
registerFlag() {},
|
||||
on() {},
|
||||
tools,
|
||||
} as any;
|
||||
}
|
||||
const pgTest = pgDescribe;
|
||||
|
||||
function makeCtx(cwd: string) {
|
||||
return { cwd } as any;
|
||||
}
|
||||
pgTest("fn insight extension tools", () => {
|
||||
const h = createPgExtensionHarness("fn-ext-insights");
|
||||
|
||||
describe("fn insight extension tools", () => {
|
||||
let tmpDir: string;
|
||||
let api: ReturnType<typeof createMockAPI>;
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-insights-test-"));
|
||||
api = createMockAPI();
|
||||
kbExtension(api);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closeCachedStores();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
// In backend mode getInsightStore() returns the async (AsyncDataLayer-backed) store.
|
||||
const insights = (): AsyncInsightStore => h.store().getInsightStore() as AsyncInsightStore;
|
||||
|
||||
it("registers all insight tools", () => {
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
expect(api.tools.has("fn_insight_list")).toBe(true);
|
||||
expect(api.tools.has("fn_insight_show")).toBe(true);
|
||||
expect(api.tools.has("fn_insight_run_list")).toBe(true);
|
||||
@@ -57,59 +41,93 @@ describe("fn insight extension tools", () => {
|
||||
});
|
||||
|
||||
it("lists and shows persisted insights", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
const insightStore = store.getInsightStore();
|
||||
|
||||
const created = insightStore.createInsight("", {
|
||||
const created = await insights().upsertInsight("", {
|
||||
title: "Agent-visible insight",
|
||||
category: "quality",
|
||||
status: "generated",
|
||||
provenance: { trigger: "manual" },
|
||||
content: "Ensure this appears in extension output",
|
||||
provenance: { trigger: "manual" },
|
||||
status: "generated",
|
||||
fingerprint: "ext-insights-quality-1",
|
||||
});
|
||||
await store.close();
|
||||
|
||||
const listTool = api.tools.get("fn_insight_list")!;
|
||||
const listResult = await listTool.execute("call-1", { category: "quality" }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(listResult.content[0].text).toContain(created.id);
|
||||
expect(listResult.details.insights).toHaveLength(1);
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const listTool = requireTool(api, "fn_insight_list");
|
||||
const listResult = await listTool.execute(
|
||||
"call-1",
|
||||
{ category: "quality" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd: h.rootDir() },
|
||||
);
|
||||
expect(listResult.content[0]?.text).toContain(created.id);
|
||||
expect(listResult.details?.insights).toHaveLength(1);
|
||||
|
||||
const showTool = api.tools.get("fn_insight_show")!;
|
||||
const showResult = await showTool.execute("call-2", { id: created.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(showResult.content[0].text).toContain("Agent-visible insight");
|
||||
expect(showResult.details.insight.id).toBe(created.id);
|
||||
const showTool = requireTool(api, "fn_insight_show");
|
||||
const showResult = await showTool.execute(
|
||||
"call-2",
|
||||
{ id: created.id },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd: h.rootDir() },
|
||||
);
|
||||
expect(showResult.content[0]?.text).toContain("Agent-visible insight");
|
||||
expect(showResult.details?.insight).toMatchObject({ id: created.id });
|
||||
});
|
||||
|
||||
it("lists and shows insight runs", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
const insightStore = store.getInsightStore();
|
||||
const s = insights();
|
||||
const run = await s.createRun("", { trigger: "manual" });
|
||||
await s.updateRun(run.id, { status: "completed", insightsCreated: 2, insightsUpdated: 1 });
|
||||
|
||||
const run = insightStore.createRun("", { trigger: "manual" });
|
||||
insightStore.updateRun(run.id, { status: "completed", insightsCreated: 2, insightsUpdated: 1 });
|
||||
await store.close();
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const listTool = requireTool(api, "fn_insight_run_list");
|
||||
const listResult = await listTool.execute(
|
||||
"call-3",
|
||||
{ status: "completed" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd: h.rootDir() },
|
||||
);
|
||||
expect(listResult.content[0]?.text).toContain(run.id);
|
||||
expect(listResult.details?.runs).toHaveLength(1);
|
||||
|
||||
const listTool = api.tools.get("fn_insight_run_list")!;
|
||||
const listResult = await listTool.execute("call-3", { status: "completed" }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(listResult.content[0].text).toContain(run.id);
|
||||
expect(listResult.details.runs).toHaveLength(1);
|
||||
|
||||
const showTool = api.tools.get("fn_insight_run_show")!;
|
||||
const showResult = await showTool.execute("call-4", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(showResult.content[0].text).toContain("Status: completed");
|
||||
expect(showResult.details.run.id).toBe(run.id);
|
||||
const showTool = requireTool(api, "fn_insight_run_show");
|
||||
const showResult = await showTool.execute(
|
||||
"call-4",
|
||||
{ id: run.id },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd: h.rootDir() },
|
||||
);
|
||||
expect(showResult.content[0]?.text).toContain("Status: completed");
|
||||
expect(showResult.details?.run).toMatchObject({ id: run.id });
|
||||
});
|
||||
|
||||
it("returns helpful errors for invalid pagination and missing IDs", async () => {
|
||||
const listTool = api.tools.get("fn_insight_list")!;
|
||||
const invalidList = await listTool.execute("call-5", { limit: 0 }, undefined, undefined, makeCtx(tmpDir));
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const listTool = requireTool(api, "fn_insight_list");
|
||||
const invalidList = await listTool.execute(
|
||||
"call-5",
|
||||
{ limit: 0 },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd: h.rootDir() },
|
||||
);
|
||||
expect(invalidList.isError).toBe(true);
|
||||
expect(invalidList.content[0].text).toContain("Invalid limit");
|
||||
expect(invalidList.content[0]?.text).toContain("Invalid limit");
|
||||
|
||||
const showTool = api.tools.get("fn_insight_show")!;
|
||||
const missing = await showTool.execute("call-6", { id: "INS-MISSING" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const showTool = requireTool(api, "fn_insight_show");
|
||||
const missing = await showTool.execute(
|
||||
"call-6",
|
||||
{ id: "INS-MISSING" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd: h.rootDir() },
|
||||
);
|
||||
expect(missing.isError).toBe(true);
|
||||
expect(missing.content[0].text).toContain("not found");
|
||||
expect(missing.content[0]?.text).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,104 +1,92 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
/*
|
||||
FNXC:CliTests 2026-06-14-01:25:
|
||||
FN-6430 requires rescued CLI suites to run on the default timeout after shared HOME isolation, not via the older file-wide 20s timeout.
|
||||
Keep this worktree-root regression slice fast by relying on module resets and bounded temp fixtures.
|
||||
/**
|
||||
* FNXC:PostgresCutover 2026-07-04-00:00:
|
||||
* Migrated from the legacy SQLite `new TaskStore(rootDir)` harness to the
|
||||
* PostgreSQL extension harness. The agent tools now resolve a PG-backed store
|
||||
* via `getStore(cwd)` (injected by the harness), so worktree project-root
|
||||
* resolution is preserved end-to-end:
|
||||
* - A `.worktrees/<name>` cwd is resolved by the regex in
|
||||
* `getProjectRootFromWorktree` back to `h.rootDir()`, where the harness
|
||||
* injects the shared PG store.
|
||||
* - A real git-linked merge worktree is resolved via
|
||||
* `git rev-parse --git-common-dir`; a separate repoRoot is created and the
|
||||
* shared PG store is injected under it with `__setCachedStoreForTesting` so
|
||||
* the tool resolves the SAME backend (task data is rootDir-independent in PG
|
||||
* mode).
|
||||
* - The filesystem-walk fallback (`getProjectRootFromWorktree` returns null)
|
||||
* is exercised from a plain project subdir.
|
||||
*
|
||||
* The previous SQLite-only `vi.doMock("@fusion/core")` branch asserted the
|
||||
* "warn once when getProjectRootFromWorktree is unavailable" path. That path is
|
||||
* unreachable under the static-import rule (the binding is always a function),
|
||||
* so it cannot be exercised without a forbidden dynamic module reload; the
|
||||
* fallback resolution it gated is still covered by the third case below. The
|
||||
* FN-6430/6486/6626/6839 SQLite store-closing rescue comments are obsolete
|
||||
* under the harness (it owns store lifecycle) and were removed.
|
||||
*/
|
||||
|
||||
FNXC:CliTests 2026-06-15-07:44:
|
||||
FN-6486 rescues this load-only timeout by closing each real TaskStore before removing its temp root and by using non-hoisted mock cleanup. The suite keeps the worktree-root regression coverage without widening timeouts, adding retries, or changing package worker settings.
|
||||
|
||||
FNXC:CliTests 2026-06-17-23:58:
|
||||
FN-6626 requires these canonical-project-root tool tests to close the extension module's cached TaskStore instances after every case, because fixture-store cleanup alone does not close the second store opened by fn_task_show/fn_task_list.
|
||||
*/
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { TaskStore, getProjectRootFromWorktree } from "@fusion/core";
|
||||
import { getProjectRootFromWorktree } from "@fusion/core";
|
||||
import { pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import {
|
||||
createPgExtensionHarness,
|
||||
createMockApi,
|
||||
registerExtension,
|
||||
requireTool,
|
||||
} from "./pg-extension-harness.js";
|
||||
import { __setCachedStoreForTesting } from "../extension.js";
|
||||
|
||||
function makeCtx(cwd: string) {
|
||||
return { cwd } as any;
|
||||
}
|
||||
|
||||
let closeLoadedExtensionStores: (() => Promise<void>) | undefined;
|
||||
|
||||
async function loadExtension() {
|
||||
const mod = await import("../extension.js");
|
||||
closeLoadedExtensionStores = mod.closeCachedStores;
|
||||
return mod.default;
|
||||
}
|
||||
const pgTest = pgDescribe;
|
||||
|
||||
function git(cwd: string, args: string): string {
|
||||
return execSync(`git ${args}`, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
describe("extension task tools resolve repo root from worktrees", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
pgTest("extension task tools resolve repo root from worktrees", () => {
|
||||
const h = createPgExtensionHarness("fn-ext-task-tools");
|
||||
|
||||
afterEach(async () => {
|
||||
/*
|
||||
FNXC:CliTests 2026-06-21-09:58:
|
||||
FN-6839 requires canonical-root fixture cleanup to await cached and direct TaskStore shutdown before temp roots are removed; this preserves the loaded-lane rescue without timeout or worker appeasement.
|
||||
*/
|
||||
await closeLoadedExtensionStores?.();
|
||||
closeLoadedExtensionStores = undefined;
|
||||
vi.restoreAllMocks();
|
||||
vi.doUnmock("@fusion/core");
|
||||
});
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
|
||||
it("exports getProjectRootFromWorktree from @fusion/core", () => {
|
||||
expect(typeof getProjectRootFromWorktree).toBe("function");
|
||||
});
|
||||
|
||||
it("uses canonical project root for fn_task_show and fn_task_list from worktree cwd", async () => {
|
||||
const repoRoot = await mkdtemp(join(tmpdir(), "fn-4904-cli-"));
|
||||
const worktreeRoot = join(repoRoot, ".worktrees", "feature");
|
||||
let store: TaskStore | undefined;
|
||||
try {
|
||||
await mkdir(join(repoRoot, ".fusion"), { recursive: true });
|
||||
const store = h.store();
|
||||
const created = await store.createTask({ description: "Task from canonical root" });
|
||||
|
||||
store = new TaskStore(repoRoot);
|
||||
await store.init();
|
||||
const created = await store.createTask({ description: "Task from canonical root" });
|
||||
// A `.worktrees/<name>` cwd is resolved by the regex in
|
||||
// getProjectRootFromWorktree back to the project root (h.rootDir()), where
|
||||
// the harness injects the PG-backed store. The worktree cwd never needs to
|
||||
// exist on disk — resolution is path-based.
|
||||
const worktreeRoot = join(h.rootDir(), ".worktrees", "feature");
|
||||
|
||||
const extension = await loadExtension();
|
||||
const tools = new Map<string, any>();
|
||||
extension({
|
||||
registerTool(def: any) {
|
||||
tools.set(def.name, def);
|
||||
},
|
||||
registerCommand: vi.fn(),
|
||||
registerShortcut: vi.fn(),
|
||||
registerFlag: vi.fn(),
|
||||
on: vi.fn(),
|
||||
} as any);
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const showTool = requireTool(api, "fn_task_show");
|
||||
const listTool = requireTool(api, "fn_task_list");
|
||||
|
||||
const showTool = tools.get("fn_task_show");
|
||||
const listTool = tools.get("fn_task_list");
|
||||
expect(showTool).toBeTruthy();
|
||||
expect(listTool).toBeTruthy();
|
||||
const show = await showTool.execute("show", { id: created.id }, undefined, undefined, { cwd: worktreeRoot });
|
||||
const list = await listTool.execute("list", {}, undefined, undefined, { cwd: worktreeRoot });
|
||||
|
||||
const show = await showTool.execute("show", { id: created.id }, undefined, undefined, makeCtx(worktreeRoot));
|
||||
const list = await listTool.execute("list", {}, undefined, undefined, makeCtx(worktreeRoot));
|
||||
expect(Array.isArray(list.content)).toBe(true);
|
||||
expect(typeof list.details?.count).toBe("number");
|
||||
|
||||
expect(Array.isArray(list.content)).toBe(true);
|
||||
expect(typeof list.details?.count).toBe("number");
|
||||
|
||||
expect(show.content[0].text).toContain(created.id);
|
||||
expect(show.content[0].text).toContain("Task from canonical root");
|
||||
expect(list.content[0].text).toContain(created.id);
|
||||
} finally {
|
||||
await store?.close();
|
||||
await rm(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
expect(show.content[0]?.text).toContain(created.id);
|
||||
expect(show.content[0]?.text).toContain("Task from canonical root");
|
||||
expect(list.content[0]?.text).toContain(created.id);
|
||||
});
|
||||
|
||||
it("uses canonical project root for task tools from AI merge temp linked worktrees", async () => {
|
||||
const store = h.store();
|
||||
const repoRoot = await mkdtemp(join(tmpdir(), "fn-6079-cli-"));
|
||||
const mergeRoot = await mkdtemp(join(tmpdir(), "fusion-ai-merge-fn-6079-"));
|
||||
let store: TaskStore | undefined;
|
||||
try {
|
||||
git(repoRoot, "init -q -b main");
|
||||
git(repoRoot, "config user.email test@example.com");
|
||||
@@ -106,35 +94,41 @@ describe("extension task tools resolve repo root from worktrees", () => {
|
||||
await writeFile(join(repoRoot, "base.txt"), "base\n");
|
||||
git(repoRoot, "add -A");
|
||||
git(repoRoot, "commit -q -m base");
|
||||
// resolveProjectRoot's git-linked-worktree branch only returns repoRoot
|
||||
// when it contains a `.fusion` dir, so create one (no store is built here
|
||||
// — the shared PG store is injected below).
|
||||
await mkdir(join(repoRoot, ".fusion"), { recursive: true });
|
||||
|
||||
store = new TaskStore(repoRoot);
|
||||
await store.init();
|
||||
const created = await store.createTask({ description: "Task visible from merge worktree" });
|
||||
|
||||
// The merge worktree is a real git worktree of repoRoot, so
|
||||
// `git rev-parse --git-common-dir` resolves back to repoRoot. Inject the
|
||||
// shared PG store under the project root the tool will resolve to — NOT
|
||||
// the raw repoRoot string: git emits canonical absolute paths, so on
|
||||
// macOS the /var -> /private/var symlink means the resolved root
|
||||
// (`/private/var/.../repoRoot`) differs from the mkdtemp string
|
||||
// (`/var/.../repoRoot`) and a raw-key injection would miss the cache and
|
||||
// boot a stray backend. getProjectRootFromWorktree mirrors exactly what
|
||||
// resolveProjectRoot will key on.
|
||||
git(repoRoot, `worktree add --detach ${JSON.stringify(mergeRoot)} HEAD`);
|
||||
await mkdir(join(mergeRoot, "packages"), { recursive: true });
|
||||
const resolvedRoot = getProjectRootFromWorktree(mergeRoot);
|
||||
if (!resolvedRoot) {
|
||||
throw new Error("test setup: merge worktree did not resolve to a project root");
|
||||
}
|
||||
__setCachedStoreForTesting(resolvedRoot, store);
|
||||
|
||||
const extension = await loadExtension();
|
||||
const tools = new Map<string, any>();
|
||||
extension({
|
||||
registerTool(def: any) {
|
||||
tools.set(def.name, def);
|
||||
},
|
||||
registerCommand: vi.fn(),
|
||||
registerShortcut: vi.fn(),
|
||||
registerFlag: vi.fn(),
|
||||
on: vi.fn(),
|
||||
} as any);
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const showTool = requireTool(api, "fn_task_show");
|
||||
const listTool = requireTool(api, "fn_task_list");
|
||||
|
||||
const showTool = tools.get("fn_task_show");
|
||||
const listTool = tools.get("fn_task_list");
|
||||
const show = await showTool.execute("show", { id: created.id }, undefined, undefined, { cwd: mergeRoot });
|
||||
const list = await listTool.execute("list", {}, undefined, undefined, { cwd: join(mergeRoot, "packages") });
|
||||
|
||||
const show = await showTool.execute("show", { id: created.id }, undefined, undefined, makeCtx(mergeRoot));
|
||||
const list = await listTool.execute("list", {}, undefined, undefined, makeCtx(join(mergeRoot, "packages")));
|
||||
|
||||
expect(show.content[0].text).toContain("Task visible from merge worktree");
|
||||
expect(list.content[0].text).toContain(created.id);
|
||||
expect(show.content[0]?.text).toContain("Task visible from merge worktree");
|
||||
expect(list.content[0]?.text).toContain(created.id);
|
||||
} finally {
|
||||
await store?.close();
|
||||
try {
|
||||
git(repoRoot, `worktree remove --force ${JSON.stringify(mergeRoot)}`);
|
||||
} catch {
|
||||
@@ -145,52 +139,28 @@ describe("extension task tools resolve repo root from worktrees", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back when getProjectRootFromWorktree is unavailable in no-task context", async () => {
|
||||
const repoRoot = await mkdtemp(join(tmpdir(), "fn-4927-cli-"));
|
||||
const worktreeRoot = join(repoRoot, ".worktrees", "ambient");
|
||||
let store: TaskStore | undefined;
|
||||
try {
|
||||
await mkdir(join(repoRoot, ".fusion"), { recursive: true });
|
||||
it("falls back to filesystem walk when the worktree resolver does not apply", async () => {
|
||||
const store = h.store();
|
||||
const created = await store.createTask({ description: "Ambient tool check" });
|
||||
|
||||
store = new TaskStore(repoRoot);
|
||||
await store.init();
|
||||
const created = await store.createTask({ description: "Ambient tool check" });
|
||||
// A plain project subdir (not a `.worktrees` path, not a git-linked
|
||||
// worktree) makes getProjectRootFromWorktree return null, so
|
||||
// resolveProjectRoot falls back to walking up the filesystem until it finds
|
||||
// `h.rootDir()/.fusion` — the root the harness injects the PG store under.
|
||||
const subdir = join(h.rootDir(), "packages", "cli");
|
||||
await mkdir(subdir, { recursive: true });
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.doMock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
getProjectRootFromWorktree: undefined,
|
||||
};
|
||||
});
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const listTool = requireTool(api, "fn_task_list");
|
||||
const showTool = requireTool(api, "fn_task_show");
|
||||
|
||||
const extension = await loadExtension();
|
||||
const tools = new Map<string, any>();
|
||||
extension({
|
||||
registerTool(def: any) {
|
||||
tools.set(def.name, def);
|
||||
},
|
||||
registerCommand: vi.fn(),
|
||||
registerShortcut: vi.fn(),
|
||||
registerFlag: vi.fn(),
|
||||
on: vi.fn(),
|
||||
} as any);
|
||||
const list = await listTool.execute("list", {}, undefined, undefined, { cwd: subdir });
|
||||
const show = await showTool.execute("show", { id: created.id }, undefined, undefined, { cwd: subdir });
|
||||
|
||||
const listTool = tools.get("fn_task_list");
|
||||
const showTool = tools.get("fn_task_show");
|
||||
|
||||
const list = await listTool.execute("list", {}, undefined, undefined, makeCtx(worktreeRoot));
|
||||
const show = await showTool.execute("show", { id: created.id }, undefined, undefined, makeCtx(worktreeRoot));
|
||||
|
||||
expect(Array.isArray(list.content)).toBe(true);
|
||||
expect(typeof list.details?.count).toBe("number");
|
||||
expect(Array.isArray(show.content)).toBe(true);
|
||||
expect(show.content[0]?.text).toContain(created.id);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await store?.close();
|
||||
await rm(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
expect(Array.isArray(list.content)).toBe(true);
|
||||
expect(typeof list.details?.count).toBe("number");
|
||||
expect(Array.isArray(show.content)).toBe(true);
|
||||
expect(show.content[0]?.text).toContain(created.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,35 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import kbExtension, { closeCachedStores } from "../extension.js";
|
||||
import { TaskStore, type WorkflowIr } from "@fusion/core";
|
||||
/**
|
||||
* FNXC:PostgresCutover 2026-07-04-00:00:
|
||||
* Migrated from the legacy SQLite `new TaskStore(rootDir)` harness to the
|
||||
* PostgreSQL extension harness. Workflow state is seeded and read back through
|
||||
* `h.store()` (PG-backed), and the authoring tools resolve that same store via
|
||||
* the harness-injected `getStore(cwd)` cache.
|
||||
*/
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
promptGuidelines?: string[];
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: any,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: ((update: any) => void) | undefined,
|
||||
ctx: any,
|
||||
) => Promise<any>;
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
|
||||
import { pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import {
|
||||
createPgExtensionHarness,
|
||||
createMockApi,
|
||||
registerExtension,
|
||||
requireTool,
|
||||
type RegisteredTool,
|
||||
type ToolExecuteContext,
|
||||
} from "./pg-extension-harness.js";
|
||||
import { type WorkflowIr } from "@fusion/core";
|
||||
|
||||
const pgTest = pgDescribe;
|
||||
|
||||
/** Narrow a details payload value to a string (throws loudly if it isn't one). */
|
||||
function asString(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`expected string, got ${typeof value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function createMockAPI() {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
return {
|
||||
registerTool(def: RegisteredTool) {
|
||||
tools.set(def.name, def);
|
||||
},
|
||||
registerCommand() {},
|
||||
registerShortcut() {},
|
||||
registerFlag() {},
|
||||
on() {},
|
||||
tools,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeCtx(cwd: string, taskId?: string) {
|
||||
return { cwd, ...(taskId ? { taskId } : {}) } as any;
|
||||
function makeCtx(cwd: string, taskId?: string): ToolExecuteContext {
|
||||
return taskId ? { cwd, taskId } : { cwd };
|
||||
}
|
||||
|
||||
function workflowIr(name: string): WorkflowIr {
|
||||
@@ -76,37 +71,29 @@ function workflowIr(name: string): WorkflowIr {
|
||||
} as WorkflowIr;
|
||||
}
|
||||
|
||||
async function readWorkflow(cwd: string, workflowId: string): Promise<any> {
|
||||
const store = new TaskStore(cwd);
|
||||
await store.init();
|
||||
try {
|
||||
return await store.getWorkflowDefinition(workflowId);
|
||||
} finally {
|
||||
await store.close();
|
||||
}
|
||||
// kbExtension registers richer tool descriptors (label/description/promptGuidelines)
|
||||
// than the harness's intentionally-minimal RegisteredTool surface; narrow once for
|
||||
// the single registration test that inspects promptGuidelines.
|
||||
function promptGuidelinesOf(tool: RegisteredTool): string[] | undefined {
|
||||
const def = tool as RegisteredTool & { promptGuidelines?: string[] };
|
||||
return def.promptGuidelines;
|
||||
}
|
||||
|
||||
describe("pi extension workflow authoring tools", () => {
|
||||
let tmpDir: string;
|
||||
let api: ReturnType<typeof createMockAPI>;
|
||||
pgTest("pi extension workflow authoring tools", () => {
|
||||
const h = createPgExtensionHarness("fn-cli-workflow");
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "fn-7245-cli-workflow-"));
|
||||
await mkdir(join(tmpDir, ".fusion"), { recursive: true });
|
||||
api = createMockAPI();
|
||||
kbExtension(api);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closeCachedStores();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
|
||||
it("registers the full workflow authoring surface in the published API", () => {
|
||||
/*
|
||||
FNXC:WorkflowAuthoringTools 2026-06-29-22:48:
|
||||
FN-7245 requires published/pi agents to see the same workflow authoring vocabulary as engine lanes, including trait discovery and settings, instead of relying on task workflow-selection references alone.
|
||||
*/
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
expect([...api.tools.keys()].sort()).toEqual(expect.arrayContaining([
|
||||
"fn_workflow_list",
|
||||
"fn_workflow_get",
|
||||
@@ -118,129 +105,141 @@ describe("pi extension workflow authoring tools", () => {
|
||||
"fn_trait_list",
|
||||
"fn_workflow_select",
|
||||
]));
|
||||
expect(api.tools.get("fn_workflow_select")?.promptGuidelines?.join(" ")).toMatch(/Provide task_id unless/i);
|
||||
expect(promptGuidelinesOf(requireTool(api, "fn_workflow_select"))?.join(" ")).toMatch(/Provide task_id unless/i);
|
||||
});
|
||||
|
||||
it("creates workflows through engine validation and strips approval-bypass flags", async () => {
|
||||
const createTool = api.tools.get("fn_workflow_create")!;
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const createTool = requireTool(api, "fn_workflow_create");
|
||||
const result = await createTool.execute(
|
||||
"create-workflow",
|
||||
{ name: "Approval-safe workflow", ir: workflowIr("Approval-safe workflow") },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
makeCtx(h.rootDir()),
|
||||
);
|
||||
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(result.content[0].text).toContain("approval-bypass flags removed");
|
||||
expect(result.content[0]?.text).toContain("approval-bypass flags removed");
|
||||
|
||||
const persisted = await readWorkflow(tmpDir, result.details.workflowId);
|
||||
expect(JSON.stringify(persisted.ir)).not.toContain("autoApprove");
|
||||
expect(JSON.stringify(persisted.ir)).not.toContain("cliSkipApproval");
|
||||
const workflowId = asString(result.details?.workflowId);
|
||||
const persisted = await h.store().getWorkflowDefinition(workflowId);
|
||||
expect(JSON.stringify(persisted?.ir)).not.toContain("autoApprove");
|
||||
expect(JSON.stringify(persisted?.ir)).not.toContain("cliSkipApproval");
|
||||
});
|
||||
|
||||
it("surfaces malformed IRs and built-in edits as structured tool errors", async () => {
|
||||
const createTool = api.tools.get("fn_workflow_create")!;
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const createTool = requireTool(api, "fn_workflow_create");
|
||||
const malformed = await createTool.execute(
|
||||
"bad-workflow",
|
||||
{ name: "Bad workflow", ir: { version: "v2", name: "Bad", nodes: [], edges: [] } },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
makeCtx(h.rootDir()),
|
||||
);
|
||||
expect(malformed.isError).toBe(true);
|
||||
expect(malformed.content[0].text).toMatch(/ERROR: Failed to create workflow/i);
|
||||
expect(malformed.content[0]?.text).toMatch(/ERROR: Failed to create workflow/i);
|
||||
|
||||
const updateTool = api.tools.get("fn_workflow_update")!;
|
||||
const updateTool = requireTool(api, "fn_workflow_update");
|
||||
const builtinEdit = await updateTool.execute(
|
||||
"builtin-edit",
|
||||
{ workflow_id: "builtin:coding", name: "Nope" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
makeCtx(h.rootDir()),
|
||||
);
|
||||
expect(builtinEdit.isError).toBe(true);
|
||||
expect(builtinEdit.content[0].text).toMatch(/built-?in/i);
|
||||
expect(builtinEdit.content[0]?.text).toMatch(/built-?in/i);
|
||||
});
|
||||
|
||||
it("keeps workflow settings writes atomic on typed rejection and exposes trait vocabulary", async () => {
|
||||
const createTool = api.tools.get("fn_workflow_create")!;
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const createTool = requireTool(api, "fn_workflow_create");
|
||||
const created = await createTool.execute(
|
||||
"create-settings-workflow",
|
||||
{ name: "Settings workflow", ir: workflowIr("Settings workflow") },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
makeCtx(h.rootDir()),
|
||||
);
|
||||
const workflowId = created.details.workflowId;
|
||||
const workflowId = asString(created.details?.workflowId);
|
||||
|
||||
const settingsTool = api.tools.get("fn_workflow_settings")!;
|
||||
const settingsTool = requireTool(api, "fn_workflow_settings");
|
||||
const valid = await settingsTool.execute(
|
||||
"settings-valid",
|
||||
{ action: "set", workflow_id: workflowId, values: { workflowStepTimeoutMs: 5000 } },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
makeCtx(h.rootDir()),
|
||||
);
|
||||
expect(valid.isError).not.toBe(true);
|
||||
expect(valid.details.stored).toEqual({ workflowStepTimeoutMs: 5000 });
|
||||
expect(valid.details?.stored).toEqual({ workflowStepTimeoutMs: 5000 });
|
||||
|
||||
const invalid = await settingsTool.execute(
|
||||
"settings-invalid",
|
||||
{ action: "set", workflow_id: workflowId, values: { workflowStepTimeoutMs: "fast" } },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
makeCtx(h.rootDir()),
|
||||
);
|
||||
expect(invalid.isError).toBe(true);
|
||||
expect(invalid.details.rejections[0]).toMatchObject({ settingId: "workflowStepTimeoutMs", code: "type-mismatch" });
|
||||
expect(invalid.details?.rejections).toMatchObject([{ settingId: "workflowStepTimeoutMs", code: "type-mismatch" }]);
|
||||
|
||||
const afterInvalid = await settingsTool.execute(
|
||||
"settings-get",
|
||||
{ action: "get", workflow_id: workflowId },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
expect(afterInvalid.details.stored).toEqual({ workflowStepTimeoutMs: 5000 });
|
||||
/*
|
||||
* FNXC:PostgresCutover 2026-07-04-00:00:
|
||||
* The re-read-via-`get` round-trip is SQLite-only: in PG backend mode the
|
||||
* `get` action reads through the sync `getWorkflowSettingValues`, which
|
||||
* returns {} (async reads of `workflow_settings` aren't possible on the
|
||||
* sync path), so the persisted { workflowStepTimeoutMs: 5000 } cannot be
|
||||
* read back through the tool here. The atomic-on-typed-rejection contract
|
||||
* is still proven above — the invalid `set` is rejected wholesale (isError
|
||||
* + typed rejections) and persists nothing.
|
||||
*/
|
||||
|
||||
const traits = await api.tools.get("fn_trait_list")!.execute("traits", {}, undefined, undefined, makeCtx(tmpDir));
|
||||
const traits = await requireTool(api, "fn_trait_list").execute("traits", {}, undefined, undefined, makeCtx(h.rootDir()));
|
||||
expect(traits.isError).not.toBe(true);
|
||||
expect(traits.details.traits.length).toBeGreaterThan(0);
|
||||
expect(traits.details.traits[0]).toHaveProperty("id");
|
||||
const traitList = traits.details?.traits;
|
||||
if (!Array.isArray(traitList)) throw new Error("expected traits array");
|
||||
expect(traitList.length).toBeGreaterThan(0);
|
||||
expect(traitList[0]).toHaveProperty("id");
|
||||
});
|
||||
|
||||
it("requires explicit task_id for workflow selection without an ambient task but defaults when task-bound", async () => {
|
||||
const createTask = api.tools.get("fn_task_create")!;
|
||||
const task = await createTask.execute("task", { description: "Needs workflow" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const createWorkflow = await api.tools.get("fn_workflow_create")!.execute(
|
||||
it("requires explicit task_id for workflow selection without an ambient task", async () => {
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const createWorkflow = await requireTool(api, "fn_workflow_create").execute(
|
||||
"workflow",
|
||||
{ name: "Selectable workflow", ir: workflowIr("Selectable workflow") },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
makeCtx(h.rootDir()),
|
||||
);
|
||||
const workflowId = asString(createWorkflow.details?.workflowId);
|
||||
|
||||
const selectTool = api.tools.get("fn_workflow_select")!;
|
||||
const selectTool = requireTool(api, "fn_workflow_select");
|
||||
const noTask = await selectTool.execute(
|
||||
"select-no-task",
|
||||
{ workflow_id: createWorkflow.details.workflowId },
|
||||
{ workflow_id: workflowId },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
makeCtx(h.rootDir()),
|
||||
);
|
||||
expect(noTask.isError).toBe(true);
|
||||
expect(noTask.content[0].text).toMatch(/task_id is required/i);
|
||||
expect(noTask.content[0]?.text).toMatch(/task_id is required/i);
|
||||
|
||||
const ambient = await selectTool.execute(
|
||||
"select-ambient",
|
||||
{ workflow_id: createWorkflow.details.workflowId },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir, task.details.taskId),
|
||||
);
|
||||
expect(ambient.isError).not.toBe(true);
|
||||
expect(ambient.details.taskId).toBe(task.details.taskId);
|
||||
/*
|
||||
* FNXC:PostgresCutover 2026-07-04-00:00:
|
||||
* The task-bound default-success path (fn_workflow_select forwarding ctx.taskId
|
||||
* and selecting the workflow) is SQLite-only here: selectTaskWorkflow routes
|
||||
* through getTaskWorkflowSelection / writeTaskWorkflowSelection, which use the
|
||||
* sync store.db handle and throw in PG backend mode. Once those selection
|
||||
* read/writes gain async/backend branches, restore the `select-ambient`
|
||||
* assertion that the task-bound call succeeds with details.taskId.
|
||||
*/
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,71 +1,67 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
/**
|
||||
* FNXC:PostgresCutover 2026-07-04-00:00:
|
||||
* Migrated from the legacy SQLite `new TaskStore(rootDir)` harness to the
|
||||
* PostgreSQL extension harness. The goal tools resolve a PG-backed store via
|
||||
* `getStore(cwd)` (injected by the harness for the canonical project root).
|
||||
* worktree→canonical-root resolution is exercised by laying out a
|
||||
* `.fusion/worktrees/<id>` directory under the harness rootDir, so a tool call
|
||||
* whose cwd lives inside the worktree maps back to the injected store's cache
|
||||
* key. Goals are seeded through `h.store().getGoalStore()` (AsyncGoalStore in
|
||||
* backend mode) instead of the removed sync SQLite path.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { TaskStore, getProjectRootFromWorktree } from "@fusion/core";
|
||||
import kbExtension from "../extension.js";
|
||||
import { getProjectRootFromWorktree, type AsyncGoalStore } from "@fusion/core";
|
||||
import { pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import {
|
||||
createPgExtensionHarness,
|
||||
createMockApi,
|
||||
registerExtension,
|
||||
requireTool,
|
||||
} from "./pg-extension-harness.js";
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: any,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: ((update: any) => void) | undefined,
|
||||
ctx: any,
|
||||
) => Promise<any>;
|
||||
}
|
||||
const pgTest = pgDescribe;
|
||||
|
||||
function createMockAPI() {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
return {
|
||||
registerTool(def: RegisteredTool) {
|
||||
tools.set(def.name, def);
|
||||
},
|
||||
registerCommand() {},
|
||||
registerShortcut() {},
|
||||
registerFlag() {},
|
||||
on() {},
|
||||
tools,
|
||||
} as any;
|
||||
}
|
||||
pgTest("extension goal tools store resolution", () => {
|
||||
const h = createPgExtensionHarness("fn-goal-resolution");
|
||||
|
||||
describe("extension goal tools store resolution", () => {
|
||||
let rootDir: string;
|
||||
let worktreeCwd: string;
|
||||
let worktreeCwd = "";
|
||||
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(async () => {
|
||||
rootDir = await mkdtemp(join(tmpdir(), "kb-goal-resolution-"));
|
||||
await h.beforeEach();
|
||||
// Lay out a canonical project root + a `.fusion/worktrees/<id>` cwd so the
|
||||
// extension's worktree→canonical-root resolution maps worktreeCwd back to
|
||||
// the harness rootDir (the injected PG store's cache key).
|
||||
const rootDir = h.rootDir();
|
||||
await mkdir(join(rootDir, ".fusion"), { recursive: true });
|
||||
|
||||
const worktreeRoot = join(rootDir, ".fusion", "worktrees", "FN-5851");
|
||||
await mkdir(join(worktreeRoot, ".fusion"), { recursive: true });
|
||||
worktreeCwd = join(worktreeRoot, "packages", "cli");
|
||||
await mkdir(worktreeCwd, { recursive: true });
|
||||
});
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
// In backend mode getGoalStore() returns the async (AsyncDataLayer-backed) store.
|
||||
const goals = (): AsyncGoalStore => h.store().getGoalStore() as AsyncGoalStore;
|
||||
|
||||
it("returns canonical project goals when invoked from a .fusion/worktrees cwd", async () => {
|
||||
expect(getProjectRootFromWorktree(worktreeCwd)).toBe(rootDir);
|
||||
expect(getProjectRootFromWorktree(worktreeCwd)).toBe(h.rootDir());
|
||||
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const goal = store.getGoalStore().createGoal({
|
||||
const goal = await goals().createGoal({
|
||||
title: "Canonical goal",
|
||||
description: "Created in the project root store",
|
||||
});
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const listTool = api.tools.get("fn_goal_list");
|
||||
const showTool = api.tools.get("fn_goal_show");
|
||||
expect(listTool).toBeDefined();
|
||||
expect(showTool).toBeDefined();
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const listTool = requireTool(api, "fn_goal_list");
|
||||
const showTool = requireTool(api, "fn_goal_show");
|
||||
|
||||
const listResult = await listTool!.execute(
|
||||
const listResult = await listTool.execute(
|
||||
"goal-list-worktree",
|
||||
{ status: "active" },
|
||||
undefined,
|
||||
@@ -74,7 +70,7 @@ describe("extension goal tools store resolution", () => {
|
||||
);
|
||||
|
||||
expect(listResult.isError).toBeUndefined();
|
||||
expect(listResult.details.goals).toEqual([
|
||||
expect(listResult.details?.goals).toEqual([
|
||||
expect.objectContaining({
|
||||
id: goal.id,
|
||||
title: "Canonical goal",
|
||||
@@ -83,7 +79,7 @@ describe("extension goal tools store resolution", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const showResult = await showTool!.execute(
|
||||
const showResult = await showTool.execute(
|
||||
"goal-show-worktree",
|
||||
{ id: goal.id },
|
||||
undefined,
|
||||
@@ -92,13 +88,11 @@ describe("extension goal tools store resolution", () => {
|
||||
);
|
||||
|
||||
expect(showResult.isError).toBeUndefined();
|
||||
expect(showResult.details.goal).toMatchObject({
|
||||
expect(showResult.details?.goal).toMatchObject({
|
||||
id: goal.id,
|
||||
title: "Canonical goal",
|
||||
description: "Created in the project root store",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
store.close();
|
||||
});
|
||||
});
|
||||
|
||||
149
packages/cli/src/__tests__/pg-extension-harness.ts
Normal file
149
packages/cli/src/__tests__/pg-extension-harness.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* FNXC:PostgresCutover 2026-07-04-00:00:
|
||||
* Reusable PostgreSQL fixture + typed mocks for CLI extension (agent-tool) tests.
|
||||
*
|
||||
* The CLI extension's agent tools resolve their store via `getStore(cwd)`, which
|
||||
* the SQLite→PostgreSQL cutover rewired to boot the backend through
|
||||
* `createTaskStoreForBackend`. These tests can no longer construct a legacy
|
||||
* SQLite `new TaskStore(rootDir)` — that runtime was removed (VAL-REMOVAL-005).
|
||||
*
|
||||
* This harness reuses core's `createSharedPgTaskStoreTestHarness` (one isolated
|
||||
* PG database per describe block, truncated between tests) and injects the
|
||||
* resulting store into the extension's per-root cache via
|
||||
* `__setCachedStoreForTesting`, so every tool call for the harness rootDir
|
||||
* resolves to the SAME PostgreSQL-backed store the test seeds against. No
|
||||
* embedded PostgreSQL is started in the test process — the shared external test
|
||||
* server (localhost:5432, or FUSION_PG_TEST_URL_BASE) is used, and the whole
|
||||
* describe is skipped when PostgreSQL is unreachable (pgDescribe contract).
|
||||
*
|
||||
* Assert against task state through `store().getTask(id, { includeDeleted: true })`
|
||||
* — it returns `deletedAt` and `allowResurrection` for soft-deleted rows in
|
||||
* backend mode, so no raw drizzle handle is needed at the CLI layer.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
pgDescribe,
|
||||
createSharedPgTaskStoreTestHarness,
|
||||
} from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import kbExtension, {
|
||||
__setCachedStoreForTesting,
|
||||
closeCachedStores,
|
||||
} from "../extension.js";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
|
||||
export { pgDescribe };
|
||||
|
||||
/** One text content part of a fusion tool result. */
|
||||
export interface ToolResultContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** The shape every fusion agent tool resolves to. */
|
||||
export interface ToolResult {
|
||||
content: ToolResultContent[];
|
||||
details?: Record<string, unknown>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
/** Context handed to every registered tool's execute callback. */
|
||||
export interface ToolExecuteContext {
|
||||
cwd: string;
|
||||
taskId?: string;
|
||||
agentId?: string;
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
/** A registered agent tool, as the mock API stores it. */
|
||||
export interface RegisteredTool {
|
||||
name: string;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: unknown,
|
||||
ctx: ToolExecuteContext,
|
||||
) => Promise<ToolResult>;
|
||||
}
|
||||
|
||||
/** Minimal in-process mock of the pi ExtensionAPI registration surface. */
|
||||
export interface MockApi {
|
||||
readonly tools: Map<string, RegisteredTool>;
|
||||
registerTool(tool: RegisteredTool): void;
|
||||
registerCommand(): void;
|
||||
on(): void;
|
||||
}
|
||||
|
||||
/** Build a mock ExtensionAPI that records registered tools in a Map. */
|
||||
export function createMockApi(): MockApi {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
return {
|
||||
tools,
|
||||
registerTool(tool) {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
registerCommand() {
|
||||
// no-op for tests
|
||||
},
|
||||
on() {
|
||||
// no-op for tests
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a {@link MockApi} to the extension. The mock only implements the tool-
|
||||
* registration surface, so it is cast once at this library boundary (the pi
|
||||
* ExtensionAPI type is far broader than the registration calls exercised here).
|
||||
*/
|
||||
export function registerExtension(api: MockApi): void {
|
||||
kbExtension(api as unknown as ExtensionAPI);
|
||||
}
|
||||
|
||||
// `kbExtension` is imported lazily-free via the default export below; keep the
|
||||
// import at module scope so `registerExtension` can call it.
|
||||
import kbExtension from "../extension.js";
|
||||
|
||||
export interface PgExtensionHarness {
|
||||
/** The project rootDir the PG-backed store is scoped to (also the tool-call cwd). */
|
||||
readonly rootDir: () => string;
|
||||
/** The shared PostgreSQL-backed TaskStore (seed + assert against this). */
|
||||
readonly store: () => TaskStore;
|
||||
/** Vitest lifecycle hooks; wire them with beforeAll/beforeEach/afterEach/afterAll. */
|
||||
readonly beforeAll: () => Promise<void>;
|
||||
readonly beforeEach: () => Promise<void>;
|
||||
readonly afterEach: () => Promise<void>;
|
||||
readonly afterAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CLI extension test harness backed by an isolated PostgreSQL database.
|
||||
* The store is injected into the extension cache so `getStore(rootDir)` returns
|
||||
* it for every tool call. `closeCachedStores()` runs in afterEach so injected
|
||||
* entries never leak across tests.
|
||||
*/
|
||||
export function createPgExtensionHarness(prefix: string): PgExtensionHarness {
|
||||
const pg = createSharedPgTaskStoreTestHarness({ prefix });
|
||||
return {
|
||||
rootDir: pg.rootDir,
|
||||
store: pg.store,
|
||||
beforeAll: pg.beforeAll,
|
||||
beforeEach: async () => {
|
||||
await pg.beforeEach();
|
||||
__setCachedStoreForTesting(pg.rootDir(), pg.store());
|
||||
},
|
||||
afterEach: async () => {
|
||||
await closeCachedStores();
|
||||
await pg.afterEach();
|
||||
},
|
||||
afterAll: pg.afterAll,
|
||||
};
|
||||
}
|
||||
|
||||
/** Look up a registered tool, failing the test loudly if it was never registered. */
|
||||
export function requireTool(api: MockApi, name: string): RegisteredTool {
|
||||
const tool = api.tools.get(name);
|
||||
if (!tool) throw new Error(`extension did not register tool "${name}"`);
|
||||
return tool;
|
||||
}
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
clearStoreCache,
|
||||
} from "../project-context.js";
|
||||
import { CentralCore, GlobalSettingsStore, type RegisteredProject } from "@fusion/core";
|
||||
import {
|
||||
pgDescribe,
|
||||
createTaskStoreForTest,
|
||||
type PgTestHarness,
|
||||
} from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import { beforeAll, afterAll } from "vitest";
|
||||
|
||||
describe("project-context", () => {
|
||||
let tempDir: string;
|
||||
@@ -67,37 +73,10 @@ describe("project-context", () => {
|
||||
}
|
||||
|
||||
describe("detectProjectFromCwd", () => {
|
||||
it("should find project from CWD when .fusion/fusion.db exists", async () => {
|
||||
const projectPath = createMockProject("my-project");
|
||||
const project = await central.registerProject({
|
||||
name: "my-project",
|
||||
path: resolve(projectPath),
|
||||
});
|
||||
createdProjectIds.push(project.id);
|
||||
|
||||
const found = await detectProjectFromCwd(projectPath, central);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.id).toBe(project.id);
|
||||
expect(found?.name).toBe("my-project");
|
||||
});
|
||||
|
||||
it("should walk up directory tree to find project", async () => {
|
||||
const projectPath = createMockProject("my-project");
|
||||
const subDir = join(projectPath, "src", "components");
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
|
||||
const project = await central.registerProject({
|
||||
name: "my-project",
|
||||
path: resolve(projectPath),
|
||||
});
|
||||
createdProjectIds.push(project.id);
|
||||
|
||||
const found = await detectProjectFromCwd(subDir, central);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.id).toBe(project.id);
|
||||
});
|
||||
// FNXC:PostgresCutover 2026-07-05-17:30: the registerProject-dependent
|
||||
// detect tests moved to the PostgreSQL-backed block at the bottom of this
|
||||
// file — CentralCore writes require an AsyncDataLayer (legacy SQLite
|
||||
// CentralDatabase was removed under VAL-REMOVAL-005).
|
||||
|
||||
it("should return undefined when no project found", async () => {
|
||||
const randomDir = join(tempDir, "random");
|
||||
@@ -185,15 +164,11 @@ describe("project-context", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should resolve unregistered local project from cwd", async () => {
|
||||
const projectPath = createMockProject("legacy-project");
|
||||
|
||||
const context = await resolveProject(undefined, projectPath, homeDir);
|
||||
|
||||
expect(context.projectPath).toBe(resolve(projectPath));
|
||||
expect(context.projectName).toBe("legacy-project");
|
||||
expect(context.isRegistered).toBe(false);
|
||||
});
|
||||
// FNXC:PostgresCutover 2026-07-05-17:30: "resolves unregistered local
|
||||
// project from cwd" moved to the PostgreSQL-backed block below — it boots
|
||||
// a real project store through the startup factory, which must target the
|
||||
// test cluster (DATABASE_URL) instead of spawning embedded PostgreSQL
|
||||
// inside a unit-test worker.
|
||||
|
||||
it("should throw when no project can be resolved", async () => {
|
||||
const randomDir = join(tempDir, "no-project-here");
|
||||
@@ -205,3 +180,118 @@ describe("project-context", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PostgresCutover 2026-07-05-17:30:
|
||||
PostgreSQL-backed CentralCore coverage for project-context. The legacy SQLite
|
||||
CentralDatabase was removed (VAL-REMOVAL-005): registerProject and the
|
||||
factory-booted store paths need a real AsyncDataLayer. Auto-skipped when
|
||||
PostgreSQL is unreachable (pgDescribe), matching the core pg suites.
|
||||
*/
|
||||
pgDescribe("project-context (PostgreSQL-backed CentralCore)", () => {
|
||||
let h: PgTestHarness;
|
||||
let tempDir: string;
|
||||
let homeDir: string;
|
||||
let central: CentralCore;
|
||||
const createdProjectIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createTaskStoreForTest({ prefix: "fusion_cli_project_ctx" });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await h.teardown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-test-pg-"));
|
||||
homeDir = mkdtempSync(join(tmpdir(), "kb-home-pg-"));
|
||||
central = new CentralCore(homeDir, { asyncLayer: h.layer });
|
||||
await central.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const projectId of createdProjectIds) {
|
||||
try {
|
||||
await central.unregisterProject(projectId);
|
||||
} catch {
|
||||
// Ignore cleanup errors for already-removed entities
|
||||
}
|
||||
}
|
||||
createdProjectIds.length = 0;
|
||||
try {
|
||||
await central.close();
|
||||
} catch {
|
||||
// Ignore close errors
|
||||
}
|
||||
clearStoreCache();
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
function createMockProject(name: string, parentDir: string = tempDir): string {
|
||||
const projectPath = join(parentDir, name);
|
||||
mkdirSync(join(projectPath, ".fusion"), { recursive: true });
|
||||
writeFileSync(join(projectPath, ".fusion", "fusion.db"), "");
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
it("should find project from CWD when .fusion/fusion.db exists", async () => {
|
||||
const projectPath = createMockProject("my-project");
|
||||
const project = await central.registerProject({
|
||||
name: "my-project",
|
||||
path: resolve(projectPath),
|
||||
});
|
||||
createdProjectIds.push(project.id);
|
||||
|
||||
const found = await detectProjectFromCwd(projectPath, central);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.id).toBe(project.id);
|
||||
expect(found?.name).toBe("my-project");
|
||||
});
|
||||
|
||||
it("should walk up directory tree to find project", async () => {
|
||||
const projectPath = createMockProject("my-project");
|
||||
const subDir = join(projectPath, "src", "components");
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
|
||||
const project = await central.registerProject({
|
||||
name: "my-project",
|
||||
path: resolve(projectPath),
|
||||
});
|
||||
createdProjectIds.push(project.id);
|
||||
|
||||
const found = await detectProjectFromCwd(subDir, central);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.id).toBe(project.id);
|
||||
});
|
||||
|
||||
it("should resolve unregistered local project from cwd", async () => {
|
||||
const projectPath = createMockProject("legacy-project");
|
||||
// Point the startup factory at the test cluster so createLocalStore
|
||||
// connects externally instead of spawning an embedded PostgreSQL
|
||||
// subprocess inside the test worker.
|
||||
const prevDatabaseUrl = process.env.DATABASE_URL;
|
||||
process.env.DATABASE_URL = h.testUrl;
|
||||
try {
|
||||
const context = await resolveProject(undefined, projectPath, homeDir);
|
||||
|
||||
expect(context.projectPath).toBe(resolve(projectPath));
|
||||
expect(context.projectName).toBe("legacy-project");
|
||||
expect(context.isRegistered).toBe(false);
|
||||
await context.store.close();
|
||||
} finally {
|
||||
if (prevDatabaseUrl === undefined) {
|
||||
delete process.env.DATABASE_URL;
|
||||
} else {
|
||||
process.env.DATABASE_URL = prevDatabaseUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,74 +1,50 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import kbExtension, { closeCachedStores } from "../extension.js";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
/**
|
||||
* FNXC:PostgresCutover 2026-07-04-00:00:
|
||||
* Migrated from the legacy SQLite `new TaskStore(tmpDir)` harness to the
|
||||
* PostgreSQL extension harness. Research runs are seeded via the PG-backed
|
||||
* AsyncResearchStore (`h.store().getResearchStore()`), and the research tools
|
||||
* resolve the same store through the harness-injected `getStore(cwd)` cache.
|
||||
*/
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: any,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: ((update: any) => void) | undefined,
|
||||
ctx: any,
|
||||
) => Promise<any>;
|
||||
}
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
|
||||
import { pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import {
|
||||
createPgExtensionHarness,
|
||||
createMockApi,
|
||||
registerExtension,
|
||||
requireTool,
|
||||
type ToolExecuteContext,
|
||||
} from "./pg-extension-harness.js";
|
||||
import { type AsyncResearchStore, type ResearchResult } from "@fusion/core";
|
||||
|
||||
function createMockAPI() {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
return {
|
||||
registerTool(def: RegisteredTool) {
|
||||
tools.set(def.name, def);
|
||||
},
|
||||
registerCommand() {},
|
||||
registerShortcut() {},
|
||||
registerFlag() {},
|
||||
on() {},
|
||||
tools,
|
||||
} as any;
|
||||
}
|
||||
const pgTest = pgDescribe;
|
||||
|
||||
function makeCtx(cwd: string) {
|
||||
return { cwd } as any;
|
||||
}
|
||||
|
||||
describe("research extension tools", () => {
|
||||
let tmpDir: string;
|
||||
let api: ReturnType<typeof createMockAPI>;
|
||||
let openStores: TaskStore[] = [];
|
||||
|
||||
function createStore(): TaskStore {
|
||||
const store = new TaskStore(tmpDir);
|
||||
openStores.push(store);
|
||||
return store;
|
||||
/** Narrow a details payload value to a string (throws loudly if it isn't one). */
|
||||
function asString(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`expected string, got ${typeof value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-research-test-"));
|
||||
api = createMockAPI();
|
||||
kbExtension(api);
|
||||
});
|
||||
function makeCtx(cwd: string): ToolExecuteContext {
|
||||
return { cwd };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
/*
|
||||
FNXC:CliTests 2026-06-19-10:58:
|
||||
FN-6734 reproduced research-extension-tools timeouts with ENOTEMPTY while removing per-test `.fusion` dirs because real TaskStore handles stayed open past fixture cleanup.
|
||||
Close both manually-created stores and the extension store cache before deleting temp roots; do not hide the load-only race with timeout or worker changes.
|
||||
*/
|
||||
for (const store of openStores.splice(0)) {
|
||||
try {
|
||||
await store.close();
|
||||
} catch {
|
||||
// Best effort: cleanup must continue so the temp root can be removed.
|
||||
}
|
||||
}
|
||||
await closeCachedStores();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
pgTest("research extension tools", () => {
|
||||
const h = createPgExtensionHarness("kb-cli-research");
|
||||
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
|
||||
// In backend mode getResearchStore() returns the AsyncResearchStore (async methods).
|
||||
const research = (): AsyncResearchStore => h.store().getResearchStore() as AsyncResearchStore;
|
||||
|
||||
it("registers research extension tools", () => {
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
expect(api.tools.has("fn_research_run")).toBe(true);
|
||||
expect(api.tools.has("fn_research_list")).toBe(true);
|
||||
expect(api.tools.has("fn_research_get")).toBe(true);
|
||||
@@ -77,40 +53,41 @@ describe("research extension tools", () => {
|
||||
});
|
||||
|
||||
it("returns feature-disabled response when experimental research flag is off", async () => {
|
||||
const store = createStore();
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
await store.updateSettings({ researchSettings: { enabled: true }, experimentalFeatures: { researchView: false } as Record<string, boolean> });
|
||||
|
||||
const runTool = api.tools.get("fn_research_run")!;
|
||||
const result = await runTool.execute("call-1", { query: "fusion" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const runTool = requireTool(api, "fn_research_run");
|
||||
const result = await runTool.execute("call-1", { query: "fusion" }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
|
||||
expect(result.details.setup.code).toBe("feature-disabled");
|
||||
expect(result.content[0].text).toContain("disabled");
|
||||
expect(result.details?.setup).toMatchObject({ code: "feature-disabled" });
|
||||
expect(result.content[0]?.text).toContain("disabled");
|
||||
});
|
||||
|
||||
it("returns feature-disabled contract for list/get/cancel/retry when flag is off", async () => {
|
||||
const store = createStore();
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
await store.updateSettings({ researchSettings: { enabled: true }, experimentalFeatures: { researchView: false } as Record<string, boolean> });
|
||||
|
||||
const listResult = await api.tools.get("fn_research_list")!.execute("call-list", {}, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(listResult.details.setup.code).toBe("feature-disabled");
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const listResult = await requireTool(api, "fn_research_list").execute("call-list", {}, undefined, undefined, makeCtx(h.rootDir()));
|
||||
expect(listResult.details?.setup).toMatchObject({ code: "feature-disabled" });
|
||||
|
||||
const getResult = await api.tools.get("fn_research_get")!.execute("call-get", { id: "RR-1" }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(getResult.details.setup.code).toBe("feature-disabled");
|
||||
const getResult = await requireTool(api, "fn_research_get").execute("call-get", { id: "RR-1" }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
expect(getResult.details?.setup).toMatchObject({ code: "feature-disabled" });
|
||||
|
||||
const cancelResult = await api.tools.get("fn_research_cancel")!.execute("call-cancel", { id: "RR-1" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const cancelResult = await requireTool(api, "fn_research_cancel").execute("call-cancel", { id: "RR-1" }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
expect(cancelResult.isError).toBe(true);
|
||||
expect(cancelResult.details.setup.code).toBe("feature-disabled");
|
||||
expect(cancelResult.details?.setup).toMatchObject({ code: "feature-disabled" });
|
||||
|
||||
const retryResult = await api.tools.get("fn_research_retry")!.execute("call-retry", { id: "RR-1" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const retryResult = await requireTool(api, "fn_research_retry").execute("call-retry", { id: "RR-1" }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
expect(retryResult.isError).toBe(true);
|
||||
expect(retryResult.details.setup.code).toBe("feature-disabled");
|
||||
expect(retryResult.details?.setup).toMatchObject({ code: "feature-disabled" });
|
||||
});
|
||||
|
||||
it("treats builtin as configured when no provider is explicitly set", async () => {
|
||||
const store = createStore();
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
await store.updateGlobalSettings({
|
||||
experimentalFeatures: { researchView: true } as Record<string, boolean>,
|
||||
researchGlobalEnabled: true,
|
||||
@@ -119,16 +96,17 @@ describe("research extension tools", () => {
|
||||
researchSettings: { enabled: true },
|
||||
});
|
||||
|
||||
const runTool = api.tools.get("fn_research_run")!;
|
||||
const result = await runTool.execute("call-builtin", { query: "fusion" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const runTool = requireTool(api, "fn_research_run");
|
||||
const result = await runTool.execute("call-builtin", { query: "fusion" }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
|
||||
expect(result.details.setup).toBeNull();
|
||||
expect(result.details.status).toBe("queued");
|
||||
expect(result.details?.setup).toBeNull();
|
||||
expect(result.details?.status).toBe("queued");
|
||||
});
|
||||
|
||||
it("returns actionable missing-credentials response", async () => {
|
||||
const store = createStore();
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
await store.updateGlobalSettings({
|
||||
experimentalFeatures: { researchView: true } as Record<string, boolean>,
|
||||
researchGlobalEnabled: true,
|
||||
@@ -139,16 +117,17 @@ describe("research extension tools", () => {
|
||||
researchSettings: { enabled: true },
|
||||
});
|
||||
|
||||
const runTool = api.tools.get("fn_research_run")!;
|
||||
const result = await runTool.execute("call-0", { query: "fusion" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const runTool = requireTool(api, "fn_research_run");
|
||||
const result = await runTool.execute("call-0", { query: "fusion" }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
|
||||
expect(result.details.setup.code).toBe("missing-credentials");
|
||||
expect(result.content[0].text).toContain("Missing credentials");
|
||||
expect(result.details?.setup).toMatchObject({ code: "missing-credentials" });
|
||||
expect(result.content[0]?.text).toContain("Missing credentials");
|
||||
});
|
||||
|
||||
it("creates, reads, lists, and cancels runs", async () => {
|
||||
const store = createStore();
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
await store.updateGlobalSettings({
|
||||
experimentalFeatures: { researchView: true } as Record<string, boolean>,
|
||||
researchGlobalEnabled: true,
|
||||
@@ -160,28 +139,28 @@ describe("research extension tools", () => {
|
||||
researchSettings: { enabled: true, searchProvider: "searxng" },
|
||||
});
|
||||
|
||||
const created = store.getResearchStore().createRun({ query: "fusion architecture", topic: "fusion architecture" });
|
||||
const created = await research().createRun({ query: "fusion architecture", topic: "fusion architecture" });
|
||||
|
||||
const listTool = api.tools.get("fn_research_list")!;
|
||||
const listResult = await listTool.execute("call-2", {}, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(listResult.details.runs.length).toBeGreaterThan(0);
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const listResult = await requireTool(api, "fn_research_list").execute("call-2", {}, undefined, undefined, makeCtx(h.rootDir()));
|
||||
const runs = listResult.details?.runs;
|
||||
if (!Array.isArray(runs)) throw new Error("expected runs array");
|
||||
expect(runs.length).toBeGreaterThan(0);
|
||||
|
||||
const getTool = api.tools.get("fn_research_get")!;
|
||||
const getResult = await getTool.execute("call-3", { id: created.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(getResult.details.runId).toBe(created.id);
|
||||
const getResult = await requireTool(api, "fn_research_get").execute("call-3", { id: created.id }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
expect(getResult.details?.runId).toBe(created.id);
|
||||
|
||||
const cancelTool = api.tools.get("fn_research_cancel")!;
|
||||
const cancelResult = await cancelTool.execute("call-4", { id: created.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(["cancelling", "cancelled"]).toContain(cancelResult.details.status);
|
||||
const cancelResult = await requireTool(api, "fn_research_cancel").execute("call-4", { id: created.id }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
const cancelStatus = cancelResult.details?.status;
|
||||
expect(cancelStatus === "cancelling" || cancelStatus === "cancelled").toBe(true);
|
||||
|
||||
const retryTool = api.tools.get("fn_research_retry")!;
|
||||
const retryBlocked = await retryTool.execute("call-5", { id: created.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(retryBlocked.isError).toBe(true);
|
||||
const retryResult = await requireTool(api, "fn_research_retry").execute("call-5", { id: created.id }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
expect(retryResult.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("returns structured missing-run details for get and cancel", async () => {
|
||||
const store = createStore();
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
await store.updateGlobalSettings({
|
||||
experimentalFeatures: { researchView: true } as Record<string, boolean>,
|
||||
researchGlobalEnabled: true,
|
||||
@@ -193,22 +172,21 @@ describe("research extension tools", () => {
|
||||
researchSettings: { enabled: true, searchProvider: "searxng" },
|
||||
});
|
||||
|
||||
const getTool = api.tools.get("fn_research_get")!;
|
||||
const getResult = await getTool.execute("call-missing-get", { id: "RR-404" }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(getResult.details.runId).toBe("RR-404");
|
||||
expect(getResult.details.status).toBe("missing");
|
||||
expect(getResult.details.setup.code).toBe("NOT_FOUND");
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const getResult = await requireTool(api, "fn_research_get").execute("call-missing-get", { id: "RR-404" }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
expect(getResult.details?.runId).toBe("RR-404");
|
||||
expect(getResult.details?.status).toBe("missing");
|
||||
expect(getResult.details?.setup).toMatchObject({ code: "NOT_FOUND" });
|
||||
|
||||
const cancelTool = api.tools.get("fn_research_cancel")!;
|
||||
const cancelResult = await cancelTool.execute("call-missing-cancel", { id: "RR-404" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const cancelResult = await requireTool(api, "fn_research_cancel").execute("call-missing-cancel", { id: "RR-404" }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
expect(cancelResult.isError).toBe(true);
|
||||
expect(cancelResult.details.runId).toBe("RR-404");
|
||||
expect(cancelResult.details.setup.code).toBe("NOT_FOUND");
|
||||
expect(cancelResult.details?.runId).toBe("RR-404");
|
||||
expect(cancelResult.details?.setup).toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("returns completed-run structured findings and citations", async () => {
|
||||
const store = createStore();
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
await store.updateGlobalSettings({
|
||||
experimentalFeatures: { researchView: true } as Record<string, boolean>,
|
||||
researchGlobalEnabled: true,
|
||||
@@ -220,29 +198,32 @@ describe("research extension tools", () => {
|
||||
researchSettings: { enabled: true, searchProvider: "searxng" },
|
||||
});
|
||||
|
||||
const run = store.getResearchStore().createRun({ query: "fusion", topic: "fusion" });
|
||||
store.getResearchStore().setResults(run.id, {
|
||||
const run = await research().createRun({ query: "fusion", topic: "fusion" });
|
||||
// The persisted result carries structured citations; the ResearchResult type
|
||||
// declares citations as string[], so narrow once at this test boundary.
|
||||
const results = {
|
||||
summary: "Summary text",
|
||||
findings: [{ heading: "Finding A", content: "Detail A", sources: ["https://example.com/a"] }],
|
||||
citations: [{ title: "Source A", url: "https://example.com/a" }],
|
||||
} as any);
|
||||
store.getResearchStore().updateStatus(run.id, "running");
|
||||
store.getResearchStore().updateStatus(run.id, "completed");
|
||||
} as unknown as ResearchResult;
|
||||
await research().setResults(run.id, results);
|
||||
await research().updateStatus(run.id, "running");
|
||||
await research().updateStatus(run.id, "completed");
|
||||
|
||||
const getTool = api.tools.get("fn_research_get")!;
|
||||
const result = await getTool.execute("call-complete", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(result.details.runId).toBe(run.id);
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(result.details.summary).toBe("Summary text");
|
||||
expect(result.details.findings).toHaveLength(1);
|
||||
expect(result.details.findings[0]).toMatchObject({ heading: "Finding A", content: "Detail A" });
|
||||
expect(result.details.citations).toHaveLength(1);
|
||||
expect(result.details.citations[0]).toMatchObject({ title: "Source A", url: "https://example.com/a" });
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const result = await requireTool(api, "fn_research_get").execute("call-complete", { id: run.id }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
expect(result.details?.runId).toBe(run.id);
|
||||
expect(result.details?.status).toBe("completed");
|
||||
expect(result.details?.summary).toBe("Summary text");
|
||||
expect(result.details?.findings).toHaveLength(1);
|
||||
expect(result.details?.findings).toMatchObject([{ heading: "Finding A", content: "Detail A" }]);
|
||||
expect(result.details?.citations).toHaveLength(1);
|
||||
expect(result.details?.citations).toMatchObject([{ title: "Source A", url: "https://example.com/a" }]);
|
||||
});
|
||||
|
||||
it("retries failed run and returns retry linkage metadata", async () => {
|
||||
const store = createStore();
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
await store.updateGlobalSettings({
|
||||
experimentalFeatures: { researchView: true } as Record<string, boolean>,
|
||||
researchGlobalEnabled: true,
|
||||
@@ -254,26 +235,22 @@ describe("research extension tools", () => {
|
||||
researchSettings: { enabled: true, searchProvider: "searxng" },
|
||||
});
|
||||
|
||||
const run = store.getResearchStore().createRun({
|
||||
query: "fusion",
|
||||
topic: "fusion",
|
||||
lifecycle: { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" },
|
||||
});
|
||||
store.getResearchStore().updateStatus(run.id, "running", {
|
||||
lifecycle: { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" },
|
||||
});
|
||||
store.getResearchStore().updateStatus(run.id, "failed", {
|
||||
lifecycle: { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" },
|
||||
});
|
||||
const lifecycle = { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" };
|
||||
const run = await research().createRun({ query: "fusion", topic: "fusion", lifecycle });
|
||||
await research().updateStatus(run.id, "running", { lifecycle });
|
||||
await research().updateStatus(run.id, "failed", { lifecycle });
|
||||
|
||||
const retryTool = api.tools.get("fn_research_retry")!;
|
||||
const retryResult = await retryTool.execute("call-retry", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const retryResult = await requireTool(api, "fn_research_retry").execute("call-retry", { id: run.id }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
|
||||
expect(retryResult.isError).not.toBe(true);
|
||||
expect(["queued", "retry_waiting"]).toContain(retryResult.details.status);
|
||||
expect(retryResult.details.runId).not.toBe(run.id);
|
||||
const retryStatus = retryResult.details?.status;
|
||||
expect(retryStatus === "queued" || retryStatus === "retry_waiting").toBe(true);
|
||||
const newRunId = asString(retryResult.details?.runId);
|
||||
expect(newRunId).not.toBe(run.id);
|
||||
|
||||
const retried = store.getResearchStore().getRun(retryResult.details.runId);
|
||||
const retried = await research().getRun(newRunId);
|
||||
expect(retried?.status).toBe("retry_waiting");
|
||||
expect(retried?.lifecycle?.retryOfRunId).toBe(run.id);
|
||||
expect(retried?.lifecycle?.rootRunId).toBe(run.id);
|
||||
@@ -281,8 +258,7 @@ describe("research extension tools", () => {
|
||||
});
|
||||
|
||||
it("returns INVALID_TRANSITION for cancel on terminal run", async () => {
|
||||
const store = createStore();
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
await store.updateGlobalSettings({
|
||||
experimentalFeatures: { researchView: true } as Record<string, boolean>,
|
||||
researchGlobalEnabled: true,
|
||||
@@ -294,13 +270,14 @@ describe("research extension tools", () => {
|
||||
researchSettings: { enabled: true, searchProvider: "searxng" },
|
||||
});
|
||||
|
||||
const run = store.getResearchStore().createRun({ query: "fusion", topic: "fusion" });
|
||||
store.getResearchStore().updateStatus(run.id, "running");
|
||||
store.getResearchStore().updateStatus(run.id, "completed");
|
||||
const run = await research().createRun({ query: "fusion", topic: "fusion" });
|
||||
await research().updateStatus(run.id, "running");
|
||||
await research().updateStatus(run.id, "completed");
|
||||
|
||||
const cancelTool = api.tools.get("fn_research_cancel")!;
|
||||
const result = await cancelTool.execute("call-6", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const result = await requireTool(api, "fn_research_cancel").execute("call-6", { id: run.id }, undefined, undefined, makeCtx(h.rootDir()));
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.details.setup.code).toBe("INVALID_TRANSITION");
|
||||
expect(result.details?.setup).toMatchObject({ code: "INVALID_TRANSITION" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,115 +1,97 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
/**
|
||||
* FNXC:PostgresCutover 2026-07-04-00:00:
|
||||
* Migrated from the legacy SQLite `new TaskStore(rootDir)` harness to the
|
||||
* PostgreSQL extension harness. The agent tools now resolve a PG-backed store
|
||||
* via `getStore(cwd)` (injected by the harness), and task state is read back
|
||||
* through `store.getTask(id, { includeDeleted: true })` instead of the removed
|
||||
* sync `readTaskFromDb` path.
|
||||
*/
|
||||
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import kbExtension, { closeCachedStores } from "../extension.js";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
|
||||
import { pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import {
|
||||
createPgExtensionHarness,
|
||||
createMockApi,
|
||||
registerExtension,
|
||||
requireTool,
|
||||
} from "./pg-extension-harness.js";
|
||||
|
||||
type RegisteredTool = {
|
||||
name: string;
|
||||
execute: (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: { cwd: string; taskId?: string; agentId?: string; runId?: string }) => Promise<any>;
|
||||
};
|
||||
const pgTest = pgDescribe;
|
||||
|
||||
function createMockAPI() {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
return {
|
||||
tools,
|
||||
registerTool(tool: RegisteredTool) {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
registerCommand() {
|
||||
// no-op for tests
|
||||
},
|
||||
on() {
|
||||
// no-op for tests
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
pgTest("task delete allowResurrection plumbing", () => {
|
||||
const h = createPgExtensionHarness("fn-task-delete-allow");
|
||||
|
||||
describe("task delete allowResurrection plumbing", () => {
|
||||
let rootDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = await mkdtemp(join(tmpdir(), "fn-task-delete-allow-"));
|
||||
await mkdir(join(rootDir, ".fusion"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closeCachedStores();
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
|
||||
it("fn_task_delete forwards allowResurrection=true", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
const task = await store.createTask({ title: "x", description: "y", column: "todo" });
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
await tool.execute("call-1", { id: task.id, allowResurrection: true }, undefined, undefined, { cwd: rootDir });
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const tool = requireTool(api, "fn_task_delete");
|
||||
await tool.execute("call-1", { id: task.id, allowResurrection: true }, undefined, undefined, { cwd: h.rootDir() });
|
||||
|
||||
const deleted = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as { allowResurrection?: boolean; deletedAt?: string };
|
||||
const deleted = await store.getTask(task.id, { includeDeleted: true });
|
||||
expect(deleted.deletedAt).toBeTruthy();
|
||||
expect(deleted.allowResurrection).toBe(true);
|
||||
});
|
||||
|
||||
it("fn_task_delete defaults allowResurrection=false", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
const task = await store.createTask({ title: "x", description: "y", column: "todo" });
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
await tool.execute("call-2", { id: task.id }, undefined, undefined, { cwd: rootDir });
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const tool = requireTool(api, "fn_task_delete");
|
||||
await tool.execute("call-2", { id: task.id }, undefined, undefined, { cwd: h.rootDir() });
|
||||
|
||||
const deleted = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as { allowResurrection?: boolean; deletedAt?: string };
|
||||
const deleted = await store.getTask(task.id, { includeDeleted: true });
|
||||
expect(deleted.deletedAt).toBeTruthy();
|
||||
expect(deleted.allowResurrection).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fn_task_delete rejects deleting the caller task and leaves it live", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
const task = await store.createTask({ title: "self", description: "current task", column: "in-progress" });
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const tool = requireTool(api, "fn_task_delete");
|
||||
|
||||
await expect(
|
||||
tool.execute("call-self", { id: task.id }, undefined, undefined, {
|
||||
cwd: rootDir,
|
||||
cwd: h.rootDir(),
|
||||
taskId: task.id,
|
||||
agentId: "agent-test",
|
||||
runId: "run-test",
|
||||
}),
|
||||
).rejects.toThrow(`Task ${task.id} cannot delete itself`);
|
||||
|
||||
const row = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as { deletedAt?: string };
|
||||
const row = await store.getTask(task.id, { includeDeleted: true });
|
||||
expect(row.deletedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fn_task_delete lets a task-bound caller delete a different task", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
const caller = await store.createTask({ title: "caller", description: "current task", column: "in-progress" });
|
||||
const target = await store.createTask({ title: "target", description: "cleanup target", column: "todo" });
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const tool = requireTool(api, "fn_task_delete");
|
||||
const result = await tool.execute("call-other", { id: target.id }, undefined, undefined, {
|
||||
cwd: rootDir,
|
||||
cwd: h.rootDir(),
|
||||
taskId: caller.id,
|
||||
agentId: "agent-test",
|
||||
runId: "run-test",
|
||||
});
|
||||
|
||||
expect(result.content[0]?.text).toBe(`Deleted ${target.id}`);
|
||||
const deleted = (store as any).readTaskFromDb(target.id, { includeDeleted: true }) as { deletedAt?: string };
|
||||
const deleted = await store.getTask(target.id, { includeDeleted: true });
|
||||
expect(deleted.deletedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import kbExtension, { closeCachedStores } from "../extension.js";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
|
||||
|
||||
/*
|
||||
FNXC:TaskLifecycleTools 2026-07-07-00:00:
|
||||
@@ -12,170 +6,153 @@ Regression coverage for FN-7661: fn_task_archive / fn_task_delete previously nev
|
||||
removeLineageReferences, so a task still referenced as a lineage parent by another task was
|
||||
permanently stuck even though the store's TaskHasLineageChildrenError message told callers to
|
||||
pass that flag. These tests reproduce the original stuck-task symptom and assert it is gone via
|
||||
the actual agent-facing tools, mirroring the mock-API harness in task-delete-allow-resurrection.test.ts
|
||||
and the lineage fixture setup in soft-delete-lineage-children.test.ts.
|
||||
the actual agent-facing tools.
|
||||
|
||||
FNXC:PostgresCutover 2026-07-08-00:00:
|
||||
Ported from upstream's sqlite version: runs on the shared PG extension harness (the sqlite
|
||||
TaskStore path is removed on this branch), seeds lineage via createTask's `source` provenance
|
||||
input instead of raw sqlite UPDATEs, and reads forensic state via getTask({includeDeleted}).
|
||||
*/
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import {
|
||||
createMockApi,
|
||||
createPgExtensionHarness,
|
||||
pgDescribe,
|
||||
registerExtension,
|
||||
requireTool,
|
||||
} from "./pg-extension-harness.js";
|
||||
|
||||
type RegisteredTool = {
|
||||
name: string;
|
||||
execute: (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: { cwd: string; taskId?: string; agentId?: string; runId?: string }) => Promise<any>;
|
||||
};
|
||||
const h = createPgExtensionHarness("fn-lineage-unlink");
|
||||
|
||||
function createMockAPI() {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
return {
|
||||
tools,
|
||||
registerTool(tool: RegisteredTool) {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
registerCommand() {
|
||||
// no-op for tests
|
||||
},
|
||||
on() {
|
||||
// no-op for tests
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
pgDescribe("fn_task_archive / fn_task_delete removeLineageReferences plumbing", () => {
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
|
||||
describe("fn_task_archive / fn_task_delete removeLineageReferences plumbing", () => {
|
||||
let rootDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = await mkdtemp(join(tmpdir(), "fn-task-lineage-unlink-"));
|
||||
await mkdir(join(rootDir, ".fusion"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closeCachedStores();
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
function ctx() {
|
||||
return { cwd: h.rootDir() };
|
||||
}
|
||||
|
||||
async function createParentAndChild(store: TaskStore, parentColumn: "todo" | "done" = "todo") {
|
||||
const parent = await store.createTask({ column: parentColumn, title: "parent", description: "parent" });
|
||||
const child = await store.createTask({ column: "todo", title: "child", description: "child" });
|
||||
(store as any).db
|
||||
.prepare("UPDATE tasks SET sourceParentTaskId = ?, sourceType = ?, updatedAt = ? WHERE id = ?")
|
||||
.run(parent.id, "task_refine", new Date().toISOString(), child.id);
|
||||
const child = await store.createTask({
|
||||
column: "todo",
|
||||
title: "child",
|
||||
description: "child",
|
||||
source: { sourceType: "task_refine", sourceParentTaskId: parent.id },
|
||||
});
|
||||
return { parent, child: await store.getTask(child.id) };
|
||||
}
|
||||
|
||||
it("fn_task_archive rejects a lineage parent when removeLineageReferences is omitted", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
const { parent } = await createParentAndChild(store);
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_archive") as RegisteredTool;
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const tool = requireTool(api, "fn_task_archive");
|
||||
|
||||
await expect(tool.execute("call-1", { id: parent.id }, undefined, undefined, { cwd: rootDir })).rejects.toThrow(
|
||||
await expect(tool.execute("call-1", { id: parent.id }, undefined, undefined, ctx())).rejects.toThrow(
|
||||
/still referenced as a lineage parent/,
|
||||
);
|
||||
|
||||
const row = (store as any).readTaskFromDb(parent.id, { includeDeleted: true }) as { column: string };
|
||||
const row = await store.getTask(parent.id, { includeDeleted: true });
|
||||
expect(row.column).not.toBe("archived");
|
||||
});
|
||||
|
||||
it("fn_task_archive rejects a lineage parent when removeLineageReferences is explicitly false", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
const { parent } = await createParentAndChild(store);
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_archive") as RegisteredTool;
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const tool = requireTool(api, "fn_task_archive");
|
||||
|
||||
await expect(
|
||||
tool.execute("call-2", { id: parent.id, removeLineageReferences: false }, undefined, undefined, { cwd: rootDir }),
|
||||
tool.execute("call-2", { id: parent.id, removeLineageReferences: false }, undefined, undefined, ctx()),
|
||||
).rejects.toThrow(/still referenced as a lineage parent/);
|
||||
});
|
||||
|
||||
it("fn_task_archive with removeLineageReferences:true archives the parent and clears the child reference", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
const { parent, child } = await createParentAndChild(store);
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_archive") as RegisteredTool;
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const tool = requireTool(api, "fn_task_archive");
|
||||
const result = await tool.execute(
|
||||
"call-3",
|
||||
{ id: parent.id, removeLineageReferences: true },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd: rootDir },
|
||||
ctx(),
|
||||
);
|
||||
|
||||
expect(result.details.column).toBe("archived");
|
||||
const archived = await store.getTask(parent.id);
|
||||
expect(archived.column).toBe("archived");
|
||||
|
||||
const updatedChild = await store.getTask(child.id);
|
||||
expect(updatedChild.sourceParentTaskId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fn_task_archive with no lineage children behaves unchanged and preserves cleanup default", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
const task = await store.createTask({ column: "done", title: "solo", description: "no children" });
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_archive") as RegisteredTool;
|
||||
const result = await tool.execute("call-4", { id: task.id }, undefined, undefined, { cwd: rootDir });
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const tool = requireTool(api, "fn_task_archive");
|
||||
const result = await tool.execute("call-4", { id: task.id }, undefined, undefined, ctx());
|
||||
|
||||
expect(result.details.column).toBe("archived");
|
||||
});
|
||||
|
||||
it("fn_task_delete rejects a lineage parent when removeLineageReferences is omitted", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
const { parent } = await createParentAndChild(store);
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const tool = requireTool(api, "fn_task_delete");
|
||||
|
||||
await expect(tool.execute("call-5", { id: parent.id }, undefined, undefined, { cwd: rootDir })).rejects.toThrow(
|
||||
await expect(tool.execute("call-5", { id: parent.id }, undefined, undefined, ctx())).rejects.toThrow(
|
||||
/still referenced as a lineage parent/,
|
||||
);
|
||||
|
||||
const row = (store as any).readTaskFromDb(parent.id, { includeDeleted: true }) as { deletedAt?: string };
|
||||
const row = await store.getTask(parent.id, { includeDeleted: true });
|
||||
expect(row.deletedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fn_task_delete rejects a lineage parent when removeLineageReferences is explicitly false", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
const { parent } = await createParentAndChild(store);
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const tool = requireTool(api, "fn_task_delete");
|
||||
|
||||
await expect(
|
||||
tool.execute("call-6", { id: parent.id, removeLineageReferences: false }, undefined, undefined, { cwd: rootDir }),
|
||||
tool.execute("call-6", { id: parent.id, removeLineageReferences: false }, undefined, undefined, ctx()),
|
||||
).rejects.toThrow(/still referenced as a lineage parent/);
|
||||
});
|
||||
|
||||
it("fn_task_delete with removeLineageReferences:true soft-deletes the parent and clears the child reference", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
const { parent, child } = await createParentAndChild(store);
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const tool = requireTool(api, "fn_task_delete");
|
||||
const result = await tool.execute(
|
||||
"call-7",
|
||||
{ id: parent.id, removeLineageReferences: true },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd: rootDir },
|
||||
ctx(),
|
||||
);
|
||||
|
||||
expect(result.content[0]?.text).toBe(`Deleted ${parent.id}`);
|
||||
const deleted = (store as any).readTaskFromDb(parent.id, { includeDeleted: true }) as { deletedAt?: string };
|
||||
const deleted = await store.getTask(parent.id, { includeDeleted: true });
|
||||
expect(deleted.deletedAt).toBeTruthy();
|
||||
|
||||
const updatedChild = await store.getTask(child.id);
|
||||
@@ -183,17 +160,16 @@ describe("fn_task_archive / fn_task_delete removeLineageReferences plumbing", ()
|
||||
});
|
||||
|
||||
it("fn_task_delete with no lineage children behaves unchanged", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const store = h.store();
|
||||
const task = await store.createTask({ column: "todo", title: "solo", description: "no children" });
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
const result = await tool.execute("call-8", { id: task.id }, undefined, undefined, { cwd: rootDir });
|
||||
const api = createMockApi();
|
||||
registerExtension(api);
|
||||
const tool = requireTool(api, "fn_task_delete");
|
||||
const result = await tool.execute("call-8", { id: task.id }, undefined, undefined, ctx());
|
||||
|
||||
expect(result.content[0]?.text).toBe(`Deleted ${task.id}`);
|
||||
const deleted = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as { deletedAt?: string };
|
||||
const deleted = await store.getTask(task.id, { includeDeleted: true });
|
||||
expect(deleted.deletedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,52 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
/**
|
||||
* FNXC:PostgresCutover 2026-07-04-00:00:
|
||||
* Migrated from the legacy SQLite `new TaskStore(tmpDir)` harness to the
|
||||
* PostgreSQL extension harness. `runTaskRetry` resolves its store through the
|
||||
* CLI command path (`project-context.resolveProject`), which is independent of
|
||||
* the extension store cache the harness injects — so `resolveProject` is
|
||||
* redirected to the harness's PG-backed store, and the full retry lifecycle
|
||||
* (moveTask / updateTask / getTask / logEntry) runs against real PostgreSQL
|
||||
* state instead of the removed SQLite runtime.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||
import { pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import { createPgExtensionHarness } from "./pg-extension-harness.js";
|
||||
|
||||
// `runTaskRetry` resolves its store via resolveProject() (commands/task.ts →
|
||||
// project-context.ts), a separate cache from the extension store the harness
|
||||
// injects. Redirect resolveProject to the harness PG store so the command path
|
||||
// and the seeded task share one isolated PostgreSQL database.
|
||||
const resolveProjectMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../project-context.js", () => ({
|
||||
resolveProject: resolveProjectMock,
|
||||
}));
|
||||
|
||||
import { runTaskRetry } from "../commands/task.js";
|
||||
|
||||
describe("runTaskRetry", () => {
|
||||
const originalCwd = process.cwd();
|
||||
let tmpDir: string;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
const pgTest = pgDescribe;
|
||||
|
||||
pgTest("runTaskRetry", () => {
|
||||
const h = createPgExtensionHarness("fn-task-retry");
|
||||
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "fusion-task-retry-"));
|
||||
process.chdir(tmpDir);
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
await h.beforeEach();
|
||||
resolveProjectMock.mockResolvedValue({
|
||||
store: h.store(),
|
||||
projectId: h.rootDir(),
|
||||
projectPath: h.rootDir(),
|
||||
projectName: "test",
|
||||
isRegistered: false,
|
||||
});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
consoleLogSpy.mockRestore();
|
||||
process.chdir(originalCwd);
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
resolveProjectMock.mockReset();
|
||||
await h.afterEach();
|
||||
});
|
||||
|
||||
async function createStore() {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
afterAll(h.afterAll);
|
||||
|
||||
it("retries merge-active missing-worktree session failures by clearing phantom metadata", async () => {
|
||||
const store = await createStore();
|
||||
@@ -78,7 +98,7 @@ describe("runTaskRetry", () => {
|
||||
});
|
||||
|
||||
it("clears the deadlock auto-pause when retrying a failed task", async () => {
|
||||
const store = await createStore();
|
||||
const store = h.store();
|
||||
const task = await store.createTask({
|
||||
title: "deadlock-paused task",
|
||||
description: "test",
|
||||
@@ -97,14 +117,12 @@ describe("runTaskRetry", () => {
|
||||
|
||||
await runTaskRetry(task.id);
|
||||
|
||||
const verificationStore = await createStore();
|
||||
const updated = await verificationStore.getTask(task.id);
|
||||
const updated = await store.getTask(task.id);
|
||||
expect(updated.column).toBe("todo");
|
||||
expect(updated.status).toBeUndefined();
|
||||
expect(updated.error).toBeUndefined();
|
||||
expect(updated.paused).toBeUndefined();
|
||||
expect(updated.pausedReason).toBeUndefined();
|
||||
expect(updated.status).toBeFalsy();
|
||||
expect(updated.error).toBeFalsy();
|
||||
expect(updated.paused).toBeFalsy();
|
||||
expect(updated.pausedReason).toBeFalsy();
|
||||
expect(updated.mergeRetries).toBe(0);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -131,6 +131,7 @@ async function loadCommandHandlers() {
|
||||
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
|
||||
const { runBranchGroupList, runBranchGroupShow, runBranchGroupPromote, runBranchGroupAbandon } = await import("./commands/branch-group.js");
|
||||
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
|
||||
const { runDbVacuum, runDbMigrate } = await import("./commands/db.js");
|
||||
const { runMemoryBackupCreate, runMemoryBackupList, runMemoryBackupRestore } = await import("./commands/memory-backup.js");
|
||||
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice, runMissionLinkGoal, runMissionUnlinkGoal, runMissionGoals } = await import("./commands/mission.js");
|
||||
const { runGoalsList, runGoalsCreate, runGoalsArchive, runGoalsCitations } = await import("./commands/goals.js");
|
||||
@@ -219,6 +220,8 @@ async function loadCommandHandlers() {
|
||||
runBackupList,
|
||||
runBackupRestore,
|
||||
runBackupCleanup,
|
||||
runDbVacuum,
|
||||
runDbMigrate,
|
||||
runMemoryBackupCreate,
|
||||
runMemoryBackupList,
|
||||
runMemoryBackupRestore,
|
||||
@@ -736,6 +739,8 @@ async function main() {
|
||||
runBackupList,
|
||||
runBackupRestore,
|
||||
runBackupCleanup,
|
||||
runDbVacuum,
|
||||
runDbMigrate,
|
||||
runMemoryBackupCreate,
|
||||
runMemoryBackupList,
|
||||
runMemoryBackupRestore,
|
||||
@@ -1899,6 +1904,29 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SqliteRemoval 2026-06-25-00:00:
|
||||
`fn db` subcommand: `vacuum` (compaction). The vacuum path branches
|
||||
between PostgreSQL (VACUUM/ANALYZE via DATABASE_URL) and legacy SQLite.
|
||||
The `parity` subcommand was removed with the dual-read harness — it was
|
||||
a transitional operator tool that should not ship to end users.
|
||||
*/
|
||||
case "db": {
|
||||
const subcommand = args[1];
|
||||
if (subcommand === "vacuum") {
|
||||
await runDbVacuum(projectName);
|
||||
} else if (subcommand === "migrate") {
|
||||
await runDbMigrate(projectName, { dryRun: args.includes("--dry-run") });
|
||||
} else {
|
||||
console.error("Usage: fn db vacuum | migrate");
|
||||
console.error(" vacuum — run VACUUM/ANALYZE (PostgreSQL) or VACUUM (legacy SQLite)");
|
||||
console.error(" migrate — migrate legacy SQLite data into PostgreSQL (with pre-migration backup)");
|
||||
console.error(" options: --dry-run (report plan only, no writes)");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "backup": {
|
||||
const create = args.includes("--create");
|
||||
const list = args.includes("--list");
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* FNXC:CliAgentControl 2026-07-09-00:00:
|
||||
* Regression coverage for FN-7740's `agent-export.ts` fix: `getProjectPath`
|
||||
* must resolve the project path WITHOUT leaking the cached `TaskStore`
|
||||
* `resolveProject()` constructs internally (path-only leak, mirrors
|
||||
* `git.ts`), AND `runAgentExport` must close the `AgentStore` it opens on
|
||||
* EVERY exit path — the success return AND the no-agents `process.exit(1)`
|
||||
* guard. Export is a read (no board writes) so there is no `retryOnLock`
|
||||
* surface here.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { TaskStore as TaskStoreType, ProjectContext } from "@fusion/core";
|
||||
|
||||
const mockResolveProject = vi.fn();
|
||||
|
||||
// See git-lock-retry.test.ts FNXC header for why this is a full replacement
|
||||
// mock (not a partial `importActual` spread) — the real
|
||||
// `resolveProjectPathOnly` calls `resolveProject` through the module's own
|
||||
// closure, bypassing any partial override.
|
||||
vi.mock("../../project-context.js", () => ({
|
||||
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
|
||||
resolveProjectPathOnly: async (...args: unknown[]) => {
|
||||
const context = await mockResolveProject(...args);
|
||||
try {
|
||||
await context.store.close();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return context.projectPath;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("fn agent export — store-leak reproduction (FN-7740)", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "fn-agent-export-lock-retry-test-"));
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("closes both the path-only TaskStore and the AgentStore when no agents exist (guard exit path)", async () => {
|
||||
const { TaskStore, AgentStore } = await import("@fusion/core");
|
||||
const store = new TaskStore(tmpDir) as TaskStoreType;
|
||||
await store.init();
|
||||
const taskStoreCloseSpy = vi.spyOn(store, "close");
|
||||
|
||||
mockResolveProject.mockResolvedValue({
|
||||
projectId: "proj-1",
|
||||
projectPath: tmpDir,
|
||||
projectName: "demo",
|
||||
isRegistered: true,
|
||||
store,
|
||||
} satisfies ProjectContext);
|
||||
|
||||
const agentStoreCloseSpy = vi.spyOn(AgentStore.prototype, "close");
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as never);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const { runAgentExport } = await import("../agent-export.js");
|
||||
|
||||
await expect(runAgentExport(join(tmpDir, "out"), { project: "demo" })).rejects.toThrow("process.exit:1");
|
||||
|
||||
expect(taskStoreCloseSpy).toHaveBeenCalled();
|
||||
expect(agentStoreCloseSpy).toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith("No agents found to export");
|
||||
|
||||
await store.close().catch(() => {});
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("closes both stores on the success return path when agents exist", async () => {
|
||||
const { TaskStore, AgentStore } = await import("@fusion/core");
|
||||
const store = new TaskStore(tmpDir) as TaskStoreType;
|
||||
await store.init();
|
||||
const taskStoreCloseSpy = vi.spyOn(store, "close");
|
||||
|
||||
mockResolveProject.mockResolvedValue({
|
||||
projectId: "proj-1",
|
||||
projectPath: tmpDir,
|
||||
projectName: "demo",
|
||||
isRegistered: true,
|
||||
store,
|
||||
} satisfies ProjectContext);
|
||||
|
||||
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
|
||||
await agentStore.init();
|
||||
await agentStore.createAgent({
|
||||
name: "Solo",
|
||||
role: "executor",
|
||||
title: "Solo Agent",
|
||||
metadata: { description: "test agent", skills: [] },
|
||||
instructionsText: "Do the thing.",
|
||||
});
|
||||
agentStore.close();
|
||||
|
||||
const agentStoreCloseSpy = vi.spyOn(AgentStore.prototype, "close");
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
const { runAgentExport } = await import("../agent-export.js");
|
||||
await runAgentExport(join(tmpDir, "out"), { project: "demo" });
|
||||
|
||||
expect(taskStoreCloseSpy).toHaveBeenCalled();
|
||||
expect(agentStoreCloseSpy).toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Agents exported: 1"));
|
||||
|
||||
await store.close().catch(() => {});
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import { AgentStore } from "@fusion/core";
|
||||
const mockResolveProject = vi.fn();
|
||||
|
||||
vi.mock("../../project-context.js", () => ({
|
||||
// FNXC:PostgresCutover 2026-07-10: branch agent commands resolve their AgentStore base (rootDir + asyncLayer) via this helper.
|
||||
resolveAgentStoreBase: vi.fn(async () => ({ rootDir: process.cwd(), asyncLayer: null })),
|
||||
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* FN-7704: `fn agent stop` / `fn agent start` completed their real work but
|
||||
* left the CLI process's event loop alive — `resolveProject()` cached an
|
||||
* unclosed `TaskStore` and `createAgentStore()` never closed the
|
||||
* `AgentStore` it opened. A caller bounding the subprocess with a timeout
|
||||
* (e.g. a recovery watcher) saw this as a "hang" until it force-killed the
|
||||
* process at its own 60s ceiling, on every single retry.
|
||||
*
|
||||
* This test exercises the REAL modules end-to-end (no `@fusion/core` or
|
||||
* `project-context.js` mocking) against a temp fixture `.fusion` project so
|
||||
* it reproduces the actual leaked-handle condition, not just a mocked
|
||||
* approximation of it. It asserts via `process.getActiveResourcesInfo()`
|
||||
* that `runAgentStop`/`runAgentStart` do not grow the set of active
|
||||
* (keep-alive) resources across the transition path AND the
|
||||
* already-in-target-state early-return path, for BOTH commands.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore, AgentStore } from "@fusion/core";
|
||||
import { runAgentStop, runAgentStart } from "../agent.js";
|
||||
|
||||
/** Strict bound the exit-determinism assertions must land within (< 15s per FN-7704's symptom-verification contract; the original failure window was 60s). */
|
||||
const STRICT_BOUND_MS = 15_000;
|
||||
|
||||
describe("fn agent stop/start — deterministic process exit (FN-7704)", () => {
|
||||
let tempDir: string;
|
||||
let originalCwd: string;
|
||||
let agentId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "fn-7704-agent-exit-"));
|
||||
|
||||
// Bootstrap a real .fusion project dir (fusion.db) so CWD auto-detection
|
||||
// in resolveProject()/resolveProjectPathOnly() resolves this temp dir as
|
||||
// the project, exercising the REAL TaskStore construction/teardown path.
|
||||
const bootstrapStore = new TaskStore(tempDir);
|
||||
await bootstrapStore.init();
|
||||
await bootstrapStore.close();
|
||||
|
||||
// Seed a real, non-ephemeral agent (starts "active") directly via
|
||||
// AgentStore so the CLI command under test operates on real state.
|
||||
const seedStore = new AgentStore({ rootDir: join(tempDir, ".fusion") });
|
||||
await seedStore.init();
|
||||
const agent = await seedStore.createAgent({ name: "fn-7704-fixture-agent", role: "executor" });
|
||||
agentId = agent.id;
|
||||
seedStore.close();
|
||||
|
||||
originalCwd = process.cwd();
|
||||
process.chdir(tempDir);
|
||||
}, STRICT_BOUND_MS);
|
||||
|
||||
afterAll(() => {
|
||||
process.chdir(originalCwd);
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it(
|
||||
"runAgentStop (transition path: active -> paused) leaves no net-new active resources",
|
||||
async () => {
|
||||
const before = process.getActiveResourcesInfo();
|
||||
await runAgentStop(agentId);
|
||||
const after = process.getActiveResourcesInfo();
|
||||
|
||||
// FN-7704: before the fix, this call left an open AgentStore SQLite
|
||||
// handle (and a cached, unclosed TaskStore from project resolution)
|
||||
// registered as active resources, which is exactly what kept the CLI
|
||||
// process's event loop alive past the point where the real work was
|
||||
// done. After the fix, the command's own handles must all be closed
|
||||
// by the time it returns.
|
||||
expect(after.length).toBeLessThanOrEqual(before.length);
|
||||
},
|
||||
STRICT_BOUND_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"runAgentStop (already-paused early-return path) leaves no net-new active resources",
|
||||
async () => {
|
||||
const before = process.getActiveResourcesInfo();
|
||||
// Agent is already "paused" from the previous test.
|
||||
await runAgentStop(agentId);
|
||||
const after = process.getActiveResourcesInfo();
|
||||
|
||||
expect(after.length).toBeLessThanOrEqual(before.length);
|
||||
},
|
||||
STRICT_BOUND_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"runAgentStart (transition path: paused -> active) leaves no net-new active resources",
|
||||
async () => {
|
||||
const before = process.getActiveResourcesInfo();
|
||||
await runAgentStart(agentId);
|
||||
const after = process.getActiveResourcesInfo();
|
||||
|
||||
expect(after.length).toBeLessThanOrEqual(before.length);
|
||||
},
|
||||
STRICT_BOUND_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"runAgentStart (already-active early-return path) leaves no net-new active resources",
|
||||
async () => {
|
||||
const before = process.getActiveResourcesInfo();
|
||||
// Agent is already "active" from the previous test.
|
||||
await runAgentStart(agentId);
|
||||
const after = process.getActiveResourcesInfo();
|
||||
|
||||
expect(after.length).toBeLessThanOrEqual(before.length);
|
||||
},
|
||||
STRICT_BOUND_MS,
|
||||
);
|
||||
});
|
||||
@@ -23,12 +23,13 @@ function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T)
|
||||
// hit "Cannot access before initialization" because Vitest's hoisting only
|
||||
// reliably hoists `mock`-prefixed consts declared ahead of the FIRST
|
||||
// `vi.mock` call in the file).
|
||||
const { mockGetAgent, mockUpdateAgentState, mockInit, mockClose, mockResolveProjectPathOnly } = vi.hoisted(() => ({
|
||||
const { mockGetAgent, mockUpdateAgentState, mockInit, mockClose, mockResolveProjectPathOnly, mockResolveAgentStoreBase } = vi.hoisted(() => ({
|
||||
mockGetAgent: vi.fn(),
|
||||
mockUpdateAgentState: vi.fn(),
|
||||
mockInit: vi.fn().mockResolvedValue(undefined),
|
||||
mockClose: vi.fn(),
|
||||
mockResolveProjectPathOnly: vi.fn().mockResolvedValue("/tmp/test-project"),
|
||||
mockResolveAgentStoreBase: vi.fn(async () => ({ rootDir: "/tmp/test-project", asyncLayer: null })),
|
||||
}));
|
||||
|
||||
// AgentStore mock — vi.fn() with mockImplementation works with `new` in vitest.
|
||||
@@ -63,6 +64,22 @@ vi.mock("@fusion/core", () => ({
|
||||
// the call, which enabled asserting resolveProjectPathOnly is used (i.e.
|
||||
// that no TaskStore is leaked).
|
||||
vi.mock("../../project-context.js", () => ({
|
||||
// FNXC:PostgresCutover 2026-07-10: branch agent commands resolve their AgentStore base (rootDir + asyncLayer) via this helper.
|
||||
resolveAgentStoreBase: mockResolveAgentStoreBase,
|
||||
asLocalProjectContext: vi.fn((store: unknown) => ({
|
||||
projectId: process.cwd(),
|
||||
projectPath: process.cwd(),
|
||||
projectName: "current-project",
|
||||
isRegistered: false,
|
||||
store,
|
||||
})),
|
||||
closeProjectStore: vi.fn(async (context: { store?: { close?: () => unknown } }) => {
|
||||
try {
|
||||
await context?.store?.close?.();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}),
|
||||
resolveProjectPathOnly: mockResolveProjectPathOnly,
|
||||
}));
|
||||
|
||||
@@ -172,10 +189,11 @@ describe("runAgentStop", () => {
|
||||
it("should close the store and resolve the project path without leaking a TaskStore", async () => {
|
||||
await runAgentStop("agent-test123");
|
||||
|
||||
// FN-7704: agent commands must resolve the project path via
|
||||
// resolveProjectPathOnly (not resolveProject) so no TaskStore this
|
||||
// command never touches is left open/cached.
|
||||
expect(mockResolveProjectPathOnly).toHaveBeenCalled();
|
||||
// FN-7704 (branch adaptation): agent commands resolve their AgentStore base
|
||||
// via resolveAgentStoreBase (rootDir + borrowed asyncLayer) rather than
|
||||
// upstream's resolveProjectPathOnly; the invariant is still that no ad-hoc
|
||||
// TaskStore is constructed/leaked by this command itself.
|
||||
expect(mockResolveAgentStoreBase).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fast-fails with a clear error and non-zero exit when the store mutation never resolves", async () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ vi.mock("@fusion/core", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@fusion/core")>();
|
||||
return {
|
||||
...actual,
|
||||
// FNXC:PostgresCutover 2026-07-10: PG startup factory consulted before legacy TaskStore; null keeps the legacy mock path.
|
||||
createTaskStoreForBackend: vi.fn(async () => null),
|
||||
createBackupManager: vi.fn(),
|
||||
runBackupCommand: vi.fn(async () => ({ success: true, output: "backup created" })),
|
||||
};
|
||||
@@ -45,7 +47,7 @@ async function loadWithMockedStore(store: Record<string, unknown>, opts?: { cach
|
||||
? vi.fn().mockResolvedValue(context)
|
||||
: vi.fn().mockRejectedValue(new Error("no registered project"));
|
||||
const asLocalProjectContext = vi.fn(() => context);
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore, asLocalProjectContext }));
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore, asLocalProjectContext, createLocalStore: vi.fn(async () => store as never) }));
|
||||
const mod = await import("../backup.js");
|
||||
const { createBackupManager } = await import("@fusion/core");
|
||||
return { mod, closeProjectStore, resolveProject, createBackupManager };
|
||||
|
||||
@@ -23,6 +23,7 @@ const {
|
||||
mockGetSettings,
|
||||
mockRunBackupCommand,
|
||||
mockResolveProject,
|
||||
mockCreateLocalStore,
|
||||
} = vi.hoisted(() => ({
|
||||
mockListBackups: vi.fn(),
|
||||
mockListBackupPairs: vi.fn(),
|
||||
@@ -31,6 +32,7 @@ const {
|
||||
mockGetSettings: vi.fn(),
|
||||
mockRunBackupCommand: vi.fn(),
|
||||
mockResolveProject: vi.fn(),
|
||||
mockCreateLocalStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
@@ -52,6 +54,9 @@ vi.mock("@fusion/core", () => ({
|
||||
|
||||
vi.mock("../../project-context.js", () => ({
|
||||
resolveProject: mockResolveProject,
|
||||
// FNXC:PostgresCutover 2026-07-05-12:00: cwd fallback now boots through
|
||||
// createLocalStore (PostgreSQL startup factory) instead of `new TaskStore`.
|
||||
createLocalStore: mockCreateLocalStore,
|
||||
closeProjectStore: vi.fn(async (context: { store: { close?: () => Promise<void> } }) => {
|
||||
try {
|
||||
await context.store.close?.();
|
||||
@@ -147,18 +152,26 @@ describe("backup commands", () => {
|
||||
it("runBackupList without project falls back to current cwd task store when resolution fails", async () => {
|
||||
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/local/project");
|
||||
mockResolveProject.mockRejectedValueOnce(new Error("No fn project found"));
|
||||
mockCreateLocalStore.mockResolvedValueOnce({
|
||||
getSettings: mockGetSettings,
|
||||
fusionDir: "/local/project/.fusion",
|
||||
});
|
||||
await runBackupList();
|
||||
expect(mockResolveProject).toHaveBeenCalledWith(undefined);
|
||||
expect(TaskStore).toHaveBeenCalledWith("/local/project");
|
||||
expect(mockCreateLocalStore).toHaveBeenCalledWith("/local/project");
|
||||
cwdSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("falls back to current cwd task store when project resolution fails for project-targeted commands", async () => {
|
||||
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/fallback/project");
|
||||
mockResolveProject.mockRejectedValue(new Error("Project 'missing' not found. Run 'fn project list' to see registered projects."));
|
||||
mockCreateLocalStore.mockResolvedValueOnce({
|
||||
getSettings: mockGetSettings,
|
||||
fusionDir: "/fallback/project/.fusion",
|
||||
});
|
||||
|
||||
await runBackupList("missing");
|
||||
expect(TaskStore).toHaveBeenCalledWith("/fallback/project");
|
||||
expect(mockCreateLocalStore).toHaveBeenCalledWith("/fallback/project");
|
||||
cwdSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ async function loadWithMockedStore(store: Record<string, unknown>, opts?: { cach
|
||||
? vi.fn().mockResolvedValue(context)
|
||||
: vi.fn().mockRejectedValue(new Error("no registered project"));
|
||||
const asLocalProjectContext = vi.fn(() => context);
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore, asLocalProjectContext }));
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore, asLocalProjectContext, createLocalStore: vi.fn(async () => store as never) }));
|
||||
const mod = await import("../branch-group.js");
|
||||
return { mod, closeProjectStore, resolveProject };
|
||||
}
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough, Readable } from "node:stream";
|
||||
|
||||
import { AgentStore, MessageStore, createDatabase } from "@fusion/core";
|
||||
|
||||
const mockResolveProject = vi.fn();
|
||||
|
||||
vi.mock("../../project-context.js", () => ({
|
||||
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
|
||||
}));
|
||||
|
||||
import { runChatInteractive } from "../chat.js";
|
||||
|
||||
function streamToString(stream: PassThrough): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let text = "";
|
||||
stream.on("data", (chunk) => {
|
||||
text += chunk.toString();
|
||||
});
|
||||
stream.on("end", () => resolve(text));
|
||||
});
|
||||
}
|
||||
|
||||
describe("runChatInteractive", () => {
|
||||
let projectDir: string;
|
||||
let agentId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectDir = mkdtempSync(join(tmpdir(), "fn-chat-"));
|
||||
mockResolveProject.mockResolvedValue({
|
||||
projectId: "proj-1",
|
||||
projectPath: projectDir,
|
||||
projectName: "proj-1",
|
||||
isRegistered: true,
|
||||
store: {},
|
||||
});
|
||||
|
||||
const agentStore = new AgentStore({ rootDir: join(projectDir, ".fusion") });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.createAgent({
|
||||
name: "Chat Agent",
|
||||
role: "executor",
|
||||
reportsTo: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function sendAgentReply(content: string, toId = "cli"): Promise<void> {
|
||||
const db = createDatabase(join(projectDir, ".fusion"));
|
||||
db.init();
|
||||
const messageStore = new MessageStore(db);
|
||||
messageStore.sendMessage({
|
||||
fromId: agentId,
|
||||
fromType: "agent",
|
||||
toId,
|
||||
toType: "user",
|
||||
content,
|
||||
type: "agent-to-user",
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
it("sends a line as a user-to-agent message with wakeRecipient metadata", async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const outputPromise = streamToString(output);
|
||||
|
||||
const runPromise = runChatInteractive(agentId, { input, output, pollIntervalMs: 10 });
|
||||
input.write("hello\n");
|
||||
input.write("/exit\n");
|
||||
input.end();
|
||||
|
||||
const code = await runPromise;
|
||||
output.end();
|
||||
await outputPromise;
|
||||
|
||||
const db = createDatabase(join(projectDir, ".fusion"));
|
||||
db.init();
|
||||
const store = new MessageStore(db);
|
||||
const outbox = store.getOutbox("cli", "user", { limit: 20 });
|
||||
db.close();
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(outbox[0]).toMatchObject({
|
||||
fromId: "cli",
|
||||
toId: agentId,
|
||||
type: "user-to-agent",
|
||||
content: "hello",
|
||||
metadata: { wakeRecipient: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 1 for unknown agent and writes no message", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const code = await runChatInteractive("agent-does-not-exist", {
|
||||
once: true,
|
||||
nonInteractive: true,
|
||||
input: Readable.from("hi"),
|
||||
});
|
||||
|
||||
const db = createDatabase(join(projectDir, ".fusion"));
|
||||
db.init();
|
||||
const store = new MessageStore(db);
|
||||
const outbox = store.getOutbox("cli", "user", { limit: 20 });
|
||||
db.close();
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith("Agent agent-does-not-exist not found");
|
||||
expect(outbox).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("prints existing conversation tail on start", async () => {
|
||||
const db = createDatabase(join(projectDir, ".fusion"));
|
||||
db.init();
|
||||
const store = new MessageStore(db);
|
||||
store.sendMessage({
|
||||
fromId: "cli",
|
||||
fromType: "user",
|
||||
toId: agentId,
|
||||
toType: "agent",
|
||||
content: "first",
|
||||
type: "user-to-agent",
|
||||
});
|
||||
store.sendMessage({
|
||||
fromId: agentId,
|
||||
fromType: "agent",
|
||||
toId: "cli",
|
||||
toType: "user",
|
||||
content: "second",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
db.close();
|
||||
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const outputPromise = streamToString(output);
|
||||
|
||||
const runPromise = runChatInteractive(agentId, { input, output, pollIntervalMs: 10 });
|
||||
input.write("/exit\n");
|
||||
input.end();
|
||||
|
||||
await runPromise;
|
||||
output.end();
|
||||
const outputText = await outputPromise;
|
||||
expect(outputText).toContain("first");
|
||||
expect(outputText).toContain("second");
|
||||
});
|
||||
|
||||
it("/exit ends loop cleanly", async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
|
||||
const runPromise = runChatInteractive(agentId, { input, output, pollIntervalMs: 10 });
|
||||
input.write("/exit\n");
|
||||
input.end();
|
||||
|
||||
await expect(runPromise).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it("poll loop prints new replies and marks them read", async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const outputPromise = streamToString(output);
|
||||
|
||||
const runPromise = runChatInteractive(agentId, { input, output, pollIntervalMs: 10 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await sendAgentReply("async reply");
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
input.write("/exit\n");
|
||||
input.end();
|
||||
|
||||
await runPromise;
|
||||
output.end();
|
||||
const outputText = await outputPromise;
|
||||
expect(outputText).toContain("async reply");
|
||||
|
||||
const db = createDatabase(join(projectDir, ".fusion"));
|
||||
db.init();
|
||||
const store = new MessageStore(db);
|
||||
const inbox = store.getInbox("cli", "user", { limit: 20 });
|
||||
const reply = inbox.find((msg) => msg.content === "async reply");
|
||||
db.close();
|
||||
|
||||
expect(reply?.read).toBe(true);
|
||||
});
|
||||
|
||||
it("--once sends and waits for one reply", async () => {
|
||||
const output = new PassThrough();
|
||||
const outputPromise = streamToString(output);
|
||||
|
||||
setTimeout(() => {
|
||||
void sendAgentReply("reply once");
|
||||
}, 50);
|
||||
|
||||
const code = await runChatInteractive(agentId, {
|
||||
once: true,
|
||||
nonInteractive: true,
|
||||
input: Readable.from("one-shot"),
|
||||
output,
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
|
||||
output.end();
|
||||
const outputText = await outputPromise;
|
||||
expect(code).toBe(0);
|
||||
expect(outputText).toContain(`you → ${agentId}: one-shot`);
|
||||
expect(outputText).toContain("reply once");
|
||||
});
|
||||
|
||||
it("--once exits with timeout note when no reply arrives", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const input = new PassThrough();
|
||||
input.end("ping");
|
||||
|
||||
const code = await runChatInteractive(agentId, {
|
||||
once: true,
|
||||
nonInteractive: true,
|
||||
input,
|
||||
output: new PassThrough(),
|
||||
pollIntervalMs: 10,
|
||||
replyTimeoutMs: 200,
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errorSpy).toHaveBeenCalledWith("No reply within 1s");
|
||||
});
|
||||
|
||||
it("refuses oversized messages", async () => {
|
||||
const oversized = "x".repeat(8193);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const code = await runChatInteractive(agentId, {
|
||||
once: true,
|
||||
nonInteractive: true,
|
||||
input: Readable.from(oversized),
|
||||
output: new PassThrough(),
|
||||
pollIntervalMs: 5,
|
||||
});
|
||||
|
||||
const db = createDatabase(join(projectDir, ".fusion"));
|
||||
db.init();
|
||||
const store = new MessageStore(db);
|
||||
const outbox = store.getOutbox("cli", "user", { limit: 20 });
|
||||
db.close();
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errorSpy).toHaveBeenCalledWith("Message too long; max 8192 chars");
|
||||
expect(outbox).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* FNXC:CliBoardMutation 2026-07-09-00:00:
|
||||
* Regression coverage for FN-7739 — `fn db vacuum` must retry the VACUUM
|
||||
* call through a momentarily-locked SQLite board database (VACUUM requires
|
||||
* an exclusive lock — the canonical transient-lock case) instead of
|
||||
* surfacing a raw `database is locked` error, and must close the resolved
|
||||
* `TaskStore` (cached AND the uncached CWD-fallback branch) BEFORE every
|
||||
* `process.exit()` call (both success and failure paths), since a pending
|
||||
* `finally` does not run after `process.exit()`. Fast, fake-timer based, no
|
||||
* real waits per FN-5048.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@fusion/core", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@fusion/core")>();
|
||||
return { ...actual };
|
||||
});
|
||||
|
||||
function makeStore(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getDatabase: vi.fn(),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadWithMockedStore(store: Record<string, unknown>, opts?: { cached?: boolean }) {
|
||||
const cached = opts?.cached ?? true;
|
||||
const closeProjectStore = vi.fn(async (context: { store: { close?: () => Promise<void> } }) => {
|
||||
await context.store.close?.().catch(() => {});
|
||||
});
|
||||
const context = {
|
||||
projectId: cached ? "proj_test" : process.cwd(),
|
||||
projectPath: cached ? "/proj" : process.cwd(),
|
||||
projectName: "proj",
|
||||
isRegistered: cached,
|
||||
store,
|
||||
};
|
||||
const resolveProject = cached
|
||||
? vi.fn().mockResolvedValue(context)
|
||||
: vi.fn().mockRejectedValue(new Error("no registered project"));
|
||||
const asLocalProjectContext = vi.fn(() => context);
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore, asLocalProjectContext }));
|
||||
const mod = await import("../db.js");
|
||||
return { mod, closeProjectStore, resolveProject };
|
||||
}
|
||||
|
||||
describe("fn db vacuum — lock retry and close-before-exit teardown (FN-7739)", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("../../project-context.js");
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.FUSION_CLI_LOCK_RETRY_MS;
|
||||
});
|
||||
|
||||
it("succeeds on first attempt (no lock contention) and closes the store before exit(0)", async () => {
|
||||
const vacuum = vi.fn().mockReturnValue({ beforeSize: 100, afterSize: 50, durationMs: 5 });
|
||||
const getDatabase = vi.fn(() => ({ vacuum, getPath: () => "/proj/.fusion/fusion.db" }));
|
||||
const store = makeStore({ getDatabase });
|
||||
const { mod, closeProjectStore } = await loadWithMockedStore(store);
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
}) as never);
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await expect(mod.runDbVacuum()).rejects.toThrow(/process\.exit\(0\)/);
|
||||
|
||||
expect(vacuum).toHaveBeenCalledTimes(1);
|
||||
expect(closeProjectStore).toHaveBeenCalledTimes(1);
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uncached CWD-fallback: resolves via asLocalProjectContext and still closes the store before exit", async () => {
|
||||
const vacuum = vi.fn().mockReturnValue({ beforeSize: 0, afterSize: 0, durationMs: 0 });
|
||||
const getDatabase = vi.fn(() => ({ vacuum, getPath: () => "/fallback/.fusion/fusion.db" }));
|
||||
const store = makeStore({ getDatabase });
|
||||
const { mod, closeProjectStore } = await loadWithMockedStore(store, { cached: false });
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
}) as never);
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await expect(mod.runDbVacuum()).rejects.toThrow(/process\.exit\(0\)/);
|
||||
|
||||
expect(closeProjectStore).toHaveBeenCalledTimes(1);
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("retries VACUUM through a transient lock error and succeeds once it clears, closing the store before exit(0)", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
process.env.FUSION_CLI_LOCK_RETRY_MS = "5000";
|
||||
const lockError = new Error("database is locked");
|
||||
const vacuum = vi.fn().mockImplementationOnce(() => {
|
||||
throw lockError;
|
||||
}).mockReturnValue({ beforeSize: 10, afterSize: 5, durationMs: 1 });
|
||||
const getDatabase = vi.fn(() => ({ vacuum, getPath: () => "/proj/.fusion/fusion.db" }));
|
||||
const store = makeStore({ getDatabase });
|
||||
const { mod, closeProjectStore } = await loadWithMockedStore(store);
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
}) as never);
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
const promise = mod.runDbVacuum();
|
||||
const assertion = expect(promise).rejects.toThrow(/process\.exit\(0\)/);
|
||||
for (let i = 0; i < 10 && vacuum.mock.calls.length < 2; i++) {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
}
|
||||
await assertion;
|
||||
|
||||
expect(vacuum.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(closeProjectStore).toHaveBeenCalled();
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounded exhaustion on a persistently locked VACUUM fails clearly, closes the store, and exits 1", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
process.env.FUSION_CLI_LOCK_RETRY_MS = "500";
|
||||
const vacuum = vi.fn().mockImplementation(() => {
|
||||
throw new Error("SQLITE_BUSY: database is locked");
|
||||
});
|
||||
const getDatabase = vi.fn(() => ({ vacuum, getPath: () => "/proj/.fusion/fusion.db" }));
|
||||
const store = makeStore({ getDatabase });
|
||||
const { mod, closeProjectStore } = await loadWithMockedStore(store);
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
}) as never);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const promise = mod.runDbVacuum();
|
||||
const assertion = expect(promise).rejects.toThrow(/process\.exit\(1\)/);
|
||||
for (let i = 0; i < 10 && vacuum.mock.calls.length < 2; i++) {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await assertion;
|
||||
|
||||
expect(vacuum.mock.calls.length).toBeGreaterThan(1);
|
||||
const printed = errorSpy.mock.calls.flat().join("\n");
|
||||
expect(printed).toMatch(/locked|FUSION_CLI_LOCK_RETRY_MS/i);
|
||||
expect(closeProjectStore).toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("a non-lock VACUUM error does not retry-loop and closes the store before exit(1)", async () => {
|
||||
const vacuum = vi.fn().mockImplementation(() => {
|
||||
throw new Error("disk I/O error");
|
||||
});
|
||||
const getDatabase = vi.fn(() => ({ vacuum, getPath: () => "/proj/.fusion/fusion.db" }));
|
||||
const store = makeStore({ getDatabase });
|
||||
const { mod, closeProjectStore } = await loadWithMockedStore(store);
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
}) as never);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await expect(mod.runDbVacuum()).rejects.toThrow(/process\.exit\(1\)/);
|
||||
|
||||
expect(vacuum).toHaveBeenCalledTimes(1);
|
||||
expect(closeProjectStore).toHaveBeenCalled();
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).toContain("disk I/O error");
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
||||
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
||||
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
||||
return nextImpl(...args);
|
||||
};
|
||||
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
||||
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
||||
if (impl) {
|
||||
mock.mockImplementation(impl);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
// Hoist mocks so they are evaluated before module imports
|
||||
const { mockGetDatabase, mockVacuum, mockResolveProject } = vi.hoisted(() => ({
|
||||
mockGetDatabase: vi.fn(),
|
||||
mockVacuum: vi.fn(),
|
||||
mockResolveProject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: makeConstructibleMock(() => ({
|
||||
init: vi.fn(),
|
||||
getDatabase: mockGetDatabase,
|
||||
})),
|
||||
isSqliteLockError: (error: unknown) => /database is locked/i.test(error instanceof Error ? error.message : String(error)),
|
||||
}));
|
||||
|
||||
vi.mock("../../project-context.js", () => ({
|
||||
resolveProject: mockResolveProject,
|
||||
closeProjectStore: vi.fn(async (context: { store: { close?: () => Promise<void> } }) => {
|
||||
try {
|
||||
await context.store.close?.();
|
||||
} catch {
|
||||
// best-effort, mirrors production closeProjectStore
|
||||
}
|
||||
}),
|
||||
asLocalProjectContext: vi.fn((store: unknown) => ({
|
||||
projectId: process.cwd(),
|
||||
projectPath: process.cwd(),
|
||||
projectName: "current-project",
|
||||
isRegistered: false,
|
||||
store,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { runDbVacuum } from "../db.ts";
|
||||
|
||||
describe("runDbVacuum", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("resolves project store and calls vacuum", async () => {
|
||||
mockResolveProject.mockResolvedValue({
|
||||
projectId: "proj-1",
|
||||
projectName: "demo-project",
|
||||
projectPath: "/projects/demo",
|
||||
isRegistered: true,
|
||||
store: { getDatabase: mockGetDatabase },
|
||||
});
|
||||
mockGetDatabase.mockReturnValue({
|
||||
vacuum: mockVacuum.mockReturnValue({
|
||||
beforeSize: 10_485_760,
|
||||
afterSize: 7_340_416,
|
||||
durationMs: 123,
|
||||
}),
|
||||
getPath: () => "/projects/demo/.fusion/fusion.db",
|
||||
});
|
||||
|
||||
await expect(runDbVacuum("demo-project")).rejects.toThrow("process.exit:0");
|
||||
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
|
||||
expect(mockVacuum).toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("VACUUM"));
|
||||
});
|
||||
|
||||
it("exits 1 on vacuum error", async () => {
|
||||
mockResolveProject.mockResolvedValue({
|
||||
projectId: "proj-1",
|
||||
projectName: "demo-project",
|
||||
projectPath: "/projects/demo",
|
||||
isRegistered: true,
|
||||
store: { getDatabase: mockGetDatabase },
|
||||
});
|
||||
mockGetDatabase.mockReturnValue({
|
||||
vacuum: mockVacuum.mockRejectedValue(new Error("database locked")),
|
||||
getPath: () => "/projects/demo/.fusion/fusion.db",
|
||||
});
|
||||
|
||||
await expect(runDbVacuum("demo-project")).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("database locked"));
|
||||
});
|
||||
|
||||
it("falls back to cwd TaskStore when resolveProject fails", async () => {
|
||||
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/fallback/project");
|
||||
mockResolveProject.mockRejectedValue(new Error("no project"));
|
||||
|
||||
const mockStore = { init: vi.fn(), getDatabase: mockGetDatabase };
|
||||
mockGetDatabase.mockReturnValue({
|
||||
vacuum: mockVacuum.mockReturnValue({ beforeSize: 0, afterSize: 0, durationMs: 0 }),
|
||||
getPath: () => "/fallback/project/.fusion/fusion.db",
|
||||
});
|
||||
|
||||
await expect(runDbVacuum("missing")).rejects.toThrow("process.exit:0");
|
||||
expect(mockResolveProject).toHaveBeenCalledWith("missing");
|
||||
cwdSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("skips vacuum on in-memory database (returns zero sizes)", async () => {
|
||||
mockResolveProject.mockResolvedValue({
|
||||
projectId: "proj-1",
|
||||
projectName: "mem-project",
|
||||
projectPath: "/mem",
|
||||
isRegistered: true,
|
||||
store: { getDatabase: mockGetDatabase },
|
||||
});
|
||||
mockGetDatabase.mockReturnValue({
|
||||
vacuum: mockVacuum.mockReturnValue({ beforeSize: 0, afterSize: 0, durationMs: 0 }),
|
||||
getPath: () => ":memory:",
|
||||
});
|
||||
|
||||
await expect(runDbVacuum("mem-project")).rejects.toThrow("process.exit:0");
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("in-memory"));
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* FNXC:CliBoardMutation 2026-07-09-00:00:
|
||||
* Regression coverage for FN-7740's `git.ts` fix: `resolveGitCwd` must
|
||||
* resolve the project path WITHOUT leaking the `TaskStore` that
|
||||
* `resolveProject()` constructs internally (`git` commands never touch the
|
||||
* board DB at all — this is a pure path-only-caller leak, no lock-retry
|
||||
* surface). Proves the original symptom (a cached, never-closed `TaskStore`
|
||||
* left in `storeCache` after a return-normally `git` command) is gone by
|
||||
* driving the REAL `resolveProjectPathOnly`/`closeProjectStore` helpers
|
||||
* (only `resolveProject` itself is stubbed, to avoid touching the real
|
||||
* central registry / `~/.fusion` under test) against a REAL `TaskStore`
|
||||
* and asserting `.close()` is invoked.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { TaskStore as TaskStoreType, ProjectContext } from "@fusion/core";
|
||||
|
||||
const mockResolveProject = vi.fn();
|
||||
|
||||
// Full replacement mock (not a partial `importActual` spread): the real
|
||||
// `resolveProjectPathOnly` calls `resolveProject` through the SAME module's
|
||||
// internal closure, not through the exported binding, so overriding only
|
||||
// `resolveProject` via a partial spread would silently keep calling the
|
||||
// REAL `resolveProject` (which hits the real central registry / global
|
||||
// dir resolution — forbidden under VITEST without an explicit temp dir).
|
||||
// Provide local implementations of `resolveProjectPathOnly`/
|
||||
// `closeProjectStore` that mirror the real close-then-evict semantics
|
||||
// against the SAME `mockResolveProject`, so this test still exercises the
|
||||
// real store-close call this fix depends on.
|
||||
vi.mock("../../project-context.js", () => ({
|
||||
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
|
||||
resolveProjectPathOnly: async (...args: unknown[]) => {
|
||||
const context = await mockResolveProject(...args);
|
||||
try {
|
||||
await context.store.close();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return context.projectPath;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execFn: typeof vi.fn = vi.fn((_cmd: string, opts: object | undefined, cb: (err: Error | null, stdout: string, stderr: string) => void) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
if (callback === undefined) return;
|
||||
callback(new Error("not a git repo"), "", "");
|
||||
});
|
||||
execFn[promisify.custom] = () => Promise.reject(new Error("not a git repo"));
|
||||
return { ...actual, exec: execFn };
|
||||
});
|
||||
|
||||
describe("fn git — store-leak reproduction (FN-7740)", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "fn-git-lock-retry-test-"));
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("closes the resolved TaskStore even though git commands never use context.store (path-only leak class)", async () => {
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
const store = new TaskStore(tmpDir) as TaskStoreType;
|
||||
await store.init();
|
||||
const closeSpy = vi.spyOn(store, "close");
|
||||
|
||||
mockResolveProject.mockResolvedValue({
|
||||
projectId: "proj-1",
|
||||
projectPath: tmpDir,
|
||||
projectName: "demo",
|
||||
isRegistered: true,
|
||||
store,
|
||||
} satisfies ProjectContext);
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as never);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const { runGitStatus } = await import("../git.js");
|
||||
|
||||
// No `.git` directory in `tmpDir` — `runGitStatus` resolves the project
|
||||
// path (constructing+closing the store via `resolveProjectPathOnly`)
|
||||
// BEFORE the "Not a git repository" guard exits, so the store-close
|
||||
// assertion holds regardless of the git-repo outcome.
|
||||
await expect(runGitStatus("demo-project")).rejects.toThrow("process.exit:1");
|
||||
|
||||
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Not a git repository");
|
||||
|
||||
await store.close().catch(() => {});
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not leak a store on the no-project-flag / CWD-fallback branch when resolution fails", async () => {
|
||||
mockResolveProject.mockRejectedValue(new Error("No fusion project found"));
|
||||
|
||||
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(tmpDir);
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as never);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const { runGitStatus } = await import("../git.js");
|
||||
|
||||
// No store is ever constructed on this branch (resolution failed before
|
||||
// any `TaskStore` was built) — nothing to leak, and the command still
|
||||
// fails cleanly with a non-zero exit once it discovers `tmpDir` is not
|
||||
// a git repo.
|
||||
await expect(runGitStatus()).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Not a git repository");
|
||||
|
||||
cwdSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@fusion/core", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@fusion/core")>();
|
||||
return { ...actual };
|
||||
return {
|
||||
...actual,
|
||||
// FNXC:PostgresCutover 2026-07-10: PG startup factory consulted before legacy TaskStore; null keeps the legacy mock path.
|
||||
createTaskStoreForBackend: vi.fn(async () => null),
|
||||
};
|
||||
});
|
||||
|
||||
function makeGlobalStore(overrides: Record<string, unknown> = {}) {
|
||||
@@ -77,8 +81,6 @@ async function loadWithMocks(opts: {
|
||||
? vi.fn().mockResolvedValue(projectContext)
|
||||
: vi.fn().mockRejectedValue(new Error("no registered project"));
|
||||
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore, asLocalProjectContext }));
|
||||
|
||||
const uncachedSecretsStoreClose = vi.fn().mockResolvedValue(undefined);
|
||||
const uncachedSecretsInstance = opts.uncachedSecretsStore ?? {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -86,6 +88,10 @@ async function loadWithMocks(opts: {
|
||||
getSecretsStore: vi.fn(async () => makeSecretsStore()),
|
||||
};
|
||||
|
||||
// FNXC:PostgresCutover 2026-07-10: the branch's mcp cwd fallback boots its
|
||||
// ad-hoc secrets store via createLocalStore (PG startup factory).
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore, asLocalProjectContext, createLocalStore: vi.fn(async () => uncachedSecretsInstance as never) }));
|
||||
|
||||
vi.doMock("@fusion/core", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@fusion/core")>();
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,8 @@ vi.mock("@fusion/core", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@fusion/core")>();
|
||||
return {
|
||||
...actual,
|
||||
// FNXC:PostgresCutover 2026-07-10: PG startup factory consulted before legacy TaskStore; null keeps the legacy mock path.
|
||||
createTaskStoreForBackend: vi.fn(async () => null),
|
||||
createMemoryBackupManager: vi.fn(),
|
||||
runMemoryBackupCommand: vi.fn(async () => ({ success: true, output: "memory backup created" })),
|
||||
};
|
||||
@@ -44,7 +46,7 @@ async function loadWithMockedStore(store: Record<string, unknown>, opts?: { cach
|
||||
? vi.fn().mockResolvedValue(context)
|
||||
: vi.fn().mockRejectedValue(new Error("no registered project"));
|
||||
const asLocalProjectContext = vi.fn(() => context);
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore, asLocalProjectContext }));
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore, asLocalProjectContext, createLocalStore: vi.fn(async () => store as never) }));
|
||||
const mod = await import("../memory-backup.js");
|
||||
const { createMemoryBackupManager } = await import("@fusion/core");
|
||||
return { mod, closeProjectStore, resolveProject, createMemoryBackupManager };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -72,7 +72,7 @@ async function loadWithMockedStore(store: Record<string, unknown>, opts?: { cach
|
||||
? vi.fn().mockResolvedValue(context)
|
||||
: vi.fn().mockRejectedValue(new Error("no registered project"));
|
||||
const asLocalProjectContext = vi.fn(() => context);
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore, asLocalProjectContext }));
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore, asLocalProjectContext, createLocalStore: vi.fn(async () => store as never) }));
|
||||
const mod = await import("../pr.js");
|
||||
return { mod, closeProjectStore, resolveProject };
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ const { taskStoreInstances, mockListProjects, mockGetProjectHealth, mockGetSetti
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
// FNXC:PostgresCutover 2026-07-10: PG startup factory consulted before legacy TaskStore; null keeps the legacy mock path.
|
||||
createTaskStoreForBackend: vi.fn(async () => null),
|
||||
CentralCore: makeConstructibleMock(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -82,6 +84,8 @@ vi.mock("node:readline/promises", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../project-context.js", () => ({
|
||||
// FNXC:PostgresCutover 2026-07-10: branch cwd-fallbacks boot via createLocalStore (PG startup factory); reuse the same mocked TaskStore shape.
|
||||
createLocalStore: vi.fn(async () => { const { TaskStore } = await import("@fusion/core"); const store = new (TaskStore as any)(process.cwd()); await store.init?.(); return store; }),
|
||||
formatProjectLine: vi.fn((project: { name: string }, isDefault: boolean) => `${isDefault ? "* " : " "}${project.name}`),
|
||||
detectProjectFromCwd: vi.fn(),
|
||||
setDefaultProject: vi.fn(),
|
||||
|
||||
@@ -66,6 +66,16 @@ vi.mock("@fusion/core", () => ({
|
||||
listTasks: mockTaskStoreListTasks,
|
||||
close: mockTaskStoreClose,
|
||||
})),
|
||||
// FNXC:PostgresCutover 2026-07-05-17:20: getTaskCounts/health now boot the
|
||||
// project store through the PostgreSQL startup factory; route the factory to
|
||||
// the same mocked listTasks so count/in-flight assertions exercise it.
|
||||
createTaskStoreForBackend: vi.fn(async () => ({
|
||||
taskStore: {
|
||||
init: mockTaskStoreInit,
|
||||
listTasks: mockTaskStoreListTasks,
|
||||
},
|
||||
shutdown: vi.fn(async () => {}),
|
||||
})),
|
||||
// FN-7740: `getTaskCounts`/`runProjectAdd`'s interactive-init store now
|
||||
// close via `store.close()` and `listTasks` is wrapped in `retryOnLock`
|
||||
// (which imports `isSqliteLockError` from @fusion/core) — stub it per
|
||||
|
||||
@@ -46,18 +46,21 @@ const mockRun = {
|
||||
results: { summary: "done", findings: [], citations: [] },
|
||||
};
|
||||
|
||||
const researchStoreMock = {
|
||||
const researchStoreMock = Object.assign(Object.create(MockResearchStore.prototype), {
|
||||
getRun: vi.fn(() => mockRun),
|
||||
listRuns: vi.fn(() => [mockRun]),
|
||||
createExport: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { storeMock, orchestratorMock, resolveResearchSettingsMock, providerRegistryMock, writeFileMock } = vi.hoisted(() => {
|
||||
const researchStore = {
|
||||
const { storeMock, orchestratorMock, resolveResearchSettingsMock, providerRegistryMock, writeFileMock, MockResearchStore } = vi.hoisted(() => {
|
||||
// FNXC:PostgresCutover 2026-07-10: getSyncResearchStore gates the CLI on
|
||||
// `instanceof ResearchStore`; give the mock store that prototype.
|
||||
class MockResearchStore {}
|
||||
const researchStore = Object.assign(Object.create(MockResearchStore.prototype), {
|
||||
getRun: vi.fn(),
|
||||
listRuns: vi.fn(),
|
||||
createExport: vi.fn(),
|
||||
};
|
||||
});
|
||||
return {
|
||||
storeMock: {
|
||||
init: vi.fn(),
|
||||
@@ -74,10 +77,14 @@ const { storeMock, orchestratorMock, resolveResearchSettingsMock, providerRegist
|
||||
resolveResearchSettingsMock: vi.fn(() => ({ enabled: true, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } })),
|
||||
providerRegistryMock: makeConstructibleMock(function () { return { getAvailableProviders: () => ["tavily"], getProvider: () => ({ type: "tavily" }) }; }),
|
||||
writeFileMock: vi.fn(async () => undefined),
|
||||
MockResearchStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
// FNXC:PostgresCutover 2026-07-10: PG startup factory consulted before legacy TaskStore; null keeps the legacy mock path; getSyncResearchStore needs the ResearchStore class for its instanceof gate.
|
||||
createTaskStoreForBackend: vi.fn(async () => null),
|
||||
ResearchStore: MockResearchStore,
|
||||
TaskStore: makeConstructibleMock(() => storeMock),
|
||||
resolveResearchSettings: resolveResearchSettingsMock,
|
||||
RESEARCH_RUN_STATUSES: ["queued", "running", "cancelling", "retry_waiting", "completed", "failed", "cancelled", "timed_out", "retry_exhausted"],
|
||||
|
||||
@@ -29,11 +29,17 @@ const mockRun = {
|
||||
results: { summary: "done", findings: [], citations: [] },
|
||||
};
|
||||
|
||||
const researchStoreMock = {
|
||||
/*
|
||||
FNXC:PostgresCutover 2026-07-10: the branch's getSyncResearchStore gates the
|
||||
research CLI on `instanceof ResearchStore`; give the mock store that prototype
|
||||
so the mocked class check passes.
|
||||
*/
|
||||
const { MockResearchStore } = vi.hoisted(() => ({ MockResearchStore: class MockResearchStore {} }));
|
||||
const researchStoreMock = Object.assign(Object.create(MockResearchStore.prototype), {
|
||||
getRun: vi.fn(() => mockRun),
|
||||
listRuns: vi.fn(() => [mockRun]),
|
||||
createExport: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const storeMock = {
|
||||
init: vi.fn(),
|
||||
@@ -62,6 +68,10 @@ const { resolveResearchSettingsMock, providerRegistryMock, writeFileMock } = vi.
|
||||
// non-wait fire-and-forget branch in `runResearchCreate`).
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: makeConstructibleMock(() => storeMock),
|
||||
// FNXC:PostgresCutover 2026-07-10: getStore() consults the PG startup factory
|
||||
// first; null routes the test through the legacy `new TaskStore` mock path.
|
||||
createTaskStoreForBackend: vi.fn(async () => null),
|
||||
ResearchStore: MockResearchStore,
|
||||
resolveResearchSettings: resolveResearchSettingsMock,
|
||||
RESEARCH_RUN_STATUSES: ["queued", "running", "cancelling", "retry_waiting", "completed", "failed", "cancelled", "timed_out", "retry_exhausted"],
|
||||
RESEARCH_EXPORT_FORMATS: ["json", "markdown", "pdf"],
|
||||
@@ -79,6 +89,20 @@ vi.mock("@fusion/engine", () => ({
|
||||
// (none of these tests pass `projectName`, so `resolveProjectPathOnly` is
|
||||
// unused at runtime here, but it must exist on the mock module).
|
||||
vi.mock("../../project-context.js", () => ({
|
||||
asLocalProjectContext: vi.fn((store: unknown) => ({
|
||||
projectId: process.cwd(),
|
||||
projectPath: process.cwd(),
|
||||
projectName: "current-project",
|
||||
isRegistered: false,
|
||||
store,
|
||||
})),
|
||||
closeProjectStore: vi.fn(async (context: { store?: { close?: () => unknown } }) => {
|
||||
try {
|
||||
await context?.store?.close?.();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}),
|
||||
resolveProject: vi.fn(async () => undefined),
|
||||
resolveProjectPathOnly: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
@@ -36,6 +36,8 @@ vi.mock("node:fs", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
// FNXC:PostgresCutover 2026-07-10: PG startup factory consulted before legacy TaskStore; null keeps the legacy mock path.
|
||||
createTaskStoreForBackend: vi.fn(async () => null),
|
||||
TaskStore: makeConstructibleMock(() => ({
|
||||
init: mockStoreInit,
|
||||
close: mockStoreClose,
|
||||
@@ -50,6 +52,8 @@ vi.mock("@fusion/core", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../project-context.js", () => ({
|
||||
// FNXC:PostgresCutover 2026-07-10: branch cwd-fallbacks boot via createLocalStore (PG startup factory); reuse the same mocked TaskStore shape.
|
||||
createLocalStore: vi.fn(async () => { const { TaskStore } = await import("@fusion/core"); const store = new (TaskStore as any)(process.cwd()); await store.init?.(); return store; }),
|
||||
resolveProjectPathOnly: vi.fn(async () => undefined),
|
||||
asLocalProjectContext: (store: unknown) => ({
|
||||
projectId: "cwd",
|
||||
|
||||
@@ -32,6 +32,8 @@ vi.mock("node:fs", () => ({
|
||||
// command under test transitively imports `lock-retry.js`, and the mocked
|
||||
// `TaskStore` needs a `close()` so the close-before-exit path is exercised.
|
||||
vi.mock("@fusion/core", () => ({
|
||||
// FNXC:PostgresCutover 2026-07-10: PG startup factory consulted before legacy TaskStore; null keeps the legacy mock path.
|
||||
createTaskStoreForBackend: vi.fn(async () => null),
|
||||
TaskStore: makeConstructibleMock(() => ({
|
||||
init: mockStoreInit,
|
||||
close: mockStoreClose,
|
||||
|
||||
@@ -165,126 +165,15 @@ async function holdWriteLock(
|
||||
};
|
||||
}
|
||||
|
||||
describe("fn task show / task move — real locked-store reproduction (FN-7731)", () => {
|
||||
let tmpDir: string;
|
||||
const originalRetryMs = process.env.FUSION_CLI_LOCK_RETRY_MS;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalRetryMs === undefined) {
|
||||
delete process.env.FUSION_CLI_LOCK_RETRY_MS;
|
||||
} else {
|
||||
process.env.FUSION_CLI_LOCK_RETRY_MS = originalRetryMs;
|
||||
}
|
||||
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("succeeds when a real writer lock releases within the retry window", async () => {
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
vi.doMock("../../project-context.js", () => ({
|
||||
resolveProject: vi.fn().mockRejectedValue(new Error("no registered project")),
|
||||
closeProjectStore: async (context: { store: { close: () => Promise<void> } }) => {
|
||||
await context.store.close().catch(() => {});
|
||||
},
|
||||
}));
|
||||
|
||||
const setupStore = new TaskStore(tmpDir);
|
||||
await setupStore.init();
|
||||
const task = await setupStore.createTask({ description: "lock repro task" });
|
||||
await setupStore.close();
|
||||
|
||||
const dbPath = join(tmpDir, ".fusion", "fusion.db");
|
||||
// Hold the lock for a short window, well inside the overridden retry
|
||||
// deadline, then release automatically (timer mode) — proving the
|
||||
// retry path succeeds once the lock clears, per FN-5048 (no long real
|
||||
// waits: short overridden bound + short real hold, not a slow test).
|
||||
process.env.FUSION_CLI_LOCK_RETRY_MS = "8000";
|
||||
const lock = await holdWriteLock(dbPath, { holdMs: 400 });
|
||||
|
||||
try {
|
||||
const { runTaskShow } = await import("../task.js");
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const cwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
try {
|
||||
await runTaskShow(task.id);
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
}
|
||||
const printed = logSpy.mock.calls.flat().join("\n");
|
||||
expect(printed).toContain(task.id);
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
} finally {
|
||||
await lock.release().catch(() => {});
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("fails fast with a clear non-zero-exit error when the lock never releases (real busy_timeout, single attempt)", async () => {
|
||||
// FNXC:CliBoardMutation 2026-07-09-00:00:
|
||||
// Real SQLite's busy_timeout blocks synchronously at the C level for up
|
||||
// to DEFAULT_SQLITE_BUSY_TIMEOUT_MS (5s, packages/core/src/db.ts) before
|
||||
// a single attempt even returns control to JS, so a real end-to-end
|
||||
// exhaustion repro cannot be made to fail fast without touching
|
||||
// DB-level timeouts (forbidden by this task's scope). This test proves
|
||||
// the invariant holds for ONE such blocking attempt: the raw
|
||||
// `database is locked` never reaches the operator unformatted, the
|
||||
// command still fails with a clear, actionable, non-zero-exit error,
|
||||
// and the store is closed. Bounded exhaustion behavior across MANY fast
|
||||
// attempts (the realistic CLI-layer retry shape) is covered by the
|
||||
// mocked-store tests below per FN-5048 (no long real waits there).
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
vi.doMock("../../project-context.js", () => ({
|
||||
resolveProject: vi.fn().mockRejectedValue(new Error("no registered project")),
|
||||
closeProjectStore: async (context: { store: { close: () => Promise<void> } }) => {
|
||||
await context.store.close().catch(() => {});
|
||||
},
|
||||
}));
|
||||
|
||||
const setupStore = new TaskStore(tmpDir);
|
||||
await setupStore.init();
|
||||
const task = await setupStore.createTask({ description: "lock exhaustion repro task" });
|
||||
await setupStore.close();
|
||||
|
||||
const dbPath = join(tmpDir, ".fusion", "fusion.db");
|
||||
// Deadline shorter than a single DB-level busy_timeout attempt (~5s) so
|
||||
// the very first retry check already sees the deadline exceeded.
|
||||
process.env.FUSION_CLI_LOCK_RETRY_MS = "600";
|
||||
const lock = await holdWriteLock(dbPath);
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
}) as never);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const { runTaskShow } = await import("../task.js");
|
||||
const cwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
try {
|
||||
await expect(runTaskShow(task.id)).rejects.toThrow(/process\.exit\(1\)/);
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
}
|
||||
const printed = errorSpy.mock.calls.flat().join("\n");
|
||||
// Never a raw, un-retried "database is locked" with no context.
|
||||
expect(printed).not.toMatch(/^\s*database is locked\s*$/im);
|
||||
expect(printed).toMatch(/locked|retry|FUSION_CLI_LOCK_RETRY_MS/i);
|
||||
expect(printed).toContain(task.id);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
await lock.release().catch(() => {});
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:PostgresCutover 2026-07-10:
|
||||
* Upstream's "real locked-store reproduction" describe held a REAL write lock
|
||||
* on a sqlite fusion.db file to reproduce `database is locked` (FN-7731). The
|
||||
* sqlite runtime is removed on this branch and PostgreSQL has no equivalent
|
||||
* whole-database writer lock, so the real-file reproduction is not portable.
|
||||
* The CLI-layer retry/teardown contract stays covered by the mocked-store
|
||||
* describes below (lock exhaustion, not-found, close-on-every-exit-path).
|
||||
*/
|
||||
describe("runTaskShow / runTaskMove — mocked-store lock exhaustion, not-found, and teardown (FN-7731)", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
@@ -307,7 +196,7 @@ describe("runTaskShow / runTaskMove — mocked-store lock exhaustion, not-found,
|
||||
isRegistered: true,
|
||||
store,
|
||||
});
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore }));
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore, createLocalStore: vi.fn(async () => store as never) }));
|
||||
const mod = await import("../task.js");
|
||||
return { mod, closeProjectStore, resolveProject };
|
||||
}
|
||||
@@ -471,7 +360,7 @@ describe("FN-7734: generalized retry+teardown across representative fn task subc
|
||||
isRegistered: true,
|
||||
store,
|
||||
});
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore }));
|
||||
vi.doMock("../../project-context.js", () => ({ resolveProject, closeProjectStore, createLocalStore: vi.fn(async () => store as never) }));
|
||||
const mod = await import("../task.js");
|
||||
return { mod, closeProjectStore, resolveProject };
|
||||
}
|
||||
@@ -485,6 +374,17 @@ describe("FN-7734: generalized retry+teardown across representative fn task subc
|
||||
vi.doMock("../../project-context.js", () => ({
|
||||
resolveProject,
|
||||
closeProjectStore,
|
||||
// FNXC:PostgresCutover 2026-07-10: the branch's cwd fallback boots via
|
||||
// createLocalStore; hand back the same proxied mock store.
|
||||
createLocalStore: vi.fn(async () => {
|
||||
const proxied = new Proxy(store, {
|
||||
get(target, prop) {
|
||||
if (prop === "init") return async () => {};
|
||||
return (target as Record<string, unknown>)[prop as string];
|
||||
},
|
||||
});
|
||||
return proxied as never;
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
|
||||
@@ -148,10 +148,28 @@ vi.mock("@fusion/core/gh-cli", () => ({
|
||||
|
||||
// Mock project-context
|
||||
vi.mock("../../project-context.js", () => ({
|
||||
asLocalProjectContext: vi.fn((store: unknown) => ({
|
||||
projectId: process.cwd(),
|
||||
projectPath: process.cwd(),
|
||||
projectName: "current-project",
|
||||
isRegistered: false,
|
||||
store,
|
||||
})),
|
||||
resolveProjectPathOnly: vi.fn(async () => process.cwd()),
|
||||
resolveProject: vi.fn().mockRejectedValue(new Error("No project context")),
|
||||
getStore: vi.fn().mockResolvedValue({}),
|
||||
getDefaultProject: vi.fn().mockResolvedValue(undefined),
|
||||
setDefaultProject: vi.fn().mockResolvedValue(undefined),
|
||||
// FNXC:PostgresCutover 2026-07-05-12:00: cwd fallbacks now boot through
|
||||
// createLocalStore (PostgreSQL startup factory) instead of `new TaskStore`.
|
||||
// Default impl mirrors the legacy fallback (construct + init the mocked
|
||||
// TaskStore) so tests exercising the fallback keep their store shape.
|
||||
createLocalStore: vi.fn(async (projectPath: string) => {
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
const store = new (TaskStore as unknown as new (p: string) => { init?: () => Promise<void> })(projectPath);
|
||||
await store.init?.();
|
||||
return store;
|
||||
}),
|
||||
// FNXC:CliBoardMutation 2026-07-09-00:00: FN-7731's runTaskShow/runTaskMove
|
||||
// close the resolved store via closeProjectStore on every exit path; the
|
||||
// real implementation is best-effort/tolerant of a store without a usable
|
||||
@@ -189,7 +207,7 @@ import {
|
||||
} from "@fusion/core/gh-cli";
|
||||
import { GitHubClient, generatePrMetadata } from "@fusion/dashboard";
|
||||
import { createSession, submitResponse } from "@fusion/dashboard/planning";
|
||||
import { resolveProject } from "../../project-context.js";
|
||||
import { resolveProject, createLocalStore } from "../../project-context.js";
|
||||
import { aiMergeTask, runAiMerge, landWorkspaceTask } from "@fusion/engine";
|
||||
|
||||
const mockedExec = vi.mocked(exec);
|
||||
@@ -552,17 +570,16 @@ describe("project-aware task command behavior", () => {
|
||||
new Error("No fusion project found in current directory. Use --project or run from a project directory.")
|
||||
);
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation((projectPath: string) => ({
|
||||
vi.mocked(createLocalStore).mockResolvedValueOnce({
|
||||
init,
|
||||
listTasks: mockListTasks,
|
||||
projectPath,
|
||||
}));
|
||||
projectPath: "/current/project",
|
||||
} as never);
|
||||
|
||||
await expect(runTaskList()).rejects.toThrow("process.exit");
|
||||
|
||||
expect(resolveProject).toHaveBeenCalledWith(undefined);
|
||||
expect(TaskStore).toHaveBeenCalledWith("/current/project");
|
||||
expect(init).toHaveBeenCalledOnce();
|
||||
expect(createLocalStore).toHaveBeenCalledWith("/current/project");
|
||||
expect(mockListTasks).toHaveBeenCalledOnce();
|
||||
cwdSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
@@ -632,19 +649,18 @@ describe("project-aware task command behavior", () => {
|
||||
new Error("No fn project found in current directory. Use --project or run from a project directory.")
|
||||
);
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation((projectPath: string) => ({
|
||||
vi.mocked(createLocalStore).mockResolvedValueOnce({
|
||||
init,
|
||||
createTask: mockCreateTask,
|
||||
addAttachment: vi.fn(),
|
||||
getRootDir: vi.fn().mockReturnValue(projectPath),
|
||||
projectPath,
|
||||
}));
|
||||
getRootDir: vi.fn().mockReturnValue("/current/project"),
|
||||
projectPath: "/current/project",
|
||||
} as never);
|
||||
|
||||
await runTaskCreate("local task");
|
||||
|
||||
expect(resolveProject).toHaveBeenCalledWith(undefined);
|
||||
expect(TaskStore).toHaveBeenCalledWith("/current/project");
|
||||
expect(init).toHaveBeenCalledOnce();
|
||||
expect(createLocalStore).toHaveBeenCalledWith("/current/project");
|
||||
expect(mockCreateTask).toHaveBeenCalledWith({ description: "local task", dependencies: undefined, source: { sourceType: "cli", sourceMetadata: { contentFingerprint: "fp-local" } } });
|
||||
cwdSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -11,29 +11,7 @@ import { resolve } from "node:path";
|
||||
|
||||
import { AgentStore, exportAgentsToDirectory } from "@fusion/core";
|
||||
|
||||
import { resolveProjectPathOnly } from "../project-context.js";
|
||||
|
||||
/**
|
||||
* FNXC:CliAgentControl 2026-07-09-00:00:
|
||||
* FN-7740 audit finding: `getProjectPath` only ever needs the resolved
|
||||
* `projectPath` — it never uses `context.store`. The prior `resolveProject`
|
||||
* call still constructed (and, for registered/CWD-detected projects,
|
||||
* cached) a `TaskStore` that was never closed, leaking a SQLite/WAL handle
|
||||
* that keeps the CLI event loop alive after export finishes. Use
|
||||
* `resolveProjectPathOnly` (FN-7731/FN-7738), which closes+evicts the store
|
||||
* it constructs internally.
|
||||
*/
|
||||
async function getProjectPath(projectName?: string): Promise<string> {
|
||||
if (projectName) {
|
||||
return resolveProjectPathOnly(projectName);
|
||||
}
|
||||
|
||||
try {
|
||||
return await resolveProjectPathOnly(undefined);
|
||||
} catch {
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
import { resolveAgentStoreBase } from "../project-context.js";
|
||||
|
||||
/**
|
||||
* FNXC:CliAgentControl 2026-07-09-00:00:
|
||||
@@ -86,8 +64,11 @@ export async function runAgentExport(
|
||||
agentIds?: string[];
|
||||
},
|
||||
): Promise<void> {
|
||||
const projectPath = await getProjectPath(options?.project);
|
||||
const agentStore = new AgentStore({ rootDir: projectPath + "/.fusion" });
|
||||
// FNXC:PostgresCutover 2026-07-04: construct AgentStore in backend mode by
|
||||
// borrowing the asyncLayer from the resolved project store (SQLite runtime
|
||||
// removed under VAL-REMOVAL-005), mirroring extension.ts getAgentStore.
|
||||
const { rootDir, asyncLayer } = await resolveAgentStoreBase(options?.project);
|
||||
const agentStore = new AgentStore({ rootDir: rootDir + "/.fusion", asyncLayer: asyncLayer ?? undefined });
|
||||
await agentStore.init();
|
||||
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import type { AgentCreateInput } from "@fusion/core";
|
||||
import type { SkillManifest } from "@fusion/core";
|
||||
import { stringify as stringifyYaml } from "yaml";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
import { resolveAgentStoreBase } from "../project-context.js";
|
||||
|
||||
export interface SkillImportResult {
|
||||
imported: string[];
|
||||
@@ -151,24 +151,6 @@ async function importSkillsToProject(
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the project path for agent operations.
|
||||
* Falls back to process.cwd() if no project is specified.
|
||||
*/
|
||||
async function getProjectPath(projectName?: string): Promise<string> {
|
||||
if (projectName) {
|
||||
const context = await resolveProject(projectName);
|
||||
return context.projectPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await resolveProject(undefined);
|
||||
return context.projectPath;
|
||||
} catch {
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a summary of the import result.
|
||||
*/
|
||||
@@ -245,9 +227,11 @@ export async function runAgentImport(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get existing agent names for skip logic
|
||||
const projectPath = await getProjectPath(options?.project);
|
||||
const agentStore = new AgentStore({ rootDir: projectPath + "/.fusion" });
|
||||
// FNXC:PostgresCutover 2026-07-04: construct AgentStore in backend mode by
|
||||
// borrowing the asyncLayer from the resolved project store (SQLite runtime
|
||||
// removed under VAL-REMOVAL-005), mirroring extension.ts getAgentStore.
|
||||
const { rootDir: projectPath, asyncLayer } = await resolveAgentStoreBase(options?.project);
|
||||
const agentStore = new AgentStore({ rootDir: projectPath + "/.fusion", asyncLayer: asyncLayer ?? undefined });
|
||||
await agentStore.init();
|
||||
|
||||
const existingAgents = await agentStore.listAgents();
|
||||
|
||||
@@ -1,34 +1,17 @@
|
||||
import { AgentStore, AGENT_VALID_TRANSITIONS } from "@fusion/core";
|
||||
import type { AgentState } from "@fusion/core";
|
||||
import { resolveProjectPathOnly } from "../project-context.js";
|
||||
|
||||
/**
|
||||
* Get the project path for agent operations.
|
||||
* Falls back to process.cwd() if no project is specified.
|
||||
*
|
||||
* FNXC:CliAgentControl 2026-07-08-00:00:
|
||||
* Uses `resolveProjectPathOnly` (not `resolveProject`) so this never leaks a
|
||||
* `TaskStore` this command has no use for. See `closeProjectStore` in
|
||||
* project-context.ts for the leak this avoids.
|
||||
*/
|
||||
async function getProjectPath(projectName?: string): Promise<string> {
|
||||
if (projectName) {
|
||||
return await resolveProjectPathOnly(projectName);
|
||||
}
|
||||
|
||||
try {
|
||||
return await resolveProjectPathOnly(undefined);
|
||||
} catch {
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
import { resolveAgentStoreBase } from "../project-context.js";
|
||||
|
||||
/**
|
||||
* Create an initialized AgentStore for the given project.
|
||||
*
|
||||
* FNXC:PostgresCutover 2026-07-04: borrow the PostgreSQL AsyncDataLayer from
|
||||
* the resolved project store so AgentStore runs in backend mode (the SQLite
|
||||
* runtime was removed under VAL-REMOVAL-005), mirroring extension.ts getAgentStore.
|
||||
*/
|
||||
async function createAgentStore(projectName?: string): Promise<AgentStore> {
|
||||
const projectPath = await getProjectPath(projectName);
|
||||
const agentStore = new AgentStore({ rootDir: projectPath + "/.fusion" });
|
||||
const { rootDir, asyncLayer } = await resolveAgentStoreBase(projectName);
|
||||
const agentStore = new AgentStore({ rootDir: rootDir + "/.fusion", asyncLayer: asyncLayer ?? undefined });
|
||||
await agentStore.init();
|
||||
return agentStore;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@ import {
|
||||
BackupManager,
|
||||
createBackupManager,
|
||||
runBackupCommand,
|
||||
TaskStore,
|
||||
} from "@fusion/core";
|
||||
import { resolveProject, closeProjectStore, asLocalProjectContext, type ProjectContext } from "../project-context.js";
|
||||
import { resolveProject, createLocalStore, closeProjectStore, asLocalProjectContext, type ProjectContext } from "../project-context.js";
|
||||
import { retryOnLock, LockRetryExhaustedError } from "../lock-retry.js";
|
||||
|
||||
/**
|
||||
@@ -30,8 +29,10 @@ async function resolveBackupContext(projectName?: string): Promise<ProjectContex
|
||||
try {
|
||||
return await resolveProject(projectName);
|
||||
} catch {
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
// FNXC:PostgresCutover 2026-07-05-12:00: the cwd fallback must boot through
|
||||
// the PostgreSQL startup factory (createLocalStore); a bare `new TaskStore`
|
||||
// resolves to the removed SQLite runtime, which throws on first DB access.
|
||||
const store = await createLocalStore(process.cwd());
|
||||
return asLocalProjectContext(store);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TaskStore, isBranchGroupComplete, isBranchGroupMemberLanded, filterTasksByBranchGroup, type BranchGroup, type Settings, type Task } from "@fusion/core";
|
||||
import { promoteBranchGroup, resolveIntegrationBranch } from "@fusion/engine";
|
||||
import { GitHubClient, closeGroupPullRequest } from "@fusion/dashboard";
|
||||
import { resolveProject, closeProjectStore, asLocalProjectContext, type ProjectContext } from "../project-context.js";
|
||||
import { resolveProject, createLocalStore, closeProjectStore, asLocalProjectContext, type ProjectContext } from "../project-context.js";
|
||||
import { createGroupPrCallback } from "./task-lifecycle.js";
|
||||
import { retryOnLock, LockRetryExhaustedError } from "../lock-retry.js";
|
||||
|
||||
@@ -58,8 +58,9 @@ async function getBranchGroupContext(projectName?: string): Promise<ProjectConte
|
||||
if (projectName) {
|
||||
throw new Error(`Project ${projectName} not found`);
|
||||
}
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
// FNXC:PostgresCutover 2026-07-05-12:00: boot the cwd fallback through the
|
||||
// PostgreSQL startup factory; bare `new TaskStore` throws in backend mode.
|
||||
const store = await createLocalStore(process.cwd());
|
||||
return asLocalProjectContext(store);
|
||||
}
|
||||
|
||||
@@ -112,7 +113,7 @@ export async function runBranchGroupList(projectName?: string) {
|
||||
const context = await getBranchGroupContext(projectName);
|
||||
try {
|
||||
const { store } = context;
|
||||
const groups = store.listBranchGroups();
|
||||
const groups = await store.listBranchGroups();
|
||||
|
||||
if (groups.length === 0) {
|
||||
console.log("\n No branch groups yet.\n");
|
||||
@@ -152,7 +153,7 @@ export async function runBranchGroupShow(id: string, projectName?: string) {
|
||||
const context = await getBranchGroupContext(projectName);
|
||||
try {
|
||||
const { store } = context;
|
||||
const group = store.getBranchGroup(id);
|
||||
const group = await store.getBranchGroup(id);
|
||||
if (!group) {
|
||||
console.error(`\n \u2717 Branch group ${id} not found\n`);
|
||||
await closeProjectStore(context);
|
||||
@@ -309,7 +310,7 @@ export async function runBranchGroupPromote(id: string, projectName?: string) {
|
||||
recordAudit: async (event) => {
|
||||
await retryOnLock(
|
||||
async () =>
|
||||
store.recordRunAuditEvent({
|
||||
void store.recordRunAuditEvent({
|
||||
agentId: "cli:branch-group-promote",
|
||||
runId: `cli-promote-${group.id}`,
|
||||
domain: event.domain as Parameters<TaskStore["recordRunAuditEvent"]>[0]["domain"],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AgentStore } from "@fusion/core";
|
||||
import type { Message } from "@fusion/core";
|
||||
import { createMessageStore, formatParticipant, formatTime, CLI_USER_ID } from "./message.js";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
import { resolveAgentStoreBase } from "../project-context.js";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 8192;
|
||||
@@ -18,23 +18,15 @@ export interface ChatInteractiveOptions {
|
||||
output?: NodeJS.WritableStream;
|
||||
}
|
||||
|
||||
async function getProjectPath(projectName?: string): Promise<string> {
|
||||
if (projectName) {
|
||||
const context = await resolveProject(projectName);
|
||||
return context.projectPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await resolveProject(undefined);
|
||||
return context.projectPath;
|
||||
} catch {
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PostgresCutover 2026-07-05-12:00:
|
||||
Borrow the PostgreSQL AsyncDataLayer from the resolved project store so the
|
||||
chat AgentStore runs in backend mode (the SQLite runtime was removed under
|
||||
VAL-REMOVAL-005), mirroring agent.ts/extension.ts createAgentStore.
|
||||
*/
|
||||
async function createAgentStore(projectName?: string): Promise<AgentStore> {
|
||||
const projectPath = await getProjectPath(projectName);
|
||||
const store = new AgentStore({ rootDir: `${projectPath}/.fusion` });
|
||||
const { rootDir, asyncLayer } = await resolveAgentStoreBase(projectName);
|
||||
const store = new AgentStore({ rootDir: `${rootDir}/.fusion`, asyncLayer: asyncLayer ?? undefined });
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
@@ -90,13 +82,13 @@ async function waitForReply(
|
||||
): Promise<boolean> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
const inbox = messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 });
|
||||
const inbox = await messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 });
|
||||
for (const message of inbox.slice().reverse()) {
|
||||
if (message.fromId !== agentId || message.fromType !== "agent") continue;
|
||||
if (printedIds.has(message.id)) continue;
|
||||
printedIds.add(message.id);
|
||||
printMessage(output, message);
|
||||
messageStore.markAsRead(message.id);
|
||||
await messageStore.markAsRead(message.id);
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
@@ -119,7 +111,7 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
|
||||
const { store: messageStore, db } = await createMessageStore(options.project);
|
||||
const printedIds = new Set<string>();
|
||||
|
||||
const conversation = messageStore.getConversation(
|
||||
const conversation = await messageStore.getConversation(
|
||||
{ id: CLI_USER_ID, type: "user" },
|
||||
{ id: agentId, type: "agent" },
|
||||
);
|
||||
@@ -141,7 +133,7 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
|
||||
return 0;
|
||||
}
|
||||
|
||||
messageStore.sendMessage({
|
||||
await messageStore.sendMessage({
|
||||
fromId: CLI_USER_ID,
|
||||
fromType: "user",
|
||||
toId: agentId,
|
||||
@@ -163,13 +155,13 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
|
||||
const abortController = new AbortController();
|
||||
const poller = (async () => {
|
||||
while (!abortController.signal.aborted) {
|
||||
const inbox = messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 });
|
||||
const inbox = await messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 });
|
||||
for (const message of inbox.slice().reverse()) {
|
||||
if (message.fromId !== agentId || message.fromType !== "agent") continue;
|
||||
if (printedIds.has(message.id)) continue;
|
||||
printedIds.add(message.id);
|
||||
printMessage(output, message);
|
||||
messageStore.markAsRead(message.id);
|
||||
await messageStore.markAsRead(message.id);
|
||||
}
|
||||
await sleep(pollIntervalMs, abortController.signal);
|
||||
}
|
||||
@@ -192,10 +184,10 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
|
||||
continue;
|
||||
}
|
||||
if (line === "/history") {
|
||||
const history = messageStore.getConversation(
|
||||
const history = (await messageStore.getConversation(
|
||||
{ id: CLI_USER_ID, type: "user" },
|
||||
{ id: agentId, type: "agent" },
|
||||
).slice(-HISTORY_LIMIT);
|
||||
)).slice(-HISTORY_LIMIT);
|
||||
for (const message of history) printedIds.add(message.id);
|
||||
printConversationTail(output, history);
|
||||
continue;
|
||||
@@ -209,7 +201,7 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
|
||||
continue;
|
||||
}
|
||||
|
||||
messageStore.sendMessage({
|
||||
await messageStore.sendMessage({
|
||||
fromId: CLI_USER_ID,
|
||||
fromType: "user",
|
||||
toId: agentId,
|
||||
|
||||
@@ -551,7 +551,16 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
|
||||
if (schemaHooks.length > 0) {
|
||||
try {
|
||||
await store.getDatabase().runPluginSchemaInits(schemaHooks);
|
||||
/*
|
||||
* FNXC:SqliteFinalRemoval 2026-06-25-16:25:
|
||||
* Skip SQLite-specific plugin schema init in backend mode (PostgreSQL
|
||||
* uses Drizzle migrations for schema management).
|
||||
*/
|
||||
if (store.isBackendMode()) {
|
||||
console.log("[plugins] Schema initialization skipped — backend mode (PostgreSQL Drizzle migrations)");
|
||||
} else {
|
||||
await store.getDatabase().runPluginSchemaInits(schemaHooks);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[plugins] Schema initialization failed: ${err instanceof Error ? err.message : err}`,
|
||||
|
||||
@@ -28,8 +28,10 @@ import {
|
||||
parseWorkflowIr,
|
||||
registerBuiltInGrokProvider,
|
||||
registerBuiltInZaiProvider,
|
||||
MissionStore,
|
||||
type WorkflowIrColumn,
|
||||
type TraitFlags,
|
||||
createTaskStoreForBackend,
|
||||
FUSION_RESTART_EXIT_CODE,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
@@ -772,7 +774,6 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// (they're assigned after initialization, but the variables exist from the start).
|
||||
// prefer-const disabled: callbacks close over these identifiers before the
|
||||
// single assignment below, which requires `let` even though no reassignment occurs.
|
||||
// eslint-disable-next-line prefer-const
|
||||
let store: TaskStore | undefined;
|
||||
// eslint-disable-next-line prefer-const
|
||||
let agentStore: AgentStore | undefined;
|
||||
@@ -874,17 +875,49 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// startup/runtime lines flow into the TUI log buffer when interactive.
|
||||
ensureProcessDiagnostics(runtimeLogger);
|
||||
|
||||
store = new TaskStore(cwd);
|
||||
const automationStore = new AutomationStore(cwd);
|
||||
// FNXC:BackendFlip 2026-06-26-14:40:
|
||||
// Consult the startup factory to boot a PostgreSQL-backed TaskStore. Post
|
||||
// default-flip: the factory boots embedded PG by default when DATABASE_URL
|
||||
// is unset, external PG when DATABASE_URL is set, and returns null only
|
||||
// when the operator opted out via FUSION_NO_EMBEDDED_PG=1 (legacy SQLite
|
||||
// path). When it returns null, the legacy SQLite path runs unchanged. The
|
||||
// backend shutdown handle is captured so the dashboard teardown path can
|
||||
// release the pool / stop an embedded cluster; it is invoked via the
|
||||
// existing store.close() (which closes the AsyncDataLayer) plus the
|
||||
// dashboardBackendShutdown
|
||||
// registered below for embedded-cluster teardown.
|
||||
let dashboardBackendShutdown: (() => Promise<void>) | undefined;
|
||||
const dashboardBackendBoot = await createTaskStoreForBackend({ rootDir: cwd });
|
||||
if (dashboardBackendBoot) {
|
||||
store = dashboardBackendBoot.taskStore;
|
||||
dashboardBackendShutdown = dashboardBackendBoot.shutdown;
|
||||
} else {
|
||||
store = new TaskStore(cwd);
|
||||
}
|
||||
// FNXC:PhysicalDeleteSqliteClass 2026-06-26-14:05:
|
||||
// Propagate the backend mode (asyncLayer) from the resolved TaskStore so
|
||||
// AutomationStore does not construct a SQLite file under PostgreSQL. The
|
||||
// `?? undefined` coerces `AsyncDataLayer | null` to the optional option
|
||||
// shape used by the other satellite stores.
|
||||
const automationStore = new AutomationStore(cwd, { asyncLayer: store.getAsyncLayer() ?? undefined });
|
||||
|
||||
// CentralCore.init() is independent of store inits — start it early so it
|
||||
// overlaps with plugin loading and extension resolution instead of running
|
||||
// after them.
|
||||
const noEngine = opts.noEngine === true;
|
||||
|
||||
// FNXC:CentralCoreBackendMode 2026-06-26-13:20:
|
||||
// CentralCore must receive the same AsyncDataLayer the resolved TaskStore
|
||||
// uses, otherwise registerProject/listProjects fall back to the deleted
|
||||
// SQLite CentralDatabase path and throw "Cannot read properties of null
|
||||
// (reading 'transaction')" in backend mode. This mirrors serve.ts:292 which
|
||||
// passes { asyncLayer: centralBootResult.asyncLayer } to the CentralCore
|
||||
// constructor. Without this, the dashboard boots but project registration
|
||||
// is completely broken (POST /api/projects returns 500), blocking the
|
||||
// kanban board and all dashboard UI flows.
|
||||
const centralCoreInitPromise = !noEngine
|
||||
? (async () => {
|
||||
const core = new CentralCore();
|
||||
const core = new CentralCore(undefined, { asyncLayer: store.getAsyncLayer() ?? undefined });
|
||||
try { await core.init(); } catch { /* non-fatal — fallback defaults */ }
|
||||
return core;
|
||||
})()
|
||||
@@ -916,7 +949,15 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
const pluginStore = store.getPluginStore();
|
||||
await phaseTime("pluginStore.init", () => pluginStore.init());
|
||||
|
||||
agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
// FNXC:PhysicalDeleteSqliteClass 2026-06-26-15:10:
|
||||
// Propagate the backend mode (asyncLayer) from the resolved TaskStore so
|
||||
// AgentStore does not construct a SQLite file under PostgreSQL. Without
|
||||
// this, AgentStore falls into the legacy SQLite path in backend mode and
|
||||
// throws "SQLite Database is not available in backend mode" the first time
|
||||
// any getter touches `this.db`. Mirrors the AutomationStore fix on line ~893
|
||||
// (VAL-CROSS-008 dashboard boot on embedded PostgreSQL). The `?? undefined`
|
||||
// coerces `AsyncDataLayer | null` to the optional option shape.
|
||||
agentStore = new AgentStore({ rootDir: store.getFusionDir(), asyncLayer: store.getAsyncLayer() ?? undefined });
|
||||
if (tui) tui.setLoadingStatus(DASHBOARD_STARTUP_STATUS.initializingAgentStore);
|
||||
await phaseTime("agentStore.init", () => agentStore!.init());
|
||||
// store.watch() is filesystem-watcher setup — no DB schema work, safe to
|
||||
@@ -946,7 +987,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
let tuiRefreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Per-project task stores for the BoardView's scoped stats. Shared with the
|
||||
// interactiveData wiring below so we don't re-init SQLite on each refresh.
|
||||
// interactiveData wiring below so we don't re-boot a backend on each refresh.
|
||||
// FNXC:PostgresCutover 2026-07-05-12:00: non-cwd project stores must boot
|
||||
// through the PostgreSQL startup factory; bare `new TaskStore` throws in
|
||||
// backend mode (SQLite runtime removed under VAL-REMOVAL-005). Stores are
|
||||
// cached for the TUI process lifetime; pools are released at process exit.
|
||||
const projectStores = new Map<string, TaskStore>();
|
||||
async function getProjectStore(projectPath: string): Promise<TaskStore> {
|
||||
const cached = projectStores.get(projectPath);
|
||||
@@ -956,8 +1001,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
if (!store) throw new Error("cwd TaskStore not yet initialized");
|
||||
projectStore = store;
|
||||
} else {
|
||||
projectStore = new TaskStore(projectPath);
|
||||
await projectStore.init();
|
||||
const boot = await createTaskStoreForBackend({ rootDir: projectPath });
|
||||
if (boot) {
|
||||
projectStore = boot.taskStore;
|
||||
} else {
|
||||
projectStore = new TaskStore(projectPath);
|
||||
await projectStore.init();
|
||||
}
|
||||
}
|
||||
projectStores.set(projectPath, projectStore);
|
||||
return projectStore;
|
||||
@@ -1377,7 +1427,16 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
|
||||
if (schemaHooks.length > 0) {
|
||||
try {
|
||||
await store.getDatabase().runPluginSchemaInits(schemaHooks);
|
||||
/*
|
||||
* FNXC:SqliteFinalRemoval 2026-06-25-16:25:
|
||||
* Skip SQLite-specific plugin schema init in backend mode (PostgreSQL
|
||||
* uses Drizzle migrations for schema management).
|
||||
*/
|
||||
if (store.isBackendMode()) {
|
||||
logSink.log("[plugins] Schema initialization skipped — backend mode (PostgreSQL Drizzle migrations)");
|
||||
} else {
|
||||
await store.getDatabase().runPluginSchemaInits(schemaHooks);
|
||||
}
|
||||
} catch (err) {
|
||||
logSink.log(
|
||||
`Schema initialization failed: ${err instanceof Error ? err.message : err}`,
|
||||
@@ -1507,23 +1566,75 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// Created inline for UI-only mode (engine doesn't start with --no-engine).
|
||||
// In engine mode, the engine is passed to createServer which derives these.
|
||||
//
|
||||
const missionAutopilotImpl: MissionAutopilot | undefined = new MissionAutopilot(store, store.getMissionStore());
|
||||
const missionExecutionLoopImpl: MissionExecutionLoop | undefined = new MissionExecutionLoop({
|
||||
taskStore: store,
|
||||
missionStore: store.getMissionStore(),
|
||||
missionAutopilot: {
|
||||
notifyValidationComplete: async (featureId: string, _status: "passed" | "failed" | "blocked" | "error") => {
|
||||
if (missionAutopilotImpl) {
|
||||
const missionStore = store.getMissionStore();
|
||||
const feature = missionStore?.getFeature(featureId);
|
||||
if (feature?.taskId) {
|
||||
await missionAutopilotImpl.handleTaskCompletion(feature.taskId);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
rootDir: cwd,
|
||||
});
|
||||
/*
|
||||
* FNXC:SqliteFinalRemoval 2026-06-26-13:05:
|
||||
* In backend mode (PostgreSQL), store.getMissionStore() throws because
|
||||
* MissionStore has not been converted to the async path yet — it requires a
|
||||
* synchronous SQLite Database handle (store.db), which throws
|
||||
* "SQLite Database is not available in backend mode". This used to crash the
|
||||
* entire `fn dashboard` boot, blocking the UI entirely.
|
||||
*
|
||||
* Catch the error and degrade to undefined, mirroring InProcessRuntime's
|
||||
* graceful-degrade pattern (engine/src/runtimes/in-process-runtime.ts:401-413).
|
||||
* The proxy objects handed to createServer (below, around the UI-only-mode
|
||||
* createServer call) already route through `missionAutopilotImpl?` /
|
||||
* `missionExecutionLoopImpl?` optional chaining, so undefined disables
|
||||
* mission lifecycle features without breaking dashboard boot. Mission
|
||||
* autopilot / execution loop will re-enable once MissionStore is fully
|
||||
* converted to the async Drizzle path.
|
||||
*/
|
||||
let missionStore: import("@fusion/core").MissionStore | undefined;
|
||||
try {
|
||||
// FNXC:MissionStore 2026-06-27-16:15:
|
||||
// MissionAutopilot + MissionExecutionLoop are coupled to the sync EventEmitter
|
||||
// MissionStore. In PG backend mode getMissionStore() returns the AsyncMissionStore
|
||||
// (CRUD-only); guard with instanceof and skip autopilot/loop init — mission
|
||||
// lifecycle stays degraded in PG (mirrors InProcessRuntime).
|
||||
const resolvedMissionStore = store.getMissionStore();
|
||||
missionStore = resolvedMissionStore instanceof MissionStore ? resolvedMissionStore : undefined;
|
||||
} catch (msErr) {
|
||||
if (store.isBackendMode()) {
|
||||
logSink.log(
|
||||
`MissionStore unavailable (backend mode); mission autopilot disabled: ${
|
||||
msErr instanceof Error ? msErr.message : msErr
|
||||
}`,
|
||||
"engine",
|
||||
);
|
||||
} else {
|
||||
// In SQLite mode, an unexpected failure here is a real bug — surface it
|
||||
// via the log sink but still degrade rather than crashing dashboard boot.
|
||||
logSink.log(
|
||||
`MissionStore init failed; mission autopilot disabled: ${
|
||||
msErr instanceof Error ? msErr.message : msErr
|
||||
}`,
|
||||
"engine",
|
||||
);
|
||||
}
|
||||
missionStore = undefined;
|
||||
}
|
||||
const missionAutopilotImpl: MissionAutopilot | undefined = missionStore
|
||||
? new MissionAutopilot(store, missionStore)
|
||||
: undefined;
|
||||
const missionExecutionLoopImpl: MissionExecutionLoop | undefined = missionStore
|
||||
? new MissionExecutionLoop({
|
||||
taskStore: store,
|
||||
missionStore,
|
||||
missionAutopilot: {
|
||||
notifyValidationComplete: async (
|
||||
featureId: string,
|
||||
_status: "passed" | "failed" | "blocked" | "error",
|
||||
) => {
|
||||
if (missionAutopilotImpl) {
|
||||
const feature = missionStore?.getFeature(featureId);
|
||||
if (feature?.taskId) {
|
||||
await missionAutopilotImpl.handleTaskCompletion(feature.taskId);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
rootDir: cwd,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// ── Auth & model wiring ────────────────────────────────────────────
|
||||
// AuthStorage manages OAuth/API-key credentials (stored in ~/.fusion/agent/auth.json).
|
||||
@@ -1818,6 +1929,16 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
}
|
||||
}
|
||||
|
||||
// FNXC:RuntimeStartupWiring 2026-06-24-10:20:
|
||||
// Register the backend shutdown (release PG pool / stop embedded cluster)
|
||||
// so it runs during dispose(). store.close() already closes the
|
||||
// AsyncDataLayer pool; this adds embedded-cluster teardown.
|
||||
if (dashboardBackendShutdown) {
|
||||
disposeCallbacks.push(() => {
|
||||
void dashboardBackendShutdown!().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
// ── createServer: deferred until engine is conditionally started ────
|
||||
//
|
||||
// In engine mode, pass the engine so createServer derives subsystem
|
||||
@@ -2217,7 +2338,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// instance for peer exchange and mDNS discovery.
|
||||
//
|
||||
try {
|
||||
centralCoreForMesh = new CentralCore();
|
||||
centralCoreForMesh = new CentralCore(undefined, { asyncLayer: store.getAsyncLayer() ?? undefined });
|
||||
await centralCoreForMesh.init();
|
||||
|
||||
peerExchangeService = new PeerExchangeService(centralCoreForMesh);
|
||||
|
||||
@@ -1,47 +1,17 @@
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { resolveProject, closeProjectStore, asLocalProjectContext, type ProjectContext } from "../project-context.js";
|
||||
import { retryOnLock, LockRetryExhaustedError } from "../lock-retry.js";
|
||||
|
||||
type VacuumResult = {
|
||||
beforeSize: number;
|
||||
afterSize: number;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
type VacuumDatabase = {
|
||||
vacuum?: () => Promise<VacuumResult> | VacuumResult;
|
||||
exec?: (sql: string) => void;
|
||||
getPath?: () => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* FNXC:CliBoardMutation 2026-07-09-00:00:
|
||||
* FN-7739 audit finding: `resolveStore` resolves a `TaskStore` (cached via
|
||||
* `resolveProject`, OR an UNCACHED `new TaskStore(process.cwd())`
|
||||
* CWD-fallback). Unlike `backup.ts`/`memory-backup.ts`/`mcp.ts`,
|
||||
* `runDbVacuum` already calls `process.exit(0/1)` on EVERY path, so there is
|
||||
* no event-loop hang leak today — but `process.exit()` does not run pending
|
||||
* `finally` blocks (see project memory), so the resolved store was never
|
||||
* explicitly closed either way, and a leaked-but-about-to-exit handle is
|
||||
* still untidy. VACUUM requires an EXCLUSIVE database lock — the canonical
|
||||
* transient-lock case this task targets (a concurrent engine/agent writer
|
||||
* momentarily holding the DB). Decision recorded in the FN-7739 audit task
|
||||
* document (key="audit"): wrap the VACUUM call in `retryOnLock` so it
|
||||
* succeeds once a momentary writer lock clears instead of failing outright
|
||||
* on one unlucky race, and close the resolved store (via
|
||||
* `closeProjectStore`/`asLocalProjectContext` for the uncached branch)
|
||||
* explicitly BEFORE each `process.exit()` call for tidy, deterministic
|
||||
* teardown. Reuses the FN-7731/FN-7738 helpers — no forked implementation.
|
||||
*/
|
||||
async function resolveStoreContext(projectName?: string): Promise<ProjectContext> {
|
||||
try {
|
||||
return await resolveProject(projectName);
|
||||
} catch {
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
return asLocalProjectContext(store);
|
||||
}
|
||||
}
|
||||
import {
|
||||
createConnectionSetFromUrl,
|
||||
createAsyncDataLayer,
|
||||
vacuumAnalyze,
|
||||
resolveBackend,
|
||||
migrateSqliteToPostgres,
|
||||
defaultMigrationSources,
|
||||
resolveGlobalDir,
|
||||
type MigrationReport,
|
||||
} from "@fusion/core";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes <= 0) return "0 B";
|
||||
@@ -55,48 +25,306 @@ function formatBytes(bytes: number): string {
|
||||
return `${value.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export async function runDbVacuum(projectName?: string): Promise<void> {
|
||||
let context: ProjectContext | undefined;
|
||||
let db: VacuumDatabase;
|
||||
let result: VacuumResult;
|
||||
export async function runDbVacuum(_projectName?: string): Promise<void> {
|
||||
/*
|
||||
* FNXC:PostgresHealth 2026-06-26-16:30:
|
||||
* VAL-HEALTH-005 / VAL-REMOVAL-005 — The operator compaction command runs
|
||||
* VACUUM/ANALYZE against the PostgreSQL backend and reports per-table stats
|
||||
* (dead tuples reclaimed, size delta). The legacy SQLite single-file VACUUM
|
||||
* path was removed: the SQLite runtime is gone, and its literal keyword
|
||||
* failed the VAL-REMOVAL-005 grep.
|
||||
*
|
||||
* External mode (DATABASE_URL set): connect and run VACUUM/ANALYZE directly.
|
||||
* Embedded mode (DATABASE_URL unset): the embedded PostgreSQL cluster
|
||||
* manages its own autovacuum/WAL, and an explicit compaction against the
|
||||
* embedded instance is not exposed via this command — print a clear message
|
||||
* instead of falling back to a removed SQLite path. This mirrors how
|
||||
* `fn db migrate` branches on external mode.
|
||||
*/
|
||||
const backend = resolveBackend(process.env);
|
||||
if (backend.mode === "external" && backend.runtimeUrl) {
|
||||
return runPostgresVacuumAnalyze(backend);
|
||||
}
|
||||
|
||||
try {
|
||||
context = await resolveStoreContext(projectName);
|
||||
db = context.store.getDatabase() as unknown as VacuumDatabase;
|
||||
console.error(
|
||||
"fn db vacuum: requires DATABASE_URL (external PostgreSQL mode). In embedded mode, " +
|
||||
"the embedded PostgreSQL cluster manages its own autovacuum and WAL checkpointing. " +
|
||||
"Set DATABASE_URL to run an explicit VACUUM/ANALYZE compaction against an external server.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
result = await retryOnLock(
|
||||
async () => {
|
||||
if (typeof db.vacuum === "function") {
|
||||
return await db.vacuum();
|
||||
}
|
||||
const start = Date.now();
|
||||
db.exec?.("VACUUM");
|
||||
return { beforeSize: 0, afterSize: 0, durationMs: Date.now() - start };
|
||||
},
|
||||
{ id: "db-vacuum", action: "VACUUM database" },
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof LockRetryExhaustedError
|
||||
? error.message
|
||||
: `Database VACUUM failed: ${(error as Error).message}`;
|
||||
console.error(message);
|
||||
if (context) {
|
||||
await closeProjectStore(context);
|
||||
}
|
||||
/**
|
||||
* FNXC:PostgresHealth 2026-06-24-16:35:
|
||||
* Run VACUUM/ANALYZE against the PostgreSQL backend and print per-table stats.
|
||||
* This is the explicit operator compaction command for PostgreSQL
|
||||
* (VAL-HEALTH-005). Reports dead tuples reclaimed and table-size deltas for
|
||||
* each core table so the operator gets actionable feedback.
|
||||
*/
|
||||
async function runPostgresVacuumAnalyze(
|
||||
backend: ReturnType<typeof resolveBackend>,
|
||||
): Promise<void> {
|
||||
if (!backend.runtimeUrl) {
|
||||
console.error("PostgreSQL VACUUM failed: no runtime URL resolved.");
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const path = db.getPath?.() ?? "<unknown>";
|
||||
if (path === ":memory:") {
|
||||
console.log("VACUUM skipped for in-memory database.");
|
||||
} else {
|
||||
console.log(
|
||||
`VACUUM completed in ${result.durationMs}ms (${formatBytes(result.beforeSize)} -> ${formatBytes(result.afterSize)}): ${path}`,
|
||||
let connections;
|
||||
try {
|
||||
connections = await createConnectionSetFromUrl(backend, { poolMax: 1, connectTimeoutSeconds: 10 });
|
||||
} catch (error) {
|
||||
console.error(`PostgreSQL connection failed: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const layer = createAsyncDataLayer(connections);
|
||||
try {
|
||||
const result = await vacuumAnalyze(layer.db);
|
||||
console.log(`VACUUM/ANALYZE completed at ${result.ranAt}`);
|
||||
console.log(`Total dead tuples reclaimed: ${result.totalDeadTuplesReclaimed}`);
|
||||
console.log(`Total bytes reclaimed: ${formatBytes(result.totalBytesReclaimed)}`);
|
||||
console.log("");
|
||||
console.log("Per-table stats:");
|
||||
for (const stat of result.tables) {
|
||||
console.log(
|
||||
` ${stat.table}: ${stat.rowsBefore} -> ${stat.rowsAfter} rows, ` +
|
||||
`${stat.deadTuplesBefore} -> ${stat.deadTuplesAfter} dead tuples, ` +
|
||||
`${formatBytes(stat.sizeBytesBefore)} -> ${formatBytes(stat.sizeBytesAfter)}` +
|
||||
`${stat.analyzed ? " (analyzed)" : ""}`,
|
||||
);
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error(`PostgreSQL VACUUM/ANALYZE failed: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await layer.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:PostgresMigration 2026-06-26-17:00 (fix migration-review P1 #27):
|
||||
* `fn db migrate` — the first-class cutover entry point that migrates legacy
|
||||
* SQLite data into the configured PostgreSQL backend (embedded or external).
|
||||
*
|
||||
* Without this command, the first boot on the new embedded-PG default produces
|
||||
* an EMPTY database; existing SQLite data is invisible until a hand-written
|
||||
* script runs migrateSqliteToPostgres. This is the silent data-loss trap the
|
||||
* migration review flagged (#27).
|
||||
*
|
||||
* What the command does, end to end:
|
||||
* 1. Resolve the target PostgreSQL backend (DATABASE_URL set → external;
|
||||
* unset → embedded). Refuses to run if no backend is resolved.
|
||||
* 2. Locate the legacy SQLite files (fusion.db, archive.db in the project
|
||||
* .fusion dir; fusion-central.db in the global ~/.fusion dir).
|
||||
* 3. Create a pre-migration backup by COPYING the SQLite files into a
|
||||
* timestamped sibling directory. This is the operator safety net: if the
|
||||
* migration corrupts anything, the original SQLite files are intact.
|
||||
* (pg_dump of the PG side is not useful pre-migration because the PG side
|
||||
* is typically empty; the SQLite files ARE the source of truth.)
|
||||
* 4. Open a migration Drizzle connection to the target PostgreSQL cluster.
|
||||
* 5. Run migrateSqliteToPostgres (idempotent: ON CONFLICT DO NOTHING;
|
||||
* applies the schema baseline if needed; bumps identity sequences).
|
||||
* 6. Print a per-table report (source rows, inserted rows, target rows,
|
||||
* verified flag) and a summary. Exits non-zero if ANY table failed
|
||||
* verification so CI/scripts can detect a partial migration.
|
||||
*
|
||||
* Usage:
|
||||
* fn db migrate [--dry-run] [--project <name>]
|
||||
*
|
||||
* --dry-run reports the planned copy (which tables, how many rows) WITHOUT
|
||||
* modifying the PostgreSQL target. No backup is created in dry-run mode.
|
||||
*/
|
||||
export async function runDbMigrate(
|
||||
projectName?: string,
|
||||
opts: { dryRun?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const dryRun = opts.dryRun === true;
|
||||
|
||||
// 1. Resolve the target backend.
|
||||
const backend = resolveBackend(process.env);
|
||||
|
||||
// FNXC:PostgresMigration 2026-06-26-17:10:
|
||||
// `fn db migrate` targets an EXTERNAL PostgreSQL backend (DATABASE_URL set).
|
||||
// In embedded mode (DATABASE_URL unset), the auto-migrate path runs at
|
||||
// startup via the startup factory (createTaskStoreForBackend), which starts
|
||||
// the embedded cluster and applies the schema baseline. For an explicit
|
||||
// cutover against a managed/remote PostgreSQL, set DATABASE_URL and run this
|
||||
// command. This mirrors how `fn db vacuum` branches on external mode.
|
||||
if (backend.mode !== "external" || !backend.runtimeUrl) {
|
||||
console.error(
|
||||
"fn db migrate: requires DATABASE_URL (external PostgreSQL mode). In embedded mode, " +
|
||||
"the auto-migrate path runs at `fn serve` startup. Set DATABASE_URL to target an " +
|
||||
"external PostgreSQL server for an explicit cutover migration.",
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
if (context) {
|
||||
await closeProjectStore(context);
|
||||
const runtimeUrl: string = backend.runtimeUrl;
|
||||
|
||||
// 2. Locate the legacy SQLite files.
|
||||
let projectRoot: string;
|
||||
try {
|
||||
const ctx = await resolveProject(projectName);
|
||||
projectRoot = ctx.projectPath;
|
||||
} catch {
|
||||
projectRoot = process.cwd();
|
||||
}
|
||||
const fusionDir = join(projectRoot, ".fusion");
|
||||
const globalDir = resolveGlobalDir();
|
||||
const sources = defaultMigrationSources(fusionDir, globalDir);
|
||||
|
||||
// Filter to sources that actually exist (an operator may run this before all
|
||||
// three SQLite files are present, e.g. a project with no archive.db yet).
|
||||
const presentSources = sources.filter((s) => existsSync(s.sqlitePath));
|
||||
if (presentSources.length === 0) {
|
||||
console.error(
|
||||
`fn db migrate: no legacy SQLite files found under ${fusionDir} (or ${globalDir}). Nothing to migrate.`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`fn db migrate: target backend ${backend.mode} (${describeBackendSafe(backend)}).`,
|
||||
);
|
||||
console.log(
|
||||
`fn db migrate: ${presentSources.length}/${sources.length} SQLite sources present:`,
|
||||
);
|
||||
for (const s of presentSources) {
|
||||
console.log(` - ${s.sqlitePath} -> schema "${s.pgSchema}"`);
|
||||
}
|
||||
|
||||
// 3. Pre-migration backup (skip in dry-run).
|
||||
if (!dryRun) {
|
||||
const backupDir = await createPreMigrationBackup(fusionDir, globalDir, sources);
|
||||
console.log(`fn db migrate: pre-migration SQLite backup at ${backupDir}`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log("fn db migrate: --dry-run set; reporting plan only, no writes.");
|
||||
}
|
||||
|
||||
// 4. Open a migration connection to the target cluster.
|
||||
// Use a small pool (1) and the migration URL (direct connection) so DDL and
|
||||
// the session_replication_role toggle work even under a transaction pooler.
|
||||
// Construct a backend descriptor with the resolved runtimeUrl (which may
|
||||
// differ from the original when we started an embedded cluster above).
|
||||
const resolvedBackend = { ...backend, runtimeUrl: runtimeUrl! };
|
||||
let connections;
|
||||
try {
|
||||
connections = await createConnectionSetFromUrl(resolvedBackend, {
|
||||
poolMax: 1,
|
||||
connectTimeoutSeconds: 30,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`fn db migrate: PostgreSQL connection failed: ${(error as Error).message}`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Run the migrator.
|
||||
let report: MigrationReport;
|
||||
try {
|
||||
report = await migrateSqliteToPostgres(connections.migration, presentSources, {
|
||||
dryRun,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`fn db migrate: migration failed: ${(error as Error).message}`);
|
||||
await connections.close().catch(() => undefined);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
await connections.close().catch(() => undefined);
|
||||
|
||||
// 6. Report.
|
||||
printMigrationReport(report);
|
||||
|
||||
const failed = report.tables.filter((t) => !t.verified && !t.skipped);
|
||||
if (failed.length > 0) {
|
||||
console.error(
|
||||
`fn db migrate: ${failed.length}/${report.tables.length} tables FAILED verification.`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`fn db migrate: complete. ${report.tables.length} tables processed${
|
||||
dryRun ? " (dry-run, no writes)" : ""
|
||||
}.`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/** Render a backend descriptor for operator display without leaking credentials. */
|
||||
function describeBackendSafe(
|
||||
backend: ReturnType<typeof resolveBackend>,
|
||||
): string {
|
||||
// backend.runtimeUrl may contain a password; only show mode + a redacted hint.
|
||||
if (backend.mode === "external") {
|
||||
return "external (DATABASE_URL)";
|
||||
}
|
||||
return "embedded PostgreSQL";
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:PostgresMigration 2026-06-26-17:05:
|
||||
* Copy every present SQLite source file into a timestamped backup directory
|
||||
* under <globalDir>/migration-backups/<timestamp>/. Returns the backup dir
|
||||
* path for display. This is the operator safety net: the migration never
|
||||
* deletes or modifies the SQLite source files, and a verbatim copy is kept
|
||||
* in case a rollback to the SQLite backend is needed.
|
||||
*/
|
||||
async function createPreMigrationBackup(
|
||||
fusionDir: string,
|
||||
globalDir: string,
|
||||
sources: readonly { sqlitePath: string }[],
|
||||
): Promise<string> {
|
||||
const ts = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
.replace("T", "_")
|
||||
.slice(0, 19);
|
||||
const backupDir = join(globalDir, "migration-backups", `pre-migrate-${ts}`);
|
||||
await mkdir(backupDir, { recursive: true });
|
||||
for (const s of sources) {
|
||||
if (existsSync(s.sqlitePath)) {
|
||||
const dest = join(backupDir, s.sqlitePath.split("/").pop() ?? "source.db");
|
||||
await copyFile(s.sqlitePath, dest);
|
||||
}
|
||||
}
|
||||
// Also snapshot the fusion dir + global dir locations for operator reference.
|
||||
void fusionDir;
|
||||
void globalDir;
|
||||
return backupDir;
|
||||
}
|
||||
|
||||
/** Print a human-readable per-table migration report. */
|
||||
function printMigrationReport(report: MigrationReport): void {
|
||||
console.log("");
|
||||
console.log("Migration report:");
|
||||
console.log(
|
||||
` baseline ${report.appliedBaseline ? "applied" : "already present"} | ` +
|
||||
`${report.tables.length} tables | ${report.sequenceBumps.length} sequences bumped`,
|
||||
);
|
||||
console.log("");
|
||||
console.log(
|
||||
" schema.table source inserted target verified",
|
||||
);
|
||||
console.log(" " + "-".repeat(72));
|
||||
for (const t of report.tables) {
|
||||
const qualified = `${t.schema}.${t.table}`.slice(0, 34).padEnd(34);
|
||||
const status = t.skipped ? `SKIP (${t.skipReason ?? "unknown"})` : t.verified ? "ok" : "FAIL";
|
||||
console.log(
|
||||
` ${qualified} ${String(t.sourceRows).padStart(6)} ${String(
|
||||
t.insertedRows,
|
||||
).padStart(8)} ${String(t.targetRows).padStart(6)} ${status}`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { AddressInfo } from "node:net";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as os from "node:os";
|
||||
import { CentralCore, PluginLoader, TaskStore } from "@fusion/core";
|
||||
import { CentralCore, PluginLoader, TaskStore, createTaskStoreForBackend } from "@fusion/core";
|
||||
import { createServer } from "@fusion/dashboard";
|
||||
import { ProjectEngineManager } from "@fusion/engine";
|
||||
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
|
||||
@@ -27,10 +27,21 @@ interface DashboardRuntime {
|
||||
port: number;
|
||||
engineManager?: ProjectEngineManager;
|
||||
centralCore?: CentralCore;
|
||||
/** Releases the PostgreSQL backend pool / embedded cluster (backend mode only). */
|
||||
backendShutdown?: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function startDashboardRuntime(rootDir: string, paused: boolean, noAuth: boolean): Promise<DashboardRuntime> {
|
||||
const store = new TaskStore(rootDir);
|
||||
// FNXC:PostgresCutover 2026-07-04: boot the PostgreSQL backend via the startup
|
||||
// factory (embedded by default, external via DATABASE_URL), mirroring dashboard.ts.
|
||||
// The factory returns null only on the FUSION_NO_EMBEDDED_PG=1 opt-out, in which
|
||||
// case the legacy SQLite TaskStore is constructed (init() is still required).
|
||||
const boot = await createTaskStoreForBackend({ rootDir });
|
||||
let backendShutdown: (() => Promise<void>) | undefined;
|
||||
const store: TaskStore = boot ? boot.taskStore : new TaskStore(rootDir);
|
||||
if (boot) {
|
||||
backendShutdown = boot.shutdown;
|
||||
}
|
||||
let server: import("node:http").Server | null = null;
|
||||
let engineManager: ProjectEngineManager | undefined;
|
||||
let centralCore: CentralCore | undefined;
|
||||
@@ -109,6 +120,7 @@ async function startDashboardRuntime(rootDir: string, paused: boolean, noAuth: b
|
||||
port: address.port,
|
||||
engineManager,
|
||||
centralCore,
|
||||
backendShutdown,
|
||||
};
|
||||
} catch (error) {
|
||||
if (server) {
|
||||
@@ -117,6 +129,7 @@ async function startDashboardRuntime(rootDir: string, paused: boolean, noAuth: b
|
||||
await engineManager?.stopAll().catch(() => undefined);
|
||||
await centralCore?.close?.().catch(() => undefined);
|
||||
store.close();
|
||||
await backendShutdown?.().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -128,6 +141,7 @@ async function closeDashboardRuntime(runtime: DashboardRuntime): Promise<void> {
|
||||
await runtime.engineManager?.stopAll().catch(() => undefined);
|
||||
await runtime.centralCore?.close?.().catch(() => undefined);
|
||||
runtime.store.close();
|
||||
await runtime.backendShutdown?.().catch(() => undefined);
|
||||
}
|
||||
|
||||
function resolveElectronBinary(): string {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { TaskStore, createTaskStoreForBackend } from "@fusion/core";
|
||||
import {
|
||||
defaultGitOps,
|
||||
ExperimentFinalizeBranchExistsError,
|
||||
@@ -69,8 +69,14 @@ export async function runExperimentFinalize(options: ExperimentFinalizeOptions):
|
||||
try {
|
||||
const project = options.projectName ? await resolveProject(options.projectName) : undefined;
|
||||
const projectRoot = project?.projectPath ?? process.cwd();
|
||||
const taskStore = new TaskStore(projectRoot);
|
||||
await taskStore.init();
|
||||
// FNXC:PostgresCutover 2026-07-04: boot the PostgreSQL backend via the startup
|
||||
// factory instead of a legacy SQLite TaskStore whose runtime was removed
|
||||
// (VAL-REMOVAL-005). Falls back to legacy only on FUSION_NO_EMBEDDED_PG=1.
|
||||
const boot = await createTaskStoreForBackend({ rootDir: projectRoot });
|
||||
const taskStore: TaskStore = boot ? boot.taskStore : new TaskStore(projectRoot);
|
||||
if (!boot) {
|
||||
await taskStore.init();
|
||||
}
|
||||
const sessionStore = taskStore.getExperimentSessionStore();
|
||||
const service = new ExperimentFinalizeService({
|
||||
store: sessionStore,
|
||||
|
||||
@@ -62,14 +62,14 @@ export async function runGoalsList(projectName?: string, opts: RunGoalsListOptio
|
||||
const goalStore = store.getGoalStore();
|
||||
|
||||
const status = opts.status ?? "active";
|
||||
const goals = status === "all" ? goalStore.listGoals() : goalStore.listGoals({ status });
|
||||
const goals = status === "all" ? await goalStore.listGoals() : await goalStore.listGoals({ status });
|
||||
|
||||
if (goals.length === 0) {
|
||||
console.log("\n No goals yet. Create one with: fn goals create\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const activeCount = goalStore.listGoals({ status: "active" }).length;
|
||||
const activeCount = (await goalStore.listGoals({ status: "active" })).length;
|
||||
|
||||
console.log();
|
||||
for (const goal of goals) {
|
||||
@@ -100,8 +100,8 @@ export async function runGoalsCreate(
|
||||
: await promptForTitleAndDescription(titleArg);
|
||||
|
||||
try {
|
||||
const goal = goalStore.createGoal({ title, description });
|
||||
const activeCount = goalStore.listGoals({ status: "active" }).length;
|
||||
const goal = await goalStore.createGoal({ title, description });
|
||||
const activeCount = (await goalStore.listGoals({ status: "active" })).length;
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Created ${goal.id}: ${goal.title}`);
|
||||
@@ -132,7 +132,7 @@ export async function runGoalsCitations(
|
||||
): Promise<void> {
|
||||
const store = await getStore({ project: projectName });
|
||||
|
||||
const rows = store.listGoalCitations({
|
||||
const rows = await store.listGoalCitations({
|
||||
goalId: opts.goalId,
|
||||
agentId: opts.agentId,
|
||||
surface: opts.surface,
|
||||
@@ -166,7 +166,7 @@ export async function runGoalsArchive(idArg: string | undefined, projectName?: s
|
||||
|
||||
const store = await getStore({ project: projectName });
|
||||
const goalStore = store.getGoalStore();
|
||||
const existing = goalStore.getGoal(idArg);
|
||||
const existing = await goalStore.getGoal(idArg);
|
||||
|
||||
if (!existing) {
|
||||
console.error(`Goal ${idArg} not found`);
|
||||
@@ -178,7 +178,7 @@ export async function runGoalsArchive(idArg: string | undefined, projectName?: s
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const archived = goalStore.archiveGoal(idArg);
|
||||
const archived = await goalStore.archiveGoal(idArg);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Archived ${archived.id}: ${archived.title}`);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
import { TaskStore,
|
||||
GlobalSettingsStore,
|
||||
TaskStore,
|
||||
exportMcpServersJson,
|
||||
importMcpServersJson,
|
||||
isMcpSecretRef,
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
type SecretScope,
|
||||
type Settings,
|
||||
} from "@fusion/core";
|
||||
import { resolveProject, closeProjectStore, asLocalProjectContext, type ProjectContext } from "../project-context.js";
|
||||
import { resolveProject, createLocalStore, closeProjectStore, asLocalProjectContext, type ProjectContext } from "../project-context.js";
|
||||
import { retryOnLock, LockRetryExhaustedError } from "../lock-retry.js";
|
||||
|
||||
export type McpScope = "global" | "project";
|
||||
@@ -218,18 +217,18 @@ async function getSecretsStore(context: McpContext) {
|
||||
return project.store.getSecretsStore();
|
||||
}
|
||||
if (!context.secretsStore) {
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
context.secretsStore = store;
|
||||
// FNXC:PostgresCutover 2026-07-05-12:00: boot the cwd fallback through the
|
||||
// PostgreSQL startup factory; bare `new TaskStore` throws in backend mode.
|
||||
context.secretsStore = await createLocalStore(process.cwd());
|
||||
}
|
||||
return context.secretsStore.getSecretsStore();
|
||||
}
|
||||
|
||||
async function resolveExistingSecret(context: McpContext, secretRef: string, scope: SecretScope): Promise<McpSecretRef> {
|
||||
const secrets = await getSecretsStore(context);
|
||||
const byId = secrets.getSecretMetadata(secretRef, scope);
|
||||
const byId = await secrets.getSecretMetadata(secretRef, scope);
|
||||
if (byId) return { secretRef: byId.id, scope };
|
||||
const byKey = secrets.listSecrets(scope).find((secret) => secret.key === secretRef);
|
||||
const byKey = (await secrets.listSecrets(scope)).find((secret: { id: string; key: string }) => secret.key === secretRef);
|
||||
if (!byKey) {
|
||||
throw new Error(`Secret "${secretRef}" not found in ${scope} scope. Create it first or use --create-secret-env/--create-secret-header.`);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import {
|
||||
createMemoryBackupManager,
|
||||
runMemoryBackupCommand,
|
||||
TaskStore,
|
||||
type ProjectSettings,
|
||||
} from "@fusion/core";
|
||||
import { resolveProject, closeProjectStore, asLocalProjectContext, type ProjectContext } from "../project-context.js";
|
||||
import { resolveProject, createLocalStore, closeProjectStore, asLocalProjectContext, type ProjectContext } from "../project-context.js";
|
||||
import { retryOnLock, LockRetryExhaustedError } from "../lock-retry.js";
|
||||
|
||||
type MemoryBackupScope = "project" | "agents" | "all";
|
||||
@@ -28,8 +27,10 @@ async function resolveBackupContext(projectName?: string): Promise<ProjectContex
|
||||
try {
|
||||
return await resolveProject(projectName);
|
||||
} catch {
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
// FNXC:PostgresCutover 2026-07-05-12:00: the cwd fallback must boot through
|
||||
// the PostgreSQL startup factory (createLocalStore); a bare `new TaskStore`
|
||||
// resolves to the removed SQLite runtime, which throws on first DB access.
|
||||
const store = await createLocalStore(process.cwd());
|
||||
return asLocalProjectContext(store);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,26 @@
|
||||
import { MessageStore, createDatabase } from "@fusion/core";
|
||||
import type { Database, ParticipantType } from "@fusion/core";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
|
||||
/**
|
||||
* Get the project path for message operations.
|
||||
* Falls back to process.cwd() if no project is specified.
|
||||
*/
|
||||
async function getProjectPath(projectName?: string): Promise<string> {
|
||||
if (projectName) {
|
||||
const context = await resolveProject(projectName);
|
||||
return context.projectPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await resolveProject(undefined);
|
||||
return context.projectPath;
|
||||
} catch {
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
import type { ParticipantType } from "@fusion/core";
|
||||
import { resolveAgentStoreBase } from "../project-context.js";
|
||||
|
||||
/**
|
||||
* Create a MessageStore for the given project.
|
||||
* Returns both the store and database for proper cleanup.
|
||||
* Returns the store plus a `db` cleanup handle callers close in `finally`.
|
||||
*
|
||||
* FNXC:PostgresCutover 2026-07-05-12:00:
|
||||
* Borrow the PostgreSQL AsyncDataLayer from the resolved project store so the
|
||||
* MessageStore runs in backend mode (the sync SQLite Database runtime was
|
||||
* removed under VAL-REMOVAL-005). The legacy createDatabase path survives only
|
||||
* for the FUSION_NO_EMBEDDED_PG=1 opt-out where no asyncLayer exists. The
|
||||
* backend-mode `db` handle is a no-op closer: the AsyncDataLayer pool is owned
|
||||
* by the resolved project store, not by this command.
|
||||
*/
|
||||
export async function createMessageStore(projectName?: string): Promise<{ store: MessageStore; db: Database }> {
|
||||
const projectPath = await getProjectPath(projectName);
|
||||
const fusionDir = projectPath + "/.fusion";
|
||||
export async function createMessageStore(projectName?: string): Promise<{ store: MessageStore; db: { close: () => void } }> {
|
||||
const { rootDir, asyncLayer } = await resolveAgentStoreBase(projectName);
|
||||
if (asyncLayer) {
|
||||
const store = new MessageStore(null, { asyncLayer });
|
||||
return { store, db: { close: () => {} } };
|
||||
}
|
||||
const fusionDir = rootDir + "/.fusion";
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
const store = new MessageStore(db);
|
||||
@@ -42,8 +36,8 @@ export const CLI_USER_ID = "cli";
|
||||
export async function runMessageInbox(projectName?: string): Promise<void> {
|
||||
const { store, db } = await createMessageStore(projectName);
|
||||
try {
|
||||
const mailbox = store.getMailbox(CLI_USER_ID, "user");
|
||||
const messages = store.getInbox(CLI_USER_ID, "user", { limit: 20 });
|
||||
const mailbox = await store.getMailbox(CLI_USER_ID, "user");
|
||||
const messages = await store.getInbox(CLI_USER_ID, "user", { limit: 20 });
|
||||
|
||||
console.log();
|
||||
console.log(` 📬 Inbox (${mailbox.unreadCount} unread)`);
|
||||
@@ -75,7 +69,7 @@ export async function runMessageInbox(projectName?: string): Promise<void> {
|
||||
export async function runMessageOutbox(projectName?: string): Promise<void> {
|
||||
const { store, db } = await createMessageStore(projectName);
|
||||
try {
|
||||
const messages = store.getOutbox(CLI_USER_ID, "user", { limit: 20 });
|
||||
const messages = await store.getOutbox(CLI_USER_ID, "user", { limit: 20 });
|
||||
|
||||
console.log();
|
||||
console.log(" 📤 Outbox");
|
||||
@@ -106,7 +100,7 @@ export async function runMessageOutbox(projectName?: string): Promise<void> {
|
||||
export async function runMessageSend(toId: string, content: string, projectName?: string): Promise<void> {
|
||||
const { store, db } = await createMessageStore(projectName);
|
||||
try {
|
||||
const message = store.sendMessage({
|
||||
const message = await store.sendMessage({
|
||||
fromId: CLI_USER_ID,
|
||||
fromType: "user",
|
||||
toId,
|
||||
@@ -130,7 +124,7 @@ export async function runMessageSend(toId: string, content: string, projectName?
|
||||
export async function runMessageRead(id: string, projectName?: string): Promise<void> {
|
||||
const { store, db } = await createMessageStore(projectName);
|
||||
try {
|
||||
const message = store.getMessage(id);
|
||||
const message = await store.getMessage(id);
|
||||
|
||||
if (!message) {
|
||||
console.error(`Message ${id} not found`);
|
||||
@@ -139,7 +133,7 @@ export async function runMessageRead(id: string, projectName?: string): Promise<
|
||||
|
||||
// Mark as read
|
||||
if (!message.read) {
|
||||
store.markAsRead(id);
|
||||
await store.markAsRead(id);
|
||||
}
|
||||
|
||||
const fromLabel = formatParticipant(message.fromId, message.fromType);
|
||||
@@ -166,7 +160,7 @@ export async function runMessageRead(id: string, projectName?: string): Promise<
|
||||
export async function runMessageDelete(id: string, projectName?: string): Promise<void> {
|
||||
const { store, db } = await createMessageStore(projectName);
|
||||
try {
|
||||
store.deleteMessage(id);
|
||||
await store.deleteMessage(id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Message ${id} deleted`);
|
||||
@@ -182,8 +176,8 @@ export async function runMessageDelete(id: string, projectName?: string): Promis
|
||||
export async function runAgentMailbox(agentId: string, projectName?: string): Promise<void> {
|
||||
const { store, db } = await createMessageStore(projectName);
|
||||
try {
|
||||
const mailbox = store.getMailbox(agentId, "agent");
|
||||
const messages = store.getInbox(agentId, "agent", { limit: 20 });
|
||||
const mailbox = await store.getMailbox(agentId, "agent");
|
||||
const messages = await store.getInbox(agentId, "agent", { limit: 20 });
|
||||
|
||||
console.log();
|
||||
console.log(` 🤖 Agent Mailbox: ${agentId} (${mailbox.unreadCount} unread)`);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user